16 KiB
Add @suaveplan/storage-plugin-cache — read-through cache decorator for AbstractStorage
Package: @suaveplan/storage-plugin-cache
Status: PROPOSED
Type: new-package (genesis-original)
Depends on: @suaveplan/storage (archived), @suaveplan/cache family (archived as add-cache-storage-redis-backends on 2026-05-16)
Sibling: add-drive-plugin-cache (this change targets the storage tier; the drive-tier twin targets Drive.read(path))
- Design: ./design.md
- Spec delta: ./specs/storage-plugin-cache/spec.md
Why
The cache family on genesis (@suaveplan/cache, cache-storage, cache-redis, built-in MemoryCache) provides one unified abstraction — AbstractCache<T> with createCache("memory" | "storage" | "redis", config) — and @suaveplan/cache-storage lets any AbstractStorage become a cache backend (storage-as-cache). The inverse surface is missing: there is no decorator that puts a cache in front of an AbstractStorage. Concretely, a consumer holding a slow remote storage (storage-http, storage-s3, storage-cdn-rackspace, storage-cdn-bunnycdn) today cannot wrap it with a fast local cache (MemoryCache for hot keys, StorageCache over OPFS/file for cross-process durability) without hand-rolling the read-through / invalidate-on-write logic at every call site.
storage-plugin-cache is the inverse of cache-storage: it takes any AbstractStorage and returns another AbstractStorage that consults a caller-supplied AbstractCache<Uint8Array> first on every get, falls through to the inner storage on miss, populates on success, and invalidates the cache key on every set/setPartial/delete/clear. The result is structurally identical to NamespacedStorage — a decorator class extending AbstractStorage that delegates to an inner instance — so every consumer in the ecosystem that accepts a StorageLike or AbstractStorage (drive, storage-uri plugins, application code) takes the cached version with zero adjustment.
This is the structural twin of add-drive-plugin-cache: same AbstractCache<Uint8Array> injection, same error-swallow rules, same OTel surface shape, same shouldCache / maxEntryBytes defaults. Where drive-plugin-cache caches reassembled plaintext at the drive middleware tier, storage-plugin-cache caches raw stored bytes at the storage tier — the most fundamental of the three planned cache decorators (the third being a future cacheUriPlugin over the URI resolver).
What changes
- ADDED:
CachedStorage extends AbstractStorage— decorator class that wraps an innerAbstractStorageand intercepts every abstract method. Constructor takes the inner storage and aCachedStorageOptionsbag. - ADDED:
cachedStorage(inner, options): CachedStoragefactory — convenience overnew CachedStorage(inner, options)matching thecreateCache/createHttpStoragefamily conventions. Validates options synchronously and throwsSTORAGE_CACHE.INVALID_OPTIONSon missing/malformedcache. - ADDED:
CachedStorageOptionsinterface —{ cache: AbstractCache<Uint8Array>; invalidateOnWrite?: boolean; invalidateOnDelete?: boolean; invalidateOnClear?: boolean; namespace?: string; shouldCache?: (key: string, bytes: Uint8Array) => boolean; maxEntryBytes?: number; logger?: LoggerLike }. - ADDED: Read-through
get(key, signal?):- Compute cache key as
${namespace ?? "storage"}::${key}. await cache.get(cacheKey). If hit, return bytes; incrementstorage_cache.hits_total{namespace}.- On miss,
await inner.get(key, signal). Incrementstorage_cache.misses_total{namespace}. - When the inner returns a
Uint8ArrayANDshouldCache(key, bytes)returns true,await cache.set(cacheKey, bytes). Incrementstorage_cache.populations_total{namespace}. - Cache
get/setfailures SHALL be swallowed (logged, counter-incremented) — a cache failure MUST NOT turn a successfulinner.getinto a thrown error.
- Compute cache key as
- ADDED: Invalidation surface. On
set(key, value, signal?), the wrapper SHALL callinner.set(key, value, signal)first; on success, wheninvalidateOnWrite !== false,await cache.delete(cacheKey). Symmetric onsetPartial(...),delete(key, signal?)(wheninvalidateOnDelete !== false), andclear(signal?)(wheninvalidateOnClear !== false, the wrapper callscache.clear()to drop every namespaced entry; the spec MUST document the scope as "every entry under this wrapper's namespace, not the entire cache"). Cachedelete/clearfailures SHALL NOT propagate. - ADDED:
getWithMetadata,getMetadata,getSize,getItemSize,exists,list— these all delegate to the inner storage unchanged.getWithMetadatadoes NOT populate the cache (metadata mutation invalidation is out of scope);existsis cheap on every backend we ship, so caching it would add invalidation surface without meaningful win. The wrapper SHALL document these as "passthrough". - ADDED:
getPartialandsetPartial— passthrough to inner. Partial-byte caching is out of scope (range invalidation across overlapping writes is a separate design problem). - ADDED:
createReadStreamandcreateWriteStream— passthrough to inner. Streaming bypasses the buffered cache by design; acacheUriPlugin(planned) will cover cached streaming at the URI tier. - ADDED:
STORAGE_CACHE_CODESregistry viadefineCodes("STORAGE_CACHE", [...] as const)— 2 codes:INVALID_OPTIONS,INNER_MISSING. The wrapper's own failures are deliberately few — everything else surfaces from the inner storage (STORAGE_HTTP.*,STORAGE.*, etc.) or the cache (CACHE.*) unchanged. - ADDED:
CachedStorageError extends SuaveplanError<CachedStorageErrorCode>withkey?andnamespace?discriminants. - ADDED:
CachedStorageStatsexposed via the class'sgetCacheStats()method (distinct fromAbstractStorage.getStats()which counts the inner storage's operations) —{ hits, misses, populations, invalidations, errors }. - ADDED: OTel surface —
storage_cache.{hits, misses, populations, invalidations, errors}_total{namespace}counters;storage_cache.entry_bytes{namespace}histogram observing populations; spans on@suaveplan/storage-cachetracer withstorage.cache.namespace,storage.cache.key,storage.cache.outcome ∈ {hit, miss, populated, invalidated, error}attributes. - ADDED: README composition recipes — memory cache in front of S3, OPFS in front of HTTPS, file cache in front of CDN, Redis cache in front of S3 (multi-process), namespaced isolation across multiple wrappers sharing one underlying cache,
cache.wrap()-based stale-while-revalidate via the exposedcachereference.
Out of scope
- Range-byte caching.
getPartial/setPartialpass through. Range invalidation (asetPartial(key, offset=10, bytes)requires evicting any cached byte slice that overlaps[10, 10+len]) is non-trivial and rare in practice. Consumers needing fast random-access reads should cache at the URI tier or use a content-addressed scheme. - Streaming caching.
createReadStream/createWriteStreampass through. Same reason as the drive-plugin-cache: cached streaming belongs at the URI layer with the plannedcacheUriPlugin, where the resolver owns the stream surface. - List-result caching. Same problem as in drive-plugin-cache: list invalidation across prefix-mutating writes is hard; consumers needing fast
listshould compose a higher-level index or use a content-addressed storage. - Cache stampede protection. If two concurrent
get(k)calls both miss the cache, both will callinner.get(k)and both will populate. Single-flight / lock-and-load belongs inAbstractCache.wrap(...)(which already provides it). Consumers needing stampede protection should compose at the cache tier viahandle.cache.wrap()rather than at the storage decorator. existscaching. Cheap on every shipped backend; caching adds an invalidation surface without a meaningful latency win. Out of scope.- Negative caching.
inner.getreturningundefined(key absent) is NOT cached. Caching absence requires a sentinel value and a separate TTL knob; can be added later if a real workload needs it. For now,undefinedis always a fresh read. - Metadata caching (
getMetadata,getWithMetadata). Passthrough. ETag-based conditional reads already live instorage-http'sEtagCache; bringing them up here would duplicate that layer. storage-uri-tiercacheUriPlugin. Separate proposal (sibling). Wraps anyUriPluginwith caching across URI schemes.drive-plugin-cacheparity. Separate sibling proposal (add-drive-plugin-cache). CachesDrive.read(path)plaintext at the drive middleware tier.
Source mapping
This is a genesis-original package; no upstream suaveplan/ reference. Requirements derive from:
- The
AbstractStorageabstract class in@suaveplan/storage(openspec/specs/storage/spec.md) — every abstract method (get/set/delete/exists/list/clear/getWithMetadata/getMetadata/getSize/getItemSize/getPartial/setPartial/createReadStream/createWriteStream) must be implemented inCachedStoragewith delegation semantics. - The
NamespacedStoragedecorator precedent in@suaveplan/storage(src/abstract/abstract.ts:559) — same structural pattern: wrap inner, prefix keys, delegate operations. - The
AbstractCache<T>contract in@suaveplan/cache. - The conventions established by
add-drive-plugin-cache(sibling) — identical option shape, error-swallow rules, OTel naming, recipes.
Requirements with no source line
| Requirement | Justification |
|---|---|
| Cache failures SHALL NOT propagate | Same as drive-plugin-cache. Cache is a perf optimisation, not a correctness boundary. |
shouldCache default is 16 MiB |
Same ceiling as drive-plugin-cache and drive-mesh-protocol's MAX_FRAME_BYTES. Avoids memory-pressure events when a large blob is read once. |
clear() only clears the namespaced subset of the cache, not the whole cache |
Two CachedStorage instances may share one AbstractCache with distinct namespaces (recipe in README); a wrapper.clear() call MUST NOT obliterate the other wrapper's entries. Implementation: AbstractCache doesn't currently expose "delete by prefix"; the spec mandates the behaviour and the implementation walks the cache via cache.keys() if available, falling back to a logged warning + skip when the cache backend can't enumerate. |
Passthrough on exists, getMetadata, getWithMetadata, getSize, getItemSize |
Listed in Out of Scope; the wrapper's value is on get/set/delete only. |
| Streaming passthrough | Same as drive-plugin-cache; cached streaming belongs at the URI tier. |
Returns CachedStorage extends AbstractStorage (class, not pure function) |
Matches NamespacedStorage precedent; lets consumers instanceof CachedStorage check; exposes getCacheStats() and the underlying cache reference on the same instance. |
| Wrapper's own error registry is 2 codes only | Almost every failure mode comes from the inner storage or the cache, both of which carry namespaced codes that pass through unchanged. The wrapper's surface is small by design. |
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete planned it(...) titles in these files:
src/cached-storage/cached-storage.test.ts— constructor wires the inner reference,getCacheStats()returns the snapshot,instanceof AbstractStorageandinstanceof CachedStorageboth hold.src/cached-storage/cached-storage.get.test.ts— hit short-circuits inner, miss populates, namespace used in cache key,shouldCachepredicate gates population, default 16 MiB ceiling,inner.getreturningundefinedis NOT cached, cache.get rejection falls through to inner.src/cached-storage/cached-storage.write.test.ts—setinvalidates post-success,setfailure does not invalidate,setPartialinvalidates,invalidateOnWrite:falseskips, cache.delete rejection swallowed.src/cached-storage/cached-storage.delete.test.ts—deleteinvalidates post-success when inner returnstrue, propagatesfalse, failure does not invalidate,invalidateOnDelete:falseskips.src/cached-storage/cached-storage.clear.test.ts—clearinvokes inner first, then iterates namespaced cache entries,invalidateOnClear:falseskips, cache withoutkeys()enumeration logs a warning and continues.src/cached-storage/cached-storage.passthrough.test.ts—exists/list/getSize/getMetadata/getWithMetadata/getItemSize/getPartial/setPartial/createReadStream/createWriteStreamall delegate to inner without touching the cache.src/cached-storage/cached-storage.signal.test.ts— pre-aborted signal skips cache work and forwards to inner; live signal threaded into inner and (when accepted) into cache.src/factory/factory.test.ts—cachedStorage(inner, options)returns aCachedStorage, throwsINVALID_OPTIONSon missing cache, throwsINVALID_OPTIONSwhen cache lacksget/set/delete.src/error-codes/error-codes.test.ts— exhaustiveSTORAGE_CACHE_CODESregistry, every code reachable, discriminants populated.src/otel/otel.test.ts— hit / miss / populate / invalidate / error counters fire,entry_byteshistogram observed on populations, span outcome attribute correct.tests/integration/memory-in-front-of-http.test.ts— realMemoryCache+ realstorage-http(via in-process fetch stub): two reads of the same key hit inner once, cache hit on the second; verifies the cache wrapper composes structurally with a real remote storage.tests/integration/file-cache-survives-restart.test.ts— realStorageCacheover realstorage-file+ real in-memory inner storage: populate, drop theCachedStoragereference, build a new one against the same file-backed cache, assert hit on first read.tests/integration/namespaced-isolation.test.ts— twoCachedStorageinstances over distinct inner storages, sharing oneAbstractCachewith distinct namespaces; neither sees the other's entries;wrapper-a.clear()does not affectwrapper-b's cache entries.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/storage-plugin-cache to enumerate. The package is new — no dependents currently. After adoption, every consumer holding an AbstractStorage reference (drive, storage-uri plugins, application code that takes a StorageLike) becomes a candidate to wrap with cachedStorage(inner, options) — no API change at the consumer.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-plugin-cachereturning the package itself).
Affected upstreams (none modified)
| Package | Action | Breaking? |
|---|---|---|
@suaveplan/storage |
None — AbstractStorage extended in the canonical way (matches NamespacedStorage precedent) |
no |
@suaveplan/cache |
None — AbstractCache<Uint8Array> consumed as-is |
no |
@suaveplan/cache-storage |
None — used only as a devDep for the file-backed integration test | no |
@suaveplan/storage-file |
None — used only as a devDep for the file-backed integration test | no |
@suaveplan/storage-http |
None — used only as a devDep for the cross-storage integration test | no |
storage-plugin-cache MUST NOT silently grow any of these packages' contracts. If a needed extension turns up during implementation (e.g. AbstractCache.keys() enumeration is needed for clear() and not all backends expose it), the spec MUST document the fallback and capture a follow-up proposal against @suaveplan/cache — never bend the contract here.
Ordering
This change is unblocked: @suaveplan/storage and the @suaveplan/cache* family both ship today on genesis with the consumed surface intact. May ship before, alongside, or after add-drive-plugin-cache — the two are independent.