@tekir/redis 0.1.0 → 0.1.2

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/dist/manager.d.ts CHANGED
@@ -106,12 +106,18 @@ export declare class RedisManager {
106
106
  unsubscribe(channel?: string): Promise<void>;
107
107
  /** @see {@link Redis.send} */
108
108
  send(command: string, args?: string[]): Promise<any>;
109
+ /** @see {@link Redis.keyName} */
110
+ keyName(key: string): string;
109
111
  /** @see {@link Redis.getJSON} */
110
112
  getJSON<T = any>(key: string): Promise<T | null>;
113
+ /** @see {@link Redis.setEx} */
114
+ setEx(key: string, value: string | number, seconds: number): Promise<void>;
111
115
  /** @see {@link Redis.setJSON} */
112
116
  setJSON(key: string, value: any, expireSeconds?: number): Promise<void>;
113
117
  /** @see {@link Redis.remember} */
114
118
  remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
119
+ /** @see {@link Redis.clearPrefix} */
120
+ clearPrefix(): Promise<number>;
115
121
  /** @see {@link Redis.flushdb} */
116
122
  flushdb(): Promise<void>;
117
123
  /** @see {@link Redis.connected} */
package/dist/manager.js CHANGED
@@ -138,12 +138,18 @@ export class RedisManager {
138
138
  unsubscribe(channel) { return this.connection().unsubscribe(channel); }
139
139
  /** @see {@link Redis.send} */
140
140
  send(command, args = []) { return this.connection().send(command, args); }
141
+ /** @see {@link Redis.keyName} */
142
+ keyName(key) { return this.connection().keyName(key); }
141
143
  /** @see {@link Redis.getJSON} */
142
144
  getJSON(key) { return this.connection().getJSON(key); }
145
+ /** @see {@link Redis.setEx} */
146
+ setEx(key, value, seconds) { return this.connection().setEx(key, value, seconds); }
143
147
  /** @see {@link Redis.setJSON} */
144
148
  setJSON(key, value, expireSeconds) { return this.connection().setJSON(key, value, expireSeconds); }
145
149
  /** @see {@link Redis.remember} */
146
150
  remember(key, seconds, callback) { return this.connection().remember(key, seconds, callback); }
151
+ /** @see {@link Redis.clearPrefix} */
152
+ clearPrefix() { return this.connection().clearPrefix(); }
147
153
  /** @see {@link Redis.flushdb} */
148
154
  flushdb() { return this.connection().flushdb(); }
149
155
  /** @see {@link Redis.connected} */
package/dist/redis.d.ts CHANGED
@@ -21,6 +21,15 @@ export declare class Redis {
21
21
  */
22
22
  constructor(config?: RedisConnectionConfig);
23
23
  private key;
24
+ /** Resolve the actual Redis key after applying this connection's namespace. */
25
+ keyName(key: string): string;
26
+ /**
27
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
28
+ *
29
+ * @param url - The connection URL, possibly containing `user:pass@host`.
30
+ * @returns The URL with the userinfo component masked.
31
+ */
32
+ private static maskUrl;
24
33
  /**
25
34
  * Open the connection to the Redis server.
26
35
  *
@@ -66,6 +75,8 @@ export declare class Redis {
66
75
  * ```
67
76
  */
68
77
  set(key: string, value: string | number): Promise<void>;
78
+ /** Atomically set a value and its expiration time. */
79
+ setEx(key: string, value: string | number, seconds: number): Promise<void>;
69
80
  /**
70
81
  * Delete one or more keys.
71
82
  *
@@ -185,6 +196,11 @@ export declare class Redis {
185
196
  /**
186
197
  * Subscribe to a channel and receive messages via a callback.
187
198
  *
199
+ * The message passed to the callback is the raw string received from Redis and
200
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
201
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
202
+ * try/catch to guard against poisoned payloads.
203
+ *
188
204
  * @param channel - The channel name to subscribe to.
189
205
  * @param callback - Invoked for each message received on the channel.
190
206
  *
@@ -203,10 +219,16 @@ export declare class Redis {
203
219
  */
204
220
  unsubscribe(channel?: string): Promise<void>;
205
221
  /**
206
- * Send a raw Redis command.
222
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
223
+ *
224
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
225
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
226
+ * known values. Never build a command name or its arguments from user input:
227
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
228
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
207
229
  *
208
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
209
- * @param args - Arguments for the command.
230
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
231
+ * @param args - Arguments for the command. Must not contain untrusted input.
210
232
  * @returns The raw response from Redis.
211
233
  *
212
234
  * @example
@@ -249,6 +271,11 @@ export declare class Redis {
249
271
  * @param callback - Async function invoked to compute the value on a cache miss.
250
272
  * @returns The cached or freshly computed value.
251
273
  *
274
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
275
+ * concurrent misses, only one caller runs `callback()` while the others wait
276
+ * for the freshly-populated value. This protects the backend from a stampede
277
+ * (thundering herd) when a hot key expires.
278
+ *
252
279
  * @example
253
280
  * ```ts
254
281
  * const users = await redis.remember('all-users', 60, async () => {
@@ -258,7 +285,24 @@ export declare class Redis {
258
285
  */
259
286
  remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
260
287
  /**
261
- * Delete all keys in the currently selected database.
288
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
289
+ *
290
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
291
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
292
+ * share the same Redis database (sessions, queues, ...) untouched. If no
293
+ * prefix is configured this deletes nothing and returns `0`, to avoid
294
+ * accidentally wiping the whole database.
295
+ *
296
+ * @returns The number of keys deleted.
297
+ */
298
+ clearPrefix(): Promise<number>;
299
+ /**
300
+ * Delete ALL keys in the currently selected database. DANGEROUS.
301
+ *
302
+ * This ignores the key prefix and removes every key in the database, including
303
+ * data owned by other logical stores (sessions, queues, other caches) that
304
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
305
+ * only this store's keys. Never expose this to user-triggered code paths.
262
306
  *
263
307
  * @returns A promise that resolves once the database has been flushed.
264
308
  */
package/dist/redis.js CHANGED
@@ -20,9 +20,17 @@ export class Redis {
20
20
  */
21
21
  constructor(config = {}) {
22
22
  this.config = config;
23
- this.prefix = config.prefix || '';
23
+ this.prefix = (config.prefix || '').replace(/:+$/, '');
24
24
  const { RedisClient } = Bun;
25
25
  const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379';
26
+ // Encourage TLS in production: a plaintext redis:// connection exposes
27
+ // credentials and data to network observers. Warn once instead of throwing
28
+ // to stay backward compatible with local/dev setups.
29
+ const isTls = config.tls != null || url.startsWith('rediss://');
30
+ if (!isTls && process.env.NODE_ENV === 'production') {
31
+ console.warn(`[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
32
+ `Use a rediss:// URL or set tls to encrypt credentials and data in transit.`);
33
+ }
26
34
  this.client = new RedisClient(url, {
27
35
  connectionTimeout: config.connectionTimeout,
28
36
  idleTimeout: config.idleTimeout,
@@ -35,6 +43,17 @@ export class Redis {
35
43
  key(k) {
36
44
  return this.prefix ? `${this.prefix}:${k}` : k;
37
45
  }
46
+ /** Resolve the actual Redis key after applying this connection's namespace. */
47
+ keyName(key) { return this.key(key); }
48
+ /**
49
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
50
+ *
51
+ * @param url - The connection URL, possibly containing `user:pass@host`.
52
+ * @returns The URL with the userinfo component masked.
53
+ */
54
+ static maskUrl(url) {
55
+ return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@');
56
+ }
38
57
  // ─── Connection ────────────────────────────────────────
39
58
  /**
40
59
  * Open the connection to the Redis server.
@@ -82,6 +101,12 @@ export class Redis {
82
101
  * ```
83
102
  */
84
103
  async set(key, value) { await this.client.set(this.key(key), String(value)); }
104
+ /** Atomically set a value and its expiration time. */
105
+ async setEx(key, value, seconds) {
106
+ if (!Number.isFinite(seconds) || seconds <= 0)
107
+ throw new Error('Redis setEx seconds must be a positive number');
108
+ await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))]);
109
+ }
85
110
  /**
86
111
  * Delete one or more keys.
87
112
  *
@@ -204,6 +229,11 @@ export class Redis {
204
229
  /**
205
230
  * Subscribe to a channel and receive messages via a callback.
206
231
  *
232
+ * The message passed to the callback is the raw string received from Redis and
233
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
234
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
235
+ * try/catch to guard against poisoned payloads.
236
+ *
207
237
  * @param channel - The channel name to subscribe to.
208
238
  * @param callback - Invoked for each message received on the channel.
209
239
  *
@@ -225,10 +255,16 @@ export class Redis {
225
255
  async unsubscribe(channel) { await this.client.unsubscribe(channel); }
226
256
  // ─── Raw command ──────────────────────────────────────
227
257
  /**
228
- * Send a raw Redis command.
258
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
259
+ *
260
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
261
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
262
+ * known values. Never build a command name or its arguments from user input:
263
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
264
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
229
265
  *
230
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
231
- * @param args - Arguments for the command.
266
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
267
+ * @param args - Arguments for the command. Must not contain untrusted input.
232
268
  * @returns The raw response from Redis.
233
269
  *
234
270
  * @example
@@ -256,7 +292,11 @@ export class Redis {
256
292
  try {
257
293
  return JSON.parse(val);
258
294
  }
259
- catch {
295
+ catch (e) {
296
+ // Surface corrupt/poisoned payloads instead of silently masking them as a
297
+ // cache miss. Returning null still preserves the previous behaviour for
298
+ // callers, but the warning makes the problem diagnosable.
299
+ console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${e.message}`);
260
300
  return null;
261
301
  }
262
302
  }
@@ -273,9 +313,17 @@ export class Redis {
273
313
  * ```
274
314
  */
275
315
  async setJSON(key, value, expireSeconds) {
276
- await this.set(key, JSON.stringify(value));
277
- if (expireSeconds)
278
- await this.expire(key, expireSeconds);
316
+ const payload = JSON.stringify(value);
317
+ if (payload === undefined)
318
+ throw new Error(`Redis cannot serialize undefined for key "${key}"`);
319
+ if (expireSeconds && expireSeconds > 0) {
320
+ // Atomic SET ... EX so a crash between writing the value and setting the
321
+ // TTL can never leave a permanent (TTL-less) key behind.
322
+ await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))]);
323
+ }
324
+ else {
325
+ await this.client.set(this.key(key), payload);
326
+ }
279
327
  }
280
328
  /**
281
329
  * Cache-aside helper: return the cached value if it exists, otherwise execute
@@ -286,6 +334,11 @@ export class Redis {
286
334
  * @param callback - Async function invoked to compute the value on a cache miss.
287
335
  * @returns The cached or freshly computed value.
288
336
  *
337
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
338
+ * concurrent misses, only one caller runs `callback()` while the others wait
339
+ * for the freshly-populated value. This protects the backend from a stampede
340
+ * (thundering herd) when a hot key expires.
341
+ *
289
342
  * @example
290
343
  * ```ts
291
344
  * const users = await redis.remember('all-users', 60, async () => {
@@ -297,12 +350,93 @@ export class Redis {
297
350
  const cached = await this.getJSON(key);
298
351
  if (cached !== null)
299
352
  return cached;
300
- const value = await callback();
301
- await this.setJSON(key, value, seconds);
302
- return value;
353
+ const lockKey = this.key(`${key}:__lock`);
354
+ const lockToken = crypto.randomUUID();
355
+ // Try to become the single flight that computes the value. SET NX EX gives
356
+ // a self-expiring lock so a crashed holder cannot deadlock other callers.
357
+ const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10']);
358
+ if (acquired == null) {
359
+ // Another caller is computing it. Wait for the populated value, but do
360
+ // not run the callback without owning the lock: doing so would turn slow
361
+ // cache fills back into a stampede. A bounded timeout keeps callers from
362
+ // hanging forever if Redis itself is unhealthy.
363
+ for (let i = 0; i < 300; i++) {
364
+ await new Promise(r => setTimeout(r, 100));
365
+ const waited = await this.getJSON(key);
366
+ if (waited !== null)
367
+ return waited;
368
+ }
369
+ throw new Error(`Redis remember timed out waiting for lock on "${key}"`);
370
+ }
371
+ const renewScript = `
372
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
373
+ return redis.call('EXPIRE', KEYS[1], 10)
374
+ end
375
+ return 0
376
+ `;
377
+ const renewTimer = setInterval(() => {
378
+ void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => { });
379
+ }, 3000);
380
+ renewTimer.unref?.();
381
+ try {
382
+ // Re-check after acquiring the lock: a racing holder may have populated it.
383
+ const fresh = await this.getJSON(key);
384
+ if (fresh !== null)
385
+ return fresh;
386
+ const value = await callback();
387
+ await this.setJSON(key, value, seconds);
388
+ return value;
389
+ }
390
+ finally {
391
+ clearInterval(renewTimer);
392
+ // Only the owner may release the lock. A plain DEL lets a slow callback
393
+ // delete a successor's lock after its own 10s lease expired, reopening
394
+ // the stampede window. Waiters that never acquired it release nothing.
395
+ if (acquired != null) {
396
+ const releaseScript = `
397
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
398
+ return redis.call('DEL', KEYS[1])
399
+ end
400
+ return 0
401
+ `;
402
+ await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => { });
403
+ }
404
+ }
405
+ }
406
+ /**
407
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
408
+ *
409
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
410
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
411
+ * share the same Redis database (sessions, queues, ...) untouched. If no
412
+ * prefix is configured this deletes nothing and returns `0`, to avoid
413
+ * accidentally wiping the whole database.
414
+ *
415
+ * @returns The number of keys deleted.
416
+ */
417
+ async clearPrefix() {
418
+ if (!this.prefix)
419
+ return 0;
420
+ const pattern = `${this.prefix}:*`;
421
+ let cursor = '0';
422
+ let deleted = 0;
423
+ do {
424
+ const [next, batch] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']);
425
+ cursor = next;
426
+ if (batch.length) {
427
+ await this.client.send('DEL', batch);
428
+ deleted += batch.length;
429
+ }
430
+ } while (cursor !== '0');
431
+ return deleted;
303
432
  }
304
433
  /**
305
- * Delete all keys in the currently selected database.
434
+ * Delete ALL keys in the currently selected database. DANGEROUS.
435
+ *
436
+ * This ignores the key prefix and removes every key in the database, including
437
+ * data owned by other logical stores (sessions, queues, other caches) that
438
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
439
+ * only this store's keys. Never expose this to user-triggered code paths.
306
440
  *
307
441
  * @returns A promise that resolves once the database has been flushed.
308
442
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekir/redis",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Redis connection and client management",
5
5
  "author": "dev@tekir.io",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@tekir/core": "^0.1.0"
42
+ "@tekir/core": "^0.1.35"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "rm -rf dist && tsc --noEmit false",
package/src/manager.ts CHANGED
@@ -146,12 +146,18 @@ export class RedisManager {
146
146
  unsubscribe(channel?: string) { return this.connection().unsubscribe(channel) }
147
147
  /** @see {@link Redis.send} */
148
148
  send(command: string, args: string[] = []) { return this.connection().send(command, args) }
149
+ /** @see {@link Redis.keyName} */
150
+ keyName(key: string) { return this.connection().keyName(key) }
149
151
  /** @see {@link Redis.getJSON} */
150
152
  getJSON<T = any>(key: string) { return this.connection().getJSON<T>(key) }
153
+ /** @see {@link Redis.setEx} */
154
+ setEx(key: string, value: string | number, seconds: number) { return this.connection().setEx(key, value, seconds) }
151
155
  /** @see {@link Redis.setJSON} */
152
156
  setJSON(key: string, value: any, expireSeconds?: number) { return this.connection().setJSON(key, value, expireSeconds) }
153
157
  /** @see {@link Redis.remember} */
154
158
  remember<T>(key: string, seconds: number, callback: () => Promise<T>) { return this.connection().remember<T>(key, seconds, callback) }
159
+ /** @see {@link Redis.clearPrefix} */
160
+ clearPrefix() { return this.connection().clearPrefix() }
155
161
  /** @see {@link Redis.flushdb} */
156
162
  flushdb() { return this.connection().flushdb() }
157
163
  /** @see {@link Redis.connected} */
package/src/redis.ts CHANGED
@@ -23,11 +23,22 @@ export class Redis {
23
23
  */
24
24
  constructor(config: RedisConnectionConfig = {}) {
25
25
  this.config = config
26
- this.prefix = config.prefix || ''
26
+ this.prefix = (config.prefix || '').replace(/:+$/, '')
27
27
 
28
28
  const { RedisClient } = Bun as any
29
29
  const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379'
30
30
 
31
+ // Encourage TLS in production: a plaintext redis:// connection exposes
32
+ // credentials and data to network observers. Warn once instead of throwing
33
+ // to stay backward compatible with local/dev setups.
34
+ const isTls = config.tls != null || url.startsWith('rediss://')
35
+ if (!isTls && process.env.NODE_ENV === 'production') {
36
+ console.warn(
37
+ `[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
38
+ `Use a rediss:// URL or set tls to encrypt credentials and data in transit.`
39
+ )
40
+ }
41
+
31
42
  this.client = new RedisClient(url, {
32
43
  connectionTimeout: config.connectionTimeout,
33
44
  idleTimeout: config.idleTimeout,
@@ -42,6 +53,19 @@ export class Redis {
42
53
  return this.prefix ? `${this.prefix}:${k}` : k
43
54
  }
44
55
 
56
+ /** Resolve the actual Redis key after applying this connection's namespace. */
57
+ keyName(key: string): string { return this.key(key) }
58
+
59
+ /**
60
+ * Strip any credentials embedded in a Redis URL so it is safe to log.
61
+ *
62
+ * @param url - The connection URL, possibly containing `user:pass@host`.
63
+ * @returns The URL with the userinfo component masked.
64
+ */
65
+ private static maskUrl(url: string): string {
66
+ return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@')
67
+ }
68
+
45
69
  // ─── Connection ────────────────────────────────────────
46
70
 
47
71
  /**
@@ -96,6 +120,12 @@ export class Redis {
96
120
  */
97
121
  async set(key: string, value: string | number): Promise<void> { await this.client.set(this.key(key), String(value)) }
98
122
 
123
+ /** Atomically set a value and its expiration time. */
124
+ async setEx(key: string, value: string | number, seconds: number): Promise<void> {
125
+ if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Redis setEx seconds must be a positive number')
126
+ await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))])
127
+ }
128
+
99
129
  /**
100
130
  * Delete one or more keys.
101
131
  *
@@ -236,6 +266,11 @@ export class Redis {
236
266
  /**
237
267
  * Subscribe to a channel and receive messages via a callback.
238
268
  *
269
+ * The message passed to the callback is the raw string received from Redis and
270
+ * is NOT deserialized by this wrapper. Treat it as untrusted input: validate
271
+ * it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
272
+ * try/catch to guard against poisoned payloads.
273
+ *
239
274
  * @param channel - The channel name to subscribe to.
240
275
  * @param callback - Invoked for each message received on the channel.
241
276
  *
@@ -260,10 +295,16 @@ export class Redis {
260
295
  // ─── Raw command ──────────────────────────────────────
261
296
 
262
297
  /**
263
- * Send a raw Redis command.
298
+ * Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
264
299
  *
265
- * @param command - The Redis command string (e.g. `'PING'`, `'INFO'`).
266
- * @param args - Arguments for the command.
300
+ * This bypasses the key prefix and every higher-level safeguard. The `command`
301
+ * and `args` are passed straight to Redis, so they MUST be trusted, statically
302
+ * known values. Never build a command name or its arguments from user input:
303
+ * doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
304
+ * `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
305
+ *
306
+ * @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
307
+ * @param args - Arguments for the command. Must not contain untrusted input.
267
308
  * @returns The raw response from Redis.
268
309
  *
269
310
  * @example
@@ -289,7 +330,15 @@ export class Redis {
289
330
  async getJSON<T = any>(key: string): Promise<T | null> {
290
331
  const val = await this.get(key)
291
332
  if (val === null) return null
292
- try { return JSON.parse(val) } catch { return null }
333
+ try {
334
+ return JSON.parse(val)
335
+ } catch (e) {
336
+ // Surface corrupt/poisoned payloads instead of silently masking them as a
337
+ // cache miss. Returning null still preserves the previous behaviour for
338
+ // callers, but the warning makes the problem diagnosable.
339
+ console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${(e as Error).message}`)
340
+ return null
341
+ }
293
342
  }
294
343
 
295
344
  /**
@@ -305,8 +354,15 @@ export class Redis {
305
354
  * ```
306
355
  */
307
356
  async setJSON(key: string, value: any, expireSeconds?: number): Promise<void> {
308
- await this.set(key, JSON.stringify(value))
309
- if (expireSeconds) await this.expire(key, expireSeconds)
357
+ const payload = JSON.stringify(value)
358
+ if (payload === undefined) throw new Error(`Redis cannot serialize undefined for key "${key}"`)
359
+ if (expireSeconds && expireSeconds > 0) {
360
+ // Atomic SET ... EX so a crash between writing the value and setting the
361
+ // TTL can never leave a permanent (TTL-less) key behind.
362
+ await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))])
363
+ } else {
364
+ await this.client.set(this.key(key), payload)
365
+ }
310
366
  }
