layercache 2.0.0 → 3.0.0

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
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <img src="./logo.png" width="520" alt="layercache logo">
6
+ <img src="./layercache-stampede.gif" width="930" alt="layercache stampede prevention demo">
7
7
  </p>
8
8
 
9
9
  <h1 align="center">layercache</h1>
@@ -24,7 +24,7 @@
24
24
  </p>
25
25
 
26
26
  <p align="center">
27
- <a href="https://layercache.flyingsquirrel.me">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
27
+ <a href="https://flyingsquirrel0419.github.io/layercache">Website</a>&nbsp;&nbsp;|&nbsp;&nbsp;
28
28
  <a href="#-quick-start">Quick Start</a>&nbsp;&nbsp;|&nbsp;&nbsp;
29
29
  <a href="#-performance">Performance</a>&nbsp;&nbsp;|&nbsp;&nbsp;
30
30
  <a href="./docs/api.md">API Reference</a>&nbsp;&nbsp;|&nbsp;&nbsp;
@@ -53,6 +53,18 @@ layercache is a multi-layer cache (Memory → Redis → Disk) for Node.js. Stamp
53
53
 
54
54
  ---
55
55
 
56
+ ## What's New in 3.0
57
+
58
+ - `RedisTagIndex` uses 16 known-key shards by default. Existing Redis tag indexes that still use the legacy `<prefix>:keys` set should be migrated with `npx layercache migrate-tag-index`.
59
+ - Production CLI commands reject plaintext `redis://` URLs unless `--allow-plaintext` is passed. Prefer `rediss://` for production Redis endpoints.
60
+ - Express and Hono implicit URL cache keys now strip sensitive query parameters before caching, and non-2xx JSON responses are not cached by default.
61
+ - Redis-backed generation persistence is available through `RedisGenerationStore`, and `CacheStack.getGeneration()` exposes the active generation.
62
+ - The docs site now runs on Rspress and GitHub Pages.
63
+
64
+ See the [changelog](./CHANGELOG.md) and [migration guide](./docs/migration-guide.md) before upgrading an existing deployment.
65
+
66
+ ---
67
+
56
68
  ## Quick Start
57
69
 
