@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 index0.
Quickstart
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:
- Identify the pattern. Pick the helper that matches the in-process shape: rate-limit (fixed/sliding), cache, session, or idempotency.
- Pick a namespace. One namespace per subsystem
(
auth-login,mfa-challenge,idempotency,subscription-cache,mcp-transport,web-session). - Construct once. Build the helper at module scope, not per request, so the underlying Redis connection is reused.
- 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. - 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. - Smoke-test in staging. Flip
REDIS_URLin Coolify and watch metrics / Sentry for connection errors. Use theRedisAdapter.ping()helper to wire a health check. - 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 aMemoryAdapter. 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. ConfigurecommandTimeout(default 2s) andconnectionTimeout(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.