ioredis-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +645 -0
  3. package/dist/cache.d.ts +298 -0
  4. package/dist/cache.js +606 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +958 -0
  7. package/dist/cluster-slot.d.ts +4 -0
  8. package/dist/cluster-slot.js +31 -0
  9. package/dist/cluster.d.ts +79 -0
  10. package/dist/cluster.js +156 -0
  11. package/dist/errors.d.ts +30 -0
  12. package/dist/errors.js +63 -0
  13. package/dist/health.d.ts +39 -0
  14. package/dist/health.js +106 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +44 -0
  17. package/dist/lock.d.ts +215 -0
  18. package/dist/lock.js +385 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/logger.js +40 -0
  21. package/dist/pubsub.d.ts +171 -0
  22. package/dist/pubsub.js +285 -0
  23. package/dist/ratelimiter.d.ts +162 -0
  24. package/dist/ratelimiter.js +289 -0
  25. package/dist/session/index.d.ts +23 -0
  26. package/dist/session/index.js +16 -0
  27. package/dist/session/revocation-store.d.ts +171 -0
  28. package/dist/session/revocation-store.js +310 -0
  29. package/dist/session/scripts/cleanup-index.lua +21 -0
  30. package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
  31. package/dist/session/scripts/conditional-update.lua +63 -0
  32. package/dist/session/scripts/create.lua +68 -0
  33. package/dist/session/scripts/delete-by-user.lua +29 -0
  34. package/dist/session/scripts/delete.lua +15 -0
  35. package/dist/session/scripts/enforce-limit.lua +38 -0
  36. package/dist/session/scripts/revoke.lua +61 -0
  37. package/dist/session/scripts/rotate-encrypted.lua +107 -0
  38. package/dist/session/scripts/rotate.lua +119 -0
  39. package/dist/session/scripts/touch-encrypted.lua +89 -0
  40. package/dist/session/scripts/touch.lua +72 -0
  41. package/dist/session/scripts/validate.lua +90 -0
  42. package/dist/session/session-circuit-breaker.d.ts +42 -0
  43. package/dist/session/session-circuit-breaker.js +129 -0
  44. package/dist/session/session-config.d.ts +335 -0
  45. package/dist/session/session-config.js +162 -0
  46. package/dist/session/session-cookie.d.ts +72 -0
  47. package/dist/session/session-cookie.js +101 -0
  48. package/dist/session/session-encryption.d.ts +87 -0
  49. package/dist/session/session-encryption.js +139 -0
  50. package/dist/session/session-errors.d.ts +85 -0
  51. package/dist/session/session-errors.js +145 -0
  52. package/dist/session/session-health.d.ts +38 -0
  53. package/dist/session/session-health.js +60 -0
  54. package/dist/session/session-keys.d.ts +51 -0
  55. package/dist/session/session-keys.js +113 -0
  56. package/dist/session/session-manager.d.ts +59 -0
  57. package/dist/session/session-manager.js +94 -0
  58. package/dist/session/session-metrics.d.ts +33 -0
  59. package/dist/session/session-metrics.js +112 -0
  60. package/dist/session/session-repository.d.ts +161 -0
  61. package/dist/session/session-repository.js +683 -0
  62. package/dist/session/session-scripts.d.ts +36 -0
  63. package/dist/session/session-scripts.js +130 -0
  64. package/dist/session/session-serializer.d.ts +42 -0
  65. package/dist/session/session-serializer.js +248 -0
  66. package/dist/session/session-service.d.ts +104 -0
  67. package/dist/session/session-service.js +611 -0
  68. package/dist/session/session-token.d.ts +38 -0
  69. package/dist/session/session-token.js +86 -0
  70. package/dist/session/session-types.d.ts +253 -0
  71. package/dist/session/session-types.js +16 -0
  72. package/dist/types.d.ts +782 -0
  73. package/dist/types.js +140 -0
  74. package/package.json +97 -0