58
70
  ```bash
@@ -363,7 +375,7 @@ layercache is built for multi-instance production environments:
363
375
 
364
376
  - **Redis single-flight** - dedup misses across instances with distributed locks
365
377
  - **Redis invalidation bus** - pub/sub-based L1 invalidation for memory consistency
366
- - **Redis tag index** - shared tag tracking with optional sharding
378
+ - **Redis tag index** - shared tag tracking with 16 known-key shards by default
367
379
  - **Snapshot persistence** - export/import state between instances
368
380
 
369
381
  <details>
@@ -375,9 +387,13 @@ import {
375
387
  RedisInvalidationBus, RedisTagIndex, RedisSingleFlightCoordinator
376
388
  } from 'layercache'
377
389
 
378
- const redis = new Redis()
379
- const bus = new RedisInvalidationBus({ publisher: redis, subscriber: new Redis() })
380
- const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags' })
390
+ const redis = new Redis(process.env.REDIS_URL)
391
+ const bus = new RedisInvalidationBus({
392
+ publisher: redis,
393
+ subscriber: new Redis(process.env.REDIS_URL),
394
+ signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
395
+ })
396
+ const tagIndex = new RedisTagIndex({ client: redis, prefix: 'myapp:tags', knownKeysShards: 16 })
381
397
  const coordinator = new RedisSingleFlightCoordinator({ client: redis })
382
398
 
383
399
  const cache = new CacheStack(
@@ -12,6 +12,9 @@ var MemoryLayer = class {
12
12
  onEvict;
13
13
  entries = /* @__PURE__ */ new Map();
14
14
  cleanupTimer;
15
+ /**
16
+ * Creates an in-memory cache layer.
17
+ */
15
18
  constructor(options = {}) {
16
19
  this.name = options.name ?? "memory";
17
20
  this.defaultTtl = options.ttl;
@@ -25,10 +28,16 @@ var MemoryLayer = class {
25
28
  this.cleanupTimer.unref?.();
26
29
  }
27
30
  }
31
+ /**
32
+ * Reads and unwraps a fresh value from memory.
33
+ */
28
34
  async get(key) {
29
35
  const value = await this.getEntry(key);
30
36
  return unwrapStoredValue(value);
31
37
  }
38
+ /**
39
+ * Reads the raw stored value or envelope from memory.
40
+ */
32
41
  async getEntry(key) {
33
42
  const entry = this.entries.get(key);
34
43
  if (!entry) {
@@ -47,12 +56,21 @@ var MemoryLayer = class {
47
56
  }
48
57
  return entry.value;
49
58
  }
59
+ /**
60
+ * Reads many raw entries from memory.
61
+ */
50
62
  async getMany(keys) {
51
63
  return Promise.all(keys.map((key) => this.getEntry(key)));
52
64
  }
65
+ /**
66
+ * Writes many entries to memory.
67
+ */
53
68
  async setMany(entries) {
54
69
  await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
55
70
  }
71
+ /**
72
+ * Stores a value in memory using the provided TTL or layer default TTL.
73
+ */
56
74
  async set(key, value, ttl = this.defaultTtl) {
57
75
  this.entries.delete(key);
58
76
  this.entries.set(key, {
@@ -65,6 +83,9 @@ var MemoryLayer = class {
65
83
  this.evict();
66
84
  }
67
85
  }
86
+ /**
87
+ * Returns true when the key exists and has not expired.
88
+ */
68
89
  async has(key) {
69
90
  const entry = this.entries.get(key);
70
91
  if (!entry) {
@@ -76,6 +97,9 @@ var MemoryLayer = class {
76
97
  }
77
98
  return true;
78
99
  }
100
+ /**
101
+ * Returns remaining TTL in milliseconds, or null when absent or non-expiring.
102
+ */
79
103
  async ttl(key) {
80
104
  const entry = this.entries.get(key);
81
105
  if (!entry) {
@@ -90,40 +114,67 @@ var MemoryLayer = class {
90
114
  }
91
115
  return Math.max(0, Math.ceil(entry.expiresAt - Date.now()));
92
116
  }
117
+ /**
118
+ * Returns the number of currently retained, non-expired entries.
119
+ */
93
120
  async size() {
94
121
  this.pruneExpired();
95
122
  return this.entries.size;
96
123
  }
124
+ /**
125
+ * Deletes a key from memory.
126
+ */
97
127
  async delete(key) {
98
128
  this.entries.delete(key);
99
129
  }
130
+ /**
131
+ * Deletes multiple keys from memory.
132
+ */
100
133
  async deleteMany(keys) {
101
134
  for (const key of keys) {
102
135
  this.entries.delete(key);
103
136
  }
104
137
  }
138
+ /**
139
+ * Removes all entries from memory.
140
+ */
105
141
  async clear() {
106
142
  this.entries.clear();
107
143
  }
144
+ /**
145
+ * Health check hook that always succeeds for the in-process layer.
146
+ */
108
147
  async ping() {
109
148
  return true;
110
149
  }
150
+ /**
151
+ * Stops the cleanup timer, when one is active.
152
+ */
111
153
  async dispose() {
112
154
  if (this.cleanupTimer) {
113
155
  clearInterval(this.cleanupTimer);
114
156
  this.cleanupTimer = void 0;
115
157
  }
116
158
  }
159
+ /**
160
+ * Returns all currently retained, non-expired keys.
161
+ */
117
162
  async keys() {
118
163
  this.pruneExpired();
119
164
  return [...this.entries.keys()];
120
165
  }
166
+ /**
167
+ * Visits all currently retained, non-expired keys.
168
+ */
121
169
  async forEachKey(visitor) {
122
170
  this.pruneExpired();
123
171
  for (const key of this.entries.keys()) {
124
172
  await visitor(key);
125
173
  }
126
174
  }
175
+ /**
176
+ * Exports memory entries for process-local snapshots.
177
+ */
127
178
  exportState() {
128
179
  this.pruneExpired();
129
180
  return [...this.entries.entries()].map(([key, entry]) => ({
@@ -132,6 +183,9 @@ var MemoryLayer = class {
132
183
  expiresAt: entry.expiresAt
133
184
  }));
134
185
  }
186
+ /**
187
+ * Imports entries previously produced by `exportState()`.
188
+ */
135
189
  importState(entries) {
136
190
  for (const entry of entries) {
137
191
  if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
@@ -202,10 +256,16 @@ var TagIndex = class {
202
256
  constructor(options = {}) {
203
257
  this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
204
258
  }
259
+ /**
260
+ * Records a key as known without changing tag assignments.
261
+ */
205
262
  async touch(key) {
206
263
  this.insertKnownKey(key);
207
264
  this.pruneKnownKeysIfNeeded();
208
265
  }
266
+ /**
267
+ * Replaces the tags associated with a key and records the key as known.
268
+ */
209
269
  async track(key, tags) {
210
270
  this.insertKnownKey(key);
211
271
  this.pruneKnownKeysIfNeeded();
@@ -226,17 +286,29 @@ var TagIndex = class {
226
286
  this.tagToKeys.set(tag, keys);
227
287
  }
228
288
  }
289
+ /**
290
+ * Removes a key from all tag mappings and known-key tracking.
291
+ */
229
292
  async remove(key) {
230
293
  this.removeKey(key);
231
294
  }
295
+ /**
296
+ * Returns keys currently associated with a tag.
297
+ */
232
298
  async keysForTag(tag) {
233
299
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
234
300
  }
301
+ /**
302
+ * Visits keys currently associated with a tag.
303
+ */
235
304
  async forEachKeyForTag(tag, visitor) {
236
305
  for (const key of this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()) {
237
306
  await visitor(key);
238
307
  }
239
308
  }
309
+ /**
310
+ * Returns known keys that start with a prefix.
311
+ */
240
312
  async keysForPrefix(prefix) {
241
313
  const node = this.findNode(prefix);
242
314
  if (!node) {
@@ -246,6 +318,9 @@ var TagIndex = class {
246
318
  this.collectFromNode(node, prefix, matches);
247
319
  return matches;
248
320
  }
321
+ /**
322
+ * Visits known keys that start with a prefix.
323
+ */
249
324
  async forEachKeyForPrefix(prefix, visitor) {
250
325
  const node = this.findNode(prefix);
251
326
  if (!node) {
@@ -253,20 +328,32 @@ var TagIndex = class {
253
328
  }
254
329
  await this.visitFromNode(node, prefix, visitor);
255
330
  }
331
+ /**
332
+ * Returns the tags currently associated with a key.
333
+ */
256
334
  async tagsForKey(key) {
257
335
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
258
336
  }
337
+ /**
338
+ * Returns known keys matching a wildcard pattern.
339
+ */
259
340
  async matchPattern(pattern) {
260
341
  const matches = /* @__PURE__ */ new Set();
261
342
  this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
262
343
  return [...matches];
263
344
  }
345
+ /**
346
+ * Visits known keys matching a wildcard pattern.
347
+ */
264
348
  async forEachKeyMatchingPattern(pattern, visitor) {
265
349
  const matches = await this.matchPattern(pattern);
266
350
  for (const key of matches) {
267
351
  await visitor(key);
268
352
  }
269
353
  }
354
+ /**
355
+ * Clears all tag and known-key index state.
356
+ */
270
357
  async clear() {
271
358
  this.tagToKeys.clear();
272
359
  this.keyToTags.clear();
@@ -284,6 +371,9 @@ var TagIndex = class {
284
371
  }
285
372
  insertKnownKey(key) {
286
373
  const isNew = !this.knownKeys.has(key);
374
+ if (!isNew) {
375
+ this.knownKeys.delete(key);
376
+ }
287
377
  this.knownKeys.set(key, Date.now());
288
378
  if (!isNew) {
289
379
  return;
@@ -382,13 +472,13 @@ var TagIndex = class {
382
472
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
383
473
  return;
384
474
  }
385
- const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
386
475
  const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
387
- for (let i = 0; i < toRemove && i < sorted.length; i += 1) {
388
- const entry = sorted[i];
389
- if (entry) {
390
- this.removeKey(entry[0]);
476
+ for (let i = 0; i < toRemove; i += 1) {
477
+ const oldestKey = this.knownKeys.keys().next().value;
478
+ if (oldestKey === void 0) {
479
+ break;
391
480
  }
481
+ this.removeKnownKey(oldestKey);
392
482
  }
393
483
  }
394
484
  removeKey(key) {
@@ -439,6 +529,41 @@ var TagIndex = class {
439
529
  }
440
530
  };
441
531
 
532
+ // src/integrations/httpCacheKeys.ts
533
+ var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
534
+ "access_token",
535
+ "api_key",
536
+ "apikey",
537
+ "auth",
538
+ "authorization",
539
+ "code",
540
+ "credentials",
541
+ "id_token",
542
+ "jwt",
543
+ "password",
544
+ "private_key",
545
+ "refresh_token",
546
+ "secret",
547
+ "session",
548
+ "sessionid",
549
+ "session_id",
550
+ "token"
551
+ ]);
552
+ function normalizeHttpCacheUrl(url) {
553
+ try {
554
+ const parsed = new URL(url, "http://localhost");
555
+ for (const name of [...parsed.searchParams.keys()]) {
556
+ if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
557
+ parsed.searchParams.delete(name);
558
+ }
559
+ }
560
+ parsed.searchParams.sort();
561
+ return parsed.pathname + parsed.search;
562
+ } catch {
563
+ return url;
564
+ }
565
+ }
566
+
442
567
  // src/integrations/hono.ts
443
568
  function createHonoCacheMiddleware(cache, options = {}) {
444
569
  const allowedMethods = new Set((options.methods ?? ["GET"]).map((method) => method.toUpperCase()));
@@ -453,39 +578,44 @@ function createHonoCacheMiddleware(cache, options = {}) {
453
578
  return;
454
579
  }
455
580
  const rawPath = context.req.path ?? context.req.url ?? "/";
456
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl(rawPath)}`;
581
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
457
582
  const cached = await cache.get(key, void 0, options);
