159 lines
9.4 KiB
Markdown
159 lines
9.4 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: cache-redis (cache-redis)
|
|
|
|
**Package**: `@suaveplan/cache-redis`
|
|
**Status**: SHIPPED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: RedisCache extends AbstractCache<T>
|
|
|
|
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/cache/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"`
|