12 KiB
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-memoryis already covered by the built-inMemoryCacheshipped from@suaveplan/cache/memory. No new package required — the row retires inSTATUS.mdwith a one-liner pointing at the built-in.cache-browserandcache-redisexist 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:
-
@suaveplan/cache-storage— aStorageCache<T>that extendsAbstractCache<T>and wraps anyAbstractStorage(from@suaveplan/storage). A 32-byte fixed-width metadata header carries TTL + access counters becauseAbstractStorageis byte-oriented and has no expiry. PluggableCodec<T>convertsT↔Uint8Array(defaultJsonCodec, opt-inIdentityCodecforT = Uint8Array). Module-augmentsCacheProviderRegistrywith"storage"and auto-registers via side-effect import. One package subsumescache-browserentirely: callers wireStorageCacheoverstorage-browser-localstorage,storage-browser-indexeddb, orstorage-browser-opfsper their need; the same package also serves server-tier file/S3/CDN backed caches. -
@suaveplan/cache-redis— aRedisCache<T>that extendsAbstractCache<T>and talks to a minimalRedisLikeinterface using nativeSET ... EX,GET,DEL,EXISTS,TTL,SCAN. No client-side eviction (Redis'smaxmemory-policyowns that). No TTL header bytes (Redis owns expiry). Module-augmentsCacheProviderRegistrywith"redis"and auto-registers on import. Replacescache-rediswith 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 everyAbstractCacheShape<T>method. - ADDED: 32-byte fixed-width metadata header prepended to every stored value:
createdAtu64 BE (bytes 0..7),expiresAtu64 BE (bytes 8..15;0⇒ no expiry),flagsu32 BE (bytes 16..19; bit 0 = hasMetadata, bit 1 = codecJson, rest reserved),hitCountu32 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 = Uint8Arraypass-through. - ADDED:
CACHE_STORAGE_CODESviadefineCodes("CACHE_STORAGE", [...])— 5 codes:ENTRY_CORRUPT,CODEC_ERROR,BACKEND_ERROR,KEY_TOO_LONG,VALUE_TOO_LARGE.StorageCacheError extends SuaveplanError<CacheStorageCode>withkeydiscriminant. - ADDED:
headersubmodule —encodeHeader(meta) → Uint8Array,decodeHeader(bytes) → HeaderMeta,HEADER_BYTES = 32. - ADDED: Module augmentation —
declare module "@suaveplan/cache"addsstorage: typeof StorageCachetoCacheProviderRegistry. - ADDED: Side-effect import (
./registry-augment.ts) callsregisterCacheProvider("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:
RedisLikeinterface — 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>; optionalmget(...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_CODESviadefineCodes("CACHE_REDIS", [...])— 5 codes:CONNECTION_FAILED,COMMAND_FAILED,CODEC_ERROR,KEY_TOO_LONG,INVALID_TTL.RedisCacheError extends SuaveplanError<CacheRedisCode>withkeyandcommanddiscriminants. - ADDED: Codec layer reused from
@suaveplan/cache-storage—RedisCacheimportsCodec<T>,JsonCodec,IdentityCodectypes and runtime values from@suaveplan/cache-storage/codec. This keeps the codec surface single-sourced. - ADDED: Native TTL —
set(key, value, { ttl })issuesSET <ns>:<key> <encoded> EX <ceil(ttl/1000)>whenttl > 0; plainSETotherwise. - ADDED: Stats via
getStats()—hits/misses/evictions/hitRateare tracked client-side;sizereturns0(Redis size is not cheap to enumerate; documented limitation, not a defect). - ADDED: Module augmentation —
declare module "@suaveplan/cache"addsredis: typeof RedisCache. - ADDED: Side-effect auto-registration.
STATUS.md updates (the §13b refresh after archive)
cache-memoryrow: flip–→ ✅ with text "Built into@suaveplan/cacheasMemoryCache. No separate package."cache-browserrow: flip–→ ✅ with text "Use@suaveplan/cache-storageover@suaveplan/storage-browser-{localstorage,indexeddb,opfs}."cache-redisrow: flip–→ ✅ with text from the new@suaveplan/cache-redisREADME.
Out of scope
- Rebuilding
@suaveplan/cache. It is already shipped with a complete typedAbstractCache<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/Setfaithful 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.StorageCacheis the upstreamable form: instead of one package per browser store, one package over anyAbstractStorage.../suaveplan/packages/server/cache-redis/src/—RedisLikeshape and native command usage. The new package mirrors the public API while keeping theAbstractCache<T>policy layer from the genesis cache.@suaveplan/cache(genesis) — extension target.AbstractCache<T>,CacheProviderRegistry,CacheEvents,CacheStats,CACHE_CODESall consumed; nothing modified.@suaveplan/storage(genesis) —AbstractStorageconsumed;MemoryStoragefrom@suaveplan/storage/memoryconsumed 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_ERRORon malformed bytes; UTF-8 edge cases (multi-byte, surrogate pairs).src/codec/identity.test.ts—Uint8Arraypass-through preserves byte identity.src/storage-cache/storage-cache.test.ts— runs againstMemoryStoragefrom@suaveplan/storage/memory; coversget/set/delete/clear/has/getMultiple/setMultiple/wrap, TTL expiration (lazy on read),ENTRY_CORRUPTon short blob, codec swap, namespace prefixing, AbortSignal propagation throughwrap, factory registration end-to-end.src/error-codes/error-codes.test.ts— everyCACHE_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 fakeRedisLikestub.src/redis-cache/redis-cache.test.ts— covers everyAbstractCacheShapemethod using a fakeRedisLikestub. NativeEXfor TTL,SCAN-basedclear()within namespace,ttl()reconstruction ofexpiresAt,INVALID_TTLrejection.src/error-codes/error-codes.test.ts— everyCACHE_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-storageor@suaveplan/cache-redis— they are new. @suaveplan/cacheitself is not modified (no augments needed;CacheProviderRegistryis already an open module-augmentation interface).bunx turbo test --filter=...@suaveplan/cacheshould 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 viaworkspace:^. Module-augmented but not modified. [x] no action needed (additive module augmentation only).@suaveplan/storage— consumed bycache-storageforAbstractStorage. No changes. [x] no action needed.@suaveplan/error—defineCodesconsumed by both new packages. No changes. [x] no action needed.@suaveplan/types—Clock. No changes. [x] no action needed.@suaveplan/events—EventBusLikeconsumed for lifecycle events. No changes. [x] no action needed.