# @suaveplan/storage-plugin-cache - Read-through cache decorator for AbstractStorage. Wraps any @suaveplan/storage with an injectable AbstractCache; hit/miss/populate on get, invalidate-on-write/setPartial/delete/clear, namespaced isolation, full OTel surface, cache failures swallow with stats. Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache`; hit/miss/populate on `get`, invalidate on every `set` / `setPartial` / `delete` / `clear`. Namespaced isolation so two wrappers can share one cache. Full OpenTelemetry surface (`storage_cache.*` counters, `entry_bytes` histogram, per-op spans). Cache failures swallow with stats so a flaky cache never breaks a read. This is the structural inverse of `@suaveplan/cache-storage`, which turns any `AbstractStorage` into a *cache backend*. Here we turn any `AbstractCache` into a *front of a storage*. ## Documentation tree - [cached-storage](./src/cached-storage/cached-storage.md) — `CachedStorage` is a read-through cache decorator that extends `AbstractStorage` from `@suaveplan/storage`. It wraps an… - [cached-storage.clear](./src/cached-storage/cached-storage.clear.md) — This module owns the `CachedStorage.clear` path. The wrapper SHALL first delegate to `inner.clear(signal)` (so the sour… - [cached-storage.context](./src/cached-storage/cached-storage.context.md) — This module defines the `CachedStorageContext` shape that the per-method helper files (`cached-storage.get.ts`, `cached… - [cached-storage.delete](./src/cached-storage/cached-storage.delete.md) — This module owns the delete-through path of `CachedStorage.delete`. The wrapper forwards the deletion to the inner stor… - [cached-storage.get](./src/cached-storage/cached-storage.get.md) — This module owns the read-through path of `CachedStorage.get(key, signal?)`. The implementation first consults the inje… - [cached-storage](./src/cached-storage/cached-storage.md) — `CachedStorage` is a read-through cache decorator that extends `AbstractStorage` from `@suaveplan/storage`. It wraps an… - [cached-storage.passthrough](./src/cached-storage/cached-storage.passthrough.md) — This module owns the passthrough surface of `CachedStorage` — every abstract method on `AbstractStorage` that does NOT… - [cached-storage.signal](./src/cached-storage/cached-storage.signal.md) — This helper centralises the wrapper's policy for a pre-aborted `AbortSignal`: every public method that takes a signal M… - [cached-storage.write](./src/cached-storage/cached-storage.write.md) — This module owns the write-through path of `CachedStorage.set` and `CachedStorage.setPartial`. Both ops invoke the inne… - [error-codes](./src/error-codes/error-codes.md) — This module owns the canonical registry of error codes raised by `@suaveplan/storage-plugin-cache`. The package deliber… - [error-codes](./src/error-codes/error-codes.md) — This module owns the canonical registry of error codes raised by `@suaveplan/storage-plugin-cache`. The package deliber… - [factory](./src/factory/factory.md) — This module exports the `cachedStorage(inner, options)` factory — a thin convenience over `new CachedStorage(inner, opt… - [factory](./src/factory/factory.md) — This module exports the `cachedStorage(inner, options)` factory — a thin convenience over `new CachedStorage(inner, opt… - [otel](./src/otel/otel.md) — This module wires the OpenTelemetry meter and tracer used by every `CachedStorage` instance. The wrapper emits five cou… - [otel](./src/otel/otel.md) — This module wires the OpenTelemetry meter and tracer used by every `CachedStorage` instance. The wrapper emits five cou… ## Modules - `./error-codes` — `STORAGE_CACHE_CODES` (`INVALID_OPTIONS`, `INNER_MISSING`), `CachedStorageError`. - `./types` — `CachedStorageOptions`, `CachedStorageStats`, `ResolvedCachedStorageOptions`, `CachedStorageErrorOp`. - `./cached-storage` — `CachedStorage extends AbstractStorage`, per-method helpers split across `.get.ts`, `.write.ts`, `.delete.ts`, `.clear.ts`, `.passthrough.ts`, `.signal.ts`. - `./factory` — `cachedStorage(inner, options): CachedStorage` factory. - `./otel` — meter + tracer wiring, `recordHit` / `recordMiss` / `recordPopulate` / `recordInvalidate` / `recordError`, `setStorageCacheMeterProvider`, `resetInstruments`. ## Quick start ```ts import { cachedStorage } from "@suaveplan/storage-plugin-cache"; import { MemoryCache } from "@suaveplan/cache"; import { createHttpStorage } from "@suaveplan/storage-http"; const inner = createHttpStorage({ baseUrl: "https://cdn.example.com/" }); const cache = new MemoryCache(); const storage = cachedStorage(inner, { cache, namespace: "media" }); const bytes = await storage.get("clip.mov"); // miss → inner → populate const again = await storage.get("clip.mov"); // hit (no inner call) await storage.set("clip.mov", new Uint8Array([1])); // inner.set + invalidate ``` ## Composition recipes ### 1. Memory cache in front of HTTPS (hot keys) ```ts import { cachedStorage } from "@suaveplan/storage-plugin-cache"; import { MemoryCache } from "@suaveplan/cache"; import { createHttpStorage } from "@suaveplan/storage-http"; const inner = createHttpStorage({ baseUrl: "https://cdn.example.com/" }); const cache = new MemoryCache(); const storage = cachedStorage(inner, { cache, namespace: "media" }); ``` ### 2. File-backed cache in front of HTTPS (cross-process durable) ```ts import { cachedStorage } from "@suaveplan/storage-plugin-cache"; import { createCache } from "@suaveplan/cache"; import { createNodeFsStorage } from "@suaveplan/storage-file"; import { createHttpStorage } from "@suaveplan/storage-http"; const inner = createHttpStorage({ baseUrl: "https://cdn.example.com/" }); const fileBackend = createNodeFsStorage({ rootDir: "/var/cache/cdn" }); const cache = createCache("storage", { storage: fileBackend }); const storage = cachedStorage(inner, { cache, namespace: "cdn-prod" }); ``` ### 3. CDN with namespace isolation ```ts // Two wrappers sharing one cache instance, distinct namespaces, // distinct inner storages — neither wrapper sees the other's entries // on get, and `wrapperA.clear()` does not evict wrapperB's entries. const cache = new MemoryCache(); const wrapperA = cachedStorage(innerA, { cache, namespace: "a" }); const wrapperB = cachedStorage(innerB, { cache, namespace: "b" }); ``` ### 4. Redis cache in front of S3 (cross-process shared) ```ts 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()` ```ts const storage = cachedStorage(inner, { cache }); // Expose the underlying cache for direct wrap()-based SWR: const bytes = await storage.cache.wrap( `storage::clip.mov`, () => storage.inner.get("clip.mov"), { staleAge: 60_000 }, ); ``` ### 6. Composed with `storage-uri` / `drive` — the wrapper is just an `AbstractStorage` ```ts import { createStorageUriResolver, fileUriPlugin } from "@suaveplan/storage-uri"; const cachedFile = cachedStorage( createNodeFsStorage({ rootDir: "/var/media" }), { cache }, ); const resolver = createStorageUriResolver([ fileUriPlugin({ rootDir: "/var/media", storage: cachedFile }), ]); ``` ## Options | Option | Default | Notes | |---|---|---| | `cache` | (required) | An `AbstractCache` instance — `MemoryCache`, `StorageCache`, `createRedisCache`, etc. | | `namespace` | `"storage"` | Prefix applied to every cache key (`${namespace}::${key}`). Distinct namespaces isolate two wrappers on a shared cache. | | `invalidateOnWrite` | `true` | When `false`, `set`/`setPartial` do NOT evict the cache entry — application owns the invalidation. | | `invalidateOnDelete` | `true` | When `false`, `delete` does NOT evict. | | `invalidateOnClear` | `true` | When `false`, `clear` does NOT walk the cache's namespace. | | `shouldCache(key, bytes)` | `bytes.byteLength <= maxEntryBytes` | Predicate gating cache population on miss. | | `maxEntryBytes` | `16 * 1024 * 1024` (16 MiB) | Used by the default `shouldCache`. Matches `drive-mesh-protocol`'s frame ceiling and `drive-plugin-cache`'s entry ceiling. | | `logger` | undefined | `LoggerLike` used to warn when `clear()` can't enumerate the cache. | ## Relationship to siblings - **`@suaveplan/cache-storage`** — the structural inverse: turns any `AbstractStorage` into a *cache backend*. Use that one when you want to plug a file/S3/redis storage into the `createCache("storage")` factory. Use *this* one when you want to put a cache *in front of* a storage to accelerate `get`. - **`add-drive-plugin-cache`** — the drive-tier twin: caches *reassembled plaintext* at the drive middleware layer. Same option shape, same OTel naming, same error-swallow contract. Use the drive wrapper for plaintext-after-chunk-reassembly, use this one for the raw storage bytes underneath. - **Future `cacheUriPlugin`** — a planned URI-tier wrapper that composes with `@suaveplan/storage-uri` to cache across URI schemes. This package's invalidation surface intentionally stops at the storage tier; URI-level caching belongs there. ## Errors | Code | When | |---|---| | `STORAGE_CACHE.INNER_MISSING` | Constructor / factory called with nullish `inner`. | | `STORAGE_CACHE.INVALID_OPTIONS` | Constructor / factory called without a valid `cache` (missing, wrong shape, missing `get`/`set`/`delete`). | Every other failure mode flows through with its original namespaced code (`STORAGE.*`, `STORAGE_HTTP.*`, `CACHE.*`). ## Cache failures never propagate A `cache.get` / `cache.set` / `cache.delete` rejection is logged through the OTel `storage_cache.errors_total{namespace, op}` counter and bumped on `getCacheStats().errors`, but the wrapper's own surface NEVER throws because the cache misbehaved. A failed `cache.get` falls through to `inner.get`; a failed `cache.set` still returns the inner-read bytes; a failed `cache.delete` on invalidation still returns the inner-write/delete result.