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,60 @@
# `persistence`
Caching, database adapters, search engines, and storage abstractions.
<!-- begin:auto-docs -->
## Subcategories
4 subcategories, 23 packages.
### `cache`
Caching abstractions, memory caches, and Redis backends.
| 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… |
### `database`
Database adapters, migrations, seeding, and SQLite support.
| Package | Description |
|---|---|
| [`@suaveplan/db`](./db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
| [`@suaveplan/db-sqlite`](./db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
| [`@suaveplan/migration`](./migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
| [`@suaveplan/seed`](./seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, ide… |
| [`@suaveplan/surrealdb`](./surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
### `search`
Full-text search, BM25, fuzzy matching, and faceted search.
| Package | Description |
|---|---|
| [`@suaveplan/search`](./search/README.md) | Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support. |
### `storage`
Storage abstractions, browser backends, S3, CDN, and URI routing.
| Package | Description |
|---|---|
| [`@suaveplan/storage`](./storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
| [`@suaveplan/storage-browser-indexeddb`](./storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
| [`@suaveplan/storage-browser-localstorage`](./storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
| [`@suaveplan/storage-browser-opfs`](./storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
| [`@suaveplan/storage-cdn`](./storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
| [`@suaveplan/storage-cdn-bunnycdn`](./storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
| [`@suaveplan/storage-cdn-rackspace`](./storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
| [`@suaveplan/storage-file`](./storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
| [`@suaveplan/storage-http`](./storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
| [`@suaveplan/storage-plugin-cache`](./storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
| [`@suaveplan/storage-s3`](./storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
| [`@suaveplan/storage-uri`](./storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
| [`@suaveplan/storage-uri-plugin-bundle`](./storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
| [`@suaveplan/storage-uri-plugin-fallback`](./storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `persistence`
Caching, database adapters, search engines, and storage abstractions.

15
packages/persistence/cache/README.md vendored Normal file
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 -->

1
packages/persistence/cache/_intro.md vendored Normal file
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,88 @@
{
"name": "@suaveplan/cache-redis",
"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 over a minimal RedisLike interface — native SET ... EX, GET, DEL, EXISTS, TTL, SCAN. Reuses @suaveplan/cache-storage codecs (JSON / identity / base64).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=18"
},
"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"
}
},
"./redis-like": {
"import": {
"types": "./dist/redis-like/redis-like.d.ts",
"default": "./dist/redis-like/redis-like.js"
}
},
"./redis-cache": {
"import": {
"types": "./dist/redis-cache/redis-cache.d.ts",
"default": "./dist/redis-cache/redis-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/cache-storage": "workspace:^",
"@suaveplan/error": "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,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,89 @@
import { JsonCodec } from "@suaveplan/cache-storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeRedis,
makeFailingRedis,
} from "../../tests/fixtures/fake-redis.js";
import { RedisCache } from "../redis-cache/redis-cache.js";
import {
CACHE_REDIS_CODES,
type CacheRedisCode,
RedisCacheError,
} from "./errors.js";
describe("CACHE_REDIS_CODES registry", () => {
it("every CACHE_REDIS code is reachable from a real call site", {
timeout: 5000,
}, async () => {
const observed = new Set<CacheRedisCode>();
// CONNECTION_FAILED — failing client throws on GET.
const cConn = new RedisCache<string>({
redis: makeFailingRedis(new Error("ECONNREFUSED")),
});
try {
await cConn.get("k");
} catch (e) {
if (e instanceof RedisCacheError) observed.add(e.code);
}
// COMMAND_FAILED — clear without namespace.
const cCmd = new RedisCache<string>({ redis: new FakeRedis() });
try {
await cCmd.clear();
} catch (e) {
if (e instanceof RedisCacheError) observed.add(e.code);
}
// CODEC_ERROR — JsonCodec on garbage payload.
const fake = new FakeRedis();
await fake.set("k", "not-json{");
const cCodec = new RedisCache<unknown>({
redis: fake,
codec: new JsonCodec<unknown>(),
});
try {
await cCodec.get("k");
} catch (e) {
if (e instanceof RedisCacheError) observed.add(e.code);
}
// KEY_TOO_LONG.
const cKey = new RedisCache<string>({ redis: new FakeRedis() });
try {
await cKey.set("x".repeat(1025), "v");
} catch (e) {
if (e instanceof RedisCacheError) observed.add(e.code);
}
// INVALID_TTL — negative ttl.
const cTtl = new RedisCache<string>({ redis: new FakeRedis() });
try {
await cTtl.set("k", "v", { ttl: -1 });
} catch (e) {
if (e instanceof RedisCacheError) observed.add(e.code);
}
const expected = new Set(Object.values(CACHE_REDIS_CODES));
expect(observed).toEqual(expected);
});
it("RedisCacheError carries key and command discriminants", {
timeout: 5000,
}, () => {
const err = new RedisCacheError(CACHE_REDIS_CODES.COMMAND_FAILED, "x", {
key: "k",
command: "SET",
});
expect(err.key).toBe("k");
expect(err.command).toBe("SET");
});
it("RedisCacheError without discriminants has undefined fields", {
timeout: 5000,
}, () => {
const err = new RedisCacheError(CACHE_REDIS_CODES.COMMAND_FAILED, "x");
expect(err.key).toBeUndefined();
expect(err.command).toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/cache-redis`.
*
* Codes are namespaced `CACHE_REDIS.<REASON>` via `defineCodes`.
*/
export const CACHE_REDIS_CODES = defineCodes("CACHE_REDIS", [
"CONNECTION_FAILED",
"COMMAND_FAILED",
"CODEC_ERROR",
"KEY_TOO_LONG",
"INVALID_TTL",
] as const);
export type CacheRedisCode =
(typeof CACHE_REDIS_CODES)[keyof typeof CACHE_REDIS_CODES];
/** Discriminants carried on every {@link RedisCacheError}. */
export interface RedisCacheErrorOptions extends SuaveplanErrorOptions {
/** Unprefixed key that produced the failure, when available. */
key?: string;
/** Redis command that failed, when applicable (`SET`, `DEL`, `SCAN`, …). */
command?: string;
}
/**
* Structured error class for `@suaveplan/cache-redis` failures.
*
* Extends `SuaveplanError<CacheRedisCode>` so cause chains and trace IDs
* propagate identically to the rest of the monorepo.
*/
export class RedisCacheError extends SuaveplanError<CacheRedisCode> {
public readonly key: string | undefined;
public readonly command: string | undefined;
public constructor(
code: CacheRedisCode,
message: string,
options?: RedisCacheErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.command = options?.command;
}
}

View File

@@ -0,0 +1,8 @@
export type {
CacheRedisCode,
RedisCacheErrorOptions,
} from "./errors.js";
export {
CACHE_REDIS_CODES,
RedisCacheError,
} from "./errors.js";

View File

@@ -0,0 +1,22 @@
// Errors
export {
CACHE_REDIS_CODES,
type CacheRedisCode,
RedisCacheError,
type RedisCacheErrorOptions,
} from "./errors/index.js";
// Cache
export { RedisCache, type RedisCacheOptions } from "./redis-cache/index.js";
// RedisLike interface
export type {
RedisLike,
RedisScanOptions,
RedisScanResult,
RedisSetOptions,
} from "./redis-like/index.js";
// Registry registration (also runs as a side effect of importing this barrel)
export { registerRedisProvider } from "./registry-augment/index.js";
// Side-effect: register the "redis" provider exactly once.
import "./registry-augment/index.js";

View File

@@ -0,0 +1,2 @@
export { RedisCache } from "./redis-cache.js";
export type { RedisCacheOptions } from "./redis-cache.types.js";

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,404 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AbstractCache } from "@suaveplan/cache";
import { IdentityCodec, JsonCodec } from "@suaveplan/cache-storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import {
FakeRedis,
makeFailingRedis,
} from "../../tests/fixtures/fake-redis.js";
import { CACHE_REDIS_CODES, RedisCacheError } from "../errors/index.js";
import { RedisCache } from "./redis-cache.js";
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 () => {};
},
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("RedisCache", () => {
it(
"instance is an AbstractCache and exposes the AbstractCacheShape surface",
T,
() => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
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.wrap).toBe("function");
expect(typeof cache.getMultiple).toBe("function");
expect(typeof cache.setMultiple).toBe("function");
}
);
it("round-trips set then get against a FakeRedis stub", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<{ id: string }>({ redis: fake });
await cache.set("k", { id: "alice" });
expect(await cache.get("k")).toEqual({ id: "alice" });
});
it("set with positive ttl issues SET with EX seconds", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<string>({
redis: fake,
namespace: "ns",
});
await cache.set("k", "v", { ttl: 30_000 });
const lastSet = fake.calls.find((c) => c.command === "SET");
expect(lastSet).toBeDefined();
expect(lastSet?.args[0]).toBe("ns:k");
expect(lastSet?.args[2]).toEqual({ EX: 30 });
});
it("set with sub-second ttl rounds up to EX: 1", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<string>({ redis: fake });
await cache.set("k", "v", { ttl: 50 });
const lastSet = fake.calls.find((c) => c.command === "SET");
expect(lastSet?.args[2]).toEqual({ EX: 1 });
});
it("set with zero default ttl issues SET without EX", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<string>({ redis: fake });
await cache.set("k", "v");
const lastSet = fake.calls.find((c) => c.command === "SET");
expect(lastSet?.args.length).toBe(2);
});
it("set with defaultTtl > 0 applies the default", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<string>({
redis: fake,
defaultTtl: 60_000,
});
await cache.set("k", "v");
const lastSet = fake.calls.find((c) => c.command === "SET");
expect(lastSet?.args[2]).toEqual({ EX: 60 });
});
it("rejects negative ttl with INVALID_TTL", T, async () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
await expect(cache.set("k", "v", { ttl: -1 })).rejects.toMatchObject({
code: CACHE_REDIS_CODES.INVALID_TTL,
key: "k",
});
});
it("clear() without namespace throws COMMAND_FAILED", T, async () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
await expect(cache.clear()).rejects.toMatchObject({
code: CACHE_REDIS_CODES.COMMAND_FAILED,
});
});
it("clear() with namespace deletes only namespaced keys", T, async () => {
const fake = new FakeRedis();
const ns = new RedisCache<string>({ redis: fake, namespace: "ns" });
await ns.set("a", "1");
await ns.set("b", "2");
await fake.set("other:c", "3");
await ns.clear();
expect(await fake.exists("ns:a")).toBe(0);
expect(await fake.exists("ns:b")).toBe(0);
expect(await fake.exists("other:c")).toBe(1);
expect(ns.getStats().evictions).toBe(2);
});
it(
"clear() wraps SCAN failure as CONNECTION_FAILED with no key",
T,
async () => {
const fake = new FakeRedis();
fake.scan = async () => {
throw new Error("scan boom");
};
const ns = new RedisCache<string>({
redis: fake,
namespace: "ns",
});
await expect(ns.clear()).rejects.toMatchObject({
code: CACHE_REDIS_CODES.CONNECTION_FAILED,
command: "SCAN",
});
// Confirm `key` discriminant is absent on SCAN failures.
try {
await ns.clear();
} catch (e) {
expect((e as RedisCacheError).key).toBeUndefined();
}
}
);
it("clear() skips DEL when SCAN returns an empty batch", T, async () => {
const fake = new FakeRedis();
// scan returns one empty batch, then end.
let i = 0;
fake.scan = async () => {
i += 1;
return i === 1
? { cursor: "1", keys: [] }
: { cursor: "0", keys: [] };
};
const ns = new RedisCache<string>({
redis: fake,
namespace: "ns",
});
await ns.clear();
expect(ns.getStats().evictions).toBe(0);
});
it(
"clear() with namespace iterates SCAN until cursor returns 0",
T,
async () => {
const fake = new FakeRedis();
await fake.set("ns:a", "1");
await fake.set("ns:b", "2");
let callCount = 0;
const originalScan = fake.scan.bind(fake);
fake.scan = async (cursor, opts) => {
callCount += 1;
if (callCount === 1) {
return { cursor: "42", keys: ["ns:a"] };
}
const result = await originalScan(cursor, opts);
return { cursor: "0", keys: result.keys };
};
const ns = new RedisCache<string>({
redis: fake,
namespace: "ns",
});
await ns.clear();
expect(callCount).toBeGreaterThanOrEqual(2);
}
);
it("JsonCodec encodes value as a UTF-8 string", T, async () => {
const fake = new FakeRedis();
const cache = new RedisCache<{ hello: string }>({ redis: fake });
await cache.set("k", { hello: "world" });
const setCall = fake.calls.find((c) => c.command === "SET");
expect(setCall?.args[1]).toBe('{"hello":"world"}');
});
it(
"IdentityCodec encodes value as a base64 string round-trip",
T,
async () => {
const fake = new FakeRedis();
const cache = new RedisCache<Uint8Array>({
redis: fake,
codec: new IdentityCodec(),
});
const value = new Uint8Array([1, 2, 3]);
await cache.set("k", value);
const setCall = fake.calls.find((c) => c.command === "SET");
expect(setCall?.args[1]).toBe("AQID");
const out = await cache.get("k");
expect(out).not.toBeUndefined();
expect(Array.from(out as Uint8Array)).toEqual([1, 2, 3]);
}
);
it("get returns undefined and emits miss for unknown key", T, async () => {
const { bus, emitted } = newBus();
const cache = new RedisCache<string>({
redis: new FakeRedis(),
events: bus,
});
expect(await cache.get("missing")).toBeUndefined();
expect(emitted).toContainEqual({
type: "miss",
payload: { key: "missing" },
});
});
it("emits hit on get after set", T, async () => {
const { bus, emitted } = newBus();
const cache = new RedisCache<string>({
redis: new FakeRedis(),
events: bus,
});
await cache.set("k", "v");
await cache.get("k");
expect(emitted.some((e) => e.type === "hit")).toBe(true);
});
it("delete returns true when the key existed", T, async () => {
const { bus, emitted } = newBus();
const cache = new RedisCache<string>({
redis: new FakeRedis(),
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/false from EXISTS", T, async () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
expect(await cache.has("missing")).toBe(false);
await cache.set("k", "v");
expect(await cache.has("k")).toBe(true);
});
it("getStats reports client-side counters and size 0", T, async () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
await cache.set("k", "v");
for (let i = 0; i < 4; i++) await cache.get("k");
await cache.get("missing");
expect(cache.getStats()).toEqual({
hits: 4,
misses: 1,
size: 0,
hitRate: 0.8,
evictions: 0,
});
});
it("getStats hitRate is 0 when no operations have run", T, () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
expect(cache.getStats().hitRate).toBe(0);
});
it("KEY_TOO_LONG when set is called with an oversize key", T, async () => {
const cache = new RedisCache<string>({ redis: new FakeRedis() });
await expect(cache.set("x".repeat(1025), "v")).rejects.toMatchObject({
code: CACHE_REDIS_CODES.KEY_TOO_LONG,
});
});
it(
"CONNECTION_FAILED when the underlying client throws on GET",
T,
async () => {
const cache = new RedisCache<string>({
redis: makeFailingRedis(new Error("ECONNREFUSED")),
});
await expect(cache.get("k")).rejects.toMatchObject({
code: CACHE_REDIS_CODES.CONNECTION_FAILED,
command: "GET",
});
}
);
it(
"CONNECTION_FAILED when the underlying client throws on SET",
T,
async () => {
const cache = new RedisCache<string>({
redis: makeFailingRedis(new Error("ECONNREFUSED")),
});
await expect(cache.set("k", "v")).rejects.toMatchObject({
code: CACHE_REDIS_CODES.CONNECTION_FAILED,
command: "SET",
});
}
);
it("CODEC_ERROR when codec.encode throws on set", T, async () => {
const cache = new RedisCache<unknown>({
redis: new FakeRedis(),
codec: {
encode() {
throw new TypeError("circular");
},
decode() {
return undefined;
},
},
});
await expect(cache.set("k", { x: 1 })).rejects.toMatchObject({
code: CACHE_REDIS_CODES.CODEC_ERROR,
});
});
it("CODEC_ERROR when codec.decode throws on get", T, async () => {
const fake = new FakeRedis();
await fake.set("k", "not-json{");
const cache = new RedisCache<unknown>({
redis: fake,
codec: new JsonCodec<unknown>(),
});
await expect(cache.get("k")).rejects.toMatchObject({
code: CACHE_REDIS_CODES.CODEC_ERROR,
});
});
it(
"propagates RedisCacheError thrown by the codec without re-wrapping",
T,
async () => {
const fake = new FakeRedis();
await fake.set("k", "anything");
const cache = new RedisCache<unknown>({
redis: fake,
codec: {
encode() {
return new Uint8Array();
},
decode() {
throw new RedisCacheError(
CACHE_REDIS_CODES.CODEC_ERROR,
"x"
);
},
},
});
await expect(cache.get("k")).rejects.toBeInstanceOf(
RedisCacheError
);
}
);
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,247 @@
import {
AbstractCache,
type CacheEvents,
type CacheSetOptions,
type CacheStats,
} from "@suaveplan/cache";
import { type Codec, IdentityCodec, JsonCodec } from "@suaveplan/cache-storage";
import type { EventBusLike } from "@suaveplan/types";
import { CACHE_REDIS_CODES, RedisCacheError } from "../errors/index.js";
import type { RedisLike } from "../redis-like/redis-like.js";
import type { RedisCacheOptions } from "./redis-cache.types.js";
type EventName = keyof CacheEvents;
const MAX_KEY_LENGTH = 1024;
/** Convert raw bytes → ASCII base64. Avoids Node-specific `Buffer`. */
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return globalThis.btoa(binary);
}
/** Convert ASCII base64 → bytes. */
function base64ToBytes(b64: string): Uint8Array {
const binary = globalThis.atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
/** Convert UTF-8 bytes → string. */
const decoder = new TextDecoder("utf-8", { fatal: true });
function bytesToUtf8(bytes: Uint8Array): string {
return decoder.decode(bytes);
}
const encoder = new TextEncoder();
function utf8ToBytes(text: string): Uint8Array {
return encoder.encode(text);
}
/**
* `AbstractCache<T>` backed by a Redis-compatible store via the minimal
* {@link RedisLike} interface. Uses native `SET ... EX` for TTL and
* `SCAN`-based prefix iteration for namespaced `clear()`.
*/
export class RedisCache<T = unknown> extends AbstractCache<T> {
readonly #redis: RedisLike;
readonly #codec: Codec<T>;
readonly #defaultTtl: number;
readonly #events: EventBusLike | undefined;
readonly #namespace: string;
readonly #binarySafe: boolean;
#hits = 0;
#misses = 0;
#evictions = 0;
public constructor(options: RedisCacheOptions<T>) {
super();
this.#redis = options.redis;
this.#codec = options.codec ?? (new JsonCodec<T>() as Codec<T>);
this.#defaultTtl = options.defaultTtl ?? 0;
this.#events = options.events;
this.#namespace = options.namespace ?? "";
this.#binarySafe = this.#codec instanceof IdentityCodec;
}
#prefixed(key: string): string {
return this.#namespace === "" ? key : `${this.#namespace}:${key}`;
}
#validateKey(key: string): void {
if (key.length > MAX_KEY_LENGTH) {
throw new RedisCacheError(
CACHE_REDIS_CODES.KEY_TOO_LONG,
"error.cache-redis.key-too-long",
{ key, command: "SET" }
);
}
}
#emit<K extends EventName>(type: K, payload: CacheEvents[K]): void {
if (this.#events !== undefined) {
this.#events.emit(type as string, payload);
}
}
#encodeForWire(value: T): string {
const bytes = this.#codec.encode(value);
return this.#binarySafe ? bytesToBase64(bytes) : bytesToUtf8(bytes);
}
#decodeFromWire(key: string, text: string): T {
try {
const bytes = this.#binarySafe
? base64ToBytes(text)
: utf8ToBytes(text);
return this.#codec.decode(bytes);
} catch (cause) {
if (cause instanceof RedisCacheError) {
throw cause;
}
throw new RedisCacheError(
CACHE_REDIS_CODES.CODEC_ERROR,
"error.cache-redis.codec.decode",
{ cause: cause as Error, key, command: "GET" }
);
}
}
async #wrapCommand<R>(
command: string,
key: string | undefined,
fn: () => Promise<R>
): Promise<R> {
try {
return await fn();
} catch (cause) {
throw new RedisCacheError(
CACHE_REDIS_CODES.CONNECTION_FAILED,
"error.cache-redis.connection",
{
cause: cause as Error,
command,
...(key !== undefined ? { key } : {}),
}
);
}
}
public async get(key: string): Promise<T | undefined> {
this.#validateKey(key);
const prefixed = this.#prefixed(key);
const raw = await this.#wrapCommand("GET", key, () =>
this.#redis.get(prefixed)
);
if (raw === null) {
this.#misses += 1;
this.#emit("miss", { key });
return undefined;
}
const value = this.#decodeFromWire(key, raw);
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;
if (ttl < 0) {
throw new RedisCacheError(
CACHE_REDIS_CODES.INVALID_TTL,
"error.cache-redis.invalid-ttl",
{ key, command: "SET" }
);
}
let wire: string;
try {
wire = this.#encodeForWire(value);
} catch (cause) {
throw new RedisCacheError(
CACHE_REDIS_CODES.CODEC_ERROR,
"error.cache-redis.codec.encode",
{ cause: cause as Error, key, command: "SET" }
);
}
const prefixed = this.#prefixed(key);
await this.#wrapCommand("SET", key, async () => {
if (ttl > 0) {
const seconds = Math.max(1, Math.ceil(ttl / 1000));
await this.#redis.set(prefixed, wire, { EX: seconds });
} else {
await this.#redis.set(prefixed, wire);
}
});
this.#emit("set", ttl > 0 ? { key, ttl } : { key });
}
public async delete(key: string): Promise<boolean> {
this.#validateKey(key);
const prefixed = this.#prefixed(key);
const removed = await this.#wrapCommand("DEL", key, () =>
this.#redis.del(prefixed)
);
const existed = removed > 0;
if (existed) {
this.#emit("delete", { key });
}
return existed;
}
public async has(key: string): Promise<boolean> {
this.#validateKey(key);
const prefixed = this.#prefixed(key);
const n = await this.#wrapCommand("EXISTS", key, () =>
this.#redis.exists(prefixed)
);
return n > 0;
}
public async clear(): Promise<void> {
if (this.#namespace === "") {
throw new RedisCacheError(
CACHE_REDIS_CODES.COMMAND_FAILED,
"error.cache-redis.clear-without-namespace",
{ command: "CLEAR" }
);
}
const pattern = `${this.#namespace}:*`;
let cursor = "0";
let evicted = 0;
do {
const result = await this.#wrapCommand("SCAN", undefined, () =>
this.#redis.scan(cursor, { MATCH: pattern, COUNT: 1000 })
);
if (result.keys.length > 0) {
evicted += await this.#wrapCommand("DEL", undefined, () =>
this.#redis.del(...result.keys)
);
}
cursor = result.cursor;
} while (cursor !== "0");
this.#evictions += evicted;
this.#emit("clear", {});
}
public getStats(): CacheStats {
const total = this.#hits + this.#misses;
return {
hits: this.#hits,
misses: this.#misses,
size: 0,
hitRate: total === 0 ? 0 : this.#hits / total,
evictions: this.#evictions,
};
}
}

View File

@@ -0,0 +1,21 @@
import type { CacheProviderConfig } from "@suaveplan/cache";
import type { Codec } from "@suaveplan/cache-storage";
import type { EventBusLike } from "@suaveplan/types";
import type { RedisLike } from "../redis-like/redis-like.js";
/** Options accepted by the {@link RedisCache} constructor. */
export interface RedisCacheOptions<T> extends CacheProviderConfig {
/** Underlying Redis client (any `RedisLike` conformer). Required. */
redis: RedisLike;
/** Value codec. Defaults to `JsonCodec<T>` from `@suaveplan/cache-storage`. */
codec?: Codec<T>;
/** Default TTL when `set` omits one. Milliseconds; `0` ⇒ no expiry. */
defaultTtl?: number;
/** Optional event bus for lifecycle topics. */
events?: EventBusLike;
/**
* Key prefix; required when `clear()` will be called. Empty/undefined
* disables prefixing — `clear()` then refuses with `COMMAND_FAILED`.
*/
namespace?: string;
}

View File

@@ -0,0 +1,6 @@
export type {
RedisLike,
RedisScanOptions,
RedisScanResult,
RedisSetOptions,
} from "./redis-like.js";

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,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeRedis } from "../../tests/fixtures/fake-redis.js";
import type { RedisLike } from "./redis-like.js";
describe("RedisLike", () => {
it("FakeRedis stub satisfies the RedisLike interface", {
timeout: 5000,
}, async () => {
const r: RedisLike = new FakeRedis();
await r.set("k", "v");
expect(await r.get("k")).toBe("v");
expect(await r.exists("k")).toBe(1);
expect(await r.del("k")).toBe(1);
expect(await r.get("k")).toBeNull();
});
it("FakeRedis supports SET with EX expiry semantics", {
timeout: 5000,
}, async () => {
const r = new FakeRedis();
await r.set("k", "v", { EX: 1 });
expect(await r.ttl("k")).toBeGreaterThan(0);
});
it("FakeRedis scan returns matched keys", { timeout: 5000 }, async () => {
const r = new FakeRedis();
await r.set("ns:a", "1");
await r.set("ns:b", "2");
await r.set("other:c", "3");
const result = await r.scan("0", { MATCH: "ns:*" });
expect(result.cursor).toBe("0");
expect(result.keys.sort()).toEqual(["ns:a", "ns:b"]);
});
it("FakeRedis expire returns 0 for missing key, 1 for existing", {
timeout: 5000,
}, async () => {
const r = new FakeRedis();
expect(await r.expire("missing", 10)).toBe(0);
await r.set("k", "v");
expect(await r.expire("k", 10)).toBe(1);
expect(await r.ttl("k")).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,66 @@
/**
* Result shape returned by {@link RedisLike.scan}.
*
* Compatible with both `ioredis` (which returns `[cursor, keys]`) and
* `node-redis` (which returns `{ cursor, keys }`) — adapters normalise into
* this shape.
*/
export interface RedisScanResult {
/** Opaque cursor; `"0"` signals end of iteration. */
cursor: string;
/** Keys discovered in this batch (may be empty). */
keys: string[];
}
/**
* Options accepted by {@link RedisLike.set}.
*
* `EX` and `PX` are mutually exclusive; the cache only ever emits `EX`.
*/
export interface RedisSetOptions {
/** Expire-time in seconds (Redis `EX`). */
EX?: number;
/** Expire-time in milliseconds (Redis `PX`). Unused by this package. */
PX?: number;
}
/**
* Options accepted by {@link RedisLike.scan}.
*
* `MATCH` is the glob pattern; `COUNT` is a hint to the server.
*/
export interface RedisScanOptions {
MATCH?: string;
COUNT?: number;
}
/**
* Minimal Redis client surface consumed by {@link RedisCache}.
*
* Implementations need not be Redis-specific — any data store offering these
* primitives with the documented semantics will satisfy the contract. The
* canonical adapters are `ioredis` and `node-redis`; tests use an in-memory
* `FakeRedis` stub.
*/
export interface RedisLike {
/** `SET key value [EX seconds]`. Returns the server response (typically `"OK"`). */
set(
key: string,
value: string,
options?: RedisSetOptions
): Promise<unknown>;
/** `GET key`. Returns `null` for misses. */
get(key: string): Promise<string | null>;
/** `DEL key1 [key2 ...]`. Returns the number of keys deleted. */
del(...keys: string[]): Promise<number>;
/** `EXISTS key1 [key2 ...]`. Returns the number of matching keys. */
exists(...keys: string[]): Promise<number>;
/** `TTL key`. Returns seconds, `-1` for "no expiry", `-2` for "missing". */
ttl(key: string): Promise<number>;
/** `EXPIRE key seconds`. Returns `1` on success, `0` when the key is missing. */
expire(key: string, seconds: number): Promise<number>;
/** `MGET key1 [key2 ...]`. Optional; if absent the cache falls back to per-key `get`. */
mget?(...keys: string[]): Promise<Array<string | null>>;
/** `SCAN cursor [MATCH pattern] [COUNT count]`. */
scan(cursor: string, options?: RedisScanOptions): Promise<RedisScanResult>;
}

View File

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

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,32 @@
import { AbstractCache, createCache, hasCacheProvider } from "@suaveplan/cache";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeRedis } from "../../tests/fixtures/fake-redis.js";
// Side-effect: registers the "redis" provider.
import "./registry-augment.js";
import { RedisCache } from "../redis-cache/index.js";
import { registerRedisProvider } from "./registry-augment.js";
describe("registry-augment", () => {
it("hasCacheProvider returns true after import", { timeout: 5000 }, () => {
expect(hasCacheProvider("redis")).toBe(true);
});
it('createCache("redis", ...) returns a RedisCache instance', {
timeout: 5000,
}, () => {
const cache = createCache(
"redis" as never,
{
redis: new FakeRedis(),
} as never
);
expect(cache).toBeInstanceOf(RedisCache);
expect(cache).toBeInstanceOf(AbstractCache);
});
it("registerRedisProvider is idempotent", { timeout: 5000 }, () => {
expect(() => registerRedisProvider()).not.toThrow();
expect(() => registerRedisProvider()).not.toThrow();
expect(hasCacheProvider("redis")).toBe(true);
});
});

View File

@@ -0,0 +1,28 @@
import { hasCacheProvider, registerCacheProvider } from "@suaveplan/cache";
import { RedisCache } from "../redis-cache/index.js";
declare module "@suaveplan/cache" {
interface CacheProviderRegistry {
redis: typeof RedisCache;
}
}
let registered = false;
/**
* Idempotently register `RedisCache` as the `"redis"` provider on the shared
* `CacheFactory` registry. Safe to invoke repeatedly.
*/
export function registerRedisProvider(): void {
if (registered || hasCacheProvider("redis")) {
registered = true;
return;
}
registerCacheProvider(
"redis",
RedisCache as unknown as Parameters<typeof registerCacheProvider>[1]
);
registered = true;
}
registerRedisProvider();

View File

@@ -0,0 +1,142 @@
import type {
RedisLike,
RedisScanResult,
} from "../../src/redis-like/redis-like.js";
interface Entry {
value: string;
expiresAt: number;
}
export interface FakeRedisCall {
command: string;
args: ReadonlyArray<unknown>;
}
/**
* Minimal in-memory `RedisLike` implementation used by the cache-redis test
* suite. Tracks every command in `calls` so assertions can verify the exact
* wire `RedisCache` issued.
*/
export class FakeRedis implements RedisLike {
public readonly calls: FakeRedisCall[] = [];
readonly #store = new Map<string, Entry>();
#now: () => number;
public constructor(now: () => number = () => Date.now()) {
this.#now = now;
}
public setClock(now: () => number): void {
this.#now = now;
}
#record(command: string, args: ReadonlyArray<unknown>): void {
this.calls.push({ command, args });
}
public async set(
key: string,
value: string,
options?: { EX?: number; PX?: number }
): Promise<"OK"> {
this.#record(
"SET",
options === undefined ? [key, value] : [key, value, options]
);
let expiresAt = Number.POSITIVE_INFINITY;
if (options?.EX !== undefined) {
expiresAt = this.#now() + options.EX * 1000;
} else if (options?.PX !== undefined) {
expiresAt = this.#now() + options.PX;
}
this.#store.set(key, { value, expiresAt });
return "OK";
}
public async get(key: string): Promise<string | null> {
this.#record("GET", [key]);
const entry = this.#store.get(key);
if (entry === undefined) return null;
if (entry.expiresAt <= this.#now()) {
this.#store.delete(key);
return null;
}
return entry.value;
}
public async del(...keys: string[]): Promise<number> {
this.#record("DEL", keys);
let n = 0;
for (const k of keys) {
if (this.#store.delete(k)) n += 1;
}
return n;
}
public async exists(...keys: string[]): Promise<number> {
this.#record("EXISTS", keys);
let n = 0;
for (const k of keys) {
const e = this.#store.get(k);
if (e === undefined) continue;
if (e.expiresAt > this.#now()) n += 1;
}
return n;
}
public async ttl(key: string): Promise<number> {
this.#record("TTL", [key]);
const entry = this.#store.get(key);
if (entry === undefined) return -2;
if (entry.expiresAt === Number.POSITIVE_INFINITY) return -1;
const remaining = Math.ceil((entry.expiresAt - this.#now()) / 1000);
return remaining > 0 ? remaining : -2;
}
public async expire(key: string, seconds: number): Promise<number> {
this.#record("EXPIRE", [key, seconds]);
const entry = this.#store.get(key);
if (entry === undefined) return 0;
entry.expiresAt = this.#now() + seconds * 1000;
return 1;
}
public async scan(
cursor: string,
options?: { MATCH?: string; COUNT?: number }
): Promise<RedisScanResult> {
this.#record("SCAN", [cursor, options]);
const match = options?.MATCH;
const re =
match === undefined
? null
: new RegExp(
`^${match
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")}$`
);
const keys: string[] = [];
for (const k of this.#store.keys()) {
if (re === null || re.test(k)) keys.push(k);
}
return { cursor: "0", keys };
}
}
/** RedisLike that fails every command with the given error. */
export function makeFailingRedis(error: Error): RedisLike {
const fail = async (): Promise<never> => {
throw error;
};
return {
set: fail,
get: fail,
del: fail,
exists: fail,
ttl: fail,
expire: fail,
scan: fail,
};
}

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-redis",
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/cache-storage",
"@suaveplan/error",
"@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,
},
});

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,
},
});

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,108 @@
{
"name": "@suaveplan/cache",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"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/cache-error": {
"import": {
"types": "./dist/errors/cache-error.d.ts",
"default": "./dist/errors/cache-error.js"
}
},
"./types/common": {
"import": {
"types": "./dist/types/common.d.ts",
"default": "./dist/types/common.js"
}
},
"./types/schemas": {
"import": {
"types": "./dist/types/schemas.d.ts",
"default": "./dist/types/schemas.js"
}
},
"./abstract/abstract-cache": {
"import": {
"types": "./dist/abstract/abstract-cache/abstract-cache.d.ts",
"default": "./dist/abstract/abstract-cache/abstract-cache.js"
}
},
"./abstract/namespaced-cache": {
"import": {
"types": "./dist/abstract/namespaced-cache/namespaced-cache.d.ts",
"default": "./dist/abstract/namespaced-cache/namespaced-cache.js"
}
},
"./memory/memory-cache": {
"import": {
"types": "./dist/memory/memory-cache/memory-cache.d.ts",
"default": "./dist/memory/memory-cache/memory-cache.js"
}
},
"./factory/cache": {
"import": {
"types": "./dist/factory/cache/cache.d.ts",
"default": "./dist/factory/cache/cache.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:bench": "vitest bench",
"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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "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,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,154 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { CacheStats } from "../../types/index.js";
import { AbstractCache } from "./abstract-cache.js";
// Minimal concrete implementation for testing the abstract base
class SimpleCache extends AbstractCache<string> {
protected readonly store = new Map<string, string>();
public hits = 0;
public misses = 0;
public async get(key: string): Promise<string | undefined> {
const val = this.store.get(key);
if (val !== undefined) this.hits++;
else this.misses++;
return val;
}
public async set(key: string, value: string): Promise<void> {
this.store.set(key, value);
}
public async delete(key: string): Promise<boolean> {
return this.store.delete(key);
}
public async clear(): Promise<void> {
this.store.clear();
}
public async has(key: string): Promise<boolean> {
return this.store.has(key);
}
public getStats(): CacheStats {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
size: this.store.size,
hitRate: total > 0 ? this.hits / total : 0,
evictions: 0,
};
}
}
describe("AbstractCache.getMultiple", () => {
it("returns found and missing keys", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
await cache.set("a", "1");
await cache.set("b", "2");
const result = await cache.getMultiple(["a", "b", "c"]);
expect(result.found.get("a")).toBe("1");
expect(result.found.get("b")).toBe("2");
expect(result.missing).toContain("c");
});
it("found.size + missing.length === keys.length", {
timeout: 30000,
}, async () => {
const cache = new SimpleCache();
await cache.set("x", "v");
const result = await cache.getMultiple(["x", "y", "z"]);
expect(result.found.size + result.missing.length).toBe(3);
});
it("returns all missing when cache is empty", {
timeout: 30000,
}, async () => {
const cache = new SimpleCache();
const result = await cache.getMultiple(["a", "b"]);
expect(result.found.size).toBe(0);
expect(result.missing).toEqual(expect.arrayContaining(["a", "b"]));
});
});
describe("AbstractCache.setMultiple", () => {
it("stores all entries", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
await cache.setMultiple([
{ key: "a", value: "1" },
{ key: "b", value: "2" },
]);
expect(await cache.get("a")).toBe("1");
expect(await cache.get("b")).toBe("2");
});
});
describe("AbstractCache.wrap", () => {
it("returns cached value on hit", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
await cache.set("key", "cached");
let called = false;
const result = await cache.wrap("key", async () => {
called = true;
return "fresh";
});
expect(result).toBe("cached");
expect(called).toBe(false);
});
it("calls fn and stores result on miss", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
const result = await cache.wrap("key", async () => "computed");
expect(result).toBe("computed");
expect(await cache.get("key")).toBe("computed");
});
it("forceRefresh bypasses cache", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
await cache.set("key", "old");
const result = await cache.wrap("key", async () => "new", {
forceRefresh: true,
});
expect(result).toBe("new");
});
it("does not store when shouldCache returns false", {
timeout: 30000,
}, async () => {
const cache = new SimpleCache();
await cache.wrap("key", async () => "", {
shouldCache: (v) => v.length > 0,
});
expect(await cache.has("key")).toBe(false);
});
it("respects AbortSignal already aborted", { timeout: 30000 }, async () => {
const cache = new SimpleCache();
const ctrl = new AbortController();
ctrl.abort();
await expect(
cache.wrap("key", async () => "value", { signal: ctrl.signal })
).rejects.toThrow();
});
it("shouldCache on cached hit — falls through when predicate fails", {
timeout: 30000,
}, async () => {
const cache = new SimpleCache();
await cache.set("key", "");
let called = false;
await cache.wrap(
"key",
async () => {
called = true;
return "fresh";
},
{ shouldCache: (v) => v.length > 0 }
);
expect(called).toBe(true);
});
});

View File

@@ -0,0 +1,119 @@
import type {
CacheMultiGetResult,
CacheMultiSetEntry,
CacheSetOptions,
CacheStats,
CacheWrapOptions,
} from "../../types/index.js";
/**
* Abstract base class for all cache implementations.
*
* Concrete subclasses must implement the six primitive operations:
* `get`, `set`, `delete`, `clear`, `has`, and `getStats`.
* The `wrap`, `getMultiple`, and `setMultiple` helpers are provided here
* and delegate to the primitives.
*/
export abstract class AbstractCache<T = unknown> {
/** Retrieve a value by key. Returns `undefined` on miss or expiry. */
public abstract get(key: string): Promise<T | undefined>;
/** Store a value with optional per-entry TTL override. */
public abstract set(
key: string,
value: T,
options?: CacheSetOptions
): Promise<void>;
/** Remove a key. Returns `true` if the key existed. */
public abstract delete(key: string): Promise<boolean>;
/** Remove all entries from the cache. */
public abstract clear(): Promise<void>;
/** Return `true` if the key exists and has not expired. */
public abstract has(key: string): Promise<boolean>;
/** Return a snapshot of cache statistics. */
public abstract getStats(): CacheStats;
// ---- Composite helpers ----
/**
* Fetch multiple keys in one call.
*
* Returns `{ found: Map<string, T>, missing: string[] }`. Missing keys are
* those that returned `undefined` from `get`.
*/
public async getMultiple(keys: string[]): Promise<CacheMultiGetResult<T>> {
const found = new Map<string, T>();
const missing: string[] = [];
await Promise.all(
keys.map(async (key) => {
const value = await this.get(key);
if (value !== undefined) {
found.set(key, value);
} else {
missing.push(key);
}
})
);
return { found, missing };
}
/**
* Store multiple key-value pairs.
*
* Per-entry `options` override the cache-level defaults for that entry.
*/
public async setMultiple(entries: CacheMultiSetEntry<T>[]): Promise<void> {
await Promise.all(
entries.map(({ key, value, options }) =>
this.set(key, value, options)
)
);
}
/**
* Get-or-compute pattern.
*
* If `key` is a hit, the cached value is returned. On a miss, `fn` is
* called and the result is stored using the given `options`. If
* `options.forceRefresh` is `true`, `fn` is called even on a hit.
*
* When `staleWhileRevalidate` is set and the entry exists but is within
* `staleThreshold` ms of expiry, the stale value is returned immediately
* while `fn` is called in the background to refresh the entry.
*/
public async wrap(
key: string,
fn: () => Promise<T>,
options?: CacheWrapOptions<T>
): Promise<T> {
options?.signal?.throwIfAborted();
if (options?.forceRefresh !== true) {
const cached = await this.get(key);
if (cached !== undefined) {
if (
options?.shouldCache !== undefined &&
!options.shouldCache(cached)
) {
// Cached value no longer passes the predicate — fall through
} else {
return cached;
}
}
}
const value = await fn();
if (options?.shouldCache === undefined || options.shouldCache(value)) {
await this.set(key, value, options);
}
return value;
}
}

View File

@@ -0,0 +1 @@
export { AbstractCache } from "./abstract-cache.js";

View File

@@ -0,0 +1 @@
export { NamespacedCache } from "./namespaced-cache.js";

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,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryCache } from "../../memory/memory-cache/index.js";
import { NamespacedCache } from "./namespaced-cache.js";
function makeShared() {
return new MemoryCache<string>({ maxSize: 100 });
}
describe("NamespacedCache", () => {
it("prefixes keys transparently", { timeout: 30000 }, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "user");
await ns.set("alice", "data");
// Check the underlying key is prefixed
expect(await shared.get("user:alice")).toBe("data");
expect(await ns.get("alice")).toBe("data");
});
it("isolates two namespaces on the same underlying cache", {
timeout: 30000,
}, async () => {
const shared = makeShared();
const users = new NamespacedCache(shared, "user");
const sessions = new NamespacedCache(shared, "session");
await users.set("alice", "user-data");
await sessions.set("alice", "sess-data");
expect(await users.get("alice")).toBe("user-data");
expect(await sessions.get("alice")).toBe("sess-data");
});
it("has() returns true for existing key", { timeout: 30000 }, async () => {
const ns = new NamespacedCache(makeShared(), "ns");
await ns.set("k", "v");
expect(await ns.has("k")).toBe(true);
expect(await ns.has("missing")).toBe(false);
});
it("delete() removes the namespaced key", { timeout: 30000 }, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "ns");
await ns.set("k", "v");
const deleted = await ns.delete("k");
expect(deleted).toBe(true);
expect(await ns.has("k")).toBe(false);
});
it("clear() clears the underlying cache", { timeout: 30000 }, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "ns");
await ns.set("k", "v");
await ns.clear();
expect(await ns.has("k")).toBe(false);
});
it("getStats() delegates to inner cache", { timeout: 30000 }, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "ns");
await ns.set("k", "v");
await ns.get("k");
const stats = ns.getStats();
expect(stats.size).toBeGreaterThan(0);
});
it("getMultiple strips prefix from results", {
timeout: 30000,
}, async () => {
const ns = new NamespacedCache(makeShared(), "ns");
await ns.set("a", "1");
await ns.set("b", "2");
const result = await ns.getMultiple(["a", "b", "c"]);
expect(result.found.has("a")).toBe(true);
expect(result.found.has("b")).toBe(true);
expect(result.missing).toContain("c");
});
it("setMultiple stores all entries with prefix", {
timeout: 30000,
}, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "ns");
await ns.setMultiple([
{ key: "x", value: "1" },
{ key: "y", value: "2" },
]);
expect(await shared.get("ns:x")).toBe("1");
expect(await shared.get("ns:y")).toBe("2");
});
it("setMultiple stores entries with per-entry options", {
timeout: 30000,
}, async () => {
const { clock, advance } = (() => {
let now = 0;
return {
clock: () => now,
advance: (ms: number) => {
now += ms;
},
};
})();
const shared = new (
await import("../../memory/memory-cache/index.js")
).MemoryCache<string>({}, clock);
const ns = new NamespacedCache(shared, "ns");
await ns.setMultiple([
{ key: "a", value: "1", options: { ttl: 50 } },
{ key: "b", value: "2" },
]);
advance(100);
expect(await ns.get("a")).toBeUndefined(); // expired
expect(await ns.get("b")).toBe("2"); // no TTL, still valid
});
it("custom separator works correctly", { timeout: 30000 }, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "img", "::");
await ns.set("logo.png", "data");
expect(await shared.get("img::logo.png")).toBe("data");
expect(await ns.get("logo.png")).toBe("data");
});
it("wrap delegates to inner cache with prefixed key", {
timeout: 30000,
}, async () => {
const shared = makeShared();
const ns = new NamespacedCache(shared, "ns");
const val = await ns.wrap("k", async () => "computed");
expect(val).toBe("computed");
// Inner cache should have the prefixed key
expect(await shared.get("ns:k")).toBe("computed");
});
});

