Move docs/ package mirror into document-navigator plugin

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

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

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

View File

@@ -0,0 +1,15 @@
# `cache`
Caching abstractions, memory caches, and Redis backends.
<!-- begin:auto-docs -->
## Packages in `cache`
3 packages.
| Package | Description |
|---|---|
| [`@suaveplan/cache`](./cache/README.md) | Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFac… |
| [`@suaveplan/cache-redis`](./cache-redis/README.md) | `AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage`… |
| [`@suaveplan/cache-storage`](./cache-storage/README.md) | `AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Caching abstractions, memory caches, and Redis backends.

View File

@@ -0,0 +1,80 @@
# @suaveplan/cache-redis - AbstractCache backend over a minimal RedisLike interface — native SET ... EX, GET, DEL, EXISTS, TTL, SCAN. Reuses @suaveplan/cache-storage codecs (JSON / identity / base64).
`AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage` codecs (JSON / identity / base64).
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops…
- [errors](./src/errors/errors.md) — Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops…
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal…
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal…
- [redis-like](./src/redis-like/redis-like.md) — Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `…
- [redis-like](./src/redis-like/redis-like.md) — Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `…
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-register…
- [registry-augment](./src/registry-augment/registry-augment.md) — Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-register…
<!-- end:auto-docs -->
## Summary
`@suaveplan/cache-redis` is the server-tier cache backend for the cache family. It plugs any Redis-compatible client (`ioredis`, `node-redis`, custom adapters over RESP3, KeyDB, Dragonfly) into the unified `AbstractCache<T>` surface. TTL is owned by the Redis server via native `SET ... EX`, so there are no client-side header bytes, no per-read expiry comparisons, and no client-side eviction tables — Redis's own `maxmemory-policy` decides what to evict. Side-effect import auto-registers `RedisCache` as the `"redis"` provider on `CacheFactory`, so `createCache("redis", { redis })` works after a single import.
## Installation
```bash
bun add @suaveplan/cache-redis
```
## Quick Start
```ts
import { RedisCache } from "@suaveplan/cache-redis";
import { Redis } from "ioredis";
const cache = new RedisCache<{ id: string; email: string }>({
redis: new Redis({ host: "redis.example.com" }),
namespace: "users",
defaultTtl: 5 * 60_000,
});
await cache.set("alice", { id: "1", email: "alice@example.com" });
const user = await cache.get("alice");
```
## Modules
- [errors](./src/errors/errors.md) — `CACHE_REDIS_CODES`, `RedisCacheError`.
- [redis-like](./src/redis-like/redis-like.md) — minimal `RedisLike` client surface.
- [redis-cache](./src/redis-cache/redis-cache.md) — `RedisCache<T>` class.
- [registry-augment](./src/registry-augment/registry-augment.md) — `CacheProviderRegistry` augmentation + auto-registration.
## Factory registration
```ts
import "@suaveplan/cache-redis"; // side-effect: register provider
import { createCache } from "@suaveplan/cache";
import { Redis } from "ioredis";
const cache = createCache("redis", { redis: new Redis() });
```
## Why a separate package, not `cache-storage` over a future `storage-redis`?
Redis owns TTL natively (`EX`, `EXPIRE`) and eviction natively (`maxmemory-policy`). Layering the cache over a hypothetical `storage-redis` would:
1. Encode TTL into the value bytes (wasted — Redis ignores it).
2. Require client-side eviction tracking (wasted — Redis evicts per policy).
3. Lose `TTL key` introspection (Redis can tell us; the header can't).
So `RedisCache` talks to Redis directly via `RedisLike`, and `cache-storage` is reserved for genuinely byte-oriented stores (filesystem, S3, browser storage).
## Dependencies
| Dependency | Purpose |
| --- | --- |
| `@suaveplan/cache` | `AbstractCache<T>`, `CacheFactory`, `CacheProviderRegistry`, `CacheEvents`. |
| `@suaveplan/cache-storage` | Shared `Codec<T>` contract + `JsonCodec` / `IdentityCodec`. |
| `@suaveplan/error` | `defineCodes`, `SuaveplanError`. |
| `@suaveplan/types` | `EventBusLike`. |
| `tslib` | TypeScript helpers. |

View File

@@ -0,0 +1,76 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error surface for `@suaveplan/cache-redis`. Every failure inside the package — connection drops to the Redis server, individual command rejections, codec round-trip failures, oversized keys, invalid TTL arguments — surfaces as a `RedisCacheError` carrying one of the codes registered in `CACHE_REDIS_CODES`. Consumers programmatically branch on `error.code` rather than parsing free-form messages, and the optional `key` and `command` discriminants pinpoint exactly which command on which key triggered the failure.
## Features
- 5-code registry built with `defineCodes("CACHE_REDIS", [...])` so codes serialise identically across OpenTelemetry span events, the wire-format error envelope, and the i18n resolver.
- `RedisCacheError` extends `SuaveplanError<CacheRedisCode>` for cause chains, structured `traceId`, and arbitrary `context`.
- `key` and `command` discriminants on every error — present whenever a failure originates at a known key or command boundary.
- Every code is exercised at a real call site by `errors.test.ts`, satisfying the §13 step 0 reachability gate.
## Usage: Basic Example
```ts
import {
CACHE_REDIS_CODES,
RedisCacheError,
} from "@suaveplan/cache-redis/errors";
try {
await cache.set("k", value);
} catch (err) {
if (
err instanceof RedisCacheError &&
err.code === CACHE_REDIS_CODES.CONNECTION_FAILED
) {
metrics.increment("cache.redis.connection_failure");
}
}
```
## Usage: Advanced Example
```ts
import {
CACHE_REDIS_CODES,
RedisCacheError,
} from "@suaveplan/cache-redis/errors";
function classify(err: unknown): "retry" | "fatal" | "ignore" {
if (!(err instanceof RedisCacheError)) return "fatal";
switch (err.code) {
case CACHE_REDIS_CODES.CONNECTION_FAILED:
case CACHE_REDIS_CODES.COMMAND_FAILED:
return "retry";
case CACHE_REDIS_CODES.CODEC_ERROR:
return "ignore"; // cache miss is safe
case CACHE_REDIS_CODES.KEY_TOO_LONG:
case CACHE_REDIS_CODES.INVALID_TTL:
return "fatal";
default:
return "fatal";
}
}
```
## API Reference
| Export | Kind | Purpose |
| --- | --- | --- |
| `CACHE_REDIS_CODES` | const | Frozen object mapping reason → `"CACHE_REDIS.<REASON>"` value. |
| `CacheRedisCode` | type | Union of the registered code values. |
| `RedisCacheError` | class | Extends `SuaveplanError<CacheRedisCode>`. Constructor: `(code, messageKey, options?)` where options may carry `key`, `command`, `cause`, `context`, `traceId`. |
| `RedisCacheErrorOptions` | type | `SuaveplanErrorOptions & { key?: string; command?: string }`. |
Registered codes: `CONNECTION_FAILED`, `COMMAND_FAILED`, `CODEC_ERROR`, `KEY_TOO_LONG`, `INVALID_TTL`.
## Implementation Notes
`CONNECTION_FAILED` is intentionally the catch-all for "the underlying `RedisLike` client threw something" — the cache cannot distinguish network errors from server-side `READONLY` rejects without parsing the cause, and most consumers treat both as "retry later." `COMMAND_FAILED` is reserved for failures that originate inside the cache itself — currently only `clear()` without a namespace; future commands that have explicit semantic preconditions will share this code.

View File

@@ -0,0 +1,105 @@
# RedisCache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RedisCache<T>` extends `AbstractCache<T>` from `@suaveplan/cache` and adapts a Redis-compatible store (via the minimal `RedisLike` interface) into the unified cache surface. The class uses native Redis primitives — `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN` — so TTL is owned by the Redis server (no client-side header bytes, no per-read expiry comparison) and the underlying `maxmemory-policy` handles eviction without duplicate client-side bookkeeping.
## Features
- Drop-in `AbstractCache<T>` subclass — inherits `wrap`, `getMultiple`, `setMultiple` from the base class with `AbortSignal` and stale-while-revalidate semantics unchanged.
- Native TTL via `SET ... EX <seconds>` — milliseconds in the cache API are converted to seconds at the boundary, with sub-second TTLs rounded up to `EX: 1` so a positive TTL is never silently dropped.
- Pluggable `Codec<T>` reused from `@suaveplan/cache-storage` — `JsonCodec<T>` default (UTF-8 string on the wire), `IdentityCodec` (base64-encoded binary).
- Namespaced `clear()` via `SCAN`-then-`DEL` batches — bounded by namespace size, never `FLUSHDB`.
- Refuses `clear()` without a namespace with `CACHE_REDIS.COMMAND_FAILED` — explicit foot-gun protection on shared Redis instances.
- Client-side `getStats()` counters (`hits`, `misses`, `evictions`, `hitRate`). `size` returns `0` — Redis `DBSIZE` is whole-DB, not per-namespace, and `SCAN`-then-count is O(N) and inappropriate for a `getStats` call.
- Structured `RedisCacheError` for every failure path with `key` and `command` discriminants.
- Optional lifecycle events through any `EventBusLike<CacheEvents>`.
- Auto-registered with the `CacheFactory` registry as the `"redis"` provider on side-effect import.
## Usage: Basic Example
```ts
import { RedisCache } from "@suaveplan/cache-redis";
import { Redis } from "ioredis";
const cache = new RedisCache<{ id: string; email: string }>({
redis: new Redis(),
namespace: "users",
defaultTtl: 5 * 60_000,
});
await cache.set("alice", { id: "1", email: "alice@example.com" });
const user = await cache.get("alice");
```
## Usage: Advanced Example
```ts
import "@suaveplan/cache-redis"; // side-effect: registers the "redis" provider
import { createCache } from "@suaveplan/cache";
import { IdentityCodec } from "@suaveplan/cache-storage";
import { EventBus } from "@suaveplan/events";
import { Redis } from "ioredis";
const events = new EventBus();
events.on("evict", ({ key }) => log.info("cache evicted", { key }));
const redis = new Redis({ host: "redis", maxRetriesPerRequest: 3 });
const binaryCache = createCache("redis" as never, {
redis,
namespace: "thumbnails",
codec: new IdentityCodec(),
events,
} as never);
await binaryCache.set("avatar:42", await renderAvatar(42));
// Wrap, single-flight, SWR — inherited from AbstractCache.
const summary = await binaryCache.wrap(
`summary:${userId}`,
() => buildSummary(userId),
{ ttl: 60_000, staleWhileRevalidate: true, staleThreshold: 5_000 },
);
// Clear only the "thumbnails" namespace — never touches the rest of the DB.
await binaryCache.clear();
```
## API Reference
### Constructor
`new RedisCache<T>(options: RedisCacheOptions<T>)`
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `redis` | `RedisLike` | — (required) | The underlying Redis client. |
| `codec` | `Codec<T>` | `new JsonCodec<T>()` | Value codec. |
| `defaultTtl` | `number` | `0` | TTL in ms when `set` omits one. `0` ⇒ no expiry. |
| `events` | `EventBusLike` | `undefined` | Lifecycle event sink. |
| `namespace` | `string` | `""` | Key prefix. Required for `clear()`. |
### Methods
Every `AbstractCacheShape<T>` method: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. See the parent `AbstractCache` documentation for inherited helpers.
### Thrown errors
- `RedisCacheError("CACHE_REDIS.CONNECTION_FAILED")` — the underlying client threw on a command.
- `RedisCacheError("CACHE_REDIS.COMMAND_FAILED")` — `clear()` invoked without a namespace.
- `RedisCacheError("CACHE_REDIS.CODEC_ERROR")` — codec encode or decode failure.
- `RedisCacheError("CACHE_REDIS.KEY_TOO_LONG")` — key longer than 1024 chars.
- `RedisCacheError("CACHE_REDIS.INVALID_TTL")` — negative TTL passed to `set`.
## Implementation Notes
The cache uses base64 only when the codec is `IdentityCodec`; the `JsonCodec` default emits valid UTF-8 strings directly to avoid the 33% base64 overhead. Detection is via `instanceof IdentityCodec` — adding a different binary codec means setting a per-codec "is-binary-safe" capability flag (planned for a follow-up; the current check covers ≥95% of use).
`clear()` issues `SCAN 0 MATCH <ns>:* COUNT 1000` and `DEL`s each batch until the cursor returns `"0"`. The total deleted count is reflected in `getStats().evictions`. For very large namespaces this is O(N) — callers who need to wipe millions of keys should run the operation off the request path.
`hitCount` and `lastAccessedAt` tracked by `MemoryCache` are not maintained — Redis cannot return them in a single round-trip, and burning a second command on every read for advisory metrics would halve throughput. Use `TTL key` if you need to introspect remaining expiry.

