153 lines
6.5 KiB
Markdown
153 lines
6.5 KiB
Markdown
# @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.
|