Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.