ts-server-lib 0.0.27
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/LICENSE +1 -0
- package/README.md +8 -0
- package/db/TSMongo.d.ts +103 -0
- package/db/TSMongo.js +483 -0
- package/db/TSRQW.d.ts +277 -0
- package/db/TSRQW.js +638 -0
- package/db/TSRedis.d.ts +372 -0
- package/db/TSRedis.js +955 -0
- package/package.json +85 -0
- package/ussd/TSUssdMenu.d.ts +139 -0
- package/ussd/TSUssdMenu.js +364 -0
- package/ussd/TSUssdScreen.d.ts +58 -0
- package/ussd/TSUssdScreen.js +218 -0
- package/ussd/index.d.ts +3 -0
- package/ussd/index.js +19 -0
- package/ussd/providers/AfricasTalking.d.ts +3 -0
- package/ussd/providers/AfricasTalking.js +17 -0
- package/ussd/providers/AirtelDRC.d.ts +9 -0
- package/ussd/providers/AirtelDRC.js +31 -0
- package/ussd/providers/OrangeDRC.d.ts +5 -0
- package/ussd/providers/OrangeDRC.js +213 -0
- package/ussd/providers/VodacomDRC.d.ts +9 -0
- package/ussd/providers/VodacomDRC.js +48 -0
- package/ussd/providers/_.d.ts +55 -0
- package/ussd/providers/_.js +83 -0
- package/ussd/providers/index.d.ts +13 -0
- package/ussd/providers/index.js +56 -0
- package/utils/TSFile.d.ts +36 -0
- package/utils/TSFile.js +244 -0
- package/utils/TSHash.d.ts +19 -0
- package/utils/TSHash.js +71 -0
- package/utils/TSRequest.d.ts +42 -0
- package/utils/TSRequest.js +282 -0
- package/utils/TSStub.d.ts +159 -0
- package/utils/TSStub.js +296 -0
- package/utils/abort.d.ts +18 -0
- package/utils/abort.js +97 -0
- package/utils/mime.json +11358 -0
package/db/TSRedis.js
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* TSRedis – Single file for Redis: connection (standalone/Sentinel), events (pub/sub), operations.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TSRedis = void 0;
|
|
7
|
+
exports.errMsg = errMsg;
|
|
8
|
+
exports.isClientOpen = isClientOpen;
|
|
9
|
+
exports.isStaleConnectionError = isStaleConnectionError;
|
|
10
|
+
exports.isActiveClientConnected = isActiveClientConnected;
|
|
11
|
+
const events_1 = require("events");
|
|
12
|
+
const redis_1 = require("redis");
|
|
13
|
+
const TSRequest_1 = require("../utils/TSRequest");
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
15
|
+
// Connection – module state
|
|
16
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
17
|
+
const ERR_PREFIX = 'TSRedis:ERROR';
|
|
18
|
+
function errMsg(e) {
|
|
19
|
+
return e instanceof Error ? e.message : String(e);
|
|
20
|
+
}
|
|
21
|
+
/** True when a node-redis client handle is open/ready (false after max-reconnect exhaustion). */
|
|
22
|
+
function isClientOpen(c) {
|
|
23
|
+
if (!c)
|
|
24
|
+
return false;
|
|
25
|
+
const open = c.isOpen;
|
|
26
|
+
if (typeof open === 'boolean')
|
|
27
|
+
return open;
|
|
28
|
+
const ready = c.isReady;
|
|
29
|
+
if (typeof ready === 'boolean')
|
|
30
|
+
return ready;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* True when an error indicates the caller is using a closed or half-open Redis / cluster client.
|
|
35
|
+
* Used by queue/cron recovery paths after max-reconnect exhaustion or cluster slot churn.
|
|
36
|
+
*/
|
|
37
|
+
function isStaleConnectionError(err) {
|
|
38
|
+
const msg = errMsg(err);
|
|
39
|
+
return /reading 'master'|reading 'replicas'|no Redis client connected|Connection is closed|Socket closed|ECONNRESET|CLUSTERDOWN|Max reconnect attempts reached|The client is closed/i.test(msg);
|
|
40
|
+
}
|
|
41
|
+
let client = null;
|
|
42
|
+
let readClient = null;
|
|
43
|
+
/**
|
|
44
|
+
* Active cluster client. Set ONLY after `clusterClient.connect()` fully resolves —
|
|
45
|
+
* callers that check this are guaranteed to get a ready, slot-mapped client.
|
|
46
|
+
*/
|
|
47
|
+
let clusterClient = null;
|
|
48
|
+
/** Active sentinel client (redis v6 createSentinel). Set only after connect() fully resolves. */
|
|
49
|
+
let sentinelClient = null;
|
|
50
|
+
/** Dedup promise: concurrent calls to connectRedisImpl share one cluster-connect attempt. */
|
|
51
|
+
let _clusterConnectPromise = null;
|
|
52
|
+
const DEFAULT_CONFIG = {
|
|
53
|
+
connectTimeout: 5000,
|
|
54
|
+
socketTimeout: 0,
|
|
55
|
+
autoReconnect: true,
|
|
56
|
+
maxReconnectRetries: 10,
|
|
57
|
+
reconnectDelay: 1000,
|
|
58
|
+
disableOfflineQueue: false
|
|
59
|
+
};
|
|
60
|
+
function parseRedisUrl(url, envPassword) {
|
|
61
|
+
const urlMatch = url.match(/^redis:\/\/:([^@]+)@(.+)$/);
|
|
62
|
+
if (urlMatch) {
|
|
63
|
+
return { url: `redis://${urlMatch[2]}`, password: urlMatch[1] };
|
|
64
|
+
}
|
|
65
|
+
return { url, password: envPassword };
|
|
66
|
+
}
|
|
67
|
+
function buildReconnectStrategy(cfg) {
|
|
68
|
+
if (!cfg.autoReconnect)
|
|
69
|
+
return false;
|
|
70
|
+
return (retries) => {
|
|
71
|
+
if (retries > cfg.maxReconnectRetries) {
|
|
72
|
+
console.error(ERR_PREFIX, 'max reconnect attempts reached');
|
|
73
|
+
return new Error('Max reconnect attempts reached');
|
|
74
|
+
}
|
|
75
|
+
const jitter = Math.floor(Math.random() * 200);
|
|
76
|
+
return Math.min(Math.pow(2, retries) * 50, cfg.reconnectDelay || 2000) + jitter;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Drop singleton slots that hold a closed client so the next connect creates a fresh pool. */
|
|
80
|
+
async function discardStaleClientsIfNeeded() {
|
|
81
|
+
const stale = (client != null && !isClientOpen(client)) ||
|
|
82
|
+
(readClient != null && !isClientOpen(readClient)) ||
|
|
83
|
+
(clusterClient != null && !isClientOpen(clusterClient)) ||
|
|
84
|
+
(sentinelClient != null && !isClientOpen(sentinelClient));
|
|
85
|
+
if (stale) {
|
|
86
|
+
await closeRedisImpl();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function buildStandaloneClientOptions(cfg, redisUrl, password) {
|
|
90
|
+
const clientOptions = {
|
|
91
|
+
url: redisUrl,
|
|
92
|
+
socket: {
|
|
93
|
+
connectTimeout: cfg.connectTimeout,
|
|
94
|
+
socketTimeout: cfg.socketTimeout || undefined,
|
|
95
|
+
keepAlive: true,
|
|
96
|
+
keepAliveInitialDelay: 5000,
|
|
97
|
+
noDelay: true,
|
|
98
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
99
|
+
},
|
|
100
|
+
disableOfflineQueue: cfg.disableOfflineQueue
|
|
101
|
+
};
|
|
102
|
+
if (cfg.clientName)
|
|
103
|
+
clientOptions.name = cfg.clientName;
|
|
104
|
+
if (cfg.pingInterval)
|
|
105
|
+
clientOptions.pingInterval = cfg.pingInterval;
|
|
106
|
+
if (cfg.commandsQueueMaxLength)
|
|
107
|
+
clientOptions.commandsQueueMaxLength = cfg.commandsQueueMaxLength;
|
|
108
|
+
if (password)
|
|
109
|
+
clientOptions.password = password;
|
|
110
|
+
return clientOptions;
|
|
111
|
+
}
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
113
|
+
// Connection – connect logic (standalone, Sentinel, read replicas)
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
115
|
+
async function connectRedisSentinel(sentinel, cfg) {
|
|
116
|
+
// Master password: embedded in cfg.url as redis://:pass@_ by buildConfigFromDescriptor.
|
|
117
|
+
const masterPassword = cfg.url.match(/^redis:\/\/:([^@]+)@/)?.[1];
|
|
118
|
+
const sc = (0, redis_1.createSentinel)({
|
|
119
|
+
name: sentinel.name,
|
|
120
|
+
sentinelRootNodes: sentinel.hosts,
|
|
121
|
+
nodeClientOptions: {
|
|
122
|
+
socket: { connectTimeout: cfg.connectTimeout, reconnectStrategy: buildReconnectStrategy(cfg) },
|
|
123
|
+
...(masterPassword ? { password: masterPassword } : {})
|
|
124
|
+
},
|
|
125
|
+
// sentinel.password is the sentinel-node auth (rare — only when sentinels have requirepass).
|
|
126
|
+
...(sentinel.password ? { sentinelClientOptions: { password: sentinel.password } } : {}),
|
|
127
|
+
// nodeAddressMap remaps master/replica addresses announced by sentinel to client-reachable
|
|
128
|
+
// addresses (e.g. Docker-internal 172.x → 127.0.0.1:hostPort for local testing).
|
|
129
|
+
...(sentinel.nodeAddressMap !== undefined ? { nodeAddressMap: sentinel.nodeAddressMap } : {})
|
|
130
|
+
});
|
|
131
|
+
sc.on('error', (err) => console.error(ERR_PREFIX, 'sentinel', errMsg(err)));
|
|
132
|
+
await sc.connect();
|
|
133
|
+
sentinelClient = sc;
|
|
134
|
+
}
|
|
135
|
+
async function connectReadReplicas(replicaConfig, cfg) {
|
|
136
|
+
if (!replicaConfig.urls?.length)
|
|
137
|
+
return;
|
|
138
|
+
const { url, password } = parseRedisUrl(replicaConfig.urls[0]);
|
|
139
|
+
const options = {
|
|
140
|
+
url,
|
|
141
|
+
socket: {
|
|
142
|
+
connectTimeout: cfg.connectTimeout,
|
|
143
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
144
|
+
},
|
|
145
|
+
readonly: true
|
|
146
|
+
};
|
|
147
|
+
if (password)
|
|
148
|
+
options.password = password;
|
|
149
|
+
readClient = (0, redis_1.createClient)(options);
|
|
150
|
+
readClient.on('error', (err) => console.error(ERR_PREFIX, 'read replica', errMsg(err)));
|
|
151
|
+
await readClient.connect();
|
|
152
|
+
}
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
154
|
+
// Connection – internal helpers (used by TSRedis static API)
|
|
155
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
156
|
+
async function connectRedisCluster(clusterConfig, cfg) {
|
|
157
|
+
// Password resolution order: explicit clusterConfig.password → URL-embedded in cfg.url → env var.
|
|
158
|
+
let password = clusterConfig.password;
|
|
159
|
+
if (!password) {
|
|
160
|
+
const urlMatch = cfg.url.match(/^redis:\/\/:([^@]+)@/);
|
|
161
|
+
if (urlMatch)
|
|
162
|
+
password = urlMatch[1];
|
|
163
|
+
else if (process.env.REDIS_PASSWORD)
|
|
164
|
+
password = process.env.REDIS_PASSWORD;
|
|
165
|
+
}
|
|
166
|
+
// `nodeAddressMap` is passed through verbatim so callers (e.g. host-side validators) can
|
|
167
|
+
// rewrite cluster-discovered hostnames to client-reachable addresses without touching docker DNS.
|
|
168
|
+
// Build inline so node-redis's RedisClusterOptions generic stays satisfied (the type
|
|
169
|
+
// inference depends on the literal object shape — extracting to a typed variable widens it).
|
|
170
|
+
// Use a local variable: module-level `clusterClient` is set ONLY after connect() resolves
|
|
171
|
+
// so concurrent callers that check `clusterClient !== null` always get a ready client.
|
|
172
|
+
const cluster = (0, redis_1.createCluster)({
|
|
173
|
+
rootNodes: clusterConfig.nodes.map((n) => ({
|
|
174
|
+
socket: { host: n.host, port: n.port }
|
|
175
|
+
})),
|
|
176
|
+
useReplicas: clusterConfig.useReplicas ?? false,
|
|
177
|
+
defaults: {
|
|
178
|
+
socket: {
|
|
179
|
+
connectTimeout: cfg.connectTimeout,
|
|
180
|
+
keepAlive: true,
|
|
181
|
+
keepAliveInitialDelay: 5000,
|
|
182
|
+
noDelay: true,
|
|
183
|
+
reconnectStrategy: buildReconnectStrategy(cfg)
|
|
184
|
+
},
|
|
185
|
+
...(password ? { password } : {})
|
|
186
|
+
},
|
|
187
|
+
...(clusterConfig.nodeAddressMap !== undefined
|
|
188
|
+
? { nodeAddressMap: clusterConfig.nodeAddressMap }
|
|
189
|
+
: {})
|
|
190
|
+
});
|
|
191
|
+
cluster.on('error', (err) => console.error(ERR_PREFIX, 'cluster', errMsg(err)));
|
|
192
|
+
await cluster.connect();
|
|
193
|
+
clusterClient = cluster; // assign only after full connection + slot discovery
|
|
194
|
+
return clusterClient;
|
|
195
|
+
}
|
|
196
|
+
async function connectRedisImpl(urlOrConfig) {
|
|
197
|
+
await discardStaleClientsIfNeeded();
|
|
198
|
+
if (client && isClientOpen(client))
|
|
199
|
+
return client;
|
|
200
|
+
if (clusterClient && isClientOpen(clusterClient)) {
|
|
201
|
+
return clusterClient;
|
|
202
|
+
}
|
|
203
|
+
if (sentinelClient && isClientOpen(sentinelClient)) {
|
|
204
|
+
return sentinelClient;
|
|
205
|
+
}
|
|
206
|
+
const config = typeof urlOrConfig === 'string' ? { url: urlOrConfig } : urlOrConfig;
|
|
207
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
208
|
+
if (config.cluster) {
|
|
209
|
+
// Dedup: concurrent callers share one connect attempt; clusterClient is only set after
|
|
210
|
+
// connect() fully resolves so nobody gets a not-yet-ready client.
|
|
211
|
+
if (!_clusterConnectPromise) {
|
|
212
|
+
_clusterConnectPromise = connectRedisCluster(config.cluster, cfg)
|
|
213
|
+
.finally(() => { _clusterConnectPromise = null; });
|
|
214
|
+
}
|
|
215
|
+
await _clusterConnectPromise;
|
|
216
|
+
return clusterClient;
|
|
217
|
+
}
|
|
218
|
+
if (config.sentinel) {
|
|
219
|
+
await connectRedisSentinel(config.sentinel, cfg);
|
|
220
|
+
return sentinelClient;
|
|
221
|
+
}
|
|
222
|
+
const { url: redisUrl, password } = parseRedisUrl(cfg.url, process.env.REDIS_PASSWORD);
|
|
223
|
+
const clientOptions = buildStandaloneClientOptions(cfg, redisUrl, password);
|
|
224
|
+
client = (0, redis_1.createClient)(clientOptions);
|
|
225
|
+
client.on('error', (err) => console.error(ERR_PREFIX, errMsg(err)));
|
|
226
|
+
await client.connect();
|
|
227
|
+
if (config.readReplicas?.enabled && config.readReplicas.urls?.length) {
|
|
228
|
+
await connectReadReplicas(config.readReplicas, cfg);
|
|
229
|
+
}
|
|
230
|
+
return client;
|
|
231
|
+
}
|
|
232
|
+
function getRedisImpl() {
|
|
233
|
+
return client;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
/** Returns the active cluster client when `connectRedisImpl` was called with `config.cluster`, else null. */
|
|
237
|
+
function getRedisClusterImpl() {
|
|
238
|
+
return clusterClient;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Returns whichever client backs the active singleton: standalone, sentinel, or cluster.
|
|
242
|
+
* Use this when the code path is topology-agnostic (e.g. health checks, GET/SET on a single key).
|
|
243
|
+
* For multi-key ops you still need to know cluster mode to enforce hash-tag co-location.
|
|
244
|
+
*/
|
|
245
|
+
function getActiveClientImpl() {
|
|
246
|
+
return clusterClient ?? sentinelClient ?? client;
|
|
247
|
+
}
|
|
248
|
+
/** True when TSRedis holds an active singleton client that is open (not post-max-reconnect closed). */
|
|
249
|
+
function isActiveClientConnected() {
|
|
250
|
+
const c = getActiveClientImpl();
|
|
251
|
+
return c != null && isClientOpen(c);
|
|
252
|
+
}
|
|
253
|
+
function requireActiveClient() {
|
|
254
|
+
const c = getActiveClientImpl();
|
|
255
|
+
if (!c)
|
|
256
|
+
throw new Error('Redis not connected');
|
|
257
|
+
return c;
|
|
258
|
+
}
|
|
259
|
+
function getRedisForReadImpl() {
|
|
260
|
+
return readClient ?? client;
|
|
261
|
+
}
|
|
262
|
+
function hasReadReplicaImpl() {
|
|
263
|
+
return readClient !== null;
|
|
264
|
+
}
|
|
265
|
+
async function checkRedisHealthImpl() {
|
|
266
|
+
const start = Date.now();
|
|
267
|
+
try {
|
|
268
|
+
// Both `RedisClientType.ping()` and `RedisClusterType.ping()` exist; cluster pings one node.
|
|
269
|
+
await requireActiveClient().ping();
|
|
270
|
+
return { healthy: true, latencyMs: Date.now() - start };
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return { healthy: false, latencyMs: -1 };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
277
|
+
// Events – pub/sub (internal)
|
|
278
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
279
|
+
async function publishImpl(channel, message) {
|
|
280
|
+
try {
|
|
281
|
+
// Cluster pub/sub: classic PUBLISH broadcasts to every node (bandwidth amplification).
|
|
282
|
+
// For per-slot delivery use SPUBLISH/SSUBSCRIBE (Redis 7+, sharded pub/sub).
|
|
283
|
+
await requireActiveClient().publish(channel, message);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
console.error(ERR_PREFIX, 'publish failed', channel, errMsg(error));
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function subscribeImpl(channel, handler) {
|
|
292
|
+
requireActiveClient(); // throws if nothing connected
|
|
293
|
+
if (clusterClient) {
|
|
294
|
+
await clusterClient.subscribe(channel, handler);
|
|
295
|
+
return async () => { await clusterClient.unsubscribe(channel); };
|
|
296
|
+
}
|
|
297
|
+
if (sentinelClient) {
|
|
298
|
+
// Sentinel has built-in pub/sub routing — no duplicate() needed.
|
|
299
|
+
await sentinelClient.subscribe(channel, handler);
|
|
300
|
+
return async () => { await sentinelClient.unsubscribe(channel); };
|
|
301
|
+
}
|
|
302
|
+
// Standalone: v6 manages the pub/sub connection internally — subscribe() directly.
|
|
303
|
+
await client.subscribe(channel, handler);
|
|
304
|
+
return async () => { await client.unsubscribe(channel); };
|
|
305
|
+
}
|
|
306
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
307
|
+
// Scan utilities (internal)
|
|
308
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
309
|
+
/** Drain a single node's scanIterator into the generator, respecting maxKeys cap. */
|
|
310
|
+
async function* _yieldBatches(iterator, maxKeys, yielded) {
|
|
311
|
+
for await (const keysBatch of iterator) {
|
|
312
|
+
for (const key of keysBatch) {
|
|
313
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
314
|
+
return;
|
|
315
|
+
yield key;
|
|
316
|
+
yielded.count++;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function* _scanCluster(pattern, batchSize, maxKeys, yielded) {
|
|
321
|
+
// Scan each master using its already-connected client directly.
|
|
322
|
+
// Reading master.client avoids nodeClient(master) which creates a new TCP connection
|
|
323
|
+
// when the node is disconnected, hanging indefinitely in k8s.
|
|
324
|
+
// isReady check: skip clients that are connecting/reconnecting.
|
|
325
|
+
for (const master of clusterClient.masters) {
|
|
326
|
+
const nodeClient = master.client;
|
|
327
|
+
if (!nodeClient || !nodeClient.isReady)
|
|
328
|
+
continue;
|
|
329
|
+
try {
|
|
330
|
+
yield* _yieldBatches(nodeClient.scanIterator({ MATCH: pattern, COUNT: batchSize }), maxKeys, yielded);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Shard unreachable — skip; L1 cleared, TTL handles L2 expiry.
|
|
334
|
+
}
|
|
335
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async function* _scanSentinel(pattern, batchSize, maxKeys, yielded) {
|
|
340
|
+
// SCAN is in NON_STICKY_COMMANDS and routes to the master automatically.
|
|
341
|
+
let cursor = '0';
|
|
342
|
+
do {
|
|
343
|
+
const reply = await sentinelClient.scan(cursor, { MATCH: pattern, COUNT: batchSize });
|
|
344
|
+
cursor = String(reply.cursor);
|
|
345
|
+
for (const key of reply.keys) {
|
|
346
|
+
if (maxKeys > 0 && yielded.count >= maxKeys)
|
|
347
|
+
return;
|
|
348
|
+
yield key;
|
|
349
|
+
yielded.count++;
|
|
350
|
+
}
|
|
351
|
+
} while (cursor !== '0' && (maxKeys <= 0 || yielded.count < maxKeys));
|
|
352
|
+
}
|
|
353
|
+
async function* scanKeysIteratorImpl(options = {}) {
|
|
354
|
+
const { pattern = '*', maxKeys = 10000, batchSize = 100 } = options;
|
|
355
|
+
const yielded = { count: 0 };
|
|
356
|
+
if (clusterClient && !client) {
|
|
357
|
+
yield* _scanCluster(pattern, batchSize, maxKeys, yielded);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (sentinelClient && !client) {
|
|
361
|
+
yield* _scanSentinel(pattern, batchSize, maxKeys, yielded);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Standalone: scanIterator yields string[] batches (one array per SCAN cursor step).
|
|
365
|
+
if (!client)
|
|
366
|
+
return;
|
|
367
|
+
yield* _yieldBatches(client.scanIterator({ MATCH: pattern, COUNT: batchSize }), maxKeys, yielded);
|
|
368
|
+
}
|
|
369
|
+
async function batchGetValuesImpl(keys) {
|
|
370
|
+
if (keys.length === 0)
|
|
371
|
+
return {};
|
|
372
|
+
// Per-key GET so keys can span different cluster slots.
|
|
373
|
+
const redis = requireActiveClient();
|
|
374
|
+
const values = await Promise.all(keys.map(k => redis.get(k)));
|
|
375
|
+
const result = {};
|
|
376
|
+
for (let i = 0; i < keys.length; i++)
|
|
377
|
+
result[keys[i]] = values[i] ?? null;
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
381
|
+
// Topology-aware multi-key helpers (internal)
|
|
382
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
383
|
+
/** True when `client` is a Redis Cluster client (has a `masters` array). */
|
|
384
|
+
function isClusterImpl(client) {
|
|
385
|
+
return 'masters' in client;
|
|
386
|
+
}
|
|
387
|
+
const CROSSSLOT_RE = /CROSSSLOT|cross.?slot/i;
|
|
388
|
+
/**
|
|
389
|
+
* Topology-aware MGET with CROSSSLOT fallback.
|
|
390
|
+
*
|
|
391
|
+
* Fast path: single `mGet` round-trip (correct when all keys share a hash tag / same slot).
|
|
392
|
+
* Cluster CROSSSLOT path: falls back to per-key GETs via `Promise.all` — N parallel GETs
|
|
393
|
+
* that node-redis auto-pipelines into one batch per target node.
|
|
394
|
+
*
|
|
395
|
+
* Callers that already use hash-tagged keys pay no overhead; cross-slot callers degrade
|
|
396
|
+
* gracefully instead of throwing.
|
|
397
|
+
*/
|
|
398
|
+
async function mGetImpl(client, keys) {
|
|
399
|
+
if (keys.length === 0)
|
|
400
|
+
return [];
|
|
401
|
+
try {
|
|
402
|
+
return await client.mGet(keys);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
406
|
+
if (!CROSSSLOT_RE.test(msg))
|
|
407
|
+
throw err;
|
|
408
|
+
return Promise.all(keys.map(k => client.get(k)));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Topology-aware multi-key SET (pipeline + CROSSSLOT fallback).
|
|
413
|
+
*
|
|
414
|
+
* Fast path: `multi().exec()` pipeline — one round-trip for all keys on the same slot.
|
|
415
|
+
* Cluster CROSSSLOT path: falls back to per-key SETEX/SET via `Promise.all`.
|
|
416
|
+
*
|
|
417
|
+
* entries.value must already be serialised to a string (JSON.stringify beforehand).
|
|
418
|
+
*/
|
|
419
|
+
async function mSetImpl(client, entries) {
|
|
420
|
+
if (entries.length === 0)
|
|
421
|
+
return;
|
|
422
|
+
const pipeline = client.multi();
|
|
423
|
+
for (const { key, value, ttl } of entries) {
|
|
424
|
+
if (ttl)
|
|
425
|
+
pipeline.setEx(key, ttl, value);
|
|
426
|
+
else
|
|
427
|
+
pipeline.set(key, value);
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
await pipeline.exec();
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
434
|
+
if (!CROSSSLOT_RE.test(msg))
|
|
435
|
+
throw err;
|
|
436
|
+
await Promise.all(entries.map(({ key, value, ttl }) => ttl ? client.setEx(key, ttl, value) : client.set(key, value)));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Topology-aware multi-key DELETE — the single canonical implementation.
|
|
441
|
+
*
|
|
442
|
+
* Standalone / sentinel: single `DEL k1 k2 …` round-trip.
|
|
443
|
+
* Cluster: per-key DEL loop — avoids CROSSSLOT when keys span different hash slots.
|
|
444
|
+
*
|
|
445
|
+
* All callers should use this instead of `client.del(array)` directly.
|
|
446
|
+
*/
|
|
447
|
+
async function mDelImpl(client, keys) {
|
|
448
|
+
if (keys.length === 0)
|
|
449
|
+
return 0;
|
|
450
|
+
if (isClusterImpl(client)) {
|
|
451
|
+
let n = 0;
|
|
452
|
+
for (const k of keys)
|
|
453
|
+
n += await client.del(k);
|
|
454
|
+
return n;
|
|
455
|
+
}
|
|
456
|
+
return client.del(keys);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Topology-aware FLUSHDB — flushes every data store.
|
|
460
|
+
*
|
|
461
|
+
* Standalone / sentinel: single `flushDb()` call.
|
|
462
|
+
* Cluster: `flushDb()` on the cluster client only flushes the node owning the command's
|
|
463
|
+
* hash slot — i.e. one master. This calls `flushDb()` on every master client directly.
|
|
464
|
+
*
|
|
465
|
+
* Use only in tests and dev tooling — never on production data.
|
|
466
|
+
*/
|
|
467
|
+
async function flushAllImpl(client) {
|
|
468
|
+
if (isClusterImpl(client)) {
|
|
469
|
+
await Promise.all(client.masters
|
|
470
|
+
.filter((m) => m.client != null)
|
|
471
|
+
.map((m) => m.client.flushDb()));
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
await client.flushDb();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
478
|
+
// Lifecycle (internal)
|
|
479
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
480
|
+
async function closeRedisImpl() {
|
|
481
|
+
if (readClient) {
|
|
482
|
+
try {
|
|
483
|
+
await readClient.quit();
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
console.error(ERR_PREFIX, 'close read replica', errMsg(e));
|
|
487
|
+
}
|
|
488
|
+
readClient = null;
|
|
489
|
+
}
|
|
490
|
+
if (clusterClient) {
|
|
491
|
+
try {
|
|
492
|
+
await clusterClient.quit();
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
console.error(ERR_PREFIX, 'close cluster', errMsg(e));
|
|
496
|
+
}
|
|
497
|
+
clusterClient = null;
|
|
498
|
+
}
|
|
499
|
+
if (sentinelClient) {
|
|
500
|
+
try {
|
|
501
|
+
await sentinelClient.close();
|
|
502
|
+
}
|
|
503
|
+
catch (e) {
|
|
504
|
+
console.error(ERR_PREFIX, 'close sentinel', errMsg(e));
|
|
505
|
+
}
|
|
506
|
+
sentinelClient = null;
|
|
507
|
+
}
|
|
508
|
+
if (client) {
|
|
509
|
+
try {
|
|
510
|
+
await client.quit();
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
console.error(ERR_PREFIX, 'close standalone', errMsg(e));
|
|
514
|
+
}
|
|
515
|
+
client = null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function getRedisConnectionStatsImpl() {
|
|
519
|
+
if (clusterClient) {
|
|
520
|
+
// Cluster mode: replica routing is handled internally by node-redis when `useReplicas`
|
|
521
|
+
// is true; we surface `hasReadReplica = true` to keep observability uniform with the
|
|
522
|
+
// standalone+readClient story. Per-node connection counts would require introspecting
|
|
523
|
+
// the cluster's slot map and are intentionally left out of this lightweight snapshot.
|
|
524
|
+
return {
|
|
525
|
+
connected: true,
|
|
526
|
+
mode: 'cluster',
|
|
527
|
+
hasReadReplica: true,
|
|
528
|
+
master: { connected: true },
|
|
529
|
+
replica: { connected: true, count: 1 }
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (sentinelClient) {
|
|
533
|
+
return { connected: true, mode: 'sentinel', hasReadReplica: false, master: { connected: true }, replica: { connected: false, count: 0 } };
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
connected: client !== null,
|
|
537
|
+
mode: 'standalone',
|
|
538
|
+
hasReadReplica: readClient !== null,
|
|
539
|
+
master: { connected: client !== null },
|
|
540
|
+
replica: { connected: readClient !== null, count: readClient ? 1 : 0 }
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
544
|
+
// Topology descriptor → RedisConfig translation
|
|
545
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
546
|
+
/**
|
|
547
|
+
* Translate a RedisTopologyDescriptor into the internal RedisConfig shape.
|
|
548
|
+
* Exported as TSRedis.buildConfigFromTopology for callers that need a RedisConfig
|
|
549
|
+
* alongside connectFromTopology (e.g. passing defaultConfig to configureRedisStrategy).
|
|
550
|
+
*/
|
|
551
|
+
function buildConfigFromDescriptor(d) {
|
|
552
|
+
const base = {
|
|
553
|
+
url: d.url ?? '',
|
|
554
|
+
maxReconnectRetries: d.maxReconnectRetries ?? DEFAULT_CONFIG.maxReconnectRetries,
|
|
555
|
+
reconnectDelay: d.reconnectDelay ?? DEFAULT_CONFIG.reconnectDelay,
|
|
556
|
+
connectTimeout: d.connectTimeout ?? DEFAULT_CONFIG.connectTimeout
|
|
557
|
+
};
|
|
558
|
+
switch (d.mode) {
|
|
559
|
+
case 'sentinel': {
|
|
560
|
+
// Master auth: embed in URL so connectRedisSentinel's urlMatch extracts it into
|
|
561
|
+
// sentinelOptions.password (the master connection password), independently of
|
|
562
|
+
// sentinel node auth. sentinel.password is strictly for sentinel-node requirepass
|
|
563
|
+
// (rare — most setups only password-protect the master, not the sentinel processes).
|
|
564
|
+
const urlWithPass = d.password ? `redis://:${d.password}@_` : (d.url ?? '');
|
|
565
|
+
return {
|
|
566
|
+
...base,
|
|
567
|
+
url: urlWithPass,
|
|
568
|
+
sentinel: {
|
|
569
|
+
hosts: d.sentinelNodes ?? [],
|
|
570
|
+
name: d.sentinelMaster ?? 'mymaster',
|
|
571
|
+
...(d.sentinelPassword ? { password: d.sentinelPassword } : {}),
|
|
572
|
+
...(d.sentinelNodeAddressMap !== undefined ? { nodeAddressMap: d.sentinelNodeAddressMap } : {})
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
case 'cluster':
|
|
577
|
+
return {
|
|
578
|
+
...base,
|
|
579
|
+
cluster: {
|
|
580
|
+
nodes: d.clusterNodes ?? [],
|
|
581
|
+
...(d.password ? { password: d.password } : {}),
|
|
582
|
+
...(d.clusterNodeAddressMap !== undefined ? { nodeAddressMap: d.clusterNodeAddressMap } : {})
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
default: // 'single'
|
|
586
|
+
return base;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Connect using a flat topology descriptor — the preferred entry point for all callers.
|
|
591
|
+
*
|
|
592
|
+
* Handles all three topologies (single / sentinel / cluster) from one call.
|
|
593
|
+
* No environment-variable reads; every value comes explicitly from the descriptor.
|
|
594
|
+
* Idempotent: if a client for this topology is already connected, this is a no-op.
|
|
595
|
+
* Stale closed clients (max-reconnect exhausted) are discarded and reconnected.
|
|
596
|
+
*/
|
|
597
|
+
async function connectFromTopologyImpl(descriptor) {
|
|
598
|
+
await discardStaleClientsIfNeeded();
|
|
599
|
+
const active = getActiveClientImpl();
|
|
600
|
+
if (active && isClientOpen(active))
|
|
601
|
+
return;
|
|
602
|
+
await connectRedisImpl(buildConfigFromDescriptor(descriptor));
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Build a RedisTopologyDescriptor from standard Redis environment variables.
|
|
606
|
+
*
|
|
607
|
+
* Detection priority:
|
|
608
|
+
* 1. REDIS_SENTINEL_NODES (comma-separated host:port) → sentinel
|
|
609
|
+
* 2. REDIS_CLUSTER_NODES (comma-separated host:port) → cluster
|
|
610
|
+
* 3. REDIS_URL → single
|
|
611
|
+
* 4. Default → cluster on 127.0.0.1:7000-7005 (local dev default)
|
|
612
|
+
*
|
|
613
|
+
* Env vars consumed:
|
|
614
|
+
* REDIS_SENTINEL_NODES e.g. "127.0.0.1:26380,127.0.0.1:26381,127.0.0.1:26382"
|
|
615
|
+
* REDIS_SENTINEL_MASTER sentinel master name (default: "mymaster")
|
|
616
|
+
* REDIS_CLUSTER_NODES e.g. "127.0.0.1:7000,...,127.0.0.1:7005"
|
|
617
|
+
* REDIS_CLUSTER_HOST_REWRITE host to rewrite cluster-announced addresses to (default: "127.0.0.1")
|
|
618
|
+
* REDIS_URL e.g. "redis://localhost:6379"
|
|
619
|
+
* REDIS_PASSWORD auth password (applied to all modes)
|
|
620
|
+
*
|
|
621
|
+
* For cluster mode the nodeAddressMap handles three redirect scenarios automatically:
|
|
622
|
+
* - Port-matched rewrites (Docker host-port-forward)
|
|
623
|
+
* - k8s FQDN MOVED redirects (redis-cluster-N.* → localhost:startPort+N)
|
|
624
|
+
* - Dynamic Docker pod-IP MOVED redirect mapping
|
|
625
|
+
* When all cluster nodes are non-loopback hosts (direct k8s pod routing), no rewrite is applied.
|
|
626
|
+
*/
|
|
627
|
+
function buildTopologyDescriptorFromEnvImpl() {
|
|
628
|
+
const password = process.env.REDIS_PASSWORD;
|
|
629
|
+
const sentinelRaw = process.env.REDIS_SENTINEL_NODES;
|
|
630
|
+
const clusterRaw = process.env.REDIS_CLUSTER_NODES;
|
|
631
|
+
const redisUrl = process.env.REDIS_URL;
|
|
632
|
+
if (sentinelRaw) {
|
|
633
|
+
const nodes = sentinelRaw.split(',').map(s => {
|
|
634
|
+
const [host, port] = s.trim().split(':');
|
|
635
|
+
return { host: host, port: Number(port) };
|
|
636
|
+
});
|
|
637
|
+
// REDIS_SENTINEL_MASTER_HOST + REDIS_SENTINEL_MASTER_PORT: remap Docker/k8s-internal
|
|
638
|
+
// master/replica addresses to a client-reachable address.
|
|
639
|
+
// Example: sentinel announces 172.21.0.2:6379 → remap to 127.0.0.1:6381.
|
|
640
|
+
// Loopback addresses (sentinel nodes themselves) are never remapped.
|
|
641
|
+
const addrHost = process.env.REDIS_SENTINEL_MASTER_HOST;
|
|
642
|
+
const addrPort = process.env.REDIS_SENTINEL_MASTER_PORT;
|
|
643
|
+
const sentinelNodeAddressMap = addrHost && addrPort
|
|
644
|
+
? (address) => {
|
|
645
|
+
// Only remap standard Redis master/replica port (6379).
|
|
646
|
+
// Sentinel root nodes (28380+) and sentinel peer ports (26379) must not be remapped —
|
|
647
|
+
// nodeAddressMap is applied to ALL node connections, not just master connections.
|
|
648
|
+
const colonIdx = address.lastIndexOf(':');
|
|
649
|
+
const reportedPort = colonIdx >= 0 ? Number(address.slice(colonIdx + 1)) : 6379;
|
|
650
|
+
if (reportedPort !== 6379)
|
|
651
|
+
return undefined;
|
|
652
|
+
return { host: addrHost, port: Number(addrPort) };
|
|
653
|
+
}
|
|
654
|
+
: undefined;
|
|
655
|
+
return {
|
|
656
|
+
mode: 'sentinel',
|
|
657
|
+
password,
|
|
658
|
+
sentinelNodes: nodes,
|
|
659
|
+
sentinelMaster: process.env.REDIS_SENTINEL_MASTER ?? 'mymaster',
|
|
660
|
+
...(sentinelNodeAddressMap ? { sentinelNodeAddressMap } : {})
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (clusterRaw || !redisUrl) {
|
|
664
|
+
const raw = clusterRaw
|
|
665
|
+
?? '127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003,127.0.0.1:7004,127.0.0.1:7005';
|
|
666
|
+
const nodes = raw.split(',').map(s => {
|
|
667
|
+
const [host, port] = s.trim().split(':');
|
|
668
|
+
return { host: host, port: Number(port) };
|
|
669
|
+
});
|
|
670
|
+
const hostRewrite = process.env.REDIS_CLUSTER_HOST_REWRITE ?? '127.0.0.1';
|
|
671
|
+
const knownPorts = new Set(nodes.map(n => n.port));
|
|
672
|
+
const startPort = Math.min(...nodes.map(n => n.port));
|
|
673
|
+
// Direct k8s: real pod hostnames, cluster is routable without any rewrite.
|
|
674
|
+
const isDirectK8s = nodes.every(n => n.host !== '127.0.0.1' && n.host !== 'localhost');
|
|
675
|
+
const k8sDynamicMap = new Map();
|
|
676
|
+
let k8sNextPort = startPort;
|
|
677
|
+
const clusterNodeAddressMap = isDirectK8s
|
|
678
|
+
? undefined
|
|
679
|
+
: (address) => {
|
|
680
|
+
const m = address.match(/:(\d+)$/);
|
|
681
|
+
if (!m)
|
|
682
|
+
return undefined;
|
|
683
|
+
const port = Number(m[1]);
|
|
684
|
+
if (knownPorts.has(port))
|
|
685
|
+
return { host: hostRewrite, port };
|
|
686
|
+
// k8s FQDN MOVED redirect: redis-cluster-N.* → localhost:startPort+N
|
|
687
|
+
const fqdn = address.match(/redis-cluster-(\d+)\./);
|
|
688
|
+
if (fqdn)
|
|
689
|
+
return { host: '127.0.0.1', port: startPort + Number(fqdn[1]) };
|
|
690
|
+
// Docker pod-IP MOVED redirect: dynamic sequential assignment
|
|
691
|
+
if (!k8sDynamicMap.has(address) && k8sNextPort < startPort + nodes.length) {
|
|
692
|
+
k8sDynamicMap.set(address, k8sNextPort++);
|
|
693
|
+
}
|
|
694
|
+
const mapped = k8sDynamicMap.get(address);
|
|
695
|
+
return mapped !== undefined ? { host: '127.0.0.1', port: mapped } : undefined;
|
|
696
|
+
};
|
|
697
|
+
return {
|
|
698
|
+
mode: 'cluster',
|
|
699
|
+
password,
|
|
700
|
+
url: `redis://${nodes[0].host}:${nodes[0].port}`,
|
|
701
|
+
clusterNodes: nodes,
|
|
702
|
+
clusterNodeAddressMap
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
return { mode: 'single', url: redisUrl, password };
|
|
706
|
+
}
|
|
707
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
708
|
+
// TSRedis – static API (connect, pub/sub, scan) + instance ops
|
|
709
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
710
|
+
class TSRedis extends events_1.EventEmitter {
|
|
711
|
+
redis;
|
|
712
|
+
constructor(instance) {
|
|
713
|
+
super();
|
|
714
|
+
this.redis = instance;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Connect from a flat topology descriptor — the preferred entry point.
|
|
718
|
+
* Handles single / sentinel / cluster from one call; no env-var reads inside.
|
|
719
|
+
* Idempotent: already-connected clients are reused.
|
|
720
|
+
* Stale closed clients (max-reconnect exhausted) are discarded and reconnected.
|
|
721
|
+
*/
|
|
722
|
+
static connectFromTopology = connectFromTopologyImpl;
|
|
723
|
+
/**
|
|
724
|
+
* Build a RedisTopologyDescriptor from standard Redis env vars.
|
|
725
|
+
* Use with connectFromTopology for zero-config topology-aware connections:
|
|
726
|
+
* await TSRedis.connectFromTopology(TSRedis.buildTopologyDescriptorFromEnv())
|
|
727
|
+
*/
|
|
728
|
+
static buildTopologyDescriptorFromEnv = buildTopologyDescriptorFromEnvImpl;
|
|
729
|
+
/**
|
|
730
|
+
* Translate a RedisTopologyDescriptor into a RedisConfig.
|
|
731
|
+
* Use when another API (e.g. configureRedisStrategy) still takes RedisConfig directly.
|
|
732
|
+
*/
|
|
733
|
+
static buildConfigFromTopology = buildConfigFromDescriptor;
|
|
734
|
+
/** Connect (standalone, Sentinel, Cluster, or with read replicas). Cluster is selected
|
|
735
|
+
* when the supplied `RedisConfig.cluster` is set; otherwise sentinel / standalone per
|
|
736
|
+
* the existing precedence rules. */
|
|
737
|
+
static connect = connectRedisImpl;
|
|
738
|
+
/** Standalone / sentinel client singleton. Returns null in cluster mode — use {@link getCluster}. */
|
|
739
|
+
static getClient = getRedisImpl;
|
|
740
|
+
/** Active cluster client. Returns null when not in cluster mode. */
|
|
741
|
+
static getCluster = getRedisClusterImpl;
|
|
742
|
+
/** Topology-agnostic accessor — returns the active client (standalone / sentinel / cluster). */
|
|
743
|
+
static getActiveClient = getActiveClientImpl;
|
|
744
|
+
static getClientForRead = getRedisForReadImpl;
|
|
745
|
+
static hasReadReplica = hasReadReplicaImpl;
|
|
746
|
+
static close = closeRedisImpl;
|
|
747
|
+
static checkHealth = checkRedisHealthImpl;
|
|
748
|
+
static getConnectionStats = getRedisConnectionStatsImpl;
|
|
749
|
+
static publish = publishImpl;
|
|
750
|
+
static subscribe = subscribeImpl;
|
|
751
|
+
static scanKeysIterator = scanKeysIteratorImpl;
|
|
752
|
+
static batchGetValues = batchGetValuesImpl;
|
|
753
|
+
/**
|
|
754
|
+
* True when `client` is a Redis Cluster client.
|
|
755
|
+
* Prefer the topology-aware helpers (mGet, mSet, mDel, flushAll) over manual branching.
|
|
756
|
+
*/
|
|
757
|
+
static isCluster = isClusterImpl;
|
|
758
|
+
/**
|
|
759
|
+
* Topology-aware MGET: tries a single mGet round-trip; on cluster CROSSSLOT falls back
|
|
760
|
+
* to per-key GETs via Promise.all. Safe for keys that span different hash slots.
|
|
761
|
+
*/
|
|
762
|
+
static mGet = mGetImpl;
|
|
763
|
+
/**
|
|
764
|
+
* Topology-aware multi-key SET: tries a multi().exec() pipeline; on cluster CROSSSLOT
|
|
765
|
+
* falls back to per-key SETEX/SET via Promise.all.
|
|
766
|
+
* entries.value must be pre-serialised (string).
|
|
767
|
+
*/
|
|
768
|
+
static mSet = mSetImpl;
|
|
769
|
+
/**
|
|
770
|
+
* Topology-aware multi-key DELETE: per-key loop in cluster (avoids CROSSSLOT),
|
|
771
|
+
* batch DEL in standalone/sentinel (single round-trip).
|
|
772
|
+
*/
|
|
773
|
+
static mDel = mDelImpl;
|
|
774
|
+
/**
|
|
775
|
+
* Topology-aware FLUSHDB: flushes every master in cluster mode, single flushDb()
|
|
776
|
+
* otherwise. Test/dev tooling only — never call on production data.
|
|
777
|
+
*/
|
|
778
|
+
static flushAll = flushAllImpl;
|
|
779
|
+
async cachedRequest(options, defaultValue = {}, debug) {
|
|
780
|
+
if (!options.getData) {
|
|
781
|
+
options.getData = (response) => response;
|
|
782
|
+
}
|
|
783
|
+
const { url, key, ...requestOptions } = options;
|
|
784
|
+
const result = await this.redis.get(key);
|
|
785
|
+
if (result) {
|
|
786
|
+
return JSON.parse(result);
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
const response = await TSRequest_1.TSRequest.json(url, { ...requestOptions, method: (options.method || 'post') }, debug);
|
|
790
|
+
const data = options.getData(response);
|
|
791
|
+
if (data) {
|
|
792
|
+
let value = this.pathToStore(options.pathValue || [], data);
|
|
793
|
+
value = options.format && typeof options.format === 'function'
|
|
794
|
+
? options.format(value)
|
|
795
|
+
: value;
|
|
796
|
+
if (value) {
|
|
797
|
+
await this.redis.setEx(key, options.expire || 300, JSON.stringify(value));
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
return defaultValue;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
console.error(response);
|
|
806
|
+
return defaultValue;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
pathToStore(keys = [], object) {
|
|
811
|
+
let current = object;
|
|
812
|
+
let value = object;
|
|
813
|
+
for (const i in keys) {
|
|
814
|
+
if (Object.prototype.hasOwnProperty.call(keys, i) && current && typeof current === 'object' && current[keys[i]]) {
|
|
815
|
+
value = current[keys[i]];
|
|
816
|
+
current = value;
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
value = undefined;
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return value;
|
|
824
|
+
}
|
|
825
|
+
cached = async (path, options) => {
|
|
826
|
+
const result = await this.redis.get(path);
|
|
827
|
+
if (result)
|
|
828
|
+
return result;
|
|
829
|
+
if (options?.value) {
|
|
830
|
+
await this.redis.setEx(path, options.expire || 3600, options.value);
|
|
831
|
+
return options.value;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
load = async (entity) => {
|
|
835
|
+
const result = await this.redis.hGetAll(entity);
|
|
836
|
+
const mappedResult = [];
|
|
837
|
+
Object.keys(result || {}).map((k) => mappedResult.push(JSON.parse(result[k])));
|
|
838
|
+
return mappedResult;
|
|
839
|
+
};
|
|
840
|
+
get = async (key, cb) => {
|
|
841
|
+
const result = await this.redis.get(key);
|
|
842
|
+
return cb ? cb(result) : result;
|
|
843
|
+
};
|
|
844
|
+
set = async (key, data) => (await this.redis.set(key, String(data))) === 'OK';
|
|
845
|
+
hget = async (key, field) => {
|
|
846
|
+
const result = await this.redis.hGet(key, field);
|
|
847
|
+
return result ? JSON.parse(result) : null;
|
|
848
|
+
};
|
|
849
|
+
getM = async (entity, keys) => this.redis.hmGet(entity, keys);
|
|
850
|
+
hgetall = async (key, cb) => {
|
|
851
|
+
const result = await this.redis.hGetAll(key);
|
|
852
|
+
return cb ? cb(result) : result;
|
|
853
|
+
};
|
|
854
|
+
hset = async (key, field, data) => {
|
|
855
|
+
if (typeof data !== 'undefined' && data !== null && String(data).length > 0) {
|
|
856
|
+
return (await this.redis.hSet(key, field, JSON.stringify(data))) >= 0;
|
|
857
|
+
}
|
|
858
|
+
return false;
|
|
859
|
+
};
|
|
860
|
+
hdel = async (key, fields) => (await this.redis.hDel(key, fields)) > 0;
|
|
861
|
+
del = async (keys) => {
|
|
862
|
+
const ks = Array.isArray(keys) ? keys : [keys];
|
|
863
|
+
if (ks.length === 0)
|
|
864
|
+
return false;
|
|
865
|
+
// Per-key DEL: cluster mode rejects multi-key DEL across slots (CROSSSLOT).
|
|
866
|
+
const counts = await Promise.all(ks.map(k => this.redis.del(k)));
|
|
867
|
+
return counts.some(c => c > 0);
|
|
868
|
+
};
|
|
869
|
+
loadLists = async (pattern = '*') => {
|
|
870
|
+
const list = await this.scanr({ pattern });
|
|
871
|
+
const result = {};
|
|
872
|
+
if (list.length === 0)
|
|
873
|
+
return result;
|
|
874
|
+
// Per-key TYPE: cluster multi() across different slots causes CROSSSLOT.
|
|
875
|
+
const types = await Promise.all(list.map(k => this.redis.type(k).catch(() => 'none')));
|
|
876
|
+
const hashKeys = [];
|
|
877
|
+
const hashScopes = [];
|
|
878
|
+
for (let i = 0; i < list.length; i++) {
|
|
879
|
+
if (String(types[i]) === 'hash') {
|
|
880
|
+
hashKeys.push(list[i]);
|
|
881
|
+
hashScopes.push(list[i].split(':').pop());
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (hashKeys.length === 0)
|
|
885
|
+
return result;
|
|
886
|
+
// Per-key hGetAll: cluster multi() across slots causes CROSSSLOT.
|
|
887
|
+
const hashResults = await Promise.all(hashKeys.map(k => this.redis.hGetAll(k).catch(() => ({}))));
|
|
888
|
+
for (let j = 0; j < hashScopes.length; j++) {
|
|
889
|
+
const val = hashResults[j];
|
|
890
|
+
result[hashScopes[j]] = (val != null && typeof val === 'object') ? val : {};
|
|
891
|
+
}
|
|
892
|
+
return result;
|
|
893
|
+
};
|
|
894
|
+
hscanr = async (entity, { pattern, COUNT = 200 }, cb) => {
|
|
895
|
+
const MATCH = this.match(pattern ?? '*');
|
|
896
|
+
const results = {};
|
|
897
|
+
let cursor = '0';
|
|
898
|
+
let entries;
|
|
899
|
+
do {
|
|
900
|
+
({ cursor, entries } = await this.redis.hScan(entity, cursor, { COUNT, MATCH }));
|
|
901
|
+
for (let i = 0; i < entries.length; i++) {
|
|
902
|
+
if (this.check(entries[i].value, pattern ?? '*')) {
|
|
903
|
+
if (cb)
|
|
904
|
+
cb(JSON.parse(entries[i].value), entries[i].field);
|
|
905
|
+
else
|
|
906
|
+
results[entries[i].field] = JSON.parse(entries[i].value);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
} while (cursor !== '0');
|
|
910
|
+
return results;
|
|
911
|
+
};
|
|
912
|
+
zscanr = async (entity, { pattern, COUNT = 200 }, cb = (value, score) => ({ value, score })) => {
|
|
913
|
+
const MATCH = this.match(pattern ?? '*');
|
|
914
|
+
const results = [];
|
|
915
|
+
let cursor = '0';
|
|
916
|
+
let members;
|
|
917
|
+
do {
|
|
918
|
+
({ cursor, members } = await this.redis.zScan(entity, cursor, { COUNT, MATCH }));
|
|
919
|
+
for (let i = 0; i < members.length; i++) {
|
|
920
|
+
if (this.check(members[i].value, pattern ?? '*')) {
|
|
921
|
+
const data = cb(members[i].value, String(members[i].score));
|
|
922
|
+
if (typeof data !== 'undefined')
|
|
923
|
+
results.push(data);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
} while (cursor !== '0');
|
|
927
|
+
return results;
|
|
928
|
+
};
|
|
929
|
+
scanr = async ({ pattern, COUNT = 200 }, cb) => {
|
|
930
|
+
// Use the cluster-aware scanKeysIterator so all shards are covered.
|
|
931
|
+
const results = [];
|
|
932
|
+
for await (const key of scanKeysIteratorImpl({ pattern: this.match(pattern ?? '*'), maxKeys: COUNT * 500 })) {
|
|
933
|
+
if (!this.check(key, pattern ?? '*'))
|
|
934
|
+
continue;
|
|
935
|
+
if (cb)
|
|
936
|
+
cb([key]);
|
|
937
|
+
else
|
|
938
|
+
results.push(key);
|
|
939
|
+
}
|
|
940
|
+
return results;
|
|
941
|
+
};
|
|
942
|
+
static _regexCache = new Map();
|
|
943
|
+
check = (value, pattern) => {
|
|
944
|
+
if (typeof pattern !== 'object' || !pattern.text)
|
|
945
|
+
return true;
|
|
946
|
+
let re = TSRedis._regexCache.get(pattern.text);
|
|
947
|
+
if (!re) {
|
|
948
|
+
re = new RegExp(pattern.text, 'i');
|
|
949
|
+
TSRedis._regexCache.set(pattern.text, re);
|
|
950
|
+
}
|
|
951
|
+
return value.search(re) > -1;
|
|
952
|
+
};
|
|
953
|
+
match = (pattern) => (typeof pattern === 'string' ? pattern : (pattern.match ?? '*'));
|
|
954
|
+
}
|
|
955
|
+
exports.TSRedis = TSRedis;
|