package/dist/client.js ADDED
@@ -0,0 +1,958 @@
1
+ import { Cluster, Redis as RedisClient, } from "ioredis";
2
+ import { ConfigurationError, RedisError } from "./errors.js";
3
+ import { RedisConfigSchema, } from "./types.js";
4
+ import { defaultLogger } from "./logger.js";
5
+ import { executeBySlot } from "./cluster.js";
6
+ import { calculateRedisClusterSlot } from "./cluster-slot.js";
7
+ import { Cache } from "./cache.js";
8
+ import { PubSub } from "./pubsub.js";
9
+ import { DistributedLock } from "./lock.js";
10
+ import { RateLimiter } from "./ratelimiter.js";
11
+ import { createSessionManager, } from "./session/session-manager.js";
12
+ import { prettifyError } from "zod";
13
+ // ============================================================================
14
+ // Type Guards
15
+ // ============================================================================
16
+ function isClusterConfig(config) {
17
+ return config.mode === "cluster";
18
+ }
19
+ function isSentinelConfig(config) {
20
+ return config.mode === "sentinel";
21
+ }
22
+ function isStandaloneConfig(config) {
23
+ return config.mode === "standalone";
24
+ }
25
+ // ============================================================================
26
+ // Redis Client
27
+ // ============================================================================
28
+ /**
29
+ * Production-grade Redis client wrapper supporting:
30
+ *
31
+ * - standalone Redis
32
+ * - Redis Sentinel
33
+ * - Redis Cluster
34
+ * - connection lifecycle handling
35
+ * - slow-command tracking
36
+ * - cluster-aware multi-key operations
37
+ * - cache
38
+ * - pub/sub
39
+ * - distributed locks
40
+ * - rate limiting
41
+ * - sessions
42
+ */
43
+ export class RedisClientWrapper {
44
+ client;
45
+ _cache;
46
+ _pubsub;
47
+ _lock;
48
+ _rateLimiter;
49
+ _session;
50
+ config;
51
+ logger;
52
+ isReady = false;
53
+ // ========================================================================
54
+ // Constructor
55
+ // ========================================================================
56
+ constructor(config, logger = defaultLogger) {
57
+ const parsed = RedisConfigSchema.safeParse(config);
58
+ if (!parsed.success) {
59
+ throw new ConfigurationError(`Invalid Redis config: ${parsed.error.message}`, {
60
+ config,
61
+ message: prettifyError(parsed.error),
62
+ });
63
+ }
64
+ /**
65
+ * IMPORTANT:
66
+ *
67
+ * `parsed.data` is already `RedisConfig`.
68
+ *
69
+ * Do NOT cast it back to `RedisConfigInput`.
70
+ * Do NOT normalize it manually.
71
+ *
72
+ * The Zod schema is the normalization boundary.
73
+ */
74
+ this.config = parsed.data;
75
+ this.logger = logger.child({
76
+ component: "RedisClient",
77
+ });
78
+ this.client = this.createClient();
79
+ this.setupEventHandlers();
80
+ }
81
+ // ========================================================================
82
+ // Configuration
83
+ // ========================================================================
84
+ get mode() {
85
+ return this.config.mode;
86
+ }
87
+ // ========================================================================
88
+ // Cache
89
+ // ========================================================================
90
+ get cache() {
91
+ if (!this._cache) {
92
+ this._cache = new Cache(this, this.config, this.logger);
93
+ }
94
+ return this._cache;
95
+ }
96
+ set cache(value) {
97
+ this._cache = value;
98
+ }
99
+ // ========================================================================
100
+ // Pub/Sub
101
+ // ========================================================================
102
+ get pubsub() {
103
+ if (!this._pubsub) {
104
+ this._pubsub = new PubSub(this, this.logger);
105
+ }
106
+ return this._pubsub;
107
+ }
108
+ set pubsub(value) {
109
+ this._pubsub = value;
110
+ }
111
+ // ========================================================================
112
+ // Distributed Lock
113
+ // ========================================================================
114
+ get lock() {
115
+ if (!this._lock) {
116
+ this._lock = new DistributedLock(this, this.logger);
117
+ }
118
+ return this._lock;
119
+ }
120
+ set lock(value) {
121
+ this._lock = value;
122
+ }
123
+ // ========================================================================
124
+ // Rate Limiter
125
+ // ========================================================================
126
+ get rateLimiter() {
127
+ if (!this._rateLimiter) {
128
+ this._rateLimiter = new RateLimiter(this);
129
+ }
130
+ return this._rateLimiter;
131
+ }
132
+ set rateLimiter(value) {
133
+ this._rateLimiter = value;
134
+ }
135
+ // ========================================================================
136
+ // Session
137
+ // ========================================================================
138
+ get session() {
139
+ if (!this._session) {
140
+ const sessionOptions = this.config.sessionOptions;
141
+ if (sessionOptions) {
142
+ const { client: customClient, ...options } = sessionOptions;
143
+ this._session = createSessionManager({
144
+ client: customClient ?? this,
145
+ ...options,
146
+ });
147
+ }
148
+ else {
149
+ this._session = createSessionManager({
150
+ client: this,
151
+ });
152
+ }
153
+ }
154
+ return this._session;
155
+ }
156
+ set session(value) {
157
+ this._session = value;
158
+ }
159
+ // ========================================================================
160
+ // Client Creation
161
+ // ========================================================================
162
+ createClient() {
163
+ const common = this.buildCommonRedisOptions();
164
+ const retryStrategy = (times) => {
165
+ if (times > this.config.maxRetries) {
166
+ return null;
167
+ }
168
+ return Math.min(times * this.config.retryDelay, 5_000);
169
+ };
170
+ switch (this.config.mode) {
171
+ case "cluster":
172
+ return this.createClusterClient(this.config, common, retryStrategy);
173
+ case "sentinel":
174
+ return this.createSentinelClient(this.config, common, retryStrategy);
175
+ case "standalone":
176
+ return this.createStandaloneClient(this.config, common, retryStrategy);
177
+ }
178
+ }
179
+ createClusterClient(config, common, retryStrategy) {
180
+ return new Cluster(config.clusterNodes.map(({ host, port }) => ({
181
+ host,
182
+ port,
183
+ })), {
184
+ clusterRetryStrategy: retryStrategy,
185
+ enableOfflineQueue: true,
186
+ scaleReads: "master",
187
+ redisOptions: {
188
+ ...common,
189
+ connectTimeout: config.connectionTimeout,
190
+ maxRetriesPerRequest: config.maxRetries,
191
+ },
192
+ });
193
+ }
194
+ createSentinelClient(config, common, retryStrategy) {
195
+ return new RedisClient({
196
+ ...common,
197
+ connectTimeout: config.connectionTimeout,
198
+ maxRetriesPerRequest: config.maxRetries,
199
+ retryStrategy,
200
+ // sentinel: true,
201
+ sentinels: config.sentinelNodes.map(({ host, port }) => ({
202
+ host,
203
+ port,
204
+ })),
205
+ name: config.sentinelMasterName,
206
+ });
207
+ }
208
+ createStandaloneClient(config, common, retryStrategy) {
209
+ const connectionOptions = {
210
+ ...common,
211
+ connectTimeout: config.connectionTimeout,
212
+ maxRetriesPerRequest: config.maxRetries,
213
+ retryStrategy,
214
+ };
215
+ if (config.url !== undefined) {
216
+ return new RedisClient(config.url, connectionOptions);
217
+ }
218
+ return new RedisClient({
219
+ ...connectionOptions,
220
+ host: config.host,
221
+ port: config.port,
222
+ db: config.database,
223
+ });
224
+ }
225
+ buildCommonRedisOptions() {
226
+ const options = {
227
+ enableReadyCheck: true,
228
+ enableOfflineQueue: true,
229
+ lazyConnect: false,
230
+ };
231
+ if (this.config.username !== undefined) {
232
+ options.username = this.config.username;
233
+ }
234
+ if (this.config.password !== undefined) {
235
+ options.password = this.config.password;
236
+ }
237
+ if (this.config.tls) {
238
+ options.tls =
239
+ this.config.tlsOptions ?? {
240
+ rejectUnauthorized: true,
241
+ };
242
+ }
243
+ return options;
244
+ }
245
+ // ========================================================================
246
+ // Events
247
+ // ========================================================================
248
+ setupEventHandlers() {
249
+ this.client.on("connect", () => {
250
+ this.logger.info("Redis connected");
251
+ });
252
+ this.client.on("ready", () => {
253
+ this.isReady = true;
254
+ this.logger.info("Redis ready");
255
+ });
256
+ this.client.on("error", (error) => {
257
+ this.isReady = false;
258
+ this.logger.error("Redis error:", {
259
+ error,
260
+ });
261
+ });
262
+ this.client.on("close", () => {
263
+ this.isReady = false;
264
+ this.logger.warn("Redis connection closed");
265
+ });
266
+ this.client.on("reconnecting", () => {
267
+ this.isReady = false;
268
+ this.logger.info("Redis reconnecting...");
269
+ });
270
+ }
271
+ // ========================================================================
272
+ // Raw Client
273
+ // ========================================================================
274
+ getRawClient() {
275
+ return this.client;
276
+ }
277
+ get raw() {
278
+ return this.client;
279
+ }
280
+ // ========================================================================
281
+ // Connection
282
+ // ========================================================================
283
+ async ping() {
284
+ try {
285
+ const result = await this.exec("PING", [], () => this.client.ping());
286
+ return result === "PONG";
287
+ }
288
+ catch {
289
+ return false;
290
+ }
291
+ }
292
+ async close() {
293
+ try {
294
+ await this.client.quit();
295
+ }
296
+ finally {
297
+ this.isReady = false;
298
+ }
299
+ }
300
+ // ========================================================================
301
+ // Internal Helpers
302
+ // ========================================================================
303
+ isClusterClient(client) {
304
+ return client instanceof Cluster;
305
+ }
306
+ async exec(command, args, operation) {
307
+ const startedAt = Date.now();
308
+ try {
309
+ const result = await operation();
310
+ const duration = Date.now() - startedAt;
311
+ if (duration >
312
+ this.config.slowCommandThreshold) {
313
+ this.logger.warn(`Slow command: ${command} took ${duration}ms`, {
314
+ command,
315
+ args: args.slice(0, 5),
316
+ duration,
317
+ });
318
+ }
319
+ return result;
320
+ }
321
+ catch (error) {
322
+ this.logger.error(`Command failed: ${command}`, {
323
+ command,
324
+ error,
325
+ });
326
+ throw error;
327
+ }
328
+ }
329
+ // ========================================================================
330
+ // Basic Commands
331
+ // ========================================================================
332
+ async get(key) {
333
+ return this.exec("GET", [key], () => this.client.get(key));
334
+ }
335
+ async set(key, value, ttl) {
336
+ if (ttl !== undefined) {
337
+ return this.exec("SET", [key, value, "EX", ttl], () => this.client.set(key, value, "EX", ttl));
338
+ }
339
+ return this.exec("SET", [key, value], () => this.client.set(key, value));
340
+ }
341
+ async setexnx(key, value, ttl) {
342
+ if (ttl !== undefined) {
343
+ return this.exec("SET", [key, value, "EX", ttl, "NX"], () => this.client.set(key, value, "EX", ttl, "NX"));
344
+ }
345
+ return this.exec("SET", [key, value, "NX"], () => this.client.set(key, value, "NX"));
346
+ }
347
+ defineCommand(...args) {
348
+ return this.client.defineCommand(...args);
349
+ }
350
+ async eval(script, numKeys, ...args) {
351
+ return this.exec("EVAL", [script, numKeys, ...args], () => this.client.eval(script, numKeys, ...args));
352
+ }
353
+ async evalsha(sha, script, numKeys, ...args) {
354
+ try {
355
+ return await this.exec("EVALSHA", [sha, numKeys, ...args], () => this.client.evalsha(sha, numKeys, ...args));
356
+ }
357
+ catch (error) {
358
+ const code = error instanceof Error &&
359
+ "code" in error &&
360
+ typeof error.code === "string"
361
+ ? error.code
362
+ : undefined;
363
+ const message = error instanceof Error
364
+ ? error.message
365
+ : String(error);
366
+ if (code === "NOSCRIPT" ||
367
+ message.includes("NOSCRIPT")) {
368
+ return this.eval(script, numKeys, ...args);
369
+ }
370
+ throw error;
371
+ }
372
+ }
373
+ async scriptLoad(script) {
374
+ return this.exec("SCRIPT LOAD", [script], () => this.client.script("LOAD", script));
375
+ }
376
+ async setnx(key, value, ttl) {
377
+ const result = ttl !== undefined
378
+ ? await this.exec("SET", [key, value, "EX", ttl, "NX"], () => this.client.set(key, value, "EX", ttl, "NX"))
379
+ : await this.exec("SET", [key, value, "NX"], () => this.client.set(key, value, "NX"));
380
+ return result === "OK" ? 1 : 0;
381
+ }
382
+ async del(...keys) {
383
+ if (keys.length === 0) {
384
+ return 0;
385
+ }
386
+ return this.exec("DEL", keys, () => this.client.del(...keys));
387
+ }
388
+ async getdel(key) {
389
+ return this.exec("GETDEL", [key], () => this.client.getdel(key));
390
+ }
391
+ async exists(key) {
392
+ return this.exec("EXISTS", [key], () => this.client.exists(key));
393
+ }
394
+ async expire(key, ttl) {
395
+ return this.exec("EXPIRE", [key, ttl], () => this.client.expire(key, ttl));
396
+ }
397
+ async ttl(key) {
398
+ return this.exec("TTL", [key], () => this.client.ttl(key));
399
+ }
400
+ async incr(key) {
401
+ return this.exec("INCR", [key], () => this.client.incr(key));
402
+ }
403
+ async decr(key) {
404
+ return this.exec("DECR", [key], () => this.client.decr(key));
405
+ }
406
+ async incrby(key, amount) {
407
+ if (!Number.isSafeInteger(amount) ||
408
+ amount <= 0) {
409
+ throw new RedisError("INCRBY amount must be a positive safe integer", "INVALID_AMOUNT");
410
+ }
411
+ return this.exec("INCRBY", [key, amount], () => this.client.incrby(key, amount));
412
+ }
413
+ async decrby(key, amount) {
414
+ if (!Number.isSafeInteger(amount) ||
415
+ amount <= 0) {
416
+ throw new RedisError("DECRBY amount must be a positive safe integer", "INVALID_AMOUNT");
417
+ }
418
+ return this.exec("DECRBY", [key, amount], () => this.client.decrby(key, amount));
419
+ }
420
+ async time() {
421
+ const result = await this.exec("TIME", [], () => this.client.time());
422
+ if (Array.isArray(result) &&
423
+ result.length > 0) {
424
+ const seconds = Number(result[0]);
425
+ if (Number.isSafeInteger(seconds)) {
426
+ return seconds;
427
+ }
428
+ }
429
+ throw new RedisError("Unexpected TIME response from Redis", "TIME_ERROR");
430
+ }
431
+ // ========================================================================
432
+ // Multi-Key Commands
433
+ // ========================================================================
434
+ async mget(...keys) {
435
+ if (keys.length === 0) {
436
+ return [];
437
+ }
438
+ if (this.isClusterClient(this.client)) {
439
+ return this.mgetClusterAware(keys);
440
+ }
441
+ return this.exec("MGET", keys, () => this.client.mget(...keys));
442
+ }
443
+ async mset(...pairs) {
444
+ if (pairs.length === 0) {
445
+ return "OK";
446
+ }
447
+ if (this.isClusterClient(this.client)) {
448
+ const groups = new Map();
449
+ for (const [key, value,] of pairs) {
450
+ const slot = this.calculateSlot(key);
451
+ const group = groups.get(slot) ?? [];
452
+ group.push(key, value);
453
+ groups.set(slot, group);
454
+ }
455
+ const tasks = [...groups.values()];
456
+ await this.runWithConcurrency(tasks, this.config.maxFanOutConcurrency, async (flat) => {
457
+ await this.exec("MSET", flat, () => this.client.mset(flat));
458
+ });
459
+ return "OK";
460
+ }
461
+ const flat = pairs.flat();
462
+ return this.exec("MSET", flat, () => this.client.mset(flat));
463
+ }
464
+ // ========================================================================
465
+ // Hash Commands
466
+ // ========================================================================
467
+ async hget(key, field) {
468
+ return this.exec("HGET", [key, field], () => this.client.hget(key, field));
469
+ }
470
+ async hset(key, field, value) {
471
+ return this.exec("HSET", [key, field, value], () => this.client.hset(key, field, value));
472
+ }
473
+ async hgetall(key) {
474
+ return this.exec("HGETALL", [key], () => this.client.hgetall(key));
475
+ }
476
+ async hdel(key, ...fields) {
477
+ if (fields.length === 0) {
478
+ return 0;
479
+ }
480
+ return this.exec("HDEL", [key, ...fields], () => this.client.hdel(key, ...fields));
481
+ }
482
+ // ========================================================================
483
+ // Set Commands
484
+ // ========================================================================
485
+ async sadd(key, ...members) {
486
+ if (members.length === 0) {
487
+ return 0;
488
+ }
489
+ return this.exec("SADD", [key, ...members], () => this.client.sadd(key, ...members));
490
+ }
491
+ async srem(key, ...members) {
492
+ if (members.length === 0) {
493
+ return 0;
494
+ }
495
+ return this.exec("SREM", [key, ...members], () => this.client.srem(key, ...members));
496
+ }
497
+ async smembers(key) {
498
+ return this.exec("SMEMBERS", [key], () => this.client.smembers(key));
499
+ }
500
+ async sismember(key, member) {
501
+ return this.exec("SISMEMBER", [key, member], () => this.client.sismember(key, member));
502
+ }
503
+ // ========================================================================
504
+ // Sorted Set Commands
505
+ // ========================================================================
506
+ async zadd(key, score, member) {
507
+ return this.exec("ZADD", [key, score, member], () => this.client.zadd(key, score, member));
508
+ }
509
+ async zrange(key, start, stop) {
510
+ return this.exec("ZRANGE", [key, start, stop], () => this.client.zrange(key, start, stop));
511
+ }
512
+ async zcard(key) {
513
+ return this.exec("ZCARD", [key], () => this.client.zcard(key));
514
+ }
515
+ async zrem(key, ...members) {
516
+ if (members.length === 0) {
517
+ return 0;
518
+ }
519
+ return this.exec("ZREM", [key, ...members], () => this.client.zrem(key, ...members));
520
+ }
521
+ // ========================================================================
522
+ // Pipeline
523
+ // ========================================================================
524
+ pipeline() {
525
+ return this.client.pipeline();
526
+ }
527
+ // ========================================================================
528
+ // Scanning
529
+ // ========================================================================
530
+ async *scanIterator(pattern, count = 100) {
531
+ for await (const batch of this.scanCluster(pattern, {
532
+ count,
533
+ batchSize: 1,
534
+ })) {
535
+ for (const key of batch) {
536
+ yield key;
537
+ }
538
+ }
539
+ }
540
+ async *scanCluster(pattern, options = {}) {
541
+ const count = options.count ?? 100;
542
+ const batchSize = Math.max(1, options.batchSize ?? 100);
543
+ const scanNode = async function* (node) {
544
+ let cursor = "0";
545
+ let buffer = [];
546
+ do {
547
+ const [nextCursor, keys,] = await node.scan(cursor, "MATCH", pattern, "COUNT", count);
548
+ cursor = nextCursor;
549
+ buffer.push(...keys);
550
+ while (buffer.length >=
551
+ batchSize) {
552
+ yield buffer.splice(0, batchSize);
553
+ }
554
+ } while (cursor !== "0");
555
+ if (buffer.length > 0) {
556
+ yield buffer;
557
+ }
558
+ };
559
+ const nodes = this.isClusterClient(this.client)
560
+ ? this.getClusterNodes()
561
+ : [this.client];
562
+ for (const node of nodes) {
563
+ yield* scanNode(node);
564
+ }
565
+ }
566
+ // ========================================================================
567
+ // Cluster
568
+ // ========================================================================
569
+ isCluster() {
570
+ return this.isClusterClient(this.client);
571
+ }
572
+ getClusterNodes() {
573
+ if (!this.isClusterClient(this.client)) {
574
+ return [];
575
+ }
576
+ return this.client.nodes("master");
577
+ }
578
+ async getClusterSlots() {
579
+ if (!this.isClusterClient(this.client)) {
580
+ return [];
581
+ }
582
+ const raw = (await this.exec("CLUSTER SLOTS", [], () => this.client.cluster("SLOTS")));
583
+ if (!Array.isArray(raw)) {
584
+ return [];
585
+ }
586
+ return raw.flatMap((entry) => {
587
+ if (!Array.isArray(entry) ||
588
+ entry.length < 3) {
589
+ return [];
590
+ }
591
+ const [start, end, master, ...replicas] = entry;
592
+ if (!Number.isInteger(start) ||
593
+ !Number.isInteger(end) ||
594
+ !Array.isArray(master)) {
595
+ return [];
596
+ }
597
+ const parseNode = (node) => {
598
+ if (!Array.isArray(node) ||
599
+ node.length < 2) {
600
+ return null;
601
+ }
602
+ const host = typeof node[0] === "string"
603
+ ? node[0]
604
+ : String(node[0]);
605
+ const port = typeof node[1] === "number"
606
+ ? node[1]
607
+ : Number(node[1]);
608
+ if (host.length === 0 ||
609
+ !Number.isInteger(port) ||
610
+ port < 1 ||
611
+ port > 65_535) {
612
+ return null;
613
+ }
614
+ const nodeId = node[2] === undefined
615
+ ? undefined
616
+ : String(node[2]);
617
+ return nodeId === undefined
618
+ ? {
619
+ host,
620
+ port,
621
+ }
622
+ : {
623
+ host,
624
+ port,
625
+ nodeId,
626
+ };
627
+ };
628
+ const parsedMaster = parseNode(master);
629
+ if (!parsedMaster) {
630
+ return [];
631
+ }
632
+ const parsedReplicas = replicas
633
+ .map(parseNode)
634
+ .filter((node) => node !== null);
635
+ return [
636
+ {
637
+ start,
638
+ end,
639
+ master: parsedMaster,
640
+ replicas: parsedReplicas,
641
+ },
642
+ ];
643
+ });
644
+ }
645
+ calculateSlot(key) {
646
+ return calculateRedisClusterSlot(key);
647
+ }
648
+ async getNodeForKey(key) {
649
+ if (!this.isClusterClient(this.client)) {
650
+ return null;
651
+ }
652
+ const nodes = this.client.nodes("master");
653
+ if (nodes.length === 0) {
654
+ return null;
655
+ }
656
+ try {
657
+ const slot = this.calculateSlot(key);
658
+ const raw = await this.client.cluster("SLOTS");
659
+ if (!Array.isArray(raw)) {
660
+ return null;
661
+ }
662
+ for (const entry of raw) {
663
+ if (!Array.isArray(entry) ||
664
+ entry.length < 3) {
665
+ continue;
666
+ }
667
+ const [startSlot, endSlot, master,] = entry;
668
+ if (typeof startSlot !==
669
+ "number" ||
670
+ typeof endSlot !==
671
+ "number" ||
672
+ !Array.isArray(master)) {
673
+ continue;
674
+ }
675
+ if (slot < startSlot ||
676
+ slot > endSlot) {
677
+ continue;
678
+ }
679
+ const host = String(master[2] ?? "");
680
+ const port = Number(master[1]);
681
+ if (host.length === 0 ||
682
+ !Number.isInteger(port)) {
683
+ continue;
684
+ }
685
+ const node = nodes.find((candidate) => candidate.options
686
+ .host === host &&
687
+ candidate.options
688
+ .port === port);
689
+ if (node) {
690
+ return node;
691
+ }
692
+ }
693
+ }
694
+ catch (error) {
695
+ this.logger.debug("Unable to resolve cluster node for key", {
696
+ error,
697
+ });
698
+ }
699
+ return null;
700
+ }
701
+ async getSlotRanges() {
702
+ const result = new Map();
703
+ const ranges = await this.getClusterSlots();
704
+ for (const range of ranges) {
705
+ const nodeId = `${range.master.host}:${range.master.port}`;
706
+ for (let slot = range.start; slot <= range.end; slot++) {
707
+ result.set(slot, [
708
+ nodeId,
709
+ ]);
710
+ }
711
+ }
712
+ return result;
713
+ }
714
+ async isKeyServed(key) {
715
+ if (!this.isClusterClient(this.client)) {
716
+ return true;
717
+ }
718
+ try {
719
+ const node = await this.getNodeForKey(key);
720
+ return node !== null;
721
+ }
722
+ catch {
723
+ return false;
724
+ }
725
+ }
726
+ async executeOnNode(key, command, ...args) {
727
+ if (this.isClusterClient(this.client)) {
728
+ const node = await this.getNodeForKey(key);
729
+ if (node) {
730
+ return this.executeCommandOnClient(node, command, args);
731
+ }
732
+ }
733
+ return this.executeCommandOnClient(this.client, command, args);
734
+ }
735
+ executeCommandOnClient(client, command, args) {
736
+ const method = Reflect.get(client, command);
737
+ if (typeof method !==
738
+ "function") {
739
+ throw new RedisError(`Unsupported Redis command: ${command}`, "UNSUPPORTED_COMMAND");
740
+ }
741
+ return Reflect.apply(method, client, args);
742
+ }
743
+ // ========================================================================
744
+ // Cluster-Aware MGET
745
+ // ========================================================================
746
+ async mgetClusterAware(keys) {
747
+ if (keys.length === 0) {
748
+ return [];
749
+ }
750
+ if (!this.isClusterClient(this.client)) {
751
+ return this.mget(...keys);
752
+ }
753
+ const groups = new Map();
754
+ for (const key of keys) {
755
+ const slot = this.calculateSlot(key);
756
+ const group = groups.get(slot) ?? [];
757
+ group.push(key);
758
+ groups.set(slot, group);
759
+ }
760
+ const values = new Map();
761
+ await this.runWithConcurrency([...groups.values()], this.config
762
+ .maxFanOutConcurrency, async (slotKeys) => {
763
+ const result = await this.exec("MGET", slotKeys, () => this.client.mget(...slotKeys));
764
+ slotKeys.forEach((key, index) => {
765
+ values.set(key, result[index] ??
766
+ null);
767
+ });
768
+ });
769
+ return keys.map((key) => values.get(key) ??
770
+ null);
771
+ }
772
+ // ========================================================================
773
+ // Namespace Operations
774
+ // ========================================================================
775
+ async deletePattern(pattern, options = {}) {
776
+ const batchSize = Math.max(1, options.batchSize ??
777
+ 100);
778
+ const scanCount = Math.max(1, options.scanCount ??
779
+ 100);
780
+ let deleted = 0;
781
+ for await (const batch of this.scanCluster(pattern, {
782
+ count: scanCount,
783
+ batchSize,
784
+ })) {
785
+ if (batch.length === 0) {
786
+ continue;
787
+ }
788
+ const commands = batch.map((key) => ({
789
+ command: "del",
790
+ args: [key],
791
+ slot: this.calculateSlot(key),
792
+ }));
793
+ const results = await executeBySlot(this, commands, {
794
+ concurrency: this.config
795
+ .maxFanOutConcurrency,
796
+ retry: 1,
797
+ });
798
+ for (const result of results) {
799
+ if (!result[0] &&
800
+ typeof result[1] ===
801
+ "number") {
802
+ deleted += result[1];
803
+ }
804
+ }
805
+ }
806
+ return deleted;
807
+ }
808
+ async clearNamespace(prefix, options = {}) {
809
+ return this.deletePattern(`${prefix}*`, options);
810
+ }
811
+ async clearNamespaceClusterAware(prefix, options = {}) {
812
+ return this.clearNamespace(prefix, options);
813
+ }
814
+ // ========================================================================
815
+ // Cluster Information
816
+ // ========================================================================
817
+ getClusterInfo() {
818
+ if (this.isClusterClient(this.client)) {
819
+ try {
820
+ const nodes = this.client.nodes("master");
821
+ return {
822
+ mode: "cluster",
823
+ status: this.isReady
824
+ ? "ready"
825
+ : "connecting",
826
+ nodeCount: nodes.length,
827
+ nodes: nodes.map((node) => ({
828
+ host: String(node.options
829
+ .host ??
830
+ ""),
831
+ port: Number(node.options
832
+ .port),
833
+ role: "master",
834
+ })),
835
+ };
836
+ }
837
+ catch (error) {
838
+ return {
839
+ mode: "cluster",
840
+ status: "error",
841
+ error: error instanceof Error
842
+ ? error.message
843
+ : String(error),
844
+ };
845
+ }
846
+ }
847
+ if (isStandaloneConfig(this.config)) {
848
+ return {
849
+ mode: "standalone",
850
+ host: this.config.host,
851
+ port: this.config.port,
852
+ status: this.isReady
853
+ ? "ready"
854
+ : "connecting",
855
+ };
856
+ }
857
+ return {
858
+ mode: this.config.mode,
859
+ status: this.isReady
860
+ ? "ready"
861
+ : "connecting",
862
+ };
863
+ }
864
+ // ========================================================================
865
+ // INFO
866
+ // ========================================================================
867
+ async info(section) {
868
+ if (section !== undefined) {
869
+ return this.exec("INFO", [section], () => this.client.info(section));
870
+ }
871
+ return this.exec("INFO", [], () => this.client.info());
872
+ }
873
+ // ========================================================================
874
+ // SELECT
875
+ // ========================================================================
876
+ async select(database) {
877
+ if (this.isClusterClient(this.client)) {
878
+ throw new RedisError("SELECT is not supported in Redis Cluster mode", "CLUSTER_MODE");
879
+ }
880
+ if (!Number.isInteger(database) ||
881
+ database < 0 ||
882
+ database > 15) {
883
+ throw new RedisError("Database must be an integer between 0 and 15", "INVALID_DATABASE");
884
+ }
885
+ return this.client.select(database);
886
+ }
887
+ // ========================================================================
888
+ // Connection Status
889
+ // ========================================================================
890
+ getConnectionStatus() {
891
+ const state = this.isReady
892
+ ? "connected"
893
+ : this.client.status ===
894
+ "connecting"
895
+ ? "connecting"
896
+ : this.client.status ===
897
+ "end"
898
+ ? "closed"
899
+ : this.client.status ===
900
+ "ready"
901
+ ? "connected"
902
+ : "disconnected";
903
+ return {
904
+ state,
905
+ connected: this.isReady,
906
+ ready: this.isReady,
907
+ reconnectAttempts: 0,
908
+ uptime: 0,
909
+ };
910
+ }
911
+ // ========================================================================
912
+ // Concurrency Helper
913
+ // ========================================================================
914
+ async runWithConcurrency(items, concurrency, worker) {
915
+ if (items.length === 0) {
916
+ return;
917
+ }
918
+ const limit = Math.max(1, Math.min(concurrency, items.length));
919
+ let cursor = 0;
920
+ const workers = Array.from({
921
+ length: limit,
922
+ }, async () => {
923
+ while (true) {
924
+ const index = cursor++;
925
+ if (index >=
926
+ items.length) {
927
+ return;
928
+ }
929
+ await worker(items[index]);
930
+ }
931
+ });
932
+ await Promise.all(workers);
933
+ }
934
+ }
935
+ /**
936
+ * Generic runtime factory.
937
+ */
938
+ export function createRedisClient(config, logger) {
939
+ const client = new RedisClientWrapper(config, logger);
940
+ /**
941
+ * The runtime configuration has already been validated by
942
+ * RedisConfigSchema inside RedisClientWrapper.
943
+ *
944
+ * The specialized public API is therefore safe to expose according to
945
+ * the validated discriminator.
946
+ *
947
+ * The cast is isolated to this factory boundary instead of being spread
948
+ * throughout the implementation.
949
+ */
950
+ switch (client.mode) {
951
+ case "cluster":
952
+ return client;
953
+ case "sentinel":
954
+ return client;
955
+ case "standalone":
956
+ return client;
957
+ }
958
+ }