# @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:::`. Two helpers in the same namespace cannot collide because each helper applies its own `` (`rl:fixed:`, `rl:sliding:`, `cache:`, `session:`, `idempotency:`). - Channel names also live inside the namespace (`shared-store::ch:`) 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` 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` 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.