Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
# @suaveplan/cache-storage - AbstractCache backend that bridges any @suaveplan/storage AbstractStorage into a typed cache via a 32-byte fixed-width TTL header and pluggable Codec<T> (JSON default, identity for Uint8Array).
`AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec<T>` (JSON default, identity for `Uint8Array`).
<!-- begin:auto-docs -->
## Documentation tree
- [identity](./src/codec/identity.md) — `IdentityCodec` is the `Codec<Uint8Array>` implementation that passes bytes through without encoding overhead. Use it w…
- [json](./src/codec/json.md) — `JsonCodec<T>` is the default codec for `StorageCache`. It serialises arbitrary JSON-compatible values to UTF-8 bytes v…
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt bl…
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt bl…
- [header](./src/header/header.md) — Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries…
- [header](./src/header/header.md) — Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries…
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-regist…
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-regist…
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/…
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/…
<!-- end:auto-docs -->
## Summary
`@suaveplan/cache-storage` is the universal cache backend for the cache family. It composes any byte-oriented storage adapter — `MemoryStorage`, `storage-browser-localstorage`, `storage-browser-indexeddb`, `storage-browser-opfs`, `storage-file`, `storage-s3`, `storage-cdn-*` — into a typed cache with TTL, lifecycle events, namespacing, and structured errors. The package subsumes the upstream `cache-browser` package by composition: pick the storage adapter that matches your environment, and the cache surface is identical to `MemoryCache`. Side-effect import auto-registers `StorageCache` as the `"storage"` provider on `CacheFactory`, so `createCache("storage", { storage })` works after a single import.
## Installation
```bash
bun add @suaveplan/cache-storage
```
## Quick Start
```ts
import { StorageCache } from "@suaveplan/cache-storage";
import { MemoryStorage } from "@suaveplan/storage/memory";
const cache = new StorageCache<{ id: string; email: string }>({
storage: new MemoryStorage(),
defaultTtl: 5 * 60_000,
});
await cache.set("user:1", { id: "1", email: "alice@example.com" });
const user = await cache.get("user:1");
```
## Modules
- [errors](./src/errors/errors.md) — `CACHE_STORAGE_CODES`, `StorageCacheError`.
- [header](./src/header/header.md) — 32-byte fixed-width metadata header codec.
- [codec/identity](./src/codec/identity.md) — `IdentityCodec` for `Uint8Array` values.
- [codec/json](./src/codec/json.md) — `JsonCodec<T>` default codec.
- [storage-cache](./src/storage-cache/storage-cache.md) — `StorageCache<T>` class.
- [registry-augment](./src/registry-augment/registry-augment.md) — `CacheProviderRegistry` augmentation + auto-registration.
## Browser-side caching (replaces `cache-browser`)
```ts
import { StorageCache } from "@suaveplan/cache-storage";
import { LocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
const cache = new StorageCache<string>({
storage: new LocalStorageStorage(),
namespace: "ui:preferences",
defaultTtl: 7 * 24 * 60 * 60_000,
});
```
The same pattern works over `storage-browser-indexeddb` (larger payloads) and `storage-browser-opfs` (file-grade durability).
## Factory registration
```ts
import "@suaveplan/cache-storage"; // side-effect: register provider
import { createCache } from "@suaveplan/cache";
import { MemoryStorage } from "@suaveplan/storage/memory";
const cache = createCache("storage", { storage: new MemoryStorage() });
```
## Dependencies
| Dependency | Purpose |
| --- | --- |
| `@suaveplan/cache` | `AbstractCache<T>`, `CacheFactory`, `CacheProviderRegistry`, `CacheEvents`, `CacheSetOptions`. |
| `@suaveplan/error` | `defineCodes`, `SuaveplanError`. |
| `@suaveplan/storage` | `AbstractStorage` contract. |
| `@suaveplan/types` | `EventBusLike`. |
| `tslib` | TypeScript helpers. |

View File

