ioredis-toolkit 0.0.5 → 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 +772 -316
- package/dist/cache.d.ts +502 -6
- package/dist/cache.js +501 -5
- package/dist/client.d.ts +105 -0
- package/dist/client.js +105 -0
- package/dist/health.d.ts +141 -0
- package/dist/health.js +133 -0
- package/dist/lock.d.ts +55 -1
- package/dist/lock.js +12 -22
- package/dist/pubsub.d.ts +259 -7
- package/dist/pubsub.js +259 -7
- package/dist/ratelimiter.d.ts +279 -1
- package/dist/ratelimiter.js +250 -0
- package/dist/types.js +0 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
12
|
+
<a name="quick-start"></a>
|
|
13
|
+
## Quick Start
|
|
56
14
|
|
|
57
15
|
```ts
|
|
58
|
-
import {
|
|
16
|
+
import { RedisClientWrapper, Cache, RateLimiter } from 'ioredis-toolkit';
|
|
59
17
|
|
|
60
|
-
// 1. Create the client
|
|
61
|
-
const client = new
|
|
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.
|
|
37
|
+
// 4. Graceful shutdown
|
|
80
38
|
await client.close();
|
|
81
39
|
```
|
|
82
40
|
|
|
83
|
-
|
|
41
|
+
<a name="topology"></a>
|
|
42
|
+
## Topology & Configuration
|
|
84
43
|
|
|
85
|
-
|
|
44
|
+
Choose the Redis topology via the `mode` config field. All features behave identically across modes; only the underlying connection changes.
|
|
86
45
|
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
102
|
-
const client = new
|
|
83
|
+
// Or with a URL:
|
|
84
|
+
const client = new RedisClientWrapper({ mode: 'standalone', url: 'redis://:secret@localhost:6379/0' });
|
|
103
85
|
```
|
|
104
86
|
|
|
105
|
-
|
|
87
|
+
#### Sentinel
|
|
106
88
|
|
|
107
89
|
```ts
|
|
108
|
-
const client = new
|
|
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
|
-
|
|
101
|
+
#### Cluster
|
|
120
102
|
|
|
121
103
|
```ts
|
|
122
|
-
const client = new
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
### Configuration
|
|
150
158
|
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
const
|
|
268
|
-
const
|
|
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
|
-
|
|
273
|
+
### Lifecycle
|
|
272
274
|
|
|
273
275
|
```ts
|
|
274
|
-
client.
|
|
275
|
-
client.
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
- Values larger than `compressionThreshold` bytes are gzip-compressed transparently.
|
|
296
|
-
- `compress: false` disables compression for a single write.
|
|
312
|
+
#### Namespaces
|
|
297
313
|
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
394
|
+
// Fixed window
|
|
395
|
+
const fixed = new RateLimiter(client, { limit: 10, duration: 1, algorithm: 'fixed' });
|
|
339
396
|
```
|
|
340
397
|
|
|
341
|
-
###
|
|
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
|
-
|
|
438
|
+
#### RateLimitAlgorithm type
|
|
355
439
|
|
|
356
440
|
```ts
|
|
357
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
## DistributedLock
|
|
491
|
+
#### LockInfo type
|
|
386
492
|
|
|
387
493
|
```ts
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
562
|
+
type PubSubStats = {
|
|
563
|
+
subscriptions: number;
|
|
564
|
+
patternSubscriptions: number;
|
|
565
|
+
connected: boolean;
|
|
566
|
+
};
|
|
567
|
+
```
|
|
409
568
|
|
|
410
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
642
|
+
#### SessionManagerOptions type
|
|
441
643
|
|
|
442
644
|
```ts
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
+
### SessionManager class
|
|
449
668
|
|
|
450
669
|
```ts
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
466
|
-
await revocations.isRevoked('a1b2c3d4'); // boolean
|
|
467
|
-
const revoked = await revocations.isRevokedMany(['b2', 'c3', 'd4']); // Set<string>
|
|
864
|
+
#### RedisRevocationStoreOptions type
|
|
468
865
|
|
|
469
|
-
|
|
470
|
-
|
|
866
|
+
```ts
|
|
867
|
+
interface RedisRevocationStoreOptions {
|
|
868
|
+
client: RedisClientWrapper;
|
|
869
|
+
keyPrefix: string;
|
|
471
870
|
}
|
|
472
871
|
```
|
|
473
872
|
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
487
|
-
|
|
899
|
+
Atomic server-side logic. Cluster-safe when keys share a hash slot.
|
|
900
|
+
|
|
901
|
+
### Usage
|
|
488
902
|
|
|
489
903
|
```ts
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
919
|
+
### Key Rules
|
|
504
920
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
968
|
+
const client = new RedisClientWrapper(config, logger);
|
|
619
969
|
```
|
|
620
970
|
|
|
621
|
-
|
|
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
|
|
640
|
-
npm run typecheck
|
|
641
|
-
npm test
|
|
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*
|