ioredis-toolkit 0.0.4 → 0.0.6

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.
package/README.md CHANGED
@@ -1,111 +1,93 @@
1
1
  # ioredis-toolkit
2
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
3
+ Production-grade, type-safe Redis infrastructure for distributed systems: a unified client, cache, rate limiter, distributed lock, pub/sub, health checking, and session management — all working in **standalone**, **sentinel**, and **cluster** modes.
48
4
 
5
+ <a name="installation"></a>
49
6
  ## Installation
50
7
 
51
8
  ```bash
52
9
  npm install ioredis-toolkit ioredis zod
53
10
  ```
54
11
 
55
- ## Quick start
12
+ <a name="quick-start"></a>
13
+ ## Quick Start
56
14
 
57
15
  ```ts
58
- import { RedisClient, Cache, RateLimiter } from 'ioredis-toolkit';
16
+ import { RedisClientWrapper, Cache, RateLimiter } from 'ioredis-toolkit';
59
17
 
60
- // 1. Create the client
61
- const client = new RedisClient({
18
+ // 1. Create the Redis client (standalone by default)
19
+ const client = new RedisClientWrapper({
62
20
  mode: 'standalone',
63
21
  host: 'localhost',
64
22
  port: 6379,
65
23
  });
66
24
 
67
- // 2. Cache
25
+ // 2. Cache — JSON serialization, TTL, namespaces, compression
68
26
  const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
69
27
  await cache.set('user:1', { name: 'alice' });
70
28
  const user = await cache.get('user:1');
71
29
 
72
- // 3. Rate limiting
30
+ // 3. Rate limiting — per-route, per-IP, per-user
73
31
  const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
74
32
  const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
75
33
  if (!result.allowed) {
76
34
  // HTTP 429, set Retry-After: result.retryAfter
77
35
  }
78
36
 
79
- // 4. Shut down gracefully
37
+ // 4. Graceful shutdown
80
38
  await client.close();
81
39
  ```
82
40
 
83
- ## Connection modes
41
+ <a name="topology"></a>
42
+ ## Topology & Configuration
84
43
 
85
- All features behave identically in every mode. Choose the mode via the `mode` config field.
44
+ Choose the Redis topology via the `mode` config field. All features behave identically across modes; only the underlying connection changes.
86
45
 
87
- ### Standalone
46
+ | Option | Type | Default | Description |
47
+ |---|---|---|---|
48
+ | `mode` | `'standalone' \| 'sentinel' \| 'cluster'` | `'standalone'` | Redis topology |
49
+ | `host` | `string` | `'localhost'` | Standalone host |
50
+ | `port` | `number` | `6379` | Standalone port |
51
+ | `url` | `string` | — | Full Redis URL (e.g. `redis://:pass@host:6379/0`) |
52
+ | `password` | `string` | — | Authentication password |
53
+ | `username` | `string` | — | Redis ACL username |
54
+ | `database` | `number` | `0` | Database index (standalone/sentinel only) |
55
+ | `sentinelNodes` | `Array<{host, port}>` | — | Sentinel nodes |
56
+ | `sentinelMasterName` | `string` | — | Sentinel master name |
57
+ | `clusterNodes` | `Array<{host, port}>` | — | Cluster nodes |
58
+ | `maxRetries` | `number` | `3` | Max reconnect attempts |
59
+ | `retryDelay` | `number` | `1000` | Base reconnect delay (ms) |
60
+ | `connectionTimeout` | `number` | `5000` | Connect timeout (ms) |
61
+ | `defaultTTL` | `number` | `3600` | Default cache TTL (seconds) |
62
+ | `compressionThreshold` | `number` | `1024` | Cache compression threshold (bytes) |
63
+ | `slowCommandThreshold` | `number` | `1000` | Log commands slower than this (ms) |
64
+ | `tls` | `boolean` | `false` | Enable TLS (`tlsOptions` for CA/cert/key) |
65
+ | `maxFanOutConcurrency` | `number` | `8` | Max concurrent fan-out operations in cluster |
66
+ | `maxBatchSize` | `number` | `500` | Max batch size for SCAN operations |
67
+
68
+ Configurations are validated with Zod (`RedisConfigSchema`). See [mode-specific configs](#mode-configs) below.
69
+
70
+ ### Mode-Specific Configurations
71
+
72
+ #### Standalone
88
73
 
89
74
  ```ts
90
- const client = new RedisClient({
75
+ const client = new RedisClientWrapper({
91
76
  mode: 'standalone',
92
77
  host: 'localhost',
93
78
  port: 6379,
94
79
  password: 'secret',
95
80
  database: 0,
96
81
  });
97
- ```
98
-
99
- Or with a URL:
100
82
 
101
- ```ts
102
- const client = new RedisClient({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });
83
+ // Or with a URL:
84
+ const client = new RedisClientWrapper({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });
103
85
  ```
104
86
 
105
- ### Sentinel
87
+ #### Sentinel
106
88
 
107
89
  ```ts
108
- const client = new RedisClient({
90
+ const client = new RedisClientWrapper({
109
91
  mode: 'sentinel',
110
92
  sentinelNodes: [
111
93
  { host: 'sentinel1', port: 26379 },
@@ -116,10 +98,10 @@ const client = new RedisClient({
116
98
  });
117
99
  ```
118
100
 
119
- ### Cluster
101
+ #### Cluster
120
102
 
121
103
  ```ts
122
- const client = new RedisClient({
104
+ const client = new RedisClientWrapper({
123
105
  mode: 'cluster',
124
106
  clusterNodes: [
125
107
  { host: 'redis1', port: 7000 },
@@ -130,25 +112,60 @@ const client = new RedisClient({
130
112
  });
131
113
  ```
132
114
 
133
- ### Common configuration
115
+ <a name="redisclient"></a>
116
+ ## RedisClientWrapper — The Unified Client
117
+
118
+ The `RedisClientWrapper` is the core of the library. It automatically adapts to the configured topology (standalone, sentinel, or cluster) and lazily creates and shares sub-components: `cache`, `pubsub`, `lock`, `rateLimiter`, and `session`.
119
+
120
+ ### Flow Diagram
121
+
122
+ ```text
123
+ +--------------------+ +----------------------+ +---------------------+
124
+ | RedisClientWrapper| --- | Sub-components | --- | Redis (underlying) |
125
+ | (config mode) | | cache/pubsub/lock | | ioredis client |
126
+ +--------------------+ +----------------------+ +---------------------+
127
+ ^ ^ |
128
+ | | |
129
+ lazy init lazy init lazy init
130
+ | | |
131
+ +-----v------+ +-----v-------+ +-----v-------+
132
+ | get cache | | get lock | | get rateLimiter|
133
+ +------------+ +-------------+ +---------------+
134
+ ```
134
135
 
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) |
136
+ ### Convenience Accessors
137
+
138
+ `RedisClientWrapper` lazily creates and shares one instance of each sub-component. Access them as properties — no manual wiring needed:
139
+
140
+ ```ts
141
+ // Shared instances (created on first access)
142
+ client.cache; // Cache
143
+ client.pubsub; // PubSub
144
+ client.lock; // DistributedLock
145
+ client.rateLimiter; // RateLimiter
144
146
 
145
- Configs are validated with Zod (`RedisConfigSchema`).
147
+ // Use them directly:
148
+ await client.cache.set('user:1', { name: 'alice' });
149
+ const ok = await client.lock.acquire('order:42');
150
+ const { allowed } = await client.rateLimiter.consume('/api', 'ip-1', { limit: 5, duration: 60 });
146
151
 
147
- ## RedisClient
152
+ // Replace with custom instances:
153
+ client.cache = new Cache(client, { defaultTTL: 600 });
154
+ client.rateLimiter = new RateLimiter(client, { limit: 50, duration: 10 });
155
+ ```
148
156
 
