Files
2026-05-27 23:29:53 +01:00

9.4 KiB

Capability Spec Delta: cache-redis (cache-redis)

Package: @suaveplan/cache-redis Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED Requirements

Requirement: RedisCache extends AbstractCache

The package SHALL provide RedisCache<T> extends AbstractCache<T> (from @suaveplan/cache). The class SHALL implement every method on AbstractCacheShape<T>: get, set, delete, clear, has, getStats, getMultiple, setMultiple, wrap. The constructor SHALL accept { redis: RedisLike; codec?: Codec<T>; defaultTtl?: number; events?: EventBusLike; namespace?: string } with codec defaulting to JsonCodec<T> (from @suaveplan/cache-storage) and defaultTtl defaulting to 0.

Scenario: instance is an AbstractCache and an AbstractCacheShape

  • WHEN a caller constructs new RedisCache({ redis: fakeRedis })
  • THEN the instance is instanceof AbstractCache AND every AbstractCacheShape<T> method exists on it

Verified by: src/redis-cache/redis-cache.test.ts::"instance is an AbstractCache and exposes the AbstractCacheShape surface"


Requirement: RedisLike interface

The package SHALL declare a RedisLike interface with set(key, value, opts?: { EX?: number; PX?: number }): Promise<unknown>, get(key): Promise<string | null>, del(...keys): Promise<number>, exists(...keys): Promise<number>, ttl(key): Promise<number>, expire(key, seconds): Promise<number>, scan(cursor, opts?: { MATCH?: string; COUNT?: number }): Promise<RedisScanResult>, and optional mget(...keys): Promise<(string | null)[]>. RedisScanResult SHALL have { cursor: string; keys: string[] }. Any client that conforms to the interface SHALL be a valid argument to RedisCache.

Scenario: a fake RedisLike stub satisfies the interface

  • WHEN the test suite constructs an in-memory FakeRedis implementing every required method AND passes it to a RedisCache
  • THEN the cache round-trips set/get/delete/has/getMultiple/setMultiple/wrap correctly

Verified by: src/redis-like/redis-like.test.ts::"FakeRedis stub satisfies the RedisLike interface" AND src/redis-cache/redis-cache.test.ts::"round-trips set then get against a FakeRedis stub"


Requirement: Native TTL via SET ... EX

RedisCache.set(key, value, opts) SHALL invoke redis.set(prefixed, encoded, { EX: Math.ceil(ttl / 1000) }) when ttl > 0. When ttl === 0 or ttl === undefined and defaultTtl === 0, the call SHALL use a plain redis.set(prefixed, encoded). When the computed EX would be 0, the package SHALL upgrade to EX: 1 to avoid Redis treating it as "no expiry" via its rounding behaviour, NOT silently drop the TTL.

Scenario: positive ttl invokes SET with EX seconds

  • WHEN a caller calls cache.set("k", "v", { ttl: 30_000 })
  • THEN the FakeRedis spy records a set("ns:k", encoded, { EX: 30 }) invocation

Scenario: ttl === 0 invokes SET with no EX

  • WHEN a caller calls cache.set("k", "v") AND defaultTtl === 0
  • THEN the FakeRedis spy records a set("ns:k", encoded) invocation with no EX option

Verified by: src/redis-cache/redis-cache.test.ts::"set with positive ttl issues SET with EX seconds" AND src/redis-cache/redis-cache.test.ts::"set with zero default ttl issues SET without EX"


Requirement: INVALID_TTL on negative ttl

RedisCache.set SHALL reject ttl < 0 with RedisCacheError whose code === "CACHE_REDIS.INVALID_TTL". The error's key discriminant SHALL be the unprefixed key.

Scenario: negative ttl is rejected with INVALID_TTL

  • WHEN a caller calls cache.set("k", "v", { ttl: -1 })
  • THEN the call rejects with RedisCacheError AND error.code === "CACHE_REDIS.INVALID_TTL" AND error.key === "k"

Verified by: src/redis-cache/redis-cache.test.ts::"rejects negative ttl with INVALID_TTL"


Requirement: clear() refuses when no namespace is configured

RedisCache.clear() SHALL throw RedisCacheError with code === "CACHE_REDIS.COMMAND_FAILED" when no namespace was provided. When namespace is provided, clear() SHALL iterate SCAN 0 MATCH <namespace>:* COUNT 1000 until the cursor returns "0", deleting each batch of matching keys with DEL. The total deleted count SHALL be reflected in getStats().evictions.

Scenario: clear without namespace throws COMMAND_FAILED

  • WHEN a caller calls cache.clear() on a RedisCache constructed without namespace
  • THEN the call throws RedisCacheError with code === "CACHE_REDIS.COMMAND_FAILED"