View File

@@ -0,0 +1,98 @@
# RedisLike
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the minimal Redis client surface `RedisCache` consumes. Any client conforming to this interface — `ioredis`, `node-redis`, a custom adapter over the official RESP3 protocol, or even a non-Redis store like KeyDB or Dragonfly that exposes the same commands — is a valid argument to the cache. Constraining the surface to seven primitives (`set`, `get`, `del`, `exists`, `ttl`, `expire`, `scan`) plus optional `mget` keeps the contract small enough that an in-memory `FakeRedis` for tests is straightforward to write and audit.
## Features
- Seven required methods plus optional `mget` — chosen to match the exact set of commands `RedisCache` needs at runtime.
- `set` accepts `{ EX, PX }` options matching the canonical Redis protocol; the cache only emits `EX`.
- `scan` returns the cursor + matched keys as a single normalised shape (`RedisScanResult`), letting adapters bridge over both the `ioredis` tuple shape `[cursor, keys]` and the `node-redis` object shape `{ cursor, keys }`.
- No transactional, pub/sub, or scripting primitives — those concerns belong to the application, not the cache.
- Pure type module — zero runtime code, no `instanceof` requirement.
## Usage: Basic Example
```ts
import type { RedisLike } from "@suaveplan/cache-redis/redis-like";
import { Redis } from "ioredis";
// `ioredis` v5 conforms to RedisLike out of the box.
const client: RedisLike = new Redis({ host: "redis.example.com" });
```
## Usage: Advanced Example
```ts
import type {
RedisLike,
RedisScanResult,
} from "@suaveplan/cache-redis/redis-like";
import { createClient } from "redis";
// `node-redis` returns scan results in `{ cursor, keys }` shape and uses
// `pExpire` for millisecond TTLs — a thin adapter normalises both.
class NodeRedisAdapter implements RedisLike {
readonly #client = createClient();
async set(key: string, value: string, opts?: { EX?: number }) {
return opts?.EX !== undefined
? this.#client.set(key, value, { EX: opts.EX })
: this.#client.set(key, value);
}
async get(k: string) {
return this.#client.get(k);
}
async del(...keys: string[]) {
return this.#client.del(keys);
}
async exists(...keys: string[]) {
return this.#client.exists(keys);
}
async ttl(k: string) {
return this.#client.ttl(k);
}
async expire(k: string, s: number) {
const ok = await this.#client.expire(k, s);
return ok ? 1 : 0;
}
async scan(
cursor: string,
opts?: { MATCH?: string; COUNT?: number },
): Promise<RedisScanResult> {
const result = await this.#client.scan(Number(cursor), {
MATCH: opts?.MATCH,
COUNT: opts?.COUNT,
});
return { cursor: String(result.cursor), keys: result.keys };
}
}
```
## API Reference
| Symbol | Kind | Description |
| --- | --- | --- |
| `RedisLike` | interface | Required surface: `set`, `get`, `del`, `exists`, `ttl`, `expire`, `scan`, optional `mget`. |
| `RedisSetOptions` | interface | `{ EX?: number; PX?: number }`. |
| `RedisScanOptions` | interface | `{ MATCH?: string; COUNT?: number }`. |
| `RedisScanResult` | interface | `{ cursor: string; keys: string[] }`. |
### Method semantics
- `set(key, value, opts?)` — `SET key value [EX seconds]`. Returns the server's response (typically `"OK"`); the cache ignores it.
- `get(key)` — returns the value or `null` on miss.
- `del(...keys)` — returns the number of keys actually deleted.
- `exists(...keys)` — returns the number of matching keys.
- `ttl(key)` — returns seconds, `-1` for "no expiry", `-2` for "missing key".
- `expire(key, seconds)` — returns `1` on success, `0` on missing key.
- `scan(cursor, opts?)` — returns `{ cursor, keys }` with `cursor === "0"` signalling end of iteration.
- `mget(...keys)` — optional; if absent, `getMultiple` falls back to per-key `get`.
## Implementation Notes
The interface uses `string` for both keys and values because the canonical RESP protocol speaks strings; `RedisCache` runs UTF-8 / base64 conversion at the boundary. Adapters that expose `Buffer` (`ioredis` in `Buffer`-mode, `node-redis` with `returnBuffers`) work as long as the adapter normalises back to `string` before returning. A future binary-safe variant of the cache may add a parallel `RedisBinaryLike` contract; until then keeping `RedisLike` string-only keeps the implementation universal compatible.