149
- Full reference is in the generated `.d.ts` (JSDoc with params + examples). Highlights:
157
+ ### Configuration
150
158
 
151
- ### Strings & keys
159
+ ```ts
160
+ interface RedisClientOptions {
161
+ config: RedisConfigInput;
162
+ logger?: LoggerLike;
163
+ }
164
+ ```
165
+
166
+ ### Basic Commands
167
+
168
+ #### Strings & Keys
152
169
 
153
170
  ```ts
154
171
  await client.set('name', 'alice'); // SET
@@ -165,20 +182,19 @@ await client.incr('visits'); // counters
165
182
  await client.decr('stock:sku-1');
166
183
  ```
167
184
 
168
- ### Batch operations (cluster-safe)
185
+ #### Batch Operations (Cluster-Safe)
169
186
 
170
187
  ```ts
171
188
  await client.mset(['user:1', 'alice'], ['user:2', 'bob']); // grouped by hash slot
172
189
  const [a, b] = await client.mget('user:1', 'user:2'); // routed per slot
173
190
  ```
174
191
 
175
- ### Hashes, sets, sorted sets
192
+ #### Hashes, Sets, Sorted Sets
176
193
 
177
194
  ```ts
178
195
  await client.hset('user:1', 'name', 'alice');
179
196
  await client.hget('user:1', 'name');
180
197
  await client.hgetall('user:1');
181
- await client.hdel('user:1', 'age');
182
198
 
183
199
  await client.sadd('tags:1', 'redis', 'typescript');
184
200
  await client.smembers('tags:1');
@@ -190,7 +206,7 @@ await client.zrange('leaderboard', 0, 9);
190
206
  await client.zrem('leaderboard', 'p1');
191
207
  ```
192
208
 
193
- ### Scanning & pipelines
209
+ #### Scanning & Pipelines
194
210
 
195
211
  ```ts
196
212
  // Scans every node in cluster mode
@@ -206,7 +222,7 @@ pipeline.incr('b');
206
222
  const results = await pipeline.exec();
207
223
  ```
208
224
 
209
- ### Cluster helpers
225
+ #### Cluster Helpers
210
226
 
211
227
  ```ts
212
228
  client.isCluster(); // boolean
@@ -221,23 +237,11 @@ await client.mgetClusterAware([...]); // slot-grouped multi-get
221
237
  client.getClusterInfo(); // topology snapshot
222
238
  ```
223
239
 
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)
240
+ #### Lua Scripts (Atomic Server-Side Logic)
236
241
 
237
242
  ```ts
238
243
  // 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.
244
+ // Cluster mode: every key touched inside the script must be declared in KEYS and share one hash slot.
241
245
  const script = `
242
246
  if redis.call('GET', KEYS[1]) == ARGV[1] then
243
247
  return redis.call('DEL', KEYS[1])
@@ -253,9 +257,7 @@ const sha = await client.scriptLoad(script);
253
257
  await client.evalsha(sha, script, 1, 'lock:job', 'owner-2'); // 0 (not owner)
254
258
  ```
255
259
 
256
- ### Convenience accessors
257
-
258
- `RedisClient` lazily creates and shares one instance of each sub-component. Access them as properties — no manual wiring needed:
260
+ #### Convenience Accessors (Session, PubSub, Lock, Cache)
259
261
 
260
262
  ```ts
261
263
  client.cache; // shared Cache (created on first access)
@@ -263,21 +265,37 @@ client.pubsub; // shared PubSub
263
265
  client.lock; // shared DistributedLock
264
266
  client.rateLimiter; // shared RateLimiter
265
267
 
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 });
268
+ // Session subsystem:
269
+ const { token, session } = await manager.service.create({ userId: 'user-42' });
270
+ const result = await manager.service.validate(token, { userId: 'user-42' });
269
271
  ```
270
272
 
271
- The corresponding setters replace the shared instance with a custom one (e.g. one built with different defaults):
273
+ ### Lifecycle
272
274
 
273
275
  ```ts
274
- client.cache = new Cache(client, config, logger);
275
- client.rateLimiter = new RateLimiter(client, { limit: 50, duration: 10 });
276
+ await client.ping(); // boolean
277
+ await client.close(); // graceful QUIT
278
+ client.raw; // raw ioredis client
276
279
  ```
277
280
 
281
+ <a name="cache"></a>
278
282
  ## Cache
279
283
 
280
- ### Basic usage
284
+ The `Cache` layer provides JSON serialization, optional gzip compression, namespaces, TTLs, hash helpers, and pattern-based cleanup. Works in all three modes.
285
+
286
+ ### Class: Cache
287
+
288
+ ```ts
289
+ constructor(client: RedisClientWrapper, config: CacheInputConfig, logger: LoggerLike = defaultLogger)
290
+ ```
291
+
292
+ | Param | Type | Description |
293
+ |---|---|---|
294
+ | `client` | `RedisClientWrapper` | The underlying Redis client |
295
+ | `config` | `CacheInputConfig` | `defaultTTL` (seconds) and `compressionThreshold` (bytes) |
296
+ | `logger` | `LoggerLike` | Optional pino-compatible logger; defaults to `console` |
297
+
298
+ #### basic usage
281
299
 
282
300
  ```ts
283
301
  const cache = new Cache(client, { defaultTTL: 3600, compressionThreshold: 1024 });
@@ -291,23 +309,21 @@ await cache.expire('user:1', 60);
291
309
  await cache.ttl('user:1');
292
310
  ```
293
311
 
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.
312
+ #### Namespaces
297
313
 
298
- ### Namespaces
314
+ Values stored under a namespace are prefixed with `namespace:`, keeping keys isolated.
299
315
 
300
316
  ```ts
301
317
  await cache.set('token', 'abc', { namespace: 'auth' });
302
318
  await cache.get('token', 'auth'); // 'abc'
303
- await cache.get('token'); // null
319
+ await cache.get('token'); // null (no namespace)
304
320
 
305
321
  await cache.clearNamespace('sessions'); // delete every 'sessions:*' key
306
322
  await cache.keys('session:*'); // list keys (cluster-safe)
307
323
  await cache.deletePattern('temp:*'); // delete by pattern
308
324
  ```
309
325
 
310
- ### Atomic & batch operations
326
+ #### Atomic & Batch Operations
311
327
 
312
328
  ```ts
313
329
  await cache.setNX('job:1', 'worker-1', { ttl: 60 }); // only if missing
@@ -320,7 +336,7 @@ await cache.mset({ 'user:1': alice, 'user:2': bob }, { ttl: 300 }); // slot-grou
320
336
  const [a, b] = await cache.mget(['user:1', 'user:2']);
