21 KiB
Capability Spec: storage-plugin-cache
Package: @suaveplan/storage-plugin-cache
Every requirement carries a
**Verified by:**line.
Requirements
Requirement: CachedStorage extends AbstractStorage and cachedStorage(inner, options) factory
The package SHALL export a CachedStorage class that extends AbstractStorage from @suaveplan/storage and a cachedStorage(inner, options): CachedStorage factory. The class SHALL implement every abstract method on the base — get, set, delete, exists, list, clear, getWithMetadata, getMetadata, getSize, getItemSize, getPartial, setPartial, createReadStream, createWriteStream — and SHALL expose readonly cache: AbstractCache<Uint8Array>, readonly inner: AbstractStorage, and getCacheStats(): CachedStorageStats.
Scenario: instance is structurally an AbstractStorage
- WHEN
cachedStorage(innerStorage, { cache })is called against a realAbstractStoragesubclass - THEN the returned instance is
instanceof AbstractStorageANDinstanceof CachedStorageAND every abstract method onAbstractStorageis callable
Scenario: cache and inner are exposed as readonly properties
- WHEN
cachedStorage(innerStorage, { cache })returns aCachedStorage - THEN
wrapper.cache === cache(same reference) ANDwrapper.inner === innerStorage(same reference)
Verified by: src/cached-storage/cached-storage.test.ts::"instance is structurally an AbstractStorage and a CachedStorage" AND src/cached-storage/cached-storage.test.ts::"cache and inner are exposed as readonly properties"
Requirement: Construction-time validation of required options
The cachedStorage(inner, options) factory and the new CachedStorage(inner, options) constructor SHALL throw STORAGE_CACHE.INNER_MISSING synchronously when inner is nullish, and STORAGE_CACHE.INVALID_OPTIONS synchronously when options.cache is missing or is not a function-typed get/set/delete carrier. The validation MUST NOT be deferred to first use.
Scenario: nullish inner throws INNER_MISSING
- WHEN
cachedStorage(null as unknown as AbstractStorage, { cache })is called - THEN the call throws
CachedStorageErrorwithcode === "STORAGE_CACHE.INNER_MISSING"
Scenario: missing cache throws INVALID_OPTIONS
- WHEN
cachedStorage(innerStorage, {} as unknown as CachedStorageOptions)is called - THEN the call throws
CachedStorageErrorwithcode === "STORAGE_CACHE.INVALID_OPTIONS"
Scenario: cache without get/set/delete methods throws INVALID_OPTIONS
- WHEN
cachedStorage(innerStorage, { cache: { name: "x" } as unknown as AbstractCache<Uint8Array> })is called - THEN the call throws
CachedStorageErrorwithcode === "STORAGE_CACHE.INVALID_OPTIONS"
Verified by: src/factory/factory.test.ts::"throws INNER_MISSING when inner is nullish" AND src/factory/factory.test.ts::"throws INVALID_OPTIONS when cache is missing" AND src/factory/factory.test.ts::"throws INVALID_OPTIONS when cache lacks get/set/delete"
Requirement: get returns cached bytes on hit without consulting inner
When get(key, signal?) is called and cache.get(cacheKey) resolves to a Uint8Array, the wrapper SHALL return those bytes WITHOUT invoking inner.get. The cache key SHALL be ${namespace ?? "storage"}::${key}.
Scenario: cache hit short-circuits inner
- WHEN
wrapper.get("clip.mov")is called andcache.get("storage::clip.mov")resolves to bytes - THEN the returned value equals those bytes AND
inner.getis NOT invoked ANDgetCacheStats().hits === 1
Scenario: custom namespace appears in cache key
- WHEN the wrapper is constructed with
{ cache, namespace: "media" }andwrapper.get("clip.mov")is called - THEN
cache.get("media::clip.mov")is invoked exactly once
Verified by: src/cached-storage/cached-storage.get.test.ts::"hit short-circuits inner and returns cached bytes" AND src/cached-storage/cached-storage.get.test.ts::"uses the configured namespace in the cache key"
Requirement: get populates the cache on miss
When cache.get(cacheKey) returns undefined, the wrapper SHALL invoke inner.get(key, signal), and when the inner returns a Uint8Array AND shouldCache(key, bytes) returns true, SHALL call cache.set(cacheKey, bytes). The default shouldCache SHALL return true when bytes.byteLength <= 16 * 1024 * 1024 (16 MiB). When the inner returns undefined, the wrapper SHALL NOT cache anything (no negative caching).
Scenario: miss invokes inner and populates the cache
- WHEN
wrapper.get("x.bin")is called,cache.getreturnsundefined, andinner.getreturnsbytes - THEN
inner.get("x.bin", signal)is invoked exactly once ANDcache.set("storage::x.bin", bytes)is invoked exactly once ANDgetCacheStats().misses === 1ANDgetCacheStats().populations === 1
Scenario: shouldCache predicate gates population
- WHEN the wrapper is constructed with
{ cache, shouldCache: () => false }andwrapper.get("x.bin")is called on a miss - THEN
inner.getis invoked exactly once ANDcache.setis NOT invoked ANDgetCacheStats().populations === 0
Scenario: default maxEntryBytes is 16 MiB
- WHEN the wrapper is constructed without
shouldCacheormaxEntryBytes, andinner.getresolves to aUint8Arrayof16 * 1024 * 1024 + 1bytes - THEN
cache.setis NOT invoked ANDgetCacheStats().populations === 0
Scenario: undefined from inner is not cached
- WHEN
wrapper.get("missing.bin")is called andinner.getreturnsundefined - THEN
cache.setis NOT invoked AND the wrapper returnsundefined
Verified by: src/cached-storage/cached-storage.get.test.ts::"miss invokes inner and populates the cache" AND src/cached-storage/cached-storage.get.test.ts::"shouldCache predicate gates population" AND src/cached-storage/cached-storage.get.test.ts::"default maxEntryBytes is 16 MiB" AND src/cached-storage/cached-storage.get.test.ts::"undefined from inner is not cached"
Requirement: Cache failures never propagate
When cache.get, cache.set, or cache.delete throws or rejects, the wrapper SHALL increment getCacheStats().errors, SHALL record the failure via the OTel storage_cache.errors_total{namespace, op} counter, and SHALL NOT propagate the failure to the consumer. On cache.get failure during a get(key) call, the wrapper SHALL fall through to inner.get and return its result. On cache.set failure during population, the wrapper SHALL still return the bytes from inner.get. On cache.delete failure during invalidation, the wrapper SHALL still return the inner's result.
Scenario: cache.get rejection falls through to inner
- WHEN
wrapper.get("x")is called andcache.getrejects withnew Error("redis down") - THEN
inner.getis invoked AND the returned bytes matchinner.get's result AND the consumer sees no error ANDgetCacheStats().errors === 1
Scenario: cache.set rejection does not propagate
- WHEN
wrapper.get("x")is called andcache.setrejects afterinner.getsucceeds - THEN
wrapper.getresolves withinner.get's bytes ANDgetCacheStats().errors === 1ANDgetCacheStats().populations === 0
Scenario: cache.delete rejection on invalidation does not propagate
- WHEN
wrapper.set("x", bytes)succeeds andcache.deleterejects - THEN
wrapper.setresolves successfully ANDgetCacheStats().errors === 1ANDgetCacheStats().invalidations === 0
Verified by: src/cached-storage/cached-storage.get.test.ts::"cache.get rejection falls through to inner" AND src/cached-storage/cached-storage.get.test.ts::"cache.set rejection does not propagate" AND src/cached-storage/cached-storage.write.test.ts::"cache.delete rejection on invalidation does not propagate"
Requirement: set invalidates the cache entry post-success
When set(key, value, signal?) is called and invalidateOnWrite !== false, the wrapper SHALL invoke inner.set(key, value, signal) first; on successful resolution, SHALL call cache.delete(cacheKey). When inner.set rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating the cache.
Scenario: successful set invalidates the cache entry
- WHEN
wrapper.set("x.bin", bytes)succeeds - THEN
cache.delete("storage::x.bin")is invoked exactly once ANDgetCacheStats().invalidations === 1
Scenario: failed set does not invalidate
- WHEN
wrapper.set("x.bin", bytes)rejects becauseinner.setthrew - THEN
cache.deleteis NOT invoked ANDgetCacheStats().invalidations === 0
Scenario: invalidateOnWrite:false skips invalidation
- WHEN the wrapper is constructed with
{ cache, invalidateOnWrite: false }andwrapper.set("x.bin", bytes)succeeds - THEN
cache.deleteis NOT invoked
Verified by: src/cached-storage/cached-storage.write.test.ts::"successful set invalidates the cache entry" AND src/cached-storage/cached-storage.write.test.ts::"failed set does not invalidate" AND src/cached-storage/cached-storage.write.test.ts::"invalidateOnWrite:false skips invalidation"
Requirement: setPartial invalidates the whole entry post-success
When setPartial(key, offset, value, signal?) is called, the wrapper SHALL invoke inner.setPartial(...) first; on success, when invalidateOnWrite !== false, SHALL call cache.delete(cacheKey) for the whole key (the cache entry for that key is now stale regardless of the partial write's range). When inner.setPartial rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating.
Scenario: setPartial invalidates the whole cache entry
- WHEN
wrapper.setPartial("x.bin", 100, bytes)succeeds - THEN
cache.delete("storage::x.bin")is invoked exactly once ANDgetCacheStats().invalidations === 1
Scenario: failed setPartial does not invalidate
- WHEN
wrapper.setPartial("x.bin", 100, bytes)rejects becauseinner.setPartialthrew - THEN
cache.deleteis NOT invoked
Verified by: src/cached-storage/cached-storage.write.test.ts::"setPartial invalidates the whole cache entry" AND src/cached-storage/cached-storage.write.test.ts::"failed setPartial does not invalidate"
Requirement: delete invalidates the cache entry post-success
When delete(key, signal?) is called and invalidateOnDelete !== false, the wrapper SHALL invoke inner.delete(key, signal) first; on successful resolution (regardless of whether inner.delete returned true or false), SHALL call cache.delete(cacheKey) to clear any stale entry. When inner.delete rejects, the wrapper SHALL propagate the rejection WITHOUT invalidating.
Scenario: successful delete invalidates the cache entry
- WHEN
wrapper.delete("x.bin")resolves - THEN
cache.delete("storage::x.bin")is invoked exactly once ANDgetCacheStats().invalidations === 1
Scenario: failed delete does not invalidate
- WHEN
wrapper.delete("x.bin")rejects becauseinner.deletethrew - THEN
cache.deleteis NOT invoked AND the rejection propagates
Scenario: invalidateOnDelete:false skips invalidation
- WHEN the wrapper is constructed with
{ cache, invalidateOnDelete: false }andwrapper.delete("x.bin")resolves totrue - THEN
cache.deleteis NOT invoked
Verified by: src/cached-storage/cached-storage.delete.test.ts::"successful delete invalidates the cache entry" AND src/cached-storage/cached-storage.delete.test.ts::"failed delete does not invalidate" AND src/cached-storage/cached-storage.delete.test.ts::"invalidateOnDelete:false skips invalidation"
Requirement: clear invalidates only the namespaced subset of the cache
When clear(signal?) is called and invalidateOnClear !== false, the wrapper SHALL invoke inner.clear(signal) first; on success, SHALL iterate the cache's keys (when cache.keys() is available) and delete every key starting with ${namespace}::. When the underlying AbstractCache does NOT expose keys(), the wrapper SHALL log a warning via options.logger?.warn?.(...) and SHALL NOT invoke cache.clear() (doing so would obliterate every other wrapper's entries sharing the cache).
Scenario: clear evicts namespaced entries only when cache supports keys()
- WHEN the underlying cache has both
"storage::a"and"other::b"andwrapper.clear()is called with namespace"storage" - THEN
cache.delete("storage::a")is invoked ANDcache.delete("other::b")is NOT invoked
Scenario: clear logs a warning when cache lacks keys()
- WHEN the underlying
AbstractCachedoes not implementkeys()andwrapper.clear()is called - THEN
options.logger.warnis invoked at least once with a message naming[storage-cache]ANDcache.clear()is NOT invoked ANDgetCacheStats().invalidations === 0
Scenario: invalidateOnClear:false skips namespaced cleanup
- WHEN the wrapper is constructed with
{ cache, invalidateOnClear: false }andwrapper.clear()is called - THEN
cache.delete/cache.clear/cache.keysare NOT invoked
Verified by: src/cached-storage/cached-storage.clear.test.ts::"clear evicts namespaced entries only when cache supports keys()" AND src/cached-storage/cached-storage.clear.test.ts::"clear logs a warning when cache lacks keys()" AND src/cached-storage/cached-storage.clear.test.ts::"invalidateOnClear:false skips namespaced cleanup"
Requirement: Passthrough surface — exists, list, metadata, partial, streams
The wrapper SHALL implement exists, list, getWithMetadata, getMetadata, getSize, getItemSize, getPartial, createReadStream, and createWriteStream as pure delegates to the inner storage. None of these SHALL consult or mutate the cache (this iteration intentionally caches whole-object reads only).
Scenario: exists delegates to inner without touching the cache
- WHEN
wrapper.exists("x")is called andinner.exists("x")returnstrue - THEN the wrapper returns
trueANDcache.get/cache.set/cache.deleteare NOT invoked
Scenario: list delegates to inner without touching the cache
- WHEN
wrapper.list({ prefix: "a/" })is called - THEN the returned value equals
inner.list({ prefix: "a/" })'s result ANDcache.get/cache.setare NOT invoked
Scenario: getMetadata delegates to inner without touching the cache
- WHEN
wrapper.getMetadata("x")is called - THEN the returned value equals
inner.getMetadata("x")'s result ANDcache.getis NOT invoked
Scenario: getPartial delegates to inner without touching the cache
- WHEN
wrapper.getPartial("x", 100, 200)is called - THEN the returned slice equals
inner.getPartial("x", 100, 200)'s result ANDcache.getis NOT invoked
Scenario: createReadStream delegates to inner without touching the cache
- WHEN
wrapper.createReadStream("x")is called - THEN the returned stream is the same reference returned by
inner.createReadStream("x")ANDcache.getis NOT invoked
Scenario: createWriteStream delegates to inner without touching the cache
- WHEN
wrapper.createWriteStream("x")is called - THEN the returned writer is the same reference returned by
inner.createWriteStream("x")ANDcache.deleteis NOT invoked at construction time (the cache is invalidated only when the writer's underlying write commits viaset, which is out of scope here — see Implementation Notes in design.md)
Verified by: src/cached-storage/cached-storage.passthrough.test.ts::"exists delegates to inner without touching the cache" AND src/cached-storage/cached-storage.passthrough.test.ts::"list delegates to inner without touching the cache" AND src/cached-storage/cached-storage.passthrough.test.ts::"getMetadata delegates to inner without touching the cache" AND src/cached-storage/cached-storage.passthrough.test.ts::"getPartial delegates to inner without touching the cache" AND src/cached-storage/cached-storage.passthrough.test.ts::"createReadStream delegates to inner without touching the cache" AND src/cached-storage/cached-storage.passthrough.test.ts::"createWriteStream delegates to inner without touching the cache"
Requirement: AbortSignal short-circuit on pre-aborted signal
When signal?.aborted === true at the time get, set, setPartial, delete, or clear is invoked, the wrapper SHALL skip its own cache work (no cache.get / cache.set / cache.delete calls) and SHALL invoke the inner method immediately so the inner storage raises the canonical STORAGE.* aborted error. Live signals SHALL be forwarded into cache.get / cache.set / cache.delete calls when the underlying AbstractCache accepts a signal argument.
Scenario: pre-aborted signal skips cache work in get
- WHEN
wrapper.get("x", AbortSignal.abort())is called - THEN
cache.getis NOT invoked ANDinner.getis invoked exactly once with the same aborted signal
Scenario: pre-aborted signal skips invalidation in set
- WHEN
wrapper.set("x", bytes, AbortSignal.abort())is called andinner.setrejects with abort - THEN
cache.deleteis NOT invoked AND the rejection propagates
Verified by: src/cached-storage/cached-storage.signal.test.ts::"pre-aborted signal skips cache work in get" AND src/cached-storage/cached-storage.signal.test.ts::"pre-aborted signal skips invalidation in set"
Requirement: getCacheStats() reports hits, misses, populations, invalidations, errors
The class SHALL expose getCacheStats(): CachedStorageStats returning a snapshot of the wrapper's counters: { hits, misses, populations, invalidations, errors }. Counters SHALL be monotonically non-decreasing. The method SHALL NOT mutate any state.
Scenario: counters reflect a hit, a miss, a population, and an invalidation
- WHEN one hit, one miss + population, and one set (invalidation) are exercised in sequence against a fresh wrapper
- THEN
getCacheStats()returns{ hits: 1, misses: 1, populations: 1, invalidations: 1, errors: 0 }
Scenario: getCacheStats does not mutate state
- WHEN
getCacheStats()is called twice without any other operation - THEN both snapshots return the same object content AND no
cache.*method is invoked
Verified by: src/cached-storage/cached-storage.test.ts::"getCacheStats reflects hit, miss, population, and invalidation" AND src/cached-storage/cached-storage.test.ts::"getCacheStats does not mutate state"
Requirement: OpenTelemetry instrumentation
Every cache interaction SHALL emit a span on the @suaveplan/storage-cache tracer with storage.cache.namespace and storage.cache.key attributes (the key is * for clear operations) and a storage.cache.outcome attribute drawn from {"hit", "miss", "populated", "invalidated", "error"}. The wrapper SHALL emit counters storage_cache.{hits, misses, populations, invalidations, errors}_total{namespace} and a storage_cache.entry_bytes{namespace} histogram observing each populations_total increment with the cached entry's byte length. The errors_total counter SHALL include an op label drawn from {"get", "set", "delete", "clear"}.
Scenario: cache hit increments hits_total and records a hit-outcome span
- WHEN a cache hit is exercised
- THEN
storage_cache.hits_total{namespace:"storage"}increments by 1 AND the emitted span hasstorage.cache.outcome === "hit"
Scenario: population records entry_bytes histogram
- WHEN a 4096-byte payload is cached on a miss
- THEN
storage_cache.entry_bytesrecords one observation of4096
Scenario: cache error increments errors_total with op label
- WHEN
cache.setrejects during population - THEN
storage_cache.errors_total{namespace:"storage", op:"set"}increments by 1
Verified by: src/otel/otel.test.ts::"hit increments hits_total and records hit-outcome span" AND src/otel/otel.test.ts::"population records entry_bytes histogram" AND src/otel/otel.test.ts::"cache error increments errors_total with op label"
Requirement: Multi-namespace isolation on a shared cache
Two CachedStorage wrappers constructed over DISTINCT inner storages with the SAME AbstractCache reference but DISTINCT namespace strings SHALL NOT observe each other's entries on get, AND wrapper.clear() on one SHALL NOT evict entries belonging to the other.
Scenario: distinct namespaces isolate entries on a shared cache
- WHEN wrapper A (namespace
"a") populates"clip.mov"and wrapper B (namespace"b") then reads"clip.mov" - THEN wrapper B observes a miss AND
cache.get("a::clip.mov")andcache.get("b::clip.mov")are distinct calls
Scenario: clear on one wrapper does not evict the other's entries
- WHEN wrapper A populates entries under
"a::"and wrapper B populates entries under"b::", thenwrapperA.clear()is called against a cache that exposeskeys() - THEN every
"a::*"key is deleted AND every"b::*"key is preserved
Verified by: tests/integration/namespaced-isolation.test.ts::"distinct namespaces isolate entries on a shared cache" AND tests/integration/namespaced-isolation.test.ts::"clear on one wrapper does not evict the other's entries"