@@ -0,0 +1,103 @@
{
"name": "@suaveplan/cache-storage",
"version": "0.1.0",
"type": "module",
"sideEffects": [
"./src/index.ts",
"./src/registry-augment/registry-augment.ts",
"./dist/index.js",
"./dist/registry-augment/registry-augment.js"
],
"description": "AbstractCache backend that bridges any @suaveplan/storage AbstractStorage into a typed cache via a 32-byte fixed-width TTL header and pluggable Codec<T> (JSON default, identity for Uint8Array).",
"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"
}
},
"./errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./header": {
"import": {
"types": "./dist/header/header.d.ts",
"default": "./dist/header/header.js"
}
},
"./codec": {
"import": {
"types": "./dist/codec/index.d.ts",
"default": "./dist/codec/index.js"
}
},
"./codec/identity": {
"import": {
"types": "./dist/codec/identity.d.ts",
"default": "./dist/codec/identity.js"
}
},
"./codec/json": {
"import": {
"types": "./dist/codec/json.d.ts",
"default": "./dist/codec/json.js"
}
},
"./storage-cache": {
"import": {
"types": "./dist/storage-cache/storage-cache.d.ts",
"default": "./dist/storage-cache/storage-cache.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/cache": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,12 @@
/**
* Bidirectional value codec consumed by `StorageCache` and `RedisCache`.
*
* `encode` MUST produce the bytes that will be written to the underlying
* backend; `decode` MUST round-trip them. Implementations SHOULD throw
* `StorageCacheError` with code `CACHE_STORAGE.CODEC_ERROR` when decode
* fails — the cache wraps the throw into a structured `get` rejection.
*/
export interface Codec<T> {
encode(value: T): Uint8Array;
decode(bytes: Uint8Array): T;
}

View File

@@ -0,0 +1,66 @@
# IdentityCodec
## Purpose
`IdentityCodec` is the `Codec<Uint8Array>` implementation that passes bytes through without encoding overhead. Use it when the cached `T` is already binary — chunked file data, pre-serialised protobufs, encrypted blobs, audio samples — and reserialising through JSON would be wasteful or lossy. The codec is paired with `StorageCache<Uint8Array>` to provide a byte-cache surface over any `AbstractStorage`.
## Features
- Zero-copy `encode` — returns the input `Uint8Array` by reference. Suitable for high-throughput paths where allocations matter.
- Defensive-copy `decode` — returns a fresh `Uint8Array` so callers mutating the result cannot corrupt the underlying storage buffer.
- No throws — every input is valid because the encode step is the identity function.
- Stateless — a single instance is safe to share across cache instances and concurrent operations.
## Usage: Basic Example
```ts
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
import { StorageCache } from "@suaveplan/cache-storage";
import { MemoryStorage } from "@suaveplan/storage/memory";
const cache = new StorageCache<Uint8Array>({
storage: new MemoryStorage(),
codec: new IdentityCodec(),
});
await cache.set("blob:1", new Uint8Array([1, 2, 3, 4]));
const out = await cache.get("blob:1");
```
## Usage: Advanced Example
```ts
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
import { StorageCache } from "@suaveplan/cache-storage";
// Cache pre-compressed payloads: avoid re-encoding on every cache hit.
const cache = new StorageCache<Uint8Array>({
storage: filesystemStorage,
codec: new IdentityCodec(),
defaultTtl: 60_000,
});
const compressed = await gzip(payload);
await cache.set(`gz:${id}`, compressed);
const cached = await cache.get(`gz:${id}`);
if (cached !== undefined) {
// Stream straight to the response without a decode step.
res.setHeader("content-encoding", "gzip");
res.end(cached);
}
```
## API Reference
| Member | Signature | Description |
| --- | --- | --- |
| `new IdentityCodec()` | — | Constructs a stateless codec. No arguments. |
| `encode(value)` | `(Uint8Array) → Uint8Array` | Returns `value` by reference. |
| `decode(bytes)` | `(Uint8Array) → Uint8Array` | Returns a fresh copy of `bytes`. |
Conforms to `Codec<Uint8Array>` from `@suaveplan/cache-storage/codec`.
## Implementation Notes
The `encode` step is intentionally non-copying. `StorageCache` immediately wraps the returned bytes in a fresh combined buffer (header + body), so referential sharing does not bleed into the on-disk representation. The `decode` step copies because the storage backend may hand out its internal buffer for performance — `MemoryStorage` and `storage-file` both do — and a caller that mutated the result would otherwise see arbitrary corruption on the next read.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IdentityCodec } from "./identity.js";
describe("IdentityCodec", () => {
it("encode then decode preserves the byte sequence", {
timeout: 5000,
}, () => {
const codec = new IdentityCodec();
const input = new Uint8Array([1, 2, 3, 4, 5]);
const decoded = codec.decode(codec.encode(input));
expect(Array.from(decoded)).toEqual([1, 2, 3, 4, 5]);
});
it("encode returns the input by reference (zero copy)", {
timeout: 5000,
}, () => {
const codec = new IdentityCodec();
const input = new Uint8Array([7, 8, 9]);
expect(codec.encode(input)).toBe(input);
});
it("decode returns a copy, not the original buffer", {
timeout: 5000,
}, () => {
const codec = new IdentityCodec();
const input = new Uint8Array([1, 2, 3]);
const decoded = codec.decode(input);
expect(decoded).not.toBe(input);
decoded[0] = 99;
expect(input[0]).toBe(1);
});
});

View File

@@ -0,0 +1,20 @@
import type { Codec } from "./codec.types.js";
/**
* Pass-through codec for `Uint8Array` values.
*
* The encode step is the identity function; the decode step copies the slice
* into a fresh `Uint8Array` so callers cannot accidentally mutate the
* underlying storage buffer.
*/
export class IdentityCodec implements Codec<Uint8Array> {
public encode(value: Uint8Array): Uint8Array {
return value;
}
public decode(bytes: Uint8Array): Uint8Array {
const out = new Uint8Array(bytes.length);
out.set(bytes);
return out;
}
}

View File

@@ -0,0 +1,3 @@
export type { Codec } from "./codec.types.js";
export { IdentityCodec } from "./identity.js";
export { JsonCodec } from "./json.js";

View File

@@ -0,0 +1,79 @@
# JsonCodec
## Purpose
`JsonCodec<T>` is the default codec for `StorageCache`. It serialises arbitrary JSON-compatible values to UTF-8 bytes via `JSON.stringify` + `TextEncoder.encode`, and parses bytes back through `TextDecoder.decode` (in fatal mode) + `JSON.parse`. Use it for the typical cache payload — profile objects, configuration documents, API responses, denormalised projections — anywhere the value is already JSON-safe.
## Features
- Round-trips every JSON-safe TypeScript value: strings, numbers, booleans, `null`, plain objects, arrays, and nested combinations of these.
- Full multi-byte UTF-8 support — emoji, CJK, RTL scripts, mathematical operators all survive a round-trip.
- Fatal-mode UTF-8 decode — invalid leading sequences fail loud instead of silently producing replacement characters, so corruption surfaces immediately.
- Decode failures (malformed UTF-8 OR malformed JSON) are wrapped in `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` with the original throw on `cause`, so callers see a single structured failure shape.
- Stateless — share one instance across many caches; both `TextEncoder` and `TextDecoder` are module-level singletons.
## Usage: Basic Example
```ts
import { JsonCodec } from "@suaveplan/cache-storage/codec/json";
const codec = new JsonCodec<{ user: string; roles: string[] }>();
const bytes = codec.encode({ user: "alice", roles: ["admin"] });
const value = codec.decode(bytes);
```
## Usage: Advanced Example
```ts
import { JsonCodec } from "@suaveplan/cache-storage/codec/json";
import { StorageCache } from "@suaveplan/cache-storage";
import { MemoryStorage } from "@suaveplan/storage/memory";
import {
CACHE_STORAGE_CODES,
StorageCacheError,
} from "@suaveplan/cache-storage/errors";
interface UserSummary {
id: string;
email: string;
preferredLocale: string;
}
const cache = new StorageCache<UserSummary>({
storage: new MemoryStorage(),
codec: new JsonCodec<UserSummary>(),
defaultTtl: 5 * 60_000,
});
async function loadUser(id: string): Promise<UserSummary> {
try {
return await cache.wrap(`user:${id}`, () => fetchUser(id));
} catch (err) {
if (
err instanceof StorageCacheError &&
err.code === CACHE_STORAGE_CODES.CODEC_ERROR
) {
log.warn("Corrupted cache entry — refetching", { id, cause: err.cause });
await cache.delete(`user:${id}`);
return fetchUser(id);
}
throw err;
}
}
```
## API Reference
| Member | Signature | Description |
| --- | --- | --- |
| `new JsonCodec<T>()` | — | Constructs a stateless codec for `T`. |
| `encode(value)` | `(T) → Uint8Array` | `TextEncoder.encode(JSON.stringify(value))`. |
| `decode(bytes)` | `(Uint8Array) → T` | `JSON.parse(TextDecoder.decode(bytes))`. Throws `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` on malformed input. |
Conforms to `Codec<T>` from `@suaveplan/cache-storage/codec`.
## Implementation Notes
`TextDecoder` is constructed once at module load with `{ fatal: true }`. The fatal flag is critical — without it, invalid bytes would be replaced with `U+FFFD` and the cache would silently return decoded garbage. The split try/catch separates the two failure modes (`utf8` vs `json`) in the wrapped error's `messageKey` so observability pipelines can disambiguate them.
`encode` failures from `JSON.stringify` (circular references, `BigInt`, `Symbol`) propagate as-is — they originate in the application's data, not the cache, and `StorageCache.set` adapts them into `CACHE_STORAGE.CODEC_ERROR` at the cache boundary so the public surface stays uniform.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
import { JsonCodec } from "./json.js";
describe("JsonCodec", () => {
it("round-trips a structured value through encode then decode", {
timeout: 5000,
}, () => {
const codec = new JsonCodec<{ user: string; roles: string[] }>();
const input = { user: "alice", roles: ["admin", "ops"] };
expect(codec.decode(codec.encode(input))).toEqual(input);
});
it("round-trips multi-byte UTF-8 characters", { timeout: 5000 }, () => {
const codec = new JsonCodec<string>();
const input = "πœ¶ — 日本語 — 🚀";
expect(codec.decode(codec.encode(input))).toBe(input);
});
it("decode throws CODEC_ERROR on malformed bytes", {
timeout: 5000,
}, () => {
const codec = new JsonCodec<unknown>();
// 0xff 0xfe 0xfd is invalid UTF-8 leading sequence.
expect(() => codec.decode(new Uint8Array([0xff, 0xfe, 0xfd]))).toThrow(
StorageCacheError
);
try {
codec.decode(new Uint8Array([0xff, 0xfe, 0xfd]));
} catch (e) {
expect((e as StorageCacheError).code).toBe(
CACHE_STORAGE_CODES.CODEC_ERROR
);
}
});
it("decode throws CODEC_ERROR on valid UTF-8 but malformed JSON", {
timeout: 5000,
}, () => {
const codec = new JsonCodec<unknown>();
const enc = new TextEncoder();
const bad = enc.encode("{not valid json");
expect(() => codec.decode(bad)).toThrow(StorageCacheError);
});
});

View File

@@ -0,0 +1,41 @@
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
import type { Codec } from "./codec.types.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });
/**
* UTF-8 JSON codec. `encode` is `JSON.stringify` → `TextEncoder.encode`;
* `decode` is `TextDecoder.decode` (fatal mode) → `JSON.parse`.
*
* Decode failures (invalid UTF-8 or malformed JSON) are mapped to
* `StorageCacheError` with code `CACHE_STORAGE.CODEC_ERROR`. Encode failures
* (e.g. circular references) propagate as-is.
*/
export class JsonCodec<T> implements Codec<T> {
public encode(value: T): Uint8Array {
return encoder.encode(JSON.stringify(value));
}
public decode(bytes: Uint8Array): T {
let text: string;
try {
text = decoder.decode(bytes);
} catch (cause) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.CODEC_ERROR,
"error.cache-storage.codec.utf8",
{ cause: cause as Error }
);
}
try {
return JSON.parse(text) as T;
} catch (cause) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.CODEC_ERROR,
"error.cache-storage.codec.json",
{ cause: cause as Error }
);
}
}
}

View File

