Files
suaveplan/openspec/archive/2026-05-16-add-cache-storage-redis-backends/proposal.md
2026-05-27 23:29:53 +01:00

12 KiB
Raw Blame History

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<T> 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<T> that extends AbstractCache<T> 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<T> 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<T> that extends AbstractCache<T> 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<T> extends AbstractCache<T> from @suaveplan/cache. Constructor accepts { storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }. Implements every AbstractCacheShape<T> 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<T> interface: encode(value: T): Uint8Array, decode(bytes: Uint8Array): T.
  • ADDED: JsonCodec<T> — 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<CacheStorageCode> 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<T> extends AbstractCache<T> from @suaveplan/cache. Constructor accepts { redis: RedisLike; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }.
  • ADDED: RedisLike interface — minimal native surface: 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>; 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<CacheRedisCode> with key and command discriminants.
  • ADDED: Codec layer reused from @suaveplan/cache-storage — RedisCache imports Codec<T>, 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 <ns>:<key> <encoded> EX <ceil(ttl/1000)> 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<T>. 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<T> policy layer from the genesis cache.
  • @suaveplan/cache (genesis) — extension target. AbstractCache<T>, 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<T> (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<T> 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.