# 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 extends AbstractCache` (from `@suaveplan/cache`). The class SHALL implement every method on `AbstractCacheShape`: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. The constructor SHALL accept `{ storage: AbstractStorage; codec?: Codec; defaultTtl?: number; clock?: Clock; events?: EventBusLike; namespace?: string }` with `codec` defaulting to `JsonCodec` 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 AbstractCache` AND every `AbstractCacheShape` 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")` returns `42` **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 `StorageCacheError` with `code === "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 `set`s a key with `ttl: 10` (ms), advances an injected clock past expiry, then `get`s the key - **THEN** the call returns `undefined` AND `storage.exists(prefixedKey)` returns `false` #### Scenario: entry with expiresAt 0 never expires - **WHEN** a caller `set`s a key with no `ttl` and `defaultTtl: 0`, then advances the clock by 10 days, then `get`s 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 `AbstractStorage` returns `new Uint8Array(16)` for `get(prefixedKey)` AND the caller calls `cache.get(originalKey)` - **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"` AND `error.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` 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` (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 `StorageCacheError` with `code === "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` 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-storage` AND calls `hasCacheProvider("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 StorageCache` AND `instanceof 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 `StorageCache` constructed with `namespace: "users"` calls `set("alice", value)` - **THEN** the underlying `storage.set` is 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`, `set`s a key, then `get`s it - **THEN** the `hit` listener is invoked exactly once with `{ key }` #### Scenario: emits miss on get of an unknown key - **WHEN** a caller subscribes to `miss` and calls `get("missing")` - **THEN** the `miss` listener 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"`