# 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`, `readonly inner: AbstractStorage`, and `getCacheStats(): CachedStorageStats`. #### Scenario: instance is structurally an AbstractStorage - **WHEN** `cachedStorage(innerStorage, { cache })` is called against a real `AbstractStorage` subclass - **THEN** the returned instance is `instanceof AbstractStorage` AND `instanceof CachedStorage` AND every abstract method on `AbstractStorage` is callable #### Scenario: cache and inner are exposed as readonly properties - **WHEN** `cachedStorage(innerStorage, { cache })` returns a `CachedStorage` - **THEN** `wrapper.cache === cache` (same reference) AND `wrapper.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 `CachedStorageError` with `code === "STORAGE_CACHE.INNER_MISSING"` #### Scenario: missing cache throws INVALID_OPTIONS - **WHEN** `cachedStorage(innerStorage, {} as unknown as CachedStorageOptions)` is called - **THEN** the call throws `CachedStorageError` with `code === "STORAGE_CACHE.INVALID_OPTIONS"` #### Scenario: cache without get/set/delete methods throws INVALID_OPTIONS - **WHEN** `cachedStorage(innerStorage, { cache: { name: "x" } as unknown as AbstractCache })` is called - **THEN** the call throws `CachedStorageError` with `code === "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 and `cache.get("storage::clip.mov")` resolves to bytes - **THEN** the returned value equals those bytes AND `inner.get` is NOT invoked AND `getCacheStats().hits === 1` #### Scenario: custom namespace appears in cache key - **WHEN** the wrapper is constructed with `{ cache, namespace: "media" }` and `wrapper.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.get` returns `undefined`, and `inner.get` returns `bytes` - **THEN** `inner.get("x.bin", signal)` is invoked exactly once AND `cache.set("storage::x.bin", bytes)` is invoked exactly once AND `getCacheStats().misses === 1` AND `getCacheStats().populations === 1` #### Scenario: shouldCache predicate gates population - **WHEN** the wrapper is constructed with `{ cache, shouldCache: () => false }` and `wrapper.get("x.bin")` is called on a miss - **THEN** `inner.get` is invoked exactly once AND `cache.set` is NOT invoked AND `getCacheStats().populations === 0` #### Scenario: default maxEntryBytes is 16 MiB - **WHEN** the wrapper is constructed without `shouldCache` or `maxEntryBytes`, and `inner.get` resolves to a `Uint8Array` of `16 * 1024 * 1024 + 1` bytes - **THEN** `cache.set` is NOT invoked AND `getCacheStats().populations === 0` #### Scenario: undefined from inner is not cached - **WHEN** `wrapper.get("missing.bin")` is called and `inner.get` returns `undefined` - **THEN** `cache.set` is NOT invoked AND the wrapper returns `undefined` **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 and `cache.get` rejects with `new Error("redis down")` - **THEN** `inner.get` is invoked AND the returned bytes match `inner.get`'s result AND the consumer sees no error AND `getCacheStats().errors === 1` #### Scenario: cache.set rejection does not propagate - **WHEN** `wrapper.get("x")` is called and `cache.set` rejects after `inner.get` succeeds - **THEN** `wrapper.get` resolves with `inner.get`'s bytes AND `getCacheStats().errors === 1` AND `getCacheStats().populations === 0` #### Scenario: cache.delete rejection on invalidation does not propagate - **WHEN** `wrapper.set("x", bytes)` succeeds and `cache.delete` rejects - **THEN** `wrapper.set` resolves successfully AND `getCacheStats().errors === 1` AND `getCacheStats().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 AND `getCacheStats().invalidations === 1` #### Scenario: failed set does not invalidate - **WHEN** `wrapper.set("x.bin", bytes)` rejects because `inner.set` threw - **THEN** `cache.delete` is NOT invoked AND `getCacheStats().invalidations === 0` #### Scenario: invalidateOnWrite:false skips invalidation - **WHEN** the wrapper is constructed with `{ cache, invalidateOnWrite: false }` and `wrapper.set("x.bin", bytes)` succeeds - **THEN** `cache.delete` is 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 AND `getCacheStats().invalidations === 1` #### Scenario: failed setPartial does not invalidate - **WHEN** `wrapper.setPartial("x.bin", 100, bytes)` rejects because `inner.setPartial` threw - **THEN** `cache.delete` is 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 AND `getCacheStats().invalidations === 1` #### Scenario: failed delete does not invalidate - **WHEN** `wrapper.delete("x.bin")` rejects because `inner.delete` threw - **THEN** `cache.delete` is NOT invoked AND the rejection propagates #### Scenario: invalidateOnDelete:false skips invalidation - **WHEN** the wrapper is constructed with `{ cache, invalidateOnDelete: false }` and `wrapper.delete("x.bin")` resolves to `true` - **THEN** `cache.delete` is 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"` and `wrapper.clear()` is called with namespace `"storage"` - **THEN** `cache.delete("storage::a")` is invoked AND `cache.delete("other::b")` is NOT invoked #### Scenario: clear logs a warning when cache lacks keys() - **WHEN** the underlying `AbstractCache` does not implement `keys()` and `wrapper.clear()` is called - **THEN** `options.logger.warn` is invoked at least once with a message naming `[storage-cache]` AND `cache.clear()` is NOT invoked AND `getCacheStats().invalidations === 0` #### Scenario: invalidateOnClear:false skips namespaced cleanup - **WHEN** the wrapper is constructed with `{ cache, invalidateOnClear: false }` and `wrapper.clear()` is called - **THEN** `cache.delete`/`cache.clear`/`cache.keys` are 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 and `inner.exists("x")` returns `true` - **THEN** the wrapper returns `true` AND `cache.get` / `cache.set` / `cache.delete` are 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 AND `cache.get` / `cache.set` are 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 AND `cache.get` is 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 AND `cache.get` is 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")` AND `cache.get` is 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")` AND `cache.delete` is NOT invoked at construction time (the cache is invalidated only when the writer's underlying write commits via `set`, 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.get` is NOT invoked AND `inner.get` is 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 and `inner.set` rejects with abort - **THEN** `cache.delete` is 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 has `storage.cache.outcome === "hit"` #### Scenario: population records entry_bytes histogram - **WHEN** a 4096-byte payload is cached on a miss - **THEN** `storage_cache.entry_bytes` records one observation of `4096` #### Scenario: cache error increments errors_total with op label - **WHEN** `cache.set` rejects 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")` and `cache.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::"`, then `wrapperA.clear()` is called against a cache that exposes `keys()` - **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"`