layercache 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # layercache
2
2
 
3
- **Multi-layer caching for Node.js — memory Redis your DB, unified in one API.**
3
+ **Production-ready multi-layer caching for Node.js — memory, Redis, persistence, invalidation, and resilience in one API.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/layercache)](https://www.npmjs.com/package/layercache)
6
6
  [![npm downloads](https://img.shields.io/npm/dw/layercache)](https://www.npmjs.com/package/layercache)
7
7
  [![license](https://img.shields.io/npm/l/layercache)](LICENSE)
8
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue)](https://www.typescriptlang.org/)
9
- [![test coverage](https://img.shields.io/badge/tests-158%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
9
+ [![test coverage](https://img.shields.io/badge/tests-180%20passing-brightgreen)](https://github.com/flyingsquirrel0419/layercache)
10
10
 
11
11
  ```
12
12
  L1 hit ~0.01 ms ← served from memory, zero network
@@ -26,6 +26,8 @@ Most Node.js services end up with the same problem:
26
26
 
27
27
  layercache solves all three. You declare your layers once and call `get`. Everything else is handled.
28
28
 
29
+ It is designed for production services that need predictable cache behavior under load: stampede prevention, cross-instance invalidation, layered TTL control, operational metrics, and safer persistence defaults.
30
+
29
31
  ```ts
30
32
  const user = await cache.get('user:123', () => db.findUser(123))
31
33
  // ↑ only called on a full miss
@@ -43,7 +45,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
43
45
  - **Batch tag invalidation** — `invalidateByTags(['tenant:a', 'users'], 'all')` for OR/AND invalidation in one call
44
46
  - **Pattern invalidation** — `invalidateByPattern('user:*')`
45
47
  - **Prefix invalidation** — efficient `invalidateByPrefix('user:123:')` for hierarchical keys
46
- - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly
48
+ - **Generation-based invalidation** — `generation` prefixes keys with `vN:` and `bumpGeneration()` rotates the namespace instantly, with optional stale-generation cleanup
47
49
  - **Per-layer TTL overrides** — different TTLs for memory vs. Redis in one call
48
50
  - **TTL policies** — align TTLs to time boundaries (`until-midnight`, `next-hour`, `{ alignTo }`, or a function)
49
51
  - **Negative caching** — cache known misses for a short TTL to protect the database
@@ -82,7 +84,7 @@ On a hit, the value is returned from the fastest layer that has it, and automati
82
84
  - **Nested namespaces** — `namespace('a').namespace('b')` for composable key prefixes with namespace-scoped metrics
83
85
  - **Custom layers** — implement the 5-method `CacheLayer` interface to plug in Memcached, DynamoDB, or anything else
84
86
  - **Edge-safe entry point** — `layercache/edge` exports the non-Node helpers for Worker-style runtimes
85
- - **ESM + CJS** — works with both module systems, Node.js ≥ 18
87
+ - **ESM + CJS** — works with both module systems, Node.js ≥ 20
86
88
 
87
89
  ---
88
90
 
@@ -171,7 +173,7 @@ await cache.set('user:123', user, {
171
173
 
172
174
  ### `cache.invalidateByTag(tag): Promise<void>`
173
175
 
174
- Deletes every key that was stored with this tag across all layers.
176
+ Deletes every key that was stored with this tag across all layers. In multi-instance deployments, this is only complete when every instance shares the same tag index implementation (for example `RedisTagIndex`).
175
177
 
176
178
  ```ts
177
179
  await cache.set('user:123', user, { tags: ['user:123'] })
@@ -191,12 +193,14 @@ await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with e
191
193
 
192
194
  ### `cache.invalidateByPattern(pattern): Promise<void>`
193
195
 
194
- Glob-style deletion against the tracked key set.
196
+ Glob-style deletion against the tracked key set, plus any layer that can enumerate real keys (for example `MemoryLayer`, `RedisLayer`, or `DiskLayer`).
195
197
 
196
198
  ```ts
197
199
  await cache.invalidateByPattern('user:*') // deletes user:1, user:2, …
198
200
  ```
199
201
 
202
+ For multi-instance deployments, prefer a shared `RedisTagIndex`. Without it, pattern invalidation still scans real layer keys when available, but that fallback only helps on layers that implement `keys()`, and tag tracking itself remains process-local.
203
+
200
204
  ### `cache.invalidateByPrefix(prefix): Promise<void>`
201
205
 
202
206
  Prefer this over glob invalidation when your keys are hierarchical.
@@ -278,6 +282,17 @@ await cache.set('user:123', user)
278
282
  cache.bumpGeneration() // now reads use v2:user:123
279
283
  ```
280
284
 
285
+ If you also want old generation keys cleaned up automatically instead of waiting for TTL expiry:
286
+
287
+ ```ts
288
+ const cache = new CacheStack([...], {
289
+ generation: 1,
290
+ generationCleanup: { batchSize: 500 }
291
+ })
292
+ ```
293
+
294
+ `bumpGeneration()` only rotates future reads and writes by default. Enable `generationCleanup` when you want previous generations to be pruned automatically instead of aging out by TTL.
295
+
281
296
  ### OpenTelemetry note
282
297
 
283
298
  `createOpenTelemetryPlugin()` currently wraps a `CacheStack` instance's methods directly. Use one OpenTelemetry plugin per cache instance; if you need to compose multiple wrappers, install them in a fixed order and uninstall them in reverse order.
@@ -452,13 +467,14 @@ const cache = new CacheStack(
452
467
  {
453
468
  singleFlightCoordinator: coordinator,
454
469
  singleFlightLeaseMs: 30_000,
470
+ singleFlightRenewIntervalMs: 10_000,
455
471
  singleFlightTimeoutMs: 5_000,
456
472
  singleFlightPollMs: 50
457
473
  }
458
474
  )
459
475
  ```
460
476
 
461
- When another instance already owns the miss, the current process waits for the value to appear in the shared layer instead of running the fetcher again.
477
+ When another instance already owns the miss, the current process waits for the value to appear in the shared layer instead of running the fetcher again. `RedisSingleFlightCoordinator` also renews its Redis lease while the worker is still running, so long fetches are less likely to expire their lock mid-flight. Keep `singleFlightLeaseMs` comfortably above your expected fetch latency, and use `singleFlightRenewIntervalMs` if you need tighter control over renewal cadence.
462
478
 
463
479
  ### Cross-server L1 invalidation
464
480
 
@@ -479,10 +495,10 @@ const cache = new CacheStack(
479
495
  await cache.disconnect() // unsubscribes cleanly on shutdown
480
496
  ```
481
497
 
482
- By default, every `set` also broadcasts an invalidation so other servers evict stale memory immediately. To suppress broadcasts on writes (high write-volume services):
498
+ By default, write-triggered L1 invalidation is **off** even when an invalidation bus is configured. This avoids surprising Redis Pub/Sub traffic in write-heavy services. Enable it explicitly when you want every write to evict peer memory caches immediately:
483
499
 
484
500
  ```ts
485
- new CacheStack([...], { invalidationBus: bus, publishSetInvalidation: false })
501
+ new CacheStack([...], { invalidationBus: bus, broadcastL1Invalidation: true })
486
502
  ```
487
503
 
488
504
  ### Distributed tag invalidation
@@ -494,7 +510,8 @@ import { RedisTagIndex } from 'layercache'
494
510
 
495
511
  const sharedTagIndex = new RedisTagIndex({
496
512
  client: redis,
497
- prefix: 'myapp:tag-index' // namespaced so it doesn't collide with other data
513
+ prefix: 'myapp:tag-index', // namespaced so it doesn't collide with other data
514
+ knownKeysShards: 8
498
515
  })
499
516
 
500
517
  // Every CacheStack instance should use the same Redis-backed tag index config
@@ -506,6 +523,8 @@ const cache = new CacheStack(
506
523
 
507
524
  Now `invalidateByTag('user:123')` on any server deletes every tagged key, regardless of which server originally wrote it.
508
525
 
526
+ The same recommendation applies to `invalidateByPattern()` and `invalidateByPrefix()` in distributed deployments: a shared tag index gives the most complete view of known keys, while layer key scans act as a fallback only when the shared layer exposes `keys()`.
527
+
509
528
  ### Safe Redis clearing
510
529
 
511
530
  `RedisLayer.clear()` is intentionally conservative. Without a `prefix`, it throws instead of deleting the whole Redis database.
@@ -528,6 +547,38 @@ new RedisLayer({
528
547
  })
529
548
  ```
530
549
 
550
+ For production Redis, also set an explicit `prefix`, enforce Redis authentication/network isolation, and configure Redis `maxmemory` / eviction policy so cache growth cannot starve unrelated workloads.
551
+
552
+ ### DiskLayer safety
553
+
554
+ `DiskLayer` is best used with an application-controlled directory and an explicit `maxFiles` bound.
555
+
556
+ ```ts
557
+ import { resolve } from 'node:path'
558
+
559
+ const disk = new DiskLayer({
560
+ directory: resolve('./var/cache/layercache'),
561
+ maxFiles: 10_000
562
+ })
563
+ ```
564
+
565
+ The library hashes cache keys before turning them into filenames, validates the configured directory, uses atomic temp-file writes, and removes malformed on-disk entries. You should still keep the directory outside any user-controlled path and set filesystem permissions so only your app can read or write it.
566
+
567
+ ### Scoped fetcher rate limiting
568
+
569
+ Rate limits are global by default, but you can scope them per cache key or per fetcher function when different backends should not throttle each other.
570
+
571
+ ```ts
572
+ await cache.get('user:123', fetchUser, {
573
+ fetcherRateLimit: {
574
+ maxConcurrent: 1,
575
+ scope: 'key'
576
+ }
577
+ })
578
+ ```
579
+
580
+ Use `scope: 'fetcher'` to share a bucket across calls using the same fetcher function reference, or `bucketKey: 'billing-api'` for a custom named bucket.
581
+
531
582
  ---
532
583
 
533
584
  ## Per-layer TTL overrides
@@ -586,6 +637,8 @@ await cache.get('leaderboard', fetchLeaderboard, {
586
637
  })
587
638
  ```
588
639
 
640
+ Background refreshes time out after 30 seconds by default so a hung upstream fetch cannot block future refresh attempts forever. Override that with `backgroundRefreshTimeoutMs`.
641
+
589
642
  ---
590
643
 
591
644
  ## Graceful degradation & circuit breaker
@@ -682,6 +735,8 @@ await cache.persistToFile('./cache-snapshot.json')
682
735
  await cache.restoreFromFile('./cache-snapshot.json')
683
736
  ```
684
737
 
738
+ For safety, file snapshots are restricted to `process.cwd()` by default. Set `snapshotBaseDir` to an explicit directory for application-controlled snapshot storage, or `false` if you intentionally want to disable that restriction.
739
+
685
740
  ---
686
741
 
687
742
  ## Event hooks
@@ -983,7 +1038,7 @@ new CacheStack([...], {
983
1038
 
984
1039
  ## Requirements
985
1040
 
986
- - Node.js ≥ 18
1041
+ - Node.js ≥ 20
987
1042
  - TypeScript ≥ 5.0 (optional — fully typed, ships `.d.ts`)
988
1043
  - ioredis ≥ 5 (optional peer dependency — only needed for `RedisLayer` / `RedisTagIndex`)
989
1044
 
@@ -1,6 +1,29 @@
1
1
  // src/internal/StoredValue.ts
2
2
  function isStoredValueEnvelope(value) {
3
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
3
+ if (typeof value !== "object" || value === null) {
4
+ return false;
5
+ }
6
+ const v = value;
7
+ if (v.__layercache !== 1) {
8
+ return false;
9
+ }
10
+ if (v.kind !== "value" && v.kind !== "empty") {
11
+ return false;
12
+ }
13
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
14
+ return false;
15
+ }
16
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
17
+ return false;
18
+ }
19
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
20
+ return false;
21
+ }
22
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
23
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
24
+ return false;
25
+ }
26
+ return true;
4
27
  }
5
28
  function createStoredValueEnvelope(options) {
6
29
  const now = options.now ?? Date.now();
@@ -1,7 +1,6 @@
1
1
  import {
2
- PatternMatcher,
3
2
  unwrapStoredValue
4
- } from "./chunk-ZMDB5KOK.js";
3
+ } from "./chunk-7V7XAB74.js";
5
4
 
6
5
  // src/layers/MemoryLayer.ts
7
6
  var MemoryLayer = class {
@@ -49,16 +48,10 @@ var MemoryLayer = class {
49
48
  return entry.value;
50
49
  }
51
50
  async getMany(keys) {
52
- const values = [];
53
- for (const key of keys) {
54
- values.push(await this.getEntry(key));
55
- }
56
- return values;
51
+ return Promise.all(keys.map((key) => this.getEntry(key)));
57
52
  }
58
53
  async setMany(entries) {
59
- for (const entry of entries) {
60
- await this.set(entry.key, entry.value, entry.ttl);
61
- }
54
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
62
55
  }
63
56
  async set(key, value, ttl = this.defaultTtl) {
64
57
  this.entries.delete(key);
@@ -197,15 +190,17 @@ var TagIndex = class {
197
190
  keyToTags = /* @__PURE__ */ new Map();
198
191
  knownKeys = /* @__PURE__ */ new Set();
199
192
  maxKnownKeys;
193
+ nextNodeId = 1;
194
+ root = this.createTrieNode();
200
195
  constructor(options = {}) {
201
- this.maxKnownKeys = options.maxKnownKeys;
196
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
202
197
  }
203
198
  async touch(key) {
204
- this.knownKeys.add(key);
199
+ this.insertKnownKey(key);
205
200
  this.pruneKnownKeysIfNeeded();
206
201
  }
207
202
  async track(key, tags) {
208
- this.knownKeys.add(key);
203
+ this.insertKnownKey(key);
209
204
  this.pruneKnownKeysIfNeeded();
210
205
  if (tags.length === 0) {
211
206
  return;
@@ -231,18 +226,104 @@ var TagIndex = class {
231
226
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
232
227
  }
233
228
  async keysForPrefix(prefix) {
234
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
229
+ const node = this.findNode(prefix);
230
+ if (!node) {
231
+ return [];
232
+ }
233
+ const matches = [];
234
+ this.collectFromNode(node, prefix, matches);
235
+ return matches;
235
236
  }
236
237
  async tagsForKey(key) {
237
238
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
238
239
  }
239
240
  async matchPattern(pattern) {
240
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
241
+ const matches = /* @__PURE__ */ new Set();
242
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
243
+ return [...matches];
241
244
  }
242
245
  async clear() {
243
246
  this.tagToKeys.clear();
244
247
  this.keyToTags.clear();
245
248
  this.knownKeys.clear();
249
+ this.root.children.clear();
250
+ this.root.terminal = false;
251
+ this.nextNodeId = this.root.id + 1;
252
+ }
253
+ createTrieNode() {
254
+ return {
255
+ id: this.nextNodeId++,
256
+ terminal: false,
257
+ children: /* @__PURE__ */ new Map()
258
+ };
259
+ }
260
+ insertKnownKey(key) {
261
+ if (this.knownKeys.has(key)) {
262
+ return;
263
+ }
264
+ this.knownKeys.add(key);
265
+ let node = this.root;
266
+ for (const character of key) {
267
+ let child = node.children.get(character);
268
+ if (!child) {
269
+ child = this.createTrieNode();
270
+ node.children.set(character, child);
271
+ }
272
+ node = child;
273
+ }
274
+ node.terminal = true;
275
+ }
276
+ findNode(prefix) {
277
+ let node = this.root;
278
+ for (const character of prefix) {
279
+ node = node.children.get(character);
280
+ if (!node) {
281
+ return void 0;
282
+ }
283
+ }
284
+ return node;
285
+ }
286
+ collectFromNode(node, prefix, matches) {
287
+ if (node.terminal) {
288
+ matches.push(prefix);
289
+ }
290
+ for (const [character, child] of node.children) {
291
+ this.collectFromNode(child, `${prefix}${character}`, matches);
292
+ }
293
+ }
294
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
295
+ const stateKey = `${node.id}:${patternIndex}`;
296
+ if (visited.has(stateKey)) {
297
+ return;
298
+ }
299
+ visited.add(stateKey);
300
+ if (patternIndex === pattern.length) {
301
+ if (node.terminal) {
302
+ matches.add(prefix);
303
+ }
304
+ return;
305
+ }
306
+ const patternChar = pattern[patternIndex];
307
+ if (patternChar === void 0) {
308
+ return;
309
+ }
310
+ if (patternChar === "*") {
311
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
312
+ for (const [character, child2] of node.children) {
313
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
314
+ }
315
+ return;
316
+ }
317
+ if (patternChar === "?") {
318
+ for (const [character, child2] of node.children) {
319
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
320
+ }
321
+ return;
322
+ }
323
+ const child = node.children.get(patternChar);
324
+ if (child) {
325
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
326
+ }
246
327
  }
247
328
  pruneKnownKeysIfNeeded() {
248
329
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -259,7 +340,7 @@ var TagIndex = class {
259
340
  }
260
341
  }
261
342
  removeKey(key) {
262
- this.knownKeys.delete(key);
343
+ this.removeKnownKey(key);
263
344
  const tags = this.keyToTags.get(key);
264
345
  if (!tags) {
265
346
  return;
@@ -276,6 +357,34 @@ var TagIndex = class {
276
357
  }
277
358
  this.keyToTags.delete(key);
278
359
  }
360
+ removeKnownKey(key) {
361
+ if (!this.knownKeys.delete(key)) {
362
+ return;
363
+ }
364
+ const path = [];
365
+ let node = this.root;
366
+ for (const character of key) {
367
+ const child = node.children.get(character);
368
+ if (!child) {
369
+ return;
370
+ }
371
+ path.push([node, character]);
372
+ node = child;
373
+ }
374
+ node.terminal = false;
375
+ for (let index = path.length - 1; index >= 0; index -= 1) {
376
+ const entry = path[index];
377
+ if (!entry) {
378
+ continue;
379
+ }
380
+ const [parent, character] = entry;
381
+ const child = parent.children.get(character);
382
+ if (!child || child.terminal || child.children.size > 0) {
383
+ break;
384
+ }
385
+ parent.children.delete(character);
386
+ }
387
+ }
279
388
  };
280
389
 
281
390
  // src/integrations/hono.ts
@@ -287,7 +396,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
287
396
  await next();
288
397
  return;
289
398
  }
290
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
399
+ const rawPath = context.req.path ?? context.req.url ?? "/";
400
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
291
401
  const cached = await cache.get(key, void 0, options);
292
402
  if (cached !== null) {
293
403
  context.header?.("x-cache", "HIT");
@@ -298,12 +408,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
298
408
  const originalJson = context.json.bind(context);
299
409
  context.json = (body, status) => {
300
410
  context.header?.("x-cache", "MISS");
301
- void cache.set(key, body, options);
411
+ cache.set(key, body, options).catch((err) => {
412
+ cache.emit("error", {
413
+ operation: "set",
414
+ error: err instanceof Error ? err.message : String(err)
415
+ });
416
+ });
302
417
  return originalJson(body, status);
303
418
  };
304
419
  await next();
305
420
  };
306
421
  }
422
+ function normalizeUrl(url) {
423
+ try {
424
+ const parsed = new URL(url, "http://localhost");
425
+ parsed.searchParams.sort();
426
+ return decodeURIComponent(parsed.pathname) + parsed.search;
427
+ } catch {
428
+ return url;
429
+ }
430
+ }
307
431
 
308
432
  export {
309
433
  MemoryLayer,
@@ -1,25 +1,27 @@
1
1
  import {
2
2
  PatternMatcher
3
- } from "./chunk-ZMDB5KOK.js";
3
+ } from "./chunk-7V7XAB74.js";
4
4
 
5
5
  // src/invalidation/RedisTagIndex.ts
6
6
  var RedisTagIndex = class {
7
7
  client;
8
8
  prefix;
9
9
  scanCount;
10
+ knownKeysShards;
10
11
  constructor(options) {
11
12
  this.client = options.client;
12
13
  this.prefix = options.prefix ?? "layercache:tag-index";
13
14
  this.scanCount = options.scanCount ?? 100;
15
+ this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
14
16
  }
15
17
  async touch(key) {
16
- await this.client.sadd(this.knownKeysKey(), key);
18
+ await this.client.sadd(this.knownKeysKeyFor(key), key);
17
19
  }
18
20
  async track(key, tags) {
19
21
  const keyTagsKey = this.keyTagsKey(key);
20
22
  const existingTags = await this.client.smembers(keyTagsKey);
21
23
  const pipeline = this.client.pipeline();
22
- pipeline.sadd(this.knownKeysKey(), key);
24
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
23
25
  for (const tag of existingTags) {
24
26
  pipeline.srem(this.tagKeysKey(tag), key);
25
27
  }
@@ -36,7 +38,7 @@ var RedisTagIndex = class {
36
38
  const keyTagsKey = this.keyTagsKey(key);
37
39
  const existingTags = await this.client.smembers(keyTagsKey);
38
40
  const pipeline = this.client.pipeline();
39
- pipeline.srem(this.knownKeysKey(), key);
41
+ pipeline.srem(this.knownKeysKeyFor(key), key);
40
42
  pipeline.del(keyTagsKey);
41
43
  for (const tag of existingTags) {
42
44
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -48,12 +50,14 @@ var RedisTagIndex = class {
48
50
  }
49
51
  async keysForPrefix(prefix) {
50
52
  const matches = [];
51
- let cursor = "0";
52
- do {
53
- const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
54
- cursor = nextCursor;
55
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
56
- } while (cursor !== "0");
53
+ for (const knownKeysKey of this.knownKeysKeys()) {
54
+ let cursor = "0";
55
+ do {
56
+ const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
57
+ cursor = nextCursor;
58
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
59
+ } while (cursor !== "0");
60
+ }
57
61
  return matches;
58
62
  }
59
63
  async tagsForKey(key) {
@@ -61,19 +65,21 @@ var RedisTagIndex = class {
61
65
  }
62
66
  async matchPattern(pattern) {
63
67
  const matches = [];
64
- let cursor = "0";
65
- do {
66
- const [nextCursor, keys] = await this.client.sscan(
67
- this.knownKeysKey(),
68
- cursor,
69
- "MATCH",
70
- pattern,
71
- "COUNT",
72
- this.scanCount
73
- );
74
- cursor = nextCursor;
75
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
76
- } while (cursor !== "0");
68
+ for (const knownKeysKey of this.knownKeysKeys()) {
69
+ let cursor = "0";
70
+ do {
71
+ const [nextCursor, keys] = await this.client.sscan(
72
+ knownKeysKey,
73
+ cursor,
74
+ "MATCH",
75
+ pattern,
76
+ "COUNT",
77
+ this.scanCount
78
+ );
79
+ cursor = nextCursor;
80
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
81
+ } while (cursor !== "0");
82
+ }
77
83
  return matches;
78
84
  }
79
85
  async clear() {
@@ -94,8 +100,17 @@ var RedisTagIndex = class {
94
100
  } while (cursor !== "0");
95
101
  return matches;
96
102
  }
97
- knownKeysKey() {
98
- return `${this.prefix}:keys`;
103
+ knownKeysKeyFor(key) {
104
+ if (this.knownKeysShards === 1) {
105
+ return `${this.prefix}:keys`;
106
+ }
107
+ return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
108
+ }
109
+ knownKeysKeys() {
110
+ if (this.knownKeysShards === 1) {
111
+ return [`${this.prefix}:keys`];
112
+ }
113
+ return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
99
114
  }
100
115
  keyTagsKey(key) {
101
116
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
@@ -104,6 +119,22 @@ var RedisTagIndex = class {
104
119
  return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
105
120
  }
106
121
  };
122
+ function normalizeKnownKeysShards(value) {
123
+ if (value === void 0) {
124
+ return 1;
125
+ }
126
+ if (!Number.isInteger(value) || value <= 0) {
127
+ throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
128
+ }
129
+ return value;
130
+ }
131
+ function simpleHash(value) {
132
+ let hash = 0;
133
+ for (let index = 0; index < value.length; index += 1) {
134
+ hash = hash * 31 + value.charCodeAt(index) >>> 0;
135
+ }
136
+ return hash;
137
+ }
107
138
 
108
139
  export {
109
140
  RedisTagIndex