Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,117 @@
{
"name": "@suaveplan/storage-plugin-cache",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/error-codes.js"
},
"require": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./cached-storage": {
"import": {
"types": "./dist/cached-storage/index.d.ts",
"default": "./dist/cached-storage/cached-storage.js"
},
"require": {
"types": "./dist/cached-storage/index.d.ts",
"default": "./dist/cached-storage/cached-storage.js"
}
},
"./factory": {
"import": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/factory.js"
},
"require": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/factory.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/otel.js"
},
"require": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/otel.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/cache": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/cache-storage": "workspace:^",
"@suaveplan/storage-file": "workspace:^",
"@suaveplan/storage-http": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,115 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import {
AsyncKeysCapableFakeCache,
FakeCache,
KeysCapableFakeCache,
} from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
describe("CachedStorage.clear", () => {
it("clear evicts namespaced entries only when cache supports keys()", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new KeysCapableFakeCache();
const wrapper = cachedStorage(inner, { cache, namespace: "storage" });
cache._seed("storage::a", new Uint8Array([1]));
cache._seed("storage::b", new Uint8Array([2]));
cache._seed("other::z", new Uint8Array([9]));
await wrapper.clear();
expect(cache.deleteCalls.sort()).toEqual(["storage::a", "storage::b"]);
expect(cache._peek("other::z")).toEqual(new Uint8Array([9]));
expect(wrapper.getCacheStats().invalidations).toBe(2);
});
it("clear evicts namespaced entries via async keys() iterator", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new AsyncKeysCapableFakeCache();
const wrapper = cachedStorage(inner, { cache, namespace: "media" });
cache._seed("media::a", new Uint8Array([1]));
cache._seed("other::z", new Uint8Array([9]));
await wrapper.clear();
expect(cache.deleteCalls).toEqual(["media::a"]);
expect(cache._peek("other::z")).toEqual(new Uint8Array([9]));
});
it("clear logs a warning when cache lacks keys()", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const warnCalls: {
msg: string;
ctx?: Readonly<Record<string, unknown>>;
}[] = [];
const logger: LoggerLike = {
debug: () => {},
info: () => {},
warn: (msg, ctx) => warnCalls.push({ msg, ctx }),
error: () => {},
};
const wrapper = cachedStorage(inner, { cache, logger });
await wrapper.clear();
expect(warnCalls.length).toBeGreaterThanOrEqual(1);
expect(warnCalls[0]?.msg).toContain("[storage-cache]");
expect(cache.clearCalls).toBe(0);
expect(wrapper.getCacheStats().invalidations).toBe(0);
});
it("clear skips quietly when no logger is supplied and keys() is absent", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
await wrapper.clear();
expect(cache.clearCalls).toBe(0);
expect(cache.deleteCalls).toEqual([]);
});
it("clear swallows iteration errors and bumps the error counter", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new KeysCapableFakeCache();
cache._seed("storage::a", new Uint8Array([1]));
// Force the cache.delete inside the iteration loop to throw.
cache.failNext("delete", new Error("cache-down"));
const wrapper = cachedStorage(inner, { cache });
await wrapper.clear();
expect(wrapper.getCacheStats().errors).toBe(1);
});
it("invalidateOnClear:false skips namespaced cleanup", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new KeysCapableFakeCache();
cache._seed("storage::a", new Uint8Array([1]));
const wrapper = cachedStorage(inner, {
cache,
invalidateOnClear: false,
});
await wrapper.clear();
expect(cache.keysCalls).toBe(0);
expect(cache.deleteCalls).toEqual([]);
expect(cache.clearCalls).toBe(0);
});
it("propagates inner.clear rejections without touching the cache", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new KeysCapableFakeCache();
cache._seed("storage::a", new Uint8Array([1]));
inner.failNext("clear", new Error("disk-down"));
const wrapper = cachedStorage(inner, { cache });
await expect(wrapper.clear()).rejects.toThrow("disk-down");
expect(cache.deleteCalls).toEqual([]);
expect(cache.keysCalls).toBe(0);
});
});

View File

@@ -0,0 +1,59 @@
import { recordError, recordInvalidate } from "../otel/otel.js";
import type { CachedStorageContext } from "./cached-storage.context.js";
/**
* Structural view of the optional `keys()` method an `AbstractCache`
* implementation MAY expose. The canonical `AbstractCache` in
* `@suaveplan/cache` does NOT mandate `keys()`; the wrapper
* feature-detects at runtime.
*/
interface CacheWithKeys {
keys(): AsyncIterable<string> | Iterable<string>;
}
function hasKeys(cache: unknown): cache is CacheWithKeys {
return typeof (cache as { keys?: unknown } | null)?.keys === "function";
}
/**
* `clear(signal?)` — clears the inner storage, then evicts the
* wrapper's namespaced subset of the cache.
*
* When the underlying `AbstractCache` exposes `keys()`, the wrapper
* iterates and deletes every entry whose key starts with
* `${namespace}::`. When `keys()` is NOT exposed, the wrapper logs a
* warning via `options.logger?.warn?.(...)` and SKIPS the eviction —
* calling `cache.clear()` would obliterate other wrappers' entries
* sharing the same cache.
*/
export async function clearThroughCache(
ctx: CachedStorageContext,
signal: AbortSignal | undefined
): Promise<void> {
await ctx.inner.clear(signal);
if (!ctx.options.invalidateOnClear) return;
const namespace = ctx.options.namespace;
const prefix = `${namespace}::`;
if (!hasKeys(ctx.cache)) {
ctx.options.logger?.warn?.(
"[storage-cache] cache backend has no keys() — clear() did not evict cache entries",
{ namespace }
);
return;
}
try {
for await (const k of ctx.cache.keys()) {
if (k.startsWith(prefix)) {
await ctx.cache.delete(k);
ctx.stats.invalidations += 1;
recordInvalidate(namespace, k.slice(prefix.length));
}
}
} catch {
ctx.stats.errors += 1;
recordError("clear", namespace, "*");
}
}

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,28 @@
import type { AbstractCache } from "@suaveplan/cache";
import type { AbstractStorage } from "@suaveplan/storage";
import type {
CachedStorageStats,
ResolvedCachedStorageOptions,
} from "../types/options.types.js";
/**
* Internal context object shared by `CachedStorage`'s helper files.
*
* Keeps the helper functions in `cached-storage.get.ts`,
* `cached-storage.write.ts`, etc. decoupled from the class definition
* so each implementation file stays well under the 300-line ceiling.
*/
export interface CachedStorageContext {
readonly inner: AbstractStorage;
readonly cache: AbstractCache<Uint8Array>;
readonly options: ResolvedCachedStorageOptions;
readonly stats: CachedStorageStats;
}
/** Compute the namespaced cache key for a given storage key. */
export function cacheKeyOf(
ctx: Pick<CachedStorageContext, "options">,
key: string
): string {
return `${ctx.options.namespace}::${key}`;
}

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,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
describe("CachedStorage.delete", () => {
it("successful delete invalidates the cache entry", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("x.bin", new Uint8Array([1]));
cache._seed("storage::x.bin", new Uint8Array([1]));
const out = await wrapper.delete("x.bin");
expect(out).toBe(true);
expect(cache.deleteCalls).toEqual(["storage::x.bin"]);
expect(wrapper.getCacheStats().invalidations).toBe(1);
});
it("delete returns false when inner reports absent and still invalidates", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache._seed("storage::x.bin", new Uint8Array([1]));
const out = await wrapper.delete("x.bin");
expect(out).toBe(false);
expect(cache.deleteCalls).toEqual(["storage::x.bin"]);
expect(wrapper.getCacheStats().invalidations).toBe(1);
});
it("failed delete does not invalidate", { timeout: 5000 }, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.failNext("delete", new Error("disk-down"));
await expect(wrapper.delete("x.bin")).rejects.toThrow("disk-down");
expect(cache.deleteCalls).toEqual([]);
});
it("invalidateOnDelete:false skips invalidation", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, {
cache,
invalidateOnDelete: false,
});
inner.seed("x.bin", new Uint8Array([1]));
cache._seed("storage::x.bin", new Uint8Array([1]));
const out = await wrapper.delete("x.bin");
expect(out).toBe(true);
expect(cache.deleteCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,23 @@
import type { CachedStorageContext } from "./cached-storage.context.js";
import { invalidateKey } from "./cached-storage.write.js";
/**
* `delete(key, signal?)` — delete-through with post-success invalidation.
*
* The wrapper invokes `inner.delete` first; on successful resolution
* (regardless of whether the inner returned `true` or `false`), it
* evicts any stale cache entry. The inner's return value (whether the
* key existed) is propagated verbatim. A rejected `inner.delete`
* propagates and SHALL NOT invalidate the cache.
*/
export async function deleteThroughCache(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<boolean> {
const removed = await ctx.inner.delete(key, signal);
if (ctx.options.invalidateOnDelete) {
await invalidateKey(ctx, key);
}
return removed;
}

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,128 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
describe("CachedStorage.get", () => {
it("hit short-circuits inner and returns cached bytes", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache._seed("storage::clip.mov", new Uint8Array([7, 7]));
const out = await wrapper.get("clip.mov");
expect(out).toEqual(new Uint8Array([7, 7]));
expect(inner.getCalls).toEqual([]);
expect(wrapper.getCacheStats().hits).toBe(1);
});
it("uses the configured namespace in the cache key", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache, namespace: "media" });
inner.seed("clip.mov", new Uint8Array([1]));
await wrapper.get("clip.mov");
expect(cache.getCalls).toEqual(["media::clip.mov"]);
});
it("miss invokes inner and populates the cache", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("x.bin", new Uint8Array([1, 2, 3]));
const out = await wrapper.get("x.bin");
expect(out).toEqual(new Uint8Array([1, 2, 3]));
expect(inner.getCalls.length).toBe(1);
expect(cache.setCalls.length).toBe(1);
expect(cache.setCalls[0]?.key).toBe("storage::x.bin");
expect(wrapper.getCacheStats()).toMatchObject({
misses: 1,
populations: 1,
});
});
it("shouldCache predicate gates population", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, {
cache,
shouldCache: () => false,
});
inner.seed("x.bin", new Uint8Array([1, 2, 3]));
await wrapper.get("x.bin");
expect(inner.getCalls.length).toBe(1);
expect(cache.setCalls.length).toBe(0);
expect(wrapper.getCacheStats().populations).toBe(0);
});
it("default maxEntryBytes is 16 MiB", { timeout: 5000 }, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
const oneByteTooBig = new Uint8Array(16 * 1024 * 1024 + 1);
inner.seed("big.bin", oneByteTooBig);
const out = await wrapper.get("big.bin");
expect(out?.byteLength).toBe(16 * 1024 * 1024 + 1);
expect(cache.setCalls.length).toBe(0);
expect(wrapper.getCacheStats().populations).toBe(0);
});
it("custom maxEntryBytes is honoured by the default shouldCache", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache, maxEntryBytes: 4 });
inner.seed("small.bin", new Uint8Array([1, 2, 3, 4]));
inner.seed("medium.bin", new Uint8Array([1, 2, 3, 4, 5]));
await wrapper.get("small.bin");
await wrapper.get("medium.bin");
expect(cache.setCalls.length).toBe(1);
expect(cache.setCalls[0]?.key).toBe("storage::small.bin");
});
it("undefined from inner is not cached", { timeout: 5000 }, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
const out = await wrapper.get("missing.bin");
expect(out).toBeUndefined();
expect(cache.setCalls.length).toBe(0);
});
it("cache.get rejection falls through to inner", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("x", new Uint8Array([42]));
cache.failNext("get", new Error("redis-down"));
const out = await wrapper.get("x");
expect(out).toEqual(new Uint8Array([42]));
expect(wrapper.getCacheStats().errors).toBe(1);
});
it("cache.set rejection does not propagate", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("x", new Uint8Array([42]));
cache.failNext("set", new Error("cache-write-failed"));
const out = await wrapper.get("x");
expect(out).toEqual(new Uint8Array([42]));
expect(wrapper.getCacheStats()).toMatchObject({
errors: 1,
populations: 0,
});
});
});

