layercache 1.2.2 → 1.2.4

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.
@@ -468,6 +468,107 @@ function addMap(base, delta) {
468
468
  return result;
469
469
  }
470
470
 
471
+ // ../../src/invalidation/PatternMatcher.ts
472
+ var PatternMatcher = class _PatternMatcher {
473
+ /**
474
+ * Tests whether a glob-style pattern matches a value.
475
+ * Supports `*` (any sequence of characters) and `?` (any single character).
476
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
477
+ * quadratic memory usage on long patterns/keys.
478
+ */
479
+ static matches(pattern, value) {
480
+ return _PatternMatcher.matchLinear(pattern, value);
481
+ }
482
+ /**
483
+ * Linear-time glob matching with O(1) extra memory.
484
+ */
485
+ static matchLinear(pattern, value) {
486
+ let patternIndex = 0;
487
+ let valueIndex = 0;
488
+ let starIndex = -1;
489
+ let backtrackValueIndex = 0;
490
+ while (valueIndex < value.length) {
491
+ const patternChar = pattern[patternIndex];
492
+ const valueChar = value[valueIndex];
493
+ if (patternChar === "*" && patternIndex < pattern.length) {
494
+ starIndex = patternIndex;
495
+ patternIndex += 1;
496
+ backtrackValueIndex = valueIndex;
497
+ continue;
498
+ }
499
+ if (patternChar === "?" || patternChar === valueChar) {
500
+ patternIndex += 1;
501
+ valueIndex += 1;
502
+ continue;
503
+ }
504
+ if (starIndex !== -1) {
505
+ patternIndex = starIndex + 1;
506
+ backtrackValueIndex += 1;
507
+ valueIndex = backtrackValueIndex;
508
+ continue;
509
+ }
510
+ return false;
511
+ }
512
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
513
+ patternIndex += 1;
514
+ }
515
+ return patternIndex === pattern.length;
516
+ }
517
+ };
518
+
519
+ // ../../src/internal/CacheKeyDiscovery.ts
520
+ var CacheKeyDiscovery = class {
521
+ constructor(options) {
522
+ this.options = options;
523
+ }
524
+ options;
525
+ async collectKeysWithPrefix(prefix) {
526
+ const { tagIndex } = this.options;
527
+ const matches = new Set(
528
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
529
+ );
530
+ await Promise.all(
531
+ this.options.layers.map(async (layer) => {
532
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
533
+ return;
534
+ }
535
+ try {
536
+ const keys = await layer.keys();
537
+ for (const key of keys) {
538
+ if (key.startsWith(prefix)) {
539
+ matches.add(key);
540
+ }
541
+ }
542
+ } catch (error) {
543
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
544
+ }
545
+ })
546
+ );
547
+ return [...matches];
548
+ }
549
+ async collectKeysMatchingPattern(pattern) {
550
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
551
+ await Promise.all(
552
+ this.options.layers.map(async (layer) => {
553
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
554
+ return;
555
+ }
556
+ try {
557
+ const keys = await layer.keys();
558
+ for (const key of keys) {
559
+ if (PatternMatcher.matches(pattern, key)) {
560
+ matches.add(key);
561
+ }
562
+ }
563
+ } catch (error) {
564
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
565
+ }
566
+ })
567
+ );
568
+ return [...matches];
569
+ }
570
+ };
571
+
471
572
  // ../../src/internal/CircuitBreakerManager.ts
