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

11 KiB

Capability Spec Delta: cache-storage (cache-storage)

Package: @suaveplan/cache-storage Status: SHIPPED

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 AbstractCache AND every AbstractCacheShape<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") 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 sets a key with ttl: 10 (ms), advances an injected clock past expiry, then gets the key
  • THEN the call returns undefined AND storage.exists(prefixedKey) returns false

Scenario: entry with expiresAt 0 never expires

  • WHEN a caller sets a key with no ttl and defaultTtl: 0, then advances the clock by 10 days, then gets 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<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 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<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-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, sets a key, then gets 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/cache/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"