@@ -0,0 +1,76 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error surface for `@suaveplan/cache-storage`. Every failure path inside the package — corrupt blob layouts, codec decode failures, oversized keys or values, propagated backend faults — is reported by throwing a `StorageCacheError` carrying one of the codes registered in `CACHE_STORAGE_CODES`. Consumers programmatically branch on `error.code` (a discriminated union literal like `"CACHE_STORAGE.CODEC_ERROR"`) instead of pattern-matching free-form message strings. The optional `key` discriminant identifies the cache key that produced the failure when one is known.
## Features
- 5-code registry built with `defineCodes("CACHE_STORAGE", [...])` so codes serialise identically across the OpenTelemetry span pipeline, the wire-format error envelope, and the i18n resolver.
- `StorageCacheError` extends `SuaveplanError<CacheStorageCode>` for cause-chain traversal and structured `traceId` / `context` propagation.
- Optional `key` discriminant carried on every instance — present on every code that originates at a key boundary (read, write, delete, validate).
- Every code is exercised by `errors.test.ts` against a real call site, satisfying the §13 step 0 reachability gate.
## Usage: Basic Example
```ts
import {
CACHE_STORAGE_CODES,
StorageCacheError,
} from "@suaveplan/cache-storage/errors";
try {
await cache.get("k");
} catch (err) {
if (
err instanceof StorageCacheError &&
err.code === CACHE_STORAGE_CODES.ENTRY_CORRUPT
) {
log.warn("Corrupted cache entry — deleting", { key: err.key });
}
}
```
## Usage: Advanced Example
```ts
import {
CACHE_STORAGE_CODES,
StorageCacheError,
} from "@suaveplan/cache-storage/errors";
function classify(err: unknown): "retry" | "fatal" | "ignore" {
if (!(err instanceof StorageCacheError)) return "fatal";
switch (err.code) {
case CACHE_STORAGE_CODES.BACKEND_ERROR:
return "retry";
case CACHE_STORAGE_CODES.ENTRY_CORRUPT:
case CACHE_STORAGE_CODES.CODEC_ERROR:
return "ignore"; // cache miss is safe
case CACHE_STORAGE_CODES.KEY_TOO_LONG:
case CACHE_STORAGE_CODES.VALUE_TOO_LARGE:
return "fatal";
default:
return "fatal";
}
}
```
## API Reference
| Export | Kind | Purpose |
| --- | --- | --- |
| `CACHE_STORAGE_CODES` | const | Frozen object mapping reason → `"CACHE_STORAGE.<REASON>"` value. |
| `CacheStorageCode` | type | Union of the registered code values. |
| `StorageCacheError` | class | `extends SuaveplanError<CacheStorageCode>`; constructor accepts `(code, messageKey, options?)` where options may include `key`, `cause`, `context`, `traceId`. |
| `StorageCacheErrorOptions` | type | `SuaveplanErrorOptions & { key?: string }`. |
Registered codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`.
## Implementation Notes
Codes are intentionally minimal — five is enough to cover every failure mode `StorageCache` can produce. `BACKEND_ERROR` is the only code that wraps a foreign exception (the underlying `AbstractStorage.get` / `set`); the others originate inside the package and have well-defined call sites. Decode failures from the codec always surface as `CODEC_ERROR` even when the codec implementation throws something else — `StorageCache.#decodeBody` adapts arbitrary throwables into the canonical form.

View File