458
583
  if (cached !== null) {
459
584
  context.header?.("x-cache", "HIT");
460
585
  context.header?.("content-type", "application/json; charset=utf-8");
461
586
  return context.json(cached);
462
587
  }
588
+ let currentStatus;
589
+ const originalStatus = context.status?.bind(context);
590
+ if (originalStatus) {
591
+ context.status = (status) => {
592
+ currentStatus = status;
593
+ return originalStatus(status);
594
+ };
595
+ }
463
596
  const originalJson = context.json.bind(context);
464
597
  context.json = (body, status) => {
465
598
  context.header?.("x-cache", "MISS");
466
- cache.set(key, body, options).catch((err) => {
467
- cache.emit("error", {
468
- operation: "set",
469
- error: err instanceof Error ? err.message : String(err)
599
+ if (isSuccessfulStatus(status ?? currentStatus)) {
600
+ cache.set(key, body, options).catch((err) => {
601
+ cache.emit("error", {
602
+ operation: "set",
603
+ error: err instanceof Error ? err.message : String(err)
604
+ });
470
605
  });
471
- });
606
+ }
472
607
  return originalJson(body, status);
473
608
  };
474
609
  await next();
475
610
  };
476
611
  }
477
- function normalizeUrl(url) {
478
- try {
479
- const parsed = new URL(url, "http://localhost");
480
- parsed.searchParams.sort();
481
- return parsed.pathname + parsed.search;
482
- } catch {
483
- return url;
484
- }
612
+ function isSuccessfulStatus(statusCode) {
613
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
485
614
  }
486
615
 
487
616
  export {
488
617
  MemoryLayer,
489
618
  TagIndex,
619
+ normalizeHttpCacheUrl,
490
620
  createHonoCacheMiddleware
491
621
  };
@@ -140,20 +140,30 @@ function validateContextEntryOptions(name, options) {
140
140
  }
141
141
 
142
142
  // src/invalidation/RedisTagIndex.ts
143
+ var DEFAULT_KNOWN_KEYS_SHARDS = 16;
143
144
  var RedisTagIndex = class {
144
145
  client;
145
146
  prefix;
146
147
  scanCount;
147
148
  knownKeysShards;
149
+ logger;
150
+ warnedLegacyKnownKeys = false;
148
151
  constructor(options) {
149
152
  this.client = options.client;
150
153
  this.prefix = options.prefix ?? "layercache:tag-index";
151
154
  this.scanCount = options.scanCount ?? 100;
152
155
  this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
156
+ this.logger = options.logger;
153
157
  }
158
+ /**
159
+ * Records a key as known without changing tag assignments.
160
+ */
154
161
  async touch(key) {
155
162
  await this.client.sadd(this.knownKeysKeyFor(key), key);
156
163
  }
164
+ /**
165
+ * Replaces the tags associated with a key and records the key as known.
166
+ */
157
167
  async track(key, tags) {
158
168
  const keyTagsKey = this.keyTagsKey(key);
159
169
  const existingTags = await this.client.smembers(keyTagsKey);
@@ -171,20 +181,32 @@ var RedisTagIndex = class {
171
181
  }
172
182
  await pipeline.exec();
173
183
  }
184
+ /**
185
+ * Removes a key from all tag mappings and known-key tracking.
186
+ */
174
187
  async remove(key) {
175
188
  const keyTagsKey = this.keyTagsKey(key);
176
189
  const existingTags = await this.client.smembers(keyTagsKey);
177
190
  const pipeline = this.client.pipeline();
178
191
  pipeline.srem(this.knownKeysKeyFor(key), key);
192
+ if (this.knownKeysShards > 1) {
193
+ pipeline.srem(this.legacyKnownKeysKey(), key);
194
+ }
179
195
  pipeline.del(keyTagsKey);
180
196
  for (const tag of existingTags) {
181
197
  pipeline.srem(this.tagKeysKey(tag), key);
182
198
  }
183
199
  await pipeline.exec();
184
200
  }
201
+ /**
202
+ * Returns keys currently associated with a tag.
203
+ */
185
204
  async keysForTag(tag) {
186
205
  return this.client.smembers(this.tagKeysKey(tag));
187
206
  }
207
+ /**
208
+ * Visits keys currently associated with a tag.
209
+ */
188
210
  async forEachKeyForTag(tag, visitor) {
189
211
  let cursor = "0";
190
212
  const tagKey = this.tagKeysKey(tag);
@@ -196,38 +218,56 @@ var RedisTagIndex = class {
196
218
  }
197
219
  } while (cursor !== "0");
198
220
  }
221
+ /**
222
+ * Returns known keys that start with a prefix.
223
+ */
199
224
  async keysForPrefix(prefix) {
200
- const matches = [];
201
- for (const knownKeysKey of this.knownKeysKeys()) {
225
+ const matches = /* @__PURE__ */ new Set();
226
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
202
227
  let cursor = "0";
203
228
  do {
204
229
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
205
230
  cursor = nextCursor;
206
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
231
+ for (const key of keys) {
232
+ if (key.startsWith(prefix)) {
233
+ matches.add(key);
234
+ }
235
+ }
207
236
  } while (cursor !== "0");
208
237
  }
209
- return matches;
238
+ return [...matches];
210
239
  }
240
+ /**
241
+ * Visits known keys that start with a prefix.
242
+ */
211
243
  async forEachKeyForPrefix(prefix, visitor) {
212
- for (const knownKeysKey of this.knownKeysKeys()) {
244
+ const visited = /* @__PURE__ */ new Set();
245
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
213
246
  let cursor = "0";
214
247
  do {
215
248
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
216
249
  cursor = nextCursor;
217
250
  for (const key of keys) {
218
- if (key.startsWith(prefix)) {
251
+ if (key.startsWith(prefix) && !visited.has(key)) {
252
+ visited.add(key);
219
253
  await visitor(key);
220
254
  }
221
255
  }
222
256
  } while (cursor !== "0");
223
257
  }
224
258
  }
259
+ /**
260
+ * Returns the tags currently associated with a key.
261
+ */
225
262
  async tagsForKey(key) {
226
263
  return this.client.smembers(this.keyTagsKey(key));
227
264
  }
265
+ /**
266
+ * Returns known keys matching a wildcard pattern.
267
+ */
228
268
  async matchPattern(pattern) {
229
- const matches = [];
230
- for (const knownKeysKey of this.knownKeysKeys()) {
269
+ const matches = /* @__PURE__ */ new Set();
270
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
231
271
  let cursor = "0";
232
272
  do {
233
273
  const [nextCursor, keys] = await this.client.sscan(
@@ -239,13 +279,21 @@ var RedisTagIndex = class {
239
279
  this.scanCount
240
280
  );
241
281
  cursor = nextCursor;
242
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
282
+ for (const key of keys) {
283
+ if (PatternMatcher.matches(pattern, key)) {
284
+ matches.add(key);
285
+ }
286
+ }
243
287
  } while (cursor !== "0");
244
288
  }
245
- return matches;
289
+ return [...matches];
246
290
  }
291
+ /**
292
+ * Visits known keys matching a wildcard pattern.
293
+ */
247
294
  async forEachKeyMatchingPattern(pattern, visitor) {
248
- for (const knownKeysKey of this.knownKeysKeys()) {
295
+ const visited = /* @__PURE__ */ new Set();
296
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
249
297
  let cursor = "0";
250
298
  do {
251
299
  const [nextCursor, keys] = await this.client.sscan(
@@ -258,13 +306,17 @@ var RedisTagIndex = class {
258
306
  );
259
307
  cursor = nextCursor;
260
308
  for (const key of keys) {
261
- if (PatternMatcher.matches(pattern, key)) {
309
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
310
+ visited.add(key);
262
311
  await visitor(key);
263
312
  }
264
313
  }
265
314
  } while (cursor !== "0");
266
315
  }
267
316
  }
317
+ /**
318
+ * Clears all Redis tag-index state under this prefix.
319
+ */
268
320
  async clear() {
269
321
  const indexKeys = await this.scanIndexKeys();
270
322
  if (indexKeys.length === 0) {
@@ -272,6 +324,31 @@ var RedisTagIndex = class {
272
324
  }
273
325
  await this.client.del(...indexKeys);
274
326
  }
327
+ async migrateLegacyKnownKeys() {
328
+ if (this.knownKeysShards === 1) {
329
+ return { migratedKeys: 0 };
330
+ }
331
+ const legacyKey = this.legacyKnownKeysKey();
332
+ let cursor = "0";
333
+ let migratedKeys = 0;
334
+ do {
335
+ const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
336
+ cursor = nextCursor;
337
+ if (keys.length === 0) {
338
+ continue;
339
+ }
340
+ const pipeline = this.client.pipeline();
341
+ for (const key of keys) {
342
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
343
+ }
344
+ await pipeline.exec();
345
+ migratedKeys += keys.length;
346
+ } while (cursor !== "0");
347
+ if (migratedKeys > 0) {
348
+ await this.client.del(legacyKey);
349
+ }
350
+ return { migratedKeys };
351
+ }
275
352
  async scanIndexKeys() {
276
353
  const matches = [];
277
354
  let cursor = "0";
@@ -289,12 +366,40 @@ var RedisTagIndex = class {
289
366
  }
290
367
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
291
368
  }
369
+ async knownKeysKeysForRead() {
370
+ if (this.knownKeysShards === 1) {
371
+ return [this.legacyKnownKeysKey()];
372
+ }
373
+ const shardedKeys = this.knownKeysKeys();
374
+ const legacyKey = this.legacyKnownKeysKey();
375
+ const legacyExists = await this.client.exists(legacyKey) > 0;
376
+ if (!legacyExists) {
377
+ return shardedKeys;
378
+ }
379
+ this.warnLegacyKnownKeys(legacyKey);
380
+ return [legacyKey, ...shardedKeys];
381
+ }
292
382
  knownKeysKeys() {
293
383
  if (this.knownKeysShards === 1) {
294
384
  return [`${this.prefix}:keys`];
295
385
  }
296
386
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
297
387
  }
388
+ legacyKnownKeysKey() {
389
+ return `${this.prefix}:keys`;
390
+ }
391
+ warnLegacyKnownKeys(legacyKey) {
392
+ if (this.warnedLegacyKnownKeys) {
393
+ return;
394
+ }
395
+ this.warnedLegacyKnownKeys = true;
396
+ const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
397
+ if (this.logger?.warn) {
398
+ this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
399
+ return;
400
+ }
401
+ console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
402
+ }
298
403
  keyTagsKey(key) {
299
404
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
300
405
  }
@@ -304,7 +409,7 @@ var RedisTagIndex = class {
304
409
  };
305
410
  function normalizeKnownKeysShards(value) {
306
411
  if (value === void 0) {
307
- return 1;
412
+ return DEFAULT_KNOWN_KEYS_SHARDS;
308
413
  }
309
414
  if (!Number.isInteger(value) || value <= 0) {
310
415
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");