15 KiB
Complete @suaveplan/cache-storage to full functional surface
Package: @suaveplan/cache-storage (universal)
Status: PROPOSED
Type: feature-complete expansion — additive + minor MODIFIED to existing shape
Supersedes: openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/
- Design: ./design.md
- Spec delta: ./specs/cache-storage/spec.md
Why
@suaveplan/cache-storage currently ships a minimum-viable StorageCache<T> over AbstractStorage: 32-byte header, two codecs (JsonCodec, IdentityCodec), namespacing, lazy TTL on read, CacheEvents emission when an EventBusLike is provided, and 5 error codes. That surface stops short of what enterprise consumers expect from a cache and what the project-wide enterprise/cache.md audit calls out (cache-storage itself has no upstream enterprise/*.md — its parent cache.md is the design intent):
- One codec.
StructuredCloneCodec(CBOR-backed, round-tripsDate/Map/Set/Uint8Array/bigint/RegExp/URL) is queued in the superseded change but never landed. JSON losesDate, throws onbigint, lossily encodesMap/Set/Uint8Array. - No eviction. The parent
cachepackage declareslru/lfu/fifo/ttlpolicies onCacheOptions, butStorageCacheignores them — every entry is permanent until the backend evicts ordelete/clearis called. Size-bounded backends (Redis withmaxmemory, OPFS with quota) hit hard limits instead of soft evicting. - TTL only fires on read. The
enterprise/cache.mdaudit mandates lazy-on-read AND a configurable background sweep. The current implementation drops expired entries only when re-read, leaving them in the backend indefinitely. - No stampede protection. Concurrent
cache.wrap(key, loader)calls invoke the loader once per concurrent caller. The cache parent'swrapdoesn't dedupe in-flight promises; for an expensive loader (DB query, remote fetch) the cache becomes a dog-pile amplifier. - No stale-while-revalidate.
CacheSetOptions.staleWhileRevalidateis in the type signature on the parent but the parent'swrapdoesn't implement it.StorageCacheships no SWR pipeline either. - No bulk byte ops on the backend.
getMultiple/setMultipleround-trip every key one at a time even when the backend supports bulk reads. - No atomic ops.
getOrSetand compare-and-swap (CAS via header-carried version) are absent. Callers writing leader-election, distributed locks, or optimistic concurrency on top of the cache have to hand-roll the read/encode/write race themselves. - No serialization size limits beyond the 10 MiB hard cap — but with no per-cache override, no
KEY_TOO_LONGfor typed key budgets, and no rejection metric. - No observability beyond
CacheEvents. No OTel spans onget/set/wrap/mget, no counters forhits/misses/evictions/stampede-prevented, no histogram on payload bytes. - No multi-backend conformance harness. Bugs in
StorageCacheagainst memory storage do not surface against Redis or S3 storage until a real consumer hits them. The codec conformance harness queued in the superseded change is missing too. - 5 error codes cover encode/backend/header. Missing: stampede timeout, CAS conflict, eviction failure, codec-unsupported-type, codec-unknown-tag, sweep failure, namespace-conflict.
This change lands the full surface as a single feature-complete release of cache-storage so that downstream packages (cache-redis, future cache-opfs, cache-s3) plug into a stable contract rather than chasing a moving target.
What changes
@suaveplan/cache-storage — ## ADDED
- Codecs (3 total). Keep
JsonCodec(default), keepIdentityCodec, addStructuredCloneCodec<T>(CBOR-backed; IANA tags 0/2/3/32/35/64/258/259 per the supersededdesign.md). All three pass a newrunCodecConformance(codec, { profile })harness from./codec/conformance. - Eviction policies. Pluggable
EvictionPolicyinterface in./eviction/policy.types.ts, plus four implementations in./eviction/:LruPolicy,LfuPolicy,FifoPolicy,TtlOnlyPolicy. Each maintains a side-index keyed byprefixedKey;StorageCache.setconsults the policy after every write to decide whether to evict (reason: "lru" | "lfu" | "fifo" | "ttl" | "size"). Caller selects viaStorageCacheOptions.eviction: { policy: EvictionPolicy; maxEntries?: number; maxBytes?: number }. - Background TTL sweep.
StorageCacheOptions.sweep: { intervalMs: number; batchSize?: number }. When set,StorageCacheschedules a sweep via the injectedTimersLikefrom@suaveplan/types(no globals) that lists keys viastorage.list({ prefix: namespace })inbatchSizepages, decodes each header, andstorage.deletes expired entries — emittingexpireevents and incrementingevictions.start()/stop()lifecycle on the cache; idempotent. - Stampede protection (single-flight).
./stampede/inflight-map.tskeyed byprefixedKey.cache.wrap(key, loader, { stampede: { timeoutMs } })deduplicates concurrent calls: the second caller awaits the first caller's promise. On timeout the dedup map clears andCACHE_STORAGE.STAMPEDE_TIMEOUTthrows. DefaulttimeoutMs: 30000. CounterstampedePreventedexposed viagetStats(). - Stale-while-revalidate.
cache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs })returns the stale value immediately whenclock() >= expiresAt - staleThresholdMs, and fires the loader on a background microtask scheduled viaTimersLike.queueMicrotask. Background refresh errors emit aswr-refresh-failedevent but never reject the caller. - Bulk ops.
mget(keys: string[]): Promise<Map<string, T>>,mset(entries: Array<{ key, value, options? }>): Promise<void>,mdelete(keys: string[]): Promise<number>. Each delegates to the newAbstractStorage.getMultiple/setMultiplewhen present (duck-typed) and falls back to per-key calls. - Atomic ops.
getOrSet(key, loader, options?)iswrapwithstampede.timeoutMsenforced by default.compareAndSet(key, expectedVersion, value, options?)reads the header's reservedversion: u32 BE(bytes 24..27 — previously reserved-zero), compares, and writes only when equal; on mismatch throwsCACHE_STORAGE.CAS_CONFLICTcarrying the observed version.getWithVersion(key)returns{ value, version } | undefinedso callers can drive the CAS loop. - Serialization size limits. New constructor option
limits: { maxKeyBytes?: number; maxValueBytes?: number }overrides the existing hard-codedMAX_KEY_LENGTH = 1024andMAX_VALUE_BYTES = 10 MiB. Defaults preserved. Rejection on overflow throwsKEY_TOO_LONG/VALUE_TOO_LARGE(existing codes) and emits arejectedevent. - Per-namespace stats.
getStats()continues to return cache-wide counters; newgetStats({ namespace })returns counters scoped to a sub-namespace when aNamespacedCache-style child has been created viacache.namespace("sub"). Implementation maintains aMap<namespace, Counters>. - OTel observability. Every public op (
get/set/delete/has/clear/wrap/mget/mset/mdelete/compareAndSet/getOrSet) opens a span via@opentelemetry/apitrace.getTracer("@suaveplan/cache-storage"). Counters:cache.hits,cache.misses,cache.evictions,cache.stampede_prevented,cache.swr_refreshes,cache.rejected. Histogram:cache.payload_bytes. All no-op when no exporter is registered. - Multi-backend conformance harness.
./testing/conformance.tsexportsrunStorageCacheConformance({ createStorage, name })that runs the fullStorageCacheAPI + every eviction policy + stampede + SWR + CAS against a caller-suppliedAbstractStoragefactory. Lives incache-storage(not@suaveplan/testing) because it depends on@suaveplan/cache-storageitself; testing-infra rule 8 forbids the inverse. - Codec conformance harness.
./codec/conformance.tsexportsrunCodecConformance(codec, { name, profile: "json" | "structured-clone" | "bytes" })per the superseded change's design. Has a self-test proving teeth. - Error registry extended. 5 → 12 codes: keep all 5 existing, ADD
STAMPEDE_TIMEOUT,CAS_CONFLICT,SWEEP_FAILED,EVICTION_FAILED,CODEC_UNSUPPORTED_TYPE,CODEC_UNKNOWN_TAG,NAMESPACE_CONFLICT. - Subpath exports. Add
./codec/structured-clone,./codec/conformance,./eviction/lru,./eviction/lfu,./eviction/fifo,./eviction/ttl,./eviction/policy,./stampede/inflight-map,./testing/conformance.
@suaveplan/cache-storage — ## MODIFIED
- Header layout (v2). Bytes 24..27 — previously reserved-zero — are repurposed for
version: u32 BE. Bytes 28..31 remain reserved-zero.flagsbit 2 (FLAG_HAS_VERSION) is set when the header carries a non-zero version. Existing v1 headers (where bytes 24..31 are zero) decode asversion: 0, so the on-disk format remains forward-compatible: every legacy entry reads back asversion: 0, and acompareAndSet(key, 0, ...)on a legacy entry succeeds on first write. StorageCacheOptions. Extended with the new optional fields (eviction,sweep,limits,timers,tracer,metrics). All defaults preserve current behaviour: zero-config callers get exactly today'sStorageCache.StorageCache.wrapoverride. The inheritedAbstractCache.wrapis overridden to add stampede dedup, SWR, and OTel span. The override SHALL be observationally equivalent to the parent whenstampede/staleWhileRevalidateare absent.
Out of scope
- Backend-specific implementations. Each backend (
@suaveplan/cache-redis, futurecache-opfs,cache-s3,cache-memory) is its own package and consumesStorageCache+ the conformance harness from here. This change only ships the universal core.
Parity Checklist
No upstream cache-storage package exists on main — cache-storage is a genesis-original. The parity comparison here is against the project-wide enterprise/cache.md design intent and against what backends (cache-redis) expect.
| Capability | Design intent (enterprise/cache.md) |
Current cache-storage |
After this change | Notes |
|---|---|---|---|---|
JsonCodec default |
required | ok | ok | unchanged |
IdentityCodec for Uint8Array |
required | ok | ok | unchanged |
StructuredCloneCodec (CBOR) |
required | missing | added | picks up superseded change |
| LRU / LFU / FIFO / TTL eviction | required | missing | added | pluggable EvictionPolicy |
| Background TTL sweep | required | missing | added | TimersLike-injected |
| Stampede protection | required | missing | added | in-flight map, configurable timeout |
| Stale-while-revalidate | required | missing | added | background microtask refresh |
| Namespacing | required | ok | ok | unchanged |
| Bulk ops (mget/mset/mdelete) | required | inherited only | added | bulk-aware backend dispatch |
| Atomic ops (getOrSet, CAS) | required | missing | added | header version repurposes reserved bytes |
| Serialization size limits | partial (hardcoded) | hard-coded only | configurable | per-cache overrides |
| OTel spans + counters + histogram | required | missing | added | @opentelemetry/api no-op when unconfigured |
| EventBus lifecycle events | required | ok | ok | extended with expire/swr-refresh-failed/rejected |
| Error registry covers every failure path | required | 5 codes (incomplete) | 12 codes | every code reachable via test |
| Multi-backend conformance harness | required | missing | added | factory-injected AbstractStorage |
| 100% coverage | required | ok | ok | maintained |
Source mapping
packages/flow/cache-storage/src/storage-cache/storage-cache.ts:1-240— current implementation; this change extends it (no greenfield rewrite).packages/flow/cache-storage/src/header/header.ts:1-90— header layout; bytes 24..27 move from reserved-zero toversion: u32 BE.packages/flow/cache-storage/src/errors/errors.ts:1-50—CACHE_STORAGE_CODESextends 5 → 12.packages/flow/cache-storage/src/codec/{json,identity,codec.types}.ts— codec seam;StructuredCloneCodecplugs into the same interface.packages/flow/cache/src/types/common.ts:30-50—CacheOptions,CacheSetOptions,CacheWrapOptionsalready declare the surface shape;StorageCacheimplements what the parent only typed.packages/flow/cbor/src/index.ts—encode,decode,Tagged,type TagHandlerconsumed byStructuredCloneCodec.openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/{proposal,design,specs/cache-storage/spec}.md— superseded; the CBOR codec design and tag table are reused verbatim.enterprise/cache.md(upstream/mnt/s/web/suaveplan/enterprise/cache.md) — design intent for the cache family;cache-storageis the storage-backed leaf of that contract.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/cache-storage to enumerate.
@suaveplan/cache-redis— [ ] tests run / [x] docs reviewed / [ ] no action needed (reason: consumesStorageCache,Codec<T>,StorageCacheError. Header v2 is forward-compatible — legacy entries decode asversion: 0. New eviction policies and stampede protection are opt-in. The new conformance harness from./testing/conformanceSHOULD be wired intocache-redis's test suite as part of the consumer's own next change, not this one.)- No other dependents at time of writing — verified per
bunx turbo ls --filter=...@suaveplan/cache-storage.
Affected upstreams
@suaveplan/cbor— newly consumed atworkspace:^. Not modified. [x] no action needed.@suaveplan/cache—AbstractCacheparent unchanged; this change overrideswrapand adds new methods. [x] no action needed.@suaveplan/storage—AbstractStorageconsumed via structuralDriveStorage-style view; bulk-op dispatch is duck-typed so storage backends withoutgetMultiplecontinue to work. [x] no action needed.@suaveplan/types—TimersLike,Clock,EventBusLikeconsumed. [x] no action needed.@suaveplan/error—defineCodesregistry extended (additive). [x] no action needed.@suaveplan/events— not a direct dep;EventBusLikefrom@suaveplan/typesis the contract surface. [x] no action needed.
Migration
- No breaking renames. All existing exports keep their names and signatures.
- Header v2 is forward-compatible. Legacy v1 entries (bytes 24..31 = 0) decode cleanly with
version: 0. AcompareAndSet(key, 0, value)on a legacy entry succeeds on first write; callers who want strict CAS semantics must seedversionviasetbefore the first CAS. - Default behaviour preserved. Zero-config
new StorageCache({ storage })runs exactly like today — no eviction, no sweep, no stampede dedup, no SWR. - Opt-in surface. Eviction, sweep, stampede, SWR, CAS, OTel are all gated on constructor options or per-call options that default to today's behaviour. Callers opt in explicitly.
- New error codes are additive. Existing 5 codes unchanged; the 7 new codes only throw from new code paths.