first commit
This commit is contained in:
@@ -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