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.
@@ -504,6 +504,107 @@ function addMap(base, delta) {
504
504
  return result;
505
505
  }
506
506
 
507
+ // ../../src/invalidation/PatternMatcher.ts
508
+ var PatternMatcher = class _PatternMatcher {
509
+ /**
510
+ * Tests whether a glob-style pattern matches a value.
511
+ * Supports `*` (any sequence of characters) and `?` (any single character).
512
+ * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
513
+ * quadratic memory usage on long patterns/keys.
514
+ */
515
+ static matches(pattern, value) {
516
+ return _PatternMatcher.matchLinear(pattern, value);
517
+ }
518
+ /**
519
+ * Linear-time glob matching with O(1) extra memory.
520
+ */
521
+ static matchLinear(pattern, value) {
522
+ let patternIndex = 0;
523
+ let valueIndex = 0;
524
+ let starIndex = -1;
525
+ let backtrackValueIndex = 0;
526
+ while (valueIndex < value.length) {
527
+ const patternChar = pattern[patternIndex];
528
+ const valueChar = value[valueIndex];
529
+ if (patternChar === "*" && patternIndex < pattern.length) {
530
+ starIndex = patternIndex;
531
+ patternIndex += 1;
532
+ backtrackValueIndex = valueIndex;
533
+ continue;
534
+ }
535
+ if (patternChar === "?" || patternChar === valueChar) {
536
+ patternIndex += 1;
537
+ valueIndex += 1;
538
+ continue;
539
+ }
540
+ if (starIndex !== -1) {
541
+ patternIndex = starIndex + 1;
542
+ backtrackValueIndex += 1;
543
+ valueIndex = backtrackValueIndex;
544
+ continue;
545
+ }
546
+ return false;
547
+ }
548
+ while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
549
+ patternIndex += 1;
550
+ }
551
+ return patternIndex === pattern.length;
552
+ }
553
+ };
554
+
555
+ // ../../src/internal/CacheKeyDiscovery.ts
556
+ var CacheKeyDiscovery = class {
557
+ constructor(options) {
558
+ this.options = options;
559
+ }
560
+ options;
561
+ async collectKeysWithPrefix(prefix) {
562
+ const { tagIndex } = this.options;
563
+ const matches = new Set(
564
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
565
+ );
566
+ await Promise.all(
567
+ this.options.layers.map(async (layer) => {
568
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
569
+ return;
570
+ }
571
+ try {
572
+ const keys = await layer.keys();
573
+ for (const key of keys) {
574
+ if (key.startsWith(prefix)) {
575
+ matches.add(key);
576
+ }
577
+ }
578
+ } catch (error) {
579
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
580
+ }
581
+ })
582
+ );
583
+ return [...matches];
584
+ }
585
+ async collectKeysMatchingPattern(pattern) {
586
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
587
+ await Promise.all(
588
+ this.options.layers.map(async (layer) => {
589
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
590
+ return;
591
+ }
592
+ try {
593
+ const keys = await layer.keys();
594
+ for (const key of keys) {
595
+ if (PatternMatcher.matches(pattern, key)) {
596
+ matches.add(key);
597
+ }
598
+ }
599
+ } catch (error) {
600
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
601
+ }
602
+ })
603
+ );
604
+ return [...matches];
605
+ }
606
+ };
607
+
507
608
  // ../../src/internal/CircuitBreakerManager.ts
