ioredis-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +645 -0
  3. package/dist/cache.d.ts +298 -0
  4. package/dist/cache.js +606 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +958 -0
  7. package/dist/cluster-slot.d.ts +4 -0
  8. package/dist/cluster-slot.js +31 -0
  9. package/dist/cluster.d.ts +79 -0
  10. package/dist/cluster.js +156 -0
  11. package/dist/errors.d.ts +30 -0
  12. package/dist/errors.js +63 -0
  13. package/dist/health.d.ts +39 -0
  14. package/dist/health.js +106 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +44 -0
  17. package/dist/lock.d.ts +215 -0
  18. package/dist/lock.js +385 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/logger.js +40 -0
  21. package/dist/pubsub.d.ts +171 -0
  22. package/dist/pubsub.js +285 -0
  23. package/dist/ratelimiter.d.ts +162 -0
  24. package/dist/ratelimiter.js +289 -0
  25. package/dist/session/index.d.ts +23 -0
  26. package/dist/session/index.js +16 -0
  27. package/dist/session/revocation-store.d.ts +171 -0
  28. package/dist/session/revocation-store.js +310 -0
  29. package/dist/session/scripts/cleanup-index.lua +21 -0
  30. package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
  31. package/dist/session/scripts/conditional-update.lua +63 -0
  32. package/dist/session/scripts/create.lua +68 -0
  33. package/dist/session/scripts/delete-by-user.lua +29 -0
  34. package/dist/session/scripts/delete.lua +15 -0
  35. package/dist/session/scripts/enforce-limit.lua +38 -0
  36. package/dist/session/scripts/revoke.lua +61 -0
  37. package/dist/session/scripts/rotate-encrypted.lua +107 -0
  38. package/dist/session/scripts/rotate.lua +119 -0
  39. package/dist/session/scripts/touch-encrypted.lua +89 -0
  40. package/dist/session/scripts/touch.lua +72 -0
  41. package/dist/session/scripts/validate.lua +90 -0
  42. package/dist/session/session-circuit-breaker.d.ts +42 -0
  43. package/dist/session/session-circuit-breaker.js +129 -0
  44. package/dist/session/session-config.d.ts +335 -0
  45. package/dist/session/session-config.js +162 -0
  46. package/dist/session/session-cookie.d.ts +72 -0
  47. package/dist/session/session-cookie.js +101 -0
  48. package/dist/session/session-encryption.d.ts +87 -0
  49. package/dist/session/session-encryption.js +139 -0
  50. package/dist/session/session-errors.d.ts +85 -0
  51. package/dist/session/session-errors.js +145 -0
  52. package/dist/session/session-health.d.ts +38 -0
  53. package/dist/session/session-health.js +60 -0
  54. package/dist/session/session-keys.d.ts +51 -0
  55. package/dist/session/session-keys.js +113 -0
  56. package/dist/session/session-manager.d.ts +59 -0
  57. package/dist/session/session-manager.js +94 -0
  58. package/dist/session/session-metrics.d.ts +33 -0
  59. package/dist/session/session-metrics.js +112 -0
  60. package/dist/session/session-repository.d.ts +161 -0
  61. package/dist/session/session-repository.js +683 -0
  62. package/dist/session/session-scripts.d.ts +36 -0
  63. package/dist/session/session-scripts.js +130 -0
  64. package/dist/session/session-serializer.d.ts +42 -0
  65. package/dist/session/session-serializer.js +248 -0
  66. package/dist/session/session-service.d.ts +104 -0
  67. package/dist/session/session-service.js +611 -0
  68. package/dist/session/session-token.d.ts +38 -0
  69. package/dist/session/session-token.js +86 -0
  70. package/dist/session/session-types.d.ts +253 -0
  71. package/dist/session/session-types.js +16 -0
  72. package/dist/types.d.ts +782 -0
  73. package/dist/types.js +140 -0
  74. package/package.json +97 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Your Organization
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,645 @@
1
+ # ioredis-toolkit
2
+
3
+ Production-grade Redis infrastructure for distributed systems: a unified client, cache, rate limiter, distributed lock, pub/sub and health checking — all working in **standalone**, **sentinel** and **cluster** modes.
4
+
5
+ ## Topology-safe client API
6
+
7
+ The recommended entry point is `createRedisClient()`. Redis topology is a discriminated union, so configuration mistakes are rejected before a connection is created. Cluster-only administrative helpers are exposed by the factory only for cluster configurations.
8
+
9
+ ```ts
10
+ import { createRedisClient } from 'ioredis-toolkit';
11
+
12
+ const client = createRedisClient({
13
+ mode: 'cluster',
14
+ clusterNodes: [
15
+ { host: 'redis-1', port: 6379 },
16
+ { host: 'redis-2', port: 6379 },
17
+ ],
18
+ });
19
+
20
+ await client.set('{user:42}:profile', JSON.stringify({ id: 42 }));
21
+ const slot = client.calculateSlot('{user:42}:profile');
22
+ ```
23
+
24
+ For standalone/Sentinel, the same normal Redis API works automatically; no separate implementation is required. Sentinel uses ioredis master discovery/failover and Cluster uses ioredis slot routing. Cluster-only APIs such as `calculateSlot()` and `getClusterSlots()` are not part of the non-cluster factory type.
25
+
26
+ ### Important topology rules
27
+
28
+ - Standalone: databases `0..15` are supported.
29
+ - Sentinel: `SELECT` is available against the selected Sentinel master.
30
+ - Cluster: database is always `0`; `SELECT` is unavailable.
31
+ - Multi-key operations spanning slots are grouped and executed as bounded fan-out operations; they are not atomic across slots.
32
+ - Lua scripts that need atomic multi-key behavior must use keys with the same hash tag, e.g. `{userId}:session` and `{userId}:index`.
33
+ - Administrative namespace cleanup uses `SCAN`, never `KEYS`.
34
+
35
+
36
+ ## Features
37
+
38
+ - **Unified client** (`RedisClientWrapper`) — one API for standalone / sentinel / cluster; all multi-key operations (`mget`, `mset`, `scanIterator`, ...) are slot-aware and cluster-safe
39
+ - **Cache** (`Cache`) — JSON serialization, optional gzip compression, namespaces, TTLs, hash helpers and pattern-based cleanup
40
+ - **Rate limiter** (`RateLimiter`) — generic, works for any resource (routes, users, IPs, API keys, databases, ...) with fixed and sliding windows
41
+ - **Distributed lock** (`DistributedLock`) — atomic acquire/release, auto-extension, retries
42
+ - **Pub/Sub** (`PubSub`) — publish, subscribe, pattern subscriptions
43
+ - **Health checker** (`HealthChecker`) — periodic health monitoring with callbacks
44
+ - **Session subsystem** (`createSessionManager`) — the production session stack: validation, retry-safe rotation, idle/absolute expiry, eviction ceilings, security versioning, optional AES-256-GCM encryption at rest, fail-closed circuit breaker, metrics and health
45
+ - **Revocation store** (`RedisRevocationStore`) — TTL-backed token revocation with batch operations and fail-closed checks
46
+ - **Lua scripts** (`eval` / `evalsha`) — atomic server-side logic, Cluster-safe when keys share a hash slot
47
+ - **Observability** — pino-compatible logging and slow-command warnings
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ npm install ioredis-toolkit ioredis zod
53
+ ```
54
+
55
+ ## Quick start
56
+
57
+ ```ts
58
+ import { RedisClient, Cache, RateLimiter } from 'ioredis-toolkit';
59
+
60
+ // 1. Create the client
61
+ const client = new RedisClient({
62
+ mode: 'standalone',
63
+ host: 'localhost',
64
+ port: 6379,
65
+ });
66
+
67
+ // 2. Cache
68
+ const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
69
+ await cache.set('user:1', { name: 'alice' });
70
+ const user = await cache.get('user:1');
71
+
72
+ // 3. Rate limiting
73
+ const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
74
+ const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
75
+ if (!result.allowed) {
76
+ // HTTP 429, set Retry-After: result.retryAfter
77
+ }
78
+
79
+ // 4. Shut down gracefully
80
+ await client.close();
81
+ ```
82
+
83
+ ## Connection modes
84
+
85
+ All features behave identically in every mode. Choose the mode via the `mode` config field.
86
+
87
+ ### Standalone
88
+
89
+ ```ts
90
+ const client = new RedisClient({
91
+ mode: 'standalone',
92
+ host: 'localhost',
93
+ port: 6379,
94
+ password: 'secret',
95
+ database: 0,
96
+ });
97
+ ```
98
+
99
+ Or with a URL:
100
+
101
+ ```ts
102
+ const client = new RedisClient({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });
103
+ ```
104
+
105
+ ### Sentinel
106
+
107
+ ```ts
108
+ const client = new RedisClient({
109
+ mode: 'sentinel',
110
+ sentinelNodes: [
111
+ { host: 'sentinel1', port: 26379 },
112
+ { host: 'sentinel2', port: 26380 },
113
+ ],
114
+ sentinelMasterName: 'mymaster',
115
+ password: 'secret',
116
+ });
117
+ ```
118
+
119
+ ### Cluster
120
+
121
+ ```ts
122
+ const client = new RedisClient({
123
+ mode: 'cluster',
124
+ clusterNodes: [
125
+ { host: 'redis1', port: 7000 },
126
+ { host: 'redis2', port: 7001 },
127
+ { host: 'redis3', port: 7002 },
128
+ ],
129
+ password: 'secret',
130
+ });
131
+ ```
132
+
133
+ ### Common configuration
134
+
135
+ | Option | Type | Default | Description |
136
+ | --- | --- | --- | --- |
137
+ | `maxRetries` | number | `3` | Max reconnect attempts |
138
+ | `retryDelay` | number | `1000` | Base reconnect delay (ms) |
139
+ | `connectionTimeout` | number | `5000` | Connect timeout (ms) |
140
+ | `defaultTTL` | number | `3600` | Default cache TTL (seconds) |
141
+ | `compressionThreshold` | number | `1024` | Cache compression threshold (bytes) |
142
+ | `slowCommandThreshold` | number | `1000` | Log commands slower than this (ms) |
143
+ | `tls` | boolean | `false` | Enable TLS (`tlsOptions` for CA/cert/key) |
144
+
145
+ Configs are validated with Zod (`RedisConfigSchema`).
146
+
147
+ ## RedisClient
148
+
149
+ Full reference is in the generated `.d.ts` (JSDoc with params + examples). Highlights:
150
+
151
+ ### Strings & keys
152
+
153
+ ```ts
154
+ await client.set('name', 'alice'); // SET
155
+ await client.set('session', 'x', 3600); // SET ... EX
156
+ await client.setexnx('job:1', 'w', 60); // SET ... EX NX (only if missing)
157
+ await client.setnx('lock:1', 'owner', 30); // SETNX + EXPIRE, returns 1|0
158
+ await client.get('name'); // 'alice' | null
159
+ await client.getdel('queue:job'); // GETDEL
160
+ await client.exists('name'); // 1 | 0
161
+ await client.del('a', 'b'); // number deleted
162
+ await client.expire('session', 3600); // set TTL
163
+ await client.ttl('session'); // seconds left
164
+ await client.incr('visits'); // counters
165
+ await client.decr('stock:sku-1');
166
+ ```
167
+
168
+ ### Batch operations (cluster-safe)
169
+
170
+ ```ts
171
+ await client.mset(['user:1', 'alice'], ['user:2', 'bob']); // grouped by hash slot
172
+ const [a, b] = await client.mget('user:1', 'user:2'); // routed per slot
173
+ ```
174
+
175
+ ### Hashes, sets, sorted sets
176
+
177
+ ```ts
178
+ await client.hset('user:1', 'name', 'alice');
179
+ await client.hget('user:1', 'name');
180
+ await client.hgetall('user:1');
181
+ await client.hdel('user:1', 'age');
182
+
183
+ await client.sadd('tags:1', 'redis', 'typescript');
184
+ await client.smembers('tags:1');
185
+ await client.sismember('tags:1', 'redis');
186
+ await client.srem('tags:1', 'redis');
187
+
188
+ await client.zadd('leaderboard', 100, 'p1');
189
+ await client.zrange('leaderboard', 0, 9);
190
+ await client.zrem('leaderboard', 'p1');
191
+ ```
192
+
193
+ ### Scanning & pipelines
194
+
195
+ ```ts
196
+ // Scans every node in cluster mode
197
+ for await (const key of client.scanIterator('session:*')) {
198
+ console.log(key);
199
+ }
200
+ await client.deletePattern('temp:*'); // delete by glob, cluster-safe
201
+
202
+ // Pipelines (cluster mode: keys must share a hash slot)
203
+ const pipeline = client.pipeline();
204
+ pipeline.set('a', '1');
205
+ pipeline.incr('b');
206
+ const results = await pipeline.exec();
207
+ ```
208
+
209
+ ### Cluster helpers
210
+
211
+ ```ts
212
+ client.isCluster(); // boolean
213
+ client.getClusterNodes(); // raw node clients
214
+ client.getClusterSlots(); // raw slot map
215
+ client.getSlotRanges(); // Map<slot, host:port[]>
216
+ client.calculateSlot('{user}:a'); // CRC16 slot, honors hash tags
217
+ await client.getNodeForKey('user:1');// node serving a key
218
+ await client.isKeyServed('user:1'); // slot is served
219
+ await client.executeOnNode('user:1', 'get', 'user:1'); // run on owning node
220
+ await client.mgetClusterAware([...]); // slot-grouped multi-get
221
+ client.getClusterInfo(); // topology snapshot
222
+ ```
223
+
224
+ ### Lifecycle & low-level
225
+
226
+ ```ts
227
+ await client.ping(); // boolean
228
+ await client.close(); // graceful QUIT
229
+ client.raw; // raw ioredis client
230
+ client.defineCommand(name, def);// custom commands (e.g. fastify-rate-limit)
231
+ await client.info('memory'); // INFO output
232
+ await client.select(1); // standalone only
233
+ ```
234
+
235
+ ### Lua scripts (atomic server-side logic)
236
+
237
+ ```ts
238
+ // EVAL: the first numKeys arguments are KEYS, everything else is ARGV.
239
+ // Cluster mode: every key touched inside the script must be declared in
240
+ // KEYS and share one hash slot.
241
+ const script = `
242
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
243
+ return redis.call('DEL', KEYS[1])
244
+ end
245
+ return 0
246
+ `;
247
+ await client.set('lock:job', 'owner-1');
248
+ await client.eval(script, 1, 'lock:job', 'owner-1'); // 1 (deleted)
249
+
250
+ // SCRIPT LOAD + EVALSHA: avoid re-sending the script body on every call.
251
+ // evalsha falls back to EVAL automatically when the server cache was flushed.
252
+ const sha = await client.scriptLoad(script);
253
+ await client.evalsha(sha, script, 1, 'lock:job', 'owner-2'); // 0 (not owner)
254
+ ```
255
+
256
+ ### Convenience accessors
257
+
258
+ `RedisClient` lazily creates and shares one instance of each sub-component. Access them as properties — no manual wiring needed:
259
+
260
+ ```ts
261
+ client.cache; // shared Cache (created on first access)
262
+ client.pubsub; // shared PubSub
263
+ client.lock; // shared DistributedLock
264
+ client.rateLimiter; // shared RateLimiter
265
+
266
+ await client.cache.set('user:1', { name: 'alice' });
267
+ const ok = await client.lock.acquire('order:42');
268
+ const { allowed } = await client.rateLimiter.consume('/api', 'ip-1', { limit: 5, duration: 60 });
269
+ ```
270
+
271
+ The corresponding setters replace the shared instance with a custom one (e.g. one built with different defaults):
272
+
273
+ ```ts
274
+ client.cache = new Cache(client, config, logger);
275
+ client.rateLimiter = new RateLimiter(client, { limit: 50, duration: 10 });
276
+ ```
277
+
278
+ ## Cache
279
+
280
+ ### Basic usage
281
+
282
+ ```ts
283
+ const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
284
+
285
+ await cache.set('user:1', { name: 'alice' }, { ttl: 300 });
286
+ const user = await cache.get('user:1');
287
+
288
+ await cache.delete('user:1');
289
+ await cache.exists('user:1');
290
+ await cache.expire('user:1', 60);
291
+ await cache.ttl('user:1');
292
+ ```
293
+
294
+ - Values are JSON-serialized; strings/numbers/buffers are stored as-is.
295
+ - Values larger than `compressionThreshold` bytes are gzip-compressed transparently.
296
+ - `compress: false` disables compression for a single write.
297
+
298
+ ### Namespaces
299
+
300
+ ```ts
301
+ await cache.set('token', 'abc', { namespace: 'auth' });
302
+ await cache.get('token', 'auth'); // 'abc'
303
+ await cache.get('token'); // null
304
+
305
+ await cache.clearNamespace('sessions'); // delete every 'sessions:*' key
306
+ await cache.keys('session:*'); // list keys (cluster-safe)
307
+ await cache.deletePattern('temp:*'); // delete by pattern
308
+ ```
309
+
310
+ ### Atomic & batch operations
311
+
312
+ ```ts
313
+ await cache.setNX('job:1', 'worker-1', { ttl: 60 }); // only if missing
314
+ await cache.setEXNX('lock:1', 'txn', { ttl: 30 }); // atomic with TTL
315
+
316
+ await cache.increment('stats:visits'); // 1, 2, 3, ...
317
+ await cache.decrement('stock:sku-1');
318
+
319
+ await cache.mset({ 'user:1': alice, 'user:2': bob }, { ttl: 300 }); // slot-grouped
320
+ const [a, b] = await cache.mget(['user:1', 'user:2']);
321
+ ```
322
+
323
+ ### Hash helpers
324
+
325
+ ```ts
326
+ await cache.hset('user:1', 'age', 30);
327
+ await cache.hget('user:1', 'age'); // 30
328
+ await cache.hgetall('user:1'); // { age: 30, ... }
329
+ ```
330
+
331
+ ## RateLimiter
332
+
333
+ Generic rate limiting for **any** resource — routes, API endpoints, users, IPs, API keys, database writes, email sends, webhooks...
334
+
335
+ ```ts
336
+ const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
337
+
338
+ const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
339
+ ```
340
+
341
+ ### Result
342
+
343
+ ```ts
344
+ interface RateLimitResult {
345
+ allowed: boolean; // request may proceed
346
+ limit: number; // configured max
347
+ used: number; // requests in current window
348
+ remaining: number; // left in the window
349
+ resetAt: number; // epoch ms when the window resets
350
+ retryAfter: number; // seconds to wait (0 when allowed)
351
+ }
352
+ ```
353
+
354
+ ### Usage in an HTTP handler
355
+
356
+ ```ts
357
+ const result = await limiter.consume('/api/orders', request.ip, { limit: 5, duration: 60 });
358
+ if (!result.allowed) {
359
+ response.setHeader('Retry-After', String(result.retryAfter));
360
+ return response.status(429).json({ error: 'Too many requests' });
361
+ }
362
+ ```
363
+
364
+ ### Peek & reset
365
+
366
+ ```ts
367
+ const state = await limiter.check('/api/search', 'user-1'); // no capacity consumed
368
+ await limiter.reset('/api/export', 'user-7'); // grant full capacity again
369
+ ```
370
+
371
+ ### Algorithms
372
+
373
+ | Algorithm | Key type | Characteristics |
374
+ | --- | --- | --- |
375
+ | `sliding` (default) | sorted set + atomic Lua | smoothest; precise rolling window |
376
+ | `fixed` | counter (`INCR`/`EXPIRE`) | cheapest; window resets at fixed boundaries |
377
+
378
+ ```ts
379
+ const fixed = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
380
+ const perRoute = await fixed.consume('/api', 'user-1', { limit: 3, duration: 10 }); // per-call override
381
+ ```
382
+
383
+ Keys are `ratelimit:{resource}:{identifier}` — each resource/identifier pair is tracked independently, so routes and callers never interfere. If Redis is unavailable the limiter **fails open** (allows requests) so an outage cannot take down the whole app.
384
+
385
+ ## DistributedLock
386
+
387
+ ```ts
388
+ const lock = new DistributedLock(client, { ttl: 30000, retryCount: 3, retryDelay: 200 });
389
+
390
+ await lock.acquire('order:42'); // boolean
391
+ await lock.release('order:42'); // owner-checked Lua delete
392
+ await lock.releaseForce('order:42'); // delete without ownership check
393
+ await lock.withLock('order:42', async () => {
394
+ // exclusive section; TTL is auto-extended, lock released afterwards
395
+ });
396
+ await lock.extend('order:42', 60000); // renew TTL while owned
397
+ await lock.isLocked('order:42');
398
+ await lock.getLockInfo('order:42'); // { locked, ttl, lockId }
399
+ await lock.getLockOwner('order:42'); // lock id | null
400
+ await lock.getLockTTL('order:42'); // seconds left
401
+ await lock.cleanupAll(); // delete every lock:* key (tests/emergency)
402
+ ```
403
+
404
+ ## PubSub
405
+
406
+ ```ts
407
+ const pubsub = new PubSub(client);
408
+ await pubsub.connectSubscriber(redisConfig); // dedicated subscriber connection
409
+
410
+ await pubsub.subscribe('orders:created', (message) => {
411
+ console.log(message); // message payload (JSON-parsed)
412
+ });
413
+ await pubsub.publish('orders:created', { id: 1 }); // JSON-serialized
414
+
415
+ await pubsub.unsubscribe('orders:created', handler); // one handler
416
+ await pubsub.unsubscribe('orders:created'); // whole channel
417
+ await pubsub.psubscribe('orders:*', ({ channel, message }) => {
418
+ // pattern handlers receive { channel, message }
419
+ });
420
+ await pubsub.punsubscribe('orders:*');
421
+ await pubsub.close(); // closes subscriber only
422
+ pubsub.getStats(); // { subscriptions, patternSubscriptions, connected }
423
+ ```
424
+
425
+ ## HealthChecker
426
+
427
+ ```ts
428
+ const health = new HealthChecker(client);
429
+ health.start(10000); // check every 10s
430
+ health.onChange((status) => console.log(status));
431
+
432
+ const status = await health.check(); // ping + latency
433
+ health.getStatus(); // last result (null before first check)
434
+ await health.waitForHealthy(30000); // boolean
435
+ health.stop();
436
+ ```
437
+
438
+ ## Revocation store
439
+
440
+ `RedisRevocationStore` supports refresh-token revocation workflows: short-lived entries (`revoked:{jti}`) so rotated/logged-out tokens are rejected for their remaining lifetime. It is framework-independent, works identically on standalone, Sentinel and Cluster, never stores raw tokens — only ids (`jti`) — and is the same store the session subsystem consults when `checkRevocationStore: true`.
441
+
442
+ ```ts
443
+ import { RedisRevocationStore } from 'ioredis-toolkit/session';
444
+ ```
445
+
446
+ ### RedisRevocationStore
447
+
448
+ Each revoked jti is stored as `{prefix}{jti} -> reason` with a Redis TTL equal to the token's remaining lifetime, so expired entries are reclaimed automatically — no sweep job required. Every operation is single-key (or a slot-grouped pipeline for batches), so no hash tags are needed on any topology.
449
+
450
+ ```ts
451
+ const revocations = new RedisRevocationStore({
452
+ client,
453
+ keyPrefix: 'authcore:revoked:',
454
+ });
455
+
456
+ const expiry = Math.floor(Date.now() / 1000) + 86400;
457
+
458
+ // Revoke (single or batch)
459
+ await revocations.revoke({ jti: 'a1b2c3d4', reason: 'logout', expiresAt: expiry });
460
+ await revocations.revokeMany([
461
+ { jti: 'b2', reason: 'logout-all', expiresAt: expiry },
462
+ { jti: 'c3', reason: 'password-change', expiresAt: expiry },
463
+ ]);
464
+
465
+ // Check (single or batch)
466
+ await revocations.isRevoked('a1b2c3d4'); // boolean
467
+ const revoked = await revocations.isRevokedMany(['b2', 'c3', 'd4']); // Set<string>
468
+
469
+ if (revoked.has('b2')) {
470
+ // reject the token
471
+ }
472
+ ```
473
+
474
+ Fail-closed semantics: if a batched command fails (Redis error, timeout, ...), `revokeMany` / `isRevokedMany` throw `RevocationBatchError` carrying the exact jtis that failed — a check can never silently treat a token as "not revoked" when its status is unknown.
475
+
476
+ ## Session subsystem
477
+
478
+ The production session stack (`createSessionManager`): validation with
479
+ fail-closed semantics, rotation with retry-safe idempotency, throttled
480
+ touches, idle + absolute expiry, per-user eviction ceilings, security
481
+ versioning, optional AES-256-GCM encryption at rest, an optional jti index
482
+ for userId-free lookup, a fail-closed circuit breaker, metrics and health
483
+ — all Cluster-safe by construction. It supersedes the historical
484
+ `RedisSessionStore` (removed; see the migration note below).
485
+
486
+ See `SESSION.md` (spec) and `docs/architecture.md` (decisions,
487
+ deviations, exact semantics) before adopting it. Highlights:
488
+
489
+ ```ts
490
+ import { createSessionManager } from 'ioredis-toolkit/session';
491
+
492
+ const manager = createSessionManager({
493
+ client,
494
+ config: {
495
+ enabled: true, // explicit opt-in
496
+ namespace: 'authcore',
497
+ maxSessionsPerUser: 20,
498
+ securityVersion: { enabled: true },
499
+ },
500
+ });
501
+ await manager.init(); // preloads the Lua scripts
502
+
503
+ const { token, session } = await manager.service.create({ userId: 'user-42' });
504
+
505
+ // Validate: single round trip when userId is known. Never throws for
506
+ // invalid sessions; throws (SessionStorageError) only on infra failure.
507
+ const result = await manager.service.validate(token, { userId: 'user-42' });
508
+ if (result.valid) { /* session is live */ }
509
+
510
+ // Rotate with a retry-safe nonce (idempotent retries).
511
+ const rotated = await manager.service.rotate(token, { userId: 'user-42', rotationNonce: 'uuid' });
512
+
513
+ await manager.service.touch(token, { userId: 'user-42' });
514
+ await manager.service.revoke(token, { userId: 'user-42' }); // tombstone
515
+ await manager.service.revokeAll('user-42'); // logout all devices
516
+ await manager.service.setSecurityVersion('user-42', 2); // invalidates old sessions
517
+ ```
518
+
519
+ - **Never stores the raw token** — only `jti = SHA-256(token)`.
520
+ - **Validation results** are discriminated: `{ valid: true, session }` or
521
+ `{ valid: false, reason: 'invalid' | 'not_found' | 'expired' |
522
+ 'idle_timeout' | 'revoked' | 'binding_mismatch' }`.
523
+ - **Fail closed**: infra errors are `SessionStorageError` (503), never
524
+ "invalid"; a broken revocation-store read is a 503, not a 401. The
525
+ circuit breaker trips only on storage failures — bad tokens, consumed
526
+ sessions and concurrent-update conflicts can never open it.
527
+ - **Idempotent creation**: `enableCreateIdempotency: true` + an
528
+ `idempotencyKey` on `create()` makes retries return the original
529
+ session (`replayed: true`) instead of duplicating it; the claim is
530
+ TTL-bounded, so replay windows cannot grow forever.
531
+ - **Encryption at rest**: `encryption: { enabled: true }` +
532
+ `encryptionKeyProvider` (AES-256-GCM, key-versioned).
533
+ - **Revocation store**: `RedisRevocationStore` plugs in via the
534
+ `revocationStore` option for external jti denylists.
535
+ - **Manager surface**: `manager.service` (ops), `manager.metrics`,
536
+ `manager.health`, `manager.circuitBreaker`, `manager.cookies`,
537
+ `manager.token`, `manager.keys` — plus `manager.config`.
538
+ - **Cookie helpers**: `manager.cookies.serialize(token)` returns the
539
+ `Set-Cookie` string; `serializeWithAttributes(token)` additionally
540
+ returns the structured cookie object
541
+ (`{ header, name, value, attributes: { path, domain?, httpOnly, secure,
542
+ sameSite, maxAge? } }`, types exported from both entry points).
543
+
544
+ ### Session configuration
545
+
546
+ | Option | Default | Description |
547
+ | --- | --- | --- |
548
+ | `enabled` | `false` | Explicit opt-in; the manager refuses to construct otherwise |
549
+ | `namespace` | — | Key prefix (e.g. `authcore`) |
550
+ | `ttl` | `2592000` (30d) | Absolute session lifetime in seconds |
551
+ | `idleTimeout` | `86400` (24h) | Rolling idle timeout in seconds (`null` disables) |
552
+ | `rolling` | `true` | `touch` extends the idle boundary |
553
+ | `touchInterval` | `300` | Minimum seconds between touch writes (throttling) |
554
+ | `maxSessionsPerUser` | `20` | Eviction ceiling, enforced atomically inside the create script |
555
+ | `securityVersion` | off | Global per-user version; a bump invalidates older sessions |
556
+ | `encryption` | off | AES-256-GCM envelopes (`enabled` + `encryptionKeyProvider`) |
557
+ | `jtiIndex` | off | `jti -> userId` map so validate/touch/rotate work without `userId` |
558
+ | `checkRevocationStore` | `false` | Consult the revocation store during validation |
559
+ | `bindingPolicy` | `disabled` | `strict` rejects on IP/UA/device mismatch, `advisory` reports it |
560
+ | `circuitBreaker` | off | `failureThreshold` 10, `resetTimeoutMs` 30_000, `halfOpenMaxRequests` 5 |
561
+ | `enableCreateIdempotency` | `false` | Idempotent `create()` via `idempotencyKey` |
562
+ | `retainConsumedTombstones` | `true` | Keep consumed records (TTL-bounded) for replay detection |
563
+
564
+ ### Session errors
565
+
566
+ All session errors extend `SessionError` (itself a `RedisError`):
567
+ `SessionNotFoundError`, `SessionExpiredError`, `SessionInvalidError`,
568
+ `SessionRevokedError`, `SessionRotationError`, `SessionConcurrencyError`,
569
+ `SessionStorageError` (503 — the only one that trips the circuit breaker),
570
+ `SessionSerializationError`, `SessionConfigurationError`,
571
+ `SessionBindingError`. Public error messages
572
+ never contain raw tokens or identifiers.
573
+
574
+ - Tested end-to-end against standalone, Sentinel and Cluster — the
575
+ Sentinel topology (`test/infra/`) includes a **live failover drill**
576
+ (`sentinel-failover-probe.mjs`): sessions created before a master
577
+ outage stay valid on the promoted replica. The real-Redis suites skip
578
+ cleanly when Redis is unreachable.
579
+
580
+ ### Migration from `RedisSessionStore` (removed)
581
+
582
+ `RedisSessionStore` and its types (`SessionStore`, `LegacySessionRecord`,
583
+ `CreateSessionInput`, `UpdateSessionInput`) are **removed**. The data and
584
+ token models are incompatible, so old sessions cannot be read through the
585
+ new API:
586
+
587
+ - The legacy store keyed sessions by a caller-supplied `jti` and stored
588
+ raw JSON; the subsystem persists token-derived jtis (`SHA-256(token)`)
589
+ in versioned envelopes and validates **tokens**, not jtis.
590
+ - **Existing sessions must be re-established** (users re-authenticate).
591
+ For a smooth cutover, validate old tokens through a time-boxed
592
+ app-side shim (legacy jti lookup in your own data → mint a new session
593
+ via `create()`) and remove the shim once the old tokens age out.
594
+ - Do not run both against the same Redis namespace — records and indexes
595
+ share the same key layout but use different formats.
596
+
597
+ ## Errors
598
+
599
+ ```ts
600
+ import { RedisError } from 'ioredis-toolkit';
601
+
602
+ try {
603
+ await client.select(1);
604
+ } catch (error) {
605
+ if (error instanceof RedisError && error.code === 'CLUSTER_MODE') {
606
+ // SELECT is not available in cluster mode
607
+ }
608
+ }
609
+ ```
610
+
611
+ ## Logging
612
+
613
+ Every component accepts a pino-compatible logger (`trace/debug/info/warn/error/fatal` + `child`). Defaults to `console`.
614
+
615
+ ```ts
616
+ import { createLogger } from 'pino';
617
+ const logger = createLogger();
618
+ const client = new RedisClient(config, logger);
619
+ ```
620
+
621
+ ## Mode compatibility
622
+
623
+ | Operation | Standalone | Sentinel | Cluster |
624
+ | --- | --- | --- | --- |
625
+ | Single-key commands (get/set/hash/set/zset/incr/...) | ✅ | ✅ | ✅ |
626
+ | `mget` / `mset` / `mgetClusterAware` | ✅ | ✅ | ✅ slot-grouped |
627
+ | `scanIterator` / `deletePattern` / `keys` | ✅ | ✅ | ✅ all nodes scanned |
628
+ | Pipelines | ✅ | ✅ | ✅ (same-slot keys per pipeline) |
629
+ | Lua scripts (`eval` / `evalsha` / `scriptLoad`) | ✅ | ✅ | ✅ (keys declared in `KEYS`, one slot) |
630
+ | `RedisRevocationStore` | ✅ | ✅ | ✅ batch ops slot-grouped |
631
+ | `createSessionManager` | ✅ | ✅ | ✅ hash-tagged Lua scripts, slot-grouped fan-out |
632
+ | `select(database)` | ✅ | ✅ | ❌ (Redis limitation) |
633
+ | Hash-tag keys `{tag}:...` | ✅ | ✅ | ✅ same slot |
634
+
635
+ ## Development
636
+
637
+ ```bash
638
+ npm install
639
+ npm run build # tsc + asset copy (Lua scripts land in dist/session/scripts)
640
+ npm run typecheck # src + test + scripts (tsconfig.test.json)
641
+ npm test # vitest
642
+ npm run test:watch
643
+ ```
644
+
645
+ The test suite covers the client, cache and rate limiter (including cluster-mode behavior) using in-memory fakes — no Redis server required. The session suites are gated: they run against real Redis (`localhost:6379`, or `REDIS_MODE=cluster` / `REDIS_MODE=sentinel` with the compose topologies in `test/infra/`) and skip cleanly when it is unreachable.