layercache 1.2.2 → 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,8 +598,9 @@ var CircuitBreakerManager = class {
598
598
 
599
599
  // ../../src/internal/FetchRateLimiter.ts
600
600
  var FetchRateLimiter = class {
601
- queue = [];
602
601
  buckets = /* @__PURE__ */ new Map();
602
+ queuesByBucket = /* @__PURE__ */ new Map();
603
+ pendingBuckets = /* @__PURE__ */ new Set();
603
604
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
604
605
  nextFetcherBucketId = 0;
605
606
  drainTimer;
@@ -612,13 +613,17 @@ var FetchRateLimiter = class {
612
613
  return task();
613
614
  }
614
615
  return new Promise((resolve, reject) => {
615
- this.queue.push({
616
- bucketKey: this.resolveBucketKey(normalized, context),
616
+ const bucketKey = this.resolveBucketKey(normalized, context);
617
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
618
+ queue.push({
619
+ bucketKey,
617
620
  options: normalized,
618
621
  task,
619
622
  resolve,
620
623
  reject
621
624
  });
625
+ this.queuesByBucket.set(bucketKey, queue);
626
+ this.pendingBuckets.add(bucketKey);
622
627
  this.drain();
623
628
  });
624
629
  }
@@ -661,22 +666,30 @@ var FetchRateLimiter = class {
661
666
  clearTimeout(this.drainTimer);
662
667
  this.drainTimer = void 0;
663
668
  }
664
- while (this.queue.length > 0) {
665
- let nextIndex = -1;
669
+ while (this.pendingBuckets.size > 0) {
670
+ let nextBucketKey;
666
671
  let nextWaitMs = Number.POSITIVE_INFINITY;
667
- for (let index = 0; index < this.queue.length; index += 1) {
668
- const next2 = this.queue[index];
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];
669
680
  if (!next2) {
681
+ this.pendingBuckets.delete(bucketKey);
682
+ this.queuesByBucket.delete(bucketKey);
670
683
  continue;
671
684
  }
672
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
685
+ const waitMs = this.waitTime(bucketKey, next2.options);
673
686
  if (waitMs <= 0) {
674
- nextIndex = index;
687
+ nextBucketKey = bucketKey;
675
688
  break;
676
689
  }
677
690
  nextWaitMs = Math.min(nextWaitMs, waitMs);
678
691
  }
679
- if (nextIndex < 0) {
692
+ if (!nextBucketKey) {
680
693
  if (Number.isFinite(nextWaitMs)) {
681
694
  this.drainTimer = setTimeout(() => {
682
695
  this.drainTimer = void 0;
@@ -686,15 +699,32 @@ var FetchRateLimiter = class {
686
699
  }
687
700
  return;
688
701
  }
689
- const next = this.queue.splice(nextIndex, 1)[0];
702
+ const queue = this.queuesByBucket.get(nextBucketKey);
703
+ const next = queue?.shift();
690
704
  if (!next) {
691
- return;
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);
692
712
  }
693
713
  const bucket = this.bucketState(next.bucketKey);
714
+ if (bucket.cleanupTimer) {
715
+ clearTimeout(bucket.cleanupTimer);
716
+ bucket.cleanupTimer = void 0;
717
+ }
694
718
  bucket.active += 1;
695
- bucket.startedAt.push(Date.now());
719
+ if (next.options.intervalMs && next.options.maxPerInterval) {
720
+ bucket.startedAt.push(Date.now());
721
+ }
696
722
  void next.task().then(next.resolve, next.reject).finally(() => {
697
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);
698
728
  this.drain();
699
729
  });
700
730
  }
@@ -736,6 +766,31 @@ var FetchRateLimiter = class {
736
766
  this.buckets.set(bucketKey, bucket);
737
767
  return bucket;
738
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?.();
793
+ }
739
794
  };
740
795
 
741
796
  // ../../src/internal/MetricsCollector.ts
@@ -814,7 +869,30 @@ var MetricsCollector = class {
814
869
 
815
870
  // ../../src/internal/StoredValue.ts
816
871
  function isStoredValueEnvelope(value) {
817
- 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;
818
896
  }
819
897
  function createStoredValueEnvelope(options) {
820
898
  const now = options.now ?? Date.now();
@@ -1079,15 +1157,17 @@ var TagIndex = class {
1079
1157
  keyToTags = /* @__PURE__ */ new Map();
1080
1158
  knownKeys = /* @__PURE__ */ new Set();
1081
1159
  maxKnownKeys;
1160
+ nextNodeId = 1;
1161
+ root = this.createTrieNode();
1082
1162
  constructor(options = {}) {
1083
- this.maxKnownKeys = options.maxKnownKeys;
1163
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
1084
1164
  }
1085
1165
  async touch(key) {
1086
- this.knownKeys.add(key);
1166
+ this.insertKnownKey(key);
1087
1167
  this.pruneKnownKeysIfNeeded();
1088
1168
  }
1089
1169
  async track(key, tags) {
1090
- this.knownKeys.add(key);
1170
+ this.insertKnownKey(key);
1091
1171
  this.pruneKnownKeysIfNeeded();
1092
1172
  if (tags.length === 0) {
1093
1173
  return;
@@ -1113,18 +1193,104 @@ var TagIndex = class {
1113
1193
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1114
1194
  }
1115
1195
  async keysForPrefix(prefix) {
1116
- 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;
1117
1203
  }
1118
1204
  async tagsForKey(key) {
1119
1205
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1120
1206
  }
1121
1207
  async matchPattern(pattern) {
1122
- 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];
1123
1211
  }
1124
1212
  async clear() {
1125
1213
  this.tagToKeys.clear();
1126
1214
  this.keyToTags.clear();
1127
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
+ }
1128
1294
  }
1129
1295
  pruneKnownKeysIfNeeded() {
1130
1296
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1141,7 +1307,7 @@ var TagIndex = class {
1141
1307
  }
1142
1308
  }
1143
1309
  removeKey(key) {
1144
- this.knownKeys.delete(key);
1310
+ this.removeKnownKey(key);
1145
1311
  const tags = this.keyToTags.get(key);
1146
1312
  if (!tags) {
1147
1313
  return;
@@ -1158,8 +1324,71 @@ var TagIndex = class {
1158
1324
  }
1159
1325
  this.keyToTags.delete(key);
1160
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
+ }
1161
1355
  };
1162
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
+ }
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
+ }
1391
+
1163
1392
  // ../../src/stampede/StampedeGuard.ts
1164
1393
  var StampedeGuard = class {
1165
1394
  mutexes = /* @__PURE__ */ new Map();
@@ -1169,7 +1398,8 @@ var StampedeGuard = class {
1169
1398
  return await entry.mutex.runExclusive(task);
1170
1399
  } finally {
1171
1400
  entry.references -= 1;
1172
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1401
+ const current = this.mutexes.get(key);
1402
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1173
1403
  this.mutexes.delete(key);
1174
1404
  }
1175
1405
  }
@@ -1199,8 +1429,10 @@ var CacheMissError = class extends Error {
1199
1429
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1200
1430
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1201
1431
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1432
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1202
1433
  var MAX_CACHE_KEY_LENGTH = 1024;
1203
1434
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1435
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1204
1436
  var DebugLogger = class {
1205
1437
  enabled;
1206
1438
  constructor(enabled) {
@@ -1247,6 +1479,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
1247
1479
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1248
1480
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1249
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
+ }
1250
1497
  this.initializeWriteBehind(options.writeBehind);
1251
1498
  this.startup = this.initialize();
1252
1499
  }
@@ -1260,6 +1507,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1260
1507
  logger;
1261
1508
  tagIndex;
1262
1509
  fetchRateLimiter = new FetchRateLimiter();
1510
+ snapshotSerializer = new JsonSerializer();
1263
1511
  backgroundRefreshes = /* @__PURE__ */ new Map();
1264
1512
  layerDegradedUntil = /* @__PURE__ */ new Map();
1265
1513
  ttlResolver;
@@ -1268,6 +1516,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1268
1516
  writeBehindQueue = [];
1269
1517
  writeBehindTimer;
1270
1518
  writeBehindFlushPromise;
1519
+ generationCleanupPromise;
1271
1520
  isDisconnecting = false;
1272
1521
  disconnectPromise;
1273
1522
  /**
@@ -1280,6 +1529,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1280
1529
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1281
1530
  this.validateWriteOptions(options);
1282
1531
  await this.awaitStartup("get");
1532
+ return this.getPrepared(normalizedKey, fetcher, options);
1533
+ }
1534
+ async getPrepared(normalizedKey, fetcher, options) {
1283
1535
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1284
1536
  if (hit.found) {
1285
1537
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1357,6 +1609,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1357
1609
  return true;
1358
1610
  }
1359
1611
  } catch {
1612
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1360
1613
  }
1361
1614
  } else {
1362
1615
  try {
@@ -1364,7 +1617,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
1364
1617
  if (value !== null) {
1365
1618
  return true;
1366
1619
  }
1367
- } catch {
1620
+ } catch (error) {
1621
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1368
1622
  }
1369
1623
  }
1370
1624
  }
@@ -1456,13 +1710,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1456
1710
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1457
1711
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1458
1712
  if (!canFastPath) {
1713
+ await this.awaitStartup("mget");
1459
1714
  const pendingReads = /* @__PURE__ */ new Map();
1460
1715
  return Promise.all(
1461
1716
  normalizedEntries.map((entry) => {
1462
1717
  const optionsSignature = this.serializeOptions(entry.options);
1463
1718
  const existing = pendingReads.get(entry.key);
1464
1719
  if (!existing) {
1465
- const promise = this.get(entry.key, entry.fetch, entry.options);
1720
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1466
1721
  pendingReads.set(entry.key, {
1467
1722
  promise,
1468
1723
  fetch: entry.fetch,
@@ -1601,14 +1856,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1601
1856
  }
1602
1857
  async invalidateByPattern(pattern) {
1603
1858
  await this.awaitStartup("invalidateByPattern");
1604
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1859
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1605
1860
  await this.deleteKeys(keys);
1606
1861
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1607
1862
  }
1608
1863
  async invalidateByPrefix(prefix) {
1609
1864
  await this.awaitStartup("invalidateByPrefix");
1610
1865
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1611
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1866
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1612
1867
  await this.deleteKeys(keys);
1613
1868
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1614
1869
  }
@@ -1658,9 +1913,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
1658
1913
  })
1659
1914
  );
1660
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
+ */
1661
1921
  bumpGeneration(nextGeneration) {
1662
1922
  const current = this.currentGeneration ?? 0;
1923
+ const previousGeneration = this.currentGeneration;
1663
1924
  this.currentGeneration = nextGeneration ?? current + 1;
1925
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1926
+ this.scheduleGenerationCleanup(previousGeneration);
1927
+ }
1664
1928
  return this.currentGeneration;
1665
1929
  }
1666
1930
  /**
@@ -1744,27 +2008,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
1744
2008
  this.assertActive("persistToFile");
1745
2009
  const snapshot = await this.exportState();
1746
2010
  const { promises: fs } = await import("fs");
1747
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
2011
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1748
2012
  }
1749
2013
  async restoreFromFile(filePath) {
1750
2014
  this.assertActive("restoreFromFile");
1751
2015
  const { promises: fs } = await import("fs");
1752
- const raw = await fs.readFile(filePath, "utf8");
2016
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1753
2017
  let parsed;
1754
2018
  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
- });
2019
+ parsed = JSON.parse(raw);
1761
2020
  } catch (cause) {
1762
2021
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1763
2022
  }
1764
2023
  if (!this.isCacheSnapshotEntries(parsed)) {
1765
2024
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1766
2025
  }
1767
- 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
+ );
1768
2033
  }
1769
2034
  async disconnect() {
1770
2035
  if (!this.disconnectPromise) {
@@ -1773,6 +2038,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1773
2038
  await this.startup;
1774
2039
  await this.unsubscribeInvalidation?.();
1775
2040
  await this.flushWriteBehindQueue();
2041
+ await this.generationCleanupPromise;
1776
2042
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1777
2043
  if (this.writeBehindTimer) {
1778
2044
  clearInterval(this.writeBehindTimer);
@@ -1856,8 +2122,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1856
2122
  await this.storeEntry(key, "empty", null, options);
1857
2123
  return null;
1858
2124
  }
1859
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1860
- 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
+ }
1861
2133
  }
1862
2134
  await this.storeEntry(key, "value", fetched, options);
1863
2135
  return fetched;
@@ -2084,7 +2356,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2084
2356
  const refresh = (async () => {
2085
2357
  this.metricsCollector.increment("refreshes");
2086
2358
  try {
2087
- await this.fetchWithGuards(key, fetcher, options);
2359
+ await this.runBackgroundRefresh(key, fetcher, options);
2088
2360
  } catch (error) {
2089
2361
  this.metricsCollector.increment("refreshErrors");
2090
2362
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2094,6 +2366,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2094
2366
  })();
2095
2367
  this.backgroundRefreshes.set(key, refresh);
2096
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
+ }
2097
2379
  resolveSingleFlightOptions() {
2098
2380
  return {
2099
2381
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -2161,8 +2443,120 @@ var CacheStack = class extends import_node_events.EventEmitter {
2161
2443
  sleep(ms) {
2162
2444
  return new Promise((resolve) => setTimeout(resolve, ms));
2163
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
+ }
2164
2476
  shouldBroadcastL1Invalidation() {
2165
- 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
+ }
2166
2560
  }
2167
2561
  initializeWriteBehind(options) {
2168
2562
  if (this.options.writeStrategy !== "write-behind") {
@@ -2200,7 +2594,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
2200
2594
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2201
2595
  const batch = this.writeBehindQueue.splice(0, batchSize);
2202
2596
  this.writeBehindFlushPromise = (async () => {
2203
- 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
+ }
2204
2608
  })();
2205
2609
  await this.writeBehindFlushPromise;
2206
2610
  this.writeBehindFlushPromise = void 0;
@@ -2305,9 +2709,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
2305
2709
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2306
2710
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2307
2711
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2712
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2308
2713
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2309
2714
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2310
2715
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2716
+ if (typeof this.options.generationCleanup === "object") {
2717
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2718
+ }
2311
2719
  if (this.options.generation !== void 0) {
2312
2720
  this.validateNonNegativeNumber("generation", this.options.generation);
2313
2721
  }
@@ -2379,6 +2787,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2379
2787
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2380
2788
  throw new Error("Cache key contains unsupported control characters.");
2381
2789
  }
2790
+ if (/[\uD800-\uDFFF]/.test(key)) {
2791
+ throw new Error("Cache key contains unsupported surrogate code points.");
2792
+ }
2382
2793
  return key;
2383
2794
  }
2384
2795
  validateTtlPolicy(name, policy) {
@@ -2456,6 +2867,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2456
2867
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2457
2868
  return null;
2458
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
+ }
2459
2878
  isGracefulDegradationEnabled() {
2460
2879
  return Boolean(this.options.gracefulDegradation);
2461
2880
  }
@@ -2479,10 +2898,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2479
2898
  }
2480
2899
  }
2481
2900
  serializeKeyPart(value) {
2482
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2483
- return String(value);
2901
+ if (typeof value === "string") {
2902
+ return `s:${value}`;
2903
+ }
2904
+ if (typeof value === "number") {
2905
+ return `n:${value}`;
2484
2906
  }
2485
- return JSON.stringify(this.normalizeForSerialization(value));
2907
+ if (typeof value === "boolean") {
2908
+ return `b:${value}`;
2909
+ }
2910
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2486
2911
  }
2487
2912
  isCacheSnapshotEntries(value) {
2488
2913
  return Array.isArray(value) && value.every((entry) => {
@@ -2490,15 +2915,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
2490
2915
  return false;
2491
2916
  }
2492
2917
  const candidate = entry;
2493
- 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);
2494
2919
  });
2495
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
+ }
2496
2942
  normalizeForSerialization(value) {
2497
2943
  if (Array.isArray(value)) {
2498
2944
  return value.map((entry) => this.normalizeForSerialization(entry));
2499
2945
  }
2500
2946
  if (value && typeof value === "object") {
2501
2947
  return Object.keys(value).sort().reduce((normalized, key) => {
2948
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2949
+ return normalized;
2950
+ }
2502
2951
  normalized[key] = this.normalizeForSerialization(value[key]);
2503
2952
  return normalized;
2504
2953
  }, {});