first commit
This commit is contained in:
190
docs/persistence/storage/storage-plugin-cache/README.md
Normal file
190
docs/persistence/storage/storage-plugin-cache/README.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# @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.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# cached-storage.clear — namespaced clear with feature-detection
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the `CachedStorage.clear` path. The wrapper SHALL
|
||||
first delegate to `inner.clear(signal)` (so the source of truth is
|
||||
emptied); on success, it MUST evict only its own namespaced subset of
|
||||
the backing cache — never the entire cache, because two wrappers
|
||||
sharing one cache instance with distinct namespaces would otherwise
|
||||
clobber each other.
|
||||
|
||||
The canonical `AbstractCache` contract in `@suaveplan/cache` does NOT
|
||||
expose a `keys()` enumeration method, so the wrapper feature-detects
|
||||
via `typeof cache.keys === "function"` at runtime. When enumeration is
|
||||
available, every key starting with `${namespace}::` is deleted one by
|
||||
one. When enumeration is NOT available, the wrapper logs a warning via
|
||||
`options.logger?.warn?.(...)` and skips eviction — calling
|
||||
`cache.clear()` as a fallback would violate the multi-wrapper
|
||||
isolation requirement.
|
||||
|
||||
## Features
|
||||
|
||||
- Inner-first delete: `await inner.clear(signal)` propagates rejections.
|
||||
- Feature-detection of `cache.keys()` — both `AsyncIterable<string>`
|
||||
and `Iterable<string>` returns are supported (the `for await` loop
|
||||
works with both).
|
||||
- Namespaced eviction: only entries with the wrapper's
|
||||
`${namespace}::` prefix are deleted; other wrappers' entries are
|
||||
preserved.
|
||||
- Warning-on-skip: when `cache.keys` is missing, a structured warning
|
||||
is logged through the optional `LoggerLike` so operators see why
|
||||
`clear()` was a no-op against this backend.
|
||||
- `invalidateOnClear: false` skips the eviction path entirely — neither
|
||||
`keys()` nor `delete()` is invoked.
|
||||
- Iteration errors swallow and bump `errors` (per the wrapper's
|
||||
cache-failures-never-propagate contract).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { clearThroughCache } from "./cached-storage.clear.js";
|
||||
|
||||
await clearThroughCache(ctx, undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { clearThroughCache } from "./cached-storage.clear.js";
|
||||
|
||||
// Provide a structured logger so operators observe the skip path.
|
||||
const ctxWithLogger: CachedStorageContext = {
|
||||
...ctx,
|
||||
options: {
|
||||
...ctx.options,
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: (msg, ctx) => console.warn(msg, ctx),
|
||||
error: () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await clearThroughCache(ctxWithLogger, signal);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `clearThroughCache(ctx, signal?): Promise<void>` — clears the inner
|
||||
storage, then iterates namespaced cache keys when `cache.keys()` is
|
||||
available, otherwise logs a warning and skips.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `hasKeys(cache)` predicate is a structural type guard — it does
|
||||
not import `AbstractCache` directly because the canonical contract
|
||||
omits `keys()`. Concrete cache implementations are free to expose it
|
||||
(`MemoryCache` in `@suaveplan/cache` does NOT today; `StorageCache`
|
||||
from `@suaveplan/cache-storage` does NOT today either; the test suite
|
||||
ships a `KeysCapableMemoryCache` test helper that DOES). Adding
|
||||
`keys()` to `AbstractCache` itself is a separate proposal documented
|
||||
in the parent change's "Affected upstreams" section. Iteration uses
|
||||
`for await…of` so both synchronous `Iterable<string>` and
|
||||
`AsyncIterable<string>` returns work; a backend that streams keys over
|
||||
the network can yield asynchronously without the wrapper needing to
|
||||
buffer the whole set.
|
||||
@@ -0,0 +1,78 @@
|
||||
# cached-storage.context — internal helper context
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the `CachedStorageContext` shape that the per-method
|
||||
helper files (`cached-storage.get.ts`, `cached-storage.write.ts`,
|
||||
`cached-storage.delete.ts`, `cached-storage.clear.ts`,
|
||||
`cached-storage.passthrough.ts`) accept. The class in
|
||||
`cached-storage.ts` constructs one context and forwards it to each
|
||||
helper, so no helper imports the class directly and the implementation
|
||||
stays well below the 300-line per-file ceiling.
|
||||
|
||||
The context bundles the four references the helpers need — the inner
|
||||
`AbstractStorage`, the backing `AbstractCache<Uint8Array>`, the
|
||||
resolved options bag, and the mutable stats counter — plus a
|
||||
`cacheKeyOf(ctx, key)` helper that computes the namespaced cache key
|
||||
exactly once. Centralising the key formatter prevents drift between
|
||||
helpers when the namespace separator (`"::"`) changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Single `CachedStorageContext` interface re-used by every helper file.
|
||||
- `cacheKeyOf(ctx, key)` formatter so the `"::"` separator lives in one
|
||||
place and the namespace prefix is applied uniformly across `get`,
|
||||
`set`, `setPartial`, `delete`, and the `clear` iteration loop.
|
||||
- Stats counter held by reference so mutations performed in any helper
|
||||
are immediately visible to `getCacheStats()` on the class.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
cacheKeyOf,
|
||||
type CachedStorageContext,
|
||||
} from "./cached-storage.context.js";
|
||||
|
||||
function example(ctx: CachedStorageContext, key: string): string {
|
||||
return cacheKeyOf(ctx, key);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { cacheKeyOf } from "./cached-storage.context.js";
|
||||
|
||||
async function readThroughHelper(
|
||||
ctx: CachedStorageContext,
|
||||
key: string,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
const cacheKey = cacheKeyOf(ctx, key);
|
||||
const hit = await ctx.cache.get(cacheKey);
|
||||
if (hit !== undefined) {
|
||||
ctx.stats.hits += 1;
|
||||
return hit;
|
||||
}
|
||||
ctx.stats.misses += 1;
|
||||
return ctx.inner.get(key);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CachedStorageContext` — `{ inner, cache, options, stats }`.
|
||||
`inner` and `cache` are readonly; `stats` is a mutable counter object.
|
||||
- `cacheKeyOf(ctx, key): string` — returns `\`${namespace}::${key}\``.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The stats object is passed by reference, not by copy: every helper that
|
||||
increments a counter mutates the same `CachedStorageStats` instance
|
||||
that the class returns from `getCacheStats()` (after spreading into a
|
||||
fresh object for snapshot semantics). Helpers MUST NOT replace
|
||||
`ctx.stats` — only mutate its fields. The interface declares `stats`
|
||||
as a plain `CachedStorageStats` (not `Readonly<…>`) precisely so the
|
||||
helpers can perform `ctx.stats.hits += 1`. Consumers of the class
|
||||
never see the mutable reference; `getCacheStats()` always returns a
|
||||
fresh shallow copy.
|
||||
@@ -0,0 +1,69 @@
|
||||
# cached-storage.delete — delete-through
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the delete-through path of `CachedStorage.delete`.
|
||||
The wrapper forwards the deletion to the inner storage first; only on
|
||||
successful resolution does it evict the corresponding cache entry. The
|
||||
inner's boolean return value (whether the key existed) is propagated
|
||||
verbatim so consumers see the same surface as a direct
|
||||
`inner.delete(key)` call.
|
||||
|
||||
Eviction happens even when the inner returns `false` (key was already
|
||||
absent). The reason: if the wrapper has a stale cache entry for a key
|
||||
that the inner doesn't know about, leaving it in place would mean a
|
||||
subsequent `get(key)` returns the stale bytes from the cache forever.
|
||||
Evicting on the "absent" path closes that gap.
|
||||
|
||||
## Features
|
||||
|
||||
- Post-success eviction — inner write completes, then cache evicts.
|
||||
- Inner `false` (key absent) still evicts cache to clear stale entries.
|
||||
- Failed `inner.delete` propagates; cache is NOT touched.
|
||||
- `invalidateOnDelete: false` opts out of eviction entirely (consumer
|
||||
manages cache invalidation out of band).
|
||||
- Cache `delete` rejections swallow and bump `errors` (via the shared
|
||||
`invalidateKey` helper in `cached-storage.write.ts`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { deleteThroughCache } from "./cached-storage.delete.js";
|
||||
|
||||
const removed = await deleteThroughCache(ctx, "clip.mov", undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { deleteThroughCache } from "./cached-storage.delete.js";
|
||||
|
||||
// Opt-out path: a higher-level orchestrator owns invalidation.
|
||||
const ctxNoInvalidate: CachedStorageContext = {
|
||||
...ctx,
|
||||
options: { ...ctx.options, invalidateOnDelete: false },
|
||||
};
|
||||
const removed = await deleteThroughCache(ctxNoInvalidate, "video.bin", signal);
|
||||
if (removed) await ownOrchestrator.evict("video.bin");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `deleteThroughCache(ctx, key, signal?): Promise<boolean>` — delegates
|
||||
to `inner.delete`, then evicts the cache entry (via
|
||||
`invalidateKey` from `cached-storage.write.ts`) when
|
||||
`invalidateOnDelete !== false`. Returns the inner's boolean verbatim.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The "evict even on `false`" behaviour is explicitly documented in the
|
||||
spec (`### Requirement: delete invalidates the cache entry post-success`)
|
||||
because the alternative leaves the wrapper holding bytes that the inner
|
||||
no longer agrees with. The shared `invalidateKey` helper in
|
||||
`cached-storage.write.ts` is reused unchanged — same eviction
|
||||
semantics, same error swallow, same stats bookkeeping. Live abort
|
||||
signals are forwarded into `inner.delete`; the inner is responsible for
|
||||
raising the canonical aborted error. Pre-aborted signals are NOT
|
||||
specifically short-circuited here — the inner storage handles the
|
||||
canonical error path, and the cache eviction never fires because
|
||||
`inner.delete` rejects before we reach the `invalidateKey` call.
|
||||
@@ -0,0 +1,76 @@
|
||||
# cached-storage.get — read-through implementation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the read-through path of `CachedStorage.get(key, signal?)`.
|
||||
The implementation first consults the injected `AbstractCache<Uint8Array>`;
|
||||
on a hit it returns the cached bytes without touching the inner storage,
|
||||
on a miss it forwards to `inner.get(key, signal)` and (when the result
|
||||
passes the `shouldCache` predicate) populates the cache for the next
|
||||
read. Every cache-side failure is swallowed so a transient cache outage
|
||||
never turns a successful inner read into a thrown error.
|
||||
|
||||
The function is exported separately from the class so the file stays
|
||||
well under the 300-line ceiling and the tests can assert the
|
||||
read-through state machine directly without instantiating the class.
|
||||
|
||||
## Features
|
||||
|
||||
- Pre-aborted signal short-circuit — forwards to `inner.get(key, signal)`
|
||||
so the inner raises the canonical `STORAGE.ABORTED` error.
|
||||
- Namespaced cache key (`${namespace}::${key}`) computed via
|
||||
`cacheKeyOf`.
|
||||
- Cache hit returns the cached bytes; bumps `hits`; emits a hit-outcome
|
||||
span and counter increment.
|
||||
- Cache miss bumps `misses`, forwards to inner; on a non-`undefined`
|
||||
result, applies `shouldCache(key, bytes)` and (on pass) populates
|
||||
via `cache.set(cacheKey, bytes)`. Populations bump `populations` and
|
||||
observe `entry_bytes`.
|
||||
- `cache.get` rejection: swallow, bump `errors`, fall through to inner.
|
||||
- `cache.set` rejection: swallow, bump `errors`, still return the bytes
|
||||
from the inner read.
|
||||
- `inner.get` returning `undefined` is never cached (no negative caching).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getThroughCache } from "./cached-storage.get.js";
|
||||
|
||||
const bytes = await getThroughCache(ctx, "clip.mov", undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { getThroughCache } from "./cached-storage.get.js";
|
||||
import { cacheKeyOf } from "./cached-storage.context.js";
|
||||
|
||||
// Stale-while-revalidate over the wrapper: read through the cache,
|
||||
// then refresh in the background via the underlying cache.wrap().
|
||||
const bytes = await getThroughCache(ctx, "clip.mov", undefined);
|
||||
queueMicrotask(() =>
|
||||
ctx.cache.wrap(cacheKeyOf(ctx, "clip.mov"), () => ctx.inner.get("clip.mov")),
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getThroughCache(ctx, key, signal?): Promise<Uint8Array | undefined>` —
|
||||
read-through. `ctx: CachedStorageContext`. Mutates `ctx.stats` and
|
||||
emits OTel records on every code path.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We deliberately call `inner.get` even when the cache returned a
|
||||
rejection (rather than re-throwing the cache's error to the caller)
|
||||
because the wrapper's correctness boundary is the inner storage: a
|
||||
cache fault is a performance regression, never a data fault. The
|
||||
post-hit short-circuit also means a healthy cache observes the inner
|
||||
storage exactly once for the key's lifetime, which is the entire point
|
||||
of the decorator. When `shouldCache` returns `false`, the population
|
||||
counter does NOT increment and the entry is never written to the cache;
|
||||
the next call will re-issue a miss and another `inner.get`. Consumers
|
||||
who want strict size-only filtering should leave `shouldCache` at its
|
||||
default (`bytes.byteLength <= maxEntryBytes`); custom predicates can
|
||||
inspect the key (e.g. only cache `*.json` paths) or the bytes (e.g.
|
||||
skip identifiable garbage).
|
||||
@@ -0,0 +1,108 @@
|
||||
# cached-storage — `CachedStorage` class
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`CachedStorage` is a read-through cache decorator that extends
|
||||
`AbstractStorage` from `@suaveplan/storage`. It wraps an inner
|
||||
`AbstractStorage` instance with an injectable
|
||||
`AbstractCache<Uint8Array>` so every `get(key)` consults the cache
|
||||
first, falls through to the inner storage on miss, and populates the
|
||||
cache on success. Writes (`set`, `setPartial`), deletes, and the bulk
|
||||
`clear` invalidate the corresponding cache entry post-success.
|
||||
|
||||
Because the class extends `AbstractStorage`, every consumer in the
|
||||
ecosystem that takes an `AbstractStorage` (drive, storage-uri
|
||||
plugins, application code that accepts a `StorageLike`) accepts a
|
||||
`CachedStorage` with zero adjustment.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every abstract method on `AbstractStorage`: `get`, `set`,
|
||||
`setPartial`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`,
|
||||
`getMetadata`, `getSize`, `getItemSize`, `getPartial`,
|
||||
`createReadStream`, `createWriteStream`.
|
||||
- Exposes `readonly cache: AbstractCache<Uint8Array>` and
|
||||
`readonly inner: AbstractStorage` so consumers can introspect the
|
||||
wrapped instances or call `cache.wrap()` for stale-while-revalidate
|
||||
on top of the wrapper.
|
||||
- `getCacheStats()` returns a fresh snapshot of the wrapper's own
|
||||
counters (`hits`, `misses`, `populations`, `invalidations`, `errors`).
|
||||
Distinct from `AbstractStorage.getStats()` (inherited) which counts
|
||||
the inner storage's operations.
|
||||
- Synchronous validation: nullish inner throws
|
||||
`STORAGE_CACHE.INNER_MISSING`; missing/malformed cache throws
|
||||
`STORAGE_CACHE.INVALID_OPTIONS`.
|
||||
- Per-method helpers in sibling files (`.get.ts`, `.write.ts`,
|
||||
`.delete.ts`, `.clear.ts`, `.passthrough.ts`, `.signal.ts`) keep the
|
||||
class file well under the 300-line ceiling and isolate each behaviour
|
||||
for testing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CachedStorage } from "@suaveplan/storage-plugin-cache/cached-storage";
|
||||
import { MemoryCache } from "@suaveplan/cache";
|
||||
import { NodeFsStorage } from "@suaveplan/storage-file";
|
||||
|
||||
const inner = new NodeFsStorage({ rootDir: "/var/media" });
|
||||
const cache = new MemoryCache<Uint8Array>();
|
||||
const wrapper = new CachedStorage(inner, { cache });
|
||||
|
||||
const bytes = await wrapper.get("clip.mov");
|
||||
await wrapper.set("clip.mov", new Uint8Array([1, 2, 3]));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CachedStorage } from "@suaveplan/storage-plugin-cache/cached-storage";
|
||||
|
||||
const wrapper = new CachedStorage(inner, {
|
||||
cache,
|
||||
namespace: "media",
|
||||
shouldCache: (key, bytes) =>
|
||||
key.endsWith(".json") || bytes.byteLength < 256 * 1024,
|
||||
invalidateOnClear: false, // application owns clear-time eviction
|
||||
});
|
||||
|
||||
// Inspect the underlying cache and run stale-while-revalidate.
|
||||
const bytes = await wrapper.cache.wrap(
|
||||
`media::clip.mov`,
|
||||
() => wrapper.inner.get("clip.mov"),
|
||||
{ staleAge: 60_000 },
|
||||
);
|
||||
|
||||
console.log(wrapper.getCacheStats());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new CachedStorage(inner, options)` — constructor. Throws
|
||||
`STORAGE_CACHE.INNER_MISSING` / `INVALID_OPTIONS` synchronously.
|
||||
- `readonly cache: AbstractCache<Uint8Array>`.
|
||||
- `readonly inner: AbstractStorage`.
|
||||
- `getCacheStats(): CachedStorageStats` — fresh snapshot.
|
||||
- Every `AbstractStorage` abstract method implemented.
|
||||
- `validateOptions(options)` — exported synchronous validator used by
|
||||
the factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class holds a private `CachedStorageContext` containing the inner
|
||||
reference, the cache reference, the resolved options bag, and the
|
||||
mutable stats object. Each abstract method delegates to a helper
|
||||
function in a sibling file that takes the context plus the method's
|
||||
arguments. This split is mandatory to stay under the 300-line per-file
|
||||
ceiling — the alternative would be a monolithic class file that mixes
|
||||
unrelated state machines. The helpers do not import the class
|
||||
directly, so the dependency graph in `src/cached-storage/` is a
|
||||
DAG with `cached-storage.ts` at the top and the per-op files as leaves.
|
||||
Inherited methods like `batch`, `namespace`, `getdel`, `mdelete`,
|
||||
`listWithPattern`, `getStats`, `resetStats`, and event/hook surfaces
|
||||
on `AbstractStorage` are NOT overridden — they call through to our
|
||||
own `get`/`set`/`delete`/`list` and pick up the cache layer
|
||||
transparently.
|
||||
@@ -0,0 +1,86 @@
|
||||
# cached-storage.passthrough — pure delegates
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the passthrough surface of `CachedStorage` — every
|
||||
abstract method on `AbstractStorage` that does NOT participate in the
|
||||
cache layer. Nine helpers delegate verbatim to the inner storage:
|
||||
`exists`, `list`, `getWithMetadata`, `getMetadata`, `getSize`,
|
||||
`getItemSize`, `getPartial`, `createReadStream`, `createWriteStream`.
|
||||
None of them consult or mutate the cache.
|
||||
|
||||
The wrapper's value-add is intentionally scoped to whole-object reads
|
||||
(`get`) and whole-object invalidations (`set`, `setPartial`, `delete`,
|
||||
`clear`). Caching metadata, byte ranges, or stream chunks belongs at a
|
||||
different tier — see `@suaveplan/storage-http`'s `EtagCache` for ETag
|
||||
caching, and the planned `cacheUriPlugin` for URI-resolver caching.
|
||||
|
||||
## Features
|
||||
|
||||
- Nine pure delegates, one per abstract method.
|
||||
- Return values flow through unchanged — `StorageListResult`,
|
||||
`StorageResult<Uint8Array> | null`, `StorageMetadata | null`,
|
||||
`number`, `number | null`, `Uint8Array | null`,
|
||||
`ReadableStream<Uint8Array>`, `WritableStream<Uint8Array>`.
|
||||
- Signal forwarded — `inner.list(opts, signal)`,
|
||||
`inner.exists(key, signal)`, etc.
|
||||
- Zero state mutation on the wrapper — no stats counter increments, no
|
||||
cache calls, no OTel records.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { existsPassthrough } from "./cached-storage.passthrough.js";
|
||||
|
||||
const yes = await existsPassthrough(ctx, "clip.mov", undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
listPassthrough,
|
||||
createReadStreamPassthrough,
|
||||
} from "./cached-storage.passthrough.js";
|
||||
|
||||
// Listing is a passthrough — pagination semantics are owned entirely
|
||||
// by the inner storage.
|
||||
const page = await listPassthrough(
|
||||
ctx,
|
||||
{ prefix: "media/", limit: 100 },
|
||||
signal,
|
||||
);
|
||||
|
||||
// Streaming reads bypass the cache by design. Consumers needing
|
||||
// cached streaming should layer a URI-tier cache on top.
|
||||
const stream = createReadStreamPassthrough(ctx, "video.bin", signal);
|
||||
for await (const chunk of stream) console.log(chunk.byteLength);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every helper takes `(ctx: CachedStorageContext, …args)` and returns
|
||||
exactly what the matching `AbstractStorage` abstract method returns:
|
||||
|
||||
- `existsPassthrough(ctx, key, signal?)`
|
||||
- `listPassthrough(ctx, options?, signal?)`
|
||||
- `getWithMetadataPassthrough(ctx, key, signal?)`
|
||||
- `getMetadataPassthrough(ctx, key, signal?)`
|
||||
- `getSizePassthrough(ctx, signal?)`
|
||||
- `getItemSizePassthrough(ctx, key, signal?)`
|
||||
- `getPartialPassthrough(ctx, key, offset, length, signal?)`
|
||||
- `createReadStreamPassthrough(ctx, key, signal?)`
|
||||
- `createWriteStreamPassthrough(ctx, key, signal?)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers are written as one-liners that return the inner's promise
|
||||
or stream directly, so there is no asynchronous bookkeeping in the
|
||||
wrapper for any passthrough op. The test suite asserts this property
|
||||
explicitly: every passthrough test installs a fake cache whose
|
||||
`get`/`set`/`delete` would throw if invoked, then exercises the
|
||||
passthrough method and verifies neither the failure nor a side-effect
|
||||
on the cache counters fires. This matters because adding cache logic
|
||||
to (say) `getMetadata` later would silently introduce an invalidation
|
||||
surface that consumers don't expect; keeping the passthrough wall
|
||||
explicit prevents accidental scope creep.
|
||||
@@ -0,0 +1,67 @@
|
||||
# cached-storage.signal — pre-aborted AbortSignal short-circuit
|
||||
|
||||
## Purpose
|
||||
|
||||
This helper centralises the wrapper's policy for a pre-aborted
|
||||
`AbortSignal`: every public method that takes a signal MUST consult
|
||||
`isPreAborted(signal)` before touching the cache and, when the signal
|
||||
is already aborted, MUST forward the call directly to the inner
|
||||
storage. The inner is then responsible for raising the canonical
|
||||
`STORAGE.ABORTED` (or backend-specific) error.
|
||||
|
||||
Centralising the check in one tiny function keeps the policy uniform
|
||||
across `get`, `set`, `setPartial`, `delete`, and `clear`. The function
|
||||
is intentionally simple — `signal?.aborted === true` — so the contract
|
||||
matches the standard `AbortSignal` semantics with no surprise.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-statement pre-abort check used by every public op.
|
||||
- Returns `false` when `signal` is `undefined` (no signal supplied).
|
||||
- Returns `true` only when the signal exists AND `signal.aborted === true`.
|
||||
- Zero allocations, zero side effects.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isPreAborted } from "./cached-storage.signal.js";
|
||||
|
||||
if (isPreAborted(signal)) {
|
||||
return inner.get(key, signal);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { isPreAborted } from "./cached-storage.signal.js";
|
||||
|
||||
async function readThrough(
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
hot: () => Promise<Uint8Array | undefined>,
|
||||
cold: () => Promise<Uint8Array | undefined>,
|
||||
): Promise<Uint8Array | undefined> {
|
||||
if (isPreAborted(signal)) return cold();
|
||||
const hit = await hot();
|
||||
return hit ?? cold();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `isPreAborted(signal: AbortSignal | undefined): boolean` — `true` when
|
||||
`signal` is non-nullish and `signal.aborted === true`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We deliberately do NOT call `signal.throwIfAborted()` here — the
|
||||
contract is that the inner storage raises the canonical aborted error
|
||||
when it sees a pre-aborted signal, so the wrapper just forwards. This
|
||||
keeps the error code on the resulting throwable consistent with what a
|
||||
direct call against the inner would produce (e.g. `STORAGE.ABORTED`
|
||||
from `@suaveplan/storage`, `STORAGE_HTTP.ABORTED` from
|
||||
`@suaveplan/storage-http`). Live signals (non-aborted at call time but
|
||||
that may abort mid-flight) are not handled here; they are threaded into
|
||||
the inner storage's call and through to the cache (when the backend
|
||||
accepts a signal argument) by the individual op helpers.
|
||||
@@ -0,0 +1,77 @@
|
||||
# cached-storage.write — set / setPartial write-through
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the write-through path of `CachedStorage.set` and
|
||||
`CachedStorage.setPartial`. Both ops invoke the inner storage first;
|
||||
only on successful resolution do they invalidate the corresponding
|
||||
cache entry. A failed inner write propagates and the cache entry is
|
||||
left intact — partial cache invalidation on a failed write would leave
|
||||
the cache in a state inconsistent with the inner store.
|
||||
|
||||
`setPartial` invalidates the whole entry (not the modified range)
|
||||
because the wrapper never caches byte ranges; the only invariant the
|
||||
cache enforces is "if the entry exists, it equals the latest
|
||||
`inner.get(key)` result". A partial write to any byte makes the cached
|
||||
entry stale as a whole, so we evict it.
|
||||
|
||||
## Features
|
||||
|
||||
- Post-success invalidation — write completes, then cache evicts. The
|
||||
pre-write alternative opens a race window where a concurrent read
|
||||
could repopulate the cache with the pre-write bytes mid-flight.
|
||||
- `invalidateOnWrite: false` skips invalidation for both `set` and
|
||||
`setPartial` — consumers who manage their own consistency can opt out.
|
||||
- Cache `delete` failures swallow and bump `errors`.
|
||||
- Invalidations are namespaced — `${namespace}::${key}` — so wrappers
|
||||
sharing one cache instance with distinct namespaces never collide.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { setWriteThrough } from "./cached-storage.write.js";
|
||||
|
||||
await setWriteThrough(ctx, "clip.mov", bytes, undefined, undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
setWriteThrough,
|
||||
setPartialWriteThrough,
|
||||
invalidateKey,
|
||||
} from "./cached-storage.write.js";
|
||||
|
||||
// 1. Targeted write-through using a non-default StorageOptions bag.
|
||||
await setWriteThrough(ctx, "video.bin", merged, { ttl: 60_000 }, signal);
|
||||
|
||||
// 2. Partial write that evicts the whole entry.
|
||||
await setPartialWriteThrough(ctx, "video.bin", 100, patch, signal);
|
||||
|
||||
// 3. Manually invalidate from a higher-level orchestrator without
|
||||
// re-running the inner write (e.g. after an out-of-band mutation).
|
||||
await invalidateKey(ctx, "video.bin");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `setWriteThrough(ctx, key, value, options?, signal?): Promise<void>` —
|
||||
delegates to `inner.set` then evicts the cache entry on success when
|
||||
`invalidateOnWrite !== false`.
|
||||
- `setPartialWriteThrough(ctx, key, offset, value, signal?): Promise<void>` —
|
||||
delegates to `inner.setPartial`, then evicts the whole entry on success
|
||||
when `invalidateOnWrite !== false`.
|
||||
- `invalidateKey(ctx, key): Promise<void>` — pure cache eviction with
|
||||
error swallow + stats. Re-used by `delete` (in `cached-storage.delete.ts`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
We pass `undefined` through to `inner.set`'s `options` argument
|
||||
verbatim — the wrapper never strips or augments per-call storage
|
||||
options. `setPartial`'s whole-entry invalidation is conservative and
|
||||
correct; the only alternative (caching byte ranges) is out of scope
|
||||
because range invalidation across overlapping writes is a separate
|
||||
design problem (see proposal §"Out of scope"). The `invalidateKey`
|
||||
helper is exported because the `delete` op shares the same eviction
|
||||
semantics; co-locating it here keeps the eviction logic in one place.
|
||||
@@ -0,0 +1,91 @@
|
||||
# error-codes — STORAGE_CACHE\_\* registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the canonical registry of error codes raised by
|
||||
`@suaveplan/storage-plugin-cache`. The package deliberately keeps its own
|
||||
error surface narrow: only synchronous configuration faults at construction
|
||||
time map to a `STORAGE_CACHE.*` code. Every other failure — inner storage
|
||||
errors (`STORAGE.*`, `STORAGE_HTTP.*`, …), cache backend errors (`CACHE.*`)
|
||||
— flows through the wrapper with its original namespaced code intact so
|
||||
the consumer can route on the real root cause.
|
||||
|
||||
Two codes are defined: `STORAGE_CACHE.INVALID_OPTIONS` (raised when the
|
||||
options bag is missing `cache` or the supplied cache lacks `get`/`set`/
|
||||
`delete`) and `STORAGE_CACHE.INNER_MISSING` (raised when the wrapped
|
||||
inner storage is nullish). Both are produced via `defineCodes` so the
|
||||
emitted strings (`"STORAGE_CACHE.INVALID_OPTIONS"`, `"STORAGE_CACHE.INNER_MISSING"`)
|
||||
are stable, type-safe, and reachable from concrete call sites.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen registry produced by `defineCodes("STORAGE_CACHE", […])` —
|
||||
no enums, no string drift.
|
||||
- `CachedStorageErrorCode` discriminated union for exhaustive switch
|
||||
coverage.
|
||||
- `CachedStorageError` subclass of `SuaveplanError<CachedStorageErrorCode>`
|
||||
carrying optional `key` and `namespace` discriminants so operators can
|
||||
correlate failures with the wrapper instance.
|
||||
- Both codes reachable from `factory.ts` / `cached-storage.ts` —
|
||||
verified by `error-codes.test.ts`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
STORAGE_CACHE_CODES,
|
||||
CachedStorageError,
|
||||
} from "@suaveplan/storage-plugin-cache/error-codes";
|
||||
|
||||
throw new CachedStorageError(
|
||||
STORAGE_CACHE_CODES.INVALID_OPTIONS,
|
||||
"cache option is required",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
STORAGE_CACHE_CODES,
|
||||
CachedStorageError,
|
||||
type CachedStorageErrorCode,
|
||||
} from "@suaveplan/storage-plugin-cache/error-codes";
|
||||
|
||||
function describe(err: CachedStorageError): string {
|
||||
const code: CachedStorageErrorCode = err.code;
|
||||
switch (code) {
|
||||
case STORAGE_CACHE_CODES.INNER_MISSING:
|
||||
return `inner storage missing (namespace=${err.namespace ?? "?"})`;
|
||||
case STORAGE_CACHE_CODES.INVALID_OPTIONS:
|
||||
return `invalid options (key=${err.key ?? "?"})`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `STORAGE_CACHE_CODES` — frozen record `{ INVALID_OPTIONS, INNER_MISSING }`.
|
||||
- `CachedStorageErrorCode` — `(typeof STORAGE_CACHE_CODES)[keyof …]` union.
|
||||
- `CachedStorageErrorOptions extends SuaveplanErrorOptions` — adds
|
||||
`key?: string` and `namespace?: string`.
|
||||
- `CachedStorageError extends SuaveplanError<CachedStorageErrorCode>` —
|
||||
constructor `(code, message, options?)`. Sets `this.name`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is built via `defineCodes` from `@suaveplan/error` so the
|
||||
namespace prefix (`"STORAGE_CACHE"`) and reasons (`"INVALID_OPTIONS"`,
|
||||
`"INNER_MISSING"`) cannot drift apart at runtime; the type system enforces
|
||||
that every member resolves to the fully-qualified `STORAGE_CACHE.<REASON>`
|
||||
string. The error class derives directly from `SuaveplanError` so the
|
||||
cause chain, redaction hook, and OTel span-event adapter on the base
|
||||
class all apply unchanged. We intentionally do NOT shadow the existing
|
||||
`CachedStorageError` exported by `@suaveplan/cache-storage` (that one
|
||||
carries `CACHE_STORAGE.*` codes) — package-qualified imports
|
||||
(`@suaveplan/storage-plugin-cache/error-codes`) disambiguate when
|
||||
consumers happen to depend on both packages at once.
|
||||
@@ -0,0 +1,85 @@
|
||||
# factory — `cachedStorage(inner, options)`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exports the `cachedStorage(inner, options)` factory — a
|
||||
thin convenience over `new CachedStorage(inner, options)`. The factory
|
||||
exists so consumers can adopt the same call shape as the rest of the
|
||||
ecosystem (`createCache`, `createHttpStorage`, `createNodeFsStorage`,
|
||||
…) without choosing between `new` and a function call. The factory
|
||||
delegates entirely to the constructor; every validation is performed
|
||||
synchronously by `validateOptions` inside the class itself.
|
||||
|
||||
The factory's surface is one line of implementation by design: the
|
||||
validation logic, the `cache`/`inner`/`getCacheStats` accessors, and
|
||||
the full `AbstractStorage` method set all live on the class. Splitting
|
||||
the factory into a separate module keeps the import boundary clean
|
||||
(consumers only have to import one symbol to build the wrapper).
|
||||
|
||||
## Features
|
||||
|
||||
- Single-line wrapper that returns `new CachedStorage(inner, options)`.
|
||||
- Inherits every synchronous validation: nullish `inner` →
|
||||
`STORAGE_CACHE.INNER_MISSING`; missing/malformed `options.cache` →
|
||||
`STORAGE_CACHE.INVALID_OPTIONS`.
|
||||
- Returns a fully-typed `CachedStorage` (not `AbstractStorage`) so
|
||||
consumers retain access to the wrapper-specific surface (`cache`,
|
||||
`inner`, `getCacheStats`) without a cast.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```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 wrapper = cachedStorage(inner, { cache });
|
||||
|
||||
const bytes = await wrapper.get("clip.mov");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { cachedStorage } from "@suaveplan/storage-plugin-cache";
|
||||
|
||||
const wrapper = cachedStorage(inner, {
|
||||
cache,
|
||||
namespace: "media-prod",
|
||||
invalidateOnWrite: true,
|
||||
invalidateOnClear: false,
|
||||
shouldCache: (key, bytes) =>
|
||||
key.startsWith("hot/") && bytes.byteLength < 1_048_576,
|
||||
maxEntryBytes: 4 * 1024 * 1024,
|
||||
logger: appLogger,
|
||||
});
|
||||
|
||||
// The returned instance is structurally an AbstractStorage,
|
||||
// so it composes with anything that takes one.
|
||||
const drive = new Drive({ defaultStorage: wrapper });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `cachedStorage(inner: AbstractStorage, options: CachedStorageOptions): CachedStorage`
|
||||
— returns a new `CachedStorage`. Throws `CachedStorageError` with code
|
||||
`STORAGE_CACHE.INNER_MISSING` (nullish inner) or
|
||||
`STORAGE_CACHE.INVALID_OPTIONS` (missing/malformed cache) synchronously.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory is a pure forwarding function so its behavior matches the
|
||||
constructor's exactly — there is no surface-level difference between
|
||||
`new CachedStorage(inner, opts)` and `cachedStorage(inner, opts)`.
|
||||
Consumers picking between the two should base the choice on style
|
||||
(factory for functional composition, `new` for explicit class
|
||||
instantiation). Validation lives on the class so the same errors fire
|
||||
regardless of entry point. Future extensions (e.g. lazy plugin
|
||||
registries that decorate the result) can layer on top of the factory
|
||||
without changing the class contract.
|
||||
@@ -0,0 +1,89 @@
|
||||
# otel — OpenTelemetry instrumentation for the storage-cache decorator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module wires the OpenTelemetry meter and tracer used by every
|
||||
`CachedStorage` instance. The wrapper emits five counters plus one
|
||||
histogram plus per-operation spans on a fixed `@suaveplan/storage-cache`
|
||||
meter/tracer name, so a single application can observe every cache
|
||||
decorator in the process without per-instance wiring.
|
||||
|
||||
The instruments are built lazily on first access and cached. An optional
|
||||
`setStorageCacheMeterProvider(provider)` hook lets test code (or
|
||||
unusual hosting setups) override the global `MeterProvider` without
|
||||
restarting the process. `resetInstruments()` clears the cached bag so a
|
||||
subsequent access binds to the active provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Counters: `storage_cache.{hits,misses,populations,invalidations,errors}_total{namespace}`.
|
||||
- Histogram: `storage_cache.entry_bytes{namespace}` observing each
|
||||
population with the cached entry's byte length.
|
||||
- Spans: one per public op tagged with
|
||||
`storage.cache.namespace`, `storage.cache.key`, and
|
||||
`storage.cache.outcome ∈ {hit, miss, populated, invalidated, error}`.
|
||||
- `errors_total` carries an additional `op ∈ {get, set, delete, clear}`
|
||||
label so operators can attribute a failure to the specific surface.
|
||||
- Provider override + reset hooks for test isolation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordHit } from "@suaveplan/storage-plugin-cache/otel";
|
||||
|
||||
recordHit("media", "clip.mov");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstruments,
|
||||
resetInstruments,
|
||||
setStorageCacheMeterProvider,
|
||||
} from "@suaveplan/storage-plugin-cache/otel";
|
||||
|
||||
// Inject a test MeterProvider before exercising the wrapper.
|
||||
setStorageCacheMeterProvider(testProvider);
|
||||
resetInstruments();
|
||||
const inst = getInstruments();
|
||||
console.log(inst.tracer, inst.meter);
|
||||
|
||||
// Revert once the test completes.
|
||||
setStorageCacheMeterProvider(undefined);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `setStorageCacheMeterProvider(provider | undefined)` — install/clear
|
||||
the override `MeterProvider`. Resets the cached instrument bag.
|
||||
- `getInstruments(force?)` — return the cached bag, building it on
|
||||
first call. `force === true` rebuilds against the active provider.
|
||||
- `resetInstruments()` — clear the cached bag.
|
||||
- `recordHit(namespace, key)` — bump `hits_total`, emit a span.
|
||||
- `recordMiss(namespace, key)` — bump `misses_total`, emit a span.
|
||||
- `recordPopulate(namespace, key, bytes)` — bump `populations_total`,
|
||||
observe `entry_bytes`, emit a span.
|
||||
- `recordInvalidate(namespace, key)` — bump `invalidations_total`,
|
||||
emit a span.
|
||||
- `recordError(op, namespace, key)` — bump `errors_total{op}`, emit a span.
|
||||
- `StorageCacheOutcome` — `"hit" | "miss" | "populated" | "invalidated" | "error"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cached instrument bag binds the counters and histogram to whichever
|
||||
`MeterProvider` was active at first access. Tests that swap providers
|
||||
between assertions MUST call `resetInstruments()` (or the
|
||||
`setStorageCacheMeterProvider(provider)` helper, which calls it
|
||||
internally) to drop the stale bag before the next access. Spans are
|
||||
ended synchronously inside the `record*` helpers so callers do not need
|
||||
to manage a span lifecycle — every emitted span represents one
|
||||
observable outcome at the moment of the cache decision. Span names
|
||||
match the operation surface (`storage_cache.get`, `storage_cache.populate`,
|
||||
`storage_cache.invalidate`, `storage_cache.set` etc.) so a tracing
|
||||
backend's flame graph reflects the cache decorator's behaviour
|
||||
distinctly from the inner storage's spans.
|
||||
Reference in New Issue
Block a user