Files
2026-05-27 23:29:53 +01:00

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 Date AND 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, has size === 2, AND iterating yields keys/values deep-equal to the input (the second value being instanceof Set with members 1 and 2)

Scenario: round-trip preserves Uint8Array byte identity

  • WHEN a caller round-trips new Uint8Array([0, 1, 127, 128, 255])
  • THEN the result is instanceof Uint8Array AND 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 ** 200n and -(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, and Uint8Array boundaries

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 through decode from @suaveplan/cbor with default tag handling
  • THEN the decoded top-level value is a Tagged instance with .tag === 0 AND .value is 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 through decode from @suaveplan/cbor with default tag handling
  • THEN the decoded top-level value is a Tagged instance 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 StorageCacheError with code === "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 StorageCacheError with code === "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") via encode from @suaveplan/cbor AND passes the resulting bytes to codec.decode
  • THEN the call throws StorageCacheError with code === "CACHE_STORAGE.CODEC_UNKNOWN_TAG" AND error.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 StorageCacheError with code === "CACHE_STORAGE.CODEC_ERROR" AND error.cause is the underlying Error

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 decode always returns a fixed null sentinel
  • 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 version equals 42

Scenario: legacy v1 header decodes as version 0

  • WHEN a caller constructs a 32-byte buffer with FLAG_HAS_VERSION clear AND bytes 24..31 all zero AND calls decodeHeader(buffer)
  • THEN the decoded version equals 0

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 }) (no eviction option) AND writes 1,000 entries
  • THEN every entry remains readable via cache.get AND getStats().evictions === 0

Scenario: EVICTION_FAILED wraps a backend delete error during eviction

  • WHEN a fake AbstractStorage whose delete rejects is wired into StorageCache with an LruPolicy({ maxEntries: 1 }) AND a second set triggers an eviction
  • THEN the call throws StorageCacheError with code === "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 }) records onSet("a"), onSet("b"), onSet("c")
  • THEN the victims returned by the third onSet include "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 onSet include "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 }) records onSet("a"), onSet("b"), onHit("a"), onHit("a"), onSet("c")
  • THEN the victims returned by the final onSet include "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 }) records onSet("a"), onSet("b"), onHit("a"), onSet("c")
  • THEN the victims returned by the final onSet include "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,000 onSet calls with arbitrary sizeBytes
  • 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 injected FakeTimers advance past 10 ms
  • THEN the call rejects with StorageCacheError code === "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 StorageCache with sweep: { intervalMs: 1000 } has 3 entries (two expired, one fresh per a FakeClock), start() is called, AND the FakeTimers advance past intervalMs
  • THEN storage.exists(prefixedKey) returns false for both expired keys AND true for the fresh key AND the expire listener was invoked twice

Scenario: sweep continues past a per-key backend error

  • WHEN the sweeper encounters one key whose backend delete rejects AND additional expired keys after it
  • THEN a sweep-error event 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 StorageCache with an injected FakeClock returns a cached value whose expiresAt - clock() === 5 and the caller calls wrap("k", loader, { staleWhileRevalidate: true, staleThresholdMs: 10 })
  • THEN the call resolves with the cached value before loader is awaited AND after draining the microtask queue storage.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 wrap call
  • THEN the original wrap call still resolves with the stale value AND a swr-refresh-failed event fires AND getStats().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 AbstractStorage whose getMultiple returns a Map with two entries (and whose per-key get throws if invoked) is wired into StorageCache.mget(["a", "b"])
  • THEN the call returns a Map of size 2 AND the per-key get was never invoked

Scenario: mget falls back to per-key get when bulk is unavailable

  • WHEN a fake AbstractStorage that lacks getMultiple is wired into StorageCache.mget(["a", "b"])
  • THEN the call returns the merged result via two get calls AND no TypeError is 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 writes version: 1), then compareAndSet("k", 1, "new")
  • THEN the call resolves with { version: 2 } AND getWithVersion("k") returns { value: "new", version: 2 }

Scenario: compareAndSet throws CAS_CONFLICT on version mismatch

  • WHEN a caller sets "k" (version 1) AND calls compareAndSet("k", 0, "new")
  • THEN the call throws StorageCacheError code === "CACHE_STORAGE.CAS_CONFLICT" AND error.context.observed === 1 AND error.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 StorageCache constructed with limits: { maxValueBytes: 16 } is asked to set("k", "a".repeat(64))
  • THEN the call throws StorageCacheError code === "CACHE_STORAGE.VALUE_TOO_LARGE" AND the rejected listener 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 SpanExporter and MetricReader are attached, a key is set, and the caller calls cache.get("k")
  • THEN exactly one span named cache.get is recorded with attributes["cache.hit"] === true AND the cache.hits counter is 1

Scenario: a thrown StorageCacheError tags the span with the error code

  • WHEN an in-memory SpanExporter is attached AND a set call rejects with VALUE_TOO_LARGE
  • THEN exactly one span named cache.set is recorded with status.code === SpanStatusCode.ERROR AND attributes["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 === 1 AND parent.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 StorageCacheError code === "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 AbstractStorage whose set is 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() }), calls set("k", { ok: 1 }), and inspects the bytes written past HEADER_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) without stampede or staleWhileRevalidate options
  • THEN the call returns the same value as the parent AbstractCache.wrap would 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 StorageCacheError with code === "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 AbstractCache AND every AbstractCacheShape<T> method exists AND the methods mget, mset, mdelete, getOrSet, compareAndSet, getWithVersion, namespace, start, stop all exist

Verified by: src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes both the AbstractCacheShape surface and the extended surface"