@@ -0,0 +1,126 @@
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { IdentityCodec } from "../codec/identity.js";
import { JsonCodec } from "../codec/json.js";
import { encodeHeader, HEADER_BYTES } from "../header/header.js";
import { StorageCache } from "../storage-cache/storage-cache.js";
import {
CACHE_STORAGE_CODES,
type CacheStorageCode,
StorageCacheError,
} from "./errors.js";
describe("CACHE_STORAGE_CODES registry", () => {
it("every CACHE_STORAGE code is reachable from a real call site", {
timeout: 5000,
}, async () => {
const observed = new Set<CacheStorageCode>();
// ENTRY_CORRUPT — short blob.
const storageShort = new MemoryStorage();
await storageShort.set("k", new Uint8Array(16));
const cacheShort = new StorageCache<unknown>({
storage: storageShort,
});
try {
await cacheShort.get("k");
} catch (e) {
if (e instanceof StorageCacheError) observed.add(e.code);
}
// CODEC_ERROR — JsonCodec on invalid UTF-8 bytes after a synthetic header.
const storageBadJson = new MemoryStorage();
const header = encodeHeader({
createdAt: 0,
expiresAt: 0,
flags: 0,
hitCount: 0,
});
const bad = new Uint8Array(HEADER_BYTES + 3);
bad.set(header, 0);
bad.set([0xff, 0xfe, 0xfd], HEADER_BYTES);
await storageBadJson.set("k", bad);
const cacheBadJson = new StorageCache<unknown>({
storage: storageBadJson,
codec: new JsonCodec<unknown>(),
});
try {
await cacheBadJson.get("k");
} catch (e) {
if (e instanceof StorageCacheError) observed.add(e.code);
}
// BACKEND_ERROR — fake storage that throws.
const cacheBackend = new StorageCache<string>({
storage: {
async get() {
throw new Error("disk on fire");
},
async set() {},
async delete() {
return false;
},
async exists() {
return false;
},
async list() {
return { keys: [], total: 0, hasMore: false };
},
async clear() {},
} as unknown as ConstructorParameters<
typeof StorageCache
>[0]["storage"],
});
try {
await cacheBackend.get("k");
} catch (e) {
if (e instanceof StorageCacheError) observed.add(e.code);
}
// KEY_TOO_LONG.
const cacheKey = new StorageCache<string>({
storage: new MemoryStorage(),
});
try {
await cacheKey.set("x".repeat(1025), "v");
} catch (e) {
if (e instanceof StorageCacheError) observed.add(e.code);
}
// VALUE_TOO_LARGE.
const cacheValue = new StorageCache<Uint8Array>({
storage: new MemoryStorage(),
codec: new IdentityCodec(),
});
try {
await cacheValue.set("k", new Uint8Array(10 * 1024 * 1024 + 1));
} catch (e) {
if (e instanceof StorageCacheError) observed.add(e.code);
}
const expected = new Set(Object.values(CACHE_STORAGE_CODES));
expect(observed).toEqual(expected);
});
it("StorageCacheError carries the key discriminant", {
timeout: 5000,
}, () => {
const err = new StorageCacheError(
CACHE_STORAGE_CODES.ENTRY_CORRUPT,
"x",
{ key: "my-key" }
);
expect(err.key).toBe("my-key");
expect(err.code).toBe(CACHE_STORAGE_CODES.ENTRY_CORRUPT);
});
it("StorageCacheError without key option has undefined key", {
timeout: 5000,
}, () => {
const err = new StorageCacheError(
CACHE_STORAGE_CODES.BACKEND_ERROR,
"x"
);
expect(err.key).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/cache-storage`.
*
* Codes are namespaced `CACHE_STORAGE.<REASON>` and registered globally via
* `defineCodes` from `@suaveplan/error`.
*/
export const CACHE_STORAGE_CODES = defineCodes("CACHE_STORAGE", [
"ENTRY_CORRUPT",
"CODEC_ERROR",
"BACKEND_ERROR",
"KEY_TOO_LONG",
"VALUE_TOO_LARGE",
] as const);
export type CacheStorageCode =
(typeof CACHE_STORAGE_CODES)[keyof typeof CACHE_STORAGE_CODES];
/** Discriminant carried on every {@link StorageCacheError}. */
export interface StorageCacheErrorOptions extends SuaveplanErrorOptions {
/** The unprefixed key that produced the failure, when available. */
key?: string;
}
/**
* Structured error class for `@suaveplan/cache-storage` failures.
*
* Extends `SuaveplanError<CacheStorageCode>` for cause-chain traversal and
* wire serialisation parity with the rest of the monorepo.
*/
export class StorageCacheError extends SuaveplanError<CacheStorageCode> {
public readonly key: string | undefined;
public constructor(
code: CacheStorageCode,
message: string,
options?: StorageCacheErrorOptions
) {
super(code, message, options);
this.key = options?.key;
}
}

View File

@@ -0,0 +1,8 @@
export type {
CacheStorageCode,
StorageCacheErrorOptions,
} from "./errors.js";
export {
CACHE_STORAGE_CODES,
StorageCacheError,
} from "./errors.js";

View File

@@ -0,0 +1,81 @@
# header
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the 32-byte fixed-width metadata header that `StorageCache` prepends to every cached value. The header carries `createdAt`, `expiresAt`, `flags`, and `hitCount` so the cache can recover TTL semantics over any byte-oriented `AbstractStorage` without a side-channel metadata API. Fixed width keeps reads single-range and avoids a second round-trip when the underlying store supports byte-range fetches.
## Features
- 32 bytes, big-endian, deterministic layout — byte-identical between writers and readers across runtimes.
- Two `u64` fields (`createdAt`, `expiresAt`) accommodate millisecond timestamps well past the year 10000.
- One `u32` `flags` word carries codec selector bits (`FLAG_CODEC_JSON`) and a `FLAG_HAS_METADATA` presence bit reserved for downstream extensions.
- One `u32` `hitCount` saturates at 2³² − 1 — informational only; not rewritten on read in this version.
- 8 reserved bytes at the tail leave room for future extension without a version bump.
- Bounds-checked `encodeHeader` — out-of-range fields surface as `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` rather than producing a silently truncated blob.
## Usage: Basic Example
```ts
import { decodeHeader, encodeHeader } from "@suaveplan/cache-storage/header";
const bytes = encodeHeader({
createdAt: Date.now(),
expiresAt: 0,
flags: 0,
hitCount: 0,
});
const meta = decodeHeader(bytes);
```
## Usage: Advanced Example
```ts
import {
decodeHeader,
encodeHeader,
FLAG_CODEC_JSON,
FLAG_HAS_METADATA,
HEADER_BYTES,
} from "@suaveplan/cache-storage/header";
function packEntry(value: Uint8Array, ttlMs: number): Uint8Array {
const now = Date.now();
const head = encodeHeader({
createdAt: now,
expiresAt: ttlMs > 0 ? now + ttlMs : 0,
flags: FLAG_HAS_METADATA | FLAG_CODEC_JSON,
hitCount: 0,
});
const out = new Uint8Array(HEADER_BYTES + value.length);
out.set(head, 0);
out.set(value, HEADER_BYTES);
return out;
}
function unpackEntry(blob: Uint8Array): {
meta: ReturnType<typeof decodeHeader>;
body: Uint8Array;
} {
return { meta: decodeHeader(blob), body: blob.subarray(HEADER_BYTES) };
}
```
## API Reference
| Export | Kind | Purpose |
| --- | --- | --- |
| `HEADER_BYTES` | const number `32` | Fixed length of the metadata header. |
| `HeaderMeta` | interface | `{ createdAt: number; expiresAt: number; flags: number; hitCount: number }`. |
| `encodeHeader(meta)` | function | Returns a fresh 32-byte `Uint8Array`. Throws `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` when any field is out of range. |
| `decodeHeader(bytes)` | function | Parses the leading 32 bytes. Throws `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` when the buffer is shorter. |
| `FLAG_HAS_METADATA` | const number | Bit 0 — set by `StorageCache` on every encode. |
| `FLAG_CODEC_JSON` | const number | Bit 1 — set when the body was produced by `JsonCodec` (for future routing). |
## Implementation Notes
`u64` values are split into two `u32` big-endian writes (`high * 2³² + low`) so the encoder runs without `BigInt` overhead. `decodeHeader` is the inverse. The reserved 8 bytes at offset 24..31 are zeroed at encode time to keep on-disk parity bytewise stable; future versions may stuff version markers there without breaking forward decoders that mask the field.

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
import {
decodeHeader,
encodeHeader,
FLAG_CODEC_JSON,
FLAG_HAS_METADATA,
HEADER_BYTES,
type HeaderMeta,
} from "./header.js";
describe("header", () => {
it("HEADER_BYTES is exactly 32", { timeout: 5000 }, () => {
expect(HEADER_BYTES).toBe(32);
});
it("header.encode then header.decode round-trips every field", {
timeout: 5000,
}, () => {
const meta: HeaderMeta = {
createdAt: 1_700_000_000_000,
expiresAt: 1_700_000_060_000,
flags: FLAG_HAS_METADATA | FLAG_CODEC_JSON,
hitCount: 42,
};
expect(decodeHeader(encodeHeader(meta))).toEqual(meta);
});
it("round-trips boundary values: zero expiresAt, max-uint32 hitCount", {
timeout: 5000,
}, () => {
const meta: HeaderMeta = {
createdAt: 0,
expiresAt: 0,
flags: 0,
hitCount: 0xffff_ffff,
};
expect(decodeHeader(encodeHeader(meta))).toEqual(meta);
});
it("decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES", {
timeout: 5000,
}, () => {
expect(() => decodeHeader(new Uint8Array(16))).toThrow(
StorageCacheError
);
try {
decodeHeader(new Uint8Array(16));
} catch (e) {
expect((e as StorageCacheError).code).toBe(
CACHE_STORAGE_CODES.ENTRY_CORRUPT
);
}
});
it("encodeHeader rejects negative createdAt with ENTRY_CORRUPT", {
timeout: 5000,
}, () => {
expect(() =>
encodeHeader({
createdAt: -1,
expiresAt: 0,
flags: 0,
hitCount: 0,
})
).toThrow(StorageCacheError);
});
it("encodeHeader rejects out-of-range flags with ENTRY_CORRUPT", {
timeout: 5000,
}, () => {
expect(() =>
encodeHeader({
createdAt: 0,
expiresAt: 0,
flags: 0x1_0000_0000,
hitCount: 0,
})
).toThrow(StorageCacheError);
});
it("encodeHeader rejects negative hitCount with ENTRY_CORRUPT", {
timeout: 5000,
}, () => {
expect(() =>
encodeHeader({
createdAt: 0,
expiresAt: 0,
flags: 0,
hitCount: -1,
})
).toThrow(StorageCacheError);
});
it("reserved bytes 24..31 are zero in encoded output", {
timeout: 5000,
}, () => {
const buf = encodeHeader({
createdAt: 1,
expiresAt: 2,
flags: 3,
hitCount: 4,
});
expect(buf.slice(24, 32)).toEqual(new Uint8Array(8));
});
});

View File

@@ -0,0 +1,93 @@
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
/** Fixed width of the metadata header prepended to every stored value. */
export const HEADER_BYTES = 32;
/** Metadata encoded in the leading {@link HEADER_BYTES} of every stored value. */
export interface HeaderMeta {
/** Creation timestamp in milliseconds since epoch. */
createdAt: number;
/** Expiration timestamp in milliseconds since epoch; `0` ⇒ no expiry. */
expiresAt: number;
/** Bit-packed flags. Bit 0: hasMetadata. Bit 1: codecJson. Rest reserved. */
flags: number;
/** Access counter; saturates at 2^32 - 1. */
hitCount: number;
}
const U32_MAX = 0xffff_ffff;
function writeUint64BE(view: DataView, offset: number, value: number): void {
if (!Number.isFinite(value) || value < 0) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.ENTRY_CORRUPT,
"error.cache-storage.header.value-out-of-range"
);
}
const high = Math.floor(value / 0x1_0000_0000);
const low = value >>> 0;
view.setUint32(offset, high, false);
view.setUint32(offset + 4, low, false);
}
function readUint64BE(view: DataView, offset: number): number {
const high = view.getUint32(offset, false);
const low = view.getUint32(offset + 4, false);
return high * 0x1_0000_0000 + low;
}
function writeUint32BE(view: DataView, offset: number, value: number): void {
if (!Number.isFinite(value) || value < 0 || value > U32_MAX) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.ENTRY_CORRUPT,
"error.cache-storage.header.value-out-of-range"
);
}
view.setUint32(offset, value >>> 0, false);
}
/**
* Encode {@link HeaderMeta} into a fresh {@link HEADER_BYTES}-byte
* `Uint8Array` using big-endian field order.
*
* Throws {@link StorageCacheError} with code `CACHE_STORAGE.ENTRY_CORRUPT` if
* any field is out of range for its destination integer width.
*/
export function encodeHeader(meta: HeaderMeta): Uint8Array {
const buffer = new Uint8Array(HEADER_BYTES);
const view = new DataView(buffer.buffer);
writeUint64BE(view, 0, meta.createdAt);
writeUint64BE(view, 8, meta.expiresAt);
writeUint32BE(view, 16, meta.flags);
writeUint32BE(view, 20, meta.hitCount);
// bytes 24..31 stay zero (reserved)
return buffer;
}
/**
* Decode the leading {@link HEADER_BYTES} of `bytes` into {@link HeaderMeta}.
*
* Throws {@link StorageCacheError} with code `CACHE_STORAGE.ENTRY_CORRUPT`
* when `bytes.length < HEADER_BYTES`.
*/
export function decodeHeader(bytes: Uint8Array): HeaderMeta {
if (bytes.length < HEADER_BYTES) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.ENTRY_CORRUPT,
"error.cache-storage.header.short-buffer"
);
}
const view = new DataView(bytes.buffer, bytes.byteOffset, HEADER_BYTES);
return {
createdAt: readUint64BE(view, 0),
expiresAt: readUint64BE(view, 8),
flags: view.getUint32(16, false),
hitCount: view.getUint32(20, false),
};
}
/** Bit set in `flags` when the entry is encoded with `JsonCodec`. */
export const FLAG_CODEC_JSON = 1 << 1;
/** Bit set in `flags` when the entry carries the standard metadata header. */
export const FLAG_HAS_METADATA = 1 << 0;

View File

@@ -0,0 +1,8 @@
export type { HeaderMeta } from "./header.js";
export {
decodeHeader,
encodeHeader,
FLAG_CODEC_JSON,
FLAG_HAS_METADATA,
HEADER_BYTES,
} from "./header.js";

View File

@@ -0,0 +1,29 @@
// Errors
// Codec layer
export { type Codec, IdentityCodec, JsonCodec } from "./codec/index.js";
export {
CACHE_STORAGE_CODES,
type CacheStorageCode,
StorageCacheError,
type StorageCacheErrorOptions,
} from "./errors/index.js";
// Header
export {
decodeHeader,
encodeHeader,
FLAG_CODEC_JSON,
FLAG_HAS_METADATA,
HEADER_BYTES,
type HeaderMeta,
} from "./header/index.js";
// Registry registration (also runs as a side effect of importing this barrel)
export { registerStorageProvider } from "./registry-augment/index.js";
// Cache
export {
StorageCache,
type StorageCacheOptions,
} from "./storage-cache/index.js";
// Side-effect: register the "storage" provider exactly once.
import "./registry-augment/index.js";

View File

@@ -0,0 +1 @@
export { registerStorageProvider } from "./registry-augment.js";

View File

@@ -0,0 +1,63 @@
# registry-augment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `storage` entry, and side-effect-registers `StorageCache` as the implementation behind that key on the shared `CacheFactory`. Importing this module — directly or transitively via the package root — is enough; the registration is idempotent and survives repeated imports or duplicate package versions resolving to the same singleton registry.
## Features
- TypeScript-side module augmentation makes `createCache("storage", config)` type-safe — the registry key is part of `CacheProviderType`, and the config parameter is inferred from `StorageCacheOptions<T>` (via the registry slot).
- Idempotent runtime registration — guarded by both an internal `registered` flag and a `hasCacheProvider("storage")` query against the live registry, so double-import never throws or overwrites.
- Side-effect import — the `registry-augment.ts` module is listed in `"sideEffects"` in `package.json` so bundlers preserve the registration call when the rest of the package is tree-shaken.
## Usage: Basic Example
```ts
// Side-effect import: registers the "storage" provider.
import "@suaveplan/cache-storage";
import { createCache } from "@suaveplan/cache";
import { MemoryStorage } from "@suaveplan/storage/memory";
const cache = createCache("storage", { storage: new MemoryStorage() });
```
## Usage: Advanced Example
```ts
// Manually drive registration — useful in tests that need to control the
// order of provider availability or want to confirm idempotency.
import {
hasCacheProvider,
unregisterCacheProvider,
} from "@suaveplan/cache";
import { registerStorageProvider } from "@suaveplan/cache-storage";
unregisterCacheProvider("storage");
registerStorageProvider();
console.log(hasCacheProvider("storage")); // true
```
## API Reference
| Export | Kind | Description |
| --- | --- | --- |
| `registerStorageProvider()` | function | Idempotently calls `registerCacheProvider("storage", StorageCache)`. Safe to invoke repeatedly. |
The module also performs module augmentation:
```ts
declare module "@suaveplan/cache" {
interface CacheProviderRegistry {
storage: typeof StorageCache;
}
}
```
## Implementation Notes
The `registered` module-local flag is set even when the registry already contained the provider (e.g. because another package version registered it first). That ensures the second branch is exercised at most once per module instance and that subsequent calls hit the fast path. The function is exported so test suites can drive it explicitly after a `clearCacheProviders()` call without re-importing the package.

View File

@@ -0,0 +1,34 @@
import { AbstractCache, createCache, hasCacheProvider } from "@suaveplan/cache";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
// Import the augment module for its side effect.
import "./registry-augment.js";
import { StorageCache } from "../storage-cache/index.js";
import { registerStorageProvider } from "./registry-augment.js";
describe("registry-augment", () => {
it("hasCacheProvider returns true after import", { timeout: 5000 }, () => {
expect(hasCacheProvider("storage")).toBe(true);
});
it('createCache("storage", ...) returns a StorageCache instance', {
timeout: 5000,
}, () => {
const cache = createCache(
"storage" as never,
{
storage: new MemoryStorage(),
} as never
);
expect(cache).toBeInstanceOf(StorageCache);
expect(cache).toBeInstanceOf(AbstractCache);
});
it("calling registerStorageProvider a second time is a no-op", {
timeout: 5000,
}, () => {
expect(() => registerStorageProvider()).not.toThrow();
expect(() => registerStorageProvider()).not.toThrow();
expect(hasCacheProvider("storage")).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
import { hasCacheProvider, registerCacheProvider } from "@suaveplan/cache";
import { StorageCache } from "../storage-cache/index.js";
declare module "@suaveplan/cache" {
interface CacheProviderRegistry {
storage: typeof StorageCache;
}
}
let registered = false;
/**
* Idempotently register `StorageCache` as the `"storage"` provider on the
* shared `CacheFactory` registry.
*
* Side-effect import — running this module once is enough; repeated imports
* are no-ops.
*/
export function registerStorageProvider(): void {
if (registered || hasCacheProvider("storage")) {
registered = true;
return;
}
registerCacheProvider(
"storage",
StorageCache as unknown as Parameters<typeof registerCacheProvider>[1]
);
registered = true;
}
registerStorageProvider();

View File

@@ -0,0 +1,2 @@
export { StorageCache } from "./storage-cache.js";
export type { StorageCacheOptions } from "./storage-cache.types.js";

View File

@@ -0,0 +1,108 @@
# StorageCache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StorageCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and turns any `AbstractStorage` (from `@suaveplan/storage`) into a typed cache. Behind the type-level `T` API it stores each entry as a 32-byte fixed-width metadata header followed by codec-encoded value bytes — letting plain byte stores like `storage-browser-localstorage`, `storage-file`, or `storage-s3` participate in the cache provider registry without bespoke per-backend code. The class subsumes the upstream `cache-browser` package by composition: pick the storage adapter that matches your environment, hand it to `StorageCache`, and the cache surface is identical to `MemoryCache`.
## Features
- Drop-in `AbstractCache<T>` subclass — inherits `wrap`, `getMultiple`, `setMultiple` with `AbortSignal` and stale-while-revalidate semantics unchanged.
- 32-byte TTL header (`createdAt`, `expiresAt`, `flags`, `hitCount`) — lazy expiration on read, never expires when `expiresAt === 0`.
- Pluggable `Codec<T>` — `JsonCodec` by default; swap to `IdentityCodec` for binary payloads.
- Optional namespacing — prefixes every storage key with `${namespace}:`. Namespaced `clear()` lists + deletes prefix-matched keys via the storage backend's `list({ prefix })` cursor.
- Lifecycle events (`hit`, `miss`, `set`, `delete`, `clear`, `expire`) emitted through any `EventBusLike<CacheEvents>` — listeners optional, zero-cost when omitted.
- Structured `StorageCacheError` for all failure modes — `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`.
- Stats counters — `hits`, `misses`, `evictions`, `size`, `hitRate`. Size tracked client-side via insert/delete deltas (storage backends rarely expose a cheap count).
- Auto-registered with the `CacheFactory` registry as the `"storage"` provider on side-effect import.
## Usage: Basic Example
```ts
import { StorageCache } from "@suaveplan/cache-storage";
import { MemoryStorage } from "@suaveplan/storage/memory";
const cache = new StorageCache<{ id: string; name: string }>({
storage: new MemoryStorage(),
defaultTtl: 60_000,
});
await cache.set("user:1", { id: "1", name: "Alice" });
const user = await cache.get("user:1");
```
## Usage: Advanced Example
```ts
import "@suaveplan/cache-storage"; // side-effect: registers the "storage" provider
import { createCache } from "@suaveplan/cache";
import { StorageCache } from "@suaveplan/cache-storage";
import { IdentityCodec } from "@suaveplan/cache-storage/codec/identity";
import { EventBus } from "@suaveplan/events";
import { FileStorage } from "@suaveplan/storage-file";
const events = new EventBus();
events.on("evict", ({ key, reason }) => log.info("evicted", { key, reason }));
// Construct directly:
const direct = new StorageCache<Uint8Array>({
storage: new FileStorage({ root: "/var/cache/app" }),
codec: new IdentityCodec(),
namespace: "thumbnails",
defaultTtl: 24 * 60 * 60_000,
events,
});
// Or via the factory (same instance shape, registry-driven):
const indirect = createCache("storage" as never, {
storage: new FileStorage({ root: "/var/cache/app" }),
namespace: "responses",
} as never);
await direct.set("avatar:42", await renderAvatar(42));
// `wrap` with single-flight + SWR is inherited:
const summary = await direct.wrap(
`summary:${userId}`,
() => buildSummary(userId),
{ ttl: 60_000, staleWhileRevalidate: true, staleThreshold: 5_000 },
);
```
## API Reference
### Constructor
`new StorageCache<T>(options: StorageCacheOptions<T>)`
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `storage` | `AbstractStorage` | — (required) | Underlying byte-oriented storage adapter. |
| `codec` | `Codec<T>` | `new JsonCodec<T>()` | Value codec. |
| `defaultTtl` | `number` | `0` | TTL in ms when `set` omits one. `0` means no expiry. |
| `clock` | `Clock` | `() => Date.now()` | Injectable time source. |
| `events` | `EventBusLike<CacheEvents>` | `undefined` | Lifecycle event sink. |
| `namespace` | `string` | `""` | Key prefix; empty disables. |
### Methods
Every `AbstractCacheShape<T>` method: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. See the parent `AbstractCache` documentation for the inherited helpers.
### Thrown errors
- `StorageCacheError("CACHE_STORAGE.ENTRY_CORRUPT")` — stored blob shorter than `HEADER_BYTES`, or decoded header out of range.
- `StorageCacheError("CACHE_STORAGE.CODEC_ERROR")` — codec encode or decode failure.
- `StorageCacheError("CACHE_STORAGE.BACKEND_ERROR")` — underlying `AbstractStorage` threw.
- `StorageCacheError("CACHE_STORAGE.KEY_TOO_LONG")` — key longer than 1024 chars.
- `StorageCacheError("CACHE_STORAGE.VALUE_TOO_LARGE")` — encoded value exceeds 10 MiB.
## Implementation Notes
`get` performs a single `storage.get(prefixedKey)` and parses the header in place. Expired entries are deleted lazily — there is no background sweeper, which keeps the package universal (no `setInterval` dependency). The size counter is tracked through `set` and `delete` deltas; it is not authoritative when the underlying storage is shared with another writer, but cache stats are advisory in that case anyway.
`clear()` with no namespace defers to the storage backend's own `clear()` — fast and atomic where the backend supports it. With a namespace, it iterates `storage.list({ prefix })` in pages of 1000 and deletes each batch, which is O(n) in matching keys but bounded by namespace size.
`hitCount` is set to `0` on write and **not** rewritten on read. Bumping it would turn every `get` into a `getThenSet`, halving throughput; the field is reserved for a future `getPartial`-aware variant that updates the header in place when the backend supports byte-range writes.

View File

@@ -0,0 +1,524 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AbstractCache } from "@suaveplan/cache";
import { MemoryStorage } from "@suaveplan/storage/memory";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { IdentityCodec } from "../codec/identity.js";
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
import { HEADER_BYTES } from "../header/header.js";
import { StorageCache } from "./storage-cache.js";
function newClock(start = 0) {
let now = start;
return {
now: () => now,
advance: (ms: number) => {
now += ms;
},
};
}
function newBus(): {
bus: EventBusLike;
emitted: Array<{ type: string; payload: unknown }>;
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const listeners = new Map<string, Array<(payload: unknown) => void>>();
const bus: EventBusLike = {
on(type, listener) {
const arr = listeners.get(type as string) ?? [];
arr.push(listener as (payload: unknown) => void);
listeners.set(type as string, arr);
return () => {
const a = listeners.get(type as string);
if (a === undefined) return;
const idx = a.indexOf(listener as (payload: unknown) => void);
if (idx >= 0) a.splice(idx, 1);
};
},
emit(type, payload) {
emitted.push({ type: type as string, payload });
for (const l of listeners.get(type as string) ?? []) {
l(payload);
}
},
};
return { bus, emitted };
}
const T = { timeout: 5000 } as const;
describe("StorageCache", () => {
it(
"instance is an AbstractCache and exposes the AbstractCacheShape surface",
T,
() => {
const cache = new StorageCache<{ x: number }>({
storage: new MemoryStorage(),
});
expect(cache).toBeInstanceOf(AbstractCache);
expect(typeof cache.get).toBe("function");
expect(typeof cache.set).toBe("function");
expect(typeof cache.delete).toBe("function");
expect(typeof cache.clear).toBe("function");
expect(typeof cache.has).toBe("function");
expect(typeof cache.getStats).toBe("function");
expect(typeof cache.getMultiple).toBe("function");
expect(typeof cache.setMultiple).toBe("function");
expect(typeof cache.wrap).toBe("function");
}
);
it("inherited wrap caches the loader result on a miss", T, async () => {
const cache = new StorageCache<number>({
storage: new MemoryStorage(),
});
let calls = 0;
const loader = async () => {
calls += 1;
return 42;
};
expect(await cache.wrap("k", loader)).toBe(42);
expect(await cache.get("k")).toBe(42);
expect(calls).toBe(1);
});
it(
"get returns undefined and deletes when entry has expired",
T,
async () => {
const clock = newClock(1_000);
const { bus, emitted } = newBus();
const storage = new MemoryStorage();
const cache = new StorageCache<string>({
storage,
clock: clock.now,
events: bus,
});
await cache.set("k", "v", { ttl: 10 });
clock.advance(20);
expect(await cache.get("k")).toBeUndefined();
expect(await storage.exists("k")).toBe(false);
expect(emitted.some((e) => e.type === "expire")).toBe(true);
}
);
it("entry with no ttl never expires", T, async () => {
const clock = newClock(1_000);
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
clock: clock.now,
defaultTtl: 0,
});
await cache.set("k", "v");
clock.advance(10 * 24 * 60 * 60 * 1000);
expect(await cache.get("k")).toBe("v");
});
it(
"get throws ENTRY_CORRUPT when underlying storage returns fewer than HEADER_BYTES bytes",
T,
async () => {
const storage = new MemoryStorage();
await storage.set("k", new Uint8Array(16));
const cache = new StorageCache<unknown>({ storage });
await expect(cache.get("k")).rejects.toBeInstanceOf(
StorageCacheError
);
try {
await cache.get("k");
} catch (e) {
expect((e as StorageCacheError).code).toBe(
CACHE_STORAGE_CODES.ENTRY_CORRUPT
);
expect((e as StorageCacheError).key).toBe("k");
}
}
);
it(
"namespace prefixes keys before delegating to the underlying storage",
T,
async () => {
const storage = new MemoryStorage();
const cache = new StorageCache<string>({
storage,
namespace: "users",
});
await cache.set("alice", "v");
expect(await storage.exists("users:alice")).toBe(true);
expect(await storage.exists("alice")).toBe(false);
}
);
it("emits hit event when get returns a stored value", T, async () => {
const { bus, emitted } = newBus();
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
events: bus,
});
await cache.set("k", "v");
await cache.get("k");
expect(
emitted.filter((e) => e.type === "hit").map((e) => e.payload)
).toEqual([{ key: "k" }]);
});
it("emits miss event when get returns undefined", T, async () => {
const { bus, emitted } = newBus();
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
events: bus,
});
await cache.get("missing");
expect(emitted).toContainEqual({
type: "miss",
payload: { key: "missing" },
});
});
it("hitRate computes hits over hits-plus-misses", T, async () => {
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
});
await cache.set("k", "v");
for (let i = 0; i < 7; i++) await cache.get("k");
for (let i = 0; i < 3; i++) await cache.get("missing");
const stats = cache.getStats();
expect(stats.hits).toBe(7);
expect(stats.misses).toBe(3);
expect(stats.hitRate).toBeCloseTo(0.7);
});
it("hitRate is 0 when there are no operations", T, () => {
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
});
expect(cache.getStats().hitRate).toBe(0);
});
it(
"delete returns true when the key existed and emits delete",
T,
async () => {
const { bus, emitted } = newBus();
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
events: bus,
});
await cache.set("k", "v");
expect(await cache.delete("k")).toBe(true);
expect(await cache.delete("k")).toBe(false);
expect(emitted.filter((e) => e.type === "delete")).toHaveLength(1);
}
);
it(
"has returns true for stored, false for expired/missing",
T,
async () => {
const clock = newClock(0);
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
clock: clock.now,
});
await cache.set("present", "v", { ttl: 100 });
expect(await cache.has("present")).toBe(true);
clock.advance(200);
expect(await cache.has("present")).toBe(false);
expect(await cache.has("never-set")).toBe(false);
}
);
it(
"clear without namespace empties the underlying storage",
T,
async () => {
const { bus, emitted } = newBus();
const storage = new MemoryStorage();
const cache = new StorageCache<string>({ storage, events: bus });
await cache.set("a", "1");
await cache.set("b", "2");
await cache.clear();
expect(await storage.exists("a")).toBe(false);
expect(await storage.exists("b")).toBe(false);
expect(emitted.some((e) => e.type === "clear")).toBe(true);
}
);
it("clear with namespace iterates a paginated list", T, async () => {
// Fake storage whose list returns one batch with hasMore=true, then empty.
const store = new Map<string, Uint8Array>();
const seed = (k: string) => {
const blob = new Uint8Array(HEADER_BYTES + 1);
store.set(k, blob);
};
seed("p:a");
seed("p:b");
seed("p:c");
let calls = 0;
const cache = new StorageCache<string>({
storage: {
async get(k: string) {
return store.get(k);
},
async set(k: string, v: Uint8Array) {
store.set(k, v);
},
async delete(k: string) {
return store.delete(k);
},
async exists(k: string) {
return store.has(k);
},
async list({ prefix }: { prefix?: string }) {
calls += 1;
const all = [...store.keys()].filter(
(k) => prefix === undefined || k.startsWith(prefix)
);
// First page returns one key + hasMore; subsequent return what's left.
if (calls === 1) {
return {
keys: all.slice(0, 1),
total: all.length,
hasMore: true,
};
}
return {
keys: all,
total: all.length,
hasMore: false,
};
},
async clear() {
store.clear();
},
} as unknown as ConstructorParameters<
typeof StorageCache
>[0]["storage"],
namespace: "p",
});
await cache.clear();
expect(store.size).toBe(0);
expect(calls).toBeGreaterThanOrEqual(2);
});
it("clear with namespace deletes only namespaced keys", T, async () => {
const storage = new MemoryStorage();
const ns = new StorageCache<string>({
storage,
namespace: "users",
});
await ns.set("alice", "1");
await ns.set("bob", "2");
await storage.set("other:c", new Uint8Array(HEADER_BYTES + 1));
await ns.clear();
expect(await storage.exists("users:alice")).toBe(false);
expect(await storage.exists("users:bob")).toBe(false);
expect(await storage.exists("other:c")).toBe(true);
});
it(
"getMultiple and setMultiple round-trip through the inherited helpers",
T,
async () => {
const cache = new StorageCache<number>({
storage: new MemoryStorage(),
});
await cache.setMultiple([
{ key: "a", value: 1 },
{ key: "b", value: 2 },
]);
const result = await cache.getMultiple(["a", "b", "missing"]);
expect(result.found.get("a")).toBe(1);
expect(result.found.get("b")).toBe(2);
expect(result.missing).toEqual(["missing"]);
}
);
it("works with IdentityCodec for Uint8Array values", T, async () => {
const cache = new StorageCache<Uint8Array>({
storage: new MemoryStorage(),
codec: new IdentityCodec(),
});
const value = new Uint8Array([1, 2, 3, 4, 5]);
await cache.set("k", value);
const out = await cache.get("k");
expect(out).not.toBeUndefined();
expect(Array.from(out as Uint8Array)).toEqual([1, 2, 3, 4, 5]);
});
it("KEY_TOO_LONG when the key exceeds 1024 chars", T, async () => {
const cache = new StorageCache<string>({
storage: new MemoryStorage(),
});
const longKey = "x".repeat(1025);
await expect(cache.set(longKey, "v")).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.KEY_TOO_LONG,
});
});
it(
"VALUE_TOO_LARGE when the encoded value exceeds the cap",
T,
async () => {
const cache = new StorageCache<Uint8Array>({
storage: new MemoryStorage(),
codec: new IdentityCodec(),
});
const tooBig = new Uint8Array(10 * 1024 * 1024 + 1);
await expect(cache.set("k", tooBig)).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.VALUE_TOO_LARGE,
});
}
);
it(
"BACKEND_ERROR when the underlying storage throws on read",
T,
async () => {
const cache = new StorageCache<string>({
storage: {
async get() {
throw new Error("disk on fire");
},
async set() {},
async delete() {
return false;
},
async exists() {
return false;
},
async list() {
return { keys: [], total: 0, hasMore: false };
},
async clear() {},
} as unknown as ConstructorParameters<
typeof StorageCache
>[0]["storage"],
});
await expect(cache.get("k")).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.BACKEND_ERROR,
});
}
);
it(
"BACKEND_ERROR when the underlying storage throws on write",
T,
async () => {
const cache = new StorageCache<string>({
storage: {
async get() {
return undefined;
},
async set() {
throw new Error("disk full");
},
async delete() {
return false;
},
async exists() {
return false;
},
async list() {
return { keys: [], total: 0, hasMore: false };
},
async clear() {},
} as unknown as ConstructorParameters<
typeof StorageCache
>[0]["storage"],
});
await expect(cache.set("k", "v")).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.BACKEND_ERROR,
});
}
);
it("CODEC_ERROR when the codec throws during encode", T, async () => {
const cache = new StorageCache<unknown>({
storage: new MemoryStorage(),
codec: {
encode() {
throw new TypeError("circular");
},
decode() {
return undefined;
},
},
});
await expect(cache.set("k", { circular: true })).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.CODEC_ERROR,
});
});
it("CODEC_ERROR when the codec throws during decode", T, async () => {
const storage = new MemoryStorage();
const cache = new StorageCache<unknown>({
storage,
codec: {
encode() {
return new Uint8Array([1, 2, 3]);
},
decode() {
throw new TypeError("bad bytes");
},
},
});
await cache.set("k", { anything: true });
await expect(cache.get("k")).rejects.toMatchObject({
code: CACHE_STORAGE_CODES.CODEC_ERROR,
});
});
it(
"propagates an already-StorageCacheError from the codec without re-wrapping",
T,
async () => {
const storage = new MemoryStorage();
const cache = new StorageCache<unknown>({
storage,
codec: {
encode() {
return new Uint8Array([1]);
},
decode() {
throw new StorageCacheError(
CACHE_STORAGE_CODES.CODEC_ERROR,
"x"
);
},
},
});
await cache.set("k", "v");
await expect(cache.get("k")).rejects.toBeInstanceOf(
StorageCacheError
);
}
);
it("src tree contains no node: or bun: imports", T, () => {
const root = join(__dirname, "..");
const offenders: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) {
walk(p);
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts")
) {
const text = readFileSync(p, "utf8");
if (/from\s+["'](?:node:|bun:)/.test(text)) {
offenders.push(p);
}
}
}
};
walk(root);
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,240 @@
import {
AbstractCache,
type CacheEvents,
type CacheSetOptions,
type CacheStats,
type Clock,
} from "@suaveplan/cache";
import type { AbstractStorage } from "@suaveplan/storage";
import type { EventBusLike } from "@suaveplan/types";
type EventName = keyof CacheEvents;
import type { Codec } from "../codec/codec.types.js";
import { JsonCodec } from "../codec/json.js";
import { CACHE_STORAGE_CODES, StorageCacheError } from "../errors/index.js";
import {
decodeHeader,
encodeHeader,
FLAG_HAS_METADATA,
HEADER_BYTES,
type HeaderMeta,
} from "../header/header.js";
import type { StorageCacheOptions } from "./storage-cache.types.js";
/** Hard cap on key length; matches AbstractStorage's own conservative budget. */
const MAX_KEY_LENGTH = 1024;
/** Hard cap on encoded value size (10 MiB) — protects backends with quotas. */
const MAX_VALUE_BYTES = 10 * 1024 * 1024;
/**
* `AbstractCache<T>` backed by any `AbstractStorage`. Stores each entry as a
* 32-byte metadata header followed by the codec-encoded value bytes.
*
* Inherits `wrap`, `getMultiple`, `setMultiple` from `AbstractCache` without
* override.
*/
export class StorageCache<T = unknown> extends AbstractCache<T> {
readonly #storage: AbstractStorage;
readonly #codec: Codec<T>;
readonly #defaultTtl: number;
readonly #clock: Clock;
readonly #events: EventBusLike | undefined;
readonly #namespace: string;
#hits = 0;
#misses = 0;
#evictions = 0;
#size = 0;
public constructor(options: StorageCacheOptions<T>) {
super();
this.#storage = options.storage;
this.#codec = options.codec ?? (new JsonCodec<T>() as Codec<T>);
this.#defaultTtl = options.defaultTtl ?? 0;
this.#clock = options.clock ?? (() => Date.now());
this.#events = options.events;
this.#namespace = options.namespace ?? "";
}
#prefixed(key: string): string {
return this.#namespace === "" ? key : `${this.#namespace}:${key}`;
}
#validateKey(key: string): void {
if (key.length > MAX_KEY_LENGTH) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.KEY_TOO_LONG,
"error.cache-storage.key-too-long",
{ key }
);
}
}
#validateValueBytes(key: string, bytes: Uint8Array): void {
if (bytes.length > MAX_VALUE_BYTES) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.VALUE_TOO_LARGE,
"error.cache-storage.value-too-large",
{ key }
);
}
}
#emit<K extends EventName>(type: K, payload: CacheEvents[K]): void {
if (this.#events !== undefined) {
this.#events.emit(type as string, payload);
}
}
#decodeBody(key: string, bytes: Uint8Array): T {
try {
return this.#codec.decode(bytes.subarray(HEADER_BYTES));
} catch (cause) {
if (cause instanceof StorageCacheError) {
throw cause;
}
throw new StorageCacheError(
CACHE_STORAGE_CODES.CODEC_ERROR,
"error.cache-storage.codec.body",
{ cause: cause as Error, key }
);
}
}
async #readStored(prefixedKey: string): Promise<Uint8Array | undefined> {
try {
const out = await this.#storage.get(prefixedKey);
return out ?? undefined;
} catch (cause) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.BACKEND_ERROR,
"error.cache-storage.backend.read",
{ cause: cause as Error, key: prefixedKey }
);
}
}
public async get(key: string): Promise<T | undefined> {
this.#validateKey(key);
const prefixed = this.#prefixed(key);
const bytes = await this.#readStored(prefixed);
if (bytes === undefined) {
this.#misses += 1;
this.#emit("miss", { key });
return undefined;
}
if (bytes.length < HEADER_BYTES) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.ENTRY_CORRUPT,
"error.cache-storage.entry-corrupt",
{ key }
);
}
const meta = decodeHeader(bytes);
const now = this.#clock();
if (meta.expiresAt !== 0 && now >= meta.expiresAt) {
await this.#storage.delete(prefixed);
this.#size = Math.max(0, this.#size - 1);
this.#evictions += 1;
this.#emit("expire", { key });
this.#misses += 1;
this.#emit("miss", { key });
return undefined;
}
const value = this.#decodeBody(key, bytes);
this.#hits += 1;
this.#emit("hit", { key });
return value;
}
public async set(
key: string,
value: T,
options?: CacheSetOptions
): Promise<void> {
this.#validateKey(key);
const ttl = options?.ttl ?? this.#defaultTtl;
const now = this.#clock();
const meta: HeaderMeta = {
createdAt: now,
expiresAt: ttl > 0 ? now + ttl : 0,
flags: FLAG_HAS_METADATA,
hitCount: 0,
};
let body: Uint8Array;
try {
body = this.#codec.encode(value);
} catch (cause) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.CODEC_ERROR,
"error.cache-storage.codec.encode",
{ cause: cause as Error, key }
);
}
this.#validateValueBytes(key, body);
const blob = new Uint8Array(HEADER_BYTES + body.length);
blob.set(encodeHeader(meta), 0);
blob.set(body, HEADER_BYTES);
const prefixed = this.#prefixed(key);
try {
await this.#storage.set(prefixed, blob);
} catch (cause) {
throw new StorageCacheError(
CACHE_STORAGE_CODES.BACKEND_ERROR,
"error.cache-storage.backend.write",
{ cause: cause as Error, key }
);
}
this.#size += 1;
this.#emit("set", ttl > 0 ? { key, ttl } : { key });
}
public async delete(key: string): Promise<boolean> {
this.#validateKey(key);
const prefixed = this.#prefixed(key);
const existed = await this.#storage.delete(prefixed);
if (existed) {
this.#size = Math.max(0, this.#size - 1);
this.#emit("delete", { key });
}
return existed;
}
public async has(key: string): Promise<boolean> {
this.#validateKey(key);
// Reuse `get` semantics so TTL is honoured and the miss event fires.
const value = await this.get(key);
return value !== undefined;
}
public async clear(): Promise<void> {
if (this.#namespace === "") {
await this.#storage.clear();
} else {
const prefix = `${this.#namespace}:`;
let hasMore = true;
while (hasMore) {
const result = await this.#storage.list({ prefix });
for (const k of result.keys) {
await this.#storage.delete(k);
}
hasMore = result.hasMore && result.keys.length > 0;
}
}
this.#size = 0;
this.#emit("clear", {});
}
public getStats(): CacheStats {
const total = this.#hits + this.#misses;
return {
hits: this.#hits,
misses: this.#misses,
size: this.#size,
hitRate: total === 0 ? 0 : this.#hits / total,
evictions: this.#evictions,
};
}
}

View File

@@ -0,0 +1,20 @@
import type { CacheProviderConfig, Clock } from "@suaveplan/cache";
import type { AbstractStorage } from "@suaveplan/storage";
import type { EventBusLike } from "@suaveplan/types";
import type { Codec } from "../codec/codec.types.js";
/** Options accepted by the {@link StorageCache} constructor. */
export interface StorageCacheOptions<T> extends CacheProviderConfig {
/** Underlying byte-oriented storage adapter. Required. */
storage: AbstractStorage;
/** Value codec. Defaults to `JsonCodec<T>`. */
codec?: Codec<T>;
/** Default TTL applied when `set` omits one. Milliseconds; `0` ⇒ no expiry. */
defaultTtl?: number;
/** Injectable clock. Defaults to `() => Date.now()`. */
clock?: Clock;
/** Optional event bus for lifecycle topics. */
events?: EventBusLike;
/** Key prefix; prepended with `:` separator. Empty/undefined disables. */
namespace?: string;
}

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/cache-storage",
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: [
"@suaveplan/cache",
"@suaveplan/error",
"@suaveplan/storage",
"@suaveplan/types",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});