# 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 extends AbstractCache` (from `@suaveplan/cache`). The class SHALL implement every method on `AbstractCacheShape`: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. The constructor SHALL accept `{ redis: RedisLike; codec?: Codec; defaultTtl?: number; events?: EventBusLike; namespace?: string }` with `codec` defaulting to `JsonCodec` (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` 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`, `get(key): Promise`, `del(...keys): Promise`, `exists(...keys): Promise`, `ttl(key): Promise`, `expire(key, seconds): Promise`, `scan(cursor, opts?: { MATCH?: string; COUNT?: number }): Promise`, 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 :* 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` 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`, `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` 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"`