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

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 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<Uint8Array> }) 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"