321
337
  ```
322
338
 
323
- ### Hash helpers
339
+ #### Hash Helpers
324
340
 
325
341
  ```ts
326
342
  await cache.hset('user:1', 'age', 30);
@@ -328,274 +344,589 @@ await cache.hget('user:1', 'age'); // 30
328
344
  await cache.hgetall('user:1'); // { age: 30, ... }
329
345
  ```
330
346
 
347
+ #### Compression
348
+
349
+ Values larger than `compressionThreshold` bytes are gzip-compressed transparently. Set `compress: false` to disable compression for a single write.
350
+
351
+ ```ts
352
+ await cache.set('large-data', bigBufferOrObject, { compress: false });
353
+ ```
354
+
355
+ ### Cache Interface — Method Documentation
356
+
357
+ | Method | Description | Args | Returns |
358
+ |---|---|---|---|
359
+ | `set(key, value, options?)` | Store a value in cache | `key: string`, `value: T`, `options: CacheOptions = {}` | `Promise<boolean>` — `true` when stored |
360
+ | `get(key, namespace?)` | Read a cached value | `key: string`, `namespace?: string` | `Promise<T \| null>` — parsed value or `null` |
361
+ | `setNX(key, value, options?)` | Store only if key does not exist | `key: string`, `value: T`, `options: CacheOptions = {}` | `Promise<boolean>` — `true` only when stored |
362
+ | `setEXNX(key, value, options?)` | Store atomically with TTL (SET ... EX NX) | `key: string`, `value: T`, `options: CacheOptions = {}` | `Promise<boolean>` — `true` only when stored |
363
+ | `mget(keys, namespace?)` | Read multiple keys (cluster-safe) | `keys: string[]`, `namespace?: string` | `Promise<(T \| null)[]>` — values in input order |
364
+ | `mset(entries, options?)` | Store multiple entries (cluster-safe, one pipeline per slot) | `entries: Record<string, T>`, `options: CacheOptions = {}` | `Promise<boolean>` — `true` when all stored |
365
+ | `delete(key, namespace?)` | Delete a cache key | `key: string`, `namespace?: string` | `Promise<boolean>` — `true` if key existed |
366
+ | `exists(key, namespace?)` | Check if key exists | `key: string`, `namespace?: string` | `Promise<boolean>` — `true` if key exists |
367
+ | `expire(key, ttl, namespace?)` | Set TTL on existing key | `key: string`, `ttl: number`, `namespace?: string` | `Promise<boolean>` — `true` if TTL applied |
368
+ | `ttl(key, namespace?)` | Get remaining TTL in seconds | `key: string`, `namespace?: string` | `Promise<number>` — seconds left (`-2` if missing, `-1` if no TTL) |
369
+ | `increment(key, by?, namespace?)` | Atomically increment counter | `key: string`, `by?: number` (default `1`), `namespace?: string` | `Promise<number>` — new counter value |
370
+ | `decrement(key, by?, namespace?)` | Atomically decrement counter | `key: string`, `by?: number` (default `1`), `namespace?: string` | `Promise<number>` — new counter value |
371
+ | `hget(key, field, namespace?)` | Read a hash field | `key: string`, `field: string`, `namespace?: string` | `Promise<T \| null>` — field value JSON-parsed or raw string |
372
+ | `hset(key, field, value, namespace?)` | Write a hash field | `key: string`, `field: string`, `value: any`, `namespace?: string` | `Promise<boolean>` — `true` if new field created |
373
+ | `hgetall(key, namespace?)` | Read all hash fields | `key: string`, `namespace?: string` | `Promise<Record<string, T>>` — field-value map JSON-parsed |
374
+ | `deletePattern(pattern, namespace?)` | Delete keys matching glob pattern (cluster-safe) | `pattern: string`, `namespace?: string` | `Promise<number>` — number of deleted keys |
375
+ | `keys(pattern, namespace?)` | List keys matching glob pattern (cluster-safe) | `pattern: string`, `namespace?: string` | `Promise<string[]>` — matching keys |
376
+ | `clearNamespace(namespace?)` | Delete every key inside a namespace | `namespace: string` | `Promise<number>` — number of deleted keys |
377
+
378
+ <a name="ratelimiter"></a>
331
379
  ## RateLimiter
332
380
 
333
- Generic rate limiting for **any** resource — routes, API endpoints, users, IPs, API keys, database writes, email sends, webhooks...
381
+ Generic rate limiting for any resource — routes, API endpoints, users, IPs, API keys, database writes, email sends, webhooks...
382
+
383
+ ### Algorithm Selection
384
+
385
+ | Algorithm | Key Type | Characteristics |
386
+ |---|---|---|
387
+ | `sliding` (default) | sorted set + atomic Lua | Smoothest; precise rolling window |
388
+ | `fixed` | counter (`INCR`/`EXPIRE`) | Cheapest; window resets at fixed boundaries |
334
389
 
335
390
  ```ts
391
+ // Sliding window (default)
336
392
  const limiter = new RateLimiter(client, { limit: 100, duration: 60 });
337
393
 
338
- const result = await limiter.consume('/api/login', 'ip-10.0.0.1');
394
+ // Fixed window
395
+ const fixed = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
339
396
  ```
340
397
 
341
- ### Result
398
+ ### RateLimiter — Type Documentation
399
+
400
+ #### Class: RateLimiter
401
+
402
+ ```ts
403
+ constructor(client: RedisClientWrapper, options: RateLimitOptionsInput = {}, logger: LoggerLike = defaultLogger)
404
+ ```
405
+
406
+ | Param | Type | Default | Description |
407
+ |---|---|---|---|
408
+ | `client` | `RedisClientWrapper` | — | The underlying Redis client |
409
+ | `options.limit` | `number` | `100` | Maximum allowed requests within `duration` |
410
+ | `options.duration` | `number` | `60` | Window length in seconds |
411
+ | `options.algorithm` | `'fixed' \| 'sliding'` | `'sliding'` | Window algorithm |
412
+ | `options.namespace` | `string` | `'ratelimit'` | Redis key prefix |
413
+
414
+ #### RateLimitOptions type
415
+
416
+ ```ts
417
+ interface RateLimitOptions {
418
+ limit?: number; // Max allowed requests within duration
419
+ duration?: number; // Window length in seconds
420
+ algorithm?: RateLimitAlgorithm; // 'fixed' | 'sliding'
421
+ namespace?: string; // Key prefix
422
+ }
423
+ ```
424
+
425
+ #### RateLimitResult type
342
426
 
343
427
  ```ts
344
428
  interface RateLimitResult {
345
429
  allowed: boolean; // request may proceed
346
430
  limit: number; // configured max
347
431
  used: number; // requests in current window
348
- remaining: number; // left in the window
432
+ remaining: number; // left in the window (limit - used, floored at 0)
349
433
  resetAt: number; // epoch ms when the window resets
350
434
  retryAfter: number; // seconds to wait (0 when allowed)
351
435
  }
352
436
  ```
353
437
 
354
- ### Usage in an HTTP handler
438
+ #### RateLimitAlgorithm type
355
439
 
356
440
  ```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