View File

@@ -0,0 +1,69 @@
import {
recordError,
recordHit,
recordMiss,
recordPopulate,
} from "../otel/otel.js";
import {
type CachedStorageContext,
cacheKeyOf,
} from "./cached-storage.context.js";
import { isPreAborted } from "./cached-storage.signal.js";
/**
* Read-through `get(key, signal?)` implementation.
*
* Order:
* 1. Pre-aborted signal → forward directly to inner (skip cache).
* 2. `cache.get(cacheKey)` — on hit, return bytes; bump `hits`.
* Cache failures are swallowed; the wrapper falls through to the
* inner read and bumps `errors`.
* 3. `inner.get(key, signal)` — on `undefined`, return `undefined`
* without caching (no negative caching).
* 4. `shouldCache(key, bytes)` gate. When `true`, `cache.set(cacheKey, bytes)`.
* A `cache.set` rejection is swallowed (bumps `errors`).
*/
export async function getThroughCache(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<Uint8Array | undefined> {
if (isPreAborted(signal)) {
return ctx.inner.get(key, signal);
}
const cacheKey = cacheKeyOf(ctx, key);
const namespace = ctx.options.namespace;
let cached: Uint8Array | undefined;
try {
cached = await ctx.cache.get(cacheKey);
} catch {
ctx.stats.errors += 1;
recordError("get", namespace, key);
// fall through — a cache failure must not block the read
}
if (cached !== undefined) {
ctx.stats.hits += 1;
recordHit(namespace, key);
return cached;
}
ctx.stats.misses += 1;
recordMiss(namespace, key);
const bytes = await ctx.inner.get(key, signal);
if (bytes === undefined) return undefined;
if (ctx.options.shouldCache(key, bytes)) {
try {
await ctx.cache.set(cacheKey, bytes);
ctx.stats.populations += 1;
recordPopulate(namespace, key, bytes.byteLength);
} catch {
ctx.stats.errors += 1;
recordError("set", namespace, key);
// swallow — same rule as drive-plugin-cache
}
}
return bytes;
}

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,117 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
function freshWrapper() {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
return { inner, cache, wrapper };
}
describe("CachedStorage passthrough surface", () => {
it("exists delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array([1]));
const out = await wrapper.exists("x");
expect(out).toBe(true);
expect(inner.existsCalls).toEqual([{ key: "x" }]);
expect(cache.getCalls).toEqual([]);
expect(cache.setCalls).toEqual([]);
expect(cache.deleteCalls).toEqual([]);
});
it("list delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("a/x", new Uint8Array([1]));
inner.seed("b/y", new Uint8Array([2]));
const out = await wrapper.list({ prefix: "a/" });
expect(out.keys).toEqual(["a/x"]);
expect(inner.listCalls.length).toBe(1);
expect(cache.getCalls).toEqual([]);
expect(cache.setCalls).toEqual([]);
});
it("getMetadata delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array([1, 2, 3]));
const out = await wrapper.getMetadata("x");
expect(out?.size).toBe(3);
expect(inner.getMetadataCalls).toEqual([{ key: "x" }]);
expect(cache.getCalls).toEqual([]);
});
it("getWithMetadata delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array([1, 2, 3]));
const out = await wrapper.getWithMetadata("x");
expect(out?.value).toEqual(new Uint8Array([1, 2, 3]));
expect(inner.getWithMetadataCalls).toEqual([{ key: "x" }]);
expect(cache.getCalls).toEqual([]);
});
it("getSize delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array(7));
const total = await wrapper.getSize();
expect(total).toBe(7);
expect(inner.getSizeCalls).toBe(1);
expect(cache.getCalls).toEqual([]);
});
it("getItemSize delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array(5));
const size = await wrapper.getItemSize("x");
expect(size).toBe(5);
expect(inner.getItemSizeCalls).toEqual([{ key: "x" }]);
expect(cache.getCalls).toEqual([]);
});
it("getPartial delegates to inner without touching the cache", {
timeout: 5000,
}, async () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array([1, 2, 3, 4, 5]));
const slice = await wrapper.getPartial("x", 1, 3);
expect(slice).toEqual(new Uint8Array([2, 3, 4]));
expect(inner.getPartialCalls).toEqual([
{ key: "x", offset: 1, length: 3 },
]);
expect(cache.getCalls).toEqual([]);
});
it("createReadStream delegates to inner without touching the cache", {
timeout: 5000,
}, () => {
const { inner, cache, wrapper } = freshWrapper();
inner.seed("x", new Uint8Array([1]));
const stream = wrapper.createReadStream("x");
expect(stream).toBeInstanceOf(ReadableStream);
expect(inner.createReadStreamCalls).toEqual([{ key: "x" }]);
expect(cache.getCalls).toEqual([]);
});
it("createWriteStream delegates to inner without touching the cache", {
timeout: 5000,
}, () => {
const { inner, cache, wrapper } = freshWrapper();
const stream = wrapper.createWriteStream("x");
expect(stream).toBeInstanceOf(WritableStream);
expect(inner.createWriteStreamCalls).toEqual([{ key: "x" }]);
expect(cache.deleteCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,87 @@
import type {
StorageListOptions,
StorageListResult,
StorageMetadata,
StorageResult,
} from "@suaveplan/storage";
import type { CachedStorageContext } from "./cached-storage.context.js";
/**
* Pure delegates to the inner storage. None of these helpers touch the
* cache. The cache surface is intentionally limited to whole-object
* reads (`get`) and whole-object invalidations (`set`, `setPartial`,
* `delete`, `clear`); everything else passes through.
*/
export function existsPassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<boolean> {
return ctx.inner.exists(key, signal);
}
export function listPassthrough(
ctx: CachedStorageContext,
options: StorageListOptions | undefined,
signal: AbortSignal | undefined
): Promise<StorageListResult> {
return ctx.inner.list(options, signal);
}
export function getWithMetadataPassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<StorageResult<Uint8Array> | null> {
return ctx.inner.getWithMetadata(key, signal);
}
export function getMetadataPassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<StorageMetadata | null> {
return ctx.inner.getMetadata(key, signal);
}
export function getSizePassthrough(
ctx: CachedStorageContext,
signal: AbortSignal | undefined
): Promise<number> {
return ctx.inner.getSize(signal);
}
export function getItemSizePassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): Promise<number | null> {
return ctx.inner.getItemSize(key, signal);
}
export function getPartialPassthrough(
ctx: CachedStorageContext,
key: string,
offset: number,
length: number,
signal: AbortSignal | undefined
): Promise<Uint8Array | null> {
return ctx.inner.getPartial(key, offset, length, signal);
}
export function createReadStreamPassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): ReadableStream<Uint8Array> {
return ctx.inner.createReadStream(key, signal);
}
export function createWriteStreamPassthrough(
ctx: CachedStorageContext,
key: string,
signal: AbortSignal | undefined
): WritableStream<Uint8Array> {
return ctx.inner.createWriteStream(key, signal);
}

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,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
import { isPreAborted } from "./cached-storage.signal.js";
describe("isPreAborted", () => {
it("returns false when signal is undefined", { timeout: 5000 }, () => {
expect(isPreAborted(undefined)).toBe(false);
});
it("returns false for a fresh, non-aborted signal", {
timeout: 5000,
}, () => {
const ctrl = new AbortController();
expect(isPreAborted(ctrl.signal)).toBe(false);
});
it("returns true when the signal is already aborted", {
timeout: 5000,
}, () => {
const ctrl = new AbortController();
ctrl.abort();
expect(isPreAborted(ctrl.signal)).toBe(true);
});
});
describe("CachedStorage AbortSignal behaviour", () => {
it("pre-aborted signal skips cache work in get", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
const ctrl = new AbortController();
ctrl.abort();
await expect(wrapper.get("x", ctrl.signal)).rejects.toThrow();
expect(cache.getCalls).toEqual([]);
expect(inner.getCalls.length).toBe(1);
expect(inner.getCalls[0]?.signal).toBe(ctrl.signal);
});
it("pre-aborted signal skips invalidation in set", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache._seed("storage::x", new Uint8Array([1]));
const ctrl = new AbortController();
ctrl.abort();
await expect(
wrapper.set("x", new Uint8Array([1]), undefined, ctrl.signal)
).rejects.toThrow();
expect(cache.deleteCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,12 @@
/**
* Pre-aborted `AbortSignal` short-circuit helper.
*
* When a caller passes an already-aborted signal into a wrapper method,
* the wrapper SHALL skip every cache-side interaction and forward
* directly to the inner storage so the inner raises the canonical
* `STORAGE.ABORTED` (or backend-specific) error. This avoids spending a
* round-trip on the cache for a request that is already known to fail.
*/
export function isPreAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}

View File

@@ -0,0 +1,74 @@
import { AbstractStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
import { CachedStorage } from "./cached-storage.js";
describe("CachedStorage", () => {
it("instance is structurally an AbstractStorage and a CachedStorage", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
expect(wrapper).toBeInstanceOf(AbstractStorage);
expect(wrapper).toBeInstanceOf(CachedStorage);
// Every abstract method exists and is callable.
expect(typeof wrapper.get).toBe("function");
expect(typeof wrapper.set).toBe("function");
expect(typeof wrapper.delete).toBe("function");
expect(typeof wrapper.exists).toBe("function");
expect(typeof wrapper.list).toBe("function");
expect(typeof wrapper.clear).toBe("function");
expect(typeof wrapper.getWithMetadata).toBe("function");
expect(typeof wrapper.getMetadata).toBe("function");
expect(typeof wrapper.getSize).toBe("function");
expect(typeof wrapper.getItemSize).toBe("function");
expect(typeof wrapper.getPartial).toBe("function");
expect(typeof wrapper.setPartial).toBe("function");
expect(typeof wrapper.createReadStream).toBe("function");
expect(typeof wrapper.createWriteStream).toBe("function");
});
it("cache and inner are exposed as readonly properties", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
expect(wrapper.cache).toBe(cache);
expect(wrapper.inner).toBe(inner);
});
it("getCacheStats reflects hit, miss, population, and invalidation", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
// Seed inner with a value so the miss + population fires.
inner.seed("a", new Uint8Array([1, 2, 3]));
await wrapper.get("a"); // miss + population
await wrapper.get("a"); // hit
await wrapper.set("a", new Uint8Array([9])); // invalidation
const stats = wrapper.getCacheStats();
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
expect(stats.populations).toBe(1);
expect(stats.invalidations).toBe(1);
expect(stats.errors).toBe(0);
});
it("getCacheStats does not mutate state", { timeout: 5000 }, () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
const before = wrapper.getCacheStats();
const after = wrapper.getCacheStats();
expect(before).toEqual(after);
// Snapshot is a fresh object, not the internal reference.
expect(before).not.toBe(after);
expect(cache.getCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,259 @@
import type { AbstractCache } from "@suaveplan/cache";
import {
AbstractStorage,
type StorageListOptions,
type StorageListResult,
type StorageMetadata,
type StorageOptions,
type StorageResult,
} from "@suaveplan/storage";
import {
CachedStorageError,
STORAGE_CACHE_CODES,
} from "../error-codes/error-codes.js";
import type {
CachedStorageOptions,
CachedStorageStats,
ResolvedCachedStorageOptions,
} from "../types/options.types.js";
import { clearThroughCache } from "./cached-storage.clear.js";
import type { CachedStorageContext } from "./cached-storage.context.js";
import { deleteThroughCache } from "./cached-storage.delete.js";
import { getThroughCache } from "./cached-storage.get.js";
import {
createReadStreamPassthrough,
createWriteStreamPassthrough,
existsPassthrough,
getItemSizePassthrough,
getMetadataPassthrough,
getPartialPassthrough,
getSizePassthrough,
getWithMetadataPassthrough,
listPassthrough,
} from "./cached-storage.passthrough.js";
import {
setPartialWriteThrough,
setWriteThrough,
} from "./cached-storage.write.js";
const DEFAULT_NAMESPACE = "storage";
const DEFAULT_MAX_ENTRY_BYTES = 16 * 1024 * 1024;
/**
* Validate `CachedStorageOptions` at construction time and throw the
* canonical `STORAGE_CACHE.INVALID_OPTIONS` synchronously when the
* cache is missing or lacks the three required methods.
*/
export function validateOptions(options: CachedStorageOptions): void {
if (options === null || options === undefined) {
throw new CachedStorageError(
STORAGE_CACHE_CODES.INVALID_OPTIONS,
"options is required"
);
}
const cache = (options as { cache?: unknown }).cache;
if (cache === null || cache === undefined) {
throw new CachedStorageError(
STORAGE_CACHE_CODES.INVALID_OPTIONS,
"options.cache is required"
);
}
const c = cache as {
get?: unknown;
set?: unknown;
delete?: unknown;
};
if (
typeof c.get !== "function" ||
typeof c.set !== "function" ||
typeof c.delete !== "function"
) {
throw new CachedStorageError(
STORAGE_CACHE_CODES.INVALID_OPTIONS,
"options.cache must expose function-typed get/set/delete"
);
}
}
function resolveDefaults(
options: CachedStorageOptions
): ResolvedCachedStorageOptions {
const maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
const shouldCache =
options.shouldCache ??
((_key: string, bytes: Uint8Array): boolean =>
bytes.byteLength <= maxEntryBytes);
return {
invalidateOnWrite: options.invalidateOnWrite !== false,
invalidateOnDelete: options.invalidateOnDelete !== false,
invalidateOnClear: options.invalidateOnClear !== false,
namespace: options.namespace ?? DEFAULT_NAMESPACE,
shouldCache,
maxEntryBytes,
logger: options.logger,
};
}
/**
* Read-through cache decorator wrapping any `AbstractStorage` with an
* injectable `AbstractCache<Uint8Array>`. Reads consult the cache first
* and fall through on miss; writes/deletes/clears invalidate the
* corresponding cache entry post-success.
*
* The class implements every abstract method on `AbstractStorage`:
* `get`, `set`, `delete`, `exists`, `list`, `clear`, `getWithMetadata`,
* `getMetadata`, `getSize`, `getItemSize`, `getPartial`, `setPartial`,
* `createReadStream`, `createWriteStream`. It also exposes
* `readonly cache`, `readonly inner`, and `getCacheStats()`.
*/
export class CachedStorage extends AbstractStorage {
readonly #context: CachedStorageContext;
readonly #cache: AbstractCache<Uint8Array>;
readonly #inner: AbstractStorage;
public constructor(inner: AbstractStorage, options: CachedStorageOptions) {
super();
if (inner === null || inner === undefined) {
throw new CachedStorageError(
STORAGE_CACHE_CODES.INNER_MISSING,
"inner storage is required"
);
}
validateOptions(options);
this.#inner = inner;
this.#cache = options.cache;
const stats: CachedStorageStats = {
hits: 0,
misses: 0,
populations: 0,
invalidations: 0,
errors: 0,
};
this.#context = {
inner,
cache: options.cache,
options: resolveDefaults(options),
stats,
};
}
public get cache(): AbstractCache<Uint8Array> {
return this.#cache;
}
public get inner(): AbstractStorage {
return this.#inner;
}
public getCacheStats(): CachedStorageStats {
return { ...this.#context.stats };
}
// ── Read-through / write-through / delete-through / clear ─────────
public get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
return getThroughCache(this.#context, key, signal);
}
public set(
key: string,
value: Uint8Array,
options?: StorageOptions,
signal?: AbortSignal
): Promise<void> {
return setWriteThrough(this.#context, key, value, options, signal);
}
public setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
return setPartialWriteThrough(
this.#context,
key,
offset,
bytes,
signal
);
}
public delete(key: string, signal?: AbortSignal): Promise<boolean> {
return deleteThroughCache(this.#context, key, signal);
}
public clear(signal?: AbortSignal): Promise<void> {
return clearThroughCache(this.#context, signal);
}
// ── Passthrough surface ──────────────────────────────────────────
public exists(key: string, signal?: AbortSignal): Promise<boolean> {
return existsPassthrough(this.#context, key, signal);
}
public list(
options?: StorageListOptions,
signal?: AbortSignal
): Promise<StorageListResult> {
return listPassthrough(this.#context, options, signal);
}
public getWithMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
return getWithMetadataPassthrough(this.#context, key, signal);
}
public getMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageMetadata | null> {
return getMetadataPassthrough(this.#context, key, signal);
}
public getSize(signal?: AbortSignal): Promise<number> {
return getSizePassthrough(this.#context, signal);
}
public getItemSize(
key: string,
signal?: AbortSignal
): Promise<number | null> {
return getItemSizePassthrough(this.#context, key, signal);
}
public getPartial(
key: string,
offset: number,
length: number,
signal?: AbortSignal
): Promise<Uint8Array | null> {
return getPartialPassthrough(
this.#context,
key,
offset,
length,
signal
);
}
public createReadStream(
key: string,
signal?: AbortSignal
): ReadableStream<Uint8Array> {
return createReadStreamPassthrough(this.#context, key, signal);
}
public createWriteStream(
key: string,
signal?: AbortSignal
): WritableStream<Uint8Array> {
return createWriteStreamPassthrough(this.#context, key, signal);
}
}

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,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
describe("CachedStorage.set", () => {
it("successful set invalidates the cache entry", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache._seed("storage::x.bin", new Uint8Array([7]));
await wrapper.set("x.bin", new Uint8Array([1, 2, 3]));
expect(cache.deleteCalls).toEqual(["storage::x.bin"]);
expect(wrapper.getCacheStats().invalidations).toBe(1);
});
it("failed set does not invalidate", { timeout: 5000 }, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.failNext("set", new Error("disk-full"));
await expect(wrapper.set("x.bin", new Uint8Array([1]))).rejects.toThrow(
"disk-full"
);
expect(cache.deleteCalls).toEqual([]);
expect(wrapper.getCacheStats().invalidations).toBe(0);
});
it("invalidateOnWrite:false skips invalidation", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, {
cache,
invalidateOnWrite: false,
});
cache._seed("storage::x.bin", new Uint8Array([7]));
await wrapper.set("x.bin", new Uint8Array([1, 2, 3]));
expect(cache.deleteCalls).toEqual([]);
});
it("cache.delete rejection on invalidation does not propagate", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache.failNext("delete", new Error("cache-down"));
await wrapper.set("x.bin", new Uint8Array([1]));
expect(wrapper.getCacheStats()).toMatchObject({
errors: 1,
invalidations: 0,
});
});
});
describe("CachedStorage.setPartial", () => {
it("setPartial invalidates the whole cache entry", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("x.bin", new Uint8Array(200));
cache._seed("storage::x.bin", new Uint8Array([7]));
await wrapper.setPartial("x.bin", 100, new Uint8Array([9, 9]));
expect(cache.deleteCalls).toEqual(["storage::x.bin"]);
expect(wrapper.getCacheStats().invalidations).toBe(1);
});
it("failed setPartial does not invalidate", { timeout: 5000 }, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.failNext("setPartial", new Error("disk-full"));
await expect(
wrapper.setPartial("x.bin", 100, new Uint8Array([1]))
).rejects.toThrow("disk-full");
expect(cache.deleteCalls).toEqual([]);
});
it("setPartial respects invalidateOnWrite:false", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, {
cache,
invalidateOnWrite: false,
});
inner.seed("x.bin", new Uint8Array(200));
await wrapper.setPartial("x.bin", 0, new Uint8Array([1]));
expect(cache.deleteCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,70 @@
import type { StorageOptions } from "@suaveplan/storage";
import { recordError, recordInvalidate } from "../otel/otel.js";
import {
type CachedStorageContext,
cacheKeyOf,
} from "./cached-storage.context.js";
/**
* `set(key, value, options?, signal?)` — write-through with post-success
* invalidation. The wrapper invokes `inner.set` first; only on
* successful resolution does it delete the cache entry.
*
* A failed `inner.set` propagates and SHALL NOT invalidate the cache
* (the entry, if any, is still consistent with the inner store's view).
*/
export async function setWriteThrough(
ctx: CachedStorageContext,
key: string,
value: Uint8Array,
options: StorageOptions | undefined,
signal: AbortSignal | undefined
): Promise<void> {
await ctx.inner.set(key, value, options, signal);
if (ctx.options.invalidateOnWrite) {
await invalidateKey(ctx, key);
}
}
/**
* `setPartial(key, offset, value, signal?)` — partial write-through.
*
* Even when the partial write only touches a byte range, the cached
* entry is now stale as a whole; the wrapper evicts the entire key's
* cache entry (no range caching). A failed `inner.setPartial` propagates
* and does NOT invalidate.
*/
export async function setPartialWriteThrough(
ctx: CachedStorageContext,
key: string,
offset: number,
value: Uint8Array,
signal: AbortSignal | undefined
): Promise<void> {
await ctx.inner.setPartial(key, offset, value, signal);
if (ctx.options.invalidateOnWrite) {
await invalidateKey(ctx, key);
}
}
/**
* Shared invalidation primitive used by `set`, `setPartial`, and
* `delete`. Swallows `cache.delete` rejections and bumps the error
* counter so the wrapper's own contract (cache failures never
* propagate) stays intact.
*/
export async function invalidateKey(
ctx: CachedStorageContext,
key: string
): Promise<void> {
const cacheKey = cacheKeyOf(ctx, key);
const namespace = ctx.options.namespace;
try {
await ctx.cache.delete(cacheKey);
ctx.stats.invalidations += 1;
recordInvalidate(namespace, key);
} catch {
ctx.stats.errors += 1;
recordError("delete", namespace, key);
}
}

View File

@@ -0,0 +1,6 @@
export {
type CachedStorageContext,
cacheKeyOf,
} from "./cached-storage.context.js";
export { CachedStorage, validateOptions } from "./cached-storage.js";
export { isPreAborted } from "./cached-storage.signal.js";

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,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CachedStorageError, STORAGE_CACHE_CODES } from "./error-codes.js";
describe("STORAGE_CACHE_CODES registry", () => {
it("exposes the two canonical codes with fully-qualified strings", {
timeout: 5000,
}, () => {
expect(STORAGE_CACHE_CODES.INVALID_OPTIONS).toBe(
"STORAGE_CACHE.INVALID_OPTIONS"
);
expect(STORAGE_CACHE_CODES.INNER_MISSING).toBe(
"STORAGE_CACHE.INNER_MISSING"
);
});
it("freezes the registry so codes cannot drift at runtime", {
timeout: 5000,
}, () => {
expect(Object.isFrozen(STORAGE_CACHE_CODES)).toBe(true);
});
});
describe("CachedStorageError", () => {
it("carries the code, name, message, key, and namespace discriminants", {
timeout: 5000,
}, () => {
const err = new CachedStorageError(
STORAGE_CACHE_CODES.INVALID_OPTIONS,
"invalid",
{ key: "k", namespace: "ns" }
);
expect(err.code).toBe("STORAGE_CACHE.INVALID_OPTIONS");
expect(err.name).toBe("CachedStorageError");
expect(err.message).toBe("invalid");
expect(err.key).toBe("k");
expect(err.namespace).toBe("ns");
});
it("leaves key and namespace undefined when not supplied", {
timeout: 5000,
}, () => {
const err = new CachedStorageError(
STORAGE_CACHE_CODES.INNER_MISSING,
"no inner"
);
expect(err.key).toBeUndefined();
expect(err.namespace).toBeUndefined();
});
it("preserves the cause when supplied", { timeout: 5000 }, () => {
const cause = new Error("redis-down");
const err = new CachedStorageError(
STORAGE_CACHE_CODES.INVALID_OPTIONS,
"propagated",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,57 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Registry of every error code raised by `@suaveplan/storage-plugin-cache`.
*
* The wrapper deliberately owns a narrow surface: only synchronous
* construction-time configuration failures map to a `STORAGE_CACHE.*`
* code. Every other failure (inner storage errors, cache backend errors)
* surfaces with its original `STORAGE.*` / `STORAGE_HTTP.*` / `CACHE.*`
* code unchanged because re-namespacing would obscure the real root cause.
*
* Both codes (`INVALID_OPTIONS`, `INNER_MISSING`) are reachable from a
* real call site in `factory.ts` / `cached-storage.ts` and are exercised
* by `error-codes.test.ts` and `factory.test.ts`.
*/
export const STORAGE_CACHE_CODES = defineCodes("STORAGE_CACHE", [
"INVALID_OPTIONS",
"INNER_MISSING",
] as const);
export type CachedStorageErrorCode =
(typeof STORAGE_CACHE_CODES)[keyof typeof STORAGE_CACHE_CODES];
export interface CachedStorageErrorOptions extends SuaveplanErrorOptions {
/** Storage key associated with the failure, when applicable. */
key?: string;
/** Cache namespace associated with the failure, when applicable. */
namespace?: string;
}
/**
* Structured error for the read-through storage-cache decorator.
*
* Carries optional `key` and `namespace` discriminants so operators can
* correlate a failure with the wrapper instance that produced it without
* parsing message text. Underlying causes (when re-wrapping a foreign
* throwable) are preserved on `cause` via the base class.
*/
export class CachedStorageError extends SuaveplanError<CachedStorageErrorCode> {
public readonly key: string | undefined;
public readonly namespace: string | undefined;
public constructor(
code: CachedStorageErrorCode,
message: string,
options?: CachedStorageErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.namespace = options?.namespace;
this.name = "CachedStorageError";
}
}

View File

@@ -0,0 +1,8 @@
export type {
CachedStorageErrorCode,
CachedStorageErrorOptions,
} from "./error-codes.js";
export {
CachedStorageError,
STORAGE_CACHE_CODES,
} from "./error-codes.js";

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,116 @@
import type { AbstractCache } from "@suaveplan/cache";
import type { AbstractStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { CachedStorage } from "../cached-storage/cached-storage.js";
import { CachedStorageError } from "../error-codes/error-codes.js";
import { cachedStorage } from "./factory.js";
describe("cachedStorage(inner, options)", () => {
it("returns a CachedStorage instance wired to the inner and cache", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
expect(wrapper).toBeInstanceOf(CachedStorage);
expect(wrapper.inner).toBe(inner);
expect(wrapper.cache).toBe(cache);
});
it("throws INNER_MISSING when inner is nullish", { timeout: 5000 }, () => {
const cache = new FakeCache();
expect(() =>
cachedStorage(null as unknown as AbstractStorage, { cache })
).toThrow(CachedStorageError);
try {
cachedStorage(null as unknown as AbstractStorage, { cache });
} catch (err) {
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INNER_MISSING"
);
}
});
it("throws INNER_MISSING when inner is undefined", {
timeout: 5000,
}, () => {
const cache = new FakeCache();
try {
cachedStorage(undefined as unknown as AbstractStorage, { cache });
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(CachedStorageError);
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INNER_MISSING"
);
}
});
it("throws INVALID_OPTIONS when cache is missing", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
try {
cachedStorage(inner, {} as never);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(CachedStorageError);
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INVALID_OPTIONS"
);
}
});
it("throws INVALID_OPTIONS when options bag itself is nullish", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
try {
cachedStorage(
inner,
null as unknown as { cache: AbstractCache<Uint8Array> }
);
throw new Error("did not throw");
} catch (err) {
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INVALID_OPTIONS"
);
}
});
it("throws INVALID_OPTIONS when cache lacks get/set/delete", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
const malformed = { name: "x" } as unknown as AbstractCache<Uint8Array>;
try {
cachedStorage(inner, { cache: malformed });
throw new Error("did not throw");
} catch (err) {
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INVALID_OPTIONS"
);
}
});
it("throws INVALID_OPTIONS when cache.get is not a function", {
timeout: 5000,
}, () => {
const inner = new FakeStorage();
const malformed = {
get: "nope",
set: () => Promise.resolve(),
delete: () => Promise.resolve(true),
} as unknown as AbstractCache<Uint8Array>;
try {
cachedStorage(inner, { cache: malformed });
throw new Error("did not throw");
} catch (err) {
expect((err as CachedStorageError).code).toBe(
"STORAGE_CACHE.INVALID_OPTIONS"
);
}
});
});

View File

@@ -0,0 +1,20 @@
import type { AbstractStorage } from "@suaveplan/storage";
import { CachedStorage } from "../cached-storage/cached-storage.js";
import type { CachedStorageOptions } from "../types/options.types.js";
/**
* Wrap an `AbstractStorage` with a read-through cache.
*
* Thin convenience over `new CachedStorage(inner, options)` matching
* the `createCache` / `createHttpStorage` factory conventions used
* elsewhere in the ecosystem. The factory performs no work the
* constructor doesn't already do — validation throws synchronously
* with `STORAGE_CACHE.INNER_MISSING` (nullish `inner`) or
* `STORAGE_CACHE.INVALID_OPTIONS` (missing/malformed cache).
*/
export function cachedStorage(
inner: AbstractStorage,
options: CachedStorageOptions
): CachedStorage {
return new CachedStorage(inner, options);
}

View File

@@ -0,0 +1 @@
export { cachedStorage } from "./factory.js";

View File

@@ -0,0 +1,38 @@
// Side-effect import: ensures the STORAGE_CACHE.* code registry registers
// even when subpath consumers never reach this barrel.
import "./error-codes/error-codes.js";
export {
type CachedStorageContext,
cacheKeyOf,
} from "./cached-storage/cached-storage.context.js";
export {
CachedStorage,
validateOptions,
} from "./cached-storage/cached-storage.js";
export { isPreAborted } from "./cached-storage/cached-storage.signal.js";
export {
CachedStorageError,
type CachedStorageErrorCode,
type CachedStorageErrorOptions,
STORAGE_CACHE_CODES,
} from "./error-codes/error-codes.js";
export { cachedStorage } from "./factory/factory.js";
export {
getInstruments,
recordError,
recordHit,
recordInvalidate,
recordMiss,
recordPopulate,
resetInstruments,
type StorageCacheInstruments,
type StorageCacheOutcome,
setStorageCacheMeterProvider,
} from "./otel/otel.js";
export type {
CachedStorageErrorOp,
CachedStorageOptions,
CachedStorageStats,
ResolvedCachedStorageOptions,
} from "./types/options.types.js";

View File

@@ -0,0 +1,14 @@
export type {
StorageCacheInstruments,
StorageCacheOutcome,
} from "./otel.js";
export {
getInstruments,
recordError,
recordHit,
recordInvalidate,
recordMiss,
recordPopulate,
resetInstruments,
setStorageCacheMeterProvider,
} from "./otel.js";

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.

View File

@@ -0,0 +1,230 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { FakeCache } from "../../tests/setup/fake-cache.js";
import { FakeStorage } from "../../tests/setup/fake-storage.js";
import { cachedStorage } from "../factory/factory.js";
import {
getInstruments,
recordError,
recordHit,
recordInvalidate,
recordMiss,
recordPopulate,
resetInstruments,
setStorageCacheMeterProvider,
} from "./otel.js";
interface Sample {
value: number;
attrs: Record<string, unknown> | undefined;
}
class StubCounter implements Counter {
public adds: Sample[] = [];
public add(v: number, attrs?: Record<string, unknown>): void {
this.adds.push({ value: v, attrs });
}
}
class StubHistogram implements Histogram {
public samples: Sample[] = [];
public record(v: number, attrs?: Record<string, unknown>): void {
this.samples.push({ value: v, attrs });
}
}
class StubMeter implements Meter {
public counters: Map<string, StubCounter> = new Map();
public histograms: Map<string, StubHistogram> = new Map();
public createCounter(name: string): Counter {
const c = new StubCounter();
this.counters.set(name, c);
return c;
}
public createHistogram(name: string): Histogram {
const h = new StubHistogram();
this.histograms.set(name, h);
return h;
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public createObservableGauge(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meter = new StubMeter();
public getMeter(): Meter {
return this.meter;
}
}
describe("storage-plugin-cache otel", () => {
let provider: StubMeterProvider;
beforeEach(() => {
provider = new StubMeterProvider();
setStorageCacheMeterProvider(provider);
resetInstruments();
});
afterEach(() => {
setStorageCacheMeterProvider(undefined);
resetInstruments();
});
it("getInstruments builds the canonical six instruments", {
timeout: 5000,
}, () => {
const inst = getInstruments();
expect(inst.hitsTotal).toBeDefined();
expect(inst.missesTotal).toBeDefined();
expect(inst.populationsTotal).toBeDefined();
expect(inst.invalidationsTotal).toBeDefined();
expect(inst.errorsTotal).toBeDefined();
expect(inst.entryBytes).toBeDefined();
expect(provider.meter.counters.has("storage_cache.hits_total")).toBe(
true
);
expect(provider.meter.counters.has("storage_cache.misses_total")).toBe(
true
);
expect(
provider.meter.counters.has("storage_cache.populations_total")
).toBe(true);
expect(
provider.meter.counters.has("storage_cache.invalidations_total")
).toBe(true);
expect(provider.meter.counters.has("storage_cache.errors_total")).toBe(
true
);
expect(provider.meter.histograms.has("storage_cache.entry_bytes")).toBe(
true
);
});
it("getInstruments returns the cached bag on the second access", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments();
expect(b).toBe(a);
});
it("getInstruments(force=true) rebuilds the bag against the active provider", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments(true);
expect(b).not.toBe(a);
});
it("recordHit increments hits_total{namespace}", { timeout: 5000 }, () => {
recordHit("media", "clip.mov");
const c = provider.meter.counters.get("storage_cache.hits_total");
expect(c?.adds.length).toBe(1);
expect(c?.adds[0]?.attrs).toEqual({ namespace: "media" });
});
it("recordMiss increments misses_total{namespace}", {
timeout: 5000,
}, () => {
recordMiss("media", "clip.mov");
const c = provider.meter.counters.get("storage_cache.misses_total");
expect(c?.adds.length).toBe(1);
expect(c?.adds[0]?.attrs).toEqual({ namespace: "media" });
});
it("recordPopulate increments populations_total and observes entry_bytes", {
timeout: 5000,
}, () => {
recordPopulate("media", "clip.mov", 4096);
const c = provider.meter.counters.get(
"storage_cache.populations_total"
);
expect(c?.adds.length).toBe(1);
const h = provider.meter.histograms.get("storage_cache.entry_bytes");
expect(h?.samples.length).toBe(1);
expect(h?.samples[0]?.value).toBe(4096);
expect(h?.samples[0]?.attrs).toEqual({ namespace: "media" });
});
it("recordInvalidate increments invalidations_total", {
timeout: 5000,
}, () => {
recordInvalidate("media", "clip.mov");
const c = provider.meter.counters.get(
"storage_cache.invalidations_total"
);
expect(c?.adds.length).toBe(1);
});
it("recordError increments errors_total with the op label", {
timeout: 5000,
}, () => {
recordError("set", "media", "clip.mov");
const c = provider.meter.counters.get("storage_cache.errors_total");
expect(c?.adds[0]?.attrs).toEqual({ namespace: "media", op: "set" });
});
it("hit increments hits_total and records hit-outcome span", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
cache._seed("storage::a", new Uint8Array([1]));
await wrapper.get("a");
const c = provider.meter.counters.get("storage_cache.hits_total");
expect(c?.adds.length).toBe(1);
expect(c?.adds[0]?.attrs).toEqual({ namespace: "storage" });
});
it("population records entry_bytes histogram", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("a", new Uint8Array(4096));
await wrapper.get("a");
const h = provider.meter.histograms.get("storage_cache.entry_bytes");
expect(h?.samples.length).toBe(1);
expect(h?.samples[0]?.value).toBe(4096);
});
it("cache error increments errors_total with op label", {
timeout: 5000,
}, async () => {
const inner = new FakeStorage();
const cache = new FakeCache();
const wrapper = cachedStorage(inner, { cache });
inner.seed("a", new Uint8Array([1]));
cache.failNext("set", new Error("cache-down"));
await wrapper.get("a");
const c = provider.meter.counters.get("storage_cache.errors_total");
expect(c?.adds[0]?.attrs).toEqual({ namespace: "storage", op: "set" });
});
});

View File

@@ -0,0 +1,173 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
import type { CachedStorageErrorOp } from "../types/options.types.js";
const METER_NAME = "@suaveplan/storage-cache";
const TRACER_NAME = "@suaveplan/storage-cache";
let overrideMeterProvider: MeterProvider | undefined;
/**
* Override the `MeterProvider` used to acquire the storage-cache meter.
* Pass `undefined` to revert to the global provider. Resets the cached
* instrument bag so the next access binds to the new provider.
*/
export function setStorageCacheMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
}
export type StorageCacheOutcome =
| "hit"
| "miss"
| "populated"
| "invalidated"
| "error";
export interface StorageCacheInstruments {
readonly tracer: Tracer;
readonly meter: Meter;
readonly hitsTotal: Counter;
readonly missesTotal: Counter;
readonly populationsTotal: Counter;
readonly invalidationsTotal: Counter;
readonly errorsTotal: Counter;
readonly entryBytes: Histogram;
}
let cachedInstruments: StorageCacheInstruments | undefined;
/** Lazily build (or return cached) instruments bound to the active provider. */
export function getInstruments(force = false): StorageCacheInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
hitsTotal: meter.createCounter("storage_cache.hits_total", {
description: "Cache hits served by the storage-cache decorator",
}),
missesTotal: meter.createCounter("storage_cache.misses_total", {
description: "Cache misses that fell through to the inner storage",
}),
populationsTotal: meter.createCounter(
"storage_cache.populations_total",
{
description: "Cache populations after a successful inner read",
}
),
invalidationsTotal: meter.createCounter(
"storage_cache.invalidations_total",
{
description: "Cache entries evicted after a write/delete/clear",
}
),
errorsTotal: meter.createCounter("storage_cache.errors_total", {
description: "Cache backend failures swallowed by the wrapper",
}),
entryBytes: meter.createHistogram("storage_cache.entry_bytes", {
description: "Distribution of cached entry sizes (bytes)",
unit: "By",
}),
};
return cachedInstruments;
}
/** Reset cached instruments — used by tests between provider swaps. */
export function resetInstruments(): void {
cachedInstruments = undefined;
}
/** Increment `storage_cache.hits_total{namespace}` and record a hit-outcome span. */
export function recordHit(namespace: string, key: string): void {
const inst = getInstruments();
inst.hitsTotal.add(1, { namespace });
inst.tracer
.startSpan("storage_cache.get", {
attributes: {
"storage.cache.namespace": namespace,
"storage.cache.key": key,
"storage.cache.outcome": "hit",
},
})
.end();
}
/** Increment `storage_cache.misses_total{namespace}` and record a miss-outcome span. */
export function recordMiss(namespace: string, key: string): void {
const inst = getInstruments();
inst.missesTotal.add(1, { namespace });
inst.tracer
.startSpan("storage_cache.get", {
attributes: {
"storage.cache.namespace": namespace,
"storage.cache.key": key,
"storage.cache.outcome": "miss",
},
})
.end();
}
/** Increment `storage_cache.populations_total{namespace}`, observe `entry_bytes`, and record a populated-outcome span. */
export function recordPopulate(
namespace: string,
key: string,
bytes: number
): void {
const inst = getInstruments();
inst.populationsTotal.add(1, { namespace });
inst.entryBytes.record(bytes, { namespace });
inst.tracer
.startSpan("storage_cache.populate", {
attributes: {
"storage.cache.namespace": namespace,
"storage.cache.key": key,
"storage.cache.outcome": "populated",
},
})
.end();
}
/** Increment `storage_cache.invalidations_total{namespace}` and record an invalidated-outcome span. */
export function recordInvalidate(namespace: string, key: string): void {
const inst = getInstruments();
inst.invalidationsTotal.add(1, { namespace });
inst.tracer
.startSpan("storage_cache.invalidate", {
attributes: {
"storage.cache.namespace": namespace,
"storage.cache.key": key,
"storage.cache.outcome": "invalidated",
},
})
.end();
}
/** Increment `storage_cache.errors_total{namespace, op}` and record an error-outcome span. */
export function recordError(
op: CachedStorageErrorOp,
namespace: string,
key: string
): void {
const inst = getInstruments();
inst.errorsTotal.add(1, { namespace, op });
inst.tracer
.startSpan(`storage_cache.${op}`, {
attributes: {
"storage.cache.namespace": namespace,
"storage.cache.key": key,
"storage.cache.outcome": "error",
},
})
.end();
}

View File

@@ -0,0 +1,6 @@
export type {
CachedStorageErrorOp,
CachedStorageOptions,
CachedStorageStats,
ResolvedCachedStorageOptions,
} from "./options.types.js";

View File

@@ -0,0 +1,66 @@
import type { AbstractCache } from "@suaveplan/cache";
import type { LoggerLike } from "@suaveplan/types";
/**
* Options accepted by `cachedStorage(inner, options)` and
* `new CachedStorage(inner, options)`.
*
* `cache` is the only required field. Every other knob has a documented
* default; see the `ResolvedCachedStorageOptions` shape for the
* post-resolution values fed into the class.
*/
export interface CachedStorageOptions {
/** Backing cache instance. MUST expose `get`/`set`/`delete`. */
cache: AbstractCache<Uint8Array>;
/** Invalidate on `set(key, value)`. Defaults to `true`. */
invalidateOnWrite?: boolean;
/** Invalidate on `delete(key)`. Defaults to `true`. */
invalidateOnDelete?: boolean;
/** Evict namespaced subset on `clear()`. Defaults to `true`. */
invalidateOnClear?: boolean;
/** Cache-key prefix. Defaults to `"storage"`. */
namespace?: string;
/**
* Predicate gating whether a freshly-read entry should be cached.
* Defaults to a size-only filter using `maxEntryBytes`.
*/
shouldCache?: (key: string, bytes: Uint8Array) => boolean;
/** Maximum bytes a single entry may have to be cached. Defaults to 16 MiB. */
maxEntryBytes?: number;
/** Optional structured logger; the wrapper warns through it when the cache backend lacks enumeration. */
logger?: LoggerLike;
}
/**
* Internal resolved options shape after defaults are applied. Every
* boolean is concrete, `namespace` is non-empty, `shouldCache` is always
* a function, and `maxEntryBytes` is a positive integer.
*/
export interface ResolvedCachedStorageOptions {
readonly invalidateOnWrite: boolean;
readonly invalidateOnDelete: boolean;
readonly invalidateOnClear: boolean;
readonly namespace: string;
readonly shouldCache: (key: string, bytes: Uint8Array) => boolean;
readonly maxEntryBytes: number;
readonly logger: LoggerLike | undefined;
}
/**
* Snapshot of the wrapper's own counters. Monotonically non-decreasing.
* Distinct from `AbstractStorage.getStats()` (inherited) which counts
* the inner storage's operations as observed through the wrapper.
*/
export interface CachedStorageStats {
hits: number;
misses: number;
populations: number;
invalidations: number;
errors: number;
}
/**
* Op label drawn from the set of methods that the wrapper attributes a
* cache error to. Used by `storage_cache.errors_total{namespace, op}`.
*/
export type CachedStorageErrorOp = "get" | "set" | "delete" | "clear";

View File

@@ -0,0 +1,47 @@
import { MemoryCache } from "@suaveplan/cache";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: a long-lived cache instance survives the
* `CachedStorage` wrapper being dropped and re-instantiated. We
* stand-in for a file-backed cache with a shared `MemoryCache`
* instance: the cache outlives the wrapper, and the second wrapper
* (constructed after the first is dropped) observes a hit on the first
* read — same behaviour any persistent backend (file, OPFS, redis)
* would exhibit.
*/
describe("integration: cache survives wrapper re-instantiation", () => {
it("populates, drops the wrapper, and the next wrapper observes a hit", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const sharedCache = new MemoryCache<Uint8Array>();
inner.seed("v.bin", new Uint8Array([9, 9, 9]));
// First wrapper populates the shared cache and is then dropped.
const first = cachedStorage(inner, {
cache: sharedCache,
namespace: "shared",
});
await first.get("v.bin");
expect(first.getCacheStats().populations).toBe(1);
// Drop the reference. The shared cache persists.
// (No explicit teardown needed — `MemoryCache` has no resource handle.)
// New wrapper over the same cache + same inner. Inner.getCalls is now 1.
// Seed the inner with a different value to prove the cached bytes
// win on the next read.
inner.seed("v.bin", new Uint8Array([1, 2, 3]));
const second = cachedStorage(inner, {
cache: sharedCache,
namespace: "shared",
});
const out = await second.get("v.bin");
expect(out).toEqual(new Uint8Array([9, 9, 9])); // served from cache
expect(inner.getCalls.length).toBe(1); // unchanged after the second read
expect(second.getCacheStats().hits).toBe(1);
});
});