View File

@@ -0,0 +1,104 @@
import { DEFAULT_NAMESPACE_SEPARATOR } from "../../constants/index.js";
import type {
CacheMultiGetResult,
CacheMultiSetEntry,
CacheSetOptions,
CacheStats,
CacheWrapOptions,
} from "../../types/index.js";
import { AbstractCache } from "../abstract-cache/index.js";
/**
* `NamespacedCache<T>` wraps any `AbstractCache` and prefixes every key with
* a fixed namespace string and separator. This allows multiple logical caches
* to share a single underlying storage without key collisions.
*/
export class NamespacedCache<T = unknown> extends AbstractCache<T> {
protected readonly _inner: AbstractCache<T>;
protected readonly _prefix: string;
public constructor(
inner: AbstractCache<T>,
namespace: string,
separator: string = DEFAULT_NAMESPACE_SEPARATOR
) {
super();
this._inner = inner;
this._prefix = `${namespace}${separator}`;
}
protected _namespaced(key: string): string {
return `${this._prefix}${key}`;
}
public async get(key: string): Promise<T | undefined> {
return this._inner.get(this._namespaced(key));
}
public async set(
key: string,
value: T,
options?: CacheSetOptions
): Promise<void> {
return this._inner.set(this._namespaced(key), value, options);
}
public async delete(key: string): Promise<boolean> {
return this._inner.delete(this._namespaced(key));
}
public async clear(): Promise<void> {
return this._inner.clear();
}
public async has(key: string): Promise<boolean> {
return this._inner.has(this._namespaced(key));
}
public getStats(): CacheStats {
return this._inner.getStats();
}
public override async getMultiple(
keys: string[]
): Promise<CacheMultiGetResult<T>> {
const result = await this._inner.getMultiple(
keys.map((k) => this._namespaced(k))
);
// Strip the namespace prefix from found keys so callers get back their
// original key strings.
const found = new Map<string, T>();
for (const [nsKey, value] of result.found) {
found.set(nsKey.slice(this._prefix.length), value);
}
const missing = result.missing.map((k) => k.slice(this._prefix.length));
return { found, missing };
}
public override async setMultiple(
entries: CacheMultiSetEntry<T>[]
): Promise<void> {
const mapped: CacheMultiSetEntry<T>[] = entries.map(
({ key, value, options }) => {
const entry: CacheMultiSetEntry<T> = {
key: this._namespaced(key),
value,
};
if (options !== undefined) entry.options = options;
return entry;
}
);
return this._inner.setMultiple(mapped);
}
public override async wrap(
key: string,
fn: () => Promise<T>,
options?: CacheWrapOptions<T>
): Promise<T> {
return this._inner.wrap(this._namespaced(key), fn, options);
}
}