- }
441
+ type RateLimitAlgorithm = 'fixed' | 'sliding';
362
442
  ```
363
443
 
364
- ### Peek & reset
444
+ ### RateLimiter Methods
445
+
446
+ | Method | Description | Args | Returns |
447
+ |---|---|---|---|
448
+ | `consume(resource, identifier, options?)` | Consume one unit of capacity | `resource: string`, `identifier: string`, `options: RateLimitOptions = {}` | `Promise<RateLimitResult>` — limit state |
449
+ | `check(resource, identifier, options?)` | Peek at current limit state (no consumption) | `resource: string`, `identifier: string`, `options: RateLimitOptions = {}` | `Promise<RateLimitResult>` — current limit state |
450
+ | `reset(resource, identifier, namespace?)` | Reset counter, grant full capacity | `resource: string`, `identifier: string`, `namespace?: string` | `Promise<boolean>` — `true` if counter existed and was removed |
451
+ | `makeKey(resource, identifier, namespace?)` | Build the Redis key for a resource + identifier | `resource: string`, `identifier: string`, `namespace?: string` | `string` — e.g. `'ratelimit:/api/login:ip-10.0.0.1'` |
452
+
453
+ #### Private Methods (algorithmic)
454
+
455
+ | Method | Description |
456
+ |---|---|
457
+ | `consumeFixed(key, limit, duration)` | Fixed-window: `INCR`/`EXPIRE` based |
458
+ | `consumeSliding(key, limit, duration)` | Sliding-window: atomic Lua over sorted set |
459
+ | `checkFixed(key, limit, duration)` | Fixed-window peek |
460
+ | `checkSliding(key, limit, duration)` | Sliding-window peek |
461
+
462
+ <a name="lock"></a>
463
+ ## DistributedLock
464
+
465
+ Atomic distributed mutual-exclusion lock backed by Redis. Works in standalone, sentinel, and cluster modes.
466
+
467
+ ### Class: DistributedLock
365
468
 
366
469
  ```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
470
+ constructor(client: RedisClientWrapper, logger: LoggerLike = defaultLogger, options: Partial<DistributedLockOptions> = {})
369
471
  ```
370
472
 
371
- ### Algorithms
473
+ | Param | Type | Default | Description |
474
+ |---|---|---|---|
475
+ | `client` | `RedisClientWrapper` | — | The underlying Redis client |
476
+ | `logger` | `LoggerLike` | `defaultLogger` | Optional pino-compatible logger |
477
+ | `options.ttl` | `number` | `30000` | Lock TTL in milliseconds |
478
+ | `options.retryCount` | `number` | `3` | Number of acquisition attempts |
479
+ | `options.retryDelay` | `number` | `200` | Base delay between retries (ms), grows exponentially |
372
480
 
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 |
481
+ #### DistributedLockOptions type
377
482
 
378
483
  ```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
484
+ type DistributedLockOptions = {
485
+ ttl?: number; // Lock TTL in milliseconds. Default: `30000`.
486
+ retryCount?: number; // Number of acquisition attempts. Default: `3`.
487
+ retryDelay?: number; // Base delay between retries in ms (grows exponentially). Default: `200`.
488
+ };
381
489
  ```
382
490
 
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
491
+ #### LockInfo type
386
492
 
387
493
  ```ts
388
- const lock = new DistributedLock(client, { ttl: 30000, retryCount: 3, retryDelay: 200 });
494
+ type LockInfo = {
495
+ locked: boolean; // Whether the lock is currently held
496
+ ttl?: number; // Remaining TTL in seconds (when held and TTL set)
497
+ lockId?: string; // Unique owner id of the lock
498
+ };
499
+ ```
389
500
 
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)
501
+ ### DistributedLock Methods
502
+
503
+ | Method | Description | Args | Returns |
504
+ |---|---|---|---|
505
+ | `acquire(key, ttl?)` | Attempt to acquire the lock | `key: string`, `ttl?: number` (ms) | `Promise<boolean>` — `true` when acquired |
506
+ | `release(key)` | Release the lock (owner-checked) | `key: string` | `Promise<boolean>` — `true` if released, `false` if not owned or missing |
507
+ | `releaseForce(key)` | Force-release without ownership check | `key: string` | `Promise<boolean>` `true` if a lock existed and was deleted |
508
+ | `extend(key, ttl?)` | Extend the lock TTL (owner-checked) | `key: string`, `ttl?: number` (ms) | `Promise<boolean>` — `true` if extended |
509
+ | `isLocked(key)` | Check if lock is held | `key: string` | `Promise<boolean>` — `true` if lock exists |
510
+ | `getLockInfo(key)` | Get lock details | `key: string` | `Promise<LockInfo>` — `{ locked, ttl, lockId }` |
511
+ | `getLockOwner(key)` | Get the lock owner ID | `key: string` | `Promise<string \| null>` — lock id or `null` |
512
+ | `getLockTTL(key)` | Get remaining TTL in seconds | `key: string` | `Promise<number>` — seconds left (`0` when not held or expired) |
513
+ | `withLock(key, fn, options?)` | Acquire lock, run critical section, auto-extend, always release | `key: string`, `fn: () => Promise<T>`, `options: DistributedLockOptions = {}` | `Promise<T>` — return value of `fn` |
514
+ | `cleanupAll()` | Delete every lock key (`lock:*`) from Redis | — | `Promise<number>` — number of deleted locks |
515
+
516
+ <a name="pubsub"></a>
517
+ ## Pub/Sub
518
+
519
+ Redis Pub/Sub with a dedicated publisher and subscriber connection. Messages are JSON-serialized on publish and auto-parsed on delivery. Extends `EventEmitter` and emits `'error'` on subscriber failures.
520
+
521
+ ### Class: PubSub
522
+
523
+ ```ts
524
+ constructor(publisher: RedisClientWrapper, logger: LoggerLike = defaultLogger)
402
525
  ```
403
526
 
404
- ## PubSub
527
+ | Param | Type | Description |
528
+ |---|---|---|
529
+ | `publisher` | `RedisClientWrapper` | A Redis client used for publishing |
530
+ | `logger` | `LoggerLike` | Optional pino-compatible logger; defaults to `console` |
531
+
532
+ ### PubSub — Type Documentation
533
+
534
+ #### Event Map
535
+
536
+ | Event | Payload |
537
+ |---|---|
538
+ | `message` | `{ channel: string, message: string }` |
539
+ | `pmessage` | `{ pattern: string, channel: string, message: string }` |
540
+ | `subscribe` | `{ channel: string, count: number }` |
541
+ | `unsubscribe` | `{ channel: string, count: number }` |
542
+ | `psubscribe` | `{ pattern: string, count: number }` |
543
+ | `punsubscribe` | `{ pattern: string, count: number }` |
544
+ | `error` | `Error` |
545
+
546
+ ### PubSub Methods
547
+
548
+ | Method | Description | Args | Returns |
549
+ |---|---|---|---|
550
+ | `connectSubscriber(config)` | Open a dedicated subscriber connection (idempotent) | `config: RedisConfig` | `Promise<void>` |
551
+ | `publish(channel, message)` | Publish a message to a channel | `channel: string`, `message: T` (string or JSON-serializable) | `Promise<number>` — number of subscribers that received the message |
552
+ | `subscribe(channel, handler)` | Subscribe a handler to a channel | `channel: string`, `handler: (data: T) => void` | `Promise<void>` |
553
+ | `unsubscribe(channel, handler?)` | Remove a handler (or all handlers) from a channel | `channel: string`, `handler?: (data: T) => void` | `Promise<void>` |
554
+ | `psubscribe(pattern, handler)` | Subscribe to all channels matching a glob pattern | `pattern: string`, `handler: (data: { channel: string; message: T }) => void` | `Promise<void>` |
555
+ | `punsubscribe(pattern, handler?)` | Remove a handler from a pattern subscription | `pattern: string`, `handler?: (data: any) => void` | `Promise<void>` |
556
+ | `close()` | Close the subscriber connection and clear all subscriptions | — | `Promise<void>` — closes subscriber only; publisher is not closed |
557
+ | `getStats()` | Return subscription statistics | — | `PubSubStats` — `{ subscriptions, patternSubscriptions, connected }` |
558
+
559
+ #### PubSubStats type
405
560
 
406
561
  ```ts
