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

191 lines
10 KiB
Markdown

# @suaveplan/storage-plugin-cache - Read-through cache decorator for AbstractStorage. Wraps any @suaveplan/storage with an injectable AbstractCache<Uint8Array>; 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<Uint8Array>`; 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*.
<!-- begin:auto-docs -->
## 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…
<!-- end:auto-docs -->
## 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<Uint8Array>();
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<Uint8Array>();
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<Uint8Array>();
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<Uint8Array>` 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.