# Add @suaveplan/cache-storage and @suaveplan/cache-redis — pluggable cache backends **Packages**: `@suaveplan/cache-storage` (universal), `@suaveplan/cache-redis` (server) **Status**: PROPOSED **Type**: new-packages (genesis-original; subsume pending `cache-browser` + `cache-redis` rows) - **Design**: ./design.md - **Spec deltas**: ./specs/cache-storage/spec.md, ./specs/cache-redis/spec.md ## Why `@suaveplan/cache` already ships on genesis as the singular cache abstraction: `AbstractCache` with `MemoryCache`, `CacheFactory`, augment-friendly `CacheProviderRegistry`, four eviction policies, lifecycle events, `wrap()` with stale-while-revalidate, namespacing, structured `CACHE_*` codes. Three sibling rows remain pending in `STATUS.md` — `cache-browser`, `cache-memory`, `cache-redis` — each upstream-only and each a copy-paste of the same adapter logic around a different backing store. Two of those collapse cleanly: - `cache-memory` is **already covered** by the built-in `MemoryCache` shipped from `@suaveplan/cache/memory`. No new package required — the row retires in `STATUS.md` with a one-liner pointing at the built-in. - `cache-browser` and `cache-redis` exist as separate packages because each speaks to a different store. The right abstraction is **one cache backend per store family**, with the cache policy layer (which already exists) reused. Browser stores fan out through the existing `@suaveplan/storage-browser-*` adapters; Redis stays direct because of native TTL. This change introduces: 1. **`@suaveplan/cache-storage`** — a `StorageCache` that extends `AbstractCache` and wraps any `AbstractStorage` (from `@suaveplan/storage`). A 32-byte fixed-width metadata header carries TTL + access counters because `AbstractStorage` is byte-oriented and has no expiry. Pluggable `Codec` converts `T` ↔ `Uint8Array` (default `JsonCodec`, opt-in `IdentityCodec` for `T = Uint8Array`). Module-augments `CacheProviderRegistry` with `"storage"` and auto-registers via side-effect import. **One package subsumes `cache-browser` entirely**: callers wire `StorageCache` over `storage-browser-localstorage`, `storage-browser-indexeddb`, or `storage-browser-opfs` per their need; the same package also serves server-tier file/S3/CDN backed caches. 2. **`@suaveplan/cache-redis`** — a `RedisCache` that extends `AbstractCache` and talks to a minimal `RedisLike` interface using native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. No client-side eviction (Redis's `maxmemory-policy` owns that). No TTL header bytes (Redis owns expiry). Module-augments `CacheProviderRegistry` with `"redis"` and auto-registers on import. **Replaces `cache-redis`** with a small native adapter. Net result: one cache surface (`createCache("memory" | "storage" | "redis", config)`), three rows retire from `STATUS.md`, and every future storage adapter (`storage-s3`, `storage-cdn-*`, future `storage-r2`) becomes a cache backend for free. ## What changes ### `@suaveplan/cache-storage` (new, universal) - ADDED: `StorageCache extends AbstractCache` from `@suaveplan/cache`. Constructor accepts `{ storage: AbstractStorage; codec?: Codec; defaultTtl?: number; clock?: Clock; events?: EventBusLike; namespace?: string }`. Implements every `AbstractCacheShape` method. - ADDED: 32-byte fixed-width metadata header prepended to every stored value: `createdAt` u64 BE (bytes 0..7), `expiresAt` u64 BE (bytes 8..15; `0` ⇒ no expiry), `flags` u32 BE (bytes 16..19; bit 0 = hasMetadata, bit 1 = codecJson, rest reserved), `hitCount` u32 BE (bytes 20..23), reserved 8 bytes (24..31). - ADDED: `Codec` interface: `encode(value: T): Uint8Array`, `decode(bytes: Uint8Array): T`. - ADDED: `JsonCodec` — `JSON.stringify` + `TextEncoder` / `TextDecoder`. Default. - ADDED: `IdentityCodec` — `T = Uint8Array` pass-through. - ADDED: `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])` — 5 codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`. `StorageCacheError extends SuaveplanError` with `key` discriminant. - ADDED: `header` submodule — `encodeHeader(meta) → Uint8Array`, `decodeHeader(bytes) → HeaderMeta`, `HEADER_BYTES = 32`. - ADDED: Module augmentation — `declare module "@suaveplan/cache"` adds `storage: typeof StorageCache` to `CacheProviderRegistry`. - ADDED: Side-effect import (`./registry-augment.ts`) calls `registerCacheProvider("storage", StorageCache)` exactly once via idempotent guard. ### `@suaveplan/cache-redis` (new, server) - ADDED: `RedisCache extends AbstractCache` from `@suaveplan/cache`. Constructor accepts `{ redis: RedisLike; codec?: Codec; defaultTtl?: number; clock?: Clock; events?: EventBusLike; namespace?: string }`. - ADDED: `RedisLike` interface — minimal native surface: `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`; optional `mget(...keys): Promise<(string | null)[]>`; `scan(cursor, opts?: { MATCH?: string; COUNT?: number }): Promise<{ cursor: string; keys: string[] }>`. Adapter accepts any client conforming to it (`ioredis`, `node-redis`, custom). - ADDED: `CACHE_REDIS_CODES` via `defineCodes("CACHE_REDIS", [...])` — 5 codes: `CONNECTION_FAILED`, `COMMAND_FAILED`, `CODEC_ERROR`, `KEY_TOO_LONG`, `INVALID_TTL`. `RedisCacheError extends SuaveplanError` with `key` and `command` discriminants. - ADDED: Codec layer reused from `@suaveplan/cache-storage` — `RedisCache` imports `Codec`, `JsonCodec`, `IdentityCodec` types **and** runtime values from `@suaveplan/cache-storage/codec`. This keeps the codec surface single-sourced. - ADDED: Native TTL — `set(key, value, { ttl })` issues `SET : EX ` when `ttl > 0`; plain `SET` otherwise. - ADDED: Stats via `getStats()` — `hits`/`misses`/`evictions`/`hitRate` are tracked client-side; `size` returns `0` (Redis size is not cheap to enumerate; documented limitation, not a defect). - ADDED: Module augmentation — `declare module "@suaveplan/cache"` adds `redis: typeof RedisCache`. - ADDED: Side-effect auto-registration. ### `STATUS.md` updates (the §13b refresh after archive) - `cache-memory` row: flip `–` → ✅ with text "Built into `@suaveplan/cache` as `MemoryCache`. No separate package." - `cache-browser` row: flip `–` → ✅ with text "Use `@suaveplan/cache-storage` over `@suaveplan/storage-browser-{localstorage,indexeddb,opfs}`." - `cache-redis` row: flip `–` → ✅ with text from the new `@suaveplan/cache-redis` README. ## Out of scope - **Rebuilding `@suaveplan/cache`.** It is already shipped with a complete typed `AbstractCache`. No changes to its public surface in this proposal. - **`StructuredCloneCodec` / CBOR codec.** Deferred. JSON + Identity cover the common case; CBOR-backed structured-clone (`Date`/`Map`/`Set` faithful round-trip) ships in a follow-up alongside the cbor catalog entry. No placeholder code lands in this change. - **Distributed cache invalidation / pub-sub busting.** Application concern, not cache-backend concern. - **Tiered (L1+L2) cache composition.** Easy to build on top of two cache instances; not a backend feature. - **Cross-process stampede protection.** `wrap()` is in-process (inherited from `@suaveplan/cache`). Cross-process coalescing requires a Redis lock and is application-layer. ## Source mapping - `../suaveplan/packages/browser/cache-browser/src/` — establishes the cache-as-storage-bridge pattern. `StorageCache` is the upstreamable form: instead of one package per browser store, one package over any `AbstractStorage`. - `../suaveplan/packages/server/cache-redis/src/` — `RedisLike` shape and native command usage. The new package mirrors the public API while keeping the `AbstractCache` policy layer from the genesis cache. - `@suaveplan/cache` (genesis) — extension target. `AbstractCache`, `CacheProviderRegistry`, `CacheEvents`, `CacheStats`, `CACHE_CODES` all consumed; nothing modified. - `@suaveplan/storage` (genesis) — `AbstractStorage` consumed; `MemoryStorage` from `@suaveplan/storage/memory` consumed in tests. ### Requirements with no source line | Requirement | Justification | |---|---| | `StorageCache` extends the genesis `AbstractCache` (not a fresh class) | Reuses every helper (`getMultiple`, `setMultiple`, `wrap` w/ SWR + AbortSignal) and integrates with the existing `CacheFactory`. Any other choice forks the cache surface. | | 32-byte fixed-width metadata header (not protobuf/cbor) | `AbstractStorage` is byte-oriented and has no metadata side-channel. Fixed width keeps reads single-range and avoids a second round-trip. Negligible (32 B) overhead per value. | | `RedisCache` reuses `@suaveplan/cache-storage`'s `Codec` | Single-sourced codec contract. `JsonCodec` is the same class object whether backend is storage or redis — tests prove the round-trip parity. Forking the codec across backends would diverge the JSON dialect over time. | | `cache-redis` `getStats().size` returns `0` (documented) | Redis `DBSIZE` covers the whole DB, not a namespace; `SCAN`-then-count is O(N) and not appropriate for a `getStats()` call. Documented limitation, not a defect; reachers needing exact size run `SCAN` directly. | | Side-effect auto-registration with idempotent guard | Matches the existing `@suaveplan/cache/factory/registry/index.js` pattern. The guard ensures `import "@suaveplan/cache-storage"` twice does not warn or throw. | | `INVALID_TTL` lives in `CACHE_REDIS_CODES` (and not `cache-storage`) | Redis's `EX` argument must be a positive integer; non-positive TTLs are rejected at the boundary. `cache-storage` stores 64-bit `expiresAt` and tolerates `0` (no expiry), so the same code does not apply. | ## Verification approach ### `@suaveplan/cache-storage` - `src/header/header.test.ts` — encode/decode round-trip for every field (boundary values, max-int hitCount, zero expiresAt, set/unset flags). Rejects malformed input. - `src/codec/json.test.ts` — round-trip structured values; `CODEC_ERROR` on malformed bytes; UTF-8 edge cases (multi-byte, surrogate pairs). - `src/codec/identity.test.ts` — `Uint8Array` pass-through preserves byte identity. - `src/storage-cache/storage-cache.test.ts` — runs against `MemoryStorage` from `@suaveplan/storage/memory`; covers `get`/`set`/`delete`/`clear`/`has`/`getMultiple`/`setMultiple`/`wrap`, TTL expiration (lazy on read), `ENTRY_CORRUPT` on short blob, codec swap, namespace prefixing, AbortSignal propagation through `wrap`, factory registration end-to-end. - `src/error-codes/error-codes.test.ts` — every `CACHE_STORAGE_*` code reachable from a real call site. - Tier-purity grep returns empty (no `node:` / `bun:` imports). ### `@suaveplan/cache-redis` - `src/redis-like/redis-like.test.ts` — minimal interface conformance via a fake `RedisLike` stub. - `src/redis-cache/redis-cache.test.ts` — covers every `AbstractCacheShape` method using a fake `RedisLike` stub. Native `EX` for TTL, `SCAN`-based `clear()` within namespace, `ttl()` reconstruction of `expiresAt`, `INVALID_TTL` rejection. - `src/error-codes/error-codes.test.ts` — every `CACHE_REDIS_*` code reachable. Integration testing against a real Redis (via `@suaveplan/testing-integration-redis`) is a follow-up. The fake `RedisLike` covers protocol conformance for this change. ## Impact ### Affected dependents Run `bunx turbo ls --filter=...@suaveplan/cache` to enumerate dependents of the existing cache package. - No dependents currently exist for `@suaveplan/cache-storage` or `@suaveplan/cache-redis` — they are new. - `@suaveplan/cache` itself is **not modified** (no augments needed; `CacheProviderRegistry` is already an open module-augmentation interface). `bunx turbo test --filter=...@suaveplan/cache` should remain green; the new sibling packages declare it as a workspace dep so dependent-sweep tests run them. - Forward-declared dependents: future consumers of caching that prefer a Redis or browser-storage backend. ### Affected upstreams - `@suaveplan/cache` — consumed via `workspace:^`. Module-augmented but not modified. [x] no action needed (additive module augmentation only). - `@suaveplan/storage` — consumed by `cache-storage` for `AbstractStorage`. No changes. [x] no action needed. - `@suaveplan/error` — `defineCodes` consumed by both new packages. No changes. [x] no action needed. - `@suaveplan/types` — `Clock`. No changes. [x] no action needed. - `@suaveplan/events` — `EventBusLike` consumed for lifecycle events. No changes. [x] no action needed.