407
- const pubsub = new PubSub(client);
408
- await pubsub.connectSubscriber(redisConfig); // dedicated subscriber connection
562
+ type PubSubStats = {
563
+ subscriptions: number;
564
+ patternSubscriptions: number;
565
+ connected: boolean;
566
+ };
567
+ ```
409
568
 
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
569
+ #### PubSubMessage type
414
570
 
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 }
571
+ ```ts
572
+ type PubSubMessage<T = unknown> = {
573
+ channel: string;
574
+ message: T;
575
+ };
423
576
  ```
424
577
 
578
+ <a name="health"></a>
425
579
  ## HealthChecker
426
580
 
581
+ Periodic health monitoring with callbacks.
582
+
583
+ ### Class: HealthChecker
584
+
585
+ ```ts
586
+ constructor(client: RedisClientWrapper, logger: LoggerLike = defaultLogger)
587
+ ```
588
+
589
+ | Param | Type | Description |
590
+ |---|---|---|
591
+ | `client` | `RedisClientWrapper` | The underlying Redis client |
592
+ | `logger` | `LoggerLike` | Optional pino-compatible logger; defaults to `console` |
593
+
594
+ ### HealthChecker Methods
595
+
596
+ | Method | Description | Args | Returns |
597
+ |---|---|---|---|
598
+ | `start(interval?)` | Start periodic health checks | `interval?: number` (ms, default `10000`) | `void` |
599
+ | `stop()` | Stop the health checker | — | `void` |
600
+ | `check()` | Run a single health check (ping + latency) | — | `Promise<HealthStatus>` — current health status |
601
+ | `getStatus()` | Get the most recent health check result | — | `HealthStatus \| null` — last result (null before first check) |
602
+ | `onChange(callback)` | Register a callback for status changes | `callback: (status: HealthStatus) => void` | `void` |
603
+ | `waitForHealthy(timeout?)` | Wait until healthy (polling) | `timeout?: number` (ms, default `30000`) | `Promise<boolean>` — `true` if became healthy within timeout |
604
+
605
+ #### HealthStatus type
606
+
427
607
  ```ts
428
- const health = new HealthChecker(client);
429
- health.start(10000); // check every 10s
430
- health.onChange((status) => console.log(status));
608
+ type HealthStatus = {
609
+ healthy: boolean;
610
+ status: 'healthy' | 'degraded' | 'unhealthy';
611
+ latency: number; // ms
612
+ timestamp: Date;
613
+ details: {
614
+ ping: boolean;
615
+ connections?: number;
616
+ memory?: string;
617
+ };
618
+ };
619
+ ```
620
+
621
+ <a name="session"></a>
622
+ ## Session Subsystem
431
623
 
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();
624
+ The production session stack (`createSessionManager`): validation with fail-closed semantics, rotation with retry-safe idempotency, throttled touches, idle/absolute expiry, per-user eviction ceilings, security versioning, optional AES-256-GCM encryption at rest, fail-closed circuit breaker, metrics and health. Cluster-safe by construction.
625
+
626
+ ### Function: createSessionManager
627
+
628
+ ```ts
629
+ createSessionManager(options: SessionManagerOptions): SessionManager
436
630
  ```
437
631
 
438
- ## Revocation store
632
+ | Param | Type | Description |
633
+ |---|---|---|
634
+ | `options.client` | `RedisClientWrapper` | The underlying Redis client |
635
+ | `options.config` | `PartialSessionConfig` | Session configuration (see SessionConfig type) |
636
+ | `options.encryptionKeyProvider?` | `SessionKeyProvider` | REQUIRED when `config.encryption.enabled` is true |
637
+ | `options.revocationStore?` | `RevocationStore` | External revocation store (JWT jti denylists etc.) |
638
+ | `options.metricsAdapter?` | `SessionMetricsAdapter` | Metrics adapter (no-op without it) |
639
+ | `options.circuitBreaker?` | `SessionCircuitBreaker` | Optional circuit breaker |
640
+ | `options.now?` | `() => number` | Injectable clock for tests |
439
641
 
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`.
642
+ #### SessionManagerOptions type
441
643
 
442
644
  ```ts
443
- import { RedisRevocationStore } from 'ioredis-toolkit/session';
645
+ type SessionManagerOptions = {
646
+ client: RedisClientWrapper;
647
+ config?: PartialSessionConfig;
648
+ encryptionKeyProvider?: SessionKeyProvider;
649
+ revocationStore?: RevocationStore;
650
+ metricsAdapter?: SessionMetricsAdapter;
651
+ circuitBreaker?: SessionCircuitBreaker;
652
+ now?: () => number;
653
+ }
444
654
  ```
445
655
 
446
- ### RedisRevocationStore
656
+ #### WithSessionManagerOptions type
657
+
658
+ ```ts
659
+ type WithSessionManagerOptions = {
660
+ config?: PartialSessionConfig;
661
+ encryptionKeyProvider?: SessionKeyProvider;
662
+ metricsAdapter?: SessionMetricsAdapter;
663
+ now?: () => number;
664
+ }
665
+ ```
447
666
 
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.
667
+ ### SessionManager class
449
668
 