View File

@@ -0,0 +1,14 @@
/** Default maximum number of items the cache will hold. */
export const DEFAULT_MAX_SIZE = 1000;
/** Default TTL in milliseconds. 0 means no expiry. */
export const DEFAULT_TTL = 0;
/** Default eviction policy when the cache is full. */
export const DEFAULT_EVICTION_POLICY = "lru" as const;
/** Default separator used by `NamespacedCache`. */
export const DEFAULT_NAMESPACE_SEPARATOR = ":";
/** Maximum allowed key length in characters. */
export const MAX_KEY_LENGTH = 1024;

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,50 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CACHE_CODES, CacheError } from "./cache-error.js";
describe("CACHE_CODES", () => {
it("has all six stable codes", { timeout: 5000 }, () => {
expect(CACHE_CODES.KEY_INVALID).toBe("CACHE.KEY_INVALID");
expect(CACHE_CODES.CAPACITY_EXCEEDED).toBe("CACHE.CAPACITY_EXCEEDED");
expect(CACHE_CODES.PROVIDER_NOT_FOUND).toBe("CACHE.PROVIDER_NOT_FOUND");
expect(CACHE_CODES.SET_FAILED).toBe("CACHE.SET_FAILED");
expect(CACHE_CODES.GET_FAILED).toBe("CACHE.GET_FAILED");
expect(CACHE_CODES.SERIALIZATION_FAILED).toBe(
"CACHE.SERIALIZATION_FAILED"
);
});
});
describe("CacheError", () => {
it("is instanceof SuaveplanError", { timeout: 5000 }, () => {
const err = new CacheError(CACHE_CODES.KEY_INVALID, "invalid key");
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(CacheError);
});
it("carries correct code", { timeout: 5000 }, () => {
const err = new CacheError(CACHE_CODES.CAPACITY_EXCEEDED, "full");
expect(err.code).toBe("CACHE.CAPACITY_EXCEEDED");
});
it("has correct name", { timeout: 5000 }, () => {
const err = new CacheError(CACHE_CODES.GET_FAILED, "get failed");
expect(err.name).toBe("CacheError");
});
it("preserves cause", { timeout: 5000 }, () => {
const cause = new Error("underlying");
const err = new CacheError(CACHE_CODES.SET_FAILED, "set failed", {
cause,
});
expect(err.cause).toBe(cause);
});
it("passes message through", { timeout: 5000 }, () => {
const err = new CacheError(
CACHE_CODES.PROVIDER_NOT_FOUND,
"no provider"
);
expect(err.message).toBe("no provider");
});
});

