Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,152 @@
# @toolctrl/shared-store
Cross-replica state primitives backed by Redis (production) or an in-memory
adapter (dev/test). This package is the foundation for horizontal scaling of
the boilerplate's stateful subsystems (rate-limits, sessions, caches,
idempotency keys, MCP transport pool).
## When to use
Reach for `@toolctrl/shared-store` whenever a piece of state must remain
consistent across multiple replicas of the API, web, or worker process. The
package exposes a small adapter surface (`SharedStoreAdapter`) plus higher
level helpers for the most common patterns:
- Fixed-window rate limiting (`createFixedWindowRateLimiter`)
- Sliding-window rate limiting (`createSlidingWindowRateLimiter`)
- Generic cache with cross-instance invalidation (`createCacheStore`)
- Session storage with TTL refresh (`createSessionStore`)
- Idempotency keys with atomic claim (`createIdempotencyStore`)
## When NOT to use
- Hot in-loop counters that never leave a single request. Plain locals are
fine.
- Long-term durable storage. Use Postgres via `@toolctrl/db`.
- Job queues. Use `@toolctrl/queue` (BullMQ) which already owns Redis DB
index `0`.
## Quickstart
```ts
import { createSharedStore, createFixedWindowRateLimiter } from "@toolctrl/shared-store";
const store = createSharedStore({
namespace: "auth-login",
ttlDefault: 60,
redisUrl: process.env.REDIS_URL,
});
const limiter = createFixedWindowRateLimiter({
store,
prefix: "ip",
windowSeconds: 60,
max: 30,
});
const decision = await limiter.consume("203.0.113.42");
if (!decision.allowed) {
throw new Error(`rate limited until ${decision.resetAt.toISOString()}`);
}
```
## Redis DB-Index allocation
When `REDIS_URL` is shared with BullMQ, pick disjoint logical DB indices so
key spaces never collide:
| Index | Owner | Notes |
| ----- | --------------------- | ------------------------------------- |
| 0 | `@toolctrl/queue` | BullMQ default |
| 1 | `@toolctrl/shared-store` | KV state (rate-limits, sessions, etc.) |
| 2 | `@toolctrl/shared-store` Pub/Sub | Reserved for cross-instance invalidation |
The factory accepts `redisDbIndex` to override the default of `1`.
## Helper catalogue
| Helper | When to use | Backing primitive |
| ------ | ----------- | ----------------- |
| `createFixedWindowRateLimiter` | IP/account login throttling, simple per-window caps | `INCR` + `EXPIRE` via Lua |
| `createSlidingWindowRateLimiter` | Tight protection against boundary-gaming attacks (login flood, MFA brute force) | Sorted set with timestamp scores via Lua |
| `createCacheStore` | Hot reads with cross-instance invalidation (subscription, feature flags, tenant config) | `SET EX` + `PUBLISH` invalidation channel |
| `createSessionStore` | Web sessions, MFA challenge state, API token sessions | `SET EX` + `EXPIRE` for `touch()` |
| `createIdempotencyStore` | Webhook deduplication, payment idempotency keys, retried job claims | `SET NX EX` for atomic claim |
## Namespace conventions
- Use a short, kebab/snake-cased namespace that names the **subsystem**, not
the helper. Good: `auth-login`, `mfa-challenge`, `stripe-idempotency`.
Bad: `ratelimit`, `cache`.
- Every key passes through `shared-store:<namespace>:<prefix>:<suffix>`.
Two helpers in the same namespace cannot collide because each helper
applies its own `<prefix>` (`rl:fixed:`, `rl:sliding:`, `cache:`,
`session:`, `idempotency:`).
- Channel names also live inside the namespace
(`shared-store:<namespace>:ch:<channel>`) so pub/sub never crosses
subsystems.
## Redis DB-Index allocation
When `REDIS_URL` is shared with BullMQ, pick disjoint logical DB indices so
key spaces never collide:
| Index | Owner | Notes |
| ----- | --------------------- | ------------------------------------- |
| 0 | `@toolctrl/queue` | BullMQ default |
| 1 | `@toolctrl/shared-store` | KV state (rate-limits, sessions, etc.) |
| 2 | `@toolctrl/shared-store` Pub/Sub | Reserved for cross-instance invalidation |
The factory accepts `redisDbIndex` to override the default of `1`.
## Migration recipe (for task #38)
When replacing an existing `Map<string, T>` with a `SharedStore`:
1. **Identify the pattern.** Pick the helper that matches the in-process
shape: rate-limit (fixed/sliding), cache, session, or idempotency.
2. **Pick a namespace.** One namespace per subsystem
(`auth-login`, `mfa-challenge`, `idempotency`, `subscription-cache`,
`mcp-transport`, `web-session`).
3. **Construct once.** Build the helper at module scope, not per request,
so the underlying Redis connection is reused.
4. **Replace call-sites.** Keep the function signatures stable; the
helpers return `Promise<T>` so call-sites already on async code can
adopt them with minimal churn.
5. **Verify in memory mode.** Run the existing test suite without
`REDIS_URL`; the memory adapter behaves identically and reveals
any subtle race or shape mismatch before staging.
6. **Smoke-test in staging.** Flip `REDIS_URL` in Coolify and watch
metrics / Sentry for connection errors. Use the `RedisAdapter.ping()`
helper to wire a health check.
7. **Delete the old Map.** After two replicas confirm consistent state,
remove the in-process structure. Leaving both paths in would defeat
the migration.
See `docs/improvement-plan/03-scaling-infrastructure/shared-store-migration.md`
for the per-store migration plan.
## Dev vs prod
- No `REDIS_URL`: the factory logs a single warning and returns a
`MemoryAdapter`. Multi-replica is unsafe in this mode and the warning
is intentionally loud so it gets caught by log review.
- With `REDIS_URL`: real Redis is used. Connection failures surface as
rejected promises and should be captured by Sentry. Configure
`commandTimeout` (default 2s) and `connectionTimeout` (default 5s) to
fail fast under Redis pressure rather than hanging requests.
## Lua-script safety
Helpers send their Lua scripts through `EVAL` (`SCRIPT LOAD` + `EVALSHA`
with a transparent `NOSCRIPT` fallback). The scripts are constants — no
user input is ever spliced into the script body. The memory adapter
matches scripts by exact string and rejects unknown scripts so a
malicious caller cannot smuggle Lua-like text into `eval`.
## Status
Authored as part of task #37. Migration of the six in-process stores
(`login-rate-limit`, `mfa-challenge-rate-limit`, `idempotency` fallback,
`subscriptionService` cache, `session-store`, `mcp` transport pool) is
task #38.
@@ -0,0 +1,37 @@
{
"name": "@toolctrl/shared-store",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./adapters": "./src/adapters/index.ts",
"./helpers": "./src/helpers/index.ts",
"./factory": "./src/factory.ts"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"ioredis": "5.10.1"
},
"devDependencies": {
"@toolctrl/logger": "workspace:*",
"@types/ioredis-mock": "^8.2.7",
"@types/node": "^25.0.0",
"ioredis-mock": "^8.13.1",
"testcontainers": "^10.28.0",
"typescript": "^5.3.2"
},
"peerDependencies": {
"@toolctrl/logger": "workspace:*"
},
"peerDependenciesMeta": {
"@toolctrl/logger": {
"optional": true
}
}
}
@@ -0,0 +1,92 @@
/**
* Options accepted by `SharedStoreAdapter.set`.
*
* - `ttlSeconds`: when set, the key expires after the given number of
* seconds. Mirrors Redis `EX`.
* - `nx`: when true, the call only succeeds if the key does not yet
* exist. Mirrors Redis `NX`.
*/
export interface SharedStoreSetOptions {
ttlSeconds?: number;
nx?: boolean;
}
/**
* Options for the streaming SCAN cursor.
*
* `count` is a hint to the underlying store; adapters are free to use it
* for pagination or to ignore it. The default is 100.
*/
export interface SharedStoreScanOptions {
count?: number;
}
/**
* Minimal Redis-compatible adapter surface. Higher-level helpers in
* `src/helpers/` rely only on these primitives so they keep working
* against the in-process `MemoryAdapter` used in dev/test.
*
* Implementations MUST keep operations atomic at the level expected by
* Redis (e.g. `set NX`, `incr`, sorted-set ops). Pub/Sub `subscribe`
* must return a disposer that fully unwires the handler.
*/
export interface SharedStoreAdapter {
/** Read a string value. Returns `null` for missing or expired keys. */
get(key: string): Promise<string | null>;
/**
* Write a string value. Returns `true` when the key was written and
* `false` when an `nx` constraint blocked the write. Always returns
* `true` when `nx` is not set.
*/
set(key: string, value: string, opts?: SharedStoreSetOptions): Promise<boolean>;
/** Delete a key. Returns the number of keys removed (0 or 1). */
del(key: string): Promise<number>;
/**
* Increment a numeric counter by `by` (default 1). The key is created
* as `0` if it does not yet exist and the increment is then applied.
*/
incr(key: string, by?: number): Promise<number>;
/**
* Set a TTL on an existing key. Returns `true` when the key existed
* and the TTL was applied, `false` when the key was missing.
*/
expire(key: string, ttlSeconds: number): Promise<boolean>;
/** Read multiple keys in one round trip. */
mget(keys: string[]): Promise<(string | null)[]>;
/** Async iterator over keys matching a glob pattern. */
scan(pattern: string, opts?: SharedStoreScanOptions): AsyncIterable<string>;
/** Publish a message to a channel. Returns the number of receivers. */
publish(channel: string, message: string): Promise<number>;
/**
* Subscribe to a channel. The returned disposer must be awaited to
* guarantee the underlying listener was torn down.
*/
subscribe(channel: string, handler: (message: string) => void | Promise<void>): Promise<() => Promise<void>>;
/** Sorted-set add. Returns the number of elements actually added. */
zAdd(key: string, score: number, member: string): Promise<number>;
/** Remove sorted-set members whose score lies in [min, max]. */
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
/** Cardinality of a sorted set. Returns 0 for missing keys. */
zCard(key: string): Promise<number>;
/**
* Execute a Lua-like atomic compound operation. In the Redis adapter
* this is real `EVAL`. In the memory adapter, the script body is
* matched against a registered TypeScript equivalent.
*/
eval(script: string, keys: string[], args: string[]): Promise<unknown>;
/**
* Register a handler that fires every time the backing connection is
* re-established AFTER it has been lost (i.e. NOT the very first
* connect). Used by L1-holding helpers to flush their local caches
* because pub/sub has no persistence and invalidations published
* during the disconnect window are silently dropped.
*
* Returns a disposer that removes the listener. Adapters without a
* disconnect concept (e.g. the memory adapter) MUST still implement
* this as a no-op returning a noop disposer so callers do not need
* to feature-detect.
*/
onReconnect(handler: () => void | Promise<void>): () => void;
/** Release any backing connections or timers. Idempotent. */
close(): Promise<void>;
}
@@ -0,0 +1 @@
export type { SharedStoreAdapter, SharedStoreSetOptions, SharedStoreScanOptions } from "./adapter.js";
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { MemoryAdapter } from "./memory.js";
afterEach(() => {
vi.useRealTimers();
});
describe("MemoryAdapter — kv operations", () => {
test("set then get returns the stored value", async () => {
const adapter = new MemoryAdapter();
await adapter.set("a", "1");
expect(await adapter.get("a")).toBe("1");
await adapter.close();
});
test("get returns null for unknown keys", async () => {
const adapter = new MemoryAdapter();
expect(await adapter.get("missing")).toBeNull();
await adapter.close();
});
test("set NX rejects writes when the key exists", async () => {
const adapter = new MemoryAdapter();
expect(await adapter.set("k", "first", { nx: true })).toBe(true);
expect(await adapter.set("k", "second", { nx: true })).toBe(false);
expect(await adapter.get("k")).toBe("first");
await adapter.close();
});
test("set NX succeeds again once the previous key has expired", async () => {
let now = 1_000;
const adapter = new MemoryAdapter({ now: () => now });
expect(await adapter.set("k", "first", { nx: true, ttlSeconds: 1 })).toBe(true);
now += 2_000;
expect(await adapter.set("k", "second", { nx: true })).toBe(true);
expect(await adapter.get("k")).toBe("second");
await adapter.close();
});
test("TTL expiry is observed lazily on get", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
await adapter.set("k", "v", { ttlSeconds: 1 });
expect(await adapter.get("k")).toBe("v");
now = 1_500;
expect(await adapter.get("k")).toBeNull();
await adapter.close();
});
test("del returns 1 for existing keys and 0 otherwise", async () => {
const adapter = new MemoryAdapter();
await adapter.set("k", "v");
expect(await adapter.del("k")).toBe(1);
expect(await adapter.del("k")).toBe(0);
await adapter.close();
});
test("incr creates the key and increments existing values", async () => {
const adapter = new MemoryAdapter();
expect(await adapter.incr("c")).toBe(1);
expect(await adapter.incr("c", 4)).toBe(5);
await adapter.close();
});
test("incr preserves the existing expiry", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
await adapter.set("c", "0", { ttlSeconds: 1 });
await adapter.incr("c");
now = 2_000;
expect(await adapter.get("c")).toBeNull();
await adapter.close();
});
test("incr rejects non-numeric keys", async () => {
const adapter = new MemoryAdapter();
await adapter.set("c", "abc");
await expect(adapter.incr("c")).rejects.toThrow(/non-numeric/);
await adapter.close();
});
test("expire sets ttl on existing keys and returns false for missing ones", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
await adapter.set("k", "v");
expect(await adapter.expire("k", 1)).toBe(true);
expect(await adapter.expire("missing", 5)).toBe(false);
now = 2_000;
expect(await adapter.get("k")).toBeNull();
await adapter.close();
});
test("mget preserves order and reports nulls for missing keys", async () => {
const adapter = new MemoryAdapter();
await adapter.set("a", "1");
await adapter.set("c", "3");
expect(await adapter.mget(["a", "b", "c"])).toEqual(["1", null, "3"]);
await adapter.close();
});
});
describe("MemoryAdapter — scan", () => {
test("yields keys matching a glob pattern", async () => {
const adapter = new MemoryAdapter();
await adapter.set("user:1", "x");
await adapter.set("user:2", "y");
await adapter.set("other:3", "z");
const collected: string[] = [];
for await (const key of adapter.scan("user:*")) {
collected.push(key);
}
expect(collected.sort()).toEqual(["user:1", "user:2"]);
await adapter.close();
});
test("skips expired keys", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
await adapter.set("temp:1", "x", { ttlSeconds: 1 });
await adapter.set("temp:2", "y");
now = 2_000;
const collected: string[] = [];
for await (const key of adapter.scan("temp:*")) {
collected.push(key);
}
expect(collected).toEqual(["temp:2"]);
await adapter.close();
});
});
describe("MemoryAdapter — pub/sub", () => {
test("publish triggers subscribers and returns the listener count", async () => {
const adapter = new MemoryAdapter();
const received: string[] = [];
const unsubscribe = await adapter.subscribe("c", (msg) => {
received.push(msg);
});
expect(await adapter.publish("c", "hello")).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received).toEqual(["hello"]);
await unsubscribe();
expect(await adapter.publish("c", "ignored")).toBe(0);
await adapter.close();
});
test("subscriber errors do not break other subscribers", async () => {
const adapter = new MemoryAdapter();
const calls: string[] = [];
await adapter.subscribe("c", () => {
throw new Error("boom");
});
await adapter.subscribe("c", (msg) => {
calls.push(msg);
});
await adapter.publish("c", "a");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(calls).toEqual(["a"]);
await adapter.close();
});
});
describe("MemoryAdapter — sorted sets", () => {
test("zAdd inserts and updates scores", async () => {
const adapter = new MemoryAdapter();
expect(await adapter.zAdd("k", 1, "a")).toBe(1);
expect(await adapter.zAdd("k", 2, "a")).toBe(0);
expect(await adapter.zCard("k")).toBe(1);
await adapter.close();
});
test("zRemRangeByScore removes the matching window", async () => {
const adapter = new MemoryAdapter();
await adapter.zAdd("k", 1, "a");
await adapter.zAdd("k", 5, "b");
await adapter.zAdd("k", 10, "c");
expect(await adapter.zRemRangeByScore("k", 0, 5)).toBe(2);
expect(await adapter.zCard("k")).toBe(1);
await adapter.close();
});
test("zCard returns 0 for unknown keys", async () => {
const adapter = new MemoryAdapter();
expect(await adapter.zCard("missing")).toBe(0);
await adapter.close();
});
test("del also clears sorted sets", async () => {
const adapter = new MemoryAdapter();
await adapter.zAdd("k", 1, "a");
expect(await adapter.del("k")).toBe(1);
expect(await adapter.zCard("k")).toBe(0);
await adapter.close();
});
});
describe("MemoryAdapter — eval / scripts", () => {
test("unknown scripts reject", async () => {
const adapter = new MemoryAdapter();
await expect(adapter.eval("unknown-script", [], [])).rejects.toThrow(/unknown eval script/);
await adapter.close();
});
test("registered scripts execute against the adapter", async () => {
MemoryAdapter.registerScript("test-script", async (instance, keys, args) => {
await instance.set(keys[0], args[0]);
return `${keys[0]}=${args[0]}`;
});
const adapter = new MemoryAdapter();
const result = await adapter.eval("test-script", ["x"], ["1"]);
expect(result).toBe("x=1");
expect(await adapter.get("x")).toBe("1");
expect(MemoryAdapter.hasRegisteredScript("test-script")).toBe(true);
await adapter.close();
});
});
describe("MemoryAdapter — lifecycle", () => {
test("close prevents further operations", async () => {
const adapter = new MemoryAdapter();
await adapter.close();
await expect(adapter.get("k")).rejects.toThrow(/closed/);
});
});
@@ -0,0 +1,265 @@
import { EventEmitter } from "node:events";
import type { SharedStoreAdapter, SharedStoreScanOptions, SharedStoreSetOptions } from "./adapter.js";
interface KvEntry {
value: string;
expiresAt: number | null;
}
interface SortedSetMember {
score: number;
member: string;
}
/**
* Registry for `eval` scripts. The memory adapter cannot run real Lua,
* so it matches the script body against a TypeScript equivalent that
* mirrors the Redis behavior. New helpers register their script via
* `MemoryAdapter.registerScript` so the same script string can be sent
* through `eval` against either backend.
*/
export type MemoryScriptHandler = (
adapter: MemoryAdapter,
keys: string[],
args: string[],
) => Promise<unknown> | unknown;
const scriptRegistry = new Map<string, MemoryScriptHandler>();
/**
* In-process implementation of `SharedStoreAdapter` for dev, tests, and
* single-instance deployments. TTLs are evaluated lazily on read; no
* background timers are used so tests can exit cleanly.
*/
export class MemoryAdapter implements SharedStoreAdapter {
private readonly kv = new Map<string, KvEntry>();
private readonly sortedSets = new Map<string, SortedSetMember[]>();
private readonly emitter = new EventEmitter();
private readonly clock: () => number;
private closed = false;
constructor(options: { now?: () => number } = {}) {
this.clock = options.now ?? (() => Date.now());
this.emitter.setMaxListeners(0);
}
/**
* Register a TypeScript equivalent of a Redis Lua script so that the
* same `eval(script, keys, args)` call works against both adapters.
*/
static registerScript(script: string, handler: MemoryScriptHandler): void {
scriptRegistry.set(script, handler);
}
static hasRegisteredScript(script: string): boolean {
return scriptRegistry.has(script);
}
async get(key: string): Promise<string | null> {
this.assertOpen();
const entry = this.kv.get(key);
if (!entry) {
return null;
}
if (this.isExpired(entry)) {
this.kv.delete(key);
return null;
}
return entry.value;
}
async set(key: string, value: string, opts: SharedStoreSetOptions = {}): Promise<boolean> {
this.assertOpen();
if (opts.nx) {
const existing = this.kv.get(key);
if (existing && !this.isExpired(existing)) {
return false;
}
}
const expiresAt =
typeof opts.ttlSeconds === "number" && opts.ttlSeconds > 0 ? this.clock() + opts.ttlSeconds * 1000 : null;
this.kv.set(key, { value, expiresAt });
return true;
}
async del(key: string): Promise<number> {
this.assertOpen();
const existed = this.kv.delete(key);
const setExisted = this.sortedSets.delete(key);
return existed || setExisted ? 1 : 0;
}
async incr(key: string, by = 1): Promise<number> {
this.assertOpen();
const entry = this.kv.get(key);
let current = 0;
let expiresAt: number | null = null;
if (entry && !this.isExpired(entry)) {
const parsed = Number.parseInt(entry.value, 10);
if (Number.isNaN(parsed)) {
throw new Error(`incr called on non-numeric key: ${key}`);
}
current = parsed;
expiresAt = entry.expiresAt;
}
const next = current + by;
this.kv.set(key, { value: String(next), expiresAt });
return next;
}
async expire(key: string, ttlSeconds: number): Promise<boolean> {
this.assertOpen();
const entry = this.kv.get(key);
if (!entry || this.isExpired(entry)) {
if (entry) this.kv.delete(key);
return false;
}
entry.expiresAt = ttlSeconds > 0 ? this.clock() + ttlSeconds * 1000 : null;
return true;
}
async mget(keys: string[]): Promise<(string | null)[]> {
this.assertOpen();
return Promise.all(keys.map((key) => this.get(key)));
}
scan(pattern: string, opts: SharedStoreScanOptions = {}): AsyncIterable<string> {
this.assertOpen();
const regex = patternToRegExp(pattern);
const snapshot: string[] = [];
for (const [key, entry] of this.kv.entries()) {
if (!this.isExpired(entry) && regex.test(key)) {
snapshot.push(key);
}
}
const batchSize = Math.max(1, opts.count ?? 100);
let index = 0;
return {
[Symbol.asyncIterator]: () => ({
async next() {
if (index >= snapshot.length) {
return { value: undefined as unknown as string, done: true };
}
const value = snapshot[index];
index += 1;
// batchSize is honored implicitly by sequential iteration; the
// counter exists so callers can reason about it in logs.
void batchSize;
return { value, done: false };
},
}),
};
}
async publish(channel: string, message: string): Promise<number> {
this.assertOpen();
const listeners = this.emitter.listenerCount(channel);
this.emitter.emit(channel, message);
return listeners;
}
async subscribe(channel: string, handler: (message: string) => void | Promise<void>): Promise<() => Promise<void>> {
this.assertOpen();
const wrapped = (message: string) => {
// Defer handler errors to the next tick so other subscribers still run.
Promise.resolve()
.then(() => handler(message))
.catch(() => {
/* swallowed; consumers handle their own errors */
});
};
this.emitter.on(channel, wrapped);
return async () => {
this.emitter.off(channel, wrapped);
};
}
async zAdd(key: string, score: number, member: string): Promise<number> {
this.assertOpen();
const set = this.sortedSets.get(key) ?? [];
const existingIndex = set.findIndex((entry) => entry.member === member);
if (existingIndex >= 0) {
set[existingIndex].score = score;
this.sortedSets.set(key, set);
return 0;
}
set.push({ score, member });
this.sortedSets.set(key, set);
return 1;
}
async zRemRangeByScore(key: string, min: number, max: number): Promise<number> {
this.assertOpen();
const set = this.sortedSets.get(key);
if (!set) return 0;
const before = set.length;
const remaining = set.filter((entry) => entry.score < min || entry.score > max);
if (remaining.length === 0) {
this.sortedSets.delete(key);
} else {
this.sortedSets.set(key, remaining);
}
return before - remaining.length;
}
async zCard(key: string): Promise<number> {
this.assertOpen();
return this.sortedSets.get(key)?.length ?? 0;
}
async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
this.assertOpen();
const handler = scriptRegistry.get(script);
if (!handler) {
throw new Error("shared-store memory adapter: unknown eval script (use MemoryAdapter.registerScript)");
}
return handler(this, keys, args);
}
onReconnect(_handler: () => void | Promise<void>): () => void {
// The in-process memory adapter has no connection that can drop, so
// there is nothing to wire. Returning a noop disposer keeps callers
// adapter-agnostic.
void _handler;
return () => {
/* no-op */
};
}
async close(): Promise<void> {
this.closed = true;
this.kv.clear();
this.sortedSets.clear();
this.emitter.removeAllListeners();
}
private assertOpen(): void {
if (this.closed) {
throw new Error("shared-store memory adapter is closed");
}
}
private isExpired(entry: KvEntry): boolean {
return entry.expiresAt !== null && entry.expiresAt <= this.clock();
}
}
function patternToRegExp(pattern: string): RegExp {
// Translate a tiny subset of glob (`*`, `?`) into a regex, escaping
// everything else. This mirrors how Redis SCAN MATCH works for the
// patterns used in this codebase.
let regex = "^";
for (const ch of pattern) {
if (ch === "*") {
regex += ".*";
} else if (ch === "?") {
regex += ".";
} else if (/[\\^$.+()|[\]{}]/.test(ch)) {
regex += `\\${ch}`;
} else {
regex += ch;
}
}
regex += "$";
return new RegExp(regex);
}
@@ -0,0 +1,348 @@
// RedisAdapter unit tests use `ioredis-mock` as a stand-in for a real
// Redis server. The mock implements every command exercised here (GET,
// SET with EX/NX, INCR/INCRBY, EXPIRE, MGET, SCAN, ZADD, ZREMRANGEBYSCORE,
// ZCARD, PUBSUB, EVAL/EVALSHA). Integration against a real Redis is
// covered by the manual smoke test recipe in README.md and will be wired
// into CI alongside task #38.
import { EventEmitter } from "node:events";
import RedisMock from "ioredis-mock";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { RedisAdapter } from "./redis.js";
type RedisClient = import("ioredis").Redis;
function buildAdapter() {
const adapter = new RedisAdapter({
clientFactory: () => new RedisMock() as unknown as RedisClient,
});
return adapter;
}
class RedisClientDouble extends EventEmitter {
readonly subscribedChannels = new Set<string>();
status = "wait";
closed = false;
constructor(private readonly broker?: { subscriber: RedisClientDouble }) {
super();
}
async publish(channel: string, message: string): Promise<number> {
const subscriber = this.broker?.subscriber;
if (!subscriber || subscriber.closed || !subscriber.subscribedChannels.has(channel)) {
return 0;
}
subscriber.emit("message", channel, message);
return 1;
}
async subscribe(channel: string): Promise<"OK"> {
this.subscribedChannels.add(channel);
return "OK";
}
async unsubscribe(channel: string): Promise<number> {
const removed = this.subscribedChannels.delete(channel);
return removed ? 1 : 0;
}
async quit(): Promise<"OK"> {
this.closed = true;
this.subscribedChannels.clear();
return "OK";
}
disconnect(): void {
this.closed = true;
this.subscribedChannels.clear();
}
}
function buildLifecycleHarness(primaryStatus = "wait") {
const primary = new RedisClientDouble();
const subscriber = new RedisClientDouble();
const publisher = new RedisClientDouble({ subscriber });
primary.status = primaryStatus;
const adapter = new RedisAdapter({
clientFactory: (role) => {
if (role === "primary") return primary as unknown as RedisClient;
if (role === "subscriber") return subscriber as unknown as RedisClient;
return publisher as unknown as RedisClient;
},
});
return { adapter, primary, subscriber };
}
const adapters: RedisAdapter[] = [];
beforeEach(async () => {
// ioredis-mock shares a process-wide keyspace across `new RedisMock()`
// instances; flush before each test so we start with a clean slate.
const flush = new RedisMock() as unknown as { flushall: () => Promise<unknown>; quit: () => Promise<void> };
await flush.flushall();
await flush.quit();
});
afterEach(async () => {
while (adapters.length > 0) {
const adapter = adapters.pop();
if (adapter) {
await adapter.close().catch(() => undefined);
}
}
});
function track(adapter: RedisAdapter): RedisAdapter {
adapters.push(adapter);
return adapter;
}
async function waitForFanout(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("RedisAdapter — kv operations", () => {
test("set and get round-trip a value", async () => {
const adapter = track(buildAdapter());
await adapter.set("k", "v");
expect(await adapter.get("k")).toBe("v");
});
test("set NX rejects when the key already exists", async () => {
const adapter = track(buildAdapter());
expect(await adapter.set("k", "first", { nx: true })).toBe(true);
expect(await adapter.set("k", "second", { nx: true })).toBe(false);
expect(await adapter.get("k")).toBe("first");
});
test("set with TTL expires the key", async () => {
const adapter = track(buildAdapter());
await adapter.set("k", "v", { ttlSeconds: 1 });
await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await adapter.get("k")).toBeNull();
});
test("incr creates and increments the counter", async () => {
const adapter = track(buildAdapter());
expect(await adapter.incr("c")).toBe(1);
expect(await adapter.incr("c", 5)).toBe(6);
});
test("expire applies a ttl to an existing key", async () => {
const adapter = track(buildAdapter());
await adapter.set("k", "v");
expect(await adapter.expire("k", 1)).toBe(true);
expect(await adapter.expire("missing", 1)).toBe(false);
});
test("mget returns values in input order", async () => {
const adapter = track(buildAdapter());
await adapter.set("a", "1");
await adapter.set("c", "3");
expect(await adapter.mget(["a", "b", "c"])).toEqual(["1", null, "3"]);
});
test("mget with an empty list short-circuits", async () => {
const adapter = track(buildAdapter());
expect(await adapter.mget([])).toEqual([]);
});
test("del removes the key and returns the count", async () => {
const adapter = track(buildAdapter());
await adapter.set("k", "v");
expect(await adapter.del("k")).toBe(1);
expect(await adapter.del("k")).toBe(0);
});
});
describe("RedisAdapter — scan", () => {
test("yields keys matching the pattern across multiple cursor pages", async () => {
const adapter = track(buildAdapter());
for (let i = 0; i < 25; i += 1) {
await adapter.set(`user:${i}`, String(i));
}
await adapter.set("other:1", "n");
const collected: string[] = [];
for await (const key of adapter.scan("user:*", { count: 5 })) {
collected.push(key);
}
expect(collected.length).toBe(25);
expect(collected.every((key) => key.startsWith("user:"))).toBe(true);
});
});
describe("RedisAdapter — sorted sets", () => {
test("zAdd reports new vs updated members and zCard counts them", async () => {
const adapter = track(buildAdapter());
expect(await adapter.zAdd("k", 1, "a")).toBe(1);
expect(await adapter.zAdd("k", 2, "a")).toBe(0);
expect(await adapter.zAdd("k", 5, "b")).toBe(1);
expect(await adapter.zCard("k")).toBe(2);
});
test("zRemRangeByScore trims the window", async () => {
const adapter = track(buildAdapter());
await adapter.zAdd("k", 1, "a");
await adapter.zAdd("k", 2, "b");
await adapter.zAdd("k", 10, "c");
expect(await adapter.zRemRangeByScore("k", 0, 5)).toBe(2);
expect(await adapter.zCard("k")).toBe(1);
});
});
describe("RedisAdapter — pub/sub", () => {
test("publish delivers to subscribers and unsubscribe stops further delivery", async () => {
const adapter = track(buildAdapter());
const received: string[] = [];
const unsubscribe = await adapter.subscribe("ch", (msg) => {
received.push(msg);
});
await adapter.publish("ch", "hi");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(received).toContain("hi");
await unsubscribe();
await adapter.publish("ch", "ignored");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(received).not.toContain("ignored");
});
test("multiple handlers on a single channel all fire and tear down independently", async () => {
const adapter = track(buildAdapter());
const a: string[] = [];
const b: string[] = [];
const stopA = await adapter.subscribe("ch", (m) => {
a.push(m);
});
await adapter.subscribe("ch", (m) => {
b.push(m);
});
await adapter.publish("ch", "m1");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(a).toEqual(["m1"]);
expect(b).toEqual(["m1"]);
await stopA();
await adapter.publish("ch", "m2");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(a).toEqual(["m1"]);
expect(b).toEqual(["m1", "m2"]);
});
test("handler errors are swallowed so remaining subscribers still receive the message", async () => {
const { adapter } = buildLifecycleHarness();
track(adapter);
const received: string[] = [];
await adapter.subscribe("ch", () => {
throw new Error("subscriber failed");
});
await adapter.subscribe("ch", async () => {
throw new Error("async subscriber failed");
});
await adapter.subscribe("ch", (message) => {
received.push(message);
});
expect(await adapter.publish("ch", "still-delivered")).toBe(1);
await waitForFanout();
expect(received).toEqual(["still-delivered"]);
});
});
describe("RedisAdapter — eval", () => {
test("runs and caches lua scripts", async () => {
const adapter = track(buildAdapter());
const script = "return ARGV[1]";
const result = await adapter.eval(script, [], ["hello"]);
expect(String(result)).toBe("hello");
// Second invocation hits the SHA cache.
const cached = await adapter.eval(script, [], ["world"]);
expect(String(cached)).toBe("world");
});
});
describe("RedisAdapter — lifecycle", () => {
test("close blocks further operations", async () => {
const adapter = buildAdapter();
await adapter.close();
await expect(adapter.get("k")).rejects.toThrow(/closed/);
});
test("ping returns true when the client is healthy", async () => {
const adapter = track(buildAdapter());
expect(await adapter.ping()).toBe(true);
});
test("onReconnect ignores the initial ready event and fires on later ready events", async () => {
const { adapter, primary } = buildLifecycleHarness();
track(adapter);
const reconnects: string[] = [];
adapter.onReconnect(() => {
reconnects.push("reconnected");
});
primary.emit("ready");
await waitForFanout();
expect(reconnects).toEqual([]);
primary.emit("ready");
await waitForFanout();
expect(reconnects).toEqual(["reconnected"]);
});
test("onReconnect treats an already-ready client as connected when registered", async () => {
const { adapter, primary } = buildLifecycleHarness("ready");
track(adapter);
let reconnects = 0;
adapter.onReconnect(() => {
reconnects += 1;
});
primary.emit("ready");
await waitForFanout();
expect(reconnects).toBe(1);
});
test("onReconnect swallows handler errors and continues fanout", async () => {
const { adapter, primary } = buildLifecycleHarness("ready");
track(adapter);
const calls: string[] = [];
adapter.onReconnect(() => {
calls.push("bad");
throw new Error("reconnect handler failed");
});
adapter.onReconnect(async () => {
calls.push("async-bad");
throw new Error("async reconnect handler failed");
});
adapter.onReconnect(() => {
calls.push("good");
});
primary.emit("ready");
await waitForFanout();
expect(calls).toEqual(["bad", "async-bad", "good"]);
});
test("close prevents later subscriber events and reconnect events from fanning out", async () => {
const { adapter, primary, subscriber } = buildLifecycleHarness("ready");
const received: string[] = [];
let reconnects = 0;
await adapter.subscribe("ch", (message) => {
received.push(message);
});
adapter.onReconnect(() => {
reconnects += 1;
});
await adapter.close();
subscriber.emit("message", "ch", "ignored");
primary.emit("ready");
await waitForFanout();
expect(received).toEqual([]);
expect(reconnects).toBe(0);
});
});
@@ -0,0 +1,307 @@
import { Redis, type RedisOptions } from "ioredis";
import type { SharedStoreAdapter, SharedStoreScanOptions, SharedStoreSetOptions } from "./adapter.js";
export interface RedisAdapterOptions {
/** Redis connection URL, e.g. `redis://host:6379`. */
redisUrl?: string;
/** Pre-built ioredis options (test injection or advanced TLS setups). */
redisOptions?: RedisOptions;
/** Logical DB index for KV state. Defaults to 1 to stay clear of BullMQ (0). */
db?: number;
/** Optional factory that returns a Redis client. Used by tests with ioredis-mock. */
clientFactory?: (role: "primary" | "subscriber" | "publisher") => Redis;
/** Connection-establish timeout in ms. */
connectionTimeout?: number;
/** Per-command timeout in ms. */
commandTimeout?: number;
}
const DEFAULT_CONNECTION_TIMEOUT_MS = 5_000;
const DEFAULT_COMMAND_TIMEOUT_MS = 2_000;
/**
* Redis-backed implementation of `SharedStoreAdapter`. Owns three
* ioredis clients:
* - `primary` — commands and reads
* - `subscriber` — receives pub/sub messages (ioredis forbids issuing
* other commands on a subscribed client)
* - `publisher` — sends pub/sub messages; reused from `primary` when
* no custom factory is supplied, otherwise a dedicated client so
* mocks behave predictably.
*/
export class RedisAdapter implements SharedStoreAdapter {
private readonly primary: Redis;
private readonly subscriber: Redis;
private readonly publisher: Redis;
private readonly handlers = new Map<string, Set<(message: string) => void | Promise<void>>>();
private readonly scriptShaCache = new Map<string, string>();
private readonly reconnectHandlers = new Set<() => void | Promise<void>>();
private subscriberWired = false;
private reconnectWired = false;
private hasConnectedOnce = false;
private closed = false;
constructor(options: RedisAdapterOptions = {}) {
const factory = options.clientFactory ?? ((role) => buildClient(role, options));
this.primary = factory("primary");
this.subscriber = factory("subscriber");
this.publisher = factory("publisher");
}
async get(key: string): Promise<string | null> {
this.assertOpen();
return this.primary.get(key);
}
async set(key: string, value: string, opts: SharedStoreSetOptions = {}): Promise<boolean> {
this.assertOpen();
const args: (string | number)[] = [key, value];
if (typeof opts.ttlSeconds === "number" && opts.ttlSeconds > 0) {
args.push("EX", opts.ttlSeconds);
}
if (opts.nx) {
args.push("NX");
}
// ioredis returns null when NX prevents the write, "OK" otherwise.
const result = await (this.primary as unknown as { set: (...a: unknown[]) => Promise<string | null> }).set(...args);
return result === "OK";
}
async del(key: string): Promise<number> {
this.assertOpen();
return this.primary.del(key);
}
async incr(key: string, by = 1): Promise<number> {
this.assertOpen();
if (by === 1) {
return this.primary.incr(key);
}
return this.primary.incrby(key, by);
}
async expire(key: string, ttlSeconds: number): Promise<boolean> {
this.assertOpen();
const result = await this.primary.expire(key, ttlSeconds);
return result === 1;
}
async mget(keys: string[]): Promise<(string | null)[]> {
this.assertOpen();
if (keys.length === 0) return [];
return this.primary.mget(keys);
}
scan(pattern: string, opts: SharedStoreScanOptions = {}): AsyncIterable<string> {
this.assertOpen();
const client = this.primary;
const count = opts.count ?? 100;
return {
[Symbol.asyncIterator]: () => {
let cursor = "0";
let buffer: string[] = [];
let exhausted = false;
return {
async next(): Promise<IteratorResult<string>> {
while (buffer.length === 0 && !exhausted) {
const [nextCursor, keys] = (await client.scan(cursor, "MATCH", pattern, "COUNT", count)) as [
string,
string[],
];
buffer = keys;
cursor = nextCursor;
if (cursor === "0") {
exhausted = true;
}
}
if (buffer.length === 0) {
return { value: undefined as unknown as string, done: true };
}
const value = buffer.shift() as string;
return { value, done: false };
},
};
},
};
}
async publish(channel: string, message: string): Promise<number> {
this.assertOpen();
return this.publisher.publish(channel, message);
}
async subscribe(channel: string, handler: (message: string) => void | Promise<void>): Promise<() => Promise<void>> {
this.assertOpen();
this.wireSubscriber();
let handlers = this.handlers.get(channel);
if (!handlers) {
handlers = new Set();
this.handlers.set(channel, handlers);
await this.subscriber.subscribe(channel);
}
handlers.add(handler);
return async () => {
const set = this.handlers.get(channel);
if (!set) return;
set.delete(handler);
if (set.size === 0) {
this.handlers.delete(channel);
if (!this.closed) {
await this.subscriber.unsubscribe(channel);
}
}
};
}
async zAdd(key: string, score: number, member: string): Promise<number> {
this.assertOpen();
return this.primary.zadd(key, score, member);
}
async zRemRangeByScore(key: string, min: number, max: number): Promise<number> {
this.assertOpen();
return this.primary.zremrangebyscore(key, min, max);
}
async zCard(key: string): Promise<number> {
this.assertOpen();
return this.primary.zcard(key);
}
async eval(script: string, keys: string[], args: string[]): Promise<unknown> {
this.assertOpen();
let sha = this.scriptShaCache.get(script);
if (!sha) {
try {
sha = (await this.primary.script("LOAD", script)) as string;
this.scriptShaCache.set(script, sha);
} catch {
// SCRIPT LOAD is not supported on every backend (ioredis-mock).
// Fall back to raw EVAL; subsequent calls will retry the load
// so production Redis still benefits from the SHA cache.
return await this.primary.eval(script, keys.length, ...keys, ...args);
}
}
try {
return await this.primary.evalsha(sha, keys.length, ...keys, ...args);
} catch (error) {
if (error instanceof Error && /NOSCRIPT/.test(error.message)) {
this.scriptShaCache.delete(script);
return await this.primary.eval(script, keys.length, ...keys, ...args);
}
throw error;
}
}
/** Health probe. Not part of `SharedStoreAdapter`; used by #38 wiring. */
async ping(): Promise<boolean> {
this.assertOpen();
const result = await this.primary.ping();
return result === "PONG";
}
onReconnect(handler: () => void | Promise<void>): () => void {
this.assertOpen();
this.wireReconnectListener();
this.reconnectHandlers.add(handler);
return () => {
this.reconnectHandlers.delete(handler);
};
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.handlers.clear();
this.reconnectHandlers.clear();
await Promise.allSettled([
safeQuit(this.subscriber),
safeQuit(this.publisher),
safeQuit(this.primary),
]);
}
private assertOpen(): void {
if (this.closed) {
throw new Error("shared-store redis adapter is closed");
}
}
private wireSubscriber(): void {
if (this.subscriberWired) return;
this.subscriber.on("message", (channel: string, message: string) => {
const handlers = this.handlers.get(channel);
if (!handlers) return;
for (const handler of handlers) {
Promise.resolve()
.then(() => handler(message))
.catch(() => {
/* swallowed; consumers handle their own errors */
});
}
});
this.subscriberWired = true;
}
/**
* Wire a single `'ready'` listener onto the primary ioredis client.
* The first `'ready'` after construction is treated as the initial
* connect and ignored — only subsequent ones (after a `'reconnecting'`
* cycle) fan out to registered handlers. Errors from handlers are
* swallowed so a single bad handler cannot poison the others.
*/
private wireReconnectListener(): void {
if (this.reconnectWired) return;
this.reconnectWired = true;
const onReady = () => {
if (!this.hasConnectedOnce) {
this.hasConnectedOnce = true;
return;
}
if (this.closed) return;
for (const handler of this.reconnectHandlers) {
Promise.resolve()
.then(() => handler())
.catch(() => {
/* swallowed; handler owns its own error reporting */
});
}
};
this.primary.on("ready", onReady);
// ioredis sets status to "ready" only after the first successful
// handshake. When `lazyConnect: true` is used, the client may not
// have connected yet at the time onReconnect is first called — in
// that case the first emitted "ready" is the initial connect and is
// correctly skipped by the `hasConnectedOnce` latch above. When the
// client is already ready at registration time, mark the latch so
// the next "ready" (after a disconnect) is the one that fires.
const status = (this.primary as unknown as { status?: string }).status;
if (status === "ready") {
this.hasConnectedOnce = true;
}
}
}
function buildClient(_role: "primary" | "subscriber" | "publisher", options: RedisAdapterOptions): Redis {
const baseOptions: RedisOptions = {
db: options.db ?? 1,
connectTimeout: options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS,
commandTimeout: options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT_MS,
enableReadyCheck: true,
lazyConnect: true,
maxRetriesPerRequest: null,
...options.redisOptions,
};
if (options.redisUrl) {
return new Redis(options.redisUrl, baseOptions);
}
return new Redis(baseOptions);
}
async function safeQuit(client: Redis): Promise<void> {
try {
await client.quit();
} catch {
client.disconnect();
}
}
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { MemoryAdapter } from "./adapters/memory.js";
import { __resetMemoryWarningForTests, createSharedStore } from "./factory.js";
afterEach(() => {
vi.unstubAllEnvs();
__resetMemoryWarningForTests();
});
beforeEach(() => {
vi.unstubAllEnvs();
});
describe("createSharedStore — adapter selection", () => {
test("falls back to MemoryAdapter when REDIS_URL is unset and warns once", async () => {
vi.stubEnv("REDIS_URL", "");
const warn = vi.fn();
const store = createSharedStore({ namespace: "demo", logger: { warn } });
expect(store.adapter).toBeInstanceOf(MemoryAdapter);
expect(warn).toHaveBeenCalledTimes(1);
// A second store does not emit another warning.
createSharedStore({ namespace: "demo2", logger: { warn } });
expect(warn).toHaveBeenCalledTimes(1);
await store.close();
});
test("uses the provided adapter when one is supplied", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "demo", adapter });
expect(store.adapter).toBe(adapter);
await store.close();
});
});
describe("createSharedStore — namespace and key validation", () => {
test("rejects an empty namespace", () => {
expect(() => createSharedStore({ namespace: "", adapter: new MemoryAdapter() })).toThrow(/namespace is required/);
});
test("rejects namespaces with forbidden characters", () => {
expect(() => createSharedStore({ namespace: "bad name", adapter: new MemoryAdapter() })).toThrow(/must match/);
});
test("rejects key suffixes containing pattern characters", async () => {
const store = createSharedStore({ namespace: "demo", adapter: new MemoryAdapter() });
await expect(store.set("key*1", "x")).rejects.toThrow(/forbidden pattern characters/);
await expect(store.get("")).rejects.toThrow(/must not be empty/);
await store.close();
});
test("rejects channels containing pattern characters", async () => {
const store = createSharedStore({ namespace: "demo", adapter: new MemoryAdapter() });
await expect(store.publish("ch*", "x")).rejects.toThrow(/forbidden characters/);
await store.close();
});
test("rejects ttlDefault <= 0", () => {
expect(() =>
createSharedStore({ namespace: "demo", adapter: new MemoryAdapter(), ttlDefault: 0 }),
).toThrow(/positive/);
});
});
describe("createSharedStore — namespace isolation", () => {
test("two stores on the same adapter cannot read each other's keys", async () => {
const adapter = new MemoryAdapter();
const a = createSharedStore({ namespace: "alpha", adapter });
const b = createSharedStore({ namespace: "beta", adapter });
await a.set("k", "from-alpha");
expect(await b.get("k")).toBeNull();
expect(await a.get("k")).toBe("from-alpha");
await a.close();
});
test("scan only returns keys inside the namespace", async () => {
const adapter = new MemoryAdapter();
const a = createSharedStore({ namespace: "alpha", adapter });
const b = createSharedStore({ namespace: "beta", adapter });
await a.set("k1", "1");
await a.set("k2", "2");
await b.set("k1", "x");
const seen: string[] = [];
for await (const key of a.scan("*")) {
seen.push(key);
}
expect(seen.sort()).toEqual(["shared-store:alpha:k1", "shared-store:alpha:k2"]);
await a.close();
});
test("pub/sub channels are namespace-scoped", async () => {
const adapter = new MemoryAdapter();
const a = createSharedStore({ namespace: "alpha", adapter });
const b = createSharedStore({ namespace: "beta", adapter });
const received: string[] = [];
await b.subscribe("invalidate", (msg) => {
received.push(msg);
});
expect(await a.publish("invalidate", "hi")).toBe(0);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(received).toEqual([]);
await a.close();
});
});
describe("createSharedStore — ttlDefault", () => {
test("applies default ttl when set is called without one", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
const store = createSharedStore({ namespace: "demo", adapter, ttlDefault: 5 });
await store.set("k", "v");
now = 10_000;
expect(await store.get("k")).toBeNull();
await store.close();
});
test("does not override an explicit ttl", async () => {
let now = 0;
const adapter = new MemoryAdapter({ now: () => now });
const store = createSharedStore({ namespace: "demo", adapter, ttlDefault: 1 });
await store.set("k", "v", { ttlSeconds: 60 });
now = 5_000;
expect(await store.get("k")).toBe("v");
await store.close();
});
});
describe("createSharedStore — adapter passthrough", () => {
test("incr, expire, del, sorted-set ops apply the namespace prefix", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "demo", adapter });
expect(await store.incr("c")).toBe(1);
expect(await store.expire("c", 60)).toBe(true);
expect(await store.zAdd("z", 1, "a")).toBe(1);
expect(await store.zCard("z")).toBe(1);
expect(await store.zRemRangeByScore("z", 0, 1)).toBe(1);
expect(await store.del("c")).toBe(1);
expect(await store.mget(["c"])).toEqual([null]);
await store.close();
});
});
@@ -0,0 +1,171 @@
import type { SharedStoreAdapter, SharedStoreScanOptions, SharedStoreSetOptions } from "./adapters/adapter.js";
import { MemoryAdapter } from "./adapters/memory.js";
import { RedisAdapter, type RedisAdapterOptions } from "./adapters/redis.js";
/**
* Minimal logger shape consumed by the factory. Matches the pino-based
* `ToolctrlLogger` shape so `@toolctrl/logger` can be passed directly,
* without forcing this package to depend on it at runtime.
*/
export interface SharedStoreLogger {
warn(obj: Record<string, unknown> | string, msg?: string): void;
info?(obj: Record<string, unknown> | string, msg?: string): void;
}
export interface CreateSharedStoreOptions {
/**
* Namespace prefix. Every key passes through `shared-store:<namespace>:<key>`
* so different consumers cannot collide. Required.
*/
namespace: string;
/** Default TTL in seconds applied to `set` calls that omit `ttlSeconds`. */
ttlDefault?: number;
/**
* Redis connection URL. When omitted, the factory checks
* `process.env.REDIS_URL`. If still undefined, the in-memory adapter
* is used and a warning is emitted.
*/
redisUrl?: string;
/** Override the Redis logical DB index (default 1). */
redisDbIndex?: number;
/** Optional logger; defaults to a no-op so the package stays leaf-free. */
logger?: SharedStoreLogger;
/** Test seam: inject a pre-built adapter, bypassing auto-selection. */
adapter?: SharedStoreAdapter;
/** Advanced ioredis-options forwarded to the RedisAdapter. */
redisAdapterOptions?: Omit<RedisAdapterOptions, "redisUrl" | "db">;
}
/**
* Bound, namespaced facade over a `SharedStoreAdapter`. Helpers receive a
* `SharedStore` (not the raw adapter) so prefix-rules are enforced in one
* place and key-injection via `*` / `:` is rejected up front.
*/
export interface SharedStore {
readonly namespace: string;
readonly ttlDefault: number | undefined;
readonly adapter: SharedStoreAdapter;
key(suffix: string): string;
get(suffix: string): Promise<string | null>;
set(suffix: string, value: string, opts?: SharedStoreSetOptions): Promise<boolean>;
del(suffix: string): Promise<number>;
incr(suffix: string, by?: number): Promise<number>;
expire(suffix: string, ttlSeconds: number): Promise<boolean>;
mget(suffixes: string[]): Promise<(string | null)[]>;
scan(pattern: string, opts?: SharedStoreScanOptions): AsyncIterable<string>;
publish(channel: string, message: string): Promise<number>;
subscribe(channel: string, handler: (message: string) => void | Promise<void>): Promise<() => Promise<void>>;
zAdd(suffix: string, score: number, member: string): Promise<number>;
zRemRangeByScore(suffix: string, min: number, max: number): Promise<number>;
zCard(suffix: string): Promise<number>;
eval(script: string, keys: string[], args: string[]): Promise<unknown>;
/**
* Register a handler that fires after the backing store reconnects.
* No-op on the in-process memory adapter. See
* `SharedStoreAdapter.onReconnect` for semantics.
*/
onReconnect(handler: () => void | Promise<void>): () => void;
close(): Promise<void>;
}
const NAMESPACE_PREFIX = "shared-store";
const INVALID_KEY_CHARS = /[*?[\]]/;
let memoryWarningEmitted = false;
/**
* Build a `SharedStore` instance. Picks the adapter automatically based
* on `REDIS_URL` unless `adapter` is supplied (used by tests). Emits a
* single warning (per process) when running in memory mode so the
* runtime is auditable.
*/
export function createSharedStore(options: CreateSharedStoreOptions): SharedStore {
validateNamespace(options.namespace);
if (options.ttlDefault !== undefined && options.ttlDefault <= 0) {
throw new Error("createSharedStore: ttlDefault must be a positive number of seconds");
}
const adapter = options.adapter ?? buildAdapter(options);
const prefix = `${NAMESPACE_PREFIX}:${options.namespace}:`;
const channelPrefix = `${NAMESPACE_PREFIX}:${options.namespace}:ch:`;
const ensureSuffix = (suffix: string): string => {
if (suffix.length === 0) {
throw new Error("shared-store key suffix must not be empty");
}
if (INVALID_KEY_CHARS.test(suffix)) {
throw new Error(`shared-store key suffix contains forbidden pattern characters: ${suffix}`);
}
return `${prefix}${suffix}`;
};
const ensureChannel = (channel: string): string => {
if (channel.length === 0) {
throw new Error("shared-store channel must not be empty");
}
if (INVALID_KEY_CHARS.test(channel)) {
throw new Error(`shared-store channel contains forbidden characters: ${channel}`);
}
return `${channelPrefix}${channel}`;
};
const applyDefaultTtl = (opts: SharedStoreSetOptions | undefined): SharedStoreSetOptions | undefined => {
if (options.ttlDefault === undefined) return opts;
if (!opts) return { ttlSeconds: options.ttlDefault };
if (typeof opts.ttlSeconds === "number") return opts;
return { ...opts, ttlSeconds: options.ttlDefault };
};
return {
namespace: options.namespace,
ttlDefault: options.ttlDefault,
adapter,
key: ensureSuffix,
get: async (suffix) => adapter.get(ensureSuffix(suffix)),
set: async (suffix, value, opts) => adapter.set(ensureSuffix(suffix), value, applyDefaultTtl(opts)),
del: async (suffix) => adapter.del(ensureSuffix(suffix)),
incr: async (suffix, by) => adapter.incr(ensureSuffix(suffix), by),
expire: async (suffix, ttlSeconds) => adapter.expire(ensureSuffix(suffix), ttlSeconds),
mget: async (suffixes) => adapter.mget(suffixes.map(ensureSuffix)),
scan: (pattern, scanOpts) => adapter.scan(`${prefix}${pattern}`, scanOpts),
publish: async (channel, message) => adapter.publish(ensureChannel(channel), message),
subscribe: async (channel, handler) => adapter.subscribe(ensureChannel(channel), handler),
zAdd: async (suffix, score, member) => adapter.zAdd(ensureSuffix(suffix), score, member),
zRemRangeByScore: async (suffix, min, max) => adapter.zRemRangeByScore(ensureSuffix(suffix), min, max),
zCard: async (suffix) => adapter.zCard(ensureSuffix(suffix)),
eval: async (script, keys, args) => adapter.eval(script, keys.map(ensureSuffix), args),
onReconnect: (handler) => adapter.onReconnect(handler),
close: async () => adapter.close(),
};
}
function validateNamespace(namespace: string): void {
if (!namespace || namespace.length === 0) {
throw new Error("createSharedStore: namespace is required");
}
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(namespace)) {
throw new Error(`createSharedStore: namespace must match /^[a-z0-9][a-z0-9_-]*$/i, got "${namespace}"`);
}
}
function buildAdapter(options: CreateSharedStoreOptions): SharedStoreAdapter {
const redisUrl = options.redisUrl ?? process.env.REDIS_URL;
if (redisUrl) {
return new RedisAdapter({
...options.redisAdapterOptions,
redisUrl,
db: options.redisDbIndex ?? 1,
});
}
if (!memoryWarningEmitted) {
memoryWarningEmitted = true;
options.logger?.warn?.(
{ component: "shared-store", namespace: options.namespace },
"shared-store: running in memory mode, multi-replica unsafe",
);
}
return new MemoryAdapter();
}
/** Test-only hook: reset the one-shot warning latch. */
export function __resetMemoryWarningForTests(): void {
memoryWarningEmitted = false;
}
@@ -0,0 +1,123 @@
import { describe, expect, test } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createCacheStore } from "./cache.js";
interface User {
id: string;
name: string;
}
function buildCache(opts: { ttlSeconds?: number; now?: () => number } = {}) {
const adapter = new MemoryAdapter({ now: opts.now });
const store = createSharedStore({ namespace: "demo-cache", adapter });
const cache = createCacheStore<User>({ store, prefix: "users", ttlSeconds: opts.ttlSeconds ?? 60 });
return { store, cache };
}
describe("createCacheStore", () => {
test("set then get round-trips structured values", async () => {
const { store, cache } = buildCache();
await cache.set("u1", { id: "u1", name: "Alice" });
expect(await cache.get("u1")).toEqual({ id: "u1", name: "Alice" });
await store.close();
});
test("returns null for missing keys", async () => {
const { store, cache } = buildCache();
expect(await cache.get("missing")).toBeNull();
await store.close();
});
test("respects ttl", async () => {
let now = 0;
const { store, cache } = buildCache({ ttlSeconds: 1, now: () => now });
await cache.set("u1", { id: "u1", name: "A" });
now += 2_000;
expect(await cache.get("u1")).toBeNull();
await store.close();
});
test("delete removes a single entry", async () => {
const { store, cache } = buildCache();
await cache.set("u1", { id: "u1", name: "A" });
await cache.delete("u1");
expect(await cache.get("u1")).toBeNull();
await store.close();
});
test("corrupted entries are dropped on get instead of throwing", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "demo-cache", adapter });
const cache = createCacheStore<User>({ store, prefix: "users", ttlSeconds: 60 });
await store.set("cache:users:u1", "{not json"); // raw corrupt write
expect(await cache.get("u1")).toBeNull();
expect(await store.get("cache:users:u1")).toBeNull();
await store.close();
});
test("rejects ttl <= 0", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "demo-cache", adapter });
expect(() => createCacheStore({ store, prefix: "x", ttlSeconds: 0 })).toThrow(/ttlSeconds/);
});
test("custom serializer round-trips values", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "demo-cache", adapter });
const cache = createCacheStore<number>({
store,
prefix: "nums",
ttlSeconds: 60,
serializer: { serialize: (v) => `n:${v}`, deserialize: (raw) => Number(raw.slice(2)) },
});
await cache.set("a", 42);
expect(await cache.get("a")).toBe(42);
await store.close();
});
});
describe("createCacheStore — invalidation pub/sub", () => {
test("invalidate publishes the key on the dedicated channel and clears the local entry", async () => {
const { store, cache } = buildCache();
const received: string[] = [];
const stop = await cache.onInvalidate((key) => {
received.push(key);
});
await cache.set("u1", { id: "u1", name: "A" });
await cache.invalidate("u1");
await new Promise((resolve) => setTimeout(resolve, 5));
expect(received).toEqual(["u1"]);
expect(await cache.get("u1")).toBeNull();
await stop();
await store.close();
});
test("invalidate() without key broadcasts a wildcard", async () => {
const { store, cache } = buildCache();
const received: string[] = [];
await cache.onInvalidate((key) => {
received.push(key);
});
await cache.invalidate();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(received).toEqual(["*"]);
await store.close();
});
test("two caches sharing the adapter receive each other's invalidations", async () => {
const adapter = new MemoryAdapter();
const storeA = createSharedStore({ namespace: "demo-cache", adapter });
const storeB = createSharedStore({ namespace: "demo-cache", adapter });
const a = createCacheStore<User>({ store: storeA, prefix: "users", ttlSeconds: 60 });
const b = createCacheStore<User>({ store: storeB, prefix: "users", ttlSeconds: 60 });
const received: string[] = [];
await b.onInvalidate((key) => {
received.push(key);
});
await a.invalidate("u1");
await new Promise((resolve) => setTimeout(resolve, 5));
expect(received).toEqual(["u1"]);
await storeA.close();
});
});
@@ -0,0 +1,89 @@
import type { SharedStore } from "../factory.js";
export interface CacheSerializer<T> {
serialize(value: T): string;
deserialize(raw: string): T;
}
export interface CacheStoreOptions<T> {
store: SharedStore;
prefix: string;
ttlSeconds: number;
serializer?: CacheSerializer<T>;
/**
* Channel suffix used for cross-instance invalidation broadcasts.
* Defaults to `"invalidate"`.
*/
invalidationChannel?: string;
}
export interface CacheStore<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
/**
* Publish an invalidation event so peers can drop their local memo.
* Pass a specific key to invalidate one entry, or `"*"` for the whole
* prefix. The local entry is also deleted when a key is supplied.
*/
invalidate(key?: string): Promise<void>;
/**
* Subscribe to invalidation events. The handler receives the key (or
* `"*"`). Returns an async disposer.
*/
onInvalidate(handler: (key: string) => void | Promise<void>): Promise<() => Promise<void>>;
}
const defaultJsonSerializer = <T>(): CacheSerializer<T> => ({
serialize: (value) => JSON.stringify(value),
deserialize: (raw) => JSON.parse(raw) as T,
});
/**
* JSON-cached values keyed under `cache:<prefix>:<key>`. The cache is
* paired with a pub/sub channel so peer replicas can be notified when
* an entry must be invalidated (e.g. after a Stripe webhook or admin
* edit). Cache misses cost one Redis round-trip; invalidation is a
* single PUBLISH.
*/
export function createCacheStore<T>(options: CacheStoreOptions<T>): CacheStore<T> {
if (options.ttlSeconds <= 0) {
throw new Error("createCacheStore: ttlSeconds must be > 0");
}
const serializer = options.serializer ?? defaultJsonSerializer<T>();
const store = options.store;
const channel = options.invalidationChannel ?? "invalidate";
const bucketKey = (key: string) => `cache:${options.prefix}:${key}`;
const channelName = `cache:${options.prefix}:${channel}`;
return {
async get(key) {
const raw = await store.get(bucketKey(key));
if (raw === null) return null;
try {
return serializer.deserialize(raw);
} catch {
// Corrupted entries are dropped rather than thrown so a single
// bad value cannot poison the cache forever.
await store.del(bucketKey(key));
return null;
}
},
async set(key, value) {
await store.set(bucketKey(key), serializer.serialize(value), { ttlSeconds: options.ttlSeconds });
},
async delete(key) {
await store.del(bucketKey(key));
},
async invalidate(key) {
const payload = key ?? "*";
if (key && key !== "*") {
await store.del(bucketKey(key));
}
await store.publish(channelName, payload);
},
async onInvalidate(handler) {
return store.subscribe(channelName, (msg) => handler(msg));
},
};
}
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createIdempotencyStore } from "./idempotency.js";
function build(opts: { ttlSeconds?: number; now?: () => number } = {}) {
const adapter = new MemoryAdapter({ now: opts.now });
const store = createSharedStore({ namespace: "idemp", adapter });
const idempotency = createIdempotencyStore({ store, prefix: "stripe", ttlSeconds: opts.ttlSeconds ?? 60 });
return { store, idempotency };
}
describe("createIdempotencyStore", () => {
test("first claim wins, second loses", async () => {
const { store, idempotency } = build();
expect(await idempotency.claim("k")).toBe(true);
expect(await idempotency.claim("k")).toBe(false);
await store.close();
});
test("claim then get returns pending state", async () => {
const { store, idempotency } = build();
await idempotency.claim("k");
expect(await idempotency.get("k")).toEqual({ state: "pending" });
await store.close();
});
test("complete persists the result", async () => {
const { store, idempotency } = build();
await idempotency.claim("k");
await idempotency.complete("k", { ok: true, value: 42 });
expect(await idempotency.get("k")).toEqual({ state: "complete", result: { ok: true, value: 42 } });
await store.close();
});
test("after ttl elapses a new claim is allowed", async () => {
let now = 0;
const { store, idempotency } = build({ ttlSeconds: 1, now: () => now });
expect(await idempotency.claim("k")).toBe(true);
now += 2_000;
expect(await idempotency.claim("k")).toBe(true);
await store.close();
});
test("complete also resets the ttl", async () => {
let now = 0;
const { store, idempotency } = build({ ttlSeconds: 5, now: () => now });
await idempotency.claim("k");
now += 4_000;
await idempotency.complete("k", "done");
now += 4_000;
expect(await idempotency.get("k")).toEqual({ state: "complete", result: "done" });
await store.close();
});
test("forget drops the key", async () => {
const { store, idempotency } = build();
await idempotency.claim("k");
await idempotency.forget("k");
expect(await idempotency.get("k")).toBeNull();
expect(await idempotency.claim("k")).toBe(true);
await store.close();
});
test("corrupt completed payload is evicted on read", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "idemp", adapter });
const idempotency = createIdempotencyStore({ store, prefix: "stripe", ttlSeconds: 60 });
await store.set("idempotency:stripe:k", "not-json");
expect(await idempotency.get("k")).toBeNull();
expect(await store.get("idempotency:stripe:k")).toBeNull();
await store.close();
});
test("rejects invalid ttl", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "idemp", adapter });
expect(() => createIdempotencyStore({ store, prefix: "x", ttlSeconds: 0 })).toThrow(/ttlSeconds/);
});
test("get on a never-claimed key returns null", async () => {
const { store, idempotency } = build();
expect(await idempotency.get("missing")).toBeNull();
await store.close();
});
test("concurrent claims across many keys are independent", async () => {
const { store, idempotency } = build();
const results = await Promise.all(
Array.from({ length: 25 }, (_, idx) => idempotency.claim(`k-${idx}`)),
);
expect(results.every((r) => r)).toBe(true);
await store.close();
});
});
@@ -0,0 +1,66 @@
import type { SharedStore } from "../factory.js";
export interface IdempotencyStoreOptions {
store: SharedStore;
prefix: string;
ttlSeconds: number;
}
export type IdempotencyEntry =
| { state: "pending"; result?: undefined }
| { state: "complete"; result: unknown };
export interface IdempotencyStore {
/**
* Try to claim the key atomically. Returns `true` if this caller now
* owns the work, `false` if another caller already claimed it.
*/
claim(key: string): Promise<boolean>;
/** Mark the key as complete and persist the result. */
complete(key: string, result: unknown): Promise<void>;
/** Read the current state of a key, or `null` if it has not been claimed. */
get(key: string): Promise<IdempotencyEntry | null>;
/** Drop the key (rare — used by manual retries). */
forget(key: string): Promise<void>;
}
const PENDING_MARKER = "__pending__";
/**
* Idempotency-key store backed by SharedStore. `claim()` does a single
* `SET NX EX` round-trip so concurrent callers across replicas cannot
* both acquire the same key. `complete()` overwrites the placeholder
* with a JSON-encoded result and re-applies the TTL.
*/
export function createIdempotencyStore(options: IdempotencyStoreOptions): IdempotencyStore {
if (options.ttlSeconds <= 0) {
throw new Error("createIdempotencyStore: ttlSeconds must be > 0");
}
const store = options.store;
const keyFor = (key: string) => `idempotency:${options.prefix}:${key}`;
return {
async claim(key) {
return store.set(keyFor(key), PENDING_MARKER, { nx: true, ttlSeconds: options.ttlSeconds });
},
async complete(key, result) {
const payload = JSON.stringify({ result });
await store.set(keyFor(key), payload, { ttlSeconds: options.ttlSeconds });
},
async get(key) {
const raw = await store.get(keyFor(key));
if (raw === null) return null;
if (raw === PENDING_MARKER) return { state: "pending" };
try {
const parsed = JSON.parse(raw) as { result: unknown };
return { state: "complete", result: parsed.result };
} catch {
await store.del(keyFor(key));
return null;
}
},
async forget(key) {
await store.del(keyFor(key));
},
};
}
@@ -0,0 +1,25 @@
export {
createFixedWindowRateLimiter,
type FixedWindowDecision,
type FixedWindowRateLimiter,
type FixedWindowRateLimiterOptions,
} from "./rate-limit-fixed.js";
export {
createSlidingWindowRateLimiter,
type SlidingWindowDecision,
type SlidingWindowRateLimiter,
type SlidingWindowRateLimiterOptions,
} from "./rate-limit-sliding.js";
export {
createCacheStore,
type CacheStore,
type CacheStoreOptions,
type CacheSerializer,
} from "./cache.js";
export { createSessionStore, type SessionStore, type SessionStoreOptions } from "./session.js";
export {
createIdempotencyStore,
type IdempotencyStore,
type IdempotencyStoreOptions,
type IdempotencyEntry,
} from "./idempotency.js";
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createFixedWindowRateLimiter } from "./rate-limit-fixed.js";
afterEach(() => {
vi.useRealTimers();
});
function buildLimiter(opts: { windowSeconds?: number; max?: number; now?: () => number } = {}) {
const adapter = new MemoryAdapter({ now: opts.now });
const store = createSharedStore({ namespace: "ratelimit", adapter });
const limiter = createFixedWindowRateLimiter({
store,
prefix: "ip",
windowSeconds: opts.windowSeconds ?? 60,
max: opts.max ?? 3,
});
return { store, limiter };
}
describe("createFixedWindowRateLimiter", () => {
test("allows up to max calls in a window", async () => {
const { limiter, store } = buildLimiter();
expect((await limiter.consume("k")).allowed).toBe(true);
expect((await limiter.consume("k")).allowed).toBe(true);
const third = await limiter.consume("k");
expect(third.allowed).toBe(true);
expect(third.remaining).toBe(0);
const fourth = await limiter.consume("k");
expect(fourth.allowed).toBe(false);
expect(fourth.remaining).toBe(0);
await store.close();
});
test("isolates buckets by key", async () => {
const { limiter, store } = buildLimiter({ max: 1 });
expect((await limiter.consume("a")).allowed).toBe(true);
expect((await limiter.consume("b")).allowed).toBe(true);
expect((await limiter.consume("a")).allowed).toBe(false);
await store.close();
});
test("resets after the window expires", async () => {
let now = 1_000;
const { limiter, store } = buildLimiter({ windowSeconds: 1, max: 1, now: () => now });
expect((await limiter.consume("k")).allowed).toBe(true);
expect((await limiter.consume("k")).allowed).toBe(false);
now += 2_000;
expect((await limiter.consume("k")).allowed).toBe(true);
await store.close();
});
test("reset() drops the counter", async () => {
const { limiter, store } = buildLimiter({ max: 1 });
expect((await limiter.consume("k")).allowed).toBe(true);
expect((await limiter.consume("k")).allowed).toBe(false);
await limiter.reset("k");
expect((await limiter.consume("k")).allowed).toBe(true);
await store.close();
});
test("rejects invalid configuration", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "ratelimit", adapter });
expect(() =>
createFixedWindowRateLimiter({ store, prefix: "x", windowSeconds: 0, max: 1 }),
).toThrow(/windowSeconds/);
expect(() =>
createFixedWindowRateLimiter({ store, prefix: "x", windowSeconds: 1, max: 0 }),
).toThrow(/max/);
});
test("reports a sensible resetAt", async () => {
const { limiter, store } = buildLimiter({ windowSeconds: 30, max: 5 });
const decision = await limiter.consume("k");
const delta = decision.resetAt.getTime() - Date.now();
expect(delta).toBeGreaterThan(10_000);
expect(delta).toBeLessThanOrEqual(30_000);
await store.close();
});
});
@@ -0,0 +1,90 @@
import { MemoryAdapter } from "../adapters/memory.js";
import type { SharedStore } from "../factory.js";
export interface FixedWindowRateLimiterOptions {
store: SharedStore;
/** Sub-namespace for this limiter (e.g. `"login-ip"`). */
prefix: string;
/** Window length in seconds. */
windowSeconds: number;
/** Allowed actions per window. */
max: number;
}
export interface FixedWindowDecision {
/** True when the request is below the cap. */
allowed: boolean;
/** How many actions are still permitted in the current window. */
remaining: number;
/** Wall-clock time when the current window resets. */
resetAt: Date;
}
export interface FixedWindowRateLimiter {
consume(key: string): Promise<FixedWindowDecision>;
/** Test/admin helper: drop the counter for a key. */
reset(key: string): Promise<void>;
}
const FIXED_WINDOW_SCRIPT = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
local ttl = redis.call('TTL', KEYS[1])
return { current, ttl }
`;
// Register the memory-adapter equivalent so the same eval() call works
// against both backends. The memory adapter resolves scripts by exact
// string match.
MemoryAdapter.registerScript(FIXED_WINDOW_SCRIPT, async (adapter, keys, args) => {
const [key] = keys;
const window = Number.parseInt(args[0] ?? "0", 10);
const current = await adapter.incr(key);
if (current === 1 && window > 0) {
await adapter.expire(key, window);
}
// We do not implement TTL command on the adapter surface; the helper
// computes resetAt locally for the memory case.
return [current, window];
});
/**
* Create a Redis-style fixed-window rate limiter. The window starts on
* the first `consume()` call and resets after `windowSeconds`. The
* decision is computed atomically in Redis via Lua (and sequentially,
* race-free, in the memory adapter).
*/
export function createFixedWindowRateLimiter(options: FixedWindowRateLimiterOptions): FixedWindowRateLimiter {
if (options.windowSeconds <= 0) {
throw new Error("createFixedWindowRateLimiter: windowSeconds must be > 0");
}
if (options.max <= 0) {
throw new Error("createFixedWindowRateLimiter: max must be > 0");
}
const store = options.store;
const bucketKey = (key: string) => `rl:fixed:${options.prefix}:${key}`;
return {
async consume(key) {
const fullKey = store.key(bucketKey(key));
const result = (await store.adapter.eval(FIXED_WINDOW_SCRIPT, [fullKey], [String(options.windowSeconds)])) as
| [number | string, number | string]
| undefined;
const current = Number(result?.[0] ?? 0);
const ttlReported = Number(result?.[1] ?? options.windowSeconds);
const ttl = ttlReported > 0 ? ttlReported : options.windowSeconds;
const remaining = Math.max(0, options.max - current);
return {
allowed: current <= options.max,
remaining,
resetAt: new Date(Date.now() + ttl * 1000),
};
},
async reset(key) {
await store.del(bucketKey(key));
},
};
}
@@ -0,0 +1,73 @@
import { describe, expect, test } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createSlidingWindowRateLimiter } from "./rate-limit-sliding.js";
function buildLimiter(opts: { windowSeconds?: number; max?: number; now?: () => number } = {}) {
const adapter = new MemoryAdapter({ now: opts.now });
const store = createSharedStore({ namespace: "rl-sliding", adapter });
const limiter = createSlidingWindowRateLimiter({
store,
prefix: "ip",
windowSeconds: opts.windowSeconds ?? 1,
max: opts.max ?? 3,
now: opts.now,
});
return { store, limiter };
}
describe("createSlidingWindowRateLimiter", () => {
test("allows up to max calls inside a window", async () => {
let now = 1_000_000;
const { limiter, store } = buildLimiter({ windowSeconds: 10, max: 3, now: () => now });
expect((await limiter.consume("k")).allowed).toBe(true);
expect((await limiter.consume("k")).allowed).toBe(true);
const third = await limiter.consume("k");
expect(third.allowed).toBe(true);
expect(third.remaining).toBe(0);
expect((await limiter.consume("k")).allowed).toBe(false);
await store.close();
});
test("trims old entries as the window slides", async () => {
let now = 0;
const { limiter, store } = buildLimiter({ windowSeconds: 1, max: 2, now: () => now });
await limiter.consume("k");
await limiter.consume("k");
expect((await limiter.consume("k")).allowed).toBe(false);
now += 1_500;
expect((await limiter.consume("k")).allowed).toBe(true);
await store.close();
});
test("isolates per-key buckets", async () => {
const { limiter, store } = buildLimiter({ windowSeconds: 60, max: 1 });
expect((await limiter.consume("a")).allowed).toBe(true);
expect((await limiter.consume("b")).allowed).toBe(true);
expect((await limiter.consume("a")).allowed).toBe(false);
await store.close();
});
test("reset clears the bucket", async () => {
const { limiter, store } = buildLimiter({ windowSeconds: 60, max: 1 });
await limiter.consume("k");
expect((await limiter.consume("k")).allowed).toBe(false);
await limiter.reset("k");
expect((await limiter.consume("k")).allowed).toBe(true);
await store.close();
});
test("rejects invalid configuration", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "rl-sliding", adapter });
expect(() => createSlidingWindowRateLimiter({ store, prefix: "x", windowSeconds: 0, max: 1 })).toThrow(/windowSeconds/);
expect(() => createSlidingWindowRateLimiter({ store, prefix: "x", windowSeconds: 1, max: 0 })).toThrow(/max/);
});
test("returns oldestAt for the current consume call", async () => {
const { limiter, store } = buildLimiter({ windowSeconds: 60, max: 5 });
const decision = await limiter.consume("k");
expect(decision.oldestAt).not.toBeNull();
await store.close();
});
});
@@ -0,0 +1,107 @@
import { MemoryAdapter } from "../adapters/memory.js";
import type { SharedStore } from "../factory.js";
export interface SlidingWindowRateLimiterOptions {
store: SharedStore;
prefix: string;
windowSeconds: number;
max: number;
/** Override the clock (test only). Defaults to `Date.now`. */
now?: () => number;
}
export interface SlidingWindowDecision {
allowed: boolean;
remaining: number;
/** Timestamp of the oldest event inside the current window, if any. */
oldestAt: Date | null;
}
export interface SlidingWindowRateLimiter {
consume(key: string): Promise<SlidingWindowDecision>;
reset(key: string): Promise<void>;
}
// Lua script: trim old entries, count remaining, append the new one and
// extend the key TTL. Returns the count *after* the new entry was
// inserted.
const SLIDING_WINDOW_SCRIPT = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local member = ARGV[3]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
return { count + 1, oldest[2] or '' }
`;
MemoryAdapter.registerScript(SLIDING_WINDOW_SCRIPT, async (adapter, keys, args) => {
const [key] = keys;
const now = Number(args[0]);
const windowMs = Number(args[1]);
const member = args[2];
await adapter.zRemRangeByScore(key, 0, now - windowMs);
const before = await adapter.zCard(key);
await adapter.zAdd(key, now, member);
await adapter.expire(key, Math.ceil(windowMs / 1000) + 1);
return [before + 1, ""];
});
/**
* Sorted-set sliding-window limiter. Each `consume` records the current
* timestamp; entries older than `windowSeconds` are trimmed before the
* count is taken so the window slides continuously instead of resetting
* on a fixed boundary.
*/
export function createSlidingWindowRateLimiter(
options: SlidingWindowRateLimiterOptions,
): SlidingWindowRateLimiter {
if (options.windowSeconds <= 0) {
throw new Error("createSlidingWindowRateLimiter: windowSeconds must be > 0");
}
if (options.max <= 0) {
throw new Error("createSlidingWindowRateLimiter: max must be > 0");
}
const store = options.store;
const windowMs = options.windowSeconds * 1000;
const clock = options.now ?? (() => Date.now());
let seq = 0;
const bucketKey = (key: string) => `rl:sliding:${options.prefix}:${key}`;
return {
async consume(key) {
const now = clock();
seq += 1;
const member = `${now}:${seq}`;
const fullKey = store.key(bucketKey(key));
const result = (await store.adapter.eval(
SLIDING_WINDOW_SCRIPT,
[fullKey],
[String(now), String(windowMs), member],
)) as [number | string, string | number] | undefined;
const count = Number(result?.[0] ?? 0);
const oldestRaw = String(result?.[1] ?? "");
let oldestAt: Date | null = null;
if (oldestRaw) {
const parsed = Number(oldestRaw);
if (Number.isFinite(parsed)) oldestAt = new Date(parsed);
}
if (!oldestAt) {
// Memory adapter doesn't surface the oldest entry; approximate
// with the current timestamp which is the conservative answer.
oldestAt = new Date(now);
}
return {
allowed: count <= options.max,
remaining: Math.max(0, options.max - count),
oldestAt,
};
},
async reset(key) {
await store.del(bucketKey(key));
},
};
}
@@ -0,0 +1,82 @@
import { describe, expect, test } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createSessionStore } from "./session.js";
interface SessionData {
userId: string;
role: "admin" | "user";
}
function buildSession(opts: { ttlSeconds?: number; now?: () => number } = {}) {
const adapter = new MemoryAdapter({ now: opts.now });
const store = createSharedStore({ namespace: "session-test", adapter });
const sessions = createSessionStore<SessionData>({ store, prefix: "web", ttlSeconds: opts.ttlSeconds ?? 60 });
return { store, sessions };
}
describe("createSessionStore", () => {
test("set then get round-trips payload", async () => {
const { store, sessions } = buildSession();
await sessions.set("s1", { userId: "u1", role: "admin" });
expect(await sessions.get("s1")).toEqual({ userId: "u1", role: "admin" });
await store.close();
});
test("get returns null for missing sessions", async () => {
const { store, sessions } = buildSession();
expect(await sessions.get("missing")).toBeNull();
await store.close();
});
test("ttl elapses without touch", async () => {
let now = 0;
const { store, sessions } = buildSession({ ttlSeconds: 1, now: () => now });
await sessions.set("s1", { userId: "u1", role: "user" });
now += 2_000;
expect(await sessions.get("s1")).toBeNull();
await store.close();
});
test("touch refreshes the ttl", async () => {
let now = 0;
const { store, sessions } = buildSession({ ttlSeconds: 5, now: () => now });
await sessions.set("s1", { userId: "u1", role: "user" });
now += 4_000;
expect(await sessions.touch("s1")).toBe(true);
now += 4_000;
// Still alive because touch reset the 5s window.
expect(await sessions.get("s1")).not.toBeNull();
await store.close();
});
test("touch on missing session returns false", async () => {
const { store, sessions } = buildSession();
expect(await sessions.touch("nope")).toBe(false);
await store.close();
});
test("destroy removes the session", async () => {
const { store, sessions } = buildSession();
await sessions.set("s1", { userId: "u1", role: "user" });
await sessions.destroy("s1");
expect(await sessions.get("s1")).toBeNull();
await store.close();
});
test("corrupt payload is dropped on read", async () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "session-test", adapter });
const sessions = createSessionStore<SessionData>({ store, prefix: "web", ttlSeconds: 60 });
await store.set("session:web:s1", "not-json");
expect(await sessions.get("s1")).toBeNull();
expect(await store.get("session:web:s1")).toBeNull();
await store.close();
});
test("rejects invalid ttl", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "session-test", adapter });
expect(() => createSessionStore({ store, prefix: "x", ttlSeconds: 0 })).toThrow(/ttlSeconds/);
});
});
@@ -0,0 +1,50 @@
import type { SharedStore } from "../factory.js";
export interface SessionStoreOptions {
store: SharedStore;
prefix: string;
ttlSeconds: number;
}
export interface SessionStore<TData = unknown> {
get(sessionId: string): Promise<TData | null>;
set(sessionId: string, data: TData): Promise<void>;
destroy(sessionId: string): Promise<void>;
/** Reset the TTL on an existing session without rewriting payload. */
touch(sessionId: string): Promise<boolean>;
}
/**
* Session-data helper. Sessions are JSON-encoded and TTL'd; `touch`
* refreshes the TTL so active users do not get logged out mid-request.
* TTL is mandatory — sessions must never live forever in the cache.
*/
export function createSessionStore<TData = unknown>(options: SessionStoreOptions): SessionStore<TData> {
if (options.ttlSeconds <= 0) {
throw new Error("createSessionStore: ttlSeconds must be > 0");
}
const store = options.store;
const keyFor = (sessionId: string) => `session:${options.prefix}:${sessionId}`;
return {
async get(sessionId) {
const raw = await store.get(keyFor(sessionId));
if (raw === null) return null;
try {
return JSON.parse(raw) as TData;
} catch {
await store.del(keyFor(sessionId));
return null;
}
},
async set(sessionId, data) {
await store.set(keyFor(sessionId), JSON.stringify(data), { ttlSeconds: options.ttlSeconds });
},
async destroy(sessionId) {
await store.del(keyFor(sessionId));
},
async touch(sessionId) {
return store.expire(keyFor(sessionId), options.ttlSeconds);
},
};
}
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { MemoryAdapter } from "../adapters/memory.js";
import { createSharedStore } from "../factory.js";
import { createTwoTierCache } from "./two-tier-cache.js";
interface Value {
id: string;
name: string;
}
function buildPair(opts: { pubsubEnabled?: boolean } = {}) {
const adapter = new MemoryAdapter();
const storeA = createSharedStore({ namespace: "ns", adapter });
const storeB = createSharedStore({ namespace: "ns", adapter });
const a = createTwoTierCache<Value>({
store: storeA,
namespace: "subscription",
ttlSeconds: 60,
pubsubEnabled: opts.pubsubEnabled,
});
const b = createTwoTierCache<Value>({
store: storeB,
namespace: "subscription",
ttlSeconds: 60,
pubsubEnabled: opts.pubsubEnabled,
});
return { a, b, storeA, storeB };
}
async function flushPubSubMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("createTwoTierCache", () => {
afterEach(() => {
vi.useRealTimers();
});
test("set populates both tiers; get returns from L1 without hitting L2 a second time", async () => {
const { a, storeA } = buildPair();
await a.set("t1", { id: "t1", name: "Alpha" });
// L1 hit
expect(a.l1.get("t1")).toEqual({ id: "t1", name: "Alpha" });
// L2 hit
expect(await a.l2.get("t1")).toEqual({ id: "t1", name: "Alpha" });
expect(await a.get("t1")).toEqual({ id: "t1", name: "Alpha" });
await storeA.close();
});
test("get falls back to L2 on L1 miss and populates L1", async () => {
const { a, storeA } = buildPair();
// Write through L2 directly so L1 is cold.
await a.l2.set("t1", { id: "t1", name: "Alpha" });
expect(a.l1.get("t1")).toBeUndefined();
expect(await a.get("t1")).toEqual({ id: "t1", name: "Alpha" });
expect(a.l1.get("t1")).toEqual({ id: "t1", name: "Alpha" });
await storeA.close();
});
test("invalidate(key) clears local L1+L2 and publishes so peer L1 drops the same key", async () => {
const { a, b, storeA } = buildPair();
await a.set("t1", { id: "t1", name: "Alpha" });
await b.set("t1", { id: "t1", name: "Alpha" });
await b.subscribe();
await a.invalidate("t1");
await flushPubSubMicrotasks();
expect(a.l1.get("t1")).toBeUndefined();
expect(b.l1.get("t1")).toBeUndefined();
await storeA.close();
});
test("invalidate() with no key broadcasts wildcard and clears every L1", async () => {
const { a, b, storeA } = buildPair();
await a.set("k1", { id: "k1", name: "x" });
await a.set("k2", { id: "k2", name: "y" });
await b.set("k1", { id: "k1", name: "x" });
await b.subscribe();
await a.invalidate();
await flushPubSubMicrotasks();
expect(Array.from(a.l1.keys())).toEqual([]);
expect(Array.from(b.l1.keys())).toEqual([]);
await storeA.close();
});
test("invalidate({ localOnly: true }) does NOT publish — peer L1 stays warm", async () => {
const { a, b, storeA } = buildPair();
await a.set("t1", { id: "t1", name: "x" });
await b.set("t1", { id: "t1", name: "x" });
await b.subscribe();
await a.invalidate("t1", { localOnly: true });
await flushPubSubMicrotasks();
expect(a.l1.get("t1")).toBeUndefined();
expect(b.l1.get("t1")).toEqual({ id: "t1", name: "x" });
await storeA.close();
});
test("pubsubEnabled=false makes subscribe a no-op and skips PUBLISH", async () => {
const { a, b, storeA } = buildPair({ pubsubEnabled: false });
await a.set("t1", { id: "t1", name: "x" });
await b.set("t1", { id: "t1", name: "x" });
const dispose = await b.subscribe();
await a.invalidate("t1");
await flushPubSubMicrotasks();
// local L1 always cleared on the invalidator
expect(a.l1.get("t1")).toBeUndefined();
// peer L1 stays warm because PUBLISH was skipped
expect(b.l1.get("t1")).toEqual({ id: "t1", name: "x" });
await dispose();
await storeA.close();
});
test("subscribe forwards key to extra handler", async () => {
const { a, b, storeA } = buildPair();
const handler = vi.fn();
await b.subscribe(handler);
await a.invalidate("t1");
await flushPubSubMicrotasks();
expect(handler).toHaveBeenCalledWith("t1");
await storeA.close();
});
test("rejects ttl <= 0 and missing namespace", () => {
const adapter = new MemoryAdapter();
const store = createSharedStore({ namespace: "ns", adapter });
expect(() => createTwoTierCache({ store, namespace: "x", ttlSeconds: 0 })).toThrow(/ttlSeconds/);
expect(() => createTwoTierCache({ store, namespace: "", ttlSeconds: 60 })).toThrow(/namespace/);
});
test("flushLocal clears the entire L1 (recovery path after reconnect)", async () => {
const { a, storeA } = buildPair();
await a.set("t1", { id: "t1", name: "x" });
await a.set("t2", { id: "t2", name: "y" });
a.flushLocal();
expect(Array.from(a.l1.keys())).toEqual([]);
// L2 still has the entries
expect(await a.l2.get("t1")).toEqual({ id: "t1", name: "x" });
await storeA.close();
});
test("subscribe() registers a reconnect handler that flushes L1 (debounced)", async () => {
vi.useFakeTimers({ now: new Date("2026-05-28T00:00:00.000Z") });
// Build a SharedStore whose adapter exposes a controllable
// reconnect trigger so we can simulate a Redis 'ready' event.
const memoryAdapter = new MemoryAdapter();
const reconnectHandlers = new Set<() => void | Promise<void>>();
const proxyAdapter = new Proxy(memoryAdapter, {
get(target, prop, receiver) {
if (prop === "onReconnect") {
return (handler: () => void | Promise<void>) => {
reconnectHandlers.add(handler);
return () => {
reconnectHandlers.delete(handler);
};
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
const store = createSharedStore({ namespace: "ns", adapter: proxyAdapter });
const cache = createTwoTierCache<Value>({
store,
namespace: "subscription",
ttlSeconds: 60,
reconnectFlushDebounceMs: 50,
});
const dispose = await cache.subscribe();
await cache.set("t1", { id: "t1", name: "x" });
expect(cache.l1.get("t1")).toEqual({ id: "t1", name: "x" });
// Fire the reconnect event — handler should flush L1.
for (const h of reconnectHandlers) await h();
expect(cache.l1.get("t1")).toBeUndefined();
// Re-populate; a second reconnect within the debounce window MUST
// NOT flush again (proves debounce works).
await cache.set("t2", { id: "t2", name: "y" });
for (const h of reconnectHandlers) await h();
expect(cache.l1.get("t2")).toEqual({ id: "t2", name: "y" });
// After the debounce window elapses, the next reconnect flushes.
vi.advanceTimersByTime(60);
for (const h of reconnectHandlers) await h();
expect(cache.l1.get("t2")).toBeUndefined();
// Disposer must remove the reconnect handler too.
await dispose();
expect(reconnectHandlers.size).toBe(0);
await store.close();
});
});
@@ -0,0 +1,176 @@
// Real-Redis integration test for the two-tier cache. Skipped by
// default — opt-in either by exporting `PUBSUB_TEST_REDIS_URL` (any
// reachable Redis) or by ensuring Docker is available so the suite
// can spin up its own container via `testcontainers`. Mirrors the
// `packages/db/src/outbox-claim.test.ts` opt-in pattern: the file
// must NEVER fail by default.
//
// The boot happens at module top-level (with a hard timeout) so vitest
// can decide before registration whether to skip the suite. This
// sidesteps the chicken-and-egg where `beforeAll` would never run if
// every test was already statically marked `.skipIf(true)`.
import type { StartedTestContainer } from "testcontainers";
import { afterAll, describe, expect, it } from "vitest";
import { RedisAdapter } from "../adapters/redis.js";
import { createSharedStore } from "../factory.js";
import { createTwoTierCache } from "./two-tier-cache.js";
interface Value {
id: string;
name: string;
}
const explicitUrl = process.env.PUBSUB_TEST_REDIS_URL?.trim();
const dockerOptOut = process.env.PUBSUB_TEST_USE_DOCKER === "false";
interface Backend {
redisUrl: string;
container?: StartedTestContainer;
}
async function bootBackend(): Promise<Backend | undefined> {
if (explicitUrl) {
return { redisUrl: explicitUrl };
}
if (dockerOptOut) return undefined;
try {
const { GenericContainer } = await import("testcontainers");
const started = await new GenericContainer("redis:7-alpine").withExposedPorts(6379).start();
const host = started.getHost();
const port = started.getMappedPort(6379);
return { redisUrl: `redis://${host}:${port}`, container: started };
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
"[two-tier-cache.testcontainers] skipping: could not start Redis container —",
err instanceof Error ? err.message : err,
);
return undefined;
}
}
// Top-level await — vitest 3 + ESM permit this and it lets the describe
// block see a concrete boolean at registration time.
const backend = await bootBackend();
const skip = !backend;
const redisUrl = backend?.redisUrl;
function buildCachePair(opts: { db?: number } = {}) {
if (!redisUrl) throw new Error("redisUrl not initialised");
const adapterA = new RedisAdapter({ redisUrl, db: opts.db ?? 7 });
const adapterB = new RedisAdapter({ redisUrl, db: opts.db ?? 7 });
const storeA = createSharedStore({ namespace: "ns", adapter: adapterA });
const storeB = createSharedStore({ namespace: "ns", adapter: adapterB });
const cacheA = createTwoTierCache<Value>({
store: storeA,
namespace: "two-tier-it",
ttlSeconds: 60,
reconnectFlushDebounceMs: 50,
});
const cacheB = createTwoTierCache<Value>({
store: storeB,
namespace: "two-tier-it",
ttlSeconds: 60,
reconnectFlushDebounceMs: 50,
});
return { cacheA, cacheB, storeA, storeB, adapterA, adapterB };
}
afterAll(async () => {
if (backend?.container) {
try {
await backend.container.stop();
} catch {
/* best effort */
}
}
}, 30_000);
describe.skipIf(skip)("two-tier cache against real Redis (integration)", () => {
it("fans out invalidations across instances via pub/sub", async () => {
const { cacheA, cacheB, storeA, storeB } = buildCachePair();
try {
await cacheB.subscribe();
await cacheA.set("k1", { id: "k1", name: "Alpha" });
// Cross-instance read goes through L2, not L1.
expect(await cacheB.get("k1")).toEqual({ id: "k1", name: "Alpha" });
expect(cacheB.l1.get("k1")).toEqual({ id: "k1", name: "Alpha" });
await cacheA.invalidate("k1");
// Pub/sub fan-out is async; give the subscriber a moment.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(cacheB.l1.get("k1")).toBeUndefined();
expect(await cacheB.get("k1")).toBeNull();
} finally {
await storeA.close();
await storeB.close();
}
}, 30_000);
it("flushes L1 after a redis reconnect", async () => {
const { cacheA, storeA, adapterA } = buildCachePair();
try {
await cacheA.subscribe();
await cacheA.set("k1", { id: "k1", name: "x" });
await cacheA.set("k2", { id: "k2", name: "y" });
expect(cacheA.l1.get("k1")).toEqual({ id: "k1", name: "x" });
// Force a disconnect on the primary client and let ioredis
// reconnect on its own. We poke the internal client because the
// adapter doesn't expose a "kill connection" API — this is the
// narrowest possible test seam.
await reconnectPrimary(adapterA);
// Wait for the debounced reconnect-flush handler to land.
await new Promise((resolve) => setTimeout(resolve, 200));
expect(Array.from(cacheA.l1.keys())).toEqual([]);
// L2 still has the entries — repopulating from L2 must work.
expect(await cacheA.get("k1")).toEqual({ id: "k1", name: "x" });
} finally {
await storeA.close();
}
}, 30_000);
it("after disconnect+reconnect, a peer's L2 mutation is visible because L1 was flushed", async () => {
const { cacheA, cacheB, storeA, storeB, adapterA } = buildCachePair();
try {
await cacheA.subscribe();
await cacheA.set("kx", { id: "kx", name: "v1" });
expect(cacheA.l1.get("kx")).toEqual({ id: "kx", name: "v1" });
// Disconnect A; while A is down, B mutates L2 directly. The
// invalidate-publish from B never reaches A — pub/sub has no
// persistence.
await cacheB.set("kx", { id: "kx", name: "v2" });
await reconnectPrimary(adapterA);
await new Promise((resolve) => setTimeout(resolve, 200));
expect(cacheA.l1.get("kx")).toBeUndefined();
expect(await cacheA.get("kx")).toEqual({ id: "kx", name: "v2" });
} finally {
await storeA.close();
await storeB.close();
}
}, 30_000);
});
/**
* Force ioredis to cycle through `'reconnecting'` → `'ready'` so the
* adapter's onReconnect handlers fire. `disconnect(true)` schedules an
* automatic reconnect; calling `connect()` ourselves races against
* that and frequently throws `Redis is already connecting/connected`.
*/
async function reconnectPrimary(adapter: RedisAdapter): Promise<void> {
type IoRedisLike = {
disconnect(reconnect?: boolean): void;
once(event: string, listener: () => void): unknown;
status?: string;
};
const primary = (adapter as unknown as { primary: IoRedisLike }).primary;
const ready = new Promise<void>((resolve) => {
primary.once("ready", () => resolve());
});
primary.disconnect(true);
await ready;
}
@@ -0,0 +1,274 @@
import type { SharedStore } from "../factory.js";
import { createCacheStore, type CacheSerializer, type CacheStore } from "./cache.js";
/**
* Minimal LRU/Map-like contract for the L1 in-process cache. Both
* `Map<string, T>` and `LRUCache<string, T>` satisfy this shape so the
* caller can pick whatever fits their memory/eviction strategy.
*/
export interface TwoTierL1Cache<T> {
get(key: string): T | undefined;
set(key: string, value: T): void;
delete(key: string): boolean | void;
clear(): void;
keys(): IterableIterator<string> | Iterable<string>;
}
export interface TwoTierCacheOptions<T> {
/** Backing SharedStore. Used for the L2 tier and the pub/sub channel. */
store: SharedStore;
/**
* Channel namespace. Becomes the L2 prefix and the invalidation channel
* suffix. Should describe the subsystem (e.g. `"subscription"`,
* `"tenant"`, `"entitlements"`).
*/
namespace: string;
/** TTL applied to L2 (and used as L1 max-age when the cache honours TTL). */
ttlSeconds: number;
/**
* Optional L1 implementation. Defaults to an internal `Map` with no
* eviction; pass an `LRUCache` if you need bounded memory.
*/
l1Cache?: TwoTierL1Cache<T>;
/** Optional JSON serializer override for the L2 tier. */
serializer?: CacheSerializer<T>;
/**
* Suffix used for the pub/sub channel inside the namespace. Defaults
* to `"invalidate"`, producing channel
* `shared-store:<store-namespace>:ch:cache:<namespace>:invalidate`.
*/
invalidationChannel?: string;
/**
* Global kill-switch. When `false` (e.g. `CACHE_INVALIDATION_ENABLED=false`)
* the cache still serves locally but `invalidate()` becomes a local-only
* delete + no PUBLISH, and `subscribe()` becomes a no-op. Used for emergency
* disable without code change.
*/
pubsubEnabled?: boolean;
/**
* Optional logger for subscriber errors / disable warnings.
*/
logger?: {
warn(obj: Record<string, unknown> | string, msg?: string): void;
info?(obj: Record<string, unknown> | string, msg?: string): void;
error?(obj: Record<string, unknown> | string, msg?: string): void;
};
/**
* Minimum interval between two reconnect-driven `flushLocal()` calls,
* in milliseconds. A flood of `'ready'` events in quick succession
* (e.g. ioredis cycling through `'reconnecting' → 'ready'` multiple
* times) collapses into a single flush. Defaults to 1000ms.
*/
reconnectFlushDebounceMs?: number;
}
export interface InvalidateOptions {
/**
* When `true`, only purge the local L1 and do NOT publish (use when
* reacting to a remote invalidation we already received). Defaults to
* `false` (publish + purge).
*/
localOnly?: boolean;
}
export interface TwoTierCache<T> {
/** Read L1 first, fall back to L2, populate L1 on hit. */
get(key: string): Promise<T | null>;
/** Write both tiers. */
set(key: string, value: T): Promise<void>;
/**
* Drop one key from both tiers and publish so peer L1s purge.
* Pass `key = "*"` (or omit) to broadcast a wildcard flush — L1 is
* cleared entirely; L2 entries expire on their own TTL.
*/
invalidate(key?: string, opts?: InvalidateOptions): Promise<void>;
/** Drop one key from L1 only (no L2, no PUBLISH). */
deleteLocal(key: string): void;
/** Clear the entire L1. Useful on reconnect to recover lost invalidations. */
flushLocal(): void;
/**
* Start the subscriber. Must be called once per process per cache during
* boot. Returns a disposer that stops the subscriber. No-op when
* `pubsubEnabled === false`.
*/
subscribe(extraHandler?: (key: string) => void | Promise<void>): Promise<() => Promise<void>>;
/** Returns the underlying L2 `CacheStore` (advanced / test access). */
readonly l2: CacheStore<T>;
/** Returns the L1 instance (advanced / test access). */
readonly l1: TwoTierL1Cache<T>;
/** Returns the namespace (debug / test). */
readonly namespace: string;
}
class MapL1Cache<T> implements TwoTierL1Cache<T> {
private readonly map = new Map<string, T>();
get(key: string): T | undefined {
return this.map.get(key);
}
set(key: string, value: T): void {
this.map.set(key, value);
}
delete(key: string): boolean {
return this.map.delete(key);
}
clear(): void {
this.map.clear();
}
keys(): IterableIterator<string> {
return this.map.keys();
}
}
/**
* Build a two-tier cache: in-process L1 + SharedStore L2 + pub/sub
* cross-replica invalidation.
*
* Reads probe L1 first (sub-microsecond), then L2 (one Redis GET), and
* populate L1 on hit. Writes go to both tiers. `invalidate(key)`
* deletes from both tiers locally and PUBLISHes the key on the
* invalidation channel so peer replicas drop the same entry from
* their L1 within milliseconds.
*
* Boot sequence on each process that needs L1:
*
* ```ts
* const cache = createTwoTierCache<MyValue>({ store, namespace: "subscription", ttlSeconds: 300 });
* await cache.subscribe(); // start listening once per process
* ```
*/
export function createTwoTierCache<T>(options: TwoTierCacheOptions<T>): TwoTierCache<T> {
if (!options.namespace) {
throw new Error("createTwoTierCache: namespace is required");
}
if (options.ttlSeconds <= 0) {
throw new Error("createTwoTierCache: ttlSeconds must be > 0");
}
const l1 = options.l1Cache ?? new MapL1Cache<T>();
const l2 = createCacheStore<T>({
store: options.store,
prefix: options.namespace,
ttlSeconds: options.ttlSeconds,
serializer: options.serializer,
invalidationChannel: options.invalidationChannel,
});
const pubsubEnabled = options.pubsubEnabled !== false;
const logger = options.logger;
const reconnectDebounceMs = options.reconnectFlushDebounceMs ?? 1000;
const flushLocal = (): void => {
l1.clear();
};
let lastReconnectFlushAt = 0;
const debouncedReconnectFlush = (): void => {
const now = Date.now();
if (now - lastReconnectFlushAt < reconnectDebounceMs) {
return;
}
lastReconnectFlushAt = now;
flushLocal();
logger?.info?.(
{ component: "two-tier-cache", namespace: options.namespace },
`two-tier cache ${options.namespace}: redis reconnected, L1 flushed`,
);
};
const deleteLocal = (key: string): void => {
l1.delete(key);
};
return {
namespace: options.namespace,
l1,
l2,
async get(key) {
const hit = l1.get(key);
if (hit !== undefined) return hit;
const fromL2 = await l2.get(key);
if (fromL2 !== null) {
l1.set(key, fromL2);
}
return fromL2;
},
async set(key, value) {
l1.set(key, value);
await l2.set(key, value);
},
async invalidate(key, opts) {
const localOnly = opts?.localOnly === true;
// Local L1 purge first — every replica handles its own L1 on
// invalidation receipt so this also covers `localOnly` paths.
if (!key || key === "*") {
flushLocal();
} else {
deleteLocal(key);
}
if (localOnly || !pubsubEnabled) {
// Best-effort L2 delete when targeting a specific key, so the
// local replica does not serve a stale L2 on the next miss.
if (key && key !== "*") {
try {
await l2.delete(key);
} catch (err) {
logger?.error?.({ component: "two-tier-cache", namespace: options.namespace, err }, "l2 delete failed");
}
}
return;
}
// Publishes and clears L2 (for specific keys) in one shot.
try {
await l2.invalidate(key);
} catch (err) {
logger?.error?.({ component: "two-tier-cache", namespace: options.namespace, err }, "l2 invalidate failed");
}
},
deleteLocal,
flushLocal,
async subscribe(extraHandler) {
if (!pubsubEnabled) {
logger?.warn?.(
{ component: "two-tier-cache", namespace: options.namespace },
"two-tier-cache: pubsub disabled, subscriber not started",
);
return async () => {
/* no-op */
};
}
const disposer = await l2.onInvalidate(async (key) => {
try {
if (!key || key === "*") {
flushLocal();
} else {
deleteLocal(key);
}
if (extraHandler) {
await extraHandler(key);
}
} catch (err) {
logger?.error?.(
{ component: "two-tier-cache", namespace: options.namespace, err, key },
"two-tier-cache: subscriber handler threw",
);
}
});
// Wire a reconnect-flush so invalidations published while the
// Redis connection was down (and therefore lost — pub/sub has no
// persistence) cannot leave this replica's L1 serving stale
// values forever. On the memory adapter this is a noop disposer.
const removeReconnect = options.store.adapter.onReconnect(() => {
try {
debouncedReconnectFlush();
} catch (err) {
logger?.error?.(
{ component: "two-tier-cache", namespace: options.namespace, err },
"two-tier-cache: reconnect-flush handler threw",
);
}
});
return async () => {
removeReconnect();
await disposer();
};
},
};
}
@@ -0,0 +1,43 @@
// Package entry point. Public exports grow as subsequent commits land
// each module (helpers). See README.md for the final surface area.
export type { SharedStoreAdapter, SharedStoreSetOptions, SharedStoreScanOptions } from "./adapters/adapter.js";
export { MemoryAdapter } from "./adapters/memory.js";
export { RedisAdapter, type RedisAdapterOptions } from "./adapters/redis.js";
export {
createSharedStore,
type CreateSharedStoreOptions,
type SharedStore,
type SharedStoreLogger,
} from "./factory.js";
export {
createFixedWindowRateLimiter,
type FixedWindowDecision,
type FixedWindowRateLimiter,
type FixedWindowRateLimiterOptions,
} from "./helpers/rate-limit-fixed.js";
export {
createSlidingWindowRateLimiter,
type SlidingWindowDecision,
type SlidingWindowRateLimiter,
type SlidingWindowRateLimiterOptions,
} from "./helpers/rate-limit-sliding.js";
export {
createCacheStore,
type CacheStore,
type CacheStoreOptions,
type CacheSerializer,
} from "./helpers/cache.js";
export { createSessionStore, type SessionStore, type SessionStoreOptions } from "./helpers/session.js";
export {
createIdempotencyStore,
type IdempotencyStore,
type IdempotencyStoreOptions,
type IdempotencyEntry,
} from "./helpers/idempotency.js";
export {
createTwoTierCache,
type TwoTierCache,
type TwoTierCacheOptions,
type TwoTierL1Cache,
type InvalidateOptions,
} from "./helpers/two-tier-cache.js";
@@ -0,0 +1 @@
export type { SharedStoreAdapter, SharedStoreSetOptions, SharedStoreScanOptions } from "./adapters/adapter.js";
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "c8",
reporter: ["text-summary", "json-summary"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/index.ts", "src/types.ts"],
reportsDirectory: "coverage",
},
},
});