11 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: 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 constructor SHALL accept { storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string } with codec defaulting to JsonCodec<T> and defaultTtl defaulting to 0 (no expiry).
Scenario: instance is an AbstractCache and an AbstractCacheShape
- WHEN a caller constructs
new StorageCache({ storage: new MemoryStorage() }) - THEN the instance is
instanceof AbstractCacheAND everyAbstractCacheShape<T>method exists on it
Scenario: inherited wrap delegates to set on miss
- WHEN a caller invokes
cache.wrap("k", async () => 42)on a miss - THEN the loader is called exactly once AND a subsequent
cache.get("k")returns42
Verified by: src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes the AbstractCacheShape surface" AND src/storage-cache/storage-cache.test.ts::"inherited wrap caches the loader result on a miss"
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..31 reserved (zero). The package SHALL export HEADER_BYTES = 32, encodeHeader(meta) → Uint8Array, and decodeHeader(bytes) → HeaderMeta.
Scenario: header round-trips every field
- WHEN a caller calls
decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 })) - THEN the result is
{ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 }
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.encode then header.decode round-trips every field" AND src/header/header.test.ts::"decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES"
Requirement: TTL expiration is lazy on read
StorageCache.get SHALL parse the stored header and compare expiresAt against clock(). When expiresAt !== 0 && clock() >= expiresAt, get SHALL invoke storage.delete(prefixedKey) AND return undefined. When expiresAt === 0, the entry SHALL never expire. set(key, value, { ttl }) with ttl > 0 SHALL compute expiresAt = clock() + ttl. set with ttl === undefined SHALL use defaultTtl.
Scenario: get returns undefined and deletes when entry is expired
- WHEN a caller
sets a key withttl: 10(ms), advances an injected clock past expiry, thengets the key - THEN the call returns
undefinedANDstorage.exists(prefixedKey)returnsfalse
Scenario: entry with expiresAt 0 never expires
- WHEN a caller
sets a key with nottlanddefaultTtl: 0, then advances the clock by 10 days, thengets the key - THEN the call returns the stored value
Verified by: src/storage-cache/storage-cache.test.ts::"get returns undefined and deletes when entry has expired" AND src/storage-cache/storage-cache.test.ts::"entry with no ttl never expires"
Requirement: ENTRY_CORRUPT on short blob from underlying storage
When storage.get(prefixedKey) returns a Uint8Array shorter than HEADER_BYTES, StorageCache.get SHALL throw StorageCacheError with code === "CACHE_STORAGE.ENTRY_CORRUPT" and key set to the original (unprefixed) key.
Scenario: short blob surfaces as ENTRY_CORRUPT
- WHEN a fake
AbstractStoragereturnsnew Uint8Array(16)forget(prefixedKey)AND the caller callscache.get(originalKey) - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.ENTRY_CORRUPT"ANDerror.key === originalKey
Verified by: src/storage-cache/storage-cache.test.ts::"get throws ENTRY_CORRUPT when underlying storage returns fewer than HEADER_BYTES bytes"
Requirement: Codec decouples T from byte storage
The package SHALL declare Codec<T> with encode(value: T): Uint8Array and decode(bytes: Uint8Array): T. The package SHALL export two codecs: IdentityCodec (constrains T = Uint8Array; pass-through identity) and JsonCodec<T> (UTF-8 JSON via TextEncoder/TextDecoder). Decode failure SHALL throw StorageCacheError with code === "CACHE_STORAGE.CODEC_ERROR". JsonCodec SHALL be the default codec used by StorageCache when none is provided.
Scenario: JsonCodec round-trips a structured value
- WHEN a caller encodes
{ user: "alice", roles: ["admin"] }AND then decodes the result - THEN the decoded value deep-equals the input
Scenario: JsonCodec.decode throws CODEC_ERROR on malformed bytes
- WHEN a caller calls
new JsonCodec().decode(new Uint8Array([0xff, 0xfe, 0xfd])) - THEN the call throws
StorageCacheErrorwithcode === "CACHE_STORAGE.CODEC_ERROR"
Scenario: IdentityCodec passes Uint8Array through unchanged
- WHEN a caller calls
new IdentityCodec().decode(new IdentityCodec().encode(bytes)) - THEN the result is byte-identical to
bytes
Verified by: src/codec/json.test.ts::"round-trips a structured value through encode then decode" AND src/codec/json.test.ts::"decode throws CODEC_ERROR on malformed bytes" AND src/codec/identity.test.ts::"encode then decode preserves the byte sequence"
Requirement: CACHE_STORAGE error registry
The package SHALL declare CACHE_STORAGE_CODES via defineCodes("CACHE_STORAGE", [...]) with exactly these 5 codes: ENTRY_CORRUPT, CODEC_ERROR, BACKEND_ERROR, KEY_TOO_LONG, VALUE_TOO_LARGE. The package SHALL declare StorageCacheError extends SuaveplanError<CacheStorageCode> with optional key discriminant. Every code SHALL be reachable from at least one test in this package.
Scenario: every declared 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: Module augmentation registers "storage" provider
The package SHALL declare module "@suaveplan/cache" and augment CacheProviderRegistry with storage: typeof StorageCache. The package SHALL side-effect-import a registry-augment module that calls registerCacheProvider("storage", StorageCache) exactly once via an idempotent guard. After importing @suaveplan/cache-storage, hasCacheProvider("storage") SHALL return true AND createCache("storage", { storage }) SHALL return a StorageCache.
Scenario: side-effect import registers the "storage" provider
- WHEN the consumer imports
@suaveplan/cache-storageAND callshasCacheProvider("storage") - THEN the result is
true
Scenario: createCache("storage", config) returns a StorageCache instance
- WHEN a caller calls
createCache("storage", { storage: new MemoryStorage() }) - THEN the result is
instanceof StorageCacheANDinstanceof AbstractCache
Verified by: src/registry-augment/registry-augment.test.ts::"hasCacheProvider returns true after import" AND src/registry-augment/registry-augment.test.ts::"createCache(\"storage\", ...) returns a StorageCache instance"
Requirement: Namespacing prefixes every stored key
When namespace is provided in the constructor and is non-empty, StorageCache SHALL prefix every key passed to the underlying AbstractStorage with ${namespace}:. The unprefixed key SHALL be carried on any thrown StorageCacheError via the key discriminant.
Scenario: namespaced set writes to the prefixed key
- WHEN a
StorageCacheconstructed withnamespace: "users"callsset("alice", value) - THEN the underlying
storage.setis invoked with key"users:alice"
Verified by: src/storage-cache/storage-cache.test.ts::"namespace prefixes keys before delegating to the underlying storage"
Requirement: Lifecycle events emitted when EventBusLike provided
When events is provided in the constructor, StorageCache SHALL emit hit, miss, set, delete, clear, and expire topics with payloads matching the CacheEvents map exported from @suaveplan/cache. When events is not provided, no event work SHALL be performed (zero-cost when unused).
Scenario: emits hit on get after set
- WHEN a caller subscribes to
hit,sets a key, thengets it - THEN the
hitlistener is invoked exactly once with{ key }
Scenario: emits miss on get of an unknown key
- WHEN a caller subscribes to
missand callsget("missing") - THEN the
misslistener is invoked exactly once with{ key: "missing" }
Verified by: src/storage-cache/storage-cache.test.ts::"emits hit event when get returns a stored value" AND src/storage-cache/storage-cache.test.ts::"emits miss event when get returns undefined"
Requirement: Stats counters via getStats
StorageCache.getStats() SHALL return a CacheStats with hits, misses, evictions, and hitRate reflecting client-side counters; size SHALL be a best-effort count maintained via set/delete. Counters SHALL be monotonically non-decreasing across the cache's lifetime; clear() SHALL reset size to 0 but SHALL NOT reset hits/misses/evictions.
Scenario: hitRate reflects hits over hits-plus-misses
- WHEN a caller records 7 hits and 3 misses
- THEN
getStats().hitRate === 0.7
Verified by: src/storage-cache/storage-cache.test.ts::"hitRate computes hits over hits-plus-misses"
Requirement: Tier purity — universal
The package SHALL NOT import from node:*, bun:*, or any DOM-only API. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
Scenario: tier-purity grep finds no forbidden imports
- 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"