View File

@@ -0,0 +1,47 @@
import { MemoryCache } from "@suaveplan/cache";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: real `MemoryCache` from `@suaveplan/cache` (no fake, no
* stub) sitting in front of an `AbstractStorage` instance that
* represents a remote backend (`FakeStorage` here, which is a real
* `AbstractStorage` subclass over an in-memory `Map`). Two reads of the
* same key MUST hit the inner exactly once; the second read MUST be
* served from the cache.
*/
describe("integration: MemoryCache in front of a remote storage", () => {
it("two reads of the same key hit inner once", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const cache = new MemoryCache<Uint8Array>();
const wrapper = cachedStorage(inner, { cache, namespace: "remote" });
inner.seed("clip.mov", new Uint8Array([1, 2, 3, 4]));
const first = await wrapper.get("clip.mov");
const second = await wrapper.get("clip.mov");
expect(first).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(second).toEqual(new Uint8Array([1, 2, 3, 4]));
expect(inner.getCalls.length).toBe(1);
expect(wrapper.getCacheStats()).toMatchObject({
hits: 1,
misses: 1,
populations: 1,
});
});
it("write invalidates so the next read refetches", {
timeout: 30000,
}, async () => {
const inner = new FakeStorage();
const cache = new MemoryCache<Uint8Array>();
const wrapper = cachedStorage(inner, { cache, namespace: "remote" });
inner.seed("clip.mov", new Uint8Array([1]));
await wrapper.get("clip.mov"); // populates
await wrapper.set("clip.mov", new Uint8Array([2, 2])); // invalidates
const refreshed = await wrapper.get("clip.mov"); // miss again
expect(refreshed).toEqual(new Uint8Array([2, 2]));
expect(inner.getCalls.length).toBe(2);
});
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cachedStorage } from "../../src/factory/factory.js";
import { KeysCapableFakeCache } from "../setup/fake-cache.js";
import { FakeStorage } from "../setup/fake-storage.js";
/**
* Integration: two `CachedStorage` wrappers sharing one
* `AbstractCache` instance with distinct namespaces. Neither wrapper
* observes the other's entries on `get`, and `wrapperA.clear()` MUST
* NOT evict `wrapperB`'s entries.
*
* Uses the `KeysCapableFakeCache` test helper because the canonical
* `MemoryCache` from `@suaveplan/cache` does not expose `keys()`. The
* test helper is a real `AbstractCache<Uint8Array>` subclass — not a
* mock — so the integration exercises real iteration over real
* cache entries.
*/
describe("integration: namespaced isolation on a shared cache", () => {
it("distinct namespaces isolate entries on a shared cache", {
timeout: 30000,
}, async () => {
const innerA = new FakeStorage();
const innerB = new FakeStorage();
const shared = new KeysCapableFakeCache();
const wrapperA = cachedStorage(innerA, {
cache: shared,
namespace: "a",
});
const wrapperB = cachedStorage(innerB, {
cache: shared,
namespace: "b",
});
innerA.seed("clip.mov", new Uint8Array([0xa]));
innerB.seed("clip.mov", new Uint8Array([0xb]));
// Wrapper A populates its entry under "a::clip.mov".
await wrapperA.get("clip.mov");
expect(shared._peek("a::clip.mov")).toEqual(new Uint8Array([0xa]));
expect(shared._peek("b::clip.mov")).toBeUndefined();
// Wrapper B observes a miss for the same logical key (different namespace).
const fromB = await wrapperB.get("clip.mov");
expect(fromB).toEqual(new Uint8Array([0xb]));
expect(wrapperB.getCacheStats().misses).toBe(1);
expect(shared._peek("b::clip.mov")).toEqual(new Uint8Array([0xb]));
});
it("clear on one wrapper does not evict the other's entries", {
timeout: 30000,
}, async () => {
const innerA = new FakeStorage();
const innerB = new FakeStorage();
const shared = new KeysCapableFakeCache();
const wrapperA = cachedStorage(innerA, {
cache: shared,
namespace: "a",
});
const wrapperB = cachedStorage(innerB, {
cache: shared,
namespace: "b",
});
// Populate both.
innerA.seed("x", new Uint8Array([0xa, 1]));
innerA.seed("y", new Uint8Array([0xa, 2]));
innerB.seed("z", new Uint8Array([0xb, 1]));
await wrapperA.get("x");
await wrapperA.get("y");
await wrapperB.get("z");
expect(shared._peek("a::x")).toBeDefined();
expect(shared._peek("a::y")).toBeDefined();
expect(shared._peek("b::z")).toBeDefined();
// Wrapper A clears.
await wrapperA.clear();
expect(shared._peek("a::x")).toBeUndefined();
expect(shared._peek("a::y")).toBeUndefined();
expect(shared._peek("b::z")).toEqual(new Uint8Array([0xb, 1]));
});
});

