Files
HiveGrid-Wireframes/hivegrid-cloud/packages/shared-store

@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

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.