# Design — @suaveplan/storage-plugin-cache ## Tier and dependency posture Universal tier. Zero `node:` / `bun:` imports in `src/`. Runtime deps: - `@suaveplan/error` — `defineCodes`, `SuaveplanError` - `@suaveplan/storage` — `AbstractStorage` (extended), associated types (`StorageMetadata`, `StorageGetMetadataResult`, `StorageListResult`, etc.) - `@suaveplan/cache` — `AbstractCache` (type-only consumption; the consumer passes a concrete instance) - `@suaveplan/types` — `LoggerLike` (optional injectable) - `@opentelemetry/api` — meter + tracer - `tslib` `@suaveplan/cache-storage`, `cache-redis`, `storage-file`, `storage-http`, `storage-browser-*` are **NOT** runtime deps. The consumer wires their preferred cache backend and inner storage before passing them to `cachedStorage(inner, options)`. Same injection posture as `drive-uri` and `drive-plugin-cache` — the wrapper sees interfaces, not concrete providers. DevDeps: `@suaveplan/testing`, `@suaveplan/storage-file`, `@suaveplan/storage-http`, `@suaveplan/cache-storage`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`. ## File layout ```text packages/storage/storage-plugin-cache/ package.json README.md src/ index.ts # explicit named re-exports (rule 10) error-codes/ error-codes.ts # defineCodes("STORAGE_CACHE", [...]), CachedStorageError error-codes.test.ts error-codes.md index.ts types/ options.types.ts # CachedStorageOptions, CachedStorageStats options.types.md index.ts cached-storage/ cached-storage.ts # class CachedStorage extends AbstractStorage cached-storage.test.ts # construct, instanceof, getCacheStats cached-storage.get.ts # get() read-through helper (kept ≤300 lines) cached-storage.get.test.ts cached-storage.write.ts # set() / setPartial() invalidation helper cached-storage.write.test.ts cached-storage.delete.ts # delete() invalidation helper cached-storage.delete.test.ts cached-storage.clear.ts # clear() namespaced-iteration helper cached-storage.clear.test.ts cached-storage.passthrough.ts # exists/list/getSize/getMetadata/streams delegates cached-storage.passthrough.test.ts cached-storage.signal.ts # pre-aborted signal short-circuit helper cached-storage.signal.test.ts cached-storage.md cached-storage.get.md cached-storage.write.md cached-storage.delete.md cached-storage.clear.md cached-storage.passthrough.md cached-storage.signal.md index.ts factory/ factory.ts # cachedStorage(inner, options): CachedStorage factory.test.ts factory.md index.ts otel/ otel.ts # meter + tracer wiring, recordOp(...) otel.test.ts otel.md index.ts tests/ setup/polyfills.ts integration/ memory-in-front-of-http.test.ts # real MemoryCache + real storage-http (in-process fetch) file-cache-survives-restart.test.ts # real StorageCache over real storage-file namespaced-isolation.test.ts # two wrappers sharing one cache, distinct namespaces tsconfig.json tsconfig.lib.json vite.config.ts vitest.config.ts ``` The `cached-storage/` feature folder is split into one-concern files (`get`, `write`, `delete`, `clear`, `passthrough`, `signal`) to stay well under the 300-line hard limit while keeping each behaviour next to its tests + docs. Every implementation `.ts` has a sibling `.md`. The folder pattern mirrors `storage-uri`'s `resolver/` split. Per AGENTS §10: parent `cached-storage/` with un-suffixed children. No `-plugin-` suffix because the children are not nested plugins (the package is named `storage-plugin-cache` because it functions as a storage-tier cache plugin — the noun "plugin" appears in the package name only, not in any child folder). ## Class shape ```ts export class CachedStorage extends AbstractStorage { readonly #inner: AbstractStorage; readonly #cache: AbstractCache; readonly #options: ResolvedCachedStorageOptions; readonly #stats: MutableStats = { hits: 0, misses: 0, populations: 0, invalidations: 0, errors: 0 }; public constructor(inner: AbstractStorage, options: CachedStorageOptions) { super(/* eventBus from inner is intentionally NOT forwarded — events are observed on the inner */); if (!inner) throw new CachedStorageError("INNER_MISSING", "inner storage is required"); validateOptions(options); this.#inner = inner; this.#cache = options.cache; this.#options = resolveDefaults(options); } public get cache(): AbstractCache { return this.#cache; } public get inner(): AbstractStorage { return this.#inner; } public getCacheStats(): CachedStorageStats { return { ...this.#stats }; } async get(key: string, signal?: AbortSignal): Promise { /* read-through */ } async set(key: string, value: Uint8Array, signal?: AbortSignal): Promise { /* delegate + invalidate */ } async delete(key: string, signal?: AbortSignal): Promise { /* delegate + invalidate */ } // … rest of AbstractStorage's 15 abstract methods … } export function cachedStorage( inner: AbstractStorage, options: CachedStorageOptions ): CachedStorage { return new CachedStorage(inner, options); } ``` The class exposes both `cache` (the underlying `AbstractCache` for `wrap()` / `getStats()` / direct manipulation) and `inner` (the wrapped `AbstractStorage` for inspection / unwrap). The factory is a one-liner over `new CachedStorage(...)` so consumers can write either form; the factory is what `validateOptions` lives in (synchronous throw on missing cache before the class constructor runs). ## Read-through (`get`) ```ts async get(key: string, signal?: AbortSignal): Promise { if (signal?.aborted) return this.#inner.get(key, signal); // let inner raise canonical AbortError const cacheKey = `${this.#options.namespace}::${key}`; let cached: Uint8Array | undefined; try { cached = await this.#cache.get(cacheKey); } catch (err) { this.#stats.errors += 1; recordError("get", err, this.#options.namespace); // fall through — cache failure must not block the read } if (cached !== undefined) { this.#stats.hits += 1; recordHit(this.#options.namespace, key); return cached; } this.#stats.misses += 1; recordMiss(this.#options.namespace, key); const bytes = await this.#inner.get(key, signal); if (bytes === undefined) return undefined; // negative results NOT cached if (this.#options.shouldCache(key, bytes)) { try { await this.#cache.set(cacheKey, bytes); this.#stats.populations += 1; recordPopulate(this.#options.namespace, key, bytes.byteLength); } catch (err) { this.#stats.errors += 1; recordError("set", err, this.#options.namespace); // swallow — same rule as drive-plugin-cache } } return bytes; } ``` ## Invalidation (`set` / `setPartial` / `delete` / `clear`) ```ts async set(key: string, value: Uint8Array, signal?: AbortSignal): Promise { await this.#inner.set(key, value, signal); if (this.#options.invalidateOnWrite) { await this.#invalidateKey(key); } } async setPartial(key: string, offset: number, value: Uint8Array, signal?: AbortSignal): Promise { await this.#inner.setPartial(key, offset, value, signal); if (this.#options.invalidateOnWrite) { await this.#invalidateKey(key); // partial writes invalidate the whole entry — no partial caching } } async delete(key: string, signal?: AbortSignal): Promise { const removed = await this.#inner.delete(key, signal); if (this.#options.invalidateOnDelete) { await this.#invalidateKey(key); // delete even on inner.delete === false to clear stale entries } return removed; } async clear(signal?: AbortSignal): Promise { await this.#inner.clear(signal); if (!this.#options.invalidateOnClear) return; // Iterate this wrapper's namespaced subset only — never wipe other wrappers' entries. if (typeof this.#cache.keys === "function") { try { const prefix = `${this.#options.namespace}::`; for await (const k of this.#cache.keys()) { if (k.startsWith(prefix)) await this.#cache.delete(k); this.#stats.invalidations += 1; } } catch (err) { this.#stats.errors += 1; recordError("clear", err, this.#options.namespace); } } else { // AbstractCache without enumeration: log warning, skip. Consumer can call cache.clear() // directly if they accept the wider blast radius. this.#options.logger?.warn?.({ msg: "[storage-cache] cache backend has no keys() — clear() did not evict cache entries", namespace: this.#options.namespace, }); } } async #invalidateKey(key: string): Promise { const cacheKey = `${this.#options.namespace}::${key}`; try { await this.#cache.delete(cacheKey); this.#stats.invalidations += 1; recordInvalidate(this.#options.namespace, key); } catch (err) { this.#stats.errors += 1; recordError("delete", err, this.#options.namespace); } } ``` Post-success invalidation matches `drive-plugin-cache`'s rationale — pre-`next()` invalidation opens a race window where a concurrent read repopulates the cache with stale bytes during the write. ## Passthrough surface ```ts exists (key, signal?) → inner.exists(key, signal) list (opts?, signal?) → inner.list(opts, signal) clear // handled above with invalidation getWithMetadata(key, signal?) → inner.getWithMetadata(key, signal) getMetadata (key, signal?) → inner.getMetadata(key, signal) getSize (signal?) → inner.getSize(signal) getItemSize (key, signal?) → inner.getItemSize(key, signal) getPartial (key, offset, length, sig?) → inner.getPartial(...) createReadStream (key, signal?) → inner.createReadStream(key, signal) createWriteStream (key, signal?) → inner.createWriteStream(key, signal) ``` These do NOT touch the cache. The cache surface is reads-of-whole-objects + writes-invalidate-whole-objects. Anything that wants cached metadata or cached streaming composes at a different tier (drive-plugin-cache for plaintext, future cacheUriPlugin for URI-resolver results, `storage-http`'s `EtagCache` for HTTP 304s). ## Error-code map | Code | When | Notes | |---|---|---| | `STORAGE_CACHE.INVALID_OPTIONS` | Constructor or factory called without a valid `cache` (missing, wrong shape, missing `get`/`set`/`delete`) | Synchronous throw | | `STORAGE_CACHE.INNER_MISSING` | Constructor called with a nullish `inner` | Synchronous throw | Every other failure mode (storage errors, cache errors) surfaces with its original code through the wrapper. The wrapper's own registry is intentionally narrow — there's no value in re-namespacing downstream errors. ## OTel surface Tracer + meter: `@suaveplan/storage-cache`. Histograms: - `storage_cache.entry_bytes{namespace}` — distribution of cached entry sizes. Sampled on `populations_total` increments. Counters: - `storage_cache.hits_total{namespace}` - `storage_cache.misses_total{namespace}` - `storage_cache.populations_total{namespace}` - `storage_cache.invalidations_total{namespace}` - `storage_cache.errors_total{namespace, op}` — `op ∈ get | set | delete | clear` Spans: one per public op, attributes `storage.cache.namespace`, `storage.cache.key` (or `*` for `clear`), `storage.cache.outcome ∈ {hit, miss, populated, invalidated, error}`. Keys are not URLs; no query-string redaction needed. ## Trade-offs accepted - **No range-byte caching.** `setPartial(key, offset, bytes)` invalidates the whole entry. Avoids the overlapping-range invalidation puzzle. Consumers with random-access read workloads should compose differently (see Out of Scope). - **No streaming-result caching.** `createReadStream` / `createWriteStream` pass through. Cached streaming lives at the URI tier. - **`clear()` scope is namespace-local when the cache exposes `keys()`, otherwise no-op with a warning.** Two wrappers sharing one cache MUST NOT obliterate each other's entries. The fallback (no `keys()`) is conservative; the spec mandates the warning so operators see why their `clear()` was a no-op. - **Negative results not cached.** `inner.get` returning `undefined` is always a fresh check. Add later if a real workload requires it (needs a sentinel value and a separate TTL). - **`getStats()` (inherited from `AbstractStorage`) vs. `getCacheStats()` (new on `CachedStorage`).** The inherited stats count the *inner* storage's operations as observed through the wrapper; the new `getCacheStats()` counts the cache layer's behaviour. Two distinct surfaces; documented in the README. - **One `AbstractCache` per wrapper.** Two-tier caches (memory in front of OPFS in front of a remote storage) are out of scope. Compose by stacking two `cachedStorage(...)` wrappers if you really need that. - **`inner` and `cache` exposed as readonly properties.** Lets the consumer inspect both and bypass the wrapper deliberately (e.g. for `cache.wrap()` stale-while-revalidate). Same shape as `drive-plugin-cache`'s `handle.cache`. ## Composition recipes (in README) ```ts // 1. Memory cache in front of S3 (server, hot keys) import { S3Storage } from "@suaveplan/storage-s3"; import { createCache } from "@suaveplan/cache"; import { cachedStorage } from "@suaveplan/storage-plugin-cache"; const inner = new S3Storage({ bucket: "media", /* … */ }); const cache = createCache("memory", { policy: "lru", maxItems: 1_000 }); const storage = cachedStorage(inner, { cache, namespace: "media" }); // Drop straight into anything that takes an AbstractStorage: const drive = new Drive({ defaultStorage: storage }); // 2. OPFS cache in front of HTTPS (browser, cross-session) import { createOpfsStorage } from "@suaveplan/storage-browser-opfs"; import { createHttpStorage } from "@suaveplan/storage-http"; const inner = createHttpStorage({ baseUrl: "https://cdn.example.com/start/here/" }); const cache = createCache("storage", { storage: await createOpfsStorage("http-cache"), policy: "lru", maxBytes: 256 * 1024 * 1024, }); const storage = cachedStorage(inner, { cache }); // 3. File cache in front of Bunny CDN (server, survives process restart) import { createBunnyStorage } from "@suaveplan/storage-cdn-bunnycdn"; import { createFileStorage } from "@suaveplan/storage-file"; const inner = createBunnyStorage({ /* … */ }); const cache = createCache("storage", { storage: createFileStorage({ rootDir: "/var/cache/bunny" }), policy: "ttl", ttlMs: 3_600_000, }); const storage = cachedStorage(inner, { cache, namespace: "bunny-prod" }); // 4. Redis cache in front of S3 (server, cross-process shared) import { createRedisCache } from "@suaveplan/cache-redis"; const inner = new S3Storage({ /* … */ }); const cache = createRedisCache({ client, prefix: "media-cache:" }); const storage = cachedStorage(inner, { cache, namespace: "media" }); // 5. Stale-while-revalidate via cache.wrap() const storage = cachedStorage(inner, { cache }); const bytes = await storage.cache.wrap(`media::clip.mov`, () => inner.get("clip.mov"), { staleAge: 60_000, refreshAhead: true, }); // 6. Composed with drive-uri / storage-uri — the cache wrapper is just another StorageLike import { createStorageUriResolver, fileUriPlugin } from "@suaveplan/storage-uri"; const cachedFile = cachedStorage(createFileStorage({ rootDir: "/var/media" }), { cache }); const resolver = createStorageUriResolver([ fileUriPlugin({ rootDir: "/var/media", storage: cachedFile }), // … other plugins … ]); ``` Recipe 6 illustrates the structural payoff: anything that took `AbstractStorage` before takes `CachedStorage` now, with zero adjustment, including `storage-uri` plugins. The cache wrapper composes uniformly across the ecosystem.