View File

@@ -0,0 +1,64 @@
# registry-augment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Augments the `CacheProviderRegistry` type exported by `@suaveplan/cache` with a `redis` entry, and side-effect-registers `RedisCache` 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("redis", config)` type-safe — the registry key becomes part of `CacheProviderType`, and the config parameter is inferred from `RedisCacheOptions<T>` via the registry slot.
- Idempotent runtime registration — guarded by both an internal `registered` flag and a `hasCacheProvider("redis")` query against the live registry, so a double-import or a second package version pointing at the same registry 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 "redis" provider.
import "@suaveplan/cache-redis";
import { createCache } from "@suaveplan/cache";
import { Redis } from "ioredis";
const cache = createCache("redis", { redis: new Redis() });
```
## Usage: Advanced Example
```ts
// Drive registration manually — useful in tests that need to control
// provider availability ordering or confirm idempotency.
import {
hasCacheProvider,
unregisterCacheProvider,
} from "@suaveplan/cache";
import { registerRedisProvider } from "@suaveplan/cache-redis";
unregisterCacheProvider("redis");
registerRedisProvider();
console.log(hasCacheProvider("redis")); // true
registerRedisProvider(); // no-op
```
## API Reference
| Export | Kind | Description |
| --- | --- | --- |
| `registerRedisProvider()` | function | Idempotently calls `registerCacheProvider("redis", RedisCache)`. Safe to invoke repeatedly. |
The module performs module augmentation:
```ts
declare module "@suaveplan/cache" {
interface CacheProviderRegistry {
redis: typeof RedisCache;
}
}
```
## 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,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,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,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,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,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,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,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,59 @@
# @suaveplan/cache - Type-safe caching abstraction: LRU/LFU/FIFO/TTL MemoryCache, NamespacedCache, CacheFactory, stale-while-revalidate, multi-get/set, lifecycle events, injectable clock, OTel instrumentation, and structured CACHE.* error codes.
## Summary
Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFactory` with module-augmentation-based provider registry, stale-while-revalidate, multi-get/set, injectable clock and event bus, OTel instrumentation, and structured `CACHE.*` error codes.
<!-- begin:auto-docs -->
## Documentation tree
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
<!-- end:auto-docs --> - [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
- [abstract-cache](./src/abstract/abstract-cache/abstract-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [namespaced-cache](./src/abstract/namespaced-cache/namespaced-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache-error](./src/errors/cache-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cache](./src/factory/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/factory/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory-cache](./src/memory/memory-cache/memory-cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circu…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration b…
## Packages
| Module | Description |
|---|---|
| [errors/cache-error](src/errors/cache-error.md) | `CacheError` class and `CACHE_CODES` registry |
| [types/common](src/types/common.md) | Shared TypeScript types and `CacheProviderRegistry` extension point |
| [types/schemas](src/types/schemas.md) | Zod validation schemas for all option types |
| [abstract/abstract-cache](src/abstract/abstract-cache/abstract-cache.md) | `AbstractCache<T>` base class with `wrap`, `getMultiple`, `setMultiple` |
| [abstract/namespaced-cache](src/abstract/namespaced-cache/namespaced-cache.md) | `NamespacedCache<T>` key-prefix wrapper |
| [memory/memory-cache](src/memory/memory-cache/memory-cache.md) | `MemoryCache<T>` with LRU/LFU/FIFO/TTL eviction |
| [factory/cache-factory](src/factory/cache-factory/cache-factory.md) | `CacheFactory` and free-function registry API |
| [factory/registry](src/factory/registry/registry.md) | Built-in memory provider registration |
## Quick Start
```ts
import { MemoryCache } from "@suaveplan/cache";
const cache = new MemoryCache<string>({ maxSize: 500, defaultTTL: 60_000 });
await cache.set("user:1", "Alice");
const alice = await cache.get("user:1"); // "Alice"
// wrap — get-or-compute
const data = await cache.wrap("expensive:key", async () => {
return await fetchSomeData();
}, { ttl: 30_000 });
```

View File

@@ -0,0 +1,88 @@
# abstract-cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AbstractCache<T>` is the base class for all cache implementations in `@suaveplan/cache`. It defines the six primitive abstract methods that every concrete cache must implement, and provides three composite helpers (`wrap`, `getMultiple`, `setMultiple`) that delegate to the primitives. This design separates the concern of cache mechanics (eviction, TTL, storage) from common access patterns, reducing code duplication across implementations.
## Features
- Six abstract primitive operations: `get`, `set`, `delete`, `clear`, `has`, `getStats`
- `getMultiple(keys)` — fetches multiple keys concurrently, returns `{ found, missing }`
- `setMultiple(entries)` — stores multiple entries concurrently with per-entry options
- `wrap(key, fn, options?)` — get-or-compute with optional `forceRefresh` and `shouldCache` predicate
- AbortSignal support on `wrap` via `options.signal`
## Usage: Basic Example
```ts
import { AbstractCache } from "@suaveplan/cache/abstract/abstract-cache";
import type { CacheStats } from "@suaveplan/cache/types/common";
class MyCache extends AbstractCache<string> {
readonly store = new Map<string, string>();
async get(key: string) { return this.store.get(key); }
async set(key: string, value: string) { this.store.set(key, value); }
async delete(key: string) { return this.store.delete(key); }
async clear() { this.store.clear(); }
async has(key: string) { return this.store.has(key); }
getStats(): CacheStats { return { hits: 0, misses: 0, size: this.store.size, hitRate: 0, evictions: 0 }; }
}
```
## Usage: Advanced Example
```ts
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
const cache = new MemoryCache<string>({ maxSize: 100 });
const value = await cache.wrap("api:user:1", async () => {
const resp = await fetch("/api/user/1");
return resp.text();
}, { ttl: 30_000, shouldCache: (v) => v.length > 0 });
```
## API Reference
### `get(key: string): Promise<T | undefined>` (abstract)
Returns value or `undefined` on miss/expiry.
### `set(key, value, options?): Promise<void>` (abstract)
Stores a value. `options.ttl` overrides the default TTL.
### `delete(key): Promise<boolean>` (abstract)
Returns `true` if the key was present.
### `clear(): Promise<void>` (abstract)
Removes all entries.
### `has(key): Promise<boolean>` (abstract)
Returns `true` if present and not expired.
### `getStats(): CacheStats` (abstract)
Returns statistics snapshot.
### `getMultiple(keys): Promise<CacheMultiGetResult<T>>`
Concurrent multi-fetch. `found.size + missing.length === keys.length`.
### `setMultiple(entries): Promise<void>`
Concurrent multi-store.
### `wrap(key, fn, options?): Promise<T>`
Get-or-compute. `forceRefresh: true` bypasses cache check.
## Implementation Notes
`getMultiple` uses `Promise.all` for concurrent fetches. The `wrap` method's `shouldCache` predicate runs on both the cached value (to decide whether to serve a cached hit that no longer satisfies the predicate) and the freshly computed value (to decide whether to store it). AbortSignal is checked at the start of `wrap` before any async operations.

View File

@@ -0,0 +1,68 @@
# namespaced-cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NamespacedCache<T>` wraps any `AbstractCache` instance and transparently prefixes every key with a `namespace + separator` string. This enables multiple logical caches to share a single underlying storage (e.g. one `MemoryCache`) without key collisions, without requiring any changes to existing cache consumers.
The namespace prefix is stripped from keys returned by `getMultiple.missing` so callers always see their original key strings, not the internal prefixed form.
## Features
- Wraps any `AbstractCache<T>` via composition
- Configurable separator (default `":"`)
- Transparent prefix/unprefix for `getMultiple` results
- Delegates `clear()` to the underlying cache (clears all namespaces)
- `wrap()` delegates to the inner cache with the prefixed key
## Usage: Basic Example
```ts
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
import { NamespacedCache } from "@suaveplan/cache/abstract/namespaced-cache";
const shared = new MemoryCache<string>({ maxSize: 1000 });
const userCache = new NamespacedCache(shared, "user");
const sessionCache = new NamespacedCache(shared, "session");
await userCache.set("alice", "data");
await sessionCache.set("alice", "sess-data");
// Keys in shared cache: "user:alice", "session:alice"
```
## Usage: Advanced Example
```ts
import { NamespacedCache } from "@suaveplan/cache/abstract/namespaced-cache";
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
const base = new MemoryCache<Buffer>({ maxSize: 500 });
const images = new NamespacedCache(base, "img", "::");
const result = await images.getMultiple(["logo.png", "hero.jpg"]);
// result.missing => ["logo.png", "hero.jpg"] (original keys, not "img::logo.png")
```
## API Reference
### `constructor(inner, namespace, separator?)`
- `inner` — backing `AbstractCache<T>`
- `namespace` — prefix string (e.g. `"user"`, `"session"`)
- `separator` — default `":"`
### All `AbstractCache` methods
All operations are transparently delegated to `inner` after key prefixing.
`getMultiple` strips the prefix from returned keys in `found` and `missing`.
### `_namespaced(key): string` (protected)
Returns `${prefix}${key}`. Accessible to subclasses.
## Implementation Notes
`clear()` clears the entire underlying cache, not just this namespace's keys. This is intentional — a `NamespacedCache` shares storage, so there is no efficient way to clear only one namespace without scanning all keys. Callers who need isolated clear semantics should use separate cache instances.

View File

@@ -0,0 +1,62 @@
# cache-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `CacheError` and the `CACHE_CODES` registry for all structured errors emitted by `@suaveplan/cache`. Every throw site in the package uses a stable code from this registry, allowing callers to distinguish failure modes (key validation errors, capacity overflows, missing providers, etc.) without string matching on error messages. `CacheError` extends `SuaveplanError` for compatibility with the `@suaveplan/error` cause-chain traversal, wire serialisation, and OTel span-event adapter.
## Features
- Six stable `CACHE.*` codes covering key validation, capacity, provider lookup, set/get failures, and serialisation errors
- `CacheError` extends `SuaveplanError<CacheCode>` with a public constructor
- Codes are registered globally at import time via `defineCodes`
## Usage: Basic Example
```ts
import { CacheError, CACHE_CODES } from "@suaveplan/cache/errors/cache-error";
throw new CacheError(CACHE_CODES.KEY_INVALID, "cache key must not be empty");
```
## Usage: Advanced Example
```ts
import { CacheError, CACHE_CODES } from "@suaveplan/cache/errors/cache-error";
try {
await cache.set("", value);
} catch (err) {
if (err instanceof CacheError && err.code === CACHE_CODES.KEY_INVALID) {
console.warn("empty key rejected:", err.message);
}
}
```
## API Reference
### `CACHE_CODES`
`Record<string, CacheCode>` — stable code dictionary.
| Key | Value |
|---|---|
| `KEY_INVALID` | `"CACHE.KEY_INVALID"` |
| `CAPACITY_EXCEEDED` | `"CACHE.CAPACITY_EXCEEDED"` |
| `PROVIDER_NOT_FOUND` | `"CACHE.PROVIDER_NOT_FOUND"` |
| `SET_FAILED` | `"CACHE.SET_FAILED"` |
| `GET_FAILED` | `"CACHE.GET_FAILED"` |
| `SERIALIZATION_FAILED` | `"CACHE.SERIALIZATION_FAILED"` |
### `CacheError`
```ts
class CacheError extends SuaveplanError<CacheCode>
constructor(code: CacheCode, message: string, options?: SuaveplanErrorOptions)
```
## Implementation Notes
The `biome-ignore` comment suppresses the `noUselessConstructor` lint rule, which does not account for the protected base constructor pattern in `SuaveplanError`. The six codes map to the six error categories identified in the `enterprise-cache` OpenSpec proposal.

View File

@@ -0,0 +1,81 @@
# cache-factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CacheFactory` and its companion free functions provide a central registry for cache provider types. The factory decouples cache consumers from concrete implementations: callers request a cache by type string (`"memory"`, `"redis"`, etc.) rather than importing implementation classes directly. Third-party backends register themselves once via `registerCacheProvider` and become available throughout the application without modifying this package.
The `"memory"` provider is registered by default. Module augmentation of `CacheProviderRegistry` in `@suaveplan/cache/types/common` gives compile-time safety for registered types.
## Features
- `CacheFactory.create(type, config?)` — creates a cache instance by type string
- `CacheFactory.register / unregister / has / providers` — static registry management
- Free-function aliases: `createCache`, `createCacheWithConfig`, `registerCacheProvider`, etc.
- `clearCacheProviders()` — removes all registrations (useful in tests)
- `"memory"` is pre-registered and always available
## Usage: Basic Example
```ts
import { createCache } from "@suaveplan/cache/factory/cache";
const cache = createCache("memory", { maxSize: 500, defaultTTL: 60_000 });
await cache.set("key", "value");
```
## Usage: Advanced Example
```ts
import {
createCacheWithConfig,
registerCacheProvider,
} from "@suaveplan/cache/factory/cache";
import type { CacheProviderImplementation } from "@suaveplan/cache/types/common";
// Register a custom provider
class MyCache { /* ... */ }
registerCacheProvider("custom", MyCache as unknown as CacheProviderImplementation);
const cache = createCacheWithConfig({ type: "custom", options: { maxSize: 100 } });
```
## API Reference
### `CacheFactory.create<T>(type, config?): AbstractCacheShape<T>`
Creates a cache of the named type. Throws `CacheError(PROVIDER_NOT_FOUND)` if unregistered.
### `CacheFactory.register(type, impl): void`
Registers a provider. Replaces existing.
### `CacheFactory.unregister(type): void`
Removes a provider.
### `CacheFactory.has(type): boolean`
Returns `true` if registered.
### `CacheFactory.providers(): string[]`
Returns all registered type names.
### `createCache<T>(type, config?)`
Free function alias for `CacheFactory.create`.
### `createCacheWithConfig<T>(config)`
Creates from `{ type, options? }` config object.
### `registerCacheProvider / unregisterCacheProvider / hasCacheProvider / getCacheProviders / clearCacheProviders`
Free-function aliases for the static methods.
## Implementation Notes
The registry uses a module-level `Map` to persist across calls within the same module instance. `clearCacheProviders` removes ALL entries including the built-in `"memory"` registration; tests that call `clearCacheProviders` should re-register `MemoryCache` if they need it afterwards.

View File

@@ -0,0 +1,42 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Performs the side-effect of registering the built-in `"memory"` cache provider into `CacheFactory` at import time. This module is imported once by the package barrel so all consumers that import from `@suaveplan/cache` automatically have the memory provider available without explicit registration calls.
The registration is idempotent: calling `registerCacheProvider("memory", MemoryCache)` on an already-registered entry simply replaces it with the same value.
## Features
- Registers `MemoryCache` as `"memory"` in `CacheFactory`
- Side-effect import — no exports needed
- Idempotent registration
## Usage: Basic Example
```ts
// Importing the package barrel automatically executes this registration
import { createCache } from "@suaveplan/cache";
const cache = createCache("memory"); // works without explicit registration
```
## Usage: Advanced Example
```ts
// Third-party packages can follow this pattern to register their own providers
import { registerCacheProvider } from "@suaveplan/cache/factory/cache";
import { RedisCache } from "./redis-cache.js";
registerCacheProvider("redis", RedisCache);
```
## API Reference
No exports. This module is a side-effect-only registration module.
## Implementation Notes
The `registry.ts` module is kept separate from `cache-factory.ts` to avoid circular imports. The factory module must not import concrete implementations; only the registry wires them together. This separation follows the same pattern used in `@suaveplan/db` where singleton helpers are distinct from adapter definitions.

View File

@@ -0,0 +1,97 @@
# memory-cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryCache<T>` is the primary in-process cache implementation. It provides four interchangeable eviction policies — LRU (Least Recently Used), LFU (Least Frequently Used), FIFO (First-In-First-Out), and TTL (time-based expiry with no frequency ordering) — selected at construction time. All policies support TTL-based expiry via an injectable `Clock`, enabling deterministic testing without real timers.
The implementation prioritises O(1) complexity for the hot paths: LRU uses a doubly-linked list + Map for O(1) get and set; LFU uses a frequency Map and min-frequency pointer for O(1) amortised eviction; FIFO uses an insertion-order array + Map for O(1) get and amortised O(1) set.
## Features
- Four eviction policies: `lru`, `lfu`, `fifo`, `ttl`
- Injectable `Clock` for deterministic TTL testing
- Optional `EventBusLike` for lifecycle events (hit, miss, set, delete, evict, expire, clear)
- Lazy TTL expiry on `get`/`has` calls with optional periodic cleanup
- `destroy()` to cancel background timers
- Key validation: rejects empty keys and keys exceeding `MAX_KEY_LENGTH` (1024)
- `getStats()` returns `{ hits, misses, size, hitRate, evictions }`
## Usage: Basic Example
```ts
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
const cache = new MemoryCache<string>({ maxSize: 500, defaultTTL: 60_000 });
await cache.set("user:1", "Alice");
const val = await cache.get("user:1"); // "Alice"
```
## Usage: Advanced Example
```ts
import { MemoryCache } from "@suaveplan/cache/memory/memory-cache";
// Inject a fake clock for deterministic TTL tests
let now = 1000;
const clock = { now: () => now };
const cache = new MemoryCache<string>({ defaultTTL: 100, evictionPolicy: "lru" }, clock);
await cache.set("key", "value");
now += 200; // advance past TTL
console.log(await cache.get("key")); // undefined — expired
// LFU example
const lfu = new MemoryCache<number>({ maxSize: 3, evictionPolicy: "lfu" }, clock);
await lfu.set("a", 1);
await lfu.set("b", 2);
await lfu.set("c", 3);
await lfu.get("a"); // freq(a) = 1
await lfu.get("a"); // freq(a) = 2
await lfu.set("d", 4); // evicts "b" or "c" (lowest freq)
```
## API Reference
### `constructor(options?, clock?, events?)`
- `options.maxSize` — default `1000`
- `options.defaultTTL` — ms, default `0` (no expiry)
- `options.evictionPolicy` — `"lru" | "lfu" | "fifo" | "ttl"`, default `"lru"`
- `options.checkExpirationInterval` — ms between eager cleanup passes; `0` means lazy-only
- `clock` — injectable `{ now: () => number }`
- `events` — optional `EventBusLike` for lifecycle events
### `get(key): Promise<T | undefined>`
O(1) for LRU/LFU. Evicts expired entries lazily.
### `set(key, value, options?): Promise<void>`
O(1) for LRU/LFU. `options.ttl` overrides default.
### `delete(key): Promise<boolean>`
Returns `true` if key existed.
### `clear(): Promise<void>`
Removes all entries from all policy maps.
### `has(key): Promise<boolean>`
Returns `true` if present and not expired. Does NOT increment hit counter.
### `getStats(): CacheStats`
Synchronous snapshot. `hitRate = hits / (hits + misses)`.
### `destroy(): void`
Cancels background cleanup timer. Call on graceful shutdown.
## Implementation Notes
The LRU doubly-linked list tracks `head` (most recently used) and `tail` (least recently used). On `get`, the accessed node is spliced out and prepended to head in O(1). On eviction, the tail is removed. The LFU implementation maintains a `_lfuFreqMap` (frequency → ordered Set of keys) and a `_lfuMinFreq` pointer. When a key is accessed its frequency is promoted and the min-freq pointer may increment; on eviction the min-freq bucket's first key is removed.

View File

@@ -0,0 +1,76 @@
# common
## Purpose
Centralises all shared TypeScript types and interfaces for `@suaveplan/cache`. Keeping types in one file prevents circular imports and enables tree-shaking of runtime code when consumers only import type-level constructs. The `CacheProviderRegistry` interface is an intentional module-augmentation extension point: third-party packages can extend it to register their own provider types at compile time without modifying this package.
## Features
- `CacheEvictionPolicy` — `"lru" | "lfu" | "fifo" | "ttl"` discriminant
- `CacheEntry<T>` — value with TTL, creation, access, and hit-count metadata
- `CacheStats` — hits, misses, size, hit rate, optional memory usage, evictions
- `CacheOptions` — capacity, TTL, eviction policy, clock injection
- `CacheSetOptions` / `CacheWrapOptions` — per-operation overrides
- `CacheEvents` — typed event map for `EventBusLike` lifecycle emission
- `CacheMultiGetResult` / `CacheMultiSetEntry` — bulk operation types
- `CacheProviderRegistry` — module-augmentation extension interface
- `CacheTransformHook<T>` — value transform pipeline entry
## Usage: Basic Example
```ts
import type { CacheOptions } from "@suaveplan/cache/types/common";
const opts: CacheOptions = {
maxSize: 500,
defaultTTL: 60_000,
evictionPolicy: "lru",
};
```
## Usage: Advanced Example
```ts
import type {
CacheMultiGetResult,
CacheWrapOptions,
} from "@suaveplan/cache/types/common";
const wrapOpts: CacheWrapOptions<string> = {
ttl: 30_000,
staleWhileRevalidate: true,
staleThreshold: 5_000,
shouldCache: (v) => v.length > 0,
};
const result: CacheMultiGetResult<string> = {
found: new Map([["key1", "value1"]]),
missing: ["key2"],
};
```
## API Reference
### `CacheEvictionPolicy`
`"lru" | "lfu" | "fifo" | "ttl"` — selects the eviction algorithm.
### `CacheEntry<T>`
`{ value, expiresAt, createdAt, lastAccessedAt, hitCount }` — internal entry shape.
### `CacheStats`
`{ hits, misses, size, hitRate, memoryUsage?, evictions }` — snapshot statistics.
### `CacheOptions`
See fields: `maxSize`, `defaultTTL`, `evictionPolicy`, `enableStats`, `checkExpirationInterval`, `clock`.
### `CacheProviderRegistry`
Empty interface — extend via module augmentation to register custom provider types.
## Implementation Notes
The `CacheProviderRegistry` interface is intentionally empty to allow clean module augmentation. The `AbstractCacheShape<T>` interface provides the minimal shape that `CacheFactory` needs to instantiate providers without importing the concrete `AbstractCache` class.

View File

@@ -0,0 +1,63 @@
# schemas
## Purpose
Provides Zod validation schemas for all public option types in `@suaveplan/cache`. These schemas run at configuration boundaries, catching invalid TTL values (negative numbers), unknown eviction policies, or extra keys in options objects before they propagate into the cache internals. Using Zod ensures type-safe runtime validation that stays in sync with the TypeScript interfaces in `common.ts`.
## Features
- `CacheEvictionPolicySchema` — validates `"lru" | "lfu" | "fifo" | "ttl"` strings
- `CacheOptionsSchema` — validates cache construction options (strict, no extra keys)
- `CacheSetOptionsSchema` — validates per-set TTL and stale-while-revalidate options
- `CacheWrapOptionsSchema` — validates `wrap()` options
- `CacheFactoryConfigSchema` — validates factory config including type string
- `CacheTransformHookSchema` — validates hook name and priority (function excluded)
## Usage: Basic Example
```ts
import { CacheOptionsSchema } from "@suaveplan/cache/types/schemas";
const opts = CacheOptionsSchema.parse({ maxSize: 500, defaultTTL: 60_000 });
```
## Usage: Advanced Example
```ts
import { CacheFactoryConfigSchema } from "@suaveplan/cache/types/schemas";
const cfg = CacheFactoryConfigSchema.safeParse({ type: "memory", options: { maxSize: 100 } });
if (!cfg.success) {
console.error(cfg.error.flatten());
}
```
## API Reference
### `CacheEvictionPolicySchema`
`z.enum(["lru", "lfu", "fifo", "ttl"])`.
### `CacheOptionsSchema`
Strict. Optional fields: `maxSize` (positive int), `defaultTTL` (non-negative), `evictionPolicy`, `enableStats`, `checkExpirationInterval`.
### `CacheSetOptionsSchema`
Strict. Optional fields: `ttl` (non-negative), `staleWhileRevalidate`, `staleThreshold`.
### `CacheWrapOptionsSchema`
Strict. Optional fields: `ttl`, `staleWhileRevalidate`, `staleThreshold`, `forceRefresh`.
### `CacheFactoryConfigSchema`
Strict. Required `type` (non-empty string), optional `options`.
### `CacheTransformHookSchema`
Strict. Required `name`, optional `priority` (int). The `transform` function is excluded from the schema as Zod cannot validate function shapes.
## Implementation Notes
All option schemas use `.strict()` to reject extra keys. This surfaces typos in configuration objects early and prevents silent misconfigurations from passing validation. The `CacheTransformHookSchema` intentionally omits the `transform` field since Zod v4 can parse but not meaningfully validate function types.