Scenario: clear with namespace deletes only namespaced keys

  • WHEN a FakeRedis contains keys ns:a, ns:b, other:c AND a namespaced cache calls clear()
  • THEN keys ns:a and ns:b are deleted AND other:c remains AND getStats().evictions is incremented by 2

Verified by: src/redis-cache/redis-cache.test.ts::"clear() without namespace throws COMMAND_FAILED" AND src/redis-cache/redis-cache.test.ts::"clear() with namespace deletes only namespaced keys"


Requirement: CACHE_REDIS error registry

The package SHALL declare CACHE_REDIS_CODES via defineCodes("CACHE_REDIS", [...]) with exactly these 5 codes: CONNECTION_FAILED, COMMAND_FAILED, CODEC_ERROR, KEY_TOO_LONG, INVALID_TTL. The package SHALL declare RedisCacheError extends SuaveplanError<CacheRedisCode> with optional key and command discriminants. Every code SHALL be reachable from at least one test in this package.

Scenario: every declared CACHE_REDIS_* code is reachable

  • WHEN the test suite runs
  • THEN for every entry in CACHE_REDIS_CODES, at least one assertion exercises a real call site that produces that code

Verified by: src/errors/errors.test.ts::"every CACHE_REDIS code is reachable from a real call site"


Requirement: Codec layer is shared with @suaveplan/cache-storage

RedisCache SHALL import Codec<T>, JsonCodec, and IdentityCodec from @suaveplan/cache-storage. The encoded Uint8Array SHALL be converted to a UTF-8 string before passing to redis.set when the codec emits UTF-8-safe bytes (JsonCodec); otherwise base64 SHALL be used (IdentityCodec). On get, the inverse conversion SHALL be applied before invoking codec.decode.

Scenario: JsonCodec emits UTF-8 string into redis.set

  • WHEN a caller calls cache.set("k", { hello: "world" })
  • THEN the FakeRedis set value is a parseable JSON string "{\"hello\":\"world\"}"

Scenario: IdentityCodec emits base64 string into redis.set

  • WHEN a RedisCache<Uint8Array> constructed with codec: new IdentityCodec() calls set("k", new Uint8Array([1,2,3]))
  • THEN the FakeRedis set value is the base64 string "AQID" AND get("k") returns the original Uint8Array

Verified by: src/redis-cache/redis-cache.test.ts::"JsonCodec encodes value as a UTF-8 string" AND src/redis-cache/redis-cache.test.ts::"IdentityCodec encodes value as a base64 string round-trip"


Requirement: Module augmentation registers "redis" provider

The package SHALL declare module "@suaveplan/cache" and augment CacheProviderRegistry with redis: typeof RedisCache. The package SHALL side-effect-import a registry-augment module that calls registerCacheProvider("redis", RedisCache) exactly once via an idempotent guard. After importing @suaveplan/cache-redis, hasCacheProvider("redis") SHALL return true AND createCache("redis", { redis }) SHALL return a RedisCache.

Scenario: side-effect import registers the "redis" provider

  • WHEN the consumer imports @suaveplan/cache-redis AND calls hasCacheProvider("redis")
  • THEN the result is true

Scenario: createCache("redis", config) returns a RedisCache instance

  • WHEN a caller calls createCache("redis", { redis: fakeRedis })
  • THEN the result is instanceof RedisCache AND instanceof AbstractCache

Verified by: src/registry-augment/registry-augment.test.ts::"hasCacheProvider returns true after import" AND src/registry-augment/registry-augment.test.ts::"createCache(\"redis\", ...) returns a RedisCache instance"


Requirement: getStats client-side counters

RedisCache.getStats() SHALL return CacheStats with hits, misses, evictions tracked client-side and hitRate = hits / (hits + misses). size SHALL be 0 (documented limitation — Redis size enumeration is O(N) and not appropriate for getStats).

Scenario: getStats reports tracked counters and size 0

  • WHEN a caller records 4 hits and 1 miss against the cache
  • THEN getStats() returns { hits: 4, misses: 1, hitRate: 0.8, evictions: 0, size: 0 }

Verified by: src/redis-cache/redis-cache.test.ts::"getStats reports client-side counters and size 0"


Requirement: Tier — server

The package SHALL declare "engines": { "node": ">=18" } in package.json and SHALL NOT include a "browser" field. The runtime source SHALL NOT import from node:* directly (the RedisLike interface is platform-neutral); however the package is published with a server-tier marker because typical clients (ioredis, node-redis) are server-only.

Scenario: tier-purity grep finds no node: imports

  • WHEN grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-redis/src/ is run
  • THEN the output is empty

Verified by: src/redis-cache/redis-cache.test.ts::"src tree contains no node: or bun: imports"