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/dist/cli.cjs CHANGED
@@ -216,20 +216,30 @@ var PatternMatcher = class _PatternMatcher {
216
216
  };
217
217
 
218
218
  // src/invalidation/RedisTagIndex.ts
219
+ var DEFAULT_KNOWN_KEYS_SHARDS = 16;
219
220
  var RedisTagIndex = class {
220
221
  client;
221
222
  prefix;
222
223
  scanCount;
223
224
  knownKeysShards;
225
+ logger;
226
+ warnedLegacyKnownKeys = false;
224
227
  constructor(options) {
225
228
  this.client = options.client;
226
229
  this.prefix = options.prefix ?? "layercache:tag-index";
227
230
  this.scanCount = options.scanCount ?? 100;
228
231
  this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
232
+ this.logger = options.logger;
229
233
  }
234
+ /**
235
+ * Records a key as known without changing tag assignments.
236
+ */
230
237
  async touch(key) {
231
238
  await this.client.sadd(this.knownKeysKeyFor(key), key);
232
239
  }
240
+ /**
241
+ * Replaces the tags associated with a key and records the key as known.
242
+ */
233
243
  async track(key, tags) {
234
244
  const keyTagsKey = this.keyTagsKey(key);
235
245
  const existingTags = await this.client.smembers(keyTagsKey);
@@ -247,20 +257,32 @@ var RedisTagIndex = class {
247
257
  }
248
258
  await pipeline.exec();
249
259
  }
260
+ /**
261
+ * Removes a key from all tag mappings and known-key tracking.
262
+ */
250
263
  async remove(key) {
251
264
  const keyTagsKey = this.keyTagsKey(key);
252
265
  const existingTags = await this.client.smembers(keyTagsKey);
253
266
  const pipeline = this.client.pipeline();
254
267
  pipeline.srem(this.knownKeysKeyFor(key), key);
268
+ if (this.knownKeysShards > 1) {
269
+ pipeline.srem(this.legacyKnownKeysKey(), key);
270
+ }
255
271
  pipeline.del(keyTagsKey);
256
272
  for (const tag of existingTags) {
257
273
  pipeline.srem(this.tagKeysKey(tag), key);
258
274
  }
259
275
  await pipeline.exec();
260
276
  }
277
+ /**
278
+ * Returns keys currently associated with a tag.
279
+ */
261
280
  async keysForTag(tag) {
262
281
  return this.client.smembers(this.tagKeysKey(tag));
263
282
  }
283
+ /**
284
+ * Visits keys currently associated with a tag.
285
+ */
264
286
  async forEachKeyForTag(tag, visitor) {
265
287
  let cursor = "0";
266
288
  const tagKey = this.tagKeysKey(tag);
@@ -272,38 +294,56 @@ var RedisTagIndex = class {
272
294
  }
273
295
  } while (cursor !== "0");
274
296
  }
297
+ /**
298
+ * Returns known keys that start with a prefix.
299
+ */
275
300
  async keysForPrefix(prefix) {
276
- const matches = [];
277
- for (const knownKeysKey of this.knownKeysKeys()) {
301
+ const matches = /* @__PURE__ */ new Set();
302
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
278
303
  let cursor = "0";
279
304
  do {
280
305
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
281
306
  cursor = nextCursor;
282
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
307
+ for (const key of keys) {
308
+ if (key.startsWith(prefix)) {
309
+ matches.add(key);
310
+ }
311
+ }
283
312
  } while (cursor !== "0");
284
313
  }
285
- return matches;
314
+ return [...matches];
286
315
  }
