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