508
609
  var CircuitBreakerManager = class {
509
610
  breakers = /* @__PURE__ */ new Map();
@@ -598,8 +699,9 @@ var CircuitBreakerManager = class {
598
699
 
599
700
  // ../../src/internal/FetchRateLimiter.ts
600
701
  var FetchRateLimiter = class {
601
- queue = [];
602
702
  buckets = /* @__PURE__ */ new Map();
703
+ queuesByBucket = /* @__PURE__ */ new Map();
704
+ pendingBuckets = /* @__PURE__ */ new Set();
603
705
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
604
706
  nextFetcherBucketId = 0;
605
707
  drainTimer;
@@ -612,13 +714,17 @@ var FetchRateLimiter = class {
612
714
  return task();
613
715
  }
614
716
  return new Promise((resolve, reject) => {
615
- this.queue.push({
616
- bucketKey: this.resolveBucketKey(normalized, context),
717
+ const bucketKey = this.resolveBucketKey(normalized, context);
718
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
719
+ queue.push({
720
+ bucketKey,
617
721
  options: normalized,
618
722
  task,
619
723
  resolve,
620
724
  reject
621
725
  });
726
+ this.queuesByBucket.set(bucketKey, queue);
727
+ this.pendingBuckets.add(bucketKey);
622
728
  this.drain();
623
729
  });
624
730
  }
@@ -661,22 +767,30 @@ var FetchRateLimiter = class {
661
767
  clearTimeout(this.drainTimer);
662
768
  this.drainTimer = void 0;
663
769
  }
664
- while (this.queue.length > 0) {
665
- let nextIndex = -1;
770
+ while (this.pendingBuckets.size > 0) {
771
+ let nextBucketKey;
666
772
  let nextWaitMs = Number.POSITIVE_INFINITY;
667
- for (let index = 0; index < this.queue.length; index += 1) {
668
- const next2 = this.queue[index];
773
+ for (const bucketKey of this.pendingBuckets) {
774
+ const queue2 = this.queuesByBucket.get(bucketKey);
775
+ if (!queue2 || queue2.length === 0) {
776
+ this.pendingBuckets.delete(bucketKey);
777
+ this.queuesByBucket.delete(bucketKey);
778
+ continue;
779
+ }
780
+ const next2 = queue2[0];
669
781
  if (!next2) {
782
+ this.pendingBuckets.delete(bucketKey);
783
+ this.queuesByBucket.delete(bucketKey);
670
784
  continue;
671
785
  }
672
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
786
+ const waitMs = this.waitTime(bucketKey, next2.options);
673
787
  if (waitMs <= 0) {
674
- nextIndex = index;
788
+ nextBucketKey = bucketKey;
675
789
  break;
676
790
  }
677
791
  nextWaitMs = Math.min(nextWaitMs, waitMs);
678
792
  }
679
- if (nextIndex < 0) {
793
+ if (!nextBucketKey) {
680
794
  if (Number.isFinite(nextWaitMs)) {
681
795
  this.drainTimer = setTimeout(() => {
682
796
  this.drainTimer = void 0;
@@ -686,15 +800,32 @@ var FetchRateLimiter = class {
686
800
  }
687
801
  return;
688
802
  }
689
- const next = this.queue.splice(nextIndex, 1)[0];
803
+ const queue = this.queuesByBucket.get(nextBucketKey);
804
+ const next = queue?.shift();
690
805
  if (!next) {
691
- return;
806
+ this.pendingBuckets.delete(nextBucketKey);
807
+ this.queuesByBucket.delete(nextBucketKey);
808
+ continue;
809
+ }
810
+ if (!queue || queue.length === 0) {
811
+ this.pendingBuckets.delete(nextBucketKey);
812
+ this.queuesByBucket.delete(nextBucketKey);
692
813
  }
693
814
  const bucket = this.bucketState(next.bucketKey);
815
+ if (bucket.cleanupTimer) {
816
+ clearTimeout(bucket.cleanupTimer);
817
+ bucket.cleanupTimer = void 0;
818
+ }
694
819
  bucket.active += 1;
695
- bucket.startedAt.push(Date.now());
820
+ if (next.options.intervalMs && next.options.maxPerInterval) {
821
+ bucket.startedAt.push(Date.now());
822
+ }
696
823
  void next.task().then(next.resolve, next.reject).finally(() => {
697
824
  bucket.active -= 1;
825
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
826
+ this.pendingBuckets.add(next.bucketKey);
827
+ }
828
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
698
829
  this.drain();
699
830
  });
700
831
  }
@@ -736,6 +867,31 @@ var FetchRateLimiter = class {
736
867
  this.buckets.set(bucketKey, bucket);
737
868
  return bucket;
738
869
  }
870
+ cleanupBucket(bucketKey, bucket, intervalMs) {
871
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
872
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
873
+ this.buckets.delete(bucketKey);
874
+ this.queuesByBucket.delete(bucketKey);
875
+ this.pendingBuckets.delete(bucketKey);
876
+ return;
877
+ }
878
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
879
+ return;
880
+ }
881
+ if (bucket.cleanupTimer) {
882
+ clearTimeout(bucket.cleanupTimer);
883
+ }
884
+ bucket.cleanupTimer = setTimeout(() => {
885
+ bucket.cleanupTimer = void 0;
886
+ this.prune(bucket, Date.now(), intervalMs);
887
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
888
+ this.buckets.delete(bucketKey);
889
+ this.queuesByBucket.delete(bucketKey);
890
+ this.pendingBuckets.delete(bucketKey);
891
+ }
892
+ }, intervalMs);
893
+ bucket.cleanupTimer.unref?.();
894
+ }
739
895
  };
740
896
 
741
897
  // ../../src/internal/MetricsCollector.ts
@@ -814,7 +970,30 @@ var MetricsCollector = class {
814
970
 
815
971
  // ../../src/internal/StoredValue.ts
816
972
  function isStoredValueEnvelope(value) {
817
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
973
+ if (typeof value !== "object" || value === null) {
974
+ return false;
975
+ }
976
+ const v = value;
977
+ if (v.__layercache !== 1) {
978
+ return false;
979
+ }
980
+ if (v.kind !== "value" && v.kind !== "empty") {
981
+ return false;
982
+ }
983
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
984
+ return false;
985
+ }
986
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
987
+ return false;
988
+ }
989
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
990
+ return false;
991
+ }
992
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
993
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
994
+ return false;
995
+ }
996
+ return true;
818
997
  }
819
998
  function createStoredValueEnvelope(options) {
820
999
  const now = options.now ?? Date.now();
@@ -1025,69 +1204,23 @@ var TtlResolver = class {
1025
1204
  }
1026
1205
  };
1027
1206
 
1028
- // ../../src/invalidation/PatternMatcher.ts
1029
- var PatternMatcher = class _PatternMatcher {
1030
- /**
1031
- * Tests whether a glob-style pattern matches a value.
1032
- * Supports `*` (any sequence of characters) and `?` (any single character).
1033
- * Uses a two-pointer algorithm to avoid ReDoS vulnerabilities and
1034
- * quadratic memory usage on long patterns/keys.
1035
- */
1036
- static matches(pattern, value) {
1037
- return _PatternMatcher.matchLinear(pattern, value);
1038
- }
1039
- /**
1040
- * Linear-time glob matching with O(1) extra memory.
1041
- */
1042
- static matchLinear(pattern, value) {
1043
- let patternIndex = 0;
1044
- let valueIndex = 0;
1045
- let starIndex = -1;
1046
- let backtrackValueIndex = 0;
1047
- while (valueIndex < value.length) {
1048
- const patternChar = pattern[patternIndex];
1049
- const valueChar = value[valueIndex];
1050
- if (patternChar === "*" && patternIndex < pattern.length) {
1051
- starIndex = patternIndex;
1052
- patternIndex += 1;
1053
- backtrackValueIndex = valueIndex;
1054
- continue;
1055
- }
1056
- if (patternChar === "?" || patternChar === valueChar) {
1057
- patternIndex += 1;
1058
- valueIndex += 1;
1059
- continue;
1060
- }
1061
- if (starIndex !== -1) {
1062
- patternIndex = starIndex + 1;
1063
- backtrackValueIndex += 1;
1064
- valueIndex = backtrackValueIndex;
1065
- continue;
1066
- }
1067
- return false;
1068
- }
1069
- while (patternIndex < pattern.length && pattern[patternIndex] === "*") {
1070
- patternIndex += 1;
1071
- }
1072
- return patternIndex === pattern.length;
1073
- }
1074
- };
1075
-
1076
1207
  // ../../src/invalidation/TagIndex.ts
1077
1208
  var TagIndex = class {
1078
1209
  tagToKeys = /* @__PURE__ */ new Map();
1079
1210
  keyToTags = /* @__PURE__ */ new Map();
1080
1211
  knownKeys = /* @__PURE__ */ new Set();
1081
1212
  maxKnownKeys;
1213
+ nextNodeId = 1;
1214
+ root = this.createTrieNode();
1082
1215
  constructor(options = {}) {
1083
- this.maxKnownKeys = options.maxKnownKeys;
1216
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
1084
1217
  }
1085
1218
  async touch(key) {
1086
- this.knownKeys.add(key);
1219
+ this.insertKnownKey(key);
1087
1220
  this.pruneKnownKeysIfNeeded();
1088
1221
  }
1089
1222
  async track(key, tags) {
1090
- this.knownKeys.add(key);
1223
+ this.insertKnownKey(key);
1091
1224
  this.pruneKnownKeysIfNeeded();
1092
1225
  if (tags.length === 0) {
1093
1226
  return;
@@ -1113,18 +1246,104 @@ var TagIndex = class {
1113
1246
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1114
1247
  }
1115
1248
  async keysForPrefix(prefix) {
1116
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
1249
+ const node = this.findNode(prefix);
1250
+ if (!node) {
1251
+ return [];
1252
+ }
1253
+ const matches = [];
1254
+ this.collectFromNode(node, prefix, matches);
1255
+ return matches;
1117
1256
  }
1118
1257
  async tagsForKey(key) {
1119
1258
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1120
1259
  }
1121
1260
  async matchPattern(pattern) {
1122
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1261
+ const matches = /* @__PURE__ */ new Set();
1262
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1263
+ return [...matches];
1123
1264
  }
1124
1265
  async clear() {
1125
1266
  this.tagToKeys.clear();
1126
1267
  this.keyToTags.clear();
1127
1268
  this.knownKeys.clear();
1269
+ this.root.children.clear();
1270
+ this.root.terminal = false;
1271
+ this.nextNodeId = this.root.id + 1;
1272
+ }
1273
+ createTrieNode() {
1274
+ return {
1275
+ id: this.nextNodeId++,
1276
+ terminal: false,
1277
+ children: /* @__PURE__ */ new Map()
1278
+ };
1279
+ }
1280
+ insertKnownKey(key) {
1281
+ if (this.knownKeys.has(key)) {
1282
+ return;
1283
+ }
1284
+ this.knownKeys.add(key);
1285
+ let node = this.root;
1286
+ for (const character of key) {
1287
+ let child = node.children.get(character);
1288
+ if (!child) {
1289
+ child = this.createTrieNode();
1290
+ node.children.set(character, child);
1291
+ }
1292
+ node = child;
1293
+ }
1294
+ node.terminal = true;
1295
+ }
1296
+ findNode(prefix) {
1297
+ let node = this.root;
1298
+ for (const character of prefix) {
1299
+ node = node.children.get(character);
1300
+ if (!node) {
1301
+ return void 0;
1302
+ }
1303
+ }
1304
+ return node;
1305
+ }
1306
+ collectFromNode(node, prefix, matches) {
1307
+ if (node.terminal) {
1308
+ matches.push(prefix);
1309
+ }
1310
+ for (const [character, child] of node.children) {
1311
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1312
+ }
1313
+ }
1314
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1315
+ const stateKey = `${node.id}:${patternIndex}`;
1316
+ if (visited.has(stateKey)) {
1317
+ return;
1318
+ }
1319
+ visited.add(stateKey);
1320
+ if (patternIndex === pattern.length) {
1321
+ if (node.terminal) {
1322
+ matches.add(prefix);
1323
+ }
1324
+ return;
1325
+ }
1326
+ const patternChar = pattern[patternIndex];
1327
+ if (patternChar === void 0) {
1328
+ return;
1329
+ }
1330
+ if (patternChar === "*") {
1331
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1332
+ for (const [character, child2] of node.children) {
1333
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1334
+ }
1335
+ return;
1336
+ }
1337
+ if (patternChar === "?") {
1338
+ for (const [character, child2] of node.children) {
1339
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1340
+ }
1341
+ return;
1342
+ }
1343
+ const child = node.children.get(patternChar);
1344
+ if (child) {
1345
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1346
+ }
1128
1347
  }
1129
1348
  pruneKnownKeysIfNeeded() {
1130
1349
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1141,7 +1360,7 @@ var TagIndex = class {
1141
1360
  }
1142
1361
  }
1143
1362
  removeKey(key) {
1144
- this.knownKeys.delete(key);
1363
+ this.removeKnownKey(key);
1145
1364
  const tags = this.keyToTags.get(key);
1146
1365
  if (!tags) {
1147
1366
  return;
@@ -1158,7 +1377,70 @@ var TagIndex = class {
1158
1377
  }
1159
1378
  this.keyToTags.delete(key);
1160
1379
  }
1380
+ removeKnownKey(key) {
1381
+ if (!this.knownKeys.delete(key)) {
1382
+ return;
1383
+ }
1384
+ const path = [];
1385
+ let node = this.root;
1386
+ for (const character of key) {
1387
+ const child = node.children.get(character);
1388
+ if (!child) {
1389
+ return;
1390
+ }
1391
+ path.push([node, character]);
1392
+ node = child;
1393
+ }
1394
+ node.terminal = false;
1395
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1396
+ const entry = path[index];
1397
+ if (!entry) {
1398
+ continue;
1399
+ }
1400
+ const [parent, character] = entry;
1401
+ const child = parent.children.get(character);
1402
+ if (!child || child.terminal || child.children.size > 0) {
1403
+ break;
1404
+ }
1405
+ parent.children.delete(character);
1406
+ }
1407
+ }
1408
+ };
1409
+
1410
+ // ../../src/serialization/JsonSerializer.ts
1411
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1412
+ var JsonSerializer = class {
1413
+ serialize(value) {
1414
+ return JSON.stringify(value);
1415
+ }
1416
+ deserialize(payload) {
1417
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1418
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1419
+ }
1161
1420
  };
1421
+ var MAX_SANITIZE_DEPTH = 200;
1422
+ function sanitizeJsonValue(value, depth) {
1423
+ if (depth > MAX_SANITIZE_DEPTH) {
1424
+ return value;
1425
+ }
1426
+ if (Array.isArray(value)) {
1427
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1428
+ }
1429
+ if (!isPlainObject(value)) {
1430
+ return value;
1431
+ }
1432
+ const sanitized = {};
1433
+ for (const [key, entry] of Object.entries(value)) {
1434
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1435
+ continue;
1436
+ }
1437
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1438
+ }
1439
+ return sanitized;
1440
+ }
1441
+ function isPlainObject(value) {
1442
+ return Object.prototype.toString.call(value) === "[object Object]";
1443
+ }
1162
1444
 
1163
1445
  // ../../src/stampede/StampedeGuard.ts
1164
1446
  var StampedeGuard = class {
@@ -1169,7 +1451,8 @@ var StampedeGuard = class {
1169
1451
  return await entry.mutex.runExclusive(task);
1170
1452
  } finally {
1171
1453
  entry.references -= 1;
1172
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1454
+ const current = this.mutexes.get(key);
1455
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1173
1456
  this.mutexes.delete(key);
1174
1457
  }
1175
1458
  }
@@ -1199,8 +1482,10 @@ var CacheMissError = class extends Error {
1199
1482
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1200
1483
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1201
1484
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1485
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1202
1486
  var MAX_CACHE_KEY_LENGTH = 1024;
1203
1487
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1488
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1204
1489
  var DebugLogger = class {
1205
1490
  enabled;
1206
1491
  constructor(enabled) {
@@ -1247,6 +1532,29 @@ var CacheStack = class extends import_node_events.EventEmitter {
1247
1532
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1248
1533
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1249
1534
  this.tagIndex = options.tagIndex ?? new TagIndex();
1535
+ this.keyDiscovery = new CacheKeyDiscovery({
1536
+ layers: this.layers,
1537
+ tagIndex: this.tagIndex,
1538
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1539
+ handleLayerFailure: async (layer, operation, error) => {
1540
+ await this.handleLayerFailure(layer, operation, error);
1541
+ }
1542
+ });
1543
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1544
+ this.logger.warn?.(
1545
+ "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."
1546
+ );
1547
+ }
1548
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1549
+ this.logger.warn?.(
1550
+ "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."
1551
+ );
1552
+ }
1553
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1554
+ this.logger.warn?.(
1555
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1556
+ );
1557
+ }
1250
1558
  this.initializeWriteBehind(options.writeBehind);
1251
1559
  this.startup = this.initialize();
1252
1560
  }
@@ -1259,7 +1567,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1259
1567
  unsubscribeInvalidation;
1260
1568
  logger;
1261
1569
  tagIndex;
1570
+ keyDiscovery;
1262
1571
  fetchRateLimiter = new FetchRateLimiter();
1572
+ snapshotSerializer = new JsonSerializer();
1263
1573
  backgroundRefreshes = /* @__PURE__ */ new Map();
1264
1574
  layerDegradedUntil = /* @__PURE__ */ new Map();
1265
1575
  ttlResolver;
@@ -1268,6 +1578,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1268
1578
  writeBehindQueue = [];
1269
1579
  writeBehindTimer;
1270
1580
  writeBehindFlushPromise;
1581
+ generationCleanupPromise;
1271
1582
  isDisconnecting = false;
1272
1583
  disconnectPromise;
1273
1584
  /**
@@ -1280,6 +1591,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1280
1591
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1281
1592
  this.validateWriteOptions(options);
1282
1593
  await this.awaitStartup("get");
1594
+ return this.getPrepared(normalizedKey, fetcher, options);
1595
+ }
1596
+ async getPrepared(normalizedKey, fetcher, options) {
1283
1597
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1284
1598
  if (hit.found) {
1285
1599
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1357,6 +1671,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1357
1671
  return true;
1358
1672
  }
1359
1673
  } catch {
1674
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1360
1675
  }
1361
1676
  } else {
1362
1677
  try {
@@ -1364,7 +1679,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
1364
1679
  if (value !== null) {
1365
1680
  return true;
1366
1681
  }
1367
- } catch {
1682
+ } catch (error) {
1683
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1368
1684
  }
1369
1685
  }
1370
1686
  }
@@ -1456,13 +1772,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1456
1772
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1457
1773
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1458
1774
  if (!canFastPath) {
1775
+ await this.awaitStartup("mget");
1459
1776
  const pendingReads = /* @__PURE__ */ new Map();
1460
1777
  return Promise.all(
1461
1778
  normalizedEntries.map((entry) => {
1462
1779
  const optionsSignature = this.serializeOptions(entry.options);
1463
1780
  const existing = pendingReads.get(entry.key);
1464
1781
  if (!existing) {
1465
- const promise = this.get(entry.key, entry.fetch, entry.options);
1782
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1466
1783
  pendingReads.set(entry.key, {
1467
1784
  promise,
1468
1785
  fetch: entry.fetch,
@@ -1601,14 +1918,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1601
1918
  }
1602
1919
  async invalidateByPattern(pattern) {
1603
1920
  await this.awaitStartup("invalidateByPattern");
1604
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1921
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1605
1922
  await this.deleteKeys(keys);
1606
1923
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1607
1924
  }
1608
1925
  async invalidateByPrefix(prefix) {
1609
1926
  await this.awaitStartup("invalidateByPrefix");
1610
1927
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1611
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1928
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1612
1929
  await this.deleteKeys(keys);
1613
1930
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1614
1931
  }
@@ -1658,9 +1975,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
1658
1975
  })
1659
1976
  );
1660
1977
  }
1978
+ /**
1979
+ * Rotates the active generation prefix used for all future cache keys.
1980
+ * Previous-generation keys remain in the underlying layers until they expire,
1981
+ * unless `generationCleanup` is enabled to prune them in the background.
1982
+ */
1661
1983
  bumpGeneration(nextGeneration) {
1662
1984
  const current = this.currentGeneration ?? 0;
1985
+ const previousGeneration = this.currentGeneration;
1663
1986
  this.currentGeneration = nextGeneration ?? current + 1;
1987
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1988
+ this.scheduleGenerationCleanup(previousGeneration);
1989
+ }
1664
1990
  return this.currentGeneration;
1665
1991
  }
1666
1992
  /**
@@ -1744,27 +2070,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
1744
2070
  this.assertActive("persistToFile");
1745
2071
  const snapshot = await this.exportState();
1746
2072
  const { promises: fs } = await import("fs");
1747
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
2073
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1748
2074
  }
1749
2075
  async restoreFromFile(filePath) {
1750
2076
  this.assertActive("restoreFromFile");
1751
2077
  const { promises: fs } = await import("fs");
1752
- const raw = await fs.readFile(filePath, "utf8");
2078
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1753
2079
  let parsed;
1754
2080
  try {
1755
- parsed = JSON.parse(raw, (_key, value) => {
1756
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1757
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1758
- }
1759
- return value;
1760
- });
2081
+ parsed = JSON.parse(raw);
1761
2082
  } catch (cause) {
1762
2083
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1763
2084
  }
1764
2085
  if (!this.isCacheSnapshotEntries(parsed)) {
1765
2086
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1766
2087
  }
1767
- await this.importState(parsed);
2088
+ await this.importState(
2089
+ parsed.map((entry) => ({
2090
+ key: entry.key,
2091
+ value: this.sanitizeSnapshotValue(entry.value),
2092
+ ttl: entry.ttl
2093
+ }))
2094
+ );
1768
2095
  }
1769
2096
  async disconnect() {
1770
2097
  if (!this.disconnectPromise) {
@@ -1773,6 +2100,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1773
2100
  await this.startup;
1774
2101
  await this.unsubscribeInvalidation?.();
1775
2102
  await this.flushWriteBehindQueue();
2103
+ await this.generationCleanupPromise;
1776
2104
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1777
2105
  if (this.writeBehindTimer) {
1778
2106
  clearInterval(this.writeBehindTimer);
@@ -1856,8 +2184,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1856
2184
  await this.storeEntry(key, "empty", null, options);
1857
2185
  return null;
1858
2186
  }
1859
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1860
- return fetched;
2187
+ if (options?.shouldCache) {
2188
+ try {
2189
+ if (!options.shouldCache(fetched)) {
2190
+ return fetched;
2191
+ }
2192
+ } catch (error) {
2193
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
2194
+ }
1861
2195
  }
1862
2196
  await this.storeEntry(key, "value", fetched, options);
1863
2197
  return fetched;
@@ -2084,7 +2418,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2084
2418
  const refresh = (async () => {
2085
2419
  this.metricsCollector.increment("refreshes");
2086
2420
  try {
2087
- await this.fetchWithGuards(key, fetcher, options);
2421
+ await this.runBackgroundRefresh(key, fetcher, options);
2088
2422
  } catch (error) {
2089
2423
  this.metricsCollector.increment("refreshErrors");
2090
2424
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2094,6 +2428,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2094
2428
  })();
2095
2429
  this.backgroundRefreshes.set(key, refresh);
2096
2430
  }
2431
+ async runBackgroundRefresh(key, fetcher, options) {
2432
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2433
+ await this.fetchWithGuards(
2434
+ key,
2435
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2436
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2437
+ }),
2438
+ options
2439
+ );
2440
+ }
2097
2441
  resolveSingleFlightOptions() {
2098
2442
  return {
2099
2443
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -2161,8 +2505,76 @@ var CacheStack = class extends import_node_events.EventEmitter {
2161
2505
  sleep(ms) {
2162
2506
  return new Promise((resolve) => setTimeout(resolve, ms));
2163
2507
  }
2508
+ async withTimeout(promise, timeoutMs, onTimeout) {
2509
+ if (timeoutMs <= 0) {
2510
+ return promise;
2511
+ }
2512
+ let timer;
2513
+ const observedPromise = promise.then(
2514
+ (value) => ({ kind: "value", value }),
2515
+ (error) => ({ kind: "error", error })
2516
+ );
2517
+ try {
2518
+ const result = await Promise.race([
2519
+ observedPromise,
2520
+ new Promise((_, reject) => {
2521
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2522
+ timer.unref?.();
2523
+ })
2524
+ ]);
2525
+ if (result && typeof result === "object" && "kind" in result) {
2526
+ if (result.kind === "error") {
2527
+ throw result.error;
2528
+ }
2529
+ return result.value;
2530
+ }
2531
+ return result;
2532
+ } finally {
2533
+ if (timer) {
2534
+ clearTimeout(timer);
2535
+ }
2536
+ }
2537
+ }
2164
2538
  shouldBroadcastL1Invalidation() {
2165
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2539
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2540
+ }
2541
+ shouldCleanupGenerations() {
2542
+ return Boolean(this.options.generationCleanup);
2543
+ }
2544
+ generationCleanupBatchSize() {
2545
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2546
+ return configured ?? 500;
2547
+ }
2548
+ scheduleGenerationCleanup(generation) {
2549
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2550
+ this.logger.warn?.("generation-cleanup-error", {
2551
+ generation,
2552
+ error: this.formatError(error)
2553
+ });
2554
+ });
2555
+ this.generationCleanupPromise = task.finally(() => {
2556
+ if (this.generationCleanupPromise === task) {
2557
+ this.generationCleanupPromise = void 0;
2558
+ }
2559
+ });
2560
+ }
2561
+ async cleanupGeneration(generation) {
2562
+ const prefix = `v${generation}:`;
2563
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
2564
+ if (keys.length === 0) {
2565
+ return;
2566
+ }
2567
+ const batchSize = this.generationCleanupBatchSize();
2568
+ for (let index = 0; index < keys.length; index += batchSize) {
2569
+ const batch = keys.slice(index, index + batchSize);
2570
+ await this.deleteKeys(batch);
2571
+ await this.publishInvalidation({
2572
+ scope: "keys",
2573
+ keys: batch,
2574
+ sourceId: this.instanceId,
2575
+ operation: "invalidate"
2576
+ });
2577
+ }
2166
2578
  }
2167
2579
  initializeWriteBehind(options) {
2168
2580
  if (this.options.writeStrategy !== "write-behind") {
@@ -2200,7 +2612,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
2200
2612
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2201
2613
  const batch = this.writeBehindQueue.splice(0, batchSize);
2202
2614
  this.writeBehindFlushPromise = (async () => {
2203
- await Promise.allSettled(batch.map((operation) => operation()));
2615
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2616
+ const failures = results.filter((result) => result.status === "rejected");
2617
+ if (failures.length > 0) {
2618
+ this.metricsCollector.increment("writeFailures", failures.length);
2619
+ this.logger.error?.("write-behind-flush-failure", {
2620
+ failed: failures.length,
2621
+ total: batch.length,
2622
+ errors: failures.map((failure) => this.formatError(failure.reason))
2623
+ });
2624
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2625
+ }
2204
2626
  })();
2205
2627
  await this.writeBehindFlushPromise;
2206
2628
  this.writeBehindFlushPromise = void 0;
@@ -2305,9 +2727,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
2305
2727
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2306
2728
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2307
2729
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2730
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2308
2731
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2309
2732
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2310
2733
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2734
+ if (typeof this.options.generationCleanup === "object") {
2735
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2736
+ }
2311
2737
  if (this.options.generation !== void 0) {
2312
2738
  this.validateNonNegativeNumber("generation", this.options.generation);
2313
2739
  }
@@ -2379,6 +2805,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2379
2805
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2380
2806
  throw new Error("Cache key contains unsupported control characters.");
2381
2807
  }
2808
+ if (/[\uD800-\uDFFF]/.test(key)) {
2809
+ throw new Error("Cache key contains unsupported surrogate code points.");
2810
+ }
2382
2811
  return key;
2383
2812
  }
2384
2813
  validateTtlPolicy(name, policy) {
@@ -2456,6 +2885,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2456
2885
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2457
2886
  return null;
2458
2887
  }
2888
+ async reportRecoverableLayerFailure(layer, operation, error) {
2889
+ if (this.isGracefulDegradationEnabled()) {
2890
+ await this.handleLayerFailure(layer, operation, error);
2891
+ return;
2892
+ }
2893
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2894
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2895
+ }
2459
2896
  isGracefulDegradationEnabled() {
2460
2897
  return Boolean(this.options.gracefulDegradation);
2461
2898
  }
@@ -2479,10 +2916,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2479
2916
  }
2480
2917
  }
2481
2918
  serializeKeyPart(value) {
2482
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2483
- return String(value);
2919
+ if (typeof value === "string") {
2920
+ return `s:${value}`;
2921
+ }
2922
+ if (typeof value === "number") {
2923
+ return `n:${value}`;
2484
2924
  }
2485
- return JSON.stringify(this.normalizeForSerialization(value));
2925
+ if (typeof value === "boolean") {
2926
+ return `b:${value}`;
2927
+ }
2928
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2486
2929
  }
2487
2930
  isCacheSnapshotEntries(value) {
2488
2931
  return Array.isArray(value) && value.every((entry) => {
@@ -2490,15 +2933,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
2490
2933
  return false;
2491
2934
  }
2492
2935
  const candidate = entry;
2493
- return typeof candidate.key === "string";
2936
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2494
2937
  });
2495
2938
  }
2939
+ sanitizeSnapshotValue(value) {
2940
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2941
+ }
2942
+ async validateSnapshotFilePath(filePath) {
2943
+ if (filePath.length === 0) {
2944
+ throw new Error("filePath must not be empty.");
2945
+ }
2946
+ if (filePath.includes("\0")) {
2947
+ throw new Error("filePath must not contain null bytes.");
2948
+ }
2949
+ const path = await import("path");
2950
+ const resolved = path.resolve(filePath);
2951
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2952
+ if (baseDir !== false) {
2953
+ const relative = path.relative(baseDir, resolved);
2954
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2955
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2956
+ }
2957
+ }
2958
+ return resolved;
2959
+ }
2496
2960
  normalizeForSerialization(value) {
2497
2961
  if (Array.isArray(value)) {
2498
2962
  return value.map((entry) => this.normalizeForSerialization(entry));
2499
2963
  }
2500
2964
  if (value && typeof value === "object") {
2501
2965
  return Object.keys(value).sort().reduce((normalized, key) => {
2966
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2967
+ return normalized;
2968
+ }
2502
2969
  normalized[key] = this.normalizeForSerialization(value[key]);
2503
2970
  return normalized;
2504
2971
  }, {});