34 KiB
Capability Spec Delta: cache-storage (cache-storage)
Package: @suaveplan/cache-storage
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: StructuredCloneCodec round-trips the structured-clone-ish JS subset
The package SHALL export StructuredCloneCodec<T> implements Codec<T> from ./codec/structured-clone. The codec SHALL faithfully round-trip the following JS value types through encode then decode: null, boolean, finite number, string, plain object, array, Date, Map, Set, Uint8Array, bigint (positive and negative), RegExp (source only — flags excluded by the tag-35 wire format), URL, and arbitrary nestings of the above. Encoding SHALL delegate to encode from @suaveplan/cbor; decoding SHALL delegate to decode from @suaveplan/cbor with a tag-handler registry that reverses the lift table.
Scenario: round-trip preserves Date instances
- WHEN a caller calls
codec.decode(codec.encode(new Date("2026-05-17T12:00:00.000Z"))) - THEN the result is
instanceof DateAND its.getTime()equals the input's.getTime()
Scenario: round-trip preserves Map with object keys and nested Set values
- WHEN a caller round-trips
new Map<unknown, unknown>([[{ id: 1 }, "a"], [{ id: 2 }, new Set([1, 2])]]) - THEN the result is
instanceof Map, hassize === 2, AND iterating yields keys/values deep-equal to the input (the second value beinginstanceof Setwith members1and2)
Scenario: round-trip preserves Uint8Array byte identity
- WHEN a caller round-trips
new Uint8Array([0, 1, 127, 128, 255]) - THEN the result is
instanceof Uint8ArrayAND every byte is equal to the input at the same index
Scenario: round-trip preserves bigint magnitude and sign
- WHEN a caller round-trips both
2n ** 200nand-(2n ** 200n) - 7n - THEN each result is
typeof "bigint"AND strictly equals the input
Scenario: deeply nested structures survive
- WHEN a caller round-trips
{ users: new Map([["alice", { tags: new Set(["admin"]), joined: new Date(0), avatar: new Uint8Array([1, 2, 3]) }]]) } - THEN the result deep-equals the input across
Map,Set,Date, andUint8Arrayboundaries
Verified by: src/codec/structured-clone.test.ts::"round-trip preserves Date instances" AND src/codec/structured-clone.test.ts::"round-trip preserves Map with object keys and nested Set values" AND src/codec/structured-clone.test.ts::"round-trip preserves Uint8Array byte identity" AND src/codec/structured-clone.test.ts::"round-trip preserves bigint magnitude and sign" AND src/codec/structured-clone.test.ts::"deeply nested structures survive"
Requirement: StructuredCloneCodec uses IANA-registered CBOR tags
The codec SHALL use the following CBOR tag numbers from the IANA CBOR Tags registry: 0 (RFC 3339 date-time string) for Date; 2 (positive bignum) for bigint > 0n; 3 (negative bignum) for bigint < 0n; 32 (URI) for URL; 35 (PCRE regex) for RegExp; 64 (Uint8 typed array) for Uint8Array; 258 (Set) for Set; 259 (Map with explicit key/value pairs) for Map. The codec SHALL NOT invent private tag numbers for any carrier listed in the round-trip requirement above.
Scenario: encoded Date emits CBOR tag 0
- WHEN a caller calls
codec.encode(new Date("2026-05-17T12:00:00.000Z"))AND the result is fed back throughdecodefrom@suaveplan/cborwith default tag handling - THEN the decoded top-level value is a
Taggedinstance with.tag === 0AND.valueis the RFC 3339 string
Scenario: encoded Uint8Array emits CBOR tag 64
- WHEN a caller calls
codec.encode(new Uint8Array([1, 2, 3]))AND the result is fed back throughdecodefrom@suaveplan/cborwith default tag handling - THEN the decoded top-level value is a
Taggedinstance with.tag === 64
Verified by: src/codec/structured-clone.test.ts::"Date encodes under CBOR tag 0" AND src/codec/structured-clone.test.ts::"Uint8Array encodes under CBOR tag 64"
Requirement: CODEC_UNSUPPORTED_TYPE on encode of unencodable carriers
StructuredCloneCodec.encode SHALL throw StorageCacheError with code === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE" when the input contains (directly or transitively) any of: Function, Symbol, WeakMap, WeakSet, Promise, or any class instance whose constructor is not one of the registered carriers (Date, Map, Set, Uint8Array, RegExp, URL). The error SHALL carry the failing JS-type name in context.typeName.
Scenario: encoding a function throws CODEC_UNSUPPORTED_TYPE
- WHEN a caller calls
codec.encode((() => 1) as unknown) - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"
Scenario: encoding a Symbol nested in an object throws CODEC_UNSUPPORTED_TYPE
- WHEN a caller calls
codec.encode({ ok: 1, bad: Symbol("x") } as unknown) - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE"
Verified by: src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a function" AND src/codec/structured-clone.test.ts::"encode throws CODEC_UNSUPPORTED_TYPE on a nested Symbol"
Requirement: CODEC_UNKNOWN_TAG on decode of an unregistered CBOR tag
StructuredCloneCodec.decode SHALL throw StorageCacheError with code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG" when the input CBOR bytes contain a Tagged(n, …) value whose tag n is not one of the registered tags (0, 2, 3, 32, 35, 64, 258, 259). The error context SHALL carry the offending tag number. The codec SHALL NOT silently return the value as a Tagged wrapper.
Scenario: decoding an unrecognised tag throws CODEC_UNKNOWN_TAG
- WHEN a caller encodes
new Tagged(9999, "x")viaencodefrom@suaveplan/cborAND passes the resulting bytes tocodec.decode - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.CODEC_UNKNOWN_TAG"ANDerror.context.tag === 9999
Verified by: src/codec/structured-clone.test.ts::"decode throws CODEC_UNKNOWN_TAG on an unregistered tag"
Requirement: CODEC_ERROR on malformed CBOR bytes
StructuredCloneCodec.decode SHALL throw StorageCacheError with code === "CACHE_STORAGE.CODEC_ERROR" when the input bytes are not valid CBOR (catching any error thrown by decode from @suaveplan/cbor and re-wrapping). The underlying error SHALL be attached as cause.
Scenario: decoding random bytes throws CODEC_ERROR with cbor cause
- WHEN a caller calls
codec.decode(new Uint8Array([0xff, 0xff, 0xff, 0xff]))for bytes that do not form a valid CBOR item - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.CODEC_ERROR"ANDerror.causeis the underlyingError
Verified by: src/codec/structured-clone.test.ts::"decode throws CODEC_ERROR with cbor cause on malformed bytes"
Requirement: Codec conformance harness with profile selector
The package SHALL export a runCodecConformance(codec, options) harness from ./codec/conformance accepting { name: string; profile: "json" | "structured-clone" | "bytes" }. The harness SHALL execute round-trip assertions whose coverage matches the requested profile: "bytes" covers Uint8Array only; "json" covers JSON primitives, plain objects, and arrays; "structured-clone" covers the union of "json" plus Date, Map, Set, Uint8Array, bigint, RegExp, URL, and deeply nested combinations. The harness SHALL fail loudly when given a deliberately broken codec.
Scenario: harness fails on a broken codec
- WHEN the test suite passes a codec whose
decodealways returns a fixednullsentinel - THEN at least one harness assertion fails
Scenario: harness passes for JsonCodec at the JSON profile
- WHEN the test suite calls
runCodecConformance(new JsonCodec<unknown>(), { name: "JsonCodec", profile: "json" }) - THEN every assertion passes
Scenario: harness passes for IdentityCodec at the bytes profile
- WHEN the test suite calls
runCodecConformance(new IdentityCodec(), { name: "IdentityCodec", profile: "bytes" }) - THEN every assertion passes
Verified by: src/codec/conformance.test.ts::"harness fails on a deliberately broken codec" AND src/codec/json.test.ts::"JsonCodec satisfies the JSON-profile conformance harness" AND src/codec/identity.test.ts::"IdentityCodec satisfies the bytes-profile conformance harness"
Requirement: Header v2 carries a u32 BE version field
The package SHALL extend the 32-byte fixed-width header so that bytes 24..27 carry version: u32 BE and bytes 28..31 remain reserved-zero. flags bit 2 (FLAG_HAS_VERSION = 1 << 2) SHALL be set when the writer is v2-aware. Legacy v1 entries (where FLAG_HAS_VERSION is clear and bytes 24..31 are all zero) SHALL decode as version: 0 so the on-disk format remains forward-compatible. HEADER_BYTES = 32 SHALL be unchanged. encodeHeader(meta) SHALL accept meta.version; decodeHeader(bytes) SHALL return meta.version.
Scenario: header round-trips a non-zero version
- WHEN a caller calls
decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 42 })) - THEN the decoded
versionequals42
Scenario: legacy v1 header decodes as version 0
- WHEN a caller constructs a 32-byte buffer with
FLAG_HAS_VERSIONclear AND bytes 24..31 all zero AND callsdecodeHeader(buffer) - THEN the decoded
versionequals0
Verified by: src/header/header.test.ts::"header round-trips a non-zero version field" AND src/header/header.test.ts::"legacy v1 header without FLAG_HAS_VERSION decodes as version 0"
Requirement: EvictionPolicy interface decouples eviction from StorageCache
The package SHALL export EvictionPolicy from ./eviction/policy with the methods onSet(key, sizeBytes, now): readonly string[], onHit(key, now): void, onDelete(key): void, reset(): void, and snapshot(): { entries: number; bytes: number }. StorageCache SHALL accept an eviction?: { policy: EvictionPolicy; maxEntries?: number; maxBytes?: number } constructor option. After every successful set, StorageCache SHALL invoke policy.onSet, delete every returned victim key via the backing storage, increment the evictions counter, and emit an evict event with the appropriate reason. Eviction-policy state SHALL be in-memory per StorageCache instance.
Scenario: no eviction policy preserves today's behaviour
- WHEN a caller constructs
new StorageCache({ storage })(noevictionoption) AND writes 1,000 entries - THEN every entry remains readable via
cache.getANDgetStats().evictions === 0
Scenario: EVICTION_FAILED wraps a backend delete error during eviction
- WHEN a fake
AbstractStoragewhosedeleterejects is wired intoStorageCachewith anLruPolicy({ maxEntries: 1 })AND a secondsettriggers an eviction - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.EVICTION_FAILED"
Verified by: src/storage-cache/storage-cache.test.ts::"no eviction policy preserves today's behaviour across 1000 writes" AND src/storage-cache/storage-cache.test.ts::"EVICTION_FAILED wraps a backend delete error during eviction"
Requirement: LruPolicy evicts the least-recently-touched key
LruPolicy SHALL maintain a doubly-linked list of keys ordered by last touch (onSet or onHit). When entries > maxEntries or bytes > maxBytes, onSet SHALL return the tail (oldest) keys until both bounds are satisfied. onHit SHALL move the touched key to the head. reset() SHALL clear the list.
Scenario: third write with maxEntries 2 evicts the first-written key
- WHEN an
LruPolicy({ maxEntries: 2 })recordsonSet("a"),onSet("b"),onSet("c") - THEN the victims returned by the third
onSetinclude"a"AND do not include"b"or"c"
Scenario: onHit on the oldest key prevents its eviction on the next set
- WHEN the policy records
onSet("a"),onSet("b"),onHit("a"),onSet("c") - THEN the victims returned by the third
onSetinclude"b"AND do not include"a"
Verified by: src/eviction/lru/lru.test.ts::"third write with maxEntries 2 evicts the first-written key" AND src/eviction/lru/lru.test.ts::"onHit promotes a key so the next set evicts the next-oldest"
Requirement: LfuPolicy evicts the lowest-frequency key
LfuPolicy SHALL maintain per-key frequency counters (saturating at u32) and frequency buckets for O(1) eviction. When over-capacity, onSet SHALL return the lowest-frequency keys; ties SHALL be broken by oldest first-insertion.
Scenario: lowest-frequency key is evicted under capacity pressure
- WHEN an
LfuPolicy({ maxEntries: 2 })recordsonSet("a"),onSet("b"),onHit("a"),onHit("a"),onSet("c") - THEN the victims returned by the final
onSetinclude"b"AND do not include"a"
Verified by: src/eviction/lfu/lfu.test.ts::"lowest-frequency key is evicted under capacity pressure"
Requirement: FifoPolicy evicts the oldest-inserted key regardless of access
FifoPolicy SHALL evict in strict insertion order. onHit SHALL be a no-op for ordering purposes.
Scenario: onHit does not affect FIFO eviction order
- WHEN a
FifoPolicy({ maxEntries: 2 })recordsonSet("a"),onSet("b"),onHit("a"),onSet("c") - THEN the victims returned by the final
onSetinclude"a"
Verified by: src/eviction/fifo/fifo.test.ts::"onHit does not affect FIFO eviction order"
Requirement: TtlOnlyPolicy never evicts on count or size
TtlOnlyPolicy SHALL always return an empty victim list from onSet. It SHALL still be a valid EvictionPolicy so StorageCache can be configured without size-bounded eviction while still using the policy seam for stats.
Scenario: TtlOnlyPolicy returns no victims even at extreme cardinality
- WHEN a
TtlOnlyPolicy()receives 10,000onSetcalls with arbitrarysizeBytes - THEN every call returns an empty array
Verified by: src/eviction/ttl/ttl.test.ts::"TtlOnlyPolicy never returns a victim regardless of cardinality"
Requirement: InflightMap deduplicates concurrent loaders (stampede protection)
The package SHALL export InflightMap<T> from ./stampede/inflight-map. run(key, loader, timeoutMs?) SHALL return the existing in-flight promise when one exists for key. The map entry SHALL be deleted in a finally block so subsequent calls re-invoke the loader. On timeout, run SHALL reject with StorageCacheError code === "CACHE_STORAGE.STAMPEDE_TIMEOUT" AND remove the entry. The pending getter SHALL return the current entry count. StorageCache.wrap and StorageCache.getOrSet SHALL consult an InflightMap and increment a stampedePrevented counter on every dedup hit.
Scenario: concurrent wrap calls invoke the loader exactly once
- WHEN 10 concurrent
cache.wrap("k", loader, { stampede: { timeoutMs: 1000 } })calls fire against a miss with a single loader - THEN the loader is invoked exactly once AND every caller resolves with the same value AND
getStats().stampedePrevented === 9
Scenario: loader exceeding the timeout rejects with STAMPEDE_TIMEOUT
- WHEN a loader that never resolves is driven against
wrap("k", loader, { stampede: { timeoutMs: 10 } })and the injectedFakeTimersadvance past10ms - THEN the call rejects with
StorageCacheErrorcode === "CACHE_STORAGE.STAMPEDE_TIMEOUT"
Verified by: src/storage-cache/storage-cache.test.ts::"concurrent wrap calls invoke the loader exactly once and increment stampedePrevented" AND src/stampede/inflight-map/inflight-map.test.ts::"loader exceeding the timeout rejects with STAMPEDE_TIMEOUT"
Requirement: Background TTL sweep removes expired entries between reads
The package SHALL export TtlSweeper from ./sweep/ttl-sweeper. When StorageCache is constructed with sweep: { intervalMs, batchSize? }, start() SHALL schedule recurring sweeps via the injected TimersLike.setTimeout; stop() SHALL cancel the next tick and await any in-flight page. Each sweep SHALL list keys via storage.list({ prefix: namespace, limit: batchSize }), read each header (HEADER_BYTES only), and storage.delete any entry whose expiresAt !== 0 and now >= expiresAt, emitting an expire event and incrementing the evictions counter. On any per-key error the sweeper SHALL emit a sweep-error event carrying a StorageCacheError with code === "CACHE_STORAGE.SWEEP_FAILED" AND continue with the next key.
Scenario: sweep deletes expired entries without a prior read
- WHEN a
StorageCachewithsweep: { intervalMs: 1000 }has 3 entries (two expired, one fresh per aFakeClock),start()is called, AND theFakeTimersadvance pastintervalMs - THEN
storage.exists(prefixedKey)returnsfalsefor both expired keys ANDtruefor the fresh key AND theexpirelistener was invoked twice
Scenario: sweep continues past a per-key backend error
- WHEN the sweeper encounters one key whose backend
deleterejects AND additional expired keys after it - THEN a
sweep-errorevent fires for the failing key AND every remaining expired key is still processed
Verified by: src/sweep/ttl-sweeper/ttl-sweeper.test.ts::"sweep deletes expired entries without a prior read" AND src/sweep/ttl-sweeper/ttl-sweeper.test.ts::"sweep continues past a per-key backend error"
Requirement: Stale-while-revalidate returns the stale value and refreshes in background
StorageCache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs }) SHALL return the cached value immediately when clock() >= expiresAt - staleThresholdMs && clock() < expiresAt, AND SHALL schedule loader to refresh the entry via TimersLike.queueMicrotask. Refresh errors SHALL emit a swr-refresh-failed event AND increment a swrRefreshFailures counter, AND SHALL NOT reject the original caller. When clock() >= expiresAt, the call SHALL fall through to the stampede-protected miss path.
Scenario: stale entry is served immediately and refreshed in background
- WHEN a
StorageCachewith an injectedFakeClockreturns a cached value whoseexpiresAt - clock() === 5and the caller callswrap("k", loader, { staleWhileRevalidate: true, staleThresholdMs: 10 }) - THEN the call resolves with the cached value before
loaderis awaited AND after draining the microtask queuestorage.get(prefixed)returns the loader's new bytes
Scenario: refresh failure does not surface to the caller
- WHEN a loader that rejects is scheduled by a stale-while-revalidate
wrapcall - THEN the original
wrapcall still resolves with the stale value AND aswr-refresh-failedevent fires ANDgetStats().swrRefreshFailures === 1
Verified by: src/storage-cache/storage-cache.test.ts::"stale entry is served immediately and refreshed in background" AND src/storage-cache/storage-cache.test.ts::"swr refresh failure does not surface to the caller"
Requirement: Bulk ops dispatch through the backend when available
The package SHALL extend StorageCache with mget(keys): Promise<Map<string, T>>, mset(entries): Promise<void>, and mdelete(keys): Promise<number>. When the underlying AbstractStorage exposes getMultiple and setMultiple (duck-typed via runtime check), mget and mset SHALL dispatch through the bulk methods in a single round-trip; otherwise they SHALL fall back to per-key calls via Promise.all. mdelete SHALL return the count of keys that actually existed.
Scenario: mget uses the backend bulk method when available
- WHEN a fake
AbstractStoragewhosegetMultiplereturns aMapwith two entries (and whose per-keygetthrows if invoked) is wired intoStorageCache.mget(["a", "b"]) - THEN the call returns a
Mapof size 2 AND the per-keygetwas never invoked
Scenario: mget falls back to per-key get when bulk is unavailable
- WHEN a fake
AbstractStoragethat lacksgetMultipleis wired intoStorageCache.mget(["a", "b"]) - THEN the call returns the merged result via two
getcalls AND noTypeErroris thrown
Verified by: src/storage-cache/storage-cache.test.ts::"mget uses the backend bulk method when available" AND src/storage-cache/storage-cache.test.ts::"mget falls back to per-key get when bulk is unavailable"
Requirement: getOrSet is wrap with stampede dedup enabled by default
StorageCache.getOrSet(key, loader, options?) SHALL return the cached value on hit; on miss it SHALL invoke loader exactly once across concurrent callers via the same InflightMap used by wrap, write the result with the given options, and return it. The default stampede.timeoutMs SHALL be 30000.
Scenario: getOrSet dedups concurrent loaders by default
- WHEN 5 concurrent
cache.getOrSet("k", loader)calls fire against a miss - THEN the loader is invoked exactly once AND
getStats().stampedePrevented === 4
Verified by: src/storage-cache/storage-cache.test.ts::"getOrSet dedups concurrent loaders by default"
Requirement: compareAndSet enforces optimistic version-based concurrency
StorageCache.compareAndSet(key, expectedVersion, value, options?) SHALL read the stored header, compare its version field against expectedVersion, and write only when they are equal. On mismatch it SHALL throw StorageCacheError code === "CACHE_STORAGE.CAS_CONFLICT" whose context carries { expected, observed }. On match it SHALL write the value with version = (observed + 1) >>> 0 AND return { version: newVersion }. getWithVersion(key) SHALL return { value, version } | undefined. CAS SHALL be optimistic — atomicity across processes requires backend-specific support and is out of scope.
Scenario: compareAndSet succeeds when the expected version matches
- WHEN a caller
sets"k"(which writesversion: 1), thencompareAndSet("k", 1, "new") - THEN the call resolves with
{ version: 2 }ANDgetWithVersion("k")returns{ value: "new", version: 2 }
Scenario: compareAndSet throws CAS_CONFLICT on version mismatch
- WHEN a caller
sets"k"(version 1) AND callscompareAndSet("k", 0, "new") - THEN the call throws
StorageCacheErrorcode === "CACHE_STORAGE.CAS_CONFLICT"ANDerror.context.observed === 1ANDerror.context.expected === 0
Scenario: compareAndSet on a legacy v1 entry treats version as 0
- WHEN a v1-shaped entry (FLAG_HAS_VERSION clear) is pre-seeded into the backend AND a caller calls
compareAndSet("k", 0, "new") - THEN the call resolves with
{ version: 1 }
Verified by: src/storage-cache/storage-cache.test.ts::"compareAndSet succeeds when the expected version matches" AND src/storage-cache/storage-cache.test.ts::"compareAndSet throws CAS_CONFLICT on version mismatch" AND src/storage-cache/storage-cache.test.ts::"compareAndSet on a legacy v1 entry treats version as 0"
Requirement: Configurable serialization size limits
StorageCacheOptions SHALL accept limits?: { maxKeyBytes?: number; maxValueBytes?: number } overriding the built-in defaults (1024 and 10 * 1024 * 1024). Overflow on set SHALL throw KEY_TOO_LONG or VALUE_TOO_LARGE AND emit a rejected event AND increment a rejected counter.
Scenario: a value larger than the configured limit throws VALUE_TOO_LARGE and emits rejected
- WHEN a
StorageCacheconstructed withlimits: { maxValueBytes: 16 }is asked toset("k", "a".repeat(64)) - THEN the call throws
StorageCacheErrorcode === "CACHE_STORAGE.VALUE_TOO_LARGE"AND therejectedlistener was invoked once with{ key: "k", reason: "value-too-large" }
Verified by: src/storage-cache/storage-cache.test.ts::"a value larger than the configured limit throws VALUE_TOO_LARGE and emits rejected"
Requirement: OTel spans and counters surface every public op
StorageCache SHALL open a span via trace.getTracer("@suaveplan/cache-storage") for every public method (get, set, delete, has, clear, wrap, getOrSet, compareAndSet, getWithVersion, mget, mset, mdelete). Each span SHALL set attributes cache.namespace and cache.hit where applicable; on error the span SHALL be set to SpanStatusCode.ERROR and carry error.code. The package SHALL register the counters cache.hits, cache.misses, cache.evictions, cache.stampede_prevented, cache.swr_refreshes, cache.rejected and the histogram cache.payload_bytes via metrics.getMeter("@suaveplan/cache-storage"). With no exporter registered, all instrumentation SHALL be no-op.
Scenario: get on a hit opens a span with cache.hit true and increments cache.hits
- WHEN an in-memory
SpanExporterandMetricReaderare attached, a key is set, and the caller callscache.get("k") - THEN exactly one span named
cache.getis recorded withattributes["cache.hit"] === trueAND thecache.hitscounter is1
Scenario: a thrown StorageCacheError tags the span with the error code
- WHEN an in-memory
SpanExporteris attached AND asetcall rejects withVALUE_TOO_LARGE - THEN exactly one span named
cache.setis recorded withstatus.code === SpanStatusCode.ERRORANDattributes["error.code"] === "CACHE_STORAGE.VALUE_TOO_LARGE"
Verified by: src/observability/trace/trace.test.ts::"get on a hit opens a span with cache.hit true and increments cache.hits" AND src/observability/trace/trace.test.ts::"a thrown StorageCacheError tags the span with the error code"
Requirement: Per-namespace stats via cache.namespace(sub)
StorageCache.namespace(sub: string): StorageCache<T> SHALL return a child view that prefixes every key with ${parentNamespace}:${sub}: and shares the same backend storage, codec, eviction policy, in-flight map, and stats collector. getStats({ namespace }) SHALL return counters scoped to the given namespace. Constructing two child views with the same sub against the same parent SHALL throw StorageCacheError code === "CACHE_STORAGE.NAMESPACE_CONFLICT".
Scenario: child namespace stats are tracked independently from the parent
- WHEN a parent cache and
child = parent.namespace("users")each record one hit - THEN
parent.getStats({ namespace: "users" }).hits === 1ANDparent.getStats({ namespace: "" }).hits === 1
Scenario: duplicate child namespace throws NAMESPACE_CONFLICT
- WHEN a caller calls
parent.namespace("users")twice without disposing the first - THEN the second call throws
StorageCacheErrorcode === "CACHE_STORAGE.NAMESPACE_CONFLICT"
Verified by: src/stats/namespace-stats/namespace-stats.test.ts::"child namespace stats are tracked independently from the parent" AND src/storage-cache/storage-cache.test.ts::"duplicate child namespace throws NAMESPACE_CONFLICT"
Requirement: Multi-backend conformance harness
The package SHALL export runStorageCacheConformance({ createStorage, name }) from ./testing/conformance. The harness SHALL invoke the full StorageCache public surface (every method, every eviction policy, stampede, SWR, CAS) against a caller-supplied AbstractStorage factory AND SHALL assert that every one of the 12 CACHE_STORAGE_CODES is reachable from at least one call site. The harness SHALL fail loudly when given a deliberately broken AbstractStorage factory.
Scenario: harness passes for MemoryStorage from @suaveplan/storage
- WHEN the test suite calls
runStorageCacheConformance({ createStorage: () => new MemoryStorage(), name: "memory" }) - THEN every assertion passes
Scenario: harness fails on a deliberately broken storage factory
- WHEN the test suite supplies a fake
AbstractStoragewhosesetis a no-op (drops every write) - THEN at least one harness assertion fails
Verified by: src/testing/conformance/conformance.test.ts::"harness passes for MemoryStorage from @suaveplan/storage" AND src/testing/conformance/conformance.test.ts::"harness fails on a deliberately broken storage factory"
Requirement: CACHE_STORAGE registry covers every failure path with 12 codes
CACHE_STORAGE_CODES SHALL be defined via defineCodes("CACHE_STORAGE", [...]) with exactly these 12 codes: ENTRY_CORRUPT, CODEC_ERROR, BACKEND_ERROR, KEY_TOO_LONG, VALUE_TOO_LARGE, STAMPEDE_TIMEOUT, CAS_CONFLICT, SWEEP_FAILED, EVICTION_FAILED, CODEC_UNSUPPORTED_TYPE, CODEC_UNKNOWN_TAG, NAMESPACE_CONFLICT. Every code SHALL be reachable from at least one test in this package.
Scenario: every CACHE_STORAGE code is reachable
- WHEN the test suite runs
- THEN for every entry in
CACHE_STORAGE_CODES, at least one assertion exercises a real call site that produces that code
Verified by: src/errors/errors.test.ts::"every CACHE_STORAGE code is reachable from a real call site"
Requirement: Default behaviour preserved — zero-config StorageCache is unchanged
A zero-config new StorageCache({ storage }) SHALL behave exactly as today: JsonCodec default; no eviction; no background sweep; no stampede dedup; no SWR; no namespace; existing MAX_KEY_LENGTH and MAX_VALUE_BYTES limits. The bytes written past HEADER_BYTES for a JSON-serialisable value SHALL equal new TextEncoder().encode(JSON.stringify(value)) byte-for-byte.
Scenario: zero-config default codec is JsonCodec
- WHEN a caller constructs
new StorageCache({ storage: new MemoryStorage() }), callsset("k", { ok: 1 }), and inspects the bytes written pastHEADER_BYTES - THEN those bytes equal
new TextEncoder().encode(JSON.stringify({ ok: 1 }))
Scenario: zero-config wrap matches AbstractCache.wrap semantics
- WHEN a caller invokes
cache.wrap("k", loader)withoutstampedeorstaleWhileRevalidateoptions - THEN the call returns the same value as the parent
AbstractCache.wrapwould AND the loader is invoked once per miss (no dedup)
Verified by: src/storage-cache/storage-cache.test.ts::"zero-config default codec is JsonCodec — bytes past the header are UTF-8 JSON" AND src/storage-cache/storage-cache.test.ts::"zero-config wrap matches AbstractCache.wrap semantics"
Requirement: Subpath exports cover every new feature module
package.json SHALL expose subpath exports for every new feature module so consumers can tree-shake: ./codec/structured-clone, ./codec/conformance, ./eviction, ./eviction/policy, ./eviction/lru, ./eviction/lfu, ./eviction/fifo, ./eviction/ttl, ./stampede/inflight-map, ./sweep/ttl-sweeper, ./swr/swr-scheduler, ./observability/metrics, ./observability/trace, ./stats/namespace-stats, ./testing/conformance. Every new feature barrel SHALL use explicit named re-exports (no export *).
Scenario: every new subpath resolves to the documented class
- WHEN a consumer imports each of the new subpaths and references the documented export
- THEN every import resolves AND the referenced class/function exists
Verified by: src/storage-cache/storage-cache.test.ts::"every new subpath export resolves to the documented class"
Requirement: Tier purity — universal preserved
The package SHALL NOT import from node:*, bun:*, or any DOM-only API. Every new file added by this change SHALL preserve this property. The package SHALL continue to run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
Scenario: tier-purity grep finds no forbidden imports after the change
- WHEN
grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-storage/src/is run - THEN the output is empty
Verified by: src/storage-cache/storage-cache.test.ts::"src tree contains no node: or bun: imports"
MODIFIED Requirements
Requirement: 32-byte fixed-width metadata header
StorageCache SHALL prepend a 32-byte fixed-width header to every stored value. The header layout SHALL be: bytes 0..7 createdAt u64 BE; bytes 8..15 expiresAt u64 BE (0 means no expiry); bytes 16..19 flags u32 BE; bytes 20..23 hitCount u32 BE; bytes 24..27 version u32 BE (v2 — formerly reserved); bytes 28..31 reserved (zero). flags bit 2 (FLAG_HAS_VERSION = 1 << 2) SHALL be set when bytes 24..27 carry a meaningful version. The package SHALL export HEADER_BYTES = 32, encodeHeader(meta) → Uint8Array, decodeHeader(bytes) → HeaderMeta, and FLAG_HAS_VERSION.
Scenario: header round-trips every field including version
- WHEN a caller calls
decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 7 })) - THEN the result is
{ createdAt: 1, expiresAt: 2, flags: FLAG_HAS_METADATA | FLAG_HAS_VERSION, hitCount: 4, version: 7 }
Scenario: decodeHeader on a buffer shorter than 32 bytes throws ENTRY_CORRUPT
- WHEN a caller calls
decodeHeader(new Uint8Array(16)) - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.ENTRY_CORRUPT"
Verified by: src/header/header.test.ts::"header round-trips every field including the version field" AND src/header/header.test.ts::"decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES"
Requirement: StorageCache extends AbstractCache
The package SHALL provide StorageCache<T> extends AbstractCache<T> (from @suaveplan/cache). The class SHALL implement every method on AbstractCacheShape<T>: get, set, delete, clear, has, getStats, getMultiple, setMultiple, wrap. The class SHALL additionally expose mget, mset, mdelete, getOrSet, compareAndSet, getWithVersion, namespace, start, and stop. The constructor SHALL accept { storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string; eviction?: EvictionConfig; sweep?: { intervalMs: number; batchSize?: number }; limits?: { maxKeyBytes?: number; maxValueBytes?: number }; timers?: TimersLike; tracer?: Tracer; metrics?: Meter }. Every option beyond storage SHALL default such that today's behaviour is preserved exactly.
Scenario: instance is an AbstractCache and exposes the new public surface
- WHEN a caller constructs
new StorageCache({ storage: new MemoryStorage() }) - THEN the instance is
instanceof AbstractCacheAND everyAbstractCacheShape<T>method exists AND the methodsmget,mset,mdelete,getOrSet,compareAndSet,getWithVersion,namespace,start,stopall exist
Verified by: src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes both the AbstractCacheShape surface and the extended surface"