View File

@@ -0,0 +1,141 @@
import type { CacheSetOptions, CacheStats } from "@suaveplan/cache";
import { AbstractCache } from "@suaveplan/cache";
/**
* Real in-memory `AbstractCache<Uint8Array>` subclass used by the unit
* tests. NOT a mock — it implements every abstract method against a
* backing `Map`. The `failNext(op, err)` helper injects a deterministic
* rejection for the next call to that op so the wrapper's
* cache-failure-swallow paths are exercised against real throwing
* behaviour.
*
* The class deliberately omits `keys()` by default; the
* `KeysCapableFakeCache` subclass adds it. This lets the wrapper's
* `clear()` feature-detection be tested against both shapes without a
* mock.
*/
export class FakeCache extends AbstractCache<Uint8Array> {
#data = new Map<string, Uint8Array>();
public getCalls: string[] = [];
public setCalls: { key: string; value: Uint8Array }[] = [];
public deleteCalls: string[] = [];
public clearCalls = 0;
public hasCalls: string[] = [];
#failNext: Partial<
Record<"get" | "set" | "delete" | "clear" | "has", Error>
> = {};
public failNext(
op: "get" | "set" | "delete" | "clear" | "has",
err: Error
): void {
this.#failNext[op] = err;
}
override async get(key: string): Promise<Uint8Array | undefined> {
this.getCalls.push(key);
const fail = this.#failNext.get;
if (fail !== undefined) {
this.#failNext.get = undefined;
throw fail;
}
return this.#data.get(key);
}
override async set(
key: string,
value: Uint8Array,
_options?: CacheSetOptions
): Promise<void> {
this.setCalls.push({ key, value });
const fail = this.#failNext.set;
if (fail !== undefined) {
this.#failNext.set = undefined;
throw fail;
}
this.#data.set(key, value);
}
override async delete(key: string): Promise<boolean> {
this.deleteCalls.push(key);
const fail = this.#failNext.delete;
if (fail !== undefined) {
this.#failNext.delete = undefined;
throw fail;
}
return this.#data.delete(key);
}
override async clear(): Promise<void> {
this.clearCalls += 1;
const fail = this.#failNext.clear;
if (fail !== undefined) {
this.#failNext.clear = undefined;
throw fail;
}
this.#data.clear();
}
override async has(key: string): Promise<boolean> {
this.hasCalls.push(key);
const fail = this.#failNext.has;
if (fail !== undefined) {
this.#failNext.has = undefined;
throw fail;
}
return this.#data.has(key);
}
override getStats(): CacheStats {
return {
hits: 0,
misses: 0,
size: this.#data.size,
hitRate: 0,
evictions: 0,
};
}
/** Test-only direct access. */
public _peek(key: string): Uint8Array | undefined {
return this.#data.get(key);
}
/** Test-only direct seed. */
public _seed(key: string, value: Uint8Array): void {
this.#data.set(key, value);
}
public get _data(): ReadonlyMap<string, Uint8Array> {
return this.#data;
}
}
/**
* `FakeCache` extension that exposes a synchronous `keys()` iterator
* — the exact shape `CachedStorage.clear()` feature-detects.
*/
export class KeysCapableFakeCache extends FakeCache {
public keysCalls = 0;
public *keys(): IterableIterator<string> {
this.keysCalls += 1;
for (const k of this._data.keys()) yield k;
}
}
/**
* `FakeCache` extension exposing an async `keys()` iterator —
* representative of remote cache backends that stream keys.
*/
export class AsyncKeysCapableFakeCache extends FakeCache {
public keysCalls = 0;
public async *keys(): AsyncIterableIterator<string> {
this.keysCalls += 1;
for (const k of this._data.keys()) {
yield k;
}
}
}

