layercache 1.2.1 → 1.2.3

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.
@@ -598,11 +598,13 @@ var CircuitBreakerManager = class {
598
598
 
599
599
  // ../../src/internal/FetchRateLimiter.ts
600
600
  var FetchRateLimiter = class {
601
- active = 0;
602
- queue = [];
603
- startedAt = [];
601
+ buckets = /* @__PURE__ */ new Map();
602
+ queuesByBucket = /* @__PURE__ */ new Map();
603
+ pendingBuckets = /* @__PURE__ */ new Set();
604
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
605
+ nextFetcherBucketId = 0;
604
606
  drainTimer;
605
- async schedule(options, task) {
607
+ async schedule(options, context, task) {
606
608
  if (!options) {
607
609
  return task();
608
610
  }
@@ -611,7 +613,17 @@ var FetchRateLimiter = class {
611
613
  return task();
612
614
  }
613
615
  return new Promise((resolve, reject) => {
614
- this.queue.push({ options: normalized, task, resolve, reject });
616
+ const bucketKey = this.resolveBucketKey(normalized, context);
617
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
618
+ queue.push({
619
+ bucketKey,
620
+ options: normalized,
621
+ task,
622
+ resolve,
623
+ reject
624
+ });
625
+ this.queuesByBucket.set(bucketKey, queue);
626
+ this.pendingBuckets.add(bucketKey);
615
627
  this.drain();
616
628
  });
617
629
  }
@@ -625,63 +637,159 @@ var FetchRateLimiter = class {
625
637
  return {
626
638
  maxConcurrent,
627
639
  intervalMs,
628
- maxPerInterval
640
+ maxPerInterval,
641
+ scope: options.scope ?? "global",
642
+ bucketKey: options.bucketKey
629
643
  };
630
644
  }
645
+ resolveBucketKey(options, context) {
646
+ if (options.bucketKey) {
647
+ return `custom:${options.bucketKey}`;
648
+ }
649
+ if (options.scope === "key") {
650
+ return `key:${context.key}`;
651
+ }
652
+ if (options.scope === "fetcher") {
653
+ const existing = this.fetcherBuckets.get(context.fetcher);
654
+ if (existing) {
655
+ return existing;
656
+ }
657
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
658
+ this.nextFetcherBucketId += 1;
659
+ this.fetcherBuckets.set(context.fetcher, bucket);
660
+ return bucket;
661
+ }
662
+ return "global";
663
+ }
631
664
  drain() {
632
665
  if (this.drainTimer) {
633
666
  clearTimeout(this.drainTimer);
634
667
  this.drainTimer = void 0;
635
668
  }
636
- while (this.queue.length > 0) {
637
- const next = this.queue[0];
638
- if (!next) {
639
- return;
669
+ while (this.pendingBuckets.size > 0) {
670
+ let nextBucketKey;
671
+ let nextWaitMs = Number.POSITIVE_INFINITY;
672
+ for (const bucketKey of this.pendingBuckets) {
673
+ const queue2 = this.queuesByBucket.get(bucketKey);
674
+ if (!queue2 || queue2.length === 0) {
675
+ this.pendingBuckets.delete(bucketKey);
676
+ this.queuesByBucket.delete(bucketKey);
677
+ continue;
678
+ }
679
+ const next2 = queue2[0];
680
+ if (!next2) {
681
+ this.pendingBuckets.delete(bucketKey);
682
+ this.queuesByBucket.delete(bucketKey);
683
+ continue;
684
+ }
685
+ const waitMs = this.waitTime(bucketKey, next2.options);
686
+ if (waitMs <= 0) {
687
+ nextBucketKey = bucketKey;
688
+ break;
689
+ }
690
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
640
691
  }
641
- const waitMs = this.waitTime(next.options);
642
- if (waitMs > 0) {
643
- this.drainTimer = setTimeout(() => {
644
- this.drainTimer = void 0;
645
- this.drain();
646
- }, waitMs);
647
- this.drainTimer.unref?.();
692
+ if (!nextBucketKey) {
693
+ if (Number.isFinite(nextWaitMs)) {
694
+ this.drainTimer = setTimeout(() => {
695
+ this.drainTimer = void 0;
696
+ this.drain();
697
+ }, nextWaitMs);
698
+ this.drainTimer.unref?.();
699
+ }
648
700
  return;
649
701
  }
650
- this.queue.shift();
651
- this.active += 1;
652
- this.startedAt.push(Date.now());
702
+ const queue = this.queuesByBucket.get(nextBucketKey);
703
+ const next = queue?.shift();
704
+ if (!next) {
705
+ this.pendingBuckets.delete(nextBucketKey);
706
+ this.queuesByBucket.delete(nextBucketKey);
707
+ continue;
708
+ }
709
+ if (!queue || queue.length === 0) {
710
+ this.pendingBuckets.delete(nextBucketKey);
711
+ this.queuesByBucket.delete(nextBucketKey);
712
+ }
713
+ const bucket = this.bucketState(next.bucketKey);
714
+ if (bucket.cleanupTimer) {
715
+ clearTimeout(bucket.cleanupTimer);
716
+ bucket.cleanupTimer = void 0;
717
+ }
718
+ bucket.active += 1;
719
+ if (next.options.intervalMs && next.options.maxPerInterval) {
720
+ bucket.startedAt.push(Date.now());
721
+ }
653
722
  void next.task().then(next.resolve, next.reject).finally(() => {
654
- this.active -= 1;
723
+ bucket.active -= 1;
724
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
725
+ this.pendingBuckets.add(next.bucketKey);
726
+ }
727
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
655
728
  this.drain();
656
729
  });
657
730
  }
658
731
  }
659
- waitTime(options) {
732
+ waitTime(bucketKey, options) {
733
+ const bucket = this.bucketState(bucketKey);
660
734
  const now = Date.now();
661
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
735
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
662
736
  return 1;
663
737
  }
664
738
  if (!options.intervalMs || !options.maxPerInterval) {
665
739
  return 0;
666
740
  }
667
- this.prune(now, options.intervalMs);
668
- if (this.startedAt.length < options.maxPerInterval) {
741
+ this.prune(bucket, now, options.intervalMs);
742
+ if (bucket.startedAt.length < options.maxPerInterval) {
669
743
  return 0;
670
744
  }
671
- const oldest = this.startedAt[0];
745
+ const oldest = bucket.startedAt[0];
672
746
  if (!oldest) {
673
747
  return 0;
674
748
  }
675
749
  return Math.max(1, options.intervalMs - (now - oldest));
676
750
  }
677
- prune(now, intervalMs) {
678
- while (this.startedAt.length > 0) {
679
- const startedAt = this.startedAt[0];
751
+ prune(bucket, now, intervalMs) {
752
+ while (bucket.startedAt.length > 0) {
753
+ const startedAt = bucket.startedAt[0];
680
754
  if (startedAt === void 0 || now - startedAt < intervalMs) {
681
755
  break;
682
756
  }
683
- this.startedAt.shift();
757
+ bucket.startedAt.shift();
758
+ }
759
+ }
760
+ bucketState(bucketKey) {
761
+ const existing = this.buckets.get(bucketKey);
762
+ if (existing) {
763
+ return existing;
684
764
  }
765
+ const bucket = { active: 0, startedAt: [] };
766
+ this.buckets.set(bucketKey, bucket);
767
+ return bucket;
768
+ }
769
+ cleanupBucket(bucketKey, bucket, intervalMs) {
770
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
771
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
772
+ this.buckets.delete(bucketKey);
773
+ this.queuesByBucket.delete(bucketKey);
774
+ this.pendingBuckets.delete(bucketKey);
775
+ return;
776
+ }
777
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
778
+ return;
779
+ }
780
+ if (bucket.cleanupTimer) {
781
+ clearTimeout(bucket.cleanupTimer);
782
+ }
783
+ bucket.cleanupTimer = setTimeout(() => {
784
+ bucket.cleanupTimer = void 0;
785
+ this.prune(bucket, Date.now(), intervalMs);
786
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
787
+ this.buckets.delete(bucketKey);
788
+ this.queuesByBucket.delete(bucketKey);
789
+ this.pendingBuckets.delete(bucketKey);
790
+ }
791
+ }, intervalMs);
792
+ bucket.cleanupTimer.unref?.();
685
793
  }
686
794
  };
687
795
 
@@ -761,7 +869,30 @@ var MetricsCollector = class {
761
869
 
762
870
  // ../../src/internal/StoredValue.ts
763
871
  function isStoredValueEnvelope(value) {
764
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
872
+ if (typeof value !== "object" || value === null) {
873
+ return false;
874
+ }
875
+ const v = value;
876
+ if (v.__layercache !== 1) {
877
+ return false;
878
+ }
879
+ if (v.kind !== "value" && v.kind !== "empty") {
880
+ return false;
881
+ }
882
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
883
+ return false;
884
+ }
885
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
886
+ return false;
887
+ }
888
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
889
+ return false;
890
+ }
891
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
892
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
893
+ return false;
894
+ }
895
+ return true;
765
896
  }
766
897
  function createStoredValueEnvelope(options) {
767
898
  const now = options.now ?? Date.now();
@@ -1026,15 +1157,17 @@ var TagIndex = class {
1026
1157
  keyToTags = /* @__PURE__ */ new Map();
1027
1158
  knownKeys = /* @__PURE__ */ new Set();
1028
1159
  maxKnownKeys;
1160
+ nextNodeId = 1;
1161
+ root = this.createTrieNode();
1029
1162
  constructor(options = {}) {
1030
- this.maxKnownKeys = options.maxKnownKeys;
1163
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
1031
1164
  }
1032
1165
  async touch(key) {
1033
- this.knownKeys.add(key);
1166
+ this.insertKnownKey(key);
1034
1167
  this.pruneKnownKeysIfNeeded();
1035
1168
  }
1036
1169
  async track(key, tags) {
1037
- this.knownKeys.add(key);
1170
+ this.insertKnownKey(key);
1038
1171
  this.pruneKnownKeysIfNeeded();
1039
1172
  if (tags.length === 0) {
1040
1173
  return;
@@ -1060,18 +1193,104 @@ var TagIndex = class {
1060
1193
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1061
1194
  }
1062
1195
  async keysForPrefix(prefix) {
1063
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
1196
+ const node = this.findNode(prefix);
1197
+ if (!node) {
1198
+ return [];
1199
+ }
1200
+ const matches = [];
1201
+ this.collectFromNode(node, prefix, matches);
1202
+ return matches;
1064
1203
  }
1065
1204
  async tagsForKey(key) {
1066
1205
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1067
1206
  }
1068
1207
  async matchPattern(pattern) {
1069
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1208
+ const matches = /* @__PURE__ */ new Set();
1209
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1210
+ return [...matches];
1070
1211
  }
1071
1212
  async clear() {
1072
1213
  this.tagToKeys.clear();
1073
1214
  this.keyToTags.clear();
1074
1215
  this.knownKeys.clear();
1216
+ this.root.children.clear();
1217
+ this.root.terminal = false;
1218
+ this.nextNodeId = this.root.id + 1;
1219
+ }
1220
+ createTrieNode() {
1221
+ return {
1222
+ id: this.nextNodeId++,
1223
+ terminal: false,
1224
+ children: /* @__PURE__ */ new Map()
1225
+ };
1226
+ }
1227
+ insertKnownKey(key) {
1228
+ if (this.knownKeys.has(key)) {
1229
+ return;
1230
+ }
1231
+ this.knownKeys.add(key);
1232
+ let node = this.root;
1233
+ for (const character of key) {
1234
+ let child = node.children.get(character);
1235
+ if (!child) {
1236
+ child = this.createTrieNode();
1237
+ node.children.set(character, child);
1238
+ }
1239
+ node = child;
1240
+ }
1241
+ node.terminal = true;
1242
+ }
1243
+ findNode(prefix) {
1244
+ let node = this.root;
1245
+ for (const character of prefix) {
1246
+ node = node.children.get(character);
1247
+ if (!node) {
1248
+ return void 0;
1249
+ }
1250
+ }
1251
+ return node;
1252
+ }
1253
+ collectFromNode(node, prefix, matches) {
1254
+ if (node.terminal) {
1255
+ matches.push(prefix);
1256
+ }
1257
+ for (const [character, child] of node.children) {
1258
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1259
+ }
1260
+ }
1261
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1262
+ const stateKey = `${node.id}:${patternIndex}`;
1263
+ if (visited.has(stateKey)) {
1264
+ return;
1265
+ }
1266
+ visited.add(stateKey);
1267
+ if (patternIndex === pattern.length) {
1268
+ if (node.terminal) {
1269
+ matches.add(prefix);
1270
+ }
1271
+ return;
1272
+ }
1273
+ const patternChar = pattern[patternIndex];
1274
+ if (patternChar === void 0) {
1275
+ return;
1276
+ }
1277
+ if (patternChar === "*") {
1278
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1279
+ for (const [character, child2] of node.children) {
1280
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1281
+ }
1282
+ return;
1283
+ }
1284
+ if (patternChar === "?") {
1285
+ for (const [character, child2] of node.children) {
1286
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1287
+ }
1288
+ return;
1289
+ }
1290
+ const child = node.children.get(patternChar);
1291
+ if (child) {
1292
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1293
+ }
1075
1294
  }
1076
1295
  pruneKnownKeysIfNeeded() {
1077
1296
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1088,7 +1307,7 @@ var TagIndex = class {
1088
1307
  }
1089
1308
  }
1090
1309
  removeKey(key) {
1091
- this.knownKeys.delete(key);
1310
+ this.removeKnownKey(key);
1092
1311
  const tags = this.keyToTags.get(key);
1093
1312
  if (!tags) {
1094
1313
  return;
@@ -1105,7 +1324,70 @@ var TagIndex = class {
1105
1324
  }
1106
1325
  this.keyToTags.delete(key);
1107
1326
  }
1327
+ removeKnownKey(key) {
1328
+ if (!this.knownKeys.delete(key)) {
1329
+ return;
1330
+ }
1331
+ const path = [];
1332
+ let node = this.root;
1333
+ for (const character of key) {
1334
+ const child = node.children.get(character);
1335
+ if (!child) {
1336
+ return;
1337
+ }
1338
+ path.push([node, character]);
1339
+ node = child;
1340
+ }
1341
+ node.terminal = false;
1342
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1343
+ const entry = path[index];
1344
+ if (!entry) {
1345
+ continue;
1346
+ }
1347
+ const [parent, character] = entry;
1348
+ const child = parent.children.get(character);
1349
+ if (!child || child.terminal || child.children.size > 0) {
1350
+ break;
1351
+ }
1352
+ parent.children.delete(character);
1353
+ }
1354
+ }
1355
+ };
1356
+
1357
+ // ../../src/serialization/JsonSerializer.ts
1358
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1359
+ var JsonSerializer = class {
1360
+ serialize(value) {
1361
+ return JSON.stringify(value);
1362
+ }
1363
+ deserialize(payload) {
1364
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1365
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1366
+ }
1108
1367
  };
1368
+ var MAX_SANITIZE_DEPTH = 200;
1369
+ function sanitizeJsonValue(value, depth) {
1370
+ if (depth > MAX_SANITIZE_DEPTH) {
1371
+ return value;
1372
+ }
1373
+ if (Array.isArray(value)) {
1374
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1375
+ }
1376
+ if (!isPlainObject(value)) {
1377
+ return value;
1378
+ }
1379
+ const sanitized = {};
1380
+ for (const [key, entry] of Object.entries(value)) {
1381
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1382
+ continue;
1383
+ }
1384
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1385
+ }
1386
+ return sanitized;
1387
+ }
1388
+ function isPlainObject(value) {
1389
+ return Object.prototype.toString.call(value) === "[object Object]";
1390
+ }
1109
1391
 
1110
1392
  // ../../src/stampede/StampedeGuard.ts
1111
1393
  var StampedeGuard = class {
@@ -1116,7 +1398,8 @@ var StampedeGuard = class {
1116
1398
  return await entry.mutex.runExclusive(task);
1117
1399
  } finally {
1118
1400
  entry.references -= 1;
1119
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1401
+ const current = this.mutexes.get(key);
1402
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1120
1403
  this.mutexes.delete(key);
1121
1404
  }
1122
1405
  }
@@ -1146,8 +1429,10 @@ var CacheMissError = class extends Error {
1146
1429
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1147
1430
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1148
1431
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1432
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1149
1433
  var MAX_CACHE_KEY_LENGTH = 1024;
1150
1434
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1435
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1151
1436
  var DebugLogger = class {
1152
1437
  enabled;
1153
1438
  constructor(enabled) {
@@ -1194,6 +1479,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
1194
1479
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1195
1480
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1196
1481
  this.tagIndex = options.tagIndex ?? new TagIndex();
1482
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1483
+ this.logger.warn?.(
1484
+ "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."
1485
+ );
1486
+ }
1487
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1488
+ this.logger.warn?.(
1489
+ "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."
1490
+ );
1491
+ }
1492
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1493
+ this.logger.warn?.(
1494
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1495
+ );
1496
+ }
1197
1497
  this.initializeWriteBehind(options.writeBehind);
1198
1498
  this.startup = this.initialize();
1199
1499
  }
@@ -1207,6 +1507,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1207
1507
  logger;
1208
1508
  tagIndex;
1209
1509
  fetchRateLimiter = new FetchRateLimiter();
1510
+ snapshotSerializer = new JsonSerializer();
1210
1511
  backgroundRefreshes = /* @__PURE__ */ new Map();
1211
1512
  layerDegradedUntil = /* @__PURE__ */ new Map();
1212
1513
  ttlResolver;
@@ -1215,6 +1516,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1215
1516
  writeBehindQueue = [];
1216
1517
  writeBehindTimer;
1217
1518
  writeBehindFlushPromise;
1519
+ generationCleanupPromise;
1218
1520
  isDisconnecting = false;
1219
1521
  disconnectPromise;
1220
1522
  /**
@@ -1227,6 +1529,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1227
1529
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1228
1530
  this.validateWriteOptions(options);
1229
1531
  await this.awaitStartup("get");
1532
+ return this.getPrepared(normalizedKey, fetcher, options);
1533
+ }
1534
+ async getPrepared(normalizedKey, fetcher, options) {
1230
1535
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1231
1536
  if (hit.found) {
1232
1537
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1304,6 +1609,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1304
1609
  return true;
1305
1610
  }
1306
1611
  } catch {
1612
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1307
1613
  }
1308
1614
  } else {
1309
1615
  try {
@@ -1311,7 +1617,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
1311
1617
  if (value !== null) {
1312
1618
  return true;
1313
1619
  }
1314
- } catch {
1620
+ } catch (error) {
1621
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1315
1622
  }
1316
1623
  }
1317
1624
  }
@@ -1403,13 +1710,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1403
1710
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1404
1711
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1405
1712
  if (!canFastPath) {
1713
+ await this.awaitStartup("mget");
1406
1714
  const pendingReads = /* @__PURE__ */ new Map();
1407
1715
  return Promise.all(
1408
1716
  normalizedEntries.map((entry) => {
1409
1717
  const optionsSignature = this.serializeOptions(entry.options);
1410
1718
  const existing = pendingReads.get(entry.key);
1411
1719
  if (!existing) {
1412
- const promise = this.get(entry.key, entry.fetch, entry.options);
1720
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1413
1721
  pendingReads.set(entry.key, {
1414
1722
  promise,
1415
1723
  fetch: entry.fetch,
@@ -1548,14 +1856,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1548
1856
  }
1549
1857
  async invalidateByPattern(pattern) {
1550
1858
  await this.awaitStartup("invalidateByPattern");
1551
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1859
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1552
1860
  await this.deleteKeys(keys);
1553
1861
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1554
1862
  }
1555
1863
  async invalidateByPrefix(prefix) {
1556
1864
  await this.awaitStartup("invalidateByPrefix");
1557
1865
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1558
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1866
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1559
1867
  await this.deleteKeys(keys);
1560
1868
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1561
1869
  }
@@ -1605,9 +1913,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
1605
1913
  })
1606
1914
  );
1607
1915
  }
1916
+ /**
1917
+ * Rotates the active generation prefix used for all future cache keys.
1918
+ * Previous-generation keys remain in the underlying layers until they expire,
1919
+ * unless `generationCleanup` is enabled to prune them in the background.
1920
+ */
1608
1921
  bumpGeneration(nextGeneration) {
1609
1922
  const current = this.currentGeneration ?? 0;
1923
+ const previousGeneration = this.currentGeneration;
1610
1924
  this.currentGeneration = nextGeneration ?? current + 1;
1925
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1926
+ this.scheduleGenerationCleanup(previousGeneration);
1927
+ }
1611
1928
  return this.currentGeneration;
1612
1929
  }
1613
1930
  /**
@@ -1691,27 +2008,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
1691
2008
  this.assertActive("persistToFile");
1692
2009
  const snapshot = await this.exportState();
1693
2010
  const { promises: fs } = await import("fs");
1694
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
2011
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1695
2012
  }
1696
2013
  async restoreFromFile(filePath) {
1697
2014
  this.assertActive("restoreFromFile");
1698
2015
  const { promises: fs } = await import("fs");
1699
- const raw = await fs.readFile(filePath, "utf8");
2016
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1700
2017
  let parsed;
1701
2018
  try {
1702
- parsed = JSON.parse(raw, (_key, value) => {
1703
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1704
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1705
- }
1706
- return value;
1707
- });
2019
+ parsed = JSON.parse(raw);
1708
2020
  } catch (cause) {
1709
2021
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1710
2022
  }
1711
2023
  if (!this.isCacheSnapshotEntries(parsed)) {
1712
2024
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1713
2025
  }
1714
- await this.importState(parsed);
2026
+ await this.importState(
2027
+ parsed.map((entry) => ({
2028
+ key: entry.key,
2029
+ value: this.sanitizeSnapshotValue(entry.value),
2030
+ ttl: entry.ttl
2031
+ }))
2032
+ );
1715
2033
  }
1716
2034
  async disconnect() {
1717
2035
  if (!this.disconnectPromise) {
@@ -1720,6 +2038,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1720
2038
  await this.startup;
1721
2039
  await this.unsubscribeInvalidation?.();
1722
2040
  await this.flushWriteBehindQueue();
2041
+ await this.generationCleanupPromise;
1723
2042
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1724
2043
  if (this.writeBehindTimer) {
1725
2044
  clearInterval(this.writeBehindTimer);
@@ -1787,6 +2106,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1787
2106
  try {
1788
2107
  fetched = await this.fetchRateLimiter.schedule(
1789
2108
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
2109
+ { key, fetcher },
1790
2110
  fetcher
1791
2111
  );
1792
2112
  this.circuitBreakerManager.recordSuccess(key);
@@ -1802,8 +2122,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1802
2122
  await this.storeEntry(key, "empty", null, options);
1803
2123
  return null;
1804
2124
  }
1805
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1806
- return fetched;
2125
+ if (options?.shouldCache) {
2126
+ try {
2127
+ if (!options.shouldCache(fetched)) {
2128
+ return fetched;
2129
+ }
2130
+ } catch (error) {
2131
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
2132
+ }
1807
2133
  }
1808
2134
  await this.storeEntry(key, "value", fetched, options);
1809
2135
  return fetched;
@@ -2030,7 +2356,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2030
2356
  const refresh = (async () => {
2031
2357
  this.metricsCollector.increment("refreshes");
2032
2358
  try {
2033
- await this.fetchWithGuards(key, fetcher, options);
2359
+ await this.runBackgroundRefresh(key, fetcher, options);
2034
2360
  } catch (error) {
2035
2361
  this.metricsCollector.increment("refreshErrors");
2036
2362
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2040,11 +2366,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
2040
2366
  })();
2041
2367
  this.backgroundRefreshes.set(key, refresh);
2042
2368
  }
2369
+ async runBackgroundRefresh(key, fetcher, options) {
2370
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2371
+ await this.fetchWithGuards(
2372
+ key,
2373
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2374
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2375
+ }),
2376
+ options
2377
+ );
2378
+ }
2043
2379
  resolveSingleFlightOptions() {
2044
2380
  return {
2045
2381
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
2046
2382
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
2047
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2383
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
2384
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
2048
2385
  };
2049
2386
  }
2050
2387
  async deleteKeys(keys) {
@@ -2106,8 +2443,120 @@ var CacheStack = class extends import_node_events.EventEmitter {
2106
2443
  sleep(ms) {
2107
2444
  return new Promise((resolve) => setTimeout(resolve, ms));
2108
2445
  }
2446
+ async withTimeout(promise, timeoutMs, onTimeout) {
2447
+ if (timeoutMs <= 0) {
2448
+ return promise;
2449
+ }
2450
+ let timer;
2451
+ const observedPromise = promise.then(
2452
+ (value) => ({ kind: "value", value }),
2453
+ (error) => ({ kind: "error", error })
2454
+ );
2455
+ try {
2456
+ const result = await Promise.race([
2457
+ observedPromise,
2458
+ new Promise((_, reject) => {
2459
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2460
+ timer.unref?.();
2461
+ })
2462
+ ]);
2463
+ if (result && typeof result === "object" && "kind" in result) {
2464
+ if (result.kind === "error") {
2465
+ throw result.error;
2466
+ }
2467
+ return result.value;
2468
+ }
2469
+ return result;
2470
+ } finally {
2471
+ if (timer) {
2472
+ clearTimeout(timer);
2473
+ }
2474
+ }
2475
+ }
2109
2476
  shouldBroadcastL1Invalidation() {
2110
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2477
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2478
+ }
2479
+ async collectKeysWithPrefix(prefix) {
2480
+ const matches = new Set(
2481
+ this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2482
+ );
2483
+ await Promise.all(
2484
+ this.layers.map(async (layer) => {
2485
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2486
+ return;
2487
+ }
2488
+ try {
2489
+ const keys = await layer.keys();
2490
+ for (const key of keys) {
2491
+ if (key.startsWith(prefix)) {
2492
+ matches.add(key);
2493
+ }
2494
+ }
2495
+ } catch (error) {
2496
+ await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2497
+ }
2498
+ })
2499
+ );
2500
+ return [...matches];
2501
+ }
2502
+ async collectKeysMatchingPattern(pattern) {
2503
+ const matches = new Set(await this.tagIndex.matchPattern(pattern));
2504
+ await Promise.all(
2505
+ this.layers.map(async (layer) => {
2506
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2507
+ return;
2508
+ }
2509
+ try {
2510
+ const keys = await layer.keys();
2511
+ for (const key of keys) {
2512
+ if (PatternMatcher.matches(pattern, key)) {
2513
+ matches.add(key);
2514
+ }
2515
+ }
2516
+ } catch (error) {
2517
+ await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2518
+ }
2519
+ })
2520
+ );
2521
+ return [...matches];
2522
+ }
2523
+ shouldCleanupGenerations() {
2524
+ return Boolean(this.options.generationCleanup);
2525
+ }
2526
+ generationCleanupBatchSize() {
2527
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2528
+ return configured ?? 500;
2529
+ }
2530
+ scheduleGenerationCleanup(generation) {
2531
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2532
+ this.logger.warn?.("generation-cleanup-error", {
2533
+ generation,
2534
+ error: this.formatError(error)
2535
+ });
2536
+ });
2537
+ this.generationCleanupPromise = task.finally(() => {
2538
+ if (this.generationCleanupPromise === task) {
2539
+ this.generationCleanupPromise = void 0;
2540
+ }
2541
+ });
2542
+ }
2543
+ async cleanupGeneration(generation) {
2544
+ const prefix = `v${generation}:`;
2545
+ const keys = await this.collectKeysWithPrefix(prefix);
2546
+ if (keys.length === 0) {
2547
+ return;
2548
+ }
2549
+ const batchSize = this.generationCleanupBatchSize();
2550
+ for (let index = 0; index < keys.length; index += batchSize) {
2551
+ const batch = keys.slice(index, index + batchSize);
2552
+ await this.deleteKeys(batch);
2553
+ await this.publishInvalidation({
2554
+ scope: "keys",
2555
+ keys: batch,
2556
+ sourceId: this.instanceId,
2557
+ operation: "invalidate"
2558
+ });
2559
+ }
2111
2560
  }
2112
2561
  initializeWriteBehind(options) {
2113
2562
  if (this.options.writeStrategy !== "write-behind") {
@@ -2145,7 +2594,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
2145
2594
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2146
2595
  const batch = this.writeBehindQueue.splice(0, batchSize);
2147
2596
  this.writeBehindFlushPromise = (async () => {
2148
- await Promise.allSettled(batch.map((operation) => operation()));
2597
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2598
+ const failures = results.filter((result) => result.status === "rejected");
2599
+ if (failures.length > 0) {
2600
+ this.metricsCollector.increment("writeFailures", failures.length);
2601
+ this.logger.error?.("write-behind-flush-failure", {
2602
+ failed: failures.length,
2603
+ total: batch.length,
2604
+ errors: failures.map((failure) => this.formatError(failure.reason))
2605
+ });
2606
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2607
+ }
2149
2608
  })();
2150
2609
  await this.writeBehindFlushPromise;
2151
2610
  this.writeBehindFlushPromise = void 0;
@@ -2249,8 +2708,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2249
2708
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2250
2709
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2251
2710
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2711
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2712
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2713
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2252
2714
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2253
2715
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2716
+ if (typeof this.options.generationCleanup === "object") {
2717
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2718
+ }
2254
2719
  if (this.options.generation !== void 0) {
2255
2720
  this.validateNonNegativeNumber("generation", this.options.generation);
2256
2721
  }
@@ -2268,6 +2733,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2268
2733
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2269
2734
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
2270
2735
  this.validateCircuitBreakerOptions(options.circuitBreaker);
2736
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2271
2737
  }
2272
2738
  validateLayerNumberOption(name, value) {
2273
2739
  if (value === void 0) {
@@ -2292,6 +2758,20 @@ var CacheStack = class extends import_node_events.EventEmitter {
2292
2758
  throw new Error(`${name} must be a positive finite number.`);
2293
2759
  }
2294
2760
  }
2761
+ validateRateLimitOptions(name, options) {
2762
+ if (!options) {
2763
+ return;
2764
+ }
2765
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2766
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2767
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2768
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2769
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2770
+ }
2771
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2772
+ throw new Error(`${name}.bucketKey must not be empty.`);
2773
+ }
2774
+ }
2295
2775
  validateNonNegativeNumber(name, value) {
2296
2776
  if (!Number.isFinite(value) || value < 0) {
2297
2777
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -2307,6 +2787,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2307
2787
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2308
2788
  throw new Error("Cache key contains unsupported control characters.");
2309
2789
  }
2790
+ if (/[\uD800-\uDFFF]/.test(key)) {
2791
+ throw new Error("Cache key contains unsupported surrogate code points.");
2792
+ }
2310
2793
  return key;
2311
2794
  }
2312
2795
  validateTtlPolicy(name, policy) {
@@ -2384,6 +2867,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2384
2867
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2385
2868
  return null;
2386
2869
  }
2870
+ async reportRecoverableLayerFailure(layer, operation, error) {
2871
+ if (this.isGracefulDegradationEnabled()) {
2872
+ await this.handleLayerFailure(layer, operation, error);
2873
+ return;
2874
+ }
2875
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2876
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2877
+ }
2387
2878
  isGracefulDegradationEnabled() {
2388
2879
  return Boolean(this.options.gracefulDegradation);
2389
2880
  }
@@ -2407,10 +2898,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2407
2898
  }
2408
2899
  }
2409
2900
  serializeKeyPart(value) {
2410
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2411
- return String(value);
2901
+ if (typeof value === "string") {
2902
+ return `s:${value}`;
2903
+ }
2904
+ if (typeof value === "number") {
2905
+ return `n:${value}`;
2906
+ }
2907
+ if (typeof value === "boolean") {
2908
+ return `b:${value}`;
2412
2909
  }
2413
- return JSON.stringify(this.normalizeForSerialization(value));
2910
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2414
2911
  }
2415
2912
  isCacheSnapshotEntries(value) {
2416
2913
  return Array.isArray(value) && value.every((entry) => {
@@ -2418,15 +2915,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
2418
2915
  return false;
2419
2916
  }
2420
2917
  const candidate = entry;
2421
- return typeof candidate.key === "string";
2918
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2422
2919
  });
2423
2920
  }
2921
+ sanitizeSnapshotValue(value) {
2922
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2923
+ }
2924
+ async validateSnapshotFilePath(filePath) {
2925
+ if (filePath.length === 0) {
2926
+ throw new Error("filePath must not be empty.");
2927
+ }
2928
+ if (filePath.includes("\0")) {
2929
+ throw new Error("filePath must not contain null bytes.");
2930
+ }
2931
+ const path = await import("path");
2932
+ const resolved = path.resolve(filePath);
2933
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2934
+ if (baseDir !== false) {
2935
+ const relative = path.relative(baseDir, resolved);
2936
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2937
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2938
+ }
2939
+ }
2940
+ return resolved;
2941
+ }
2424
2942
  normalizeForSerialization(value) {
2425
2943
  if (Array.isArray(value)) {
2426
2944
  return value.map((entry) => this.normalizeForSerialization(entry));
2427
2945
  }
2428
2946
  if (value && typeof value === "object") {
2429
2947
  return Object.keys(value).sort().reduce((normalized, key) => {
2948
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2949
+ return normalized;
2950
+ }
2430
2951
  normalized[key] = this.normalizeForSerialization(value[key]);
2431
2952
  return normalized;
2432
2953
  }, {});