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.
@@ -562,11 +562,13 @@ var CircuitBreakerManager = class {
562
562
 
563
563
  // ../../src/internal/FetchRateLimiter.ts
564
564
  var FetchRateLimiter = class {
565
- active = 0;
566
- queue = [];
567
- startedAt = [];
565
+ buckets = /* @__PURE__ */ new Map();
566
+ queuesByBucket = /* @__PURE__ */ new Map();
567
+ pendingBuckets = /* @__PURE__ */ new Set();
568
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
569
+ nextFetcherBucketId = 0;
568
570
  drainTimer;
569
- async schedule(options, task) {
571
+ async schedule(options, context, task) {
570
572
  if (!options) {
571
573
  return task();
572
574
  }
@@ -575,7 +577,17 @@ var FetchRateLimiter = class {
575
577
  return task();
576
578
  }
577
579
  return new Promise((resolve, reject) => {
578
- this.queue.push({ options: normalized, task, resolve, reject });
580
+ const bucketKey = this.resolveBucketKey(normalized, context);
581
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
582
+ queue.push({
583
+ bucketKey,
584
+ options: normalized,
585
+ task,
586
+ resolve,
587
+ reject
588
+ });
589
+ this.queuesByBucket.set(bucketKey, queue);
590
+ this.pendingBuckets.add(bucketKey);
579
591
  this.drain();
580
592
  });
581
593
  }
@@ -589,63 +601,159 @@ var FetchRateLimiter = class {
589
601
  return {
590
602
  maxConcurrent,
591
603
  intervalMs,
592
- maxPerInterval
604
+ maxPerInterval,
605
+ scope: options.scope ?? "global",
606
+ bucketKey: options.bucketKey
593
607
  };
594
608
  }
609
+ resolveBucketKey(options, context) {
610
+ if (options.bucketKey) {
611
+ return `custom:${options.bucketKey}`;
612
+ }
613
+ if (options.scope === "key") {
614
+ return `key:${context.key}`;
615
+ }
616
+ if (options.scope === "fetcher") {
617
+ const existing = this.fetcherBuckets.get(context.fetcher);
618
+ if (existing) {
619
+ return existing;
620
+ }
621
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
622
+ this.nextFetcherBucketId += 1;
623
+ this.fetcherBuckets.set(context.fetcher, bucket);
624
+ return bucket;
625
+ }
626
+ return "global";
627
+ }
595
628
  drain() {
596
629
  if (this.drainTimer) {
597
630
  clearTimeout(this.drainTimer);
598
631
  this.drainTimer = void 0;
599
632
  }
600
- while (this.queue.length > 0) {
601
- const next = this.queue[0];
602
- if (!next) {
603
- return;
633
+ while (this.pendingBuckets.size > 0) {
634
+ let nextBucketKey;
635
+ let nextWaitMs = Number.POSITIVE_INFINITY;
636
+ for (const bucketKey of this.pendingBuckets) {
637
+ const queue2 = this.queuesByBucket.get(bucketKey);
638
+ if (!queue2 || queue2.length === 0) {
639
+ this.pendingBuckets.delete(bucketKey);
640
+ this.queuesByBucket.delete(bucketKey);
641
+ continue;
642
+ }
643
+ const next2 = queue2[0];
644
+ if (!next2) {
645
+ this.pendingBuckets.delete(bucketKey);
646
+ this.queuesByBucket.delete(bucketKey);
647
+ continue;
648
+ }
649
+ const waitMs = this.waitTime(bucketKey, next2.options);
650
+ if (waitMs <= 0) {
651
+ nextBucketKey = bucketKey;
652
+ break;
653
+ }
654
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
604
655
  }
605
- const waitMs = this.waitTime(next.options);
606
- if (waitMs > 0) {
607
- this.drainTimer = setTimeout(() => {
608
- this.drainTimer = void 0;
609
- this.drain();
610
- }, waitMs);
611
- this.drainTimer.unref?.();
656
+ if (!nextBucketKey) {
657
+ if (Number.isFinite(nextWaitMs)) {
658
+ this.drainTimer = setTimeout(() => {
659
+ this.drainTimer = void 0;
660
+ this.drain();
661
+ }, nextWaitMs);
662
+ this.drainTimer.unref?.();
663
+ }
612
664
  return;
613
665
  }
614
- this.queue.shift();
615
- this.active += 1;
616
- this.startedAt.push(Date.now());
666
+ const queue = this.queuesByBucket.get(nextBucketKey);
667
+ const next = queue?.shift();
668
+ if (!next) {
669
+ this.pendingBuckets.delete(nextBucketKey);
670
+ this.queuesByBucket.delete(nextBucketKey);
671
+ continue;
672
+ }
673
+ if (!queue || queue.length === 0) {
674
+ this.pendingBuckets.delete(nextBucketKey);
675
+ this.queuesByBucket.delete(nextBucketKey);
676
+ }
677
+ const bucket = this.bucketState(next.bucketKey);
678
+ if (bucket.cleanupTimer) {
679
+ clearTimeout(bucket.cleanupTimer);
680
+ bucket.cleanupTimer = void 0;
681
+ }
682
+ bucket.active += 1;
683
+ if (next.options.intervalMs && next.options.maxPerInterval) {
684
+ bucket.startedAt.push(Date.now());
685
+ }
617
686
  void next.task().then(next.resolve, next.reject).finally(() => {
618
- this.active -= 1;
687
+ bucket.active -= 1;
688
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
689
+ this.pendingBuckets.add(next.bucketKey);
690
+ }
691
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
619
692
  this.drain();
620
693
  });
621
694
  }
622
695
  }
623
- waitTime(options) {
696
+ waitTime(bucketKey, options) {
697
+ const bucket = this.bucketState(bucketKey);
624
698
  const now = Date.now();
625
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
699
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
626
700
  return 1;
627
701
  }
628
702
  if (!options.intervalMs || !options.maxPerInterval) {
629
703
  return 0;
630
704
  }
631
- this.prune(now, options.intervalMs);
632
- if (this.startedAt.length < options.maxPerInterval) {
705
+ this.prune(bucket, now, options.intervalMs);
706
+ if (bucket.startedAt.length < options.maxPerInterval) {
633
707
  return 0;
634
708
  }
635
- const oldest = this.startedAt[0];
709
+ const oldest = bucket.startedAt[0];
636
710
  if (!oldest) {
637
711
  return 0;
638
712
  }
639
713
  return Math.max(1, options.intervalMs - (now - oldest));
640
714
  }
641
- prune(now, intervalMs) {
642
- while (this.startedAt.length > 0) {
643
- const startedAt = this.startedAt[0];
715
+ prune(bucket, now, intervalMs) {
716
+ while (bucket.startedAt.length > 0) {
717
+ const startedAt = bucket.startedAt[0];
644
718
  if (startedAt === void 0 || now - startedAt < intervalMs) {
645
719
  break;
646
720
  }
647
- this.startedAt.shift();
721
+ bucket.startedAt.shift();
722
+ }
723
+ }
724
+ bucketState(bucketKey) {
725
+ const existing = this.buckets.get(bucketKey);
726
+ if (existing) {
727
+ return existing;
648
728
  }
729
+ const bucket = { active: 0, startedAt: [] };
730
+ this.buckets.set(bucketKey, bucket);
731
+ return bucket;
732
+ }
733
+ cleanupBucket(bucketKey, bucket, intervalMs) {
734
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
735
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
736
+ this.buckets.delete(bucketKey);
737
+ this.queuesByBucket.delete(bucketKey);
738
+ this.pendingBuckets.delete(bucketKey);
739
+ return;
740
+ }
741
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
742
+ return;
743
+ }
744
+ if (bucket.cleanupTimer) {
745
+ clearTimeout(bucket.cleanupTimer);
746
+ }
747
+ bucket.cleanupTimer = setTimeout(() => {
748
+ bucket.cleanupTimer = void 0;
749
+ this.prune(bucket, Date.now(), intervalMs);
750
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
751
+ this.buckets.delete(bucketKey);
752
+ this.queuesByBucket.delete(bucketKey);
753
+ this.pendingBuckets.delete(bucketKey);
754
+ }
755
+ }, intervalMs);
756
+ bucket.cleanupTimer.unref?.();
649
757
  }
650
758
  };
651
759
 
@@ -725,7 +833,30 @@ var MetricsCollector = class {
725
833
 
726
834
  // ../../src/internal/StoredValue.ts
727
835
  function isStoredValueEnvelope(value) {
728
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
836
+ if (typeof value !== "object" || value === null) {
837
+ return false;
838
+ }
839
+ const v = value;
840
+ if (v.__layercache !== 1) {
841
+ return false;
842
+ }
843
+ if (v.kind !== "value" && v.kind !== "empty") {
844
+ return false;
845
+ }
846
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
847
+ return false;
848
+ }
849
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
850
+ return false;
851
+ }
852
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
853
+ return false;
854
+ }
855
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
856
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
857
+ return false;
858
+ }
859
+ return true;
729
860
  }
730
861
  function createStoredValueEnvelope(options) {
731
862
  const now = options.now ?? Date.now();
@@ -990,15 +1121,17 @@ var TagIndex = class {
990
1121
  keyToTags = /* @__PURE__ */ new Map();
991
1122
  knownKeys = /* @__PURE__ */ new Set();
992
1123
  maxKnownKeys;
1124
+ nextNodeId = 1;
1125
+ root = this.createTrieNode();
993
1126
  constructor(options = {}) {
994
- this.maxKnownKeys = options.maxKnownKeys;
1127
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
995
1128
  }
996
1129
  async touch(key) {
997
- this.knownKeys.add(key);
1130
+ this.insertKnownKey(key);
998
1131
  this.pruneKnownKeysIfNeeded();
999
1132
  }
1000
1133
  async track(key, tags) {
1001
- this.knownKeys.add(key);
1134
+ this.insertKnownKey(key);
1002
1135
  this.pruneKnownKeysIfNeeded();
1003
1136
  if (tags.length === 0) {
1004
1137
  return;
@@ -1024,18 +1157,104 @@ var TagIndex = class {
1024
1157
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1025
1158
  }
1026
1159
  async keysForPrefix(prefix) {
1027
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
1160
+ const node = this.findNode(prefix);
1161
+ if (!node) {
1162
+ return [];
1163
+ }
1164
+ const matches = [];
1165
+ this.collectFromNode(node, prefix, matches);
1166
+ return matches;
1028
1167
  }
1029
1168
  async tagsForKey(key) {
1030
1169
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1031
1170
  }
1032
1171
  async matchPattern(pattern) {
1033
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1172
+ const matches = /* @__PURE__ */ new Set();
1173
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1174
+ return [...matches];
1034
1175
  }
1035
1176
  async clear() {
1036
1177
  this.tagToKeys.clear();
1037
1178
  this.keyToTags.clear();
1038
1179
  this.knownKeys.clear();
1180
+ this.root.children.clear();
1181
+ this.root.terminal = false;
1182
+ this.nextNodeId = this.root.id + 1;
1183
+ }
1184
+ createTrieNode() {
1185
+ return {
1186
+ id: this.nextNodeId++,
1187
+ terminal: false,
1188
+ children: /* @__PURE__ */ new Map()
1189
+ };
1190
+ }
1191
+ insertKnownKey(key) {
1192
+ if (this.knownKeys.has(key)) {
1193
+ return;
1194
+ }
1195
+ this.knownKeys.add(key);
1196
+ let node = this.root;
1197
+ for (const character of key) {
1198
+ let child = node.children.get(character);
1199
+ if (!child) {
1200
+ child = this.createTrieNode();
1201
+ node.children.set(character, child);
1202
+ }
1203
+ node = child;
1204
+ }
1205
+ node.terminal = true;
1206
+ }
1207
+ findNode(prefix) {
1208
+ let node = this.root;
1209
+ for (const character of prefix) {
1210
+ node = node.children.get(character);
1211
+ if (!node) {
1212
+ return void 0;
1213
+ }
1214
+ }
1215
+ return node;
1216
+ }
1217
+ collectFromNode(node, prefix, matches) {
1218
+ if (node.terminal) {
1219
+ matches.push(prefix);
1220
+ }
1221
+ for (const [character, child] of node.children) {
1222
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1223
+ }
1224
+ }
1225
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1226
+ const stateKey = `${node.id}:${patternIndex}`;
1227
+ if (visited.has(stateKey)) {
1228
+ return;
1229
+ }
1230
+ visited.add(stateKey);
1231
+ if (patternIndex === pattern.length) {
1232
+ if (node.terminal) {
1233
+ matches.add(prefix);
1234
+ }
1235
+ return;
1236
+ }
1237
+ const patternChar = pattern[patternIndex];
1238
+ if (patternChar === void 0) {
1239
+ return;
1240
+ }
1241
+ if (patternChar === "*") {
1242
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1243
+ for (const [character, child2] of node.children) {
1244
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1245
+ }
1246
+ return;
1247
+ }
1248
+ if (patternChar === "?") {
1249
+ for (const [character, child2] of node.children) {
1250
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1251
+ }
1252
+ return;
1253
+ }
1254
+ const child = node.children.get(patternChar);
1255
+ if (child) {
1256
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1257
+ }
1039
1258
  }
1040
1259
  pruneKnownKeysIfNeeded() {
1041
1260
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1052,7 +1271,7 @@ var TagIndex = class {
1052
1271
  }
1053
1272
  }
1054
1273
  removeKey(key) {
1055
- this.knownKeys.delete(key);
1274
+ this.removeKnownKey(key);
1056
1275
  const tags = this.keyToTags.get(key);
1057
1276
  if (!tags) {
1058
1277
  return;
@@ -1069,7 +1288,70 @@ var TagIndex = class {
1069
1288
  }
1070
1289
  this.keyToTags.delete(key);
1071
1290
  }
1291
+ removeKnownKey(key) {
1292
+ if (!this.knownKeys.delete(key)) {
1293
+ return;
1294
+ }
1295
+ const path = [];
1296
+ let node = this.root;
1297
+ for (const character of key) {
1298
+ const child = node.children.get(character);
1299
+ if (!child) {
1300
+ return;
1301
+ }
1302
+ path.push([node, character]);
1303
+ node = child;
1304
+ }
1305
+ node.terminal = false;
1306
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1307
+ const entry = path[index];
1308
+ if (!entry) {
1309
+ continue;
1310
+ }
1311
+ const [parent, character] = entry;
1312
+ const child = parent.children.get(character);
1313
+ if (!child || child.terminal || child.children.size > 0) {
1314
+ break;
1315
+ }
1316
+ parent.children.delete(character);
1317
+ }
1318
+ }
1319
+ };
1320
+
1321
+ // ../../src/serialization/JsonSerializer.ts
1322
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1323
+ var JsonSerializer = class {
1324
+ serialize(value) {
1325
+ return JSON.stringify(value);
1326
+ }
1327
+ deserialize(payload) {
1328
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1329
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1330
+ }
1072
1331
  };
1332
+ var MAX_SANITIZE_DEPTH = 200;
1333
+ function sanitizeJsonValue(value, depth) {
1334
+ if (depth > MAX_SANITIZE_DEPTH) {
1335
+ return value;
1336
+ }
1337
+ if (Array.isArray(value)) {
1338
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1339
+ }
1340
+ if (!isPlainObject(value)) {
1341
+ return value;
1342
+ }
1343
+ const sanitized = {};
1344
+ for (const [key, entry] of Object.entries(value)) {
1345
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1346
+ continue;
1347
+ }
1348
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1349
+ }
1350
+ return sanitized;
1351
+ }
1352
+ function isPlainObject(value) {
1353
+ return Object.prototype.toString.call(value) === "[object Object]";
1354
+ }
1073
1355
 
1074
1356
  // ../../src/stampede/StampedeGuard.ts
1075
1357
  var StampedeGuard = class {
@@ -1080,7 +1362,8 @@ var StampedeGuard = class {
1080
1362
  return await entry.mutex.runExclusive(task);
1081
1363
  } finally {
1082
1364
  entry.references -= 1;
1083
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1365
+ const current = this.mutexes.get(key);
1366
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1084
1367
  this.mutexes.delete(key);
1085
1368
  }
1086
1369
  }
@@ -1110,8 +1393,10 @@ var CacheMissError = class extends Error {
1110
1393
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1111
1394
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1112
1395
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1396
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1113
1397
  var MAX_CACHE_KEY_LENGTH = 1024;
1114
1398
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1399
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1115
1400
  var DebugLogger = class {
1116
1401
  enabled;
1117
1402
  constructor(enabled) {
@@ -1158,6 +1443,21 @@ var CacheStack = class extends EventEmitter {
1158
1443
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1159
1444
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1160
1445
  this.tagIndex = options.tagIndex ?? new TagIndex();
1446
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1447
+ this.logger.warn?.(
1448
+ "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."
1449
+ );
1450
+ }
1451
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1452
+ this.logger.warn?.(
1453
+ "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."
1454
+ );
1455
+ }
1456
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1457
+ this.logger.warn?.(
1458
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1459
+ );
1460
+ }
1161
1461
  this.initializeWriteBehind(options.writeBehind);
1162
1462
  this.startup = this.initialize();
1163
1463
  }
@@ -1171,6 +1471,7 @@ var CacheStack = class extends EventEmitter {
1171
1471
  logger;
1172
1472
  tagIndex;
1173
1473
  fetchRateLimiter = new FetchRateLimiter();
1474
+ snapshotSerializer = new JsonSerializer();
1174
1475
  backgroundRefreshes = /* @__PURE__ */ new Map();
1175
1476
  layerDegradedUntil = /* @__PURE__ */ new Map();
1176
1477
  ttlResolver;
@@ -1179,6 +1480,7 @@ var CacheStack = class extends EventEmitter {
1179
1480
  writeBehindQueue = [];
1180
1481
  writeBehindTimer;
1181
1482
  writeBehindFlushPromise;
1483
+ generationCleanupPromise;
1182
1484
  isDisconnecting = false;
1183
1485
  disconnectPromise;
1184
1486
  /**
@@ -1191,6 +1493,9 @@ var CacheStack = class extends EventEmitter {
1191
1493
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1192
1494
  this.validateWriteOptions(options);
1193
1495
  await this.awaitStartup("get");
1496
+ return this.getPrepared(normalizedKey, fetcher, options);
1497
+ }
1498
+ async getPrepared(normalizedKey, fetcher, options) {
1194
1499
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1195
1500
  if (hit.found) {
1196
1501
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1268,6 +1573,7 @@ var CacheStack = class extends EventEmitter {
1268
1573
  return true;
1269
1574
  }
1270
1575
  } catch {
1576
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1271
1577
  }
1272
1578
  } else {
1273
1579
  try {
@@ -1275,7 +1581,8 @@ var CacheStack = class extends EventEmitter {
1275
1581
  if (value !== null) {
1276
1582
  return true;
1277
1583
  }
1278
- } catch {
1584
+ } catch (error) {
1585
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1279
1586
  }
1280
1587
  }
1281
1588
  }
@@ -1367,13 +1674,14 @@ var CacheStack = class extends EventEmitter {
1367
1674
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1368
1675
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1369
1676
  if (!canFastPath) {
1677
+ await this.awaitStartup("mget");
1370
1678
  const pendingReads = /* @__PURE__ */ new Map();
1371
1679
  return Promise.all(
1372
1680
  normalizedEntries.map((entry) => {
1373
1681
  const optionsSignature = this.serializeOptions(entry.options);
1374
1682
  const existing = pendingReads.get(entry.key);
1375
1683
  if (!existing) {
1376
- const promise = this.get(entry.key, entry.fetch, entry.options);
1684
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1377
1685
  pendingReads.set(entry.key, {
1378
1686
  promise,
1379
1687
  fetch: entry.fetch,
@@ -1512,14 +1820,14 @@ var CacheStack = class extends EventEmitter {
1512
1820
  }
1513
1821
  async invalidateByPattern(pattern) {
1514
1822
  await this.awaitStartup("invalidateByPattern");
1515
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1823
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1516
1824
  await this.deleteKeys(keys);
1517
1825
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1518
1826
  }
1519
1827
  async invalidateByPrefix(prefix) {
1520
1828
  await this.awaitStartup("invalidateByPrefix");
1521
1829
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1522
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1830
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1523
1831
  await this.deleteKeys(keys);
1524
1832
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1525
1833
  }
@@ -1569,9 +1877,18 @@ var CacheStack = class extends EventEmitter {
1569
1877
  })
1570
1878
  );
1571
1879
  }
1880
+ /**
1881
+ * Rotates the active generation prefix used for all future cache keys.
1882
+ * Previous-generation keys remain in the underlying layers until they expire,
1883
+ * unless `generationCleanup` is enabled to prune them in the background.
1884
+ */
1572
1885
  bumpGeneration(nextGeneration) {
1573
1886
  const current = this.currentGeneration ?? 0;
1887
+ const previousGeneration = this.currentGeneration;
1574
1888
  this.currentGeneration = nextGeneration ?? current + 1;
1889
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1890
+ this.scheduleGenerationCleanup(previousGeneration);
1891
+ }
1575
1892
  return this.currentGeneration;
1576
1893
  }
1577
1894
  /**
@@ -1655,27 +1972,28 @@ var CacheStack = class extends EventEmitter {
1655
1972
  this.assertActive("persistToFile");
1656
1973
  const snapshot = await this.exportState();
1657
1974
  const { promises: fs } = await import("fs");
1658
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1975
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1659
1976
  }
1660
1977
  async restoreFromFile(filePath) {
1661
1978
  this.assertActive("restoreFromFile");
1662
1979
  const { promises: fs } = await import("fs");
1663
- const raw = await fs.readFile(filePath, "utf8");
1980
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1664
1981
  let parsed;
1665
1982
  try {
1666
- parsed = JSON.parse(raw, (_key, value) => {
1667
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1668
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1669
- }
1670
- return value;
1671
- });
1983
+ parsed = JSON.parse(raw);
1672
1984
  } catch (cause) {
1673
1985
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1674
1986
  }
1675
1987
  if (!this.isCacheSnapshotEntries(parsed)) {
1676
1988
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1677
1989
  }
1678
- await this.importState(parsed);
1990
+ await this.importState(
1991
+ parsed.map((entry) => ({
1992
+ key: entry.key,
1993
+ value: this.sanitizeSnapshotValue(entry.value),
1994
+ ttl: entry.ttl
1995
+ }))
1996
+ );
1679
1997
  }
1680
1998
  async disconnect() {
1681
1999
  if (!this.disconnectPromise) {
@@ -1684,6 +2002,7 @@ var CacheStack = class extends EventEmitter {
1684
2002
  await this.startup;
1685
2003
  await this.unsubscribeInvalidation?.();
1686
2004
  await this.flushWriteBehindQueue();
2005
+ await this.generationCleanupPromise;
1687
2006
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1688
2007
  if (this.writeBehindTimer) {
1689
2008
  clearInterval(this.writeBehindTimer);
@@ -1751,6 +2070,7 @@ var CacheStack = class extends EventEmitter {
1751
2070
  try {
1752
2071
  fetched = await this.fetchRateLimiter.schedule(
1753
2072
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
2073
+ { key, fetcher },
1754
2074
  fetcher
1755
2075
  );
1756
2076
  this.circuitBreakerManager.recordSuccess(key);
@@ -1766,8 +2086,14 @@ var CacheStack = class extends EventEmitter {
1766
2086
  await this.storeEntry(key, "empty", null, options);
1767
2087
  return null;
1768
2088
  }
1769
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1770
- return fetched;
2089
+ if (options?.shouldCache) {
2090
+ try {
2091
+ if (!options.shouldCache(fetched)) {
2092
+ return fetched;
2093
+ }
2094
+ } catch (error) {
2095
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
2096
+ }
1771
2097
  }
1772
2098
  await this.storeEntry(key, "value", fetched, options);
1773
2099
  return fetched;
@@ -1994,7 +2320,7 @@ var CacheStack = class extends EventEmitter {
1994
2320
  const refresh = (async () => {
1995
2321
  this.metricsCollector.increment("refreshes");
1996
2322
  try {
1997
- await this.fetchWithGuards(key, fetcher, options);
2323
+ await this.runBackgroundRefresh(key, fetcher, options);
1998
2324
  } catch (error) {
1999
2325
  this.metricsCollector.increment("refreshErrors");
2000
2326
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2004,11 +2330,22 @@ var CacheStack = class extends EventEmitter {
2004
2330
  })();
2005
2331
  this.backgroundRefreshes.set(key, refresh);
2006
2332
  }
2333
+ async runBackgroundRefresh(key, fetcher, options) {
2334
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2335
+ await this.fetchWithGuards(
2336
+ key,
2337
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2338
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2339
+ }),
2340
+ options
2341
+ );
2342
+ }
2007
2343
  resolveSingleFlightOptions() {
2008
2344
  return {
2009
2345
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
2010
2346
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
2011
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2347
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
2348
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
2012
2349
  };
2013
2350
  }
2014
2351
  async deleteKeys(keys) {
@@ -2070,8 +2407,120 @@ var CacheStack = class extends EventEmitter {
2070
2407
  sleep(ms) {
2071
2408
  return new Promise((resolve) => setTimeout(resolve, ms));
2072
2409
  }
2410
+ async withTimeout(promise, timeoutMs, onTimeout) {
2411
+ if (timeoutMs <= 0) {
2412
+ return promise;
2413
+ }
2414
+ let timer;
2415
+ const observedPromise = promise.then(
2416
+ (value) => ({ kind: "value", value }),
2417
+ (error) => ({ kind: "error", error })
2418
+ );
2419
+ try {
2420
+ const result = await Promise.race([
2421
+ observedPromise,
2422
+ new Promise((_, reject) => {
2423
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2424
+ timer.unref?.();
2425
+ })
2426
+ ]);
2427
+ if (result && typeof result === "object" && "kind" in result) {
2428
+ if (result.kind === "error") {
2429
+ throw result.error;
2430
+ }
2431
+ return result.value;
2432
+ }
2433
+ return result;
2434
+ } finally {
2435
+ if (timer) {
2436
+ clearTimeout(timer);
2437
+ }
2438
+ }
2439
+ }
2073
2440
  shouldBroadcastL1Invalidation() {
2074
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2441
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2442
+ }
2443
+ async collectKeysWithPrefix(prefix) {
2444
+ const matches = new Set(
2445
+ this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2446
+ );
2447
+ await Promise.all(
2448
+ this.layers.map(async (layer) => {
2449
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2450
+ return;
2451
+ }
2452
+ try {
2453
+ const keys = await layer.keys();
2454
+ for (const key of keys) {
2455
+ if (key.startsWith(prefix)) {
2456
+ matches.add(key);
2457
+ }
2458
+ }
2459
+ } catch (error) {
2460
+ await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2461
+ }
2462
+ })
2463
+ );
2464
+ return [...matches];
2465
+ }
2466
+ async collectKeysMatchingPattern(pattern) {
2467
+ const matches = new Set(await this.tagIndex.matchPattern(pattern));
2468
+ await Promise.all(
2469
+ this.layers.map(async (layer) => {
2470
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2471
+ return;
2472
+ }
2473
+ try {
2474
+ const keys = await layer.keys();
2475
+ for (const key of keys) {
2476
+ if (PatternMatcher.matches(pattern, key)) {
2477
+ matches.add(key);
2478
+ }
2479
+ }
2480
+ } catch (error) {
2481
+ await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2482
+ }
2483
+ })
2484
+ );
2485
+ return [...matches];
2486
+ }
2487
+ shouldCleanupGenerations() {
2488
+ return Boolean(this.options.generationCleanup);
2489
+ }
2490
+ generationCleanupBatchSize() {
2491
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2492
+ return configured ?? 500;
2493
+ }
2494
+ scheduleGenerationCleanup(generation) {
2495
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2496
+ this.logger.warn?.("generation-cleanup-error", {
2497
+ generation,
2498
+ error: this.formatError(error)
2499
+ });
2500
+ });
2501
+ this.generationCleanupPromise = task.finally(() => {
2502
+ if (this.generationCleanupPromise === task) {
2503
+ this.generationCleanupPromise = void 0;
2504
+ }
2505
+ });
2506
+ }
2507
+ async cleanupGeneration(generation) {
2508
+ const prefix = `v${generation}:`;
2509
+ const keys = await this.collectKeysWithPrefix(prefix);
2510
+ if (keys.length === 0) {
2511
+ return;
2512
+ }
2513
+ const batchSize = this.generationCleanupBatchSize();
2514
+ for (let index = 0; index < keys.length; index += batchSize) {
2515
+ const batch = keys.slice(index, index + batchSize);
2516
+ await this.deleteKeys(batch);
2517
+ await this.publishInvalidation({
2518
+ scope: "keys",
2519
+ keys: batch,
2520
+ sourceId: this.instanceId,
2521
+ operation: "invalidate"
2522
+ });
2523
+ }
2075
2524
  }
2076
2525
  initializeWriteBehind(options) {
2077
2526
  if (this.options.writeStrategy !== "write-behind") {
@@ -2109,7 +2558,17 @@ var CacheStack = class extends EventEmitter {
2109
2558
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2110
2559
  const batch = this.writeBehindQueue.splice(0, batchSize);
2111
2560
  this.writeBehindFlushPromise = (async () => {
2112
- await Promise.allSettled(batch.map((operation) => operation()));
2561
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2562
+ const failures = results.filter((result) => result.status === "rejected");
2563
+ if (failures.length > 0) {
2564
+ this.metricsCollector.increment("writeFailures", failures.length);
2565
+ this.logger.error?.("write-behind-flush-failure", {
2566
+ failed: failures.length,
2567
+ total: batch.length,
2568
+ errors: failures.map((failure) => this.formatError(failure.reason))
2569
+ });
2570
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2571
+ }
2113
2572
  })();
2114
2573
  await this.writeBehindFlushPromise;
2115
2574
  this.writeBehindFlushPromise = void 0;
@@ -2213,8 +2672,14 @@ var CacheStack = class extends EventEmitter {
2213
2672
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2214
2673
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2215
2674
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2675
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2676
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2677
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2216
2678
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2217
2679
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2680
+ if (typeof this.options.generationCleanup === "object") {
2681
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2682
+ }
2218
2683
  if (this.options.generation !== void 0) {
2219
2684
  this.validateNonNegativeNumber("generation", this.options.generation);
2220
2685
  }
@@ -2232,6 +2697,7 @@ var CacheStack = class extends EventEmitter {
2232
2697
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2233
2698
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
2234
2699
  this.validateCircuitBreakerOptions(options.circuitBreaker);
2700
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2235
2701
  }
2236
2702
  validateLayerNumberOption(name, value) {
2237
2703
  if (value === void 0) {
@@ -2256,6 +2722,20 @@ var CacheStack = class extends EventEmitter {
2256
2722
  throw new Error(`${name} must be a positive finite number.`);
2257
2723
  }
2258
2724
  }
2725
+ validateRateLimitOptions(name, options) {
2726
+ if (!options) {
2727
+ return;
2728
+ }
2729
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2730
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2731
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2732
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2733
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2734
+ }
2735
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2736
+ throw new Error(`${name}.bucketKey must not be empty.`);
2737
+ }
2738
+ }
2259
2739
  validateNonNegativeNumber(name, value) {
2260
2740
  if (!Number.isFinite(value) || value < 0) {
2261
2741
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -2271,6 +2751,9 @@ var CacheStack = class extends EventEmitter {
2271
2751
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2272
2752
  throw new Error("Cache key contains unsupported control characters.");
2273
2753
  }
2754
+ if (/[\uD800-\uDFFF]/.test(key)) {
2755
+ throw new Error("Cache key contains unsupported surrogate code points.");
2756
+ }
2274
2757
  return key;
2275
2758
  }
2276
2759
  validateTtlPolicy(name, policy) {
@@ -2348,6 +2831,14 @@ var CacheStack = class extends EventEmitter {
2348
2831
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2349
2832
  return null;
2350
2833
  }
2834
+ async reportRecoverableLayerFailure(layer, operation, error) {
2835
+ if (this.isGracefulDegradationEnabled()) {
2836
+ await this.handleLayerFailure(layer, operation, error);
2837
+ return;
2838
+ }
2839
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2840
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2841
+ }
2351
2842
  isGracefulDegradationEnabled() {
2352
2843
  return Boolean(this.options.gracefulDegradation);
2353
2844
  }
@@ -2371,10 +2862,16 @@ var CacheStack = class extends EventEmitter {
2371
2862
  }
2372
2863
  }
2373
2864
  serializeKeyPart(value) {
2374
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2375
- return String(value);
2865
+ if (typeof value === "string") {
2866
+ return `s:${value}`;
2867
+ }
2868
+ if (typeof value === "number") {
2869
+ return `n:${value}`;
2870
+ }
2871
+ if (typeof value === "boolean") {
2872
+ return `b:${value}`;
2376
2873
  }
2377
- return JSON.stringify(this.normalizeForSerialization(value));
2874
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2378
2875
  }
2379
2876
  isCacheSnapshotEntries(value) {
2380
2877
  return Array.isArray(value) && value.every((entry) => {
@@ -2382,15 +2879,39 @@ var CacheStack = class extends EventEmitter {
2382
2879
  return false;
2383
2880
  }
2384
2881
  const candidate = entry;
2385
- return typeof candidate.key === "string";
2882
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2386
2883
  });
2387
2884
  }
2885
+ sanitizeSnapshotValue(value) {
2886
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2887
+ }
2888
+ async validateSnapshotFilePath(filePath) {
2889
+ if (filePath.length === 0) {
2890
+ throw new Error("filePath must not be empty.");
2891
+ }
2892
+ if (filePath.includes("\0")) {
2893
+ throw new Error("filePath must not contain null bytes.");
2894
+ }
2895
+ const path = await import("path");
2896
+ const resolved = path.resolve(filePath);
2897
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2898
+ if (baseDir !== false) {
2899
+ const relative = path.relative(baseDir, resolved);
2900
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2901
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2902
+ }
2903
+ }
2904
+ return resolved;
2905
+ }
2388
2906
  normalizeForSerialization(value) {
2389
2907
  if (Array.isArray(value)) {
2390
2908
  return value.map((entry) => this.normalizeForSerialization(entry));
2391
2909
  }
2392
2910
  if (value && typeof value === "object") {
2393
2911
  return Object.keys(value).sort().reduce((normalized, key) => {
2912
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2913
+ return normalized;
2914
+ }
2394
2915
  normalized[key] = this.normalizeForSerialization(value[key]);
2395
2916
  return normalized;
2396
2917
  }, {});