450
669
  ```ts
451
- const revocations = new RedisRevocationStore({
452
- client,
453
- keyPrefix: 'authcore:revoked:',
454
- });
670
+ new SessionManager(options: SessionManagerOptions)
671
+ ```
672
+
673
+ Properties:
674
+ - `config: SessionConfig` — normalized configuration
675
+ - `service: SessionService` — the application-facing API
676
+ - `repository: SessionRepository` — low-level data access
677
+ - `metrics: SessionMetrics` — metrics tracking
678
+ - `circuitBreaker: SessionCircuitBreaker \| null` — circuit breaker (or null)
679
+ - `health: SessionHealthChecker` — health checking
680
+ - `cookies: SessionCookieManager` — cookie helpers
681
+ - `token: SessionTokenManager` — token generation/hashing
682
+ - `keys: SessionKeyStrategy` — key strategy for userId mapping
683
+
684
+ ### SessionConfig type documentation
685
+
686
+ ```ts
687
+ type SessionConfig = {
688
+ enabled: boolean; // Explicit opt-in; manager refuses to construct otherwise
689
+ namespace: string; // Key prefix (e.g. 'authcore')
690
+ ttl: number; // Absolute session lifetime in seconds (default: 2592000 = 30d)
691
+ idleTimeout: number | null; // Rolling idle timeout in seconds (default: 86400 = 24h, null disables)
692
+ rolling: boolean; // touch extends the idle boundary (default: true)
693
+ touchInterval: number; // Minimum seconds between touch writes (default: 300)
694
+ maxSessionsPerUser: number; // Eviction ceiling, enforced atomically (default: 20)
695
+ securityVersion: { // Global per-user version; bump invalidates older sessions
696
+ enabled: boolean;
697
+ };
698
+ encryption: { // AES-256-GCM envelopes
699
+ enabled: boolean;
700
+ encryptionKeyProvider: SessionKeyProvider;
701
+ };
702
+ jtiIndex: { // jti -> userId map so validate/touch/rotate work without userId
703
+ enabled: boolean;
704
+ };
705
+ checkRevocationStore: boolean; // Consult revocation store during validation (default: false)
706
+ bindingPolicy: 'disabled' | 'strict' | 'advisory'; // strict rejects on mismatch, advisory reports it
707
+ circuitBreaker: { // Circuit breaker config
708
+ failureThreshold: number; // default: 10
709
+ resetTimeoutMs: number; // default: 30_000
710
+ halfOpenMaxRequests: number; // default: 5
711
+ };
712
+ enableCreateIdempotency: boolean; // Idempotent create() via idempotencyKey (default: false)
713
+ retainConsumedTombstones: boolean; // Keep consumed records (TTL-bounded) for replay detection (default: true)
714
+ limits: { // Hard limits
715
+ maxMetadataSize: number; // max size of metadata in bytes
716
+ maxSessionsPerUserHardCap: number; // hard cap for listing sessions
717
+ };
718
+ health: { // Health check config
719
+ // ...
720
+ };
721
+ };
722
+ ```
723
+
724
+ ### Session Service Methods
725
+
726
+ | Method | Description | Args | Returns |
727
+ |---|---|---|---|
728
+ | `create(input)` | Create a session | `SessionCreateInput` | `Promise<CreatedSession>` — `{ token, session, replayed? }` |
729
+ | `validate(token, options?)` | Validate a session token | `token: string`, `options: ValidateOptions = {}` | `Promise<SessionValidationResult>` — `{ valid: true, session? }` or `{ valid: false, reason }` |
730
+ | `touch(token, options?)` | Refresh activity (throttled) | `token: string`, `options: TouchOptions = {}` | `Promise<TouchOutcome>` — outcome code |
731
+ | `rotate(token, options?)` | Rotate to a new session (idempotent via nonce) | `token: string`, `options: RotateOptions = {}` | `Promise<RotatedSession>` — `{ token?, session, replayed }` |
732
+ | `update(token, patch, options?)` | Patch update (optimistic concurrency) | `token: string`, `patch: SessionUpdatePatch`, `options: UpdateOptions = {}` | `Promise<SessionRecord>` |
733
+ | `destroy(token, options?)` | Physically delete a session (idempotent) | `token: string`, `options: { userId?: string } = {}` | `Promise<boolean>` |
734
+ | `revoke(token, options?)` | Logically revoke a session | `token: string`, `options: { userId?: string } = {}` | `Promise<string>` — outcome (`'revoked' \| 'already_revoked' \|\| 'not_found'`) |
735
+ | `revokeAll(userId)` | Revoke every session of a user | `userId: string` | `Promise<number>` — number revoked |
736
+ | `deleteByUser(userId)` | Delete every session of a user (physical) | `userId: string` | `Promise<string[]>` — deleted JTIs |
737
+ | `findByUser(userId, options?)` | List a user's sessions (oldest first) | `userId: string`, `options: ListOptions = {}` | `Promise<SessionRecord[]>` |
738
+ | `list(userId, options?)` | Alias of findByUser | `userId: string`, `options: ListOptions = {}` | `Promise<SessionRecord[]>` |
739
+ | `setSecurityVersion(userId, version?)` | Bump security version, invalidate old sessions | `userId: string`, `version?: number` | `Promise<number>` — new version |
740
+ | `getSecurityVersion(userId)` | Get current security version | `userId: string` | `Promise<number \| null>` |
741
+ | `health()` | Dependency health check | — | `Promise<ReturnType<SessionHealthChecker['check']>>` |
742
+
743
+ #### SessionCreateInput type
744
+
745
+ ```ts
746
+ type SessionCreateInput = {
747
+ userId: string;
748
+ deviceId?: string; // stored when config.storeDeviceId is true
749
+ ipAddress?: string; // stored when config.storeIpAddress is true
750
+ userAgent?: string; // stored when config.storeUserAgent is true
751
+ metadata?: Record<string, unknown>; // bounded by config.maxMetadataSize
752
+ idempotencyKey?: string; // when provided + enableCreateIdempotency, enables idempotent create
753
+ };
754
+ ```
755
+
756
+ #### CreatedSession type
757
+
758
+ ```ts
759
+ type CreatedSession = {
760
+ token: string; // The raw session token. Give to client; store nowhere.
761
+ session: SessionRecord; // The persisted session record (contains only jti, never the token)
762
+ replayed?: boolean; // True when create was an idempotent replay
763
+ };
764
+ ```
765
+
766
+ #### SessionValidationResult type
767
+
768
+ ```ts
769
+ type SessionValidationResult =
770
+ | { valid: true; session: SessionRecord; binding?: BindingMismatch }
771
+ | { valid: false; reason: SessionInvalidReason; session?: never };
772
+ ```
773
+
774
+ #### SessionInvalidReason type
775
+
776
+ ```ts
777
+ type SessionInvalidReason =
778
+ | 'not_found'
779
+ | 'expired'
780
+ | 'idle_timeout'
781
+ | 'absolute_timeout'
782
+ | 'revoked'
783
+ | 'invalid'
784
+ | 'binding_mismatch';
785
+ ```
786
+
787
+ #### TouchOutcome type
788
+
789
+ ```ts
790
+ type TouchOutcome =
791
+ | 'touched'
792
+ | 'skipped_throttled'
793
+ | 'skipped_stale'
794
+ | 'not_found'
795
+ | 'consumed'
796
+ | 'expired'
797
+ | 'idle_expired';
798
+ ```
799
+
800
+ #### TouchOptions type
801
+
802
+ ```ts
803
+ type TouchOptions = {
804
+ force?: boolean; // Force a write regardless of touchInterval
805
+ userId?: string; // When known, avoids JTI lookup index round trip
806
+ };
807
+ ```
808
+
809
+ #### RotateOptions type
810
+
811
+ ```ts
812
+ type RotateOptions = {
813
+ rotationNonce?: string; // Client-supplied random nonce for retry-safe rotation
814
+ userId?: string; // Skip pre-flight GET, let Lua script be authoritative
815
+ expectedVersion?: number; // Optimistic concurrency: only rotate when version matches
816
+ };
817
+ ```
818
+
819
+ #### UpdateOptions type
820
+
821
+ ```ts
822
+ type UpdateOptions = {
823
+ expectedVersion?: number; // Optimistic concurrency: only update when version matches
824
+ userId?: string; // When known, avoids JTI lookup index round trip
825
+ };
826
+ ```
455
827
 
