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 AbstractCacheAND everyAbstractCacheShape<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
FakeRedisimplementing every required method AND passes it to aRedisCache - THEN the cache round-trips
set/get/delete/has/getMultiple/setMultiple/wrapcorrectly
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
FakeRedisspy records aset("ns:k", encoded, { EX: 30 })invocation
Scenario: ttl === 0 invokes SET with no EX
- WHEN a caller calls
cache.set("k", "v")ANDdefaultTtl === 0 - THEN the
FakeRedisspy records aset("ns:k", encoded)invocation with noEXoption
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
RedisCacheErrorANDerror.code === "CACHE_REDIS.INVALID_TTL"ANDerror.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 aRedisCacheconstructed withoutnamespace - THEN the call throws
RedisCacheErrorwithcode === "CACHE_REDIS.COMMAND_FAILED"
Scenario: clear with namespace deletes only namespaced keys
- WHEN a
FakeRediscontains keysns:a,ns:b,other:cAND a namespaced cache callsclear() - THEN keys
ns:aandns:bare deleted ANDother:cremains ANDgetStats().evictionsis incremented by2
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
FakeRedissetvalue is a parseable JSON string"{\"hello\":\"world\"}"
Scenario: IdentityCodec emits base64 string into redis.set
- WHEN a
RedisCache<Uint8Array>constructed withcodec: new IdentityCodec()callsset("k", new Uint8Array([1,2,3])) - THEN the
FakeRedissetvalue is the base64 string"AQID"ANDget("k")returns the originalUint8Array
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-redisAND callshasCacheProvider("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 RedisCacheANDinstanceof 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"