316
+ /**
317
+ * Visits known keys that start with a prefix.
318
+ */
287
319
  async forEachKeyForPrefix(prefix, visitor) {
288
- for (const knownKeysKey of this.knownKeysKeys()) {
320
+ const visited = /* @__PURE__ */ new Set();
321
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
289
322
  let cursor = "0";
290
323
  do {
291
324
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
292
325
  cursor = nextCursor;
293
326
  for (const key of keys) {
294
- if (key.startsWith(prefix)) {
327
+ if (key.startsWith(prefix) && !visited.has(key)) {
328
+ visited.add(key);
295
329
  await visitor(key);
296
330
  }
297
331
  }
298
332
  } while (cursor !== "0");
299
333
  }
300
334
  }
335
+ /**
336
+ * Returns the tags currently associated with a key.
337
+ */
301
338
  async tagsForKey(key) {
302
339
  return this.client.smembers(this.keyTagsKey(key));
303
340
  }
341
+ /**
342
+ * Returns known keys matching a wildcard pattern.
343
+ */
304
344
  async matchPattern(pattern) {
305
- const matches = [];
306
- for (const knownKeysKey of this.knownKeysKeys()) {
345
+ const matches = /* @__PURE__ */ new Set();
346
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
307
347
  let cursor = "0";
308
348
  do {
309
349
  const [nextCursor, keys] = await this.client.sscan(
@@ -315,13 +355,21 @@ var RedisTagIndex = class {
315
355
  this.scanCount
316
356
  );
317
357
  cursor = nextCursor;
318
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
358
+ for (const key of keys) {
359
+ if (PatternMatcher.matches(pattern, key)) {
360
+ matches.add(key);
361
+ }
362
+ }
319
363
  } while (cursor !== "0");
320
364
  }
321
- return matches;
365
+ return [...matches];
322
366
  }
367
+ /**
368
+ * Visits known keys matching a wildcard pattern.
369
+ */
323
370
  async forEachKeyMatchingPattern(pattern, visitor) {
324
- for (const knownKeysKey of this.knownKeysKeys()) {
371
+ const visited = /* @__PURE__ */ new Set();
372
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
325
373
  let cursor = "0";
326
374
  do {
327
375
  const [nextCursor, keys] = await this.client.sscan(
@@ -334,13 +382,17 @@ var RedisTagIndex = class {
334
382
  );
335
383
  cursor = nextCursor;
336
384
  for (const key of keys) {
337
- if (PatternMatcher.matches(pattern, key)) {
385
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
386
+ visited.add(key);
338
387
  await visitor(key);
339
388
  }
340
389
  }
341
390
  } while (cursor !== "0");
342
391
  }
343
392
  }
393
+ /**
394
+ * Clears all Redis tag-index state under this prefix.
395
+ */
344
396
  async clear() {
345
397
  const indexKeys = await this.scanIndexKeys();
346
398
  if (indexKeys.length === 0) {
@@ -348,6 +400,31 @@ var RedisTagIndex = class {
348
400
  }
349
401
  await this.client.del(...indexKeys);
350
402
  }
403
+ async migrateLegacyKnownKeys() {
404
+ if (this.knownKeysShards === 1) {
405
+ return { migratedKeys: 0 };
406
+ }
407
+ const legacyKey = this.legacyKnownKeysKey();
408
+ let cursor = "0";
409
+ let migratedKeys = 0;
410
+ do {
411
+ const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
412
+ cursor = nextCursor;
413
+ if (keys.length === 0) {
414
+ continue;
415
+ }
416
+ const pipeline = this.client.pipeline();
417
+ for (const key of keys) {
418
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
419
+ }
420
+ await pipeline.exec();
421
+ migratedKeys += keys.length;
422
+ } while (cursor !== "0");
423
+ if (migratedKeys > 0) {
424
+ await this.client.del(legacyKey);
425
+ }
426
+ return { migratedKeys };
427
+ }
351
428
  async scanIndexKeys() {
352
429
  const matches = [];
353
430
  let cursor = "0";
@@ -365,12 +442,40 @@ var RedisTagIndex = class {
365
442
  }
366
443
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
367
444
  }
445
+ async knownKeysKeysForRead() {
446
+ if (this.knownKeysShards === 1) {
447
+ return [this.legacyKnownKeysKey()];
448
+ }
449
+ const shardedKeys = this.knownKeysKeys();
450
+ const legacyKey = this.legacyKnownKeysKey();
451
+ const legacyExists = await this.client.exists(legacyKey) > 0;
452
+ if (!legacyExists) {
453
+ return shardedKeys;
454
+ }
455
+ this.warnLegacyKnownKeys(legacyKey);
456
+ return [legacyKey, ...shardedKeys];
457
+ }
368
458
  knownKeysKeys() {
369
459
  if (this.knownKeysShards === 1) {
370
460
  return [`${this.prefix}:keys`];
371
461
  }
372
462
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
373
463
  }
464
+ legacyKnownKeysKey() {
465
+ return `${this.prefix}:keys`;
466
+ }
467
+ warnLegacyKnownKeys(legacyKey) {
468
+ if (this.warnedLegacyKnownKeys) {
469
+ return;
470
+ }
471
+ this.warnedLegacyKnownKeys = true;
472
+ const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
473
+ if (this.logger?.warn) {
474
+ this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
475
+ return;
476
+ }
477
+ console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
478
+ }
374
479
  keyTagsKey(key) {
375
480
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
376
481
  }
@@ -380,7 +485,7 @@ var RedisTagIndex = class {
380
485
  };
381
486
  function normalizeKnownKeysShards(value) {
382
487
  if (value === void 0) {
383
- return 1;
488
+ return DEFAULT_KNOWN_KEYS_SHARDS;
384
489
  }
385
490
  if (!Number.isInteger(value) || value <= 0) {
386
491
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
@@ -398,7 +503,11 @@ function simpleHash(value) {
398
503
  // src/cli.ts
399
504
  var CONNECT_TIMEOUT_MS = 5e3;
400
505
  async function main(argv = process.argv.slice(2)) {
506
+ process.exitCode = void 0;
401
507
  const args = parseArgs(argv);
508
+ if (args.parseError) {
509
+ return;
510
+ }
402
511
  if (!args.command || !args.redisUrl) {
403
512
  printUsage();
404
513
  process.exitCode = 1;
@@ -418,6 +527,13 @@ async function main(argv = process.argv.slice(2)) {
418
527
  process.exitCode = 1;
419
528
  return;
420
529
  }
530
+ if (process.env.NODE_ENV === "production" && !args.allowPlaintext) {
531
+ process.stderr.write(
532
+ "Error: refusing plaintext redis:// connection because NODE_ENV=production. Use rediss:// for TLS-encrypted connections, or pass --allow-plaintext to explicitly override.\n"
533
+ );
534
+ process.exitCode = 1;
535
+ return;
536
+ }
421
537
  process.stderr.write(
422
538
  "Warning: connecting to Redis without TLS (redis://). All data including cached values and credentials will be transmitted in plaintext. Use rediss:// in production environments, or set --require-tls.\n"
423
539
  );
@@ -435,7 +551,7 @@ async function main(argv = process.argv.slice(2)) {
435
551
  if (args.command === "stats") {
436
552
  const pattern = args.pattern ?? "*";
437
553
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
438
- const keys = await scanKeys(redis, pattern);
554
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
439
555
  process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern }, null, 2)}
440
556
  `);
441
557
  return;
@@ -443,7 +559,7 @@ async function main(argv = process.argv.slice(2)) {
443
559
  if (args.command === "keys") {
444
560
  const pattern = args.pattern ?? "*";
445
561
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
446
- const keys = await scanKeys(redis, pattern);
562
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
447
563
  if (keys.length > 0) {
448
564
  process.stdout.write(`${keys.join("\n")}
449
565
  `);
@@ -464,7 +580,7 @@ async function main(argv = process.argv.slice(2)) {
464
580
  }
465
581
  const effectivePattern = args.pattern ?? "*";
466
582
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
467
- const keys = await scanKeys(redis, effectivePattern);
583
+ const keys = await scanKeys(redis, effectivePattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
468
584
  if (!args.pattern && !args.force && keys.length > 0) {
469
585
  process.stderr.write(`Warning: this operation will invalidate ${keys.length} keys. Use --force to confirm.
470
586
  `);
@@ -474,6 +590,17 @@ async function main(argv = process.argv.slice(2)) {
474
590
  await batchDelete(redis, keys);
475
591
  }
476
592
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: effectivePattern }, null, 2)}
593
+ `);
594
+ return;
595
+ }
596
+ if (args.command === "migrate-tag-index") {
597
+ const tagIndex = new RedisTagIndex({
598
+ client: redis,
599
+ prefix: args.tagIndexPrefix ?? "layercache:tag-index",
600
+ knownKeysShards: args.knownKeysShards
601
+ });
602
+ const result = await tagIndex.migrateLegacyKnownKeys();
603
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
477
604
  `);
478
605
  return;
479
606
  }
@@ -538,6 +665,7 @@ function parseArgs(argv) {
538
665
  if (!value || value.startsWith("--")) {
539
666
  process.stderr.write("Error: --redis requires a value (e.g. redis://localhost:6379)\n");
540
667
  process.exitCode = 1;
668
+ parsed.parseError = true;
541
669
  return parsed;
542
670
  }
543
671
  parsed.redisUrl = value;
@@ -554,8 +682,42 @@ function parseArgs(argv) {
554
682
  } else if (token === "--tag-index-prefix") {
555
683
  parsed.tagIndexPrefix = value;
556
684
  index += 1;
685
+ } else if (token === "--known-key-shards") {
686
+ if (!value || value.startsWith("--")) {
687
+ process.stderr.write("Error: --known-key-shards requires a positive integer value.\n");
688
+ process.exitCode = 1;
689
+ parsed.parseError = true;
690
+ return parsed;
691
+ }
692
+ const knownKeysShards = Number(value);
693
+ if (!Number.isSafeInteger(knownKeysShards) || knownKeysShards <= 0) {
694
+ process.stderr.write("Error: --known-key-shards requires a positive integer value.\n");
695
+ process.exitCode = 1;
696
+ parsed.parseError = true;
697
+ return parsed;
698
+ }
699
+ parsed.knownKeysShards = knownKeysShards;
700
+ index += 1;
701
+ } else if (token === "--limit") {
702
+ if (!value || value.startsWith("--")) {
703
+ process.stderr.write("Error: --limit requires a positive integer value.\n");
704
+ process.exitCode = 1;
705
+ parsed.parseError = true;
706
+ return parsed;
707
+ }
708
+ const limit = Number(value);
709
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
710
+ process.stderr.write("Error: --limit requires a positive integer value.\n");
711
+ process.exitCode = 1;
712
+ parsed.parseError = true;
713
+ return parsed;
714
+ }
715
+ parsed.scanLimit = limit;
716
+ index += 1;
557
717
  } else if (token === "--require-tls") {
558
718
  parsed.requireTls = true;
719
+ } else if (token === "--allow-plaintext") {
720
+ parsed.allowPlaintext = true;
559
721
  } else if (token === "--force") {
560
722
  parsed.force = true;
561
723
  }
@@ -563,25 +725,24 @@ function parseArgs(argv) {
563
725
  return parsed;
564
726
  }
565
727
  var BATCH_DELETE_SIZE = 500;
566
- var SCAN_MAX_KEYS = 1e6;
728
+ var DEFAULT_SCAN_MAX_KEYS = 1e5;
567
729
  async function batchDelete(redis, keys) {
568
730
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
569
731
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
570
732
  await redis.del(...batch);
571
733
  }
572
734
  }
573
- async function scanKeys(redis, pattern) {
735
+ async function scanKeys(redis, pattern, limit) {
574
736
  const keys = [];
575
737
  let cursor = "0";
576
738
  do {
577
739
  const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
578
740
  cursor = nextCursor;
579
- keys.push(...batch);
580
- if (keys.length >= SCAN_MAX_KEYS) {
581
- process.stderr.write(
582
- `Warning: stopped scanning after ${SCAN_MAX_KEYS} keys. Use a more specific --pattern to narrow results.
583
- `
584
- );
741
+ const remaining = limit - keys.length;
742
+ keys.push(...batch.slice(0, remaining));
743
+ if (keys.length >= limit) {
744
+ process.stderr.write(`Warning: stopped scanning after ${limit} keys. Use --limit to raise the scan cap.
745
+ `);
585
746
  return keys;
586
747
  }
587
748
  } while (cursor !== "0");
@@ -589,7 +750,7 @@ async function scanKeys(redis, pattern) {
589
750
  }
590
751
  function printUsage() {
591
752
  process.stdout.write(
592
- "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --require-tls Reject non-TLS (redis://) connections\n"
753
+ "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n layercache migrate-tag-index --redis <url> [--tag-index-prefix <prefix>] [--known-key-shards <count>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --known-key-shards <count> Shard count for RedisTagIndex migration (default: 16)\n --limit <count> Maximum Redis keys to scan (default: 100000)\n --require-tls Reject non-TLS (redis://) connections\n --allow-plaintext Explicitly allow redis:// when NODE_ENV=production\n"
593
754
  );
594
755
  }
595
756
  function decodeInspectablePayload(payload) {
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  validateCacheKey,
5
5
  validatePattern,
6
6
  validateTag
7
- } from "./chunk-7KMKQ6QZ.js";
7
+ } from "./chunk-NBMG7DHT.js";
8
8
  import {
9
9
  isStoredValueEnvelope,
10
10
  resolveStoredValue
@@ -14,7 +14,11 @@ import {
14
14
  import Redis from "ioredis";
15
15
  var CONNECT_TIMEOUT_MS = 5e3;
16
16
  async function main(argv = process.argv.slice(2)) {
17
+ process.exitCode = void 0;
17
18
  const args = parseArgs(argv);
19
+ if (args.parseError) {
20
+ return;
21
+ }
18
22
  if (!args.command || !args.redisUrl) {
19
23
  printUsage();
20
24
  process.exitCode = 1;
@@ -34,6 +38,13 @@ async function main(argv = process.argv.slice(2)) {
34
38
  process.exitCode = 1;
35
39
  return;
36
40
  }
41
+ if (process.env.NODE_ENV === "production" && !args.allowPlaintext) {
42
+ process.stderr.write(
43
+ "Error: refusing plaintext redis:// connection because NODE_ENV=production. Use rediss:// for TLS-encrypted connections, or pass --allow-plaintext to explicitly override.\n"
44
+ );
45
+ process.exitCode = 1;
46
+ return;
47
+ }
37
48
  process.stderr.write(
38
49
  "Warning: connecting to Redis without TLS (redis://). All data including cached values and credentials will be transmitted in plaintext. Use rediss:// in production environments, or set --require-tls.\n"
39
50
  );
@@ -51,7 +62,7 @@ async function main(argv = process.argv.slice(2)) {
51
62
  if (args.command === "stats") {
52
63
  const pattern = args.pattern ?? "*";
53
64
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
54
- const keys = await scanKeys(redis, pattern);
65
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
55
66
  process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern }, null, 2)}
56
67
  `);
57
68
  return;
@@ -59,7 +70,7 @@ async function main(argv = process.argv.slice(2)) {
59
70
  if (args.command === "keys") {
60
71
  const pattern = args.pattern ?? "*";
61
72
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
62
- const keys = await scanKeys(redis, pattern);
73
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
63
74
  if (keys.length > 0) {
64
75
  process.stdout.write(`${keys.join("\n")}
65
76
  `);
@@ -80,7 +91,7 @@ async function main(argv = process.argv.slice(2)) {
80
91
  }
81
92
  const effectivePattern = args.pattern ?? "*";
82
93
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
83
- const keys = await scanKeys(redis, effectivePattern);
94
+ const keys = await scanKeys(redis, effectivePattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
84
95
  if (!args.pattern && !args.force && keys.length > 0) {
85
96
  process.stderr.write(`Warning: this operation will invalidate ${keys.length} keys. Use --force to confirm.
86
97
  `);
@@ -90,6 +101,17 @@ async function main(argv = process.argv.slice(2)) {
90
101
  await batchDelete(redis, keys);
91
102
  }
92
103
  process.stdout.write(`${JSON.stringify({ deletedKeys: keys.length, pattern: effectivePattern }, null, 2)}
104
+ `);
105
+ return;
106
+ }
107
+ if (args.command === "migrate-tag-index") {
108
+ const tagIndex = new RedisTagIndex({
109
+ client: redis,
110
+ prefix: args.tagIndexPrefix ?? "layercache:tag-index",
111
+ knownKeysShards: args.knownKeysShards
112
+ });
113
+ const result = await tagIndex.migrateLegacyKnownKeys();
114
+ process.stdout.write(`${JSON.stringify(result, null, 2)}
93
115
  `);
94
116
  return;
95
117
  }
@@ -154,6 +176,7 @@ function parseArgs(argv) {
154
176
  if (!value || value.startsWith("--")) {
155
177
  process.stderr.write("Error: --redis requires a value (e.g. redis://localhost:6379)\n");
156
178
  process.exitCode = 1;
179
+ parsed.parseError = true;
157
180
  return parsed;
158
181
  }
159
182
  parsed.redisUrl = value;
@@ -170,8 +193,42 @@ function parseArgs(argv) {
170
193
  } else if (token === "--tag-index-prefix") {
171
194
  parsed.tagIndexPrefix = value;
172
195
  index += 1;
196
+ } else if (token === "--known-key-shards") {
197
+ if (!value || value.startsWith("--")) {
198
+ process.stderr.write("Error: --known-key-shards requires a positive integer value.\n");
199
+ process.exitCode = 1;
200
+ parsed.parseError = true;
201
+ return parsed;
202
+ }
203
+ const knownKeysShards = Number(value);
204
+ if (!Number.isSafeInteger(knownKeysShards) || knownKeysShards <= 0) {
205
+ process.stderr.write("Error: --known-key-shards requires a positive integer value.\n");
206
+ process.exitCode = 1;
207
+ parsed.parseError = true;
208
+ return parsed;
209
+ }
210
+ parsed.knownKeysShards = knownKeysShards;
211
+ index += 1;
212
+ } else if (token === "--limit") {
213
+ if (!value || value.startsWith("--")) {
214
+ process.stderr.write("Error: --limit requires a positive integer value.\n");
215
+ process.exitCode = 1;
216
+ parsed.parseError = true;
217
+ return parsed;
218
+ }
219
+ const limit = Number(value);
220
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
221
+ process.stderr.write("Error: --limit requires a positive integer value.\n");
222
+ process.exitCode = 1;
223
+ parsed.parseError = true;
224
+ return parsed;
225
+ }
226
+ parsed.scanLimit = limit;
227
+ index += 1;
173
228
  } else if (token === "--require-tls") {
174
229
  parsed.requireTls = true;
230
+ } else if (token === "--allow-plaintext") {
231
+ parsed.allowPlaintext = true;
175
232
  } else if (token === "--force") {
176
233
  parsed.force = true;
177
234
  }
@@ -179,25 +236,24 @@ function parseArgs(argv) {
179
236
  return parsed;
180
237
  }
181
238
  var BATCH_DELETE_SIZE = 500;
182
- var SCAN_MAX_KEYS = 1e6;
239
+ var DEFAULT_SCAN_MAX_KEYS = 1e5;
183
240
  async function batchDelete(redis, keys) {
184
241
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
185
242
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
186
243
  await redis.del(...batch);
187
244
  }
188
245
  }
189
- async function scanKeys(redis, pattern) {
246
+ async function scanKeys(redis, pattern, limit) {
190
247
  const keys = [];
191
248
  let cursor = "0";
192
249
  do {
193
250
  const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
194
251
  cursor = nextCursor;
195
- keys.push(...batch);
196
- if (keys.length >= SCAN_MAX_KEYS) {
197
- process.stderr.write(
198
- `Warning: stopped scanning after ${SCAN_MAX_KEYS} keys. Use a more specific --pattern to narrow results.
199
- `
200
- );
252
+ const remaining = limit - keys.length;
253
+ keys.push(...batch.slice(0, remaining));
254
+ if (keys.length >= limit) {
255
+ process.stderr.write(`Warning: stopped scanning after ${limit} keys. Use --limit to raise the scan cap.
256
+ `);
201
257
  return keys;
202
258
  }
203
259
  } while (cursor !== "0");
@@ -205,7 +261,7 @@ async function scanKeys(redis, pattern) {
205
261
  }
206
262
  function printUsage() {
207
263
  process.stdout.write(
208
- "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --require-tls Reject non-TLS (redis://) connections\n"
264
+ "Usage:\n layercache stats --redis <url> [--pattern <glob>]\n layercache keys --redis <url> [--pattern <glob>]\n layercache inspect --redis <url> --key <key>\n layercache invalidate --redis <url> [--pattern <glob> | --tag <tag>] [--tag-index-prefix <prefix>]\n layercache migrate-tag-index --redis <url> [--tag-index-prefix <prefix>] [--known-key-shards <count>]\n\nOptions:\n --redis <url> Redis connection URL (e.g. redis://localhost:6379)\n --pattern <glob> Glob pattern to filter keys (default: *)\n --key <key> Exact cache key to inspect\n --tag <tag> Invalidate by tag name\n --tag-index-prefix <prefix> Redis key prefix for tag index (default: layercache:tag-index)\n --known-key-shards <count> Shard count for RedisTagIndex migration (default: 16)\n --limit <count> Maximum Redis keys to scan (default: 100000)\n --require-tls Reject non-TLS (redis://) connections\n --allow-plaintext Explicitly allow redis:// when NODE_ENV=production\n"
209
265
  );
210
266
  }
211
267
  function decodeInspectablePayload(payload) {