456
- const expiry = Math.floor(Date.now() / 1000) + 86400;
828
+ #### ListOptions type
829
+
830
+ ```ts
831
+ type ListOptions = {
832
+ limit?: number; // Max sessions to return (default: 100)
833
+ offset?: number; // Skip first N sessions (oldest first)
834
+ includeInactive?: boolean; // Include consumed/revoked records (default: false)
835
+ };
836
+ ```
837
+
838
+ #### BindingMismatch type
839
+
840
+ ```ts
841
+ type BindingMismatch = {
842
+ ipAddress: boolean; // IP address mismatch
843
+ userAgent: boolean; // User agent mismatch
844
+ deviceId: boolean; // Device ID mismatch
845
+ };
846
+ ```
847
+
848
+ <a name="revocation"></a>
849
+ ## RedisRevocationStore
850
+
851
+ Supports refresh-token revocation workflows: short-lived entries (`revoked:{jti}`) so rotated/logged-out tokens are rejected for their remaining lifetime. Framework-independent, works identically on standalone, Sentinel and Cluster, never stores raw tokens — only ids (`jti`).
852
+
853
+ ### Class: RedisRevocationStore
854
+
855
+ ```ts
856
+ new RedisRevocationStore(options: RedisRevocationStoreOptions)
857
+ ```
457
858
 
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
- ]);
859
+ | Param | Type | Description |
860
+ |---|---|---|
861
+ | `options.client` | `RedisClientWrapper` | The underlying Redis client |
862
+ | `options.keyPrefix` | `string` | Key prefix (e.g. `'authcore:revoked:'`) |
464
863
 
465
- // Check (single or batch)
466
- await revocations.isRevoked('a1b2c3d4'); // boolean
467
- const revoked = await revocations.isRevokedMany(['b2', 'c3', 'd4']); // Set<string>
864
+ #### RedisRevocationStoreOptions type
468
865
 
469
- if (revoked.has('b2')) {
470
- // reject the token
866
+ ```ts
867
+ interface RedisRevocationStoreOptions {
868
+ client: RedisClientWrapper;
869
+ keyPrefix: string;
471
870
  }
472
871
  ```
473
872
 
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.
873
+ ### RedisRevocationStore Methods
874
+
875
+ | Method | Description | Args | Returns |
876
+ |---|---|---|---|
877
+ | `revoke(record)` | Revoke a single jti | `record: RevocationRecord` | `Promise<void>` |
878
+ | `revokeMany(records)` | Revoke multiple jtis (batch) | `records: RevocationRecord[]` | `Promise<void>` — throws `RevocationBatchError` on failure |
879
+ | `isRevoked(jti)` | Check if a single jti is revoked | `jti: string` | `Promise<boolean>` |
880
+ | `isRevokedMany(jtis)` | Check multiple jtis (batch) | `jtis: string[]` | `Promise<Set<string>>` — set of revoked jtis that were found; throws `RevocationBatchError` on partial failure |
881
+
882
+ #### RevocationRecord type
883
+
884
+ ```ts
885
+ type RevocationRecord = {
886
+ jti: string; // The JWT jti (base64url-encoded SHA-256 of the token)
887
+ expiresAt: number; // Unix seconds after which this entry may be garbage collected
888
+ reason?: string; // e.g. 'logout', 'logout-all', 'password-change'
889
+ };
890
+ ```
891
+
892
+ #### RevocationBatchError type
475
893
 
476
- ## Session subsystem
894
+ Thrown when a batched command fails (Redis error, timeout, ...). Carries the exact jtis that failed — a check can never silently treat a token as "not revoked" when its status is unknown.
477
895
 
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).
896
+ <a name="lua-scripts"></a>
897
+ ## Lua Scripts (`eval` / `evalsha`)
485
898
 
486
- See `SESSION.md` (spec) and `docs/architecture.md` (decisions,
487
- deviations, exact semantics) before adopting it. Highlights:
899
+ Atomic server-side logic. Cluster-safe when keys share a hash slot.
900
+
901
+ ### Usage
488
902
 
489
903
  ```ts
490
- import { createSessionManager } from 'ioredis-toolkit/session';
904
+ // EVAL: the first numKeys arguments are KEYS, everything else is ARGV.
905
+ const script = `
906
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
907
+ return redis.call('DEL', KEYS[1])
908
+ end
909
+ return 0
910
+ `;
911
+ await client.set('lock:job', 'owner-1');
912
+ await client.eval(script, 1, 'lock:job', 'owner-1'); // 1 (deleted)
491
913
 
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
914
+ // SCRIPT LOAD + EVALSHA
915
+ const sha = await client.scriptLoad(script);
916
+ await client.evalsha(sha, script, 1, 'lock:job', 'owner-2'); // 0 (not owner)
917
+ ```
502
918
 
503
- const { token, session } = await manager.service.create({ userId: 'user-42' });
919
+ ### Key Rules
504
920
 
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.
921
+ - **EVAL**: The first `numKeys` arguments are `KEYS`, everything else is `ARGV`.
922
+ - **Cluster mode**: Every key touched inside the script must be declared in `KEYS` and share one hash slot (honored via hash tags `{tag}:...`).
923
+ - **evalsha fallback**: If the script cache was flushed, `evalsha` automatically falls back to `EVAL`.
596
924
 
925
+ <a name="errors"></a>
597
926
  ## Errors
598
927
 
928
+ ### RedisError
929
+
599
930
  ```ts
600
931
  import { RedisError } from 'ioredis-toolkit';
601
932
 
@@ -608,6 +939,25 @@ try {
608
939
  }
609
940
  ```
610
941
 
942
+ ### Common Error Codes
943
+
944
+ | Code | When It Occurs |
945
+ |---|---|
946
+ | `CLUSTER_MODE` | Operations unavailable in cluster mode (e.g. `SELECT`) |
947
+ | `LOCK_ACQUISITION_FAILED` | Lock could not be acquired after retries |
948
+ | `LOCK_LOST` | Lock was lost during `withLock` execution |
949
+ | `SessionStorageError` | Infra failure (503) — only one that trips the circuit breaker |
950
+ | `SessionNotFoundError` | Session not found |
951
+ | `SessionExpiredError` | Session absolute TTL passed |
952
+ | `SessionInvalidError` | Session invalid (corrupt, cyclic metadata, etc.) |
953
+ | `SessionRevokedError` | Session explicitly revoked |
954
+ | `SessionRotationError` | Rotation failed (version conflict, successor collision, etc.) |
955
+ | `SessionConcurrencyError` | Optimistic concurrency violation (version mismatch) |
956
+ | `SessionSerializationError` | Session deserialization failed |
957
+ | `SessionConfigurationError` | Invalid session configuration |
958
+ | `SessionBindingError` | Binding policy mismatch (when strict) |
959
+
960
+ <a name="logging"></a>
611
961
  ## Logging