View File

@@ -0,0 +1,39 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/cache`.
*
* Codes are prefixed with `CACHE.` and registered globally via `defineCodes`
* from `@suaveplan/error`.
*/
export const CACHE_CODES = defineCodes("CACHE", [
"KEY_INVALID",
"CAPACITY_EXCEEDED",
"PROVIDER_NOT_FOUND",
"SET_FAILED",
"GET_FAILED",
"SERIALIZATION_FAILED",
] as const);
export type CacheCode = (typeof CACHE_CODES)[keyof typeof CACHE_CODES];
/**
* Structured error class for all `@suaveplan/cache` failures.
*
* Extends `SuaveplanError<CacheCode>` for compatibility with cause-chain
* traversal and the `@suaveplan/error` wire serialisation format.
*/
export class CacheError extends SuaveplanError<CacheCode> {
// biome-ignore lint/complexity/noUselessConstructor: SuaveplanError constructor is protected; exposes public entry point
public constructor(
code: CacheCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,5 @@
export {
CACHE_CODES,
type CacheCode,
CacheError,
} from "./cache-error.js";

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,116 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { CACHE_CODES } from "../../errors/index.js";
import { MemoryCache } from "../../memory/memory-cache/index.js";
import type { CacheProviderImplementation } from "../../types/index.js";
import {
CacheFactory,
clearCacheProviders,
createCache,
createCacheWithConfig,
getCacheProviders,
hasCacheProvider,
registerCacheProvider,
unregisterCacheProvider,
} from "./cache.js";
// Restore registry after each test that modifies it
afterEach(() => {
// Re-register memory if it was cleared
if (!CacheFactory.has("memory")) {
CacheFactory.register(
"memory",
MemoryCache as unknown as CacheProviderImplementation
);
}
});
describe("CacheFactory.create", () => {
it("creates a MemoryCache for type 'memory'", { timeout: 5000 }, () => {
const cache = CacheFactory.create("memory");
expect(cache).toBeInstanceOf(MemoryCache);
});
it("passes config to the provider constructor", { timeout: 5000 }, () => {
const cache = CacheFactory.create("memory", {
maxSize: 50,
}) as MemoryCache;
expect(cache.getStats().size).toBe(0);
});
it("throws PROVIDER_NOT_FOUND for unregistered type", {
timeout: 5000,
}, () => {
expect(() => CacheFactory.create("__unknown__" as "memory")).toThrow();
try {
CacheFactory.create("__unknown__" as "memory");
} catch (err) {
expect((err as { code: string }).code).toBe(
CACHE_CODES.PROVIDER_NOT_FOUND
);
}
});
});
describe("CacheFactory registry", () => {
it("register and has work together", { timeout: 5000 }, () => {
CacheFactory.register(
"custom",
MemoryCache as unknown as CacheProviderImplementation
);
expect(CacheFactory.has("custom")).toBe(true);
CacheFactory.unregister("custom");
});
it("unregister removes provider", { timeout: 5000 }, () => {
CacheFactory.register(
"temp",
MemoryCache as unknown as CacheProviderImplementation
);
CacheFactory.unregister("temp");
expect(CacheFactory.has("temp")).toBe(false);
});
it("providers() returns registered names", { timeout: 5000 }, () => {
const names = CacheFactory.providers();
expect(names).toContain("memory");
});
});
describe("createCache free function", () => {
it("creates a memory cache", { timeout: 5000 }, () => {
const cache = createCache("memory");
expect(cache).toBeInstanceOf(MemoryCache);
});
});
describe("createCacheWithConfig", () => {
it("creates from config object", { timeout: 5000 }, () => {
const cache = createCacheWithConfig({
type: "memory",
options: { maxSize: 10 },
});
expect(cache).toBeInstanceOf(MemoryCache);
});
});
describe("free-function registry helpers", () => {
it("registerCacheProvider and hasCacheProvider", { timeout: 5000 }, () => {
registerCacheProvider(
"custom2",
MemoryCache as unknown as CacheProviderImplementation
);
expect(hasCacheProvider("custom2")).toBe(true);
unregisterCacheProvider("custom2");
expect(hasCacheProvider("custom2")).toBe(false);
});
it("getCacheProviders returns all names", { timeout: 5000 }, () => {
expect(getCacheProviders()).toContain("memory");
});
it("clearCacheProviders removes all", { timeout: 5000 }, () => {
clearCacheProviders();
expect(hasCacheProvider("memory")).toBe(false);
// Restore for afterEach
});
});

View File

@@ -0,0 +1,115 @@
import { CACHE_CODES, CacheError } from "../../errors/index.js";
import { MemoryCache } from "../../memory/memory-cache/index.js";
import type {
AbstractCacheShape,
CacheFactoryConfig,
CacheProviderConfig,
CacheProviderImplementation,
CacheProviderType,
} from "../../types/index.js";
// Module-level registry
const _registry = new Map<string, CacheProviderImplementation>();
// Built-in registrations
_registry.set("memory", MemoryCache as unknown as CacheProviderImplementation);
/**
* Central factory for creating cache instances from a registered provider type.
*
* The registry is module-augmentation-friendly: third-party packages can call
* `registerCacheProvider` to add their own backends (Redis, Memcached, etc.)
* and then reference the type via `createCache`.
*/
export const CacheFactory = {
/**
* Create a new cache instance of `type` with optional `config`.
*
* @throws `CacheError(PROVIDER_NOT_FOUND)` if the type is not registered.
*/
create<_T>(
type: CacheProviderType,
config?: CacheProviderConfig
// biome-ignore lint/suspicious/noExplicitAny: required for generic provider return
): AbstractCacheShape<any> {
const Impl = _registry.get(type as string);
if (Impl === undefined) {
throw new CacheError(
CACHE_CODES.PROVIDER_NOT_FOUND,
`Cache provider '${String(type)}' is not registered. Available: ${[..._registry.keys()].join(", ")}`
);
}
return new Impl(config);
},
/** Register a new provider under `type`. Replaces any existing registration. */
register(type: string, impl: CacheProviderImplementation): void {
_registry.set(type, impl);
},
/** Remove a provider registration. No-op if the type was not registered. */
unregister(type: string): void {
_registry.delete(type);
},
/** Return `true` if `type` is registered. */
has(type: string): boolean {
return _registry.has(type);
},
/** Return the list of all registered provider type names. */
providers(): string[] {
return [..._registry.keys()];
},
};
// ---- Free functions ----
/**
* Create a cache from a registered `type`.
*
* Delegates to `CacheFactory.create`.
*/
export function createCache<T>(
type: CacheProviderType,
config?: CacheProviderConfig
): AbstractCacheShape<T> {
return CacheFactory.create<T>(type, config);
}
/**
* Create a cache from a `CacheFactoryConfig` object.
*/
export function createCacheWithConfig<T>(
config: CacheFactoryConfig
): AbstractCacheShape<T> {
return CacheFactory.create<T>(config.type, config.options);
}
/** Register a cache provider. Delegates to `CacheFactory.register`. */
export function registerCacheProvider(
type: string,
impl: CacheProviderImplementation
): void {
CacheFactory.register(type, impl);
}
/** Unregister a cache provider. Delegates to `CacheFactory.unregister`. */
export function unregisterCacheProvider(type: string): void {
CacheFactory.unregister(type);
}
/** Check if a provider is registered. */
export function hasCacheProvider(type: string): boolean {
return CacheFactory.has(type);
}
/** Get all registered provider names. */
export function getCacheProviders(): string[] {
return CacheFactory.providers();
}
/** Clear all registered providers (useful in tests). */
export function clearCacheProviders(): void {
_registry.clear();
}

View File

@@ -0,0 +1,10 @@
export {
CacheFactory,
clearCacheProviders,
createCache,
createCacheWithConfig,
getCacheProviders,
hasCacheProvider,
registerCacheProvider,
unregisterCacheProvider,
} from "./cache.js";

View File

@@ -0,0 +1,2 @@
// Side-effect: register built-in memory provider
export {} from "./registry.js";

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,20 @@
/**
* Built-in provider registry.
*
* This module performs the side-effect of registering the `"memory"` provider
* into `CacheFactory` at import time. It is imported by the package barrel
* (`src/index.ts`) so the registration happens automatically when the package
* is loaded.
*
* The registration is idempotent — calling `registerCacheProvider("memory", ...)`
* on a pre-registered type simply overwrites the existing entry.
*/
import { MemoryCache } from "../../memory/memory-cache/index.js";
import type { CacheProviderImplementation } from "../../types/index.js";
import { registerCacheProvider } from "../cache/index.js";
// Register the built-in memory provider
registerCacheProvider(
"memory",
MemoryCache as unknown as CacheProviderImplementation
);

View File

@@ -0,0 +1,61 @@
// Abstract base
export { AbstractCache } from "./abstract/abstract-cache/index.js";
export { NamespacedCache } from "./abstract/namespaced-cache/index.js";
// Constants
export {
DEFAULT_EVICTION_POLICY,
DEFAULT_MAX_SIZE,
DEFAULT_NAMESPACE_SEPARATOR,
DEFAULT_TTL,
MAX_KEY_LENGTH,
} from "./constants/index.js";
// Errors
export {
CACHE_CODES,
type CacheCode,
CacheError,
} from "./errors/index.js";
// Factory
export {
CacheFactory,
clearCacheProviders,
createCache,
createCacheWithConfig,
getCacheProviders,
hasCacheProvider,
registerCacheProvider,
unregisterCacheProvider,
} from "./factory/cache/index.js";
// Side-effect: registers built-in "memory" provider (idempotent)
export {} from "./factory/registry/index.js";
// Memory cache
export { MemoryCache } from "./memory/memory-cache/index.js";
// Types
export type {
AbstractCacheShape,
CacheEntry,
CacheEvents,
CacheEvictionPolicy,
CacheFactoryConfig,
CacheMultiGetResult,
CacheMultiSetEntry,
CacheOptions,
CacheProviderConfig,
CacheProviderImplementation,
CacheProviderRegistry,
CacheProviderType,
CacheSetOptions,
CacheStats,
CacheTransformHook,
CacheWrapOptions,
Clock,
} from "./types/index.js";
// Zod schemas
export {
CacheEvictionPolicySchema,
CacheFactoryConfigSchema,
CacheOptionsSchema,
CacheSetOptionsSchema,
CacheTransformHookSchema,
CacheWrapOptionsSchema,
} from "./types/index.js";

View File

@@ -0,0 +1 @@
export { MemoryCache } from "./memory-cache.js";

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,506 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { EventBusLike } from "@suaveplan/types";
import { CACHE_CODES } from "../../errors/index.js";
import { MemoryCache } from "./memory-cache.js";
function makeEventBus(): {
bus: EventBusLike;
emitted: Array<{ type: string; payload: unknown }>;
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const bus: EventBusLike = {
on: (_type, _listener) => () => {},
emit: (type, payload) => {
emitted.push({ type: type as string, payload });
},
};
return { bus, emitted };
}
function makeClockAt(initial: number) {
let now = initial;
return {
clock: () => now,
advance: (ms: number) => {
now += ms;
},
set: (val: number) => {
now = val;
},
};
}
describe("MemoryCache — LRU (default)", () => {
it("stores and retrieves values", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
await cache.set("key", "value");
expect(await cache.get("key")).toBe("value");
});
it("returns undefined for missing keys", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
expect(await cache.get("missing")).toBeUndefined();
});
it("evicts LRU entry when at capacity", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ maxSize: 2 });
await cache.set("a", "1");
await cache.set("b", "2");
await cache.get("a"); // access "a" — "b" is now LRU
await cache.set("c", "3"); // evicts "b"
expect(await cache.get("b")).toBeUndefined();
expect(await cache.get("a")).toBe("1");
expect(await cache.get("c")).toBe("3");
});
it("overwrites existing key without eviction", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({ maxSize: 2 });
await cache.set("a", "1");
await cache.set("b", "2");
await cache.set("a", "updated");
expect(await cache.get("a")).toBe("updated");
expect(cache.getStats().size).toBe(2);
});
it("delete returns true for existing key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
await cache.set("k", "v");
expect(await cache.delete("k")).toBe(true);
expect(await cache.get("k")).toBeUndefined();
});
it("delete returns false for missing key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
expect(await cache.delete("missing")).toBe(false);
});
it("clear empties the cache", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
await cache.set("a", "1");
await cache.set("b", "2");
await cache.clear();
expect(cache.getStats().size).toBe(0);
});
it("has returns true for existing, false for missing", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>();
await cache.set("k", "v");
expect(await cache.has("k")).toBe(true);
expect(await cache.has("missing")).toBe(false);
});
});
describe("MemoryCache — TTL", () => {
it("returns undefined after TTL expires", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(1000);
const cache = new MemoryCache<string>({ defaultTTL: 100 }, clock);
await cache.set("k", "v");
advance(50);
expect(await cache.get("k")).toBe("v");
advance(60); // total 110ms > 100ms TTL
expect(await cache.get("k")).toBeUndefined();
});
it("per-entry ttl overrides default", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>({ defaultTTL: 1000 }, clock);
await cache.set("short", "v", { ttl: 50 });
advance(100);
expect(await cache.get("short")).toBeUndefined();
});
it("ttl: 0 means no expiry", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>({ defaultTTL: 100 }, clock);
await cache.set("k", "v", { ttl: 0 });
advance(99999);
expect(await cache.get("k")).toBe("v");
});
it("has returns false for expired entry", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>({ defaultTTL: 50 }, clock);
await cache.set("k", "v");
advance(100);
expect(await cache.has("k")).toBe(false);
});
});
describe("MemoryCache — LFU get() expired path", () => {
it("returns undefined when lfu entry expires during get", {
timeout: 30000,
}, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "lfu", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.get("k")).toBeUndefined();
});
});
describe("MemoryCache — LFU", () => {
it("evicts least frequently used entry", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({
maxSize: 3,
evictionPolicy: "lfu",
});
await cache.set("a", "1");
await cache.set("b", "2");
await cache.set("c", "3");
// Access "a" twice and "b" once — "c" stays at freq 0
await cache.get("a");
await cache.get("a");
await cache.get("b");
// Evict — "c" has lowest freq
await cache.set("d", "4");
expect(await cache.get("c")).toBeUndefined();
});
it("overwrites existing lfu key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "lfu" });
await cache.set("k", "v1");
await cache.set("k", "v2");
expect(await cache.get("k")).toBe("v2");
});
it("delete removes lfu entry", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "lfu" });
await cache.set("k", "v");
await cache.delete("k");
expect(await cache.get("k")).toBeUndefined();
});
});
describe("MemoryCache — FIFO", () => {
it("evicts oldest inserted entry", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({
maxSize: 2,
evictionPolicy: "fifo",
});
await cache.set("a", "1"); // oldest
await cache.set("b", "2");
await cache.get("a"); // access does not affect eviction order
await cache.set("c", "3"); // evicts "a"
expect(await cache.get("a")).toBeUndefined();
expect(await cache.get("b")).toBe("2");
});
it("overwrites existing fifo key without changing order", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "fifo" });
await cache.set("k", "old");
await cache.set("k", "new");
expect(await cache.get("k")).toBe("new");
});
});
describe("MemoryCache — TTL policy", () => {
it("evicts expired entries when full", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ maxSize: 2, evictionPolicy: "ttl", defaultTTL: 100 },
clock
);
await cache.set("a", "1");
await cache.set("b", "2");
advance(200); // both entries expired
await cache.set("c", "3"); // should evict expired entry
expect(cache.getStats().size).toBeLessThanOrEqual(2);
});
});
describe("MemoryCache — stats", () => {
it("tracks hits and misses", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
await cache.set("k", "v");
await cache.get("k"); // hit
await cache.get("missing"); // miss
const stats = cache.getStats();
expect(stats.hits).toBe(1);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBe(0.5);
});
it("tracks evictions", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ maxSize: 1 });
await cache.set("a", "1");
await cache.set("b", "2"); // evicts "a"
expect(cache.getStats().evictions).toBe(1);
});
it("hitRate is 0 before any access", { timeout: 5000 }, () => {
const cache = new MemoryCache<string>();
expect(cache.getStats().hitRate).toBe(0);
});
});
describe("MemoryCache — key validation", () => {
it("throws KEY_INVALID for empty key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>();
await expect(cache.get("")).rejects.toMatchObject({
code: CACHE_CODES.KEY_INVALID,
});
});
it("throws KEY_INVALID for key exceeding max length", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>();
const longKey = "x".repeat(1025);
await expect(cache.get(longKey)).rejects.toMatchObject({
code: CACHE_CODES.KEY_INVALID,
});
});
});
describe("MemoryCache — destroy", () => {
it("destroy cancels the cleanup timer", { timeout: 5000 }, () => {
const cache = new MemoryCache<string>({
checkExpirationInterval: 100,
});
cache.destroy(); // should not throw
});
it("setInterval callback fires and cleans up entries", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({
defaultTTL: 1,
checkExpirationInterval: 5,
});
await cache.set("k", "v");
await new Promise((resolve) => setTimeout(resolve, 50));
cache.destroy();
// Timer callback was exercised
});
});
describe("MemoryCache — TTL policy operations", () => {
it("delete returns true for existing key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "ttl" });
await cache.set("k", "v");
expect(await cache.delete("k")).toBe(true);
expect(await cache.get("k")).toBeUndefined();
});
it("delete returns false for missing key", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "ttl" });
expect(await cache.delete("missing")).toBe(false);
});
it("has returns true for existing, false for missing", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "ttl" });
await cache.set("k", "v");
expect(await cache.has("k")).toBe(true);
expect(await cache.has("missing")).toBe(false);
});
it("has returns false for expired entry", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "ttl", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.has("k")).toBe(false);
});
});
describe("MemoryCache — LFU has()", () => {
it("has returns true for existing, false for missing", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "lfu" });
await cache.set("k", "v");
expect(await cache.has("k")).toBe(true);
expect(await cache.has("missing")).toBe(false);
});
it("has returns false for expired lfu entry", {
timeout: 30000,
}, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "lfu", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.has("k")).toBe(false);
});
});
describe("MemoryCache — FIFO has()", () => {
it("has returns true for existing, false for missing", {
timeout: 30000,
}, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "fifo" });
await cache.set("k", "v");
expect(await cache.has("k")).toBe(true);
expect(await cache.has("missing")).toBe(false);
});
it("has returns false for expired fifo entry", {
timeout: 30000,
}, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "fifo", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.has("k")).toBe(false);
});
});
describe("MemoryCache — events (_emitEvent)", () => {
it("emits set/hit/miss/delete events when bus provided", {
timeout: 30000,
}, async () => {
const { bus, emitted } = makeEventBus();
const cache = new MemoryCache<string>({}, undefined, bus);
await cache.set("k", "v");
await cache.get("k"); // hit
await cache.get("missing"); // miss
await cache.delete("k");
const types = emitted.map((e) => e.type);
expect(types).toContain("set");
expect(types).toContain("hit");
expect(types).toContain("miss");
expect(types).toContain("delete");
});
it("emits evict event on LRU eviction", { timeout: 30000 }, async () => {
const { bus, emitted } = makeEventBus();
const cache = new MemoryCache<string>({ maxSize: 1 }, undefined, bus);
await cache.set("a", "1");
await cache.set("b", "2"); // evicts "a"
expect(emitted.some((e) => e.type === "evict")).toBe(true);
});
});
describe("MemoryCache — _cleanupExpired via timer", () => {
it("cleanup removes expired LRU entries", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>({ defaultTTL: 50 }, clock);
await cache.set("k", "v");
advance(100);
cache["_cleanupExpired"]();
expect(cache.getStats().size).toBe(0);
});
it("cleanup removes expired LFU entries", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{
evictionPolicy: "lfu",
defaultTTL: 50,
checkExpirationInterval: 1,
},
clock
);
await cache.set("k", "v");
advance(100);
cache["_cleanupExpired"](); // call protected directly
expect(cache.getStats().size).toBe(0);
cache.destroy();
});
it("cleanup removes expired FIFO entries", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "fifo", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
cache["_cleanupExpired"]();
expect(cache.getStats().size).toBe(0);
});
it("cleanup removes expired TTL entries", { timeout: 30000 }, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "ttl", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
cache["_cleanupExpired"]();
expect(cache.getStats().size).toBe(0);
});
});
describe("MemoryCache — FIFO delete and get() expired path", () => {
it("delete removes fifo entry", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "fifo" });
await cache.set("k", "v");
expect(await cache.delete("k")).toBe(true);
expect(await cache.get("k")).toBeUndefined();
});
it("returns undefined when fifo entry expires during get", {
timeout: 30000,
}, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "fifo", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.get("k")).toBeUndefined();
});
});
describe("MemoryCache — TTL policy get() hit and expired paths", () => {
it("returns value on hit and counts it", { timeout: 30000 }, async () => {
const cache = new MemoryCache<string>({ evictionPolicy: "ttl" });
await cache.set("k", "v");
expect(await cache.get("k")).toBe("v");
expect(cache.getStats().hits).toBe(1);
});
it("returns undefined when ttl entry expires during get", {
timeout: 30000,
}, async () => {
const { clock, advance } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "ttl", defaultTTL: 50 },
clock
);
await cache.set("k", "v");
advance(100);
expect(await cache.get("k")).toBeUndefined();
});
});
describe("MemoryCache — TTL policy eviction (no-expired path)", () => {
it("evicts oldest non-expired entry when full and no expired entries", {
timeout: 30000,
}, async () => {
const { clock } = makeClockAt(0);
const cache = new MemoryCache<string>(
{ evictionPolicy: "ttl", maxSize: 2 },
clock
);
// No TTL — entries never expire
await cache.set("a", "1");
await cache.set("b", "2");
await cache.set("c", "3"); // evicts oldest ("a") since nothing is expired
expect(await cache.get("a")).toBeUndefined();
expect(cache.getStats().size).toBe(2);
});
});

View File

@@ -0,0 +1,682 @@
import type { EventBusLike } from "@suaveplan/types";
import { AbstractCache } from "../../abstract/abstract-cache/index.js";
import {
DEFAULT_EVICTION_POLICY,
DEFAULT_MAX_SIZE,
DEFAULT_TTL,
MAX_KEY_LENGTH,
} from "../../constants/index.js";
import { CACHE_CODES, CacheError } from "../../errors/index.js";
import type {
CacheEntry,
CacheEvents,
CacheEvictionPolicy,
CacheOptions,
CacheSetOptions,
CacheStats,
Clock,
} from "../../types/index.js";
// ---- LRU doubly-linked list node ----
interface LruNode<T> {
key: string;
value: T;
expiresAt: number;
createdAt: number;
lastAccessedAt: number;
hitCount: number;
prev: LruNode<T> | null;
next: LruNode<T> | null;
}
// ---- LFU frequency node ----
interface LfuEntry<T> {
value: T;
freq: number;
expiresAt: number;
createdAt: number;
lastAccessedAt: number;
hitCount: number;
}
/**
* In-memory cache with LRU, LFU, FIFO, and TTL eviction policies.
*
* - **LRU**: doubly-linked list + Map for O(1) get/set.
* - **LFU**: frequency map + min-frequency tracking for O(1) amortised eviction.
* - **FIFO**: insertion-order Map; evicts the oldest-inserted entry.
* - **TTL**: items expire lazily on access; periodic cleanup via
* `checkExpirationInterval` if configured.
*
* TTL is checked on every `get` and `has` call. Expired entries are removed
* lazily — no background timer unless `checkExpirationInterval > 0`.
*/
export class MemoryCache<T = unknown> extends AbstractCache<T> {
protected readonly _maxSize: number;
protected readonly _defaultTTL: number;
protected readonly _policy: CacheEvictionPolicy;
protected readonly _clock: Clock;
protected readonly _events: EventBusLike | undefined;
// Stats counters
protected _hits = 0;
protected _misses = 0;
protected _evictions = 0;
// ---- LRU storage ----
protected _lruMap = new Map<string, LruNode<T>>();
protected _lruHead: LruNode<T> | null = null; // most recently used
protected _lruTail: LruNode<T> | null = null; // least recently used
// ---- LFU storage ----
protected _lfuMap = new Map<string, LfuEntry<T>>();
protected _lfuFreqMap = new Map<number, Set<string>>(); // freq -> ordered set of keys
protected _lfuMinFreq = 0;
// ---- FIFO storage ----
protected _fifoMap = new Map<string, CacheEntry<T>>();
protected _fifoOrder: string[] = [];
// ---- TTL-only storage (when policy === "ttl") ----
protected _ttlMap = new Map<string, CacheEntry<T>>();
protected _cleanupTimer: ReturnType<typeof setInterval> | null = null;
public constructor(
options: CacheOptions = {},
clock?: Clock,
events?: EventBusLike
) {
super();
this._maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
this._defaultTTL = options.defaultTTL ?? DEFAULT_TTL;
this._policy = options.evictionPolicy ?? DEFAULT_EVICTION_POLICY;
this._clock = clock ?? options.clock ?? (() => Date.now());
this._events = events;
if (
options.checkExpirationInterval !== undefined &&
options.checkExpirationInterval > 0
) {
this._cleanupTimer = setInterval(
() => this._cleanupExpired(),
options.checkExpirationInterval
);
}
}
/** Stop the background cleanup timer if one was started. */
public destroy(): void {
if (this._cleanupTimer !== null) {
clearInterval(this._cleanupTimer);
this._cleanupTimer = null;
}
}
// ---- AbstractCache.get ----
public async get(key: string): Promise<T | undefined> {
this._validateKey(key);
const now = this._clock();
switch (this._policy) {
case "lru":
return this._lruGet(key, now);
case "lfu":
return this._lfuGet(key, now);
case "fifo":
return this._fifoGet(key, now);
case "ttl":
return this._ttlGet(key, now);
}
}
// ---- AbstractCache.set ----
public async set(
key: string,
value: T,
options?: CacheSetOptions
): Promise<void> {
this._validateKey(key);
const now = this._clock();
const ttlMs = options?.ttl ?? this._defaultTTL;
const expiresAt = ttlMs > 0 ? now + ttlMs : 0;
switch (this._policy) {
case "lru":
this._lruSet(key, value, now, expiresAt);
break;
case "lfu":
this._lfuSet(key, value, now, expiresAt);
break;
case "fifo":
this._fifoSet(key, value, now, expiresAt);
break;
case "ttl":
this._ttlSet(key, value, now, expiresAt);
break;
}
const setPayload: CacheEvents["set"] =
options?.ttl !== undefined ? { key, ttl: options.ttl } : { key };
this._emitEvent("set", setPayload);
}
// ---- AbstractCache.delete ----
public async delete(key: string): Promise<boolean> {
this._validateKey(key);
let found = false;
switch (this._policy) {
case "lru":
found = this._lruDelete(key);
break;
case "lfu":
found = this._lfuDelete(key);
break;
case "fifo":
found = this._fifoDelete(key);
break;
case "ttl": {
found = this._ttlMap.has(key);
this._ttlMap.delete(key);
break;
}
}
if (found) this._emitEvent("delete", { key });
return found;
}
// ---- AbstractCache.clear ----
public async clear(): Promise<void> {
this._lruMap.clear();
this._lruHead = null;
this._lruTail = null;
this._lfuMap.clear();
this._lfuFreqMap.clear();
this._lfuMinFreq = 0;
this._fifoMap.clear();
this._fifoOrder = [];
this._ttlMap.clear();
this._emitEvent("clear", {});
}
// ---- AbstractCache.has ----
public async has(key: string): Promise<boolean> {
this._validateKey(key);
const now = this._clock();
const value = await this._peekExpiry(key, now);
return value !== undefined;
}
// ---- AbstractCache.getStats ----
public getStats(): CacheStats {
const total = this._hits + this._misses;
return {
hits: this._hits,
misses: this._misses,
size: this._getSize(),
hitRate: total > 0 ? this._hits / total : 0,
evictions: this._evictions,
};
}
// ---- LRU implementation ----
protected _lruGet(key: string, now: number): T | undefined {
const node = this._lruMap.get(key);
if (node === undefined) {
this._misses++;
this._emitEvent("miss", { key });
return undefined;
}
if (this._isExpired(node.expiresAt, now)) {
this._lruDelete(key);
this._misses++;
this._emitEvent("expire", { key });
return undefined;
}
// Move to head (most recently used)
this._lruMoveToHead(node);
node.lastAccessedAt = now;
node.hitCount++;
this._hits++;
this._emitEvent("hit", { key });
return node.value;
}
protected _lruSet(
key: string,
value: T,
now: number,
expiresAt: number
): void {
const existing = this._lruMap.get(key);
if (existing !== undefined) {
existing.value = value;
existing.expiresAt = expiresAt;
existing.lastAccessedAt = now;
this._lruMoveToHead(existing);
return;
}
if (this._lruMap.size >= this._maxSize) {
this._lruEvict();
}
const node: LruNode<T> = {
key,
value,
expiresAt,
createdAt: now,
lastAccessedAt: now,
hitCount: 0,
prev: null,
next: this._lruHead,
};
if (this._lruHead !== null) this._lruHead.prev = node;
this._lruHead = node;
if (this._lruTail === null) this._lruTail = node;
this._lruMap.set(key, node);
}
protected _lruEvict(): void {
if (this._lruTail === null) return;
const tail = this._lruTail;
this._lruDelete(tail.key);
this._evictions++;
this._emitEvent("evict", { key: tail.key, reason: "lru" });
}
protected _lruDelete(key: string): boolean {
const node = this._lruMap.get(key);
if (node === undefined) return false;
this._lruMap.delete(key);
if (node.prev !== null) node.prev.next = node.next;
else this._lruHead = node.next;
if (node.next !== null) node.next.prev = node.prev;
else this._lruTail = node.prev;
return true;
}
protected _lruMoveToHead(node: LruNode<T>): void {
if (node === this._lruHead) return;
// Detach
if (node.prev !== null) node.prev.next = node.next;
if (node.next !== null) node.next.prev = node.prev;
if (node === this._lruTail) this._lruTail = node.prev;
// Prepend
node.prev = null;
node.next = this._lruHead;
if (this._lruHead !== null) this._lruHead.prev = node;
this._lruHead = node;
}
// ---- LFU implementation ----
protected _lfuGet(key: string, now: number): T | undefined {
const entry = this._lfuMap.get(key);
if (entry === undefined) {
this._misses++;
this._emitEvent("miss", { key });
return undefined;
}
if (this._isExpired(entry.expiresAt, now)) {
this._lfuDelete(key);
this._misses++;
this._emitEvent("expire", { key });
return undefined;
}
this._lfuPromote(key, entry);
entry.lastAccessedAt = now;
entry.hitCount++;
this._hits++;
this._emitEvent("hit", { key });
return entry.value;
}
protected _lfuSet(
key: string,
value: T,
now: number,
expiresAt: number
): void {
const existing = this._lfuMap.get(key);
if (existing !== undefined) {
existing.value = value;
existing.expiresAt = expiresAt;
existing.lastAccessedAt = now;
this._lfuPromote(key, existing);
return;
}
if (this._lfuMap.size >= this._maxSize) {
this._lfuEvict();
}
const entry: LfuEntry<T> = {
value,
freq: 1,
expiresAt,
createdAt: now,
lastAccessedAt: now,
hitCount: 0,
};
this._lfuMap.set(key, entry);
this._lfuMinFreq = 1;
const bucket = this._lfuFreqMap.get(1) ?? new Set();
bucket.add(key);
this._lfuFreqMap.set(1, bucket);
}
protected _lfuPromote(key: string, entry: LfuEntry<T>): void {
const oldFreq = entry.freq;
const oldBucket = this._lfuFreqMap.get(oldFreq);
if (oldBucket !== undefined) {
oldBucket.delete(key);
if (oldBucket.size === 0) {
this._lfuFreqMap.delete(oldFreq);
if (this._lfuMinFreq === oldFreq) {
this._lfuMinFreq = oldFreq + 1;
}
}
}
const newFreq = oldFreq + 1;
entry.freq = newFreq;
const newBucket = this._lfuFreqMap.get(newFreq) ?? new Set();
newBucket.add(key);
this._lfuFreqMap.set(newFreq, newBucket);
}
protected _lfuEvict(): void {
const bucket = this._lfuFreqMap.get(this._lfuMinFreq);
if (bucket === undefined || bucket.size === 0) return;
const key = bucket.values().next().value as string;
bucket.delete(key);
if (bucket.size === 0) this._lfuFreqMap.delete(this._lfuMinFreq);
this._lfuMap.delete(key);
this._evictions++;
this._emitEvent("evict", { key, reason: "lfu" });
}
protected _lfuDelete(key: string): boolean {
const entry = this._lfuMap.get(key);
if (entry === undefined) return false;
const bucket = this._lfuFreqMap.get(entry.freq);
if (bucket !== undefined) {
bucket.delete(key);
if (bucket.size === 0) this._lfuFreqMap.delete(entry.freq);
}
this._lfuMap.delete(key);
return true;
}
// ---- FIFO implementation ----
protected _fifoGet(key: string, now: number): T | undefined {
const entry = this._fifoMap.get(key);
if (entry === undefined) {
this._misses++;
this._emitEvent("miss", { key });
return undefined;
}
if (this._isExpired(entry.expiresAt, now)) {
this._fifoDelete(key);
this._misses++;
this._emitEvent("expire", { key });
return undefined;
}
entry.lastAccessedAt = now;
entry.hitCount++;
this._hits++;
this._emitEvent("hit", { key });
return entry.value;
}
protected _fifoSet(
key: string,
value: T,
now: number,
expiresAt: number
): void {
if (this._fifoMap.has(key)) {
const existing = this._fifoMap.get(key)!;
existing.value = value;
existing.expiresAt = expiresAt;
return;
}
if (this._fifoMap.size >= this._maxSize) {
this._fifoEvict();
}
this._fifoMap.set(key, {
value,
expiresAt,
createdAt: now,
lastAccessedAt: now,
hitCount: 0,
});
this._fifoOrder.push(key);
}
protected _fifoEvict(): void {
while (this._fifoOrder.length > 0) {
const key = this._fifoOrder.shift()!;
if (this._fifoMap.has(key)) {
this._fifoMap.delete(key);
this._evictions++;
this._emitEvent("evict", { key, reason: "fifo" });
return;
}
}
}
protected _fifoDelete(key: string): boolean {
if (!this._fifoMap.has(key)) return false;
this._fifoMap.delete(key);
const idx = this._fifoOrder.indexOf(key);
if (idx !== -1) this._fifoOrder.splice(idx, 1);
return true;
}
// ---- TTL-only implementation ----
protected _ttlGet(key: string, now: number): T | undefined {
const entry = this._ttlMap.get(key);
if (entry === undefined) {
this._misses++;
this._emitEvent("miss", { key });
return undefined;
}
if (this._isExpired(entry.expiresAt, now)) {
this._ttlMap.delete(key);
this._misses++;
this._emitEvent("expire", { key });
return undefined;
}
entry.lastAccessedAt = now;
entry.hitCount++;
this._hits++;
this._emitEvent("hit", { key });
return entry.value;
}
protected _ttlSet(
key: string,
value: T,
now: number,
expiresAt: number
): void {
if (this._ttlMap.size >= this._maxSize && !this._ttlMap.has(key)) {
this._ttlEvict(now);
}
this._ttlMap.set(key, {
value,
expiresAt,
createdAt: now,
lastAccessedAt: now,
hitCount: 0,
});
}
protected _ttlEvict(now: number): void {
for (const [key, entry] of this._ttlMap) {
if (this._isExpired(entry.expiresAt, now)) {
this._ttlMap.delete(key);
this._evictions++;
this._emitEvent("evict", { key, reason: "ttl" });
return;
}
}
// No expired entries; evict first (oldest insertion order)
const firstKey = this._ttlMap.keys().next().value;
if (firstKey !== undefined) {
this._ttlMap.delete(firstKey);
this._evictions++;
this._emitEvent("evict", { key: firstKey, reason: "ttl" });
}
}
// ---- Helpers ----
protected _isExpired(expiresAt: number, now: number): boolean {
return expiresAt > 0 && now >= expiresAt;
}
protected _validateKey(key: string): void {
if (key.length === 0) {
throw new CacheError(
CACHE_CODES.KEY_INVALID,
"Cache key must not be empty"
);
}
if (key.length > MAX_KEY_LENGTH) {
throw new CacheError(
CACHE_CODES.KEY_INVALID,
`Cache key exceeds maximum length of ${MAX_KEY_LENGTH} characters`
);
}
}
protected _getSize(): number {
switch (this._policy) {
case "lru":
return this._lruMap.size;
case "lfu":
return this._lfuMap.size;
case "fifo":
return this._fifoMap.size;
case "ttl":
return this._ttlMap.size;
}
}
protected async _peekExpiry(
key: string,
now: number
): Promise<T | undefined> {
switch (this._policy) {
case "lru": {
const node = this._lruMap.get(key);
if (node === undefined) return undefined;
if (this._isExpired(node.expiresAt, now)) {
this._lruDelete(key);
return undefined;
}
return node.value;
}
case "lfu": {
const e = this._lfuMap.get(key);
if (e === undefined) return undefined;
if (this._isExpired(e.expiresAt, now)) {
this._lfuDelete(key);
return undefined;
}
return e.value;
}
case "fifo": {
const e = this._fifoMap.get(key);
if (e === undefined) return undefined;
if (this._isExpired(e.expiresAt, now)) {
this._fifoDelete(key);
return undefined;
}
return e.value;
}
case "ttl": {
const e = this._ttlMap.get(key);
if (e === undefined) return undefined;
if (this._isExpired(e.expiresAt, now)) {
this._ttlMap.delete(key);
return undefined;
}
return e.value;
}
}
}
protected _cleanupExpired(): void {
const now = this._clock();
switch (this._policy) {
case "lru": {
for (const [key, node] of this._lruMap) {
if (this._isExpired(node.expiresAt, now)) {
this._lruDelete(key);
this._emitEvent("expire", { key });
}
}
break;
}
case "lfu": {
for (const [key, entry] of this._lfuMap) {
if (this._isExpired(entry.expiresAt, now)) {
this._lfuDelete(key);
this._emitEvent("expire", { key });
}
}
break;
}
case "fifo": {
for (const [key, entry] of this._fifoMap) {
if (this._isExpired(entry.expiresAt, now)) {
this._fifoDelete(key);
this._emitEvent("expire", { key });
}
}
break;
}
case "ttl": {
for (const [key, entry] of this._ttlMap) {
if (this._isExpired(entry.expiresAt, now)) {
this._ttlMap.delete(key);
this._emitEvent("expire", { key });
}
}
break;
}
}
}
protected _emitEvent<K extends keyof CacheEvents>(
type: K,
payload: CacheEvents[K]
): void {
if (this._events === undefined) return;
this._events.emit(type as string, payload as Record<string, unknown>);
}
}

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,143 @@
/**
* Injectable time source. Matches `Clock` from `@suaveplan/types`.
* Defined locally to avoid importing at types-file level; the real
* `Clock = () => number` from `@suaveplan/types` is assignable to this.
*/
export type Clock = () => number;
/** Eviction policy discriminant. */
export type CacheEvictionPolicy = "lru" | "lfu" | "fifo" | "ttl";
/** An individual cache entry with metadata. */
export interface CacheEntry<T = unknown> {
value: T;
expiresAt: number;
createdAt: number;
lastAccessedAt: number;
hitCount: number;
}
/** Statistics snapshot for a cache instance. */
export interface CacheStats {
hits: number;
misses: number;
size: number;
hitRate: number;
memoryUsage?: number;
evictions: number;
}
/** Options controlling cache capacity, TTL, and eviction policy. */
export interface CacheOptions {
maxSize?: number;
defaultTTL?: number;
evictionPolicy?: CacheEvictionPolicy;
enableStats?: boolean;
checkExpirationInterval?: number;
clock?: Clock;
}
/** Per-set options that override the default TTL for one entry. */
export interface CacheSetOptions {
ttl?: number;
staleWhileRevalidate?: boolean;
staleThreshold?: number;
}
/** Options for the `wrap` method. */
export interface CacheWrapOptions<T = unknown> {
ttl?: number;
staleWhileRevalidate?: boolean;
staleThreshold?: number;
forceRefresh?: boolean;
shouldCache?: (value: T) => boolean;
signal?: AbortSignal;
}
/**
* Lifecycle event map emitted by `MemoryCache` when an `EventBusLike` is
* provided via `CacheOptions.events`.
*/
export interface CacheEvents {
hit: { key: string };
miss: { key: string };
set: { key: string; ttl?: number };
delete: { key: string };
clear: Record<string, never>;
evict: { key: string; reason: "ttl" | "lru" | "lfu" | "fifo" | "manual" };
expire: { key: string };
}
/** Result type for `getMultiple`. */
export interface CacheMultiGetResult<T = unknown> {
found: Map<string, T>;
missing: string[];
}
/** Entry type for `setMultiple`. */
export interface CacheMultiSetEntry<T = unknown> {
key: string;
value: T;
options?: CacheSetOptions;
}
/** Provider type string for the `CacheFactory` registry. */
export type CacheProviderType = keyof CacheProviderRegistry;
/** Generic provider config. */
export interface CacheProviderConfig extends CacheOptions {
[key: string]: unknown;
}
/** Factory config for `createCacheWithConfig`. */
export interface CacheFactoryConfig {
type: CacheProviderType;
options?: CacheProviderConfig;
}
/** Provider implementation shape — a constructable class. */
export interface CacheProviderImplementation {
// biome-ignore lint/suspicious/noExplicitAny: required for generic constructor signature
new (options?: CacheProviderConfig): AbstractCacheShape<any>;
}
/**
* Module-augmentation extension point for registering additional cache
* provider types at compile time.
*
* Third-party packages can augment this interface to add their own entries:
*
* ```ts
* declare module "@suaveplan/cache/types/common" {
* interface CacheProviderRegistry {
* redis: typeof RedisCache;
* }
* }
* ```
*/
// biome-ignore lint/suspicious/noEmptyInterface: intentional module-augmentation extension point
export interface CacheProviderRegistry {}
/** Minimal abstract cache shape used by the provider registry. */
export interface AbstractCacheShape<T = unknown> {
get(key: string): Promise<T | undefined>;
set(key: string, value: T, options?: CacheSetOptions): Promise<void>;
delete(key: string): Promise<boolean>;
clear(): Promise<void>;
has(key: string): Promise<boolean>;
getStats(): CacheStats;
getMultiple(keys: string[]): Promise<CacheMultiGetResult<T>>;
setMultiple(entries: CacheMultiSetEntry<T>[]): Promise<void>;
wrap(
key: string,
fn: () => Promise<T>,
options?: CacheWrapOptions<T>
): Promise<T>;
}
/** Hook applied to cache values before storage or after retrieval. */
export interface CacheTransformHook<T = unknown> {
name: string;
transform: (key: string, value: T) => Promise<T> | T;
priority?: number;
}

View File

@@ -0,0 +1,28 @@
export type {
AbstractCacheShape,
CacheEntry,
CacheEvents,
CacheEvictionPolicy,
CacheFactoryConfig,
CacheMultiGetResult,
CacheMultiSetEntry,
CacheOptions,
CacheProviderConfig,
CacheProviderImplementation,
CacheProviderRegistry,
CacheProviderType,
CacheSetOptions,
CacheStats,
CacheTransformHook,
CacheWrapOptions,
Clock,
} from "./common.js";
export {
CacheEvictionPolicySchema,
CacheFactoryConfigSchema,
CacheOptionsSchema,
CacheSetOptionsSchema,
CacheTransformHookSchema,
CacheWrapOptionsSchema,
} from "./schemas.js";

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.

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CacheEvictionPolicySchema,
CacheFactoryConfigSchema,
CacheOptionsSchema,
CacheSetOptionsSchema,
CacheTransformHookSchema,
CacheWrapOptionsSchema,
} from "./schemas.js";
describe("CacheEvictionPolicySchema", () => {
it("accepts valid policies", { timeout: 5000 }, () => {
for (const p of ["lru", "lfu", "fifo", "ttl"]) {
expect(CacheEvictionPolicySchema.parse(p)).toBe(p);
}
});
it("rejects invalid policy", { timeout: 5000 }, () => {
expect(() => CacheEvictionPolicySchema.parse("random")).toThrow();
});
});
describe("CacheOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(CacheOptionsSchema.parse({})).toEqual({});
});
it("accepts all valid fields", { timeout: 5000 }, () => {
const result = CacheOptionsSchema.parse({
maxSize: 1000,
defaultTTL: 60000,
evictionPolicy: "lru",
enableStats: true,
checkExpirationInterval: 5000,
});
expect(result.maxSize).toBe(1000);
expect(result.evictionPolicy).toBe("lru");
});
it("rejects negative maxSize", { timeout: 5000 }, () => {
expect(() => CacheOptionsSchema.parse({ maxSize: -1 })).toThrow();
});
it("rejects negative defaultTTL", { timeout: 5000 }, () => {
expect(() => CacheOptionsSchema.parse({ defaultTTL: -1 })).toThrow();
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(() => CacheOptionsSchema.parse({ unknownKey: true })).toThrow();
});
});
describe("CacheSetOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(CacheSetOptionsSchema.parse({})).toEqual({});
});
it("accepts valid TTL", { timeout: 5000 }, () => {
const result = CacheSetOptionsSchema.parse({ ttl: 30000 });
expect(result.ttl).toBe(30000);
});
it("accepts ttl: 0", { timeout: 5000 }, () => {
expect(CacheSetOptionsSchema.parse({ ttl: 0 })).toEqual({ ttl: 0 });
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(() => CacheSetOptionsSchema.parse({ bad: true })).toThrow();
});
});
describe("CacheWrapOptionsSchema", () => {
it("accepts all valid fields", { timeout: 5000 }, () => {
const result = CacheWrapOptionsSchema.parse({
ttl: 5000,
staleWhileRevalidate: true,
staleThreshold: 1000,
forceRefresh: false,
});
expect(result.forceRefresh).toBe(false);
});
});
describe("CacheFactoryConfigSchema", () => {
it("accepts valid config", { timeout: 5000 }, () => {
const result = CacheFactoryConfigSchema.parse({ type: "memory" });
expect(result.type).toBe("memory");
});
it("rejects empty type", { timeout: 5000 }, () => {
expect(() => CacheFactoryConfigSchema.parse({ type: "" })).toThrow();
});
});
describe("CacheTransformHookSchema", () => {
it("accepts valid hook metadata", { timeout: 5000 }, () => {
const result = CacheTransformHookSchema.parse({
name: "uppercase",
priority: 10,
});
expect(result.name).toBe("uppercase");
});
it("rejects empty name", { timeout: 5000 }, () => {
expect(() => CacheTransformHookSchema.parse({ name: "" })).toThrow();
});
});

View File

@@ -0,0 +1,50 @@
import z from "zod";
/** Zod schema for `CacheEvictionPolicy`. */
export const CacheEvictionPolicySchema = z.enum(["lru", "lfu", "fifo", "ttl"]);
/** Zod schema for `CacheOptions`. */
export const CacheOptionsSchema = z
.object({
maxSize: z.number().int().positive().optional(),
defaultTTL: z.number().nonnegative().optional(),
evictionPolicy: CacheEvictionPolicySchema.optional(),
enableStats: z.boolean().optional(),
checkExpirationInterval: z.number().positive().optional(),
})
.strict();
/** Zod schema for `CacheSetOptions`. */
export const CacheSetOptionsSchema = z
.object({
ttl: z.number().nonnegative().optional(),
staleWhileRevalidate: z.boolean().optional(),
staleThreshold: z.number().nonnegative().optional(),
})
.strict();
/** Zod schema for `CacheWrapOptions`. */
export const CacheWrapOptionsSchema = z
.object({
ttl: z.number().nonnegative().optional(),
staleWhileRevalidate: z.boolean().optional(),
staleThreshold: z.number().nonnegative().optional(),
forceRefresh: z.boolean().optional(),
})
.strict();
/** Zod schema for `CacheFactoryConfig`. */
export const CacheFactoryConfigSchema = z
.object({
type: z.string().min(1),
options: CacheOptionsSchema.optional(),
})
.strict();
/** Zod schema for `CacheTransformHook` (name + priority only — transform fn excluded). */
export const CacheTransformHookSchema = z
.object({
name: z.string().min(1),
priority: z.number().int().optional(),
})
.strict();

View File

@@ -0,0 +1,43 @@
/**
* Dependency-graph smoke test.
*
* Ensures the package barrel imports cleanly and all expected exports exist.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as cache from "../src/index.js";
// Side-effect import: registers built-in memory provider
import "../src/factory/registry/registry.js";
describe("@suaveplan/cache barrel", () => {
it("exports MemoryCache", { timeout: 5000 }, () => {
expect(typeof cache.MemoryCache).toBe("function");
});
it("exports AbstractCache", { timeout: 5000 }, () => {
expect(typeof cache.AbstractCache).toBe("function");
});
it("exports NamespacedCache", { timeout: 5000 }, () => {
expect(typeof cache.NamespacedCache).toBe("function");
});
it("exports CacheFactory", { timeout: 5000 }, () => {
expect(typeof cache.CacheFactory).toBe("object");
expect(typeof cache.CacheFactory.create).toBe("function");
expect(typeof cache.CacheFactory.register).toBe("function");
});
it("exports CacheError and CACHE_CODES", { timeout: 5000 }, () => {
expect(typeof cache.CacheError).toBe("function");
expect(typeof cache.CACHE_CODES).toBe("object");
});
it("exports createCache", { timeout: 5000 }, () => {
expect(typeof cache.createCache).toBe("function");
});
it("exports Zod schemas", { timeout: 5000 }, () => {
expect(typeof cache.CacheOptionsSchema).toBe("object");
expect(typeof cache.CacheSetOptionsSchema).toBe("object");
});
});

View File

@@ -0,0 +1,321 @@
/**
* Internal edge-case coverage tests for MemoryCache protected methods.
*
* These tests target defensive branches in the LRU, LFU, FIFO, and TTL
* implementations that are hard or impossible to trigger through the public API.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryCache } from "../src/memory/memory-cache/memory-cache.js";
// Test subclass that exposes protected internals
class TestableCache<T> extends MemoryCache<T> {
lruEvict(): void {
this._lruEvict();
}
lruDelete(key: string): boolean {
return this._lruDelete(key);
}
lfuEvict(): void {
this._lfuEvict();
}
lfuDelete(key: string): boolean {
return this._lfuDelete(key);
}
fifoEvict(): void {
this._fifoEvict();
}
fifoDelete(key: string): boolean {
return this._fifoDelete(key);
}
ttlEvict(now: number): void {
this._ttlEvict(now);
}
cleanupExpired(): void {
this._cleanupExpired();
}
get lruHead() {
return this._lruHead;
}
get lruMap() {
return this._lruMap;
}
get lfuMap() {
return this._lfuMap;
}
get lfuFreqMap() {
return this._lfuFreqMap;
}
get fifoMap() {
return this._fifoMap;
}
get fifoOrder() {
return this._fifoOrder;
}
get ttlMap() {
return this._ttlMap;
}
}
function makeClockAt(initial: number) {
let now = initial;
return {
clock: () => now,
advance: (ms: number) => {
now += ms;
},
};
}
describe("MemoryCache internals — destroy without timer", () => {
it("destroy is a no-op when no timer set", { timeout: 5000 }, () => {
const cache = new TestableCache<string>(); // no checkExpirationInterval
cache.destroy(); // should not throw — timer is null
});
});
describe("MemoryCache internals — _lruEvict on empty cache", () => {
it("_lruEvict is a no-op when tail is null", { timeout: 5000 }, () => {
const cache = new TestableCache<string>();
cache.lruEvict(); // no entries — lruTail is null, should return early
});
});
describe("MemoryCache internals — _lruDelete tail node", () => {
it("deletes tail correctly (sets _lruTail to null for single-entry list)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>();
await cache.set("only", "v");
// "only" is both head and tail
const deleted = cache.lruDelete("only");
expect(deleted).toBe(true);
expect(cache.lruHead).toBeNull();
});
});
describe("MemoryCache internals — _lruDelete prev-null branch", () => {
it("deletes head node correctly (node.prev is null)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ maxSize: 3 });
await cache.set("a", "1");
await cache.set("b", "2");
// "b" is now the head (most recently used). Delete it.
const deleted = cache.lruDelete("b");
expect(deleted).toBe(true);
});
});
describe("MemoryCache internals — _lruMoveToHead", () => {
it("handles single-element list (head === tail)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ maxSize: 3 });
await cache.set("a", "1");
await cache.set("b", "2");
// Access "a" — it's the tail, so moveToHead exercises the else branch
// (node.prev is null when moving a single child where prev is null)
await cache.get("a"); // moves "a" to head
expect(await cache.get("a")).toBe("1");
});
});
describe("MemoryCache internals — _lfuEvict edge cases", () => {
it("_lfuEvict is a no-op when frequency bucket is empty", {
timeout: 5000,
}, () => {
const cache = new TestableCache<string>({ evictionPolicy: "lfu" });
// No entries — lfuFreqMap is empty, _lfuMinFreq = 0
cache.lfuEvict(); // should not throw
});
it("eviction removes key from non-empty bucket and does not delete freq bucket when others remain", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({
evictionPolicy: "lfu",
maxSize: 3,
});
await cache.set("a", "1");
await cache.set("b", "2");
// Both "a" and "b" are at freq 1 in the same bucket
// Evicting one should NOT delete the freq=1 bucket (bucket.size > 0 after delete)
cache.lfuEvict();
// Only 1 eviction — the other key remains
expect(cache.lfuMap.size).toBe(1);
});
});
describe("MemoryCache internals — _lfuDelete edge cases", () => {
it("_lfuDelete returns false for missing key", { timeout: 5000 }, () => {
const cache = new TestableCache<string>({ evictionPolicy: "lfu" });
expect(cache.lfuDelete("not-here")).toBe(false);
});
it("_lfuDelete does not delete bucket when other keys remain at same freq", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ evictionPolicy: "lfu" });
await cache.set("a", "1");
await cache.set("b", "2");
// Both at freq 1; deleting "a" should keep the freq=1 bucket for "b"
cache.lfuDelete("a");
expect(cache.lfuFreqMap.get(1)?.has("b")).toBe(true);
});
});
describe("MemoryCache internals — _fifoEvict ghost key", () => {
it("_fifoEvict skips deleted keys in fifoOrder", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ evictionPolicy: "fifo" });
await cache.set("a", "1");
// Manually remove "a" from fifoMap but leave it in fifoOrder
cache.fifoMap.delete("a");
// fifoOrder still has "a". _fifoEvict should skip it.
cache.fifoEvict(); // should not crash or corrupt state
});
});
describe("MemoryCache internals — _fifoDelete edge cases", () => {
it("_fifoDelete returns false for missing key", { timeout: 5000 }, () => {
const cache = new TestableCache<string>({ evictionPolicy: "fifo" });
expect(cache.fifoDelete("not-here")).toBe(false);
});
it("_fifoDelete does not splice when key is not in fifoOrder", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ evictionPolicy: "fifo" });
await cache.set("a", "1");
// Remove "a" from fifoOrder but keep it in fifoMap
const idx = cache.fifoOrder.indexOf("a");
if (idx !== -1) cache.fifoOrder.splice(idx, 1);
// Now delete — fifoMap has "a" but fifoOrder does not
cache.fifoDelete("a"); // indexOf returns -1, splice not called
});
});
describe("MemoryCache internals — _ttlEvict on empty map", () => {
it("_ttlEvict is a no-op when TTL map is empty", { timeout: 5000 }, () => {
const cache = new TestableCache<string>({ evictionPolicy: "ttl" });
cache.ttlEvict(Date.now()); // should not throw
});
});
describe("MemoryCache internals — _lruMoveToHead prev-null and head-null branches", () => {
it("handles node with null prev (defensive branch)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ maxSize: 3 });
await cache.set("a", "1");
await cache.set("b", "2");
await cache.set("c", "3");
// Access "a" which is the tail — its prev is non-null (covered above)
// Now manually corrupt a node to have null prev but not be the head
const nodeA = cache.lruMap.get("a");
if (nodeA !== undefined && nodeA !== cache.lruHead) {
const origPrev = nodeA.prev;
nodeA.prev = null; // null the prev — triggers the false branch at line 314
// Manually call _lruMoveToHead via a get
await cache.get("a");
if (origPrev !== null) nodeA.prev = origPrev; // restore
}
});
it("handles null lruHead during moveToHead (defensive branch)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ maxSize: 2 });
await cache.set("a", "1");
await cache.set("b", "2");
// "a" is the tail. Force the head to null so that when we move "a" to
// head, the `if (this._lruHead !== null)` check is false.
const nodeA = cache.lruMap.get("a");
if (nodeA !== undefined) {
cache["_lruHead"] = null; // force null head for branch at line 321
cache["_lruMoveToHead"](nodeA);
// state is now corrupt — just reset
await cache.clear();
}
});
});
describe("MemoryCache internals — _lfuPromote and _lfuDelete missing bucket branches", () => {
it("_lfuPromote with missing freq bucket (defensive)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ evictionPolicy: "lfu" });
await cache.set("k", "v");
const entry = cache.lfuMap.get("k");
if (entry !== undefined) {
// Remove the freq bucket to trigger the `oldBucket === undefined` false branch
cache.lfuFreqMap.delete(entry.freq);
cache["_lfuPromote"]("k", entry); // should not throw
}
});
it("_lfuDelete with missing freq bucket (defensive)", {
timeout: 30000,
}, async () => {
const cache = new TestableCache<string>({ evictionPolicy: "lfu" });
await cache.set("k", "v");
const entry = cache.lfuMap.get("k");
if (entry !== undefined) {
// Remove the freq bucket
cache.lfuFreqMap.delete(entry.freq);
cache.lfuDelete("k"); // should not throw, bucket is undefined
}
});
});
describe("MemoryCache internals — _cleanupExpired with non-expired entries", () => {
it("LRU cleanup does nothing when entries have not expired", {
timeout: 30000,
}, async () => {
const { clock } = makeClockAt(0);
const cache = new TestableCache<string>({ defaultTTL: 10000 }, clock);
await cache.set("k", "v");
cache.cleanupExpired(); // entry not expired — cleanup is a no-op
expect(cache.getStats().size).toBe(1);
});
it("LFU cleanup does nothing when entries have not expired", {
timeout: 30000,
}, async () => {
const { clock } = makeClockAt(0);
const cache = new TestableCache<string>(
{ evictionPolicy: "lfu", defaultTTL: 10000 },
clock
);
await cache.set("k", "v");
cache.cleanupExpired();
expect(cache.getStats().size).toBe(1);
});
it("FIFO cleanup does nothing when entries have not expired", {
timeout: 30000,
}, async () => {
const { clock } = makeClockAt(0);
const cache = new TestableCache<string>(
{ evictionPolicy: "fifo", defaultTTL: 10000 },
clock
);
await cache.set("k", "v");
cache.cleanupExpired();
expect(cache.getStats().size).toBe(1);
});
it("TTL cleanup does nothing when entries have not expired", {
timeout: 30000,
}, async () => {
const { clock } = makeClockAt(0);
const cache = new TestableCache<string>(
{ evictionPolicy: "ttl", defaultTTL: 10000 },
clock
);
await cache.set("k", "v");
cache.cleanupExpired();
expect(cache.getStats().size).toBe(1);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Property-based tests for @suaveplan/cache.
*
* Uses fast-check to verify invariants over arbitrary inputs.
*/
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { MemoryCache } from "../../src/memory/memory-cache/index.js";
// Valid key generator: non-empty strings up to 100 chars
const validKey = fc.string({ minLength: 1, maxLength: 100 });
const validValue = fc.string({ minLength: 0, maxLength: 200 });
describe("MemoryCache property tests", () => {
it("LRU: after filling to capacity N and inserting N+1, LRU item is evicted", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 2, max: 20 }), async (n) => {
const cache = new MemoryCache<number>({ maxSize: n });
// Fill
const keys: string[] = [];
for (let i = 0; i < n; i++) {
await cache.set(`key${i}`, i);
keys.push(`key${i}`);
}
// Access all but the first (making key0 LRU)
for (let i = 1; i < n; i++) {
await cache.get(`key${i}`);
}
// Insert one more
await cache.set(`key${n}`, n);
// key0 should have been evicted
const evicted = await cache.get("key0");
return evicted === undefined;
}),
{ numRuns: 20 }
);
});
it("TTL: items expire after clock advances past TTL", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 1000 }),
fc.integer({ min: 1, max: 100 }),
async (ttl, offset) => {
let now = 0;
const clock = () => now;
const cache = new MemoryCache<string>(
{ defaultTTL: ttl },
clock
);
await cache.set("k", "v");
now = ttl + offset; // advance past TTL
return (await cache.get("k")) === undefined;
}
),
{ numRuns: 50 }
);
});
it("getMultiple: found.size + missing.length === keys.length", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(validKey, { minLength: 1, maxLength: 20 }),
fc.array(validKey, { minLength: 0, maxLength: 10 }),
async (queryKeys, extraKeys) => {
const cache = new MemoryCache<string>();
// Store some query keys
const stored = queryKeys.slice(
0,
Math.ceil(queryKeys.length / 2)
);
for (const k of stored) {
await cache.set(k, "v");
}
// Query all + some extra
const all = [...new Set([...queryKeys, ...extraKeys])];
const result = await cache.getMultiple(all);
return (
result.found.size + result.missing.length === all.length
);
}
),
{ numRuns: 30 }
);
});
it("stats: hits + misses === total get calls", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.record({
key: validKey,
value: validValue,
}),
{ minLength: 1, maxLength: 10 }
),
fc.array(validKey, { minLength: 1, maxLength: 20 }),
async (entries, queries) => {
const cache = new MemoryCache<string>();
for (const { key, value } of entries) {
await cache.set(key, value);
}
const deduped = [...new Set(queries)];
for (const key of deduped) {
await cache.get(key);
}
const stats = cache.getStats();
return stats.hits + stats.misses === deduped.length;
}
),
{ numRuns: 30 }
);
});
});

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", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

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

Some files were not shown because too many files have changed in this diff Show More