311
367
 
312
368
  /**
@@ -318,6 +374,11 @@ export class Redis {
318
374
  * @param callback - Async function invoked to compute the value on a cache miss.
319
375
  * @returns The cached or freshly computed value.
320
376
  *
377
+ * On a cache miss this acquires a short-lived `SET NX` lock so that, under
378
+ * concurrent misses, only one caller runs `callback()` while the others wait
379
+ * for the freshly-populated value. This protects the backend from a stampede
380
+ * (thundering herd) when a hot key expires.
381
+ *
321
382
  * @example
322
383
  * ```ts
323
384
  * const users = await redis.remember('all-users', 60, async () => {
@@ -328,13 +389,95 @@ export class Redis {
328
389
  async remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T> {
329
390
  const cached = await this.getJSON<T>(key)
330
391
  if (cached !== null) return cached
331
- const value = await callback()
332
- await this.setJSON(key, value, seconds)
333
- return value
392
+
393
+ const lockKey = this.key(`${key}:__lock`)
394
+ const lockToken = crypto.randomUUID()
395
+ // Try to become the single flight that computes the value. SET NX EX gives
396
+ // a self-expiring lock so a crashed holder cannot deadlock other callers.
397
+ const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10'])
398
+
399
+ if (acquired == null) {
400
+ // Another caller is computing it. Wait for the populated value, but do
401
+ // not run the callback without owning the lock: doing so would turn slow
402
+ // cache fills back into a stampede. A bounded timeout keeps callers from
403
+ // hanging forever if Redis itself is unhealthy.
404
+ for (let i = 0; i < 300; i++) {
405
+ await new Promise(r => setTimeout(r, 100))
406
+ const waited = await this.getJSON<T>(key)
407
+ if (waited !== null) return waited
408
+ }
409
+ throw new Error(`Redis remember timed out waiting for lock on "${key}"`)
410
+ }
411
+
412
+ const renewScript = `
413
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
414
+ return redis.call('EXPIRE', KEYS[1], 10)
415
+ end
416
+ return 0
417
+ `
418
+ const renewTimer = setInterval(() => {
419
+ void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => {})
420
+ }, 3000)
421
+ ;(renewTimer as any).unref?.()
422
+
423
+ try {
424
+ // Re-check after acquiring the lock: a racing holder may have populated it.
425
+ const fresh = await this.getJSON<T>(key)
426
+ if (fresh !== null) return fresh
427
+ const value = await callback()
428
+ await this.setJSON(key, value, seconds)
429
+ return value
430
+ } finally {
431
+ clearInterval(renewTimer)
432
+ // Only the owner may release the lock. A plain DEL lets a slow callback
433
+ // delete a successor's lock after its own 10s lease expired, reopening
434
+ // the stampede window. Waiters that never acquired it release nothing.
435
+ if (acquired != null) {
436
+ const releaseScript = `
437
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
438
+ return redis.call('DEL', KEYS[1])
439
+ end
440
+ return 0
441
+ `
442
+ await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => {})
443
+ }
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Delete every key under this connection's prefix using a non-blocking SCAN.
449
+ *
450
+ * Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
451
+ * belong to this logical store (`<prefix>:*`), leaving other stores that
452
+ * share the same Redis database (sessions, queues, ...) untouched. If no
453
+ * prefix is configured this deletes nothing and returns `0`, to avoid
454
+ * accidentally wiping the whole database.
455
+ *
456
+ * @returns The number of keys deleted.
457
+ */
458
+ async clearPrefix(): Promise<number> {
459
+ if (!this.prefix) return 0
460
+ const pattern = `${this.prefix}:*`
461
+ let cursor = '0'
462
+ let deleted = 0
463
+ do {
464
+ const [next, batch]: [string, string[]] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])
465
+ cursor = next
466
+ if (batch.length) {
467
+ await this.client.send('DEL', batch)
468
+ deleted += batch.length
469
+ }
470
+ } while (cursor !== '0')
471
+ return deleted
334
472
  }
335
473
 
336
474
  /**
337
- * Delete all keys in the currently selected database.
475
+ * Delete ALL keys in the currently selected database. DANGEROUS.
476
+ *
477
+ * This ignores the key prefix and removes every key in the database, including
478
+ * data owned by other logical stores (sessions, queues, other caches) that
479
+ * share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
480
+ * only this store's keys. Never expose this to user-triggered code paths.
338
481
  *
339
482
  * @returns A promise that resolves once the database has been flushed.
340
483
  */