612
962
 
613
963
  Every component accepts a pino-compatible logger (`trace/debug/info/warn/error/fatal` + `child`). Defaults to `console`.
@@ -615,13 +965,14 @@ Every component accepts a pino-compatible logger (`trace/debug/info/warn/error/f
615
965
  ```ts
616
966
  import { createLogger } from 'pino';
617
967
  const logger = createLogger();
618
- const client = new RedisClient(config, logger);
968
+ const client = new RedisClientWrapper(config, logger);
619
969
  ```
620
970
 
621
- ## Mode compatibility
971
+ <a name="mode-compatibility"></a>
972
+ ## Mode Compatibility
622
973
 
623
974
  | Operation | Standalone | Sentinel | Cluster |
624
- | --- | --- | --- | --- |
975
+ |---|---|---|---|
625
976
  | Single-key commands (get/set/hash/set/zset/incr/...) | ✅ | ✅ | ✅ |
626
977
  | `mget` / `mset` / `mgetClusterAware` | ✅ | ✅ | ✅ slot-grouped |
627
978
  | `scanIterator` / `deletePattern` / `keys` | ✅ | ✅ | ✅ all nodes scanned |
@@ -632,14 +983,119 @@ const client = new RedisClient(config, logger);
632
983
  | `select(database)` | ✅ | ✅ | ❌ (Redis limitation) |
633
984
  | Hash-tag keys `{tag}:...` | ✅ | ✅ | ✅ same slot |
634
985
 
986
+ <a name="development"></a>
635
987
  ## Development
636
988
 
637
989
  ```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
990
+ npm install # install dependencies
991
+ npm run build # tsc + asset copy (Lua scripts land in dist/session/scripts)
992
+ npm run typecheck # src + test + scripts (tsconfig.test.json)
993
+ npm test # vitest
994
+ npm run test:watch # vitest watch mode
995
+ npm run format # prettier --write 'src/**/*.ts'
643
996
  ```
644
997
 
645
998
  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.
999
+
1000
+ <a name="types"></a>
1001
+ ## Type Exports
1002
+
1003
+ The package exports comprehensive types for all modules. Key type exports:
1004
+
1005
+ ### Core Client Types
1006
+
1007
+ | Type | Description |
1008
+ |---|---|
1009
+ | `RedisClientWrapper` | The unified client wrapper (standalone/sentinel/cluster) |
1010
+ | `createRedisClient` | Factory function: `createRedisClient(config)` — creates client for specified mode |
1011
+ | `RedisConfig` | Normalized Redis configuration (after Zod validation) |
1012
+ | `RedisConfigInput` | User-facing configuration input (validated with Zod) |
1013
+ | `RedisMode` | `'standalone' \| 'sentinel' \| 'cluster'` |
1014
+ | `RedisConfigForMode<M>` | Mode-specific config type |
1015
+
1016
+ ### Cache Types
1017
+
1018
+ | Type | Description |
1019
+ |---|---|
1020
+ | `Cache` | Cache layer with JSON serialization, TTL, namespaces, compression |
1021
+ | `CacheOptions` | Options for cache operations (`ttl`, `compress`, `namespace`) |
1022
+ | `CacheInputConfig` | Constructor config (`defaultTTL`, `compressionThreshold`) |
1023
+
1024
+ ### Rate Limiting Types
1025
+
1026
+ | Type | Description |
1027
+ |---|---|
1028
+ | `RateLimiter` | Rate limiter instance |
1029
+ | `RateLimitAlgorithm` | `'fixed' \| 'sliding'` |
1030
+ | `RateLimitOptions` | Options with defaults materialized (limit, duration, algorithm, namespace) |
1031
+ | `RateLimitResult` | Result of consume/check: allowed, limit, used, remaining, resetAt, retryAfter |
1032
+
1033
+ ### Distributed Lock Types
1034
+
1035
+ | Type | Description |
1036
+ |---|---|
1037
+ | `DistributedLock` | Distributed lock instance |
1038
+ | `DistributedLockOptions` | Constructor options (ttl, retryCount, retryDelay) |
1039
+ | `LockInfo` | Lock info: `{ locked, ttl?, lockId? }` |
1040
+
1041
+ ### Pub/Sub Types
1042
+
1043
+ | Type | Description |
1044
+ |---|---|
1045
+ | `PubSub` | Pub/sub instance |
1046
+ | `PubSubMessage<T>` | `{ channel: string; message: T }` |
1047
+ | `PubSubStats` | `{ subscriptions, patternSubscriptions, connected }` |
1048
+
1049
+ ### Health Types
1050
+
1051
+ | Type | Description |
1052
+ |---|---|
1053
+ | `HealthStatus` | `{ healthy, status, latency, timestamp, details }` |
1054
+
1055
+ ### Session Types
1056
+
1057
+ | Type | Description |
1058
+ |---|---|
1059
+ | `SessionManager` | Session manager composition root |
1060
+ | `SessionService` | Application-facing session API |
1061
+ | `SessionRecord` | Persisted session record |
1062
+ | `SessionCreateInput` | Input for `create()` |
1063
+ | `CreatedSession` | Result of `create()`: `{ token, session, replayed? }` |
1064
+ | `SessionValidationResult` | Result of `validate()` |
1065
+ | `SessionInvalidReason` | Invalid reason discriminant |
1066
+ | `TouchOutcome` | Touch outcome codes |
1067
+ | `RotateOptions` | Options for `rotate()` |
1068
+ | `UpdateOptions` | Options for `update()` |
1069
+ | `ListOptions` | Options for `findByUser`/`list()` |
1070
+ | `BindingMismatch` | Binding mismatch details |
1071
+ | `SessionStatus` | `'active' \| 'consumed' \| 'revoked'` |
1072
+ | `SessionStatus` | Session lifecycle state |
1073
+ | `RevocationRecord` | Revocation store record |
1074
+ | `RevocationStore` | Storage-agnostic revocation interface |
1075
+
1076
+ ### Configuration Types
1077
+
1078
+ | Type | Description |
1079
+ |---|---|
1080
+ | `RedisCommonConfig` | Common config shared by all topologies |
1081
+ | `StandaloneRedisConfig` | Standalone-specific config |
1082
+ | `SentinelRedisConfig` | Sentinel-specific config |
1083
+ | `ClusterRedisConfig` | Cluster-specific config |
1084
+ | `RedisConfigInputSchema` | Zod schema for config validation |
1085
+ | `BaseRedisConfigSchema` | Base config schema (password, username, database, tls, etc.) |
1086
+
1087
+ ### Utility Types
1088
+
1089
+ | Type | Description |
1090
+ |---|---|
1091
+ | `RedisError` | Base Redis error type |
1092
+ | `calculateRedisClusterSlot` | CRC16 slot calculation for cluster keys |
1093
+ | `hashTag` | Extract hash tag from key (`{tag}:key`) |
1094
+
1095
+ <a name="changelog"></a>
1096
+ ## Changelog
1097
+
1098
+ See [CHANGELOG.md](CHANGELOG.md) for recent changes.
1099
+
1100
+ ---
1101
+ *Generated with ioredis-toolkit v0.0.4*