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