View File

@@ -0,0 +1,264 @@
import {
AbstractStorage,
STORAGE_CODES,
StorageError,
type StorageListOptions,
type StorageListResult,
type StorageMetadata,
type StorageResult,
} from "@suaveplan/storage";
/**
* Real in-memory `AbstractStorage` subclass used by the unit tests. NOT
* a mock — it implements every abstract method against a backing
* `Map<string, Uint8Array>`. The helpers `setFailOn` / `setReturn`
* inject deterministic failures or value overrides for one upcoming
* call (e.g. force `set("x", _)` to reject with a foreign throwable so
* the wrapper's "failed set does not invalidate" path is exercised).
*/
export class FakeStorage extends AbstractStorage {
#data = new Map<string, Uint8Array>();
public getCalls: { key: string; signal?: AbortSignal | undefined }[] = [];
public setCalls: {
key: string;
value: Uint8Array;
signal?: AbortSignal | undefined;
}[] = [];
public setPartialCalls: {
key: string;
offset: number;
bytes: Uint8Array;
}[] = [];
public deleteCalls: { key: string }[] = [];
public clearCalls = 0;
public existsCalls: { key: string }[] = [];
public listCalls: { options?: StorageListOptions | undefined }[] = [];
public getMetadataCalls: { key: string }[] = [];
public getWithMetadataCalls: { key: string }[] = [];
public getSizeCalls = 0;
public getItemSizeCalls: { key: string }[] = [];
public getPartialCalls: {
key: string;
offset: number;
length: number;
}[] = [];
public createReadStreamCalls: { key: string }[] = [];
public createWriteStreamCalls: { key: string }[] = [];
#failNext: Partial<
Record<"get" | "set" | "setPartial" | "delete" | "clear", Error>
> = {};
#returnNext: { get?: Uint8Array | undefined } = {};
public failNext(
op: "get" | "set" | "setPartial" | "delete" | "clear",
err: Error
): void {
this.#failNext[op] = err;
}
public returnNextGet(value: Uint8Array | undefined): void {
this.#returnNext.get = value;
}
public seed(key: string, value: Uint8Array): void {
this.#data.set(key, value);
}
async get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
this.getCalls.push({ key, signal });
const fail = this.#failNext.get;
if (fail !== undefined) {
this.#failNext.get = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
if ("get" in this.#returnNext) {
const override = this.#returnNext.get;
delete this.#returnNext.get;
return override;
}
return this.#data.get(key);
}
async set(
key: string,
value: Uint8Array,
_options?: unknown,
signal?: AbortSignal
): Promise<void> {
this.setCalls.push({ key, value, signal });
const fail = this.#failNext.set;
if (fail !== undefined) {
this.#failNext.set = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
this.#data.set(key, value);
}
async setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
this.setPartialCalls.push({ key, offset, bytes });
const fail = this.#failNext.setPartial;
if (fail !== undefined) {
this.#failNext.setPartial = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
await this.defaultSetPartial(key, offset, bytes, signal);
}
async delete(key: string, signal?: AbortSignal): Promise<boolean> {
this.deleteCalls.push({ key });
const fail = this.#failNext.delete;
if (fail !== undefined) {
this.#failNext.delete = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
return this.#data.delete(key);
}
async clear(signal?: AbortSignal): Promise<void> {
this.clearCalls += 1;
const fail = this.#failNext.clear;
if (fail !== undefined) {
this.#failNext.clear = undefined;
throw fail;
}
if (signal?.aborted === true) {
throw new StorageError(STORAGE_CODES.ABORTED, "aborted");
}
this.#data.clear();
}
async exists(key: string, _signal?: AbortSignal): Promise<boolean> {
this.existsCalls.push({ key });
return this.#data.has(key);
}
async list(
options?: StorageListOptions,
_signal?: AbortSignal
): Promise<StorageListResult> {
this.listCalls.push({ options });
const prefix = options?.prefix ?? "";
const keys = [...this.#data.keys()].filter((k) => k.startsWith(prefix));
return { keys, total: keys.length, hasMore: false };
}
async getMetadata(
key: string,
_signal?: AbortSignal
): Promise<StorageMetadata | null> {
this.getMetadataCalls.push({ key });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return {
size: bytes.byteLength,
createdAt: 0,
modifiedAt: 0,
};
}
async getWithMetadata(
key: string,
_signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
this.getWithMetadataCalls.push({ key });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return {
value: bytes,
metadata: {
size: bytes.byteLength,
createdAt: 0,
modifiedAt: 0,
},
};
}
async getSize(_signal?: AbortSignal): Promise<number> {
this.getSizeCalls += 1;
let total = 0;
for (const v of this.#data.values()) total += v.byteLength;
return total;
}
async getItemSize(
key: string,
_signal?: AbortSignal
): Promise<number | null> {
this.getItemSizeCalls.push({ key });
const bytes = this.#data.get(key);
return bytes === undefined ? null : bytes.byteLength;
}
async getPartial(
key: string,
offset: number,
length: number,
_signal?: AbortSignal
): Promise<Uint8Array | null> {
this.getPartialCalls.push({ key, offset, length });
const bytes = this.#data.get(key);
if (bytes === undefined) return null;
return bytes.subarray(offset, offset + length);
}
createReadStream(
key: string,
_signal?: AbortSignal
): ReadableStream<Uint8Array> {
this.createReadStreamCalls.push({ key });
const bytes = this.#data.get(key);
return new ReadableStream<Uint8Array>({
start(controller) {
if (bytes !== undefined) controller.enqueue(bytes);
controller.close();
},
});
}
createWriteStream(
key: string,
_signal?: AbortSignal
): WritableStream<Uint8Array> {
this.createWriteStreamCalls.push({ key });
const chunks: Uint8Array[] = [];
const data = this.#data;
return new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(chunk);
},
close() {
let total = 0;
for (const c of chunks) total += c.byteLength;
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.byteLength;
}
data.set(key, merged);
},
});
}
}

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required beyond what
// `@suaveplan/testing/polyfills` already installs. Node 18+ provides
// `URL`, `AbortController`, `ReadableStream`, `WritableStream`, and
// `Response` natively. File exists so vitest has a stable additional
// setup entry alongside the package-local fixture imports.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/storage-plugin-cache",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/cache",
"@suaveplan/error",
"@suaveplan/storage",
"@suaveplan/types",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30000,
additionalSetupFiles: ["./tests/setup/polyfills.ts"],
});