layercache 2.1.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 +22 -6
- package/dist/{chunk-IVX6ABFX.js → chunk-5CIBABDH.js} +62 -19
- package/dist/{chunk-6X7NV5BG.js → chunk-NBMG7DHT.js} +85 -13
- package/dist/cli.cjs +153 -25
- package/dist/cli.js +69 -13
- package/dist/{edge-BCU8D-Yd.d.cts → edge-BDyuPmIq.d.cts} +5 -0
- package/dist/{edge-BCU8D-Yd.d.ts → edge-BDyuPmIq.d.ts} +5 -0
- package/dist/edge.cjs +61 -19
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +1 -1
- package/dist/index.cjs +312 -82
- package/dist/index.d.cts +47 -3
- package/dist/index.d.ts +47 -3
- package/dist/index.js +163 -47
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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-
|
|
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
|
|
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.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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) {
|
|
@@ -1162,6 +1162,10 @@ declare class CacheStack extends EventEmitter {
|
|
|
1162
1162
|
* unless `generationCleanup` is enabled to prune them in the background.
|
|
1163
1163
|
*/
|
|
1164
1164
|
bumpGeneration(nextGeneration?: number): number;
|
|
1165
|
+
/**
|
|
1166
|
+
* Returns the active generation prefix number used for future cache keys.
|
|
1167
|
+
*/
|
|
1168
|
+
getGeneration(): number | undefined;
|
|
1165
1169
|
/**
|
|
1166
1170
|
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
1167
1171
|
* remaining fresh/stale/error TTLs, and associated tags.
|
|
@@ -1246,6 +1250,7 @@ interface HonoLikeRequest {
|
|
|
1246
1250
|
interface HonoLikeContext {
|
|
1247
1251
|
req: HonoLikeRequest;
|
|
1248
1252
|
header?: (name: string, value: string) => void;
|
|
1253
|
+
status?: (status: number) => unknown;
|
|
1249
1254
|
json: (body: unknown, status?: number) => Response | Promise<Response> | unknown;
|
|
1250
1255
|
}
|
|
1251
1256
|
interface HonoCacheMiddlewareOptions extends CacheGetOptions {
|
|
@@ -1162,6 +1162,10 @@ declare class CacheStack extends EventEmitter {
|
|
|
1162
1162
|
* unless `generationCleanup` is enabled to prune them in the background.
|
|
1163
1163
|
*/
|
|
1164
1164
|
bumpGeneration(nextGeneration?: number): number;
|
|
1165
|
+
/**
|
|
1166
|
+
* Returns the active generation prefix number used for future cache keys.
|
|
1167
|
+
*/
|
|
1168
|
+
getGeneration(): number | undefined;
|
|
1165
1169
|
/**
|
|
1166
1170
|
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
1167
1171
|
* remaining fresh/stale/error TTLs, and associated tags.
|
|
@@ -1246,6 +1250,7 @@ interface HonoLikeRequest {
|
|
|
1246
1250
|
interface HonoLikeContext {
|
|
1247
1251
|
req: HonoLikeRequest;
|
|
1248
1252
|
header?: (name: string, value: string) => void;
|
|
1253
|
+
status?: (status: number) => unknown;
|
|
1249
1254
|
json: (body: unknown, status?: number) => Response | Promise<Response> | unknown;
|
|
1250
1255
|
}
|
|
1251
1256
|
interface HonoCacheMiddlewareOptions extends CacheGetOptions {
|