Files
HiveGrid-Wireframes/hivegrid-cloud/packages/shared-store/README.md
T

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.