layercache 2.1.0 → 3.1.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,16 +216,20 @@ 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
  }
230
234
  /**
231
235
  * Records a key as known without changing tag assignments.
@@ -261,6 +265,9 @@ var RedisTagIndex = class {
261
265
  const existingTags = await this.client.smembers(keyTagsKey);
262
266
  const pipeline = this.client.pipeline();
263
267
  pipeline.srem(this.knownKeysKeyFor(key), key);
268
+ if (this.knownKeysShards > 1) {
269
+ pipeline.srem(this.legacyKnownKeysKey(), key);
270
+ }
264
271
  pipeline.del(keyTagsKey);
265
272
  for (const tag of existingTags) {
266
273
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -291,28 +298,34 @@ var RedisTagIndex = class {
291
298
  * Returns known keys that start with a prefix.
292
299
  */
293
300
  async keysForPrefix(prefix) {
294
- const matches = [];
295
- for (const knownKeysKey of this.knownKeysKeys()) {
301
+ const matches = /* @__PURE__ */ new Set();
302
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
296
303
  let cursor = "0";
297
304
  do {
298
305
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
299
306
  cursor = nextCursor;
300
- 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
+ }
301
312
  } while (cursor !== "0");
302
313
  }
303
- return matches;
314
+ return [...matches];
304
315
  }
305
316
  /**
306
317
  * Visits known keys that start with a prefix.
307
318
  */
