# Design — @suaveplan/cache-storage + @suaveplan/cache-redis Non-normative companion to `proposal.md` and the two spec deltas. ## 1. Role in the family ```text @suaveplan/cache AbstractCache, MemoryCache, CacheFactory + registry ├─ @suaveplan/cache-storage THIS PACKAGE — bridge to AbstractStorage │ └─ provider key "storage" └─ @suaveplan/cache-redis THIS PACKAGE — native Redis backend └─ provider key "redis" ``` Both new packages extend `AbstractCache` from `@suaveplan/cache` and register themselves through the module-augmented `CacheProviderRegistry`. The cache policy layer (`wrap`/SWR/`getMultiple`/`setMultiple`/namespacing/events) is inherited, not re-implemented. ## 2. `StorageCache` design ```ts class StorageCache extends AbstractCache { constructor(opts: { storage: AbstractStorage; codec?: Codec; // default JsonCodec defaultTtl?: number; // ms; 0 = no expiry; default 0 clock?: Clock; // default () => Date.now() events?: EventBusLike; namespace?: string; // prepended to every key with ':' separator }); // From AbstractCacheShape async get(key: string): Promise; async set(key: string, value: T, options?: CacheSetOptions): Promise; async delete(key: string): Promise; async clear(): Promise; async has(key: string): Promise; getStats(): CacheStats; } ``` ### TTL header (32 bytes, big-endian) ```text bytes 0..7 createdAt u64 BE (ms since epoch) bytes 8..15 expiresAt u64 BE (ms since epoch; 0 = no expiry) bytes 16..19 flags u32 BE (bit 0: hasMetadata; bit 1: codecJson; rest reserved) bytes 20..23 hitCount u32 BE (best-effort; can saturate at 2^32-1) bytes 24..31 reserved 8 bytes (zero) ``` On `set`: encode header + encoded value, single `storage.set(prefixedKey, headerPlusBytes)`. On `get`: `storage.get(prefixedKey)` → if shorter than 32 bytes → `ENTRY_CORRUPT` → if `expiresAt !== 0 && now >= expiresAt` → lazy delete + `undefined` → else codec.decode(value slice). `hitCount` is informational only and **not** rewritten on read — that would double every `get` to a write. A future change can add `getPartial`-aware backends that read just the header byte-range and skip the body when expired, but the simple full-blob read is correct and small for cache values. ### Key prefixing `namespace` (if set) is prepended to every key with `:` separator. The key passed to `AbstractStorage` is `${namespace}:${key}` (or just `${key}` when no namespace). Empty namespace is treated as unset. ### Lifecycle event emission When `events` is provided, the cache emits `hit`/`miss`/`set`/`delete`/`clear`/`expire` to match the existing `@suaveplan/cache`'s `MemoryCache` event topics. Listener exceptions are surfaced through the bus, not propagated up to the cache caller. ### Stats In-memory counters (`hits`, `misses`, `evictions`, `size`). `size` is best-effort — `AbstractStorage` does not expose a fast count, so `getStats().size` is approximated by `hits + misses - misses` tracking inserts/deletes rather than by enumerating storage. (Practically: a `setCounter`/`deleteCounter` increment on each op.) Documented in the `.md`. ## 3. `RedisCache` design ```ts class RedisCache extends AbstractCache { constructor(opts: { redis: RedisLike; codec?: Codec; // default JsonCodec defaultTtl?: number; // ms; 0 = no expiry; default 0 clock?: Clock; events?: EventBusLike; namespace?: string; }); } interface RedisLike { set(key: string, value: string, opts?: { EX?: number; PX?: number }): Promise; get(key: string): Promise; del(...keys: string[]): Promise; exists(...keys: string[]): Promise; ttl(key: string): Promise; expire(key: string, seconds: number): Promise; mget?(...keys: string[]): Promise<(string | null)[]>; scan(cursor: string, opts?: { MATCH?: string; COUNT?: number }): Promise; } interface RedisScanResult { cursor: string; keys: string[]; } ``` ### Native TTL `set(key, value, { ttl })` with `ttl > 0` → `redis.set(prefixed, encoded, { EX: Math.ceil(ttl / 1000) })`. `ttl === 0` or `undefined` (and `defaultTtl === 0`) → plain `redis.set(prefixed, encoded)` with no expiry. `ttl < 0` rejected at the boundary with `CACHE_REDIS.INVALID_TTL`. ### `clear()` within a namespace Namespaced `clear()` issues `SCAN 0 MATCH :* COUNT 1000` and `DEL`s each batch until cursor returns `"0"`. The global cache (no namespace) refuses `clear()` to avoid accidental `FLUSHDB`-equivalent — calling `clear()` without a namespace throws `CACHE_REDIS.COMMAND_FAILED`. Documented prominently. ### `size` `getStats().size` is `0`. Counted hits/misses/evictions are tracked client-side. ### Codec sharing `RedisCache` imports `JsonCodec`, `IdentityCodec`, and the `Codec` type from `@suaveplan/cache-storage`. The encoded `Uint8Array` is converted to a base64 string for `set()` (Redis values are strings in the canonical RESP protocol; bytes round-trip safely through base64). `get()` reverses. UTF-8-only callers using `JsonCodec` benefit from no base64 overhead — the codec emits UTF-8 bytes directly and base64 is only applied to non-UTF-8 codecs. Detection via `flags & 0x02` flag emitted by JsonCodec. Trade-off: base64 adds 33% overhead. Alternative would be `Buffer`-backed Redis clients (ioredis supports both string and Buffer values), but that forks the `RedisLike` interface. Sticking with strings keeps the contract minimal; high-volume binary callers can adopt a future `RedisCacheBinary` if real. ## 4. File layout — `@suaveplan/cache-storage` ```text src/ index.ts # explicit named re-exports + side-effect import errors/ errors.ts # CACHE_STORAGE_CODES + StorageCacheError errors.md errors.test.ts index.ts types/ types.ts # HeaderMeta, StorageCacheOptions types.md index.ts codec/ codec.ts # Codec interface + barrel codec.md index.ts identity.ts # IdentityCodec identity.md identity.test.ts json.ts # JsonCodec json.md json.test.ts header/ header.ts # encode/decode + HEADER_BYTES header.md header.test.ts index.ts storage-cache/ storage-cache.ts # StorageCache storage-cache.md storage-cache.test.ts index.ts registry-augment/ registry-augment.ts # declare module + register call registry-augment.md index.ts ``` (`*.test.ts` files are excluded from coverage `.types` exclusion patterns; `index.ts` barrels are excluded.) ## 5. File layout — `@suaveplan/cache-redis` ```text src/ index.ts errors/ errors.ts # CACHE_REDIS_CODES + RedisCacheError errors.md errors.test.ts index.ts types/ types.ts # RedisCacheOptions types.md index.ts redis-like/ redis-like.ts # RedisLike + RedisScanResult interfaces redis-like.md redis-like.test.ts # fake stub conformance index.ts redis-cache/ redis-cache.ts # RedisCache redis-cache.md redis-cache.test.ts # via fake RedisLike index.ts registry-augment/ registry-augment.ts registry-augment.md index.ts ``` ## 6. Tier purity - `@suaveplan/cache-storage` — universal. `Uint8Array`, `TextEncoder`, `DataView` only. No `node:*` / `bun:*` / DOM. - `@suaveplan/cache-redis` — server. Pure TypeScript; the only platform requirement is a `RedisLike` implementation. The package itself imports nothing from `node:*`; the integration test consumer wires in `ioredis`. ## 7. Trade-offs - **Two packages vs. one mega-package with adapters subfolder.** Chose two packages because the dep graphs are different (`cache-redis` is server-only because the only meaningful client is server-side; `cache-storage` is universal and bundles with browser apps). Splitting also lets the storage backend ship before any future Redis integration testing landed. - **JSON over base64 vs. binary-safe Redis.** Base64 keeps the `RedisLike` interface to string values, which every major Node Redis client supports without configuration. The 33% overhead is acceptable for the typical cache value (JSON payloads, profile objects). High-volume binary callers can compose `IdentityCodec` + raw `RedisLike.set` with binary in a follow-up. - **`clear()` refuses on the global namespace.** A namespace-less `clear()` would have to `FLUSHDB` or `SCAN MATCH *`. Both are foot-guns when the Redis instance is shared. Refusing at the boundary forces callers to be explicit, with a clear error code. - **`hitCount` not rewritten on `StorageCache.get`.** Bumping `hitCount` would turn every `get` into a `getThenSet`, halving throughput. The field is reserved for future `getPartial`-aware backends that can update the header in place. Documented. - **No `wrap()` override.** The inherited `AbstractCache.wrap` already handles SWR + AbortSignal + force-refresh. Both new backends use it verbatim. - **`StructuredCloneCodec` deferred.** JSON + Identity cover ≥95% of cache values. CBOR ships in a follow-up once the catalog entry exists. Shipping a placeholder would violate §2b.