472
573
  var CircuitBreakerManager = class {
473
574
  breakers = /* @__PURE__ */ new Map();
@@ -562,8 +663,9 @@ var CircuitBreakerManager = class {
562
663
 
563
664
  // ../../src/internal/FetchRateLimiter.ts
564
665
  var FetchRateLimiter = class {
565
- queue = [];
566
666
  buckets = /* @__PURE__ */ new Map();
667
+ queuesByBucket = /* @__PURE__ */ new Map();
668
+ pendingBuckets = /* @__PURE__ */ new Set();
567
669
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
568
670
  nextFetcherBucketId = 0;
569
671
  drainTimer;
@@ -576,13 +678,17 @@ var FetchRateLimiter = class {
576
678
  return task();
577
679
  }
578
680
  return new Promise((resolve, reject) => {
579
- this.queue.push({
580
- bucketKey: this.resolveBucketKey(normalized, context),
681
+ const bucketKey = this.resolveBucketKey(normalized, context);
682
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
683
+ queue.push({
684
+ bucketKey,
581
685
  options: normalized,
582
686
  task,
583
687
  resolve,
584
688
  reject
585
689
  });
690
+ this.queuesByBucket.set(bucketKey, queue);
691
+ this.pendingBuckets.add(bucketKey);
586
692
  this.drain();
587
693
  });
588
694
  }
@@ -625,22 +731,30 @@ var FetchRateLimiter = class {
625
731
  clearTimeout(this.drainTimer);
626
732
  this.drainTimer = void 0;
627
733
  }
628
- while (this.queue.length > 0) {
629
- let nextIndex = -1;
734
+ while (this.pendingBuckets.size > 0) {
735
+ let nextBucketKey;
630
736
  let nextWaitMs = Number.POSITIVE_INFINITY;
631
- for (let index = 0; index < this.queue.length; index += 1) {
632
- const next2 = this.queue[index];
737
+ for (const bucketKey of this.pendingBuckets) {
738
+ const queue2 = this.queuesByBucket.get(bucketKey);
739
+ if (!queue2 || queue2.length === 0) {
740
+ this.pendingBuckets.delete(bucketKey);
741
+ this.queuesByBucket.delete(bucketKey);
742
+ continue;
743
+ }
744
+ const next2 = queue2[0];
633
745
  if (!next2) {
746
+ this.pendingBuckets.delete(bucketKey);
747
+ this.queuesByBucket.delete(bucketKey);
634
748
  continue;
635
749
  }
636
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
750
+ const waitMs = this.waitTime(bucketKey, next2.options);
637
751
  if (waitMs <= 0) {
638
- nextIndex = index;
752
+ nextBucketKey = bucketKey;
639
753
  break;
640
754
  }
641
755
  nextWaitMs = Math.min(nextWaitMs, waitMs);
642
756
  }
643
- if (nextIndex < 0) {
757
+ if (!nextBucketKey) {
644
758
  if (Number.isFinite(nextWaitMs)) {
645
759
  this.drainTimer = setTimeout(() => {
646
760
  this.drainTimer = void 0;
@@ -650,15 +764,32 @@ var FetchRateLimiter = class {
650
764
  }
651
765
  return;
652
766
  }
653
- const next = this.queue.splice(nextIndex, 1)[0];
767
+ const queue = this.queuesByBucket.get(nextBucketKey);
768
+ const next = queue?.shift();
654
769
  if (!next) {
655
- return;
770
+ this.pendingBuckets.delete(nextBucketKey);
771
+ this.queuesByBucket.delete(nextBucketKey);
772
+ continue;
773
+ }
774
+ if (!queue || queue.length === 0) {
775
+ this.pendingBuckets.delete(nextBucketKey);
776
+ this.queuesByBucket.delete(nextBucketKey);
656
777
  }
657
778
  const bucket = this.bucketState(next.bucketKey);
779
+ if (bucket.cleanupTimer) {
780
+ clearTimeout(bucket.cleanupTimer);
781
+ bucket.cleanupTimer = void 0;
782
+ }
658
783
  bucket.active += 1;
659
- bucket.startedAt.push(Date.now());
784
+ if (next.options.intervalMs && next.options.maxPerInterval) {
785
+ bucket.startedAt.push(Date.now());
786
+ }
660
787
  void next.task().then(next.resolve, next.reject).finally(() => {
661
788
  bucket.active -= 1;
789
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
790
+ this.pendingBuckets.add(next.bucketKey);
791
+ }
792
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
662
793
  this.drain();
663
794
  });
664
795
  }
@@ -700,6 +831,31 @@ var FetchRateLimiter = class {
700
831
  this.buckets.set(bucketKey, bucket);
701
832
  return bucket;
702
833
  }
834
+ cleanupBucket(bucketKey, bucket, intervalMs) {
835
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
836
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
837
+ this.buckets.delete(bucketKey);
838
+ this.queuesByBucket.delete(bucketKey);
839
+ this.pendingBuckets.delete(bucketKey);
840
+ return;
841
+ }
842
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
843
+ return;
844
+ }
845
+ if (bucket.cleanupTimer) {
846
+ clearTimeout(bucket.cleanupTimer);
847
+ }
848
+ bucket.cleanupTimer = setTimeout(() => {
849
+ bucket.cleanupTimer = void 0;
850
+ this.prune(bucket, Date.now(), intervalMs);
851
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
852
+ this.buckets.delete(bucketKey);
853
+ this.queuesByBucket.delete(bucketKey);
854
+ this.pendingBuckets.delete(bucketKey);
855
+ }
856
+ }, intervalMs);
857
+ bucket.cleanupTimer.unref?.();
858
+ }
703
859
  };
704
860
 
705
861
  // ../../src/internal/MetricsCollector.ts
@@ -778,7 +934,30 @@ var MetricsCollector = class {
778
934
 
779
935
  // ../../src/internal/StoredValue.ts
780
936
  function isStoredValueEnvelope(value) {
781
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
937
+ if (typeof value !== "object" || value === null) {
938
+ return false;
939
+ }
940
+ const v = value;
941
+ if (v.__layercache !== 1) {
942
+ return false;
943
+ }
944
+ if (v.kind !== "value" && v.kind !== "empty") {
945
+ return false;
946
+ }
947
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
948
+ return false;
949
+ }
950
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
951
+ return false;
952
+ }
953
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
954
+ return false;
955
+ }
956
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
957
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
958
+ return false;
959
+ }
960
+ return true;
782
961
  }
783
962
  function createStoredValueEnvelope(options) {
784
963
  const now = options.now ?? Date.now();
@@ -989,69 +1168,23 @@ var TtlResolver = class {
989
1168
  }
990
1169
  };
991
1170
 
992
- // ../../src/invalidation/PatternMatcher.ts
993
- var PatternMatcher = class _PatternMatcher {
994
- /**
995
- * Tests whether a glob-style pattern matches a value.
996
- * Supports `*` (any sequence of characters) and `?` (any single character).
997
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
998
- * quadratic memory usage on long patterns/keys.
999
- */
1000
- static matches(pattern, value) {
1001
- return _PatternMatcher.matchLinear(pattern, value);
1002
- }
1003
- /**
1004
- * Linear-time glob matching with O(1) extra memory.
1005
- */
1006
- static matchLinear(pattern, value) {
1007
- let patternIndex = 0;
1008
- let valueIndex = 0;
1009
- let starIndex = -1;
1010
- let backtrackValueIndex = 0;
1011
- while (valueIndex < value.length) {
1012
- const patternChar = pattern[patternIndex];
1013
- const valueChar = value[valueIndex];
1014
- if (patternChar === "*" && patternIndex < pattern.length) {
1015
- starIndex = patternIndex;
1016
- patternIndex += 1;
1017
- backtrackValueIndex = valueIndex;
1018
- continue;
1019
- }
1020
- if (patternChar === "?" || patternChar === valueChar) {
1021
- patternIndex += 1;
1022
- valueIndex += 1;
1023
- continue;
1024
- }
1025
- if (starIndex !== -1) {
1026
- patternIndex = starIndex + 1;
1027
- backtrackValueIndex += 1;
1028
- valueIndex = backtrackValueIndex;
1029
- continue;
1030
- }
1031
- return false;
1032
- }
1033
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
1034
- patternIndex += 1;
1035
- }
1036
- return patternIndex === pattern.length;
1037
- }
1038
- };
1039
-
1040
1171
  // ../../src/invalidation/TagIndex.ts
1041
1172
  var TagIndex = class {
1042
1173
  tagToKeys = /* @__PURE__ */ new Map();
1043
1174
  keyToTags = /* @__PURE__ */ new Map();
1044
1175
  knownKeys = /* @__PURE__ */ new Set();
1045
1176
  maxKnownKeys;
1177
+ nextNodeId = 1;
1178
+ root = this.createTrieNode();
1046
1179
  constructor(options = {}) {
1047
- this.maxKnownKeys = options.maxKnownKeys;
1180
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
1048
1181
  }
1049
1182
  async touch(key) {
1050
- this.knownKeys.add(key);
1183
+ this.insertKnownKey(key);
1051
1184
  this.pruneKnownKeysIfNeeded();
1052
1185
  }
1053
1186
  async track(key, tags) {
1054
- this.knownKeys.add(key);
1187
+ this.insertKnownKey(key);
1055
1188
  this.pruneKnownKeysIfNeeded();
1056
1189
  if (tags.length === 0) {
1057
1190
  return;
@@ -1077,18 +1210,104 @@ var TagIndex = class {
1077
1210
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1078
1211
  }
1079
1212
  async keysForPrefix(prefix) {
1080
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
1213
+ const node = this.findNode(prefix);
1214
+ if (!node) {
1215
+ return [];
1216
+ }
1217
+ const matches = [];
1218
+ this.collectFromNode(node, prefix, matches);
1219
+ return matches;
1081
1220
  }
1082
1221
  async tagsForKey(key) {
1083
1222
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1084
1223
  }
1085
1224
  async matchPattern(pattern) {
1086
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1225
+ const matches = /* @__PURE__ */ new Set();
1226
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1227
+ return [...matches];
1087
1228
  }
1088
1229
  async clear() {
1089
1230
  this.tagToKeys.clear();
1090
1231
  this.keyToTags.clear();
1091
1232
  this.knownKeys.clear();
1233
+ this.root.children.clear();
1234
+ this.root.terminal = false;
1235
+ this.nextNodeId = this.root.id + 1;
1236
+ }
1237
+ createTrieNode() {
1238
+ return {
1239
+ id: this.nextNodeId++,
1240
+ terminal: false,
1241
+ children: /* @__PURE__ */ new Map()
1242
+ };
1243
+ }
1244
+ insertKnownKey(key) {
1245
+ if (this.knownKeys.has(key)) {
1246
+ return;
1247
+ }
1248
+ this.knownKeys.add(key);
1249
+ let node = this.root;
1250
+ for (const character of key) {
1251
+ let child = node.children.get(character);
1252
+ if (!child) {
1253
+ child = this.createTrieNode();
1254
+ node.children.set(character, child);
1255
+ }
1256
+ node = child;
1257
+ }
1258
+ node.terminal = true;
1259
+ }
1260
+ findNode(prefix) {
1261
+ let node = this.root;
1262
+ for (const character of prefix) {
1263
+ node = node.children.get(character);
1264
+ if (!node) {
1265
+ return void 0;
1266
+ }
1267
+ }
1268
+ return node;
1269
+ }
1270
+ collectFromNode(node, prefix, matches) {
1271
+ if (node.terminal) {
1272
+ matches.push(prefix);
1273
+ }
1274
+ for (const [character, child] of node.children) {
1275
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1276
+ }
1277
+ }
1278
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1279
+ const stateKey = `${node.id}:${patternIndex}`;
1280
+ if (visited.has(stateKey)) {
1281
+ return;
1282
+ }
1283
+ visited.add(stateKey);
1284
+ if (patternIndex === pattern.length) {
1285
+ if (node.terminal) {
1286
+ matches.add(prefix);
1287
+ }
1288
+ return;
1289
+ }
1290
+ const patternChar = pattern[patternIndex];
1291
+ if (patternChar === void 0) {
1292
+ return;
1293
+ }
1294
+ if (patternChar === "*") {
1295
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1296
+ for (const [character, child2] of node.children) {
1297
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1298
+ }
1299
+ return;
1300
+ }
1301
+ if (patternChar === "?") {
1302
+ for (const [character, child2] of node.children) {
1303
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1304
+ }
1305
+ return;
1306
+ }
1307
+ const child = node.children.get(patternChar);
1308
+ if (child) {
1309
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1310
+ }
1092
1311
  }
1093
1312
  pruneKnownKeysIfNeeded() {
1094
1313
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1105,7 +1324,7 @@ var TagIndex = class {
1105
1324
  }
1106
1325
  }
1107
1326
  removeKey(key) {
1108
- this.knownKeys.delete(key);
1327
+ this.removeKnownKey(key);
1109
1328
  const tags = this.keyToTags.get(key);
1110
1329
  if (!tags) {
1111
1330
  return;
@@ -1122,7 +1341,70 @@ var TagIndex = class {
1122
1341
  }
1123
1342
  this.keyToTags.delete(key);
1124
1343
  }
1344
+ removeKnownKey(key) {
1345
+ if (!this.knownKeys.delete(key)) {
1346
+ return;
1347
+ }
1348
+ const path = [];
1349
+ let node = this.root;
1350
+ for (const character of key) {
1351
+ const child = node.children.get(character);
1352
+ if (!child) {
1353
+ return;
1354
+ }
1355
+ path.push([node, character]);
1356
+ node = child;
1357
+ }
1358
+ node.terminal = false;
1359
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1360
+ const entry = path[index];
1361
+ if (!entry) {
1362
+ continue;
1363
+ }
1364
+ const [parent, character] = entry;
1365
+ const child = parent.children.get(character);
1366
+ if (!child || child.terminal || child.children.size > 0) {
1367
+ break;
1368
+ }
1369
+ parent.children.delete(character);
1370
+ }
1371
+ }
1372
+ };
1373
+
1374
+ // ../../src/serialization/JsonSerializer.ts
1375
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1376
+ var JsonSerializer = class {
1377
+ serialize(value) {
1378
+ return JSON.stringify(value);
1379
+ }
1380
+ deserialize(payload) {
1381
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1382
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1383
+ }
1125
1384
  };
1385
+ var MAX_SANITIZE_DEPTH = 200;
1386
+ function sanitizeJsonValue(value, depth) {
1387
+ if (depth > MAX_SANITIZE_DEPTH) {
1388
+ return value;
1389
+ }
1390
+ if (Array.isArray(value)) {
1391
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1392
+ }
1393
+ if (!isPlainObject(value)) {
1394
+ return value;
1395
+ }
1396
+ const sanitized = {};
1397
+ for (const [key, entry] of Object.entries(value)) {
1398
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1399
+ continue;
1400
+ }
1401
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1402
+ }
1403
+ return sanitized;
1404
+ }
1405
+ function isPlainObject(value) {
1406
+ return Object.prototype.toString.call(value) === "[object Object]";
1407
+ }
1126
1408
 
1127
1409
  // ../../src/stampede/StampedeGuard.ts
1128
1410
  var StampedeGuard = class {
@@ -1133,7 +1415,8 @@ var StampedeGuard = class {
1133
1415
  return await entry.mutex.runExclusive(task);
1134
1416
  } finally {
1135
1417
  entry.references -= 1;
1136
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1418
+ const current = this.mutexes.get(key);
1419
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1137
1420
  this.mutexes.delete(key);
1138
1421
  }
1139
1422
  }
@@ -1163,8 +1446,10 @@ var CacheMissError = class extends Error {
1163
1446
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1164
1447
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1165
1448
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1449
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1166
1450
  var MAX_CACHE_KEY_LENGTH = 1024;
1167
1451
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1452
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1168
1453
  var DebugLogger = class {
1169
1454
  enabled;
1170
1455
  constructor(enabled) {
@@ -1211,6 +1496,29 @@ var CacheStack = class extends EventEmitter {
1211
1496
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1212
1497
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1213
1498
  this.tagIndex = options.tagIndex ?? new TagIndex();
1499
+ this.keyDiscovery = new CacheKeyDiscovery({
1500
+ layers: this.layers,
1501
+ tagIndex: this.tagIndex,
1502
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1503
+ handleLayerFailure: async (layer, operation, error) => {
1504
+ await this.handleLayerFailure(layer, operation, error);
1505
+ }
1506
+ });
1507
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1508
+ this.logger.warn?.(
1509
+ "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
1510
+ );
1511
+ }
1512
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1513
+ this.logger.warn?.(
1514
+ "Using the default in-memory TagIndex with a shared cache layer that does not implement keys() can leave invalidateByPattern() and invalidateByPrefix() incomplete after restarts. Use RedisTagIndex or implement keys() on the shared layer."
1515
+ );
1516
+ }
1517
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1518
+ this.logger.warn?.(
1519
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1520
+ );
1521
+ }
1214
1522
  this.initializeWriteBehind(options.writeBehind);
1215
1523
  this.startup = this.initialize();
1216
1524
  }
@@ -1223,7 +1531,9 @@ var CacheStack = class extends EventEmitter {
1223
1531
  unsubscribeInvalidation;
1224
1532
  logger;
1225
1533
  tagIndex;
1534
+ keyDiscovery;
1226
1535
  fetchRateLimiter = new FetchRateLimiter();
1536
+ snapshotSerializer = new JsonSerializer();
1227
1537
  backgroundRefreshes = /* @__PURE__ */ new Map();
1228
1538
  layerDegradedUntil = /* @__PURE__ */ new Map();
1229
1539
  ttlResolver;
@@ -1232,6 +1542,7 @@ var CacheStack = class extends EventEmitter {
1232
1542
  writeBehindQueue = [];
1233
1543
  writeBehindTimer;
1234
1544
  writeBehindFlushPromise;
1545
+ generationCleanupPromise;
1235
1546
  isDisconnecting = false;
1236
1547
  disconnectPromise;
1237
1548
  /**
@@ -1244,6 +1555,9 @@ var CacheStack = class extends EventEmitter {
1244
1555
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1245
1556
  this.validateWriteOptions(options);
1246
1557
  await this.awaitStartup("get");
1558
+ return this.getPrepared(normalizedKey, fetcher, options);
1559
+ }
1560
+ async getPrepared(normalizedKey, fetcher, options) {
1247
1561
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1248
1562
  if (hit.found) {
1249
1563
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1321,6 +1635,7 @@ var CacheStack = class extends EventEmitter {
1321
1635
  return true;
1322
1636
  }
1323
1637
  } catch {
1638
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1324
1639
  }
1325
1640
  } else {
1326
1641
  try {
@@ -1328,7 +1643,8 @@ var CacheStack = class extends EventEmitter {
1328
1643
  if (value !== null) {
1329
1644
  return true;
1330
1645
  }
1331
- } catch {
1646
+ } catch (error) {
1647
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1332
1648
  }
1333
1649
  }
1334
1650
  }
@@ -1420,13 +1736,14 @@ var CacheStack = class extends EventEmitter {
1420
1736
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1421
1737
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1422
1738
  if (!canFastPath) {
1739
+ await this.awaitStartup("mget");
1423
1740
  const pendingReads = /* @__PURE__ */ new Map();
1424
1741
  return Promise.all(
1425
1742
  normalizedEntries.map((entry) => {
1426
1743
  const optionsSignature = this.serializeOptions(entry.options);
1427
1744
  const existing = pendingReads.get(entry.key);
1428
1745
  if (!existing) {
1429
- const promise = this.get(entry.key, entry.fetch, entry.options);
1746
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1430
1747
  pendingReads.set(entry.key, {
1431
1748
  promise,
1432
1749
  fetch: entry.fetch,
@@ -1565,14 +1882,14 @@ var CacheStack = class extends EventEmitter {
1565
1882
  }
1566
1883
  async invalidateByPattern(pattern) {
1567
1884
  await this.awaitStartup("invalidateByPattern");
1568
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1885
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1569
1886
  await this.deleteKeys(keys);
1570
1887
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1571
1888
  }
1572
1889
  async invalidateByPrefix(prefix) {
1573
1890
  await this.awaitStartup("invalidateByPrefix");
1574
1891
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1575
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1892
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1576
1893
  await this.deleteKeys(keys);
1577
1894
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1578
1895
  }
@@ -1622,9 +1939,18 @@ var CacheStack = class extends EventEmitter {
1622
1939
  })
1623
1940
  );
1624
1941
  }
1942
+ /**
1943
+ * Rotates the active generation prefix used for all future cache keys.
1944
+ * Previous-generation keys remain in the underlying layers until they expire,
1945
+ * unless `generationCleanup` is enabled to prune them in the background.
1946
+ */
1625
1947
  bumpGeneration(nextGeneration) {
1626
1948
  const current = this.currentGeneration ?? 0;
1949
+ const previousGeneration = this.currentGeneration;
1627
1950
  this.currentGeneration = nextGeneration ?? current + 1;
1951
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1952
+ this.scheduleGenerationCleanup(previousGeneration);
1953
+ }
1628
1954
  return this.currentGeneration;
1629
1955
  }
1630
1956
  /**
@@ -1708,27 +2034,28 @@ var CacheStack = class extends EventEmitter {
1708
2034
  this.assertActive("persistToFile");
1709
2035
  const snapshot = await this.exportState();
1710
2036
  const { promises: fs } = await import("fs");
1711
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
2037
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1712
2038
  }
1713
2039
  async restoreFromFile(filePath) {
1714
2040
  this.assertActive("restoreFromFile");
1715
2041
  const { promises: fs } = await import("fs");
1716
- const raw = await fs.readFile(filePath, "utf8");
2042
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1717
2043
  let parsed;
1718
2044
  try {
1719
- parsed = JSON.parse(raw, (_key, value) => {
1720
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1721
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1722
- }
1723
- return value;
1724
- });
2045
+ parsed = JSON.parse(raw);
1725
2046
  } catch (cause) {
1726
2047
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1727
2048
  }
1728
2049
  if (!this.isCacheSnapshotEntries(parsed)) {
1729
2050
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1730
2051
  }
1731
- await this.importState(parsed);
2052
+ await this.importState(
2053
+ parsed.map((entry) => ({
2054
+ key: entry.key,
2055
+ value: this.sanitizeSnapshotValue(entry.value),
2056
+ ttl: entry.ttl
2057
+ }))
2058
+ );
1732
2059
  }
1733
2060
  async disconnect() {
1734
2061
  if (!this.disconnectPromise) {
@@ -1737,6 +2064,7 @@ var CacheStack = class extends EventEmitter {
1737
2064
  await this.startup;
1738
2065
  await this.unsubscribeInvalidation?.();
1739
2066
  await this.flushWriteBehindQueue();
2067
+ await this.generationCleanupPromise;
1740
2068
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1741
2069
  if (this.writeBehindTimer) {
1742
2070
  clearInterval(this.writeBehindTimer);
@@ -1820,8 +2148,14 @@ var CacheStack = class extends EventEmitter {
1820
2148
  await this.storeEntry(key, "empty", null, options);
1821
2149
  return null;
1822
2150
  }
1823
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1824
- return fetched;
2151
+ if (options?.shouldCache) {
2152
+ try {
2153
+ if (!options.shouldCache(fetched)) {
2154
+ return fetched;
2155
+ }
2156
+ } catch (error) {
2157
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
2158
+ }
1825
2159
  }
1826
2160
  await this.storeEntry(key, "value", fetched, options);
1827
2161
  return fetched;
@@ -2048,7 +2382,7 @@ var CacheStack = class extends EventEmitter {
2048
2382
  const refresh = (async () => {
2049
2383
  this.metricsCollector.increment("refreshes");
2050
2384
  try {
2051
- await this.fetchWithGuards(key, fetcher, options);
2385
+ await this.runBackgroundRefresh(key, fetcher, options);
2052
2386
  } catch (error) {
2053
2387
  this.metricsCollector.increment("refreshErrors");
2054
2388
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2058,6 +2392,16 @@ var CacheStack = class extends EventEmitter {
2058
2392
  })();
2059
2393
  this.backgroundRefreshes.set(key, refresh);
2060
2394
  }
2395
+ async runBackgroundRefresh(key, fetcher, options) {
2396
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2397
+ await this.fetchWithGuards(
2398
+ key,
2399
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2400
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2401
+ }),
2402
+ options
2403
+ );
2404
+ }
2061
2405
  resolveSingleFlightOptions() {
2062
2406
  return {
2063
2407
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -2125,8 +2469,76 @@ var CacheStack = class extends EventEmitter {
2125
2469
  sleep(ms) {
2126
2470
  return new Promise((resolve) => setTimeout(resolve, ms));
2127
2471
  }
2472
+ async withTimeout(promise, timeoutMs, onTimeout) {
2473
+ if (timeoutMs <= 0) {
2474
+ return promise;
2475
+ }
2476
+ let timer;
2477
+ const observedPromise = promise.then(
2478
+ (value) => ({ kind: "value", value }),
2479
+ (error) => ({ kind: "error", error })
2480
+ );
2481
+ try {
2482
+ const result = await Promise.race([
2483
+ observedPromise,
2484
+ new Promise((_, reject) => {
2485
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2486
+ timer.unref?.();
2487
+ })
2488
+ ]);
2489
+ if (result && typeof result === "object" && "kind" in result) {
2490
+ if (result.kind === "error") {
2491
+ throw result.error;
2492
+ }
2493
+ return result.value;
2494
+ }
2495
+ return result;
2496
+ } finally {
2497
+ if (timer) {
2498
+ clearTimeout(timer);
2499
+ }
2500
+ }
2501
+ }
2128
2502
  shouldBroadcastL1Invalidation() {
2129
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2503
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2504
+ }
2505
+ shouldCleanupGenerations() {
2506
+ return Boolean(this.options.generationCleanup);
2507
+ }
2508
+ generationCleanupBatchSize() {
2509
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2510
+ return configured ?? 500;
2511
+ }
2512
+ scheduleGenerationCleanup(generation) {
2513
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2514
+ this.logger.warn?.("generation-cleanup-error", {
2515
+ generation,
2516
+ error: this.formatError(error)
2517
+ });
2518
+ });
2519
+ this.generationCleanupPromise = task.finally(() => {
2520
+ if (this.generationCleanupPromise === task) {
2521
+ this.generationCleanupPromise = void 0;
2522
+ }
2523
+ });
2524
+ }
2525
+ async cleanupGeneration(generation) {
2526
+ const prefix = `v${generation}:`;
2527
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2528
+ if (keys.length === 0) {
2529
+ return;
2530
+ }
2531
+ const batchSize = this.generationCleanupBatchSize();
2532
+ for (let index = 0; index < keys.length; index += batchSize) {
2533
+ const batch = keys.slice(index, index + batchSize);
2534
+ await this.deleteKeys(batch);
2535
+ await this.publishInvalidation({
2536
+ scope: "keys",
2537
+ keys: batch,
2538
+ sourceId: this.instanceId,
2539
+ operation: "invalidate"
2540
+ });
2541
+ }
2130
2542
  }
2131
2543
  initializeWriteBehind(options) {
2132
2544
  if (this.options.writeStrategy !== "write-behind") {
@@ -2164,7 +2576,17 @@ var CacheStack = class extends EventEmitter {
2164
2576
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2165
2577
  const batch = this.writeBehindQueue.splice(0, batchSize);
2166
2578
  this.writeBehindFlushPromise = (async () => {
2167
- await Promise.allSettled(batch.map((operation) => operation()));
2579
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2580
+ const failures = results.filter((result) => result.status === "rejected");
2581
+ if (failures.length > 0) {
2582
+ this.metricsCollector.increment("writeFailures", failures.length);
2583
+ this.logger.error?.("write-behind-flush-failure", {
2584
+ failed: failures.length,
2585
+ total: batch.length,
2586
+ errors: failures.map((failure) => this.formatError(failure.reason))
2587
+ });
2588
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2589
+ }
2168
2590
  })();
2169
2591
  await this.writeBehindFlushPromise;
2170
2592
  this.writeBehindFlushPromise = void 0;
@@ -2269,9 +2691,13 @@ var CacheStack = class extends EventEmitter {
2269
2691
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2270
2692
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2271
2693
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2694
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2272
2695
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2273
2696
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2274
2697
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2698
+ if (typeof this.options.generationCleanup === "object") {
2699
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2700
+ }
2275
2701
  if (this.options.generation !== void 0) {
2276
2702
  this.validateNonNegativeNumber("generation", this.options.generation);
2277
2703
  }
@@ -2343,6 +2769,9 @@ var CacheStack = class extends EventEmitter {
2343
2769
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2344
2770
  throw new Error("Cache key contains unsupported control characters.");
2345
2771
  }
2772
+ if (/[\uD800-\uDFFF]/.test(key)) {
2773
+ throw new Error("Cache key contains unsupported surrogate code points.");
2774
+ }
2346
2775
  return key;
2347
2776
  }
2348
2777
  validateTtlPolicy(name, policy) {
@@ -2420,6 +2849,14 @@ var CacheStack = class extends EventEmitter {
2420
2849
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2421
2850
  return null;
2422
2851
  }
2852
+ async reportRecoverableLayerFailure(layer, operation, error) {
2853
+ if (this.isGracefulDegradationEnabled()) {
2854
+ await this.handleLayerFailure(layer, operation, error);
2855
+ return;
2856
+ }
2857
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2858
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2859
+ }
2423
2860
  isGracefulDegradationEnabled() {
2424
2861
  return Boolean(this.options.gracefulDegradation);
2425
2862
  }
@@ -2443,10 +2880,16 @@ var CacheStack = class extends EventEmitter {
2443
2880
  }
2444
2881
  }
2445
2882
  serializeKeyPart(value) {
2446
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2447
- return String(value);
2883
+ if (typeof value === "string") {
2884
+ return `s:${value}`;
2885
+ }
2886
+ if (typeof value === "number") {
2887
+ return `n:${value}`;
2448
2888
  }
2449
- return JSON.stringify(this.normalizeForSerialization(value));
2889
+ if (typeof value === "boolean") {
2890
+ return `b:${value}`;
2891
+ }
2892
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2450
2893
  }
2451
2894
  isCacheSnapshotEntries(value) {
2452
2895
  return Array.isArray(value) && value.every((entry) => {
@@ -2454,15 +2897,39 @@ var CacheStack = class extends EventEmitter {
2454
2897
  return false;
2455
2898
  }
2456
2899
  const candidate = entry;
2457
- return typeof candidate.key === "string";
2900
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2458
2901
  });
2459
2902
  }
2903
+ sanitizeSnapshotValue(value) {
2904
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2905
+ }
2906
+ async validateSnapshotFilePath(filePath) {
2907
+ if (filePath.length === 0) {
2908
+ throw new Error("filePath must not be empty.");
2909
+ }
2910
+ if (filePath.includes("\0")) {
2911
+ throw new Error("filePath must not contain null bytes.");
2912
+ }
2913
+ const path = await import("path");
2914
+ const resolved = path.resolve(filePath);
2915
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2916
+ if (baseDir !== false) {
2917
+ const relative = path.relative(baseDir, resolved);
2918
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2919
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2920
+ }
2921
+ }
2922
+ return resolved;
2923
+ }
2460
2924
  normalizeForSerialization(value) {
2461
2925
  if (Array.isArray(value)) {
2462
2926
  return value.map((entry) => this.normalizeForSerialization(entry));
2463
2927
  }
2464
2928
  if (value && typeof value === "object") {
2465
2929
  return Object.keys(value).sort().reduce((normalized, key) => {
2930
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2931
+ return normalized;
2932
+ }
2466
2933
  normalized[key] = this.normalizeForSerialization(value[key]);
2467
2934
  return normalized;
2468
2935
  }, {});