308
319
  async forEachKeyForPrefix(prefix, visitor) {
309
- for (const knownKeysKey of this.knownKeysKeys()) {
320
+ const visited = /* @__PURE__ */ new Set();
321
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
310
322
  let cursor = "0";
311
323
  do {
312
324
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
313
325
  cursor = nextCursor;
314
326
  for (const key of keys) {
315
- if (key.startsWith(prefix)) {
327
+ if (key.startsWith(prefix) && !visited.has(key)) {
328
+ visited.add(key);
316
329
  await visitor(key);
317
330
  }
318
331
  }
@@ -329,8 +342,8 @@ var RedisTagIndex = class {
329
342
  * Returns known keys matching a wildcard pattern.
330
343
  */
331
344
  async matchPattern(pattern) {
332
- const matches = [];
333
- for (const knownKeysKey of this.knownKeysKeys()) {
345
+ const matches = /* @__PURE__ */ new Set();
346
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
334
347
  let cursor = "0";
335
348
  do {
336
349
  const [nextCursor, keys] = await this.client.sscan(
@@ -342,16 +355,21 @@ var RedisTagIndex = class {
342
355
  this.scanCount
343
356
  );
344
357
  cursor = nextCursor;
345
- 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
+ }
346
363
  } while (cursor !== "0");
347
364
  }
348
- return matches;
365
+ return [...matches];
349
366
  }
350
367
  /**
351
368
  * Visits known keys matching a wildcard pattern.
352
369
  */
353
370
  async forEachKeyMatchingPattern(pattern, visitor) {
354
- for (const knownKeysKey of this.knownKeysKeys()) {
371
+ const visited = /* @__PURE__ */ new Set();
372
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
355
373
  let cursor = "0";
356
374
  do {
357
375
  const [nextCursor, keys] = await this.client.sscan(
@@ -364,7 +382,8 @@ var RedisTagIndex = class {
364
382
  );
365
383
  cursor = nextCursor;
366
384
  for (const key of keys) {
367
- if (PatternMatcher.matches(pattern, key)) {
385
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
386
+ visited.add(key);
368
387
  await visitor(key);
369
388
  }
370
389
  }
@@ -381,6 +400,31 @@ var RedisTagIndex = class {
381
400
  }
382
401
  await this.client.del(...indexKeys);
383
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
+ }
384
428
  async scanIndexKeys() {
385
429
  const matches = [];
386
430
  let cursor = "0";
@@ -398,12 +442,40 @@ var RedisTagIndex = class {
398
442
  }
399
443
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
400
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
+ }
401
458
  knownKeysKeys() {
402
459
  if (this.knownKeysShards === 1) {
403
460
  return [`${this.prefix}:keys`];
404
461
  }
405
462
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
406
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
+ }
407
479
  keyTagsKey(key) {
408
480
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
409
481
  }
@@ -413,7 +485,7 @@ var RedisTagIndex = class {
413
485
  };
414
486
  function normalizeKnownKeysShards(value) {
415
487
  if (value === void 0) {
416
- return 1;
488
+ return DEFAULT_KNOWN_KEYS_SHARDS;
417
489
  }
418
490
  if (!Number.isInteger(value) || value <= 0) {
419
491
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
@@ -431,7 +503,11 @@ function simpleHash(value) {
431
503
  // src/cli.ts
432
504
  var CONNECT_TIMEOUT_MS = 5e3;
433
505
  async function main(argv = process.argv.slice(2)) {
506
+ process.exitCode = void 0;
434
507
  const args = parseArgs(argv);
508
+ if (args.parseError) {
509
+ return;
510
+ }
435
511
  if (!args.command || !args.redisUrl) {
436
512
  printUsage();
437
513
  process.exitCode = 1;
@@ -451,6 +527,13 @@ async function main(argv = process.argv.slice(2)) {
451
527
  process.exitCode = 1;
452
528
  return;
453
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
+ }
454
537
  process.stderr.write(
455
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"
456
539
  );
@@ -468,7 +551,7 @@ async function main(argv = process.argv.slice(2)) {
468
551
  if (args.command === "stats") {
469
552
  const pattern = args.pattern ?? "*";
470
553
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
471
- const keys = await scanKeys(redis, pattern);
554
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
472
555
  process.stdout.write(`${JSON.stringify({ totalKeys: keys.length, pattern }, null, 2)}
473
556
  `);
474
557
  return;
@@ -476,7 +559,7 @@ async function main(argv = process.argv.slice(2)) {
476
559
  if (args.command === "keys") {
477
560
  const pattern = args.pattern ?? "*";
478
561
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
479
- const keys = await scanKeys(redis, pattern);
562
+ const keys = await scanKeys(redis, pattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
480
563
  if (keys.length > 0) {
481
564
  process.stdout.write(`${keys.join("\n")}
482
565
  `);
@@ -497,7 +580,7 @@ async function main(argv = process.argv.slice(2)) {
497
580
  }
498
581
  const effectivePattern = args.pattern ?? "*";
499
582
  if (args.pattern && !validateCliInput(args.pattern, validatePattern)) return;
500
- const keys = await scanKeys(redis, effectivePattern);
583
+ const keys = await scanKeys(redis, effectivePattern, args.scanLimit ?? DEFAULT_SCAN_MAX_KEYS);
501
584
  if (!args.pattern && !args.force && keys.length > 0) {
502
585
  process.stderr.write(`Warning: this operation will invalidate ${keys.length} keys. Use --force to confirm.
503
586
  `);
@@ -507,6 +590,17 @@ async function main(argv = process.argv.slice(2)) {
507
590
  await batchDelete(redis, keys);
508
591
  }
509
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)}
510
604
  `);
511
605
  return;
512
606
  }
@@ -571,6 +665,7 @@ function parseArgs(argv) {
571
665
  if (!value || value.startsWith("--")) {
572
666
  process.stderr.write("Error: --redis requires a value (e.g. redis://localhost:6379)\n");
573
667
  process.exitCode = 1;
668
+ parsed.parseError = true;
574
669
  return parsed;
575
670
  }
576
671
  parsed.redisUrl = value;
@@ -587,8 +682,42 @@ function parseArgs(argv) {
587
682
  } else if (token === "--tag-index-prefix") {
588
683
  parsed.tagIndexPrefix = value;
589
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;
590
717
  } else if (token === "--require-tls") {
591
718
  parsed.requireTls = true;
719
+ } else if (token === "--allow-plaintext") {
720
+ parsed.allowPlaintext = true;
592
721
  } else if (token === "--force") {
593
722
  parsed.force = true;
594
723
  }
@@ -596,25 +725,24 @@ function parseArgs(argv) {
596
725
  return parsed;
597
726
  }
598
727
  var BATCH_DELETE_SIZE = 500;
599
- var SCAN_MAX_KEYS = 1e6;
728
+ var DEFAULT_SCAN_MAX_KEYS = 1e5;
600
729
  async function batchDelete(redis, keys) {
601
730
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
602
731
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
603
732
  await redis.del(...batch);
604
733
  }
605
734
  }
606
- async function scanKeys(redis, pattern) {
735
+ async function scanKeys(redis, pattern, limit) {
607
736
  const keys = [];
608
737
  let cursor = "0";
609
738
  do {
610
739
  const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
611
740
  cursor = nextCursor;
612
- keys.push(...batch);
613
- if (keys.length >= SCAN_MAX_KEYS) {
614
- process.stderr.write(
615
- `Warning: stopped scanning after ${SCAN_MAX_KEYS} keys. Use a more specific --pattern to narrow results.
616
- `
617
- );
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
+ `);
618
746
  return keys;
619
747
  }
620
748
  } while (cursor !== "0");
@@ -622,7 +750,7 @@ async function scanKeys(redis, pattern) {
622
750
  }
623
751
  function printUsage() {
624
752
  process.stdout.write(
625
- "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"
626
754
  );
627
755
  }
628
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-6X7NV5BG.js";
7
+ } from "./chunk-L6L7QXYF.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) {