Track HiveGrid Cloud planning reference
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
+176
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user