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.
@@ -562,8 +562,9 @@ var CircuitBreakerManager = class {
562
562
 
563
563
  // ../../src/internal/FetchRateLimiter.ts
564
564
  var FetchRateLimiter = class {
565
- queue = [];
566
565
  buckets = /* @__PURE__ */ new Map();
566
+ queuesByBucket = /* @__PURE__ */ new Map();
567
+ pendingBuckets = /* @__PURE__ */ new Set();
567
568
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
568
569
  nextFetcherBucketId = 0;
569
570
  drainTimer;
@@ -576,13 +577,17 @@ var FetchRateLimiter = class {
576
577
  return task();
577
578
  }
578
579
  return new Promise((resolve, reject) => {
579
- this.queue.push({
580
- bucketKey: this.resolveBucketKey(normalized, context),
580
+ const bucketKey = this.resolveBucketKey(normalized, context);
581
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
582
+ queue.push({
583
+ bucketKey,
581
584
  options: normalized,
582
585
  task,
583
586
  resolve,
584
587
  reject
585
588
  });
589
+ this.queuesByBucket.set(bucketKey, queue);
590
+ this.pendingBuckets.add(bucketKey);
586
591
  this.drain();
587
592
  });
588
593
  }
@@ -625,22 +630,30 @@ var FetchRateLimiter = class {
625
630
  clearTimeout(this.drainTimer);
626
631
  this.drainTimer = void 0;
627
632
  }
628
- while (this.queue.length > 0) {
629
- let nextIndex = -1;
633
+ while (this.pendingBuckets.size > 0) {
634
+ let nextBucketKey;
630
635
  let nextWaitMs = Number.POSITIVE_INFINITY;
631
- for (let index = 0; index < this.queue.length; index += 1) {
632
- const next2 = this.queue[index];
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];
633
644
  if (!next2) {
645
+ this.pendingBuckets.delete(bucketKey);
646
+ this.queuesByBucket.delete(bucketKey);
634
647
  continue;
635
648
  }
636
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
649
+ const waitMs = this.waitTime(bucketKey, next2.options);
637
650
  if (waitMs <= 0) {
638
- nextIndex = index;
651
+ nextBucketKey = bucketKey;
639
652
  break;
640
653
  }
641
654
  nextWaitMs = Math.min(nextWaitMs, waitMs);
642
655
  }
643
- if (nextIndex < 0) {
656
+ if (!nextBucketKey) {
644
657
  if (Number.isFinite(nextWaitMs)) {
645
658
  this.drainTimer = setTimeout(() => {
646
659
  this.drainTimer = void 0;
@@ -650,15 +663,32 @@ var FetchRateLimiter = class {
650
663
  }
651
664
  return;
652
665
  }
653
- const next = this.queue.splice(nextIndex, 1)[0];
666
+ const queue = this.queuesByBucket.get(nextBucketKey);
667
+ const next = queue?.shift();
654
668
  if (!next) {
655
- return;
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);
656
676
  }
657
677
  const bucket = this.bucketState(next.bucketKey);
678
+ if (bucket.cleanupTimer) {
679
+ clearTimeout(bucket.cleanupTimer);
680
+ bucket.cleanupTimer = void 0;
681
+ }
658
682
  bucket.active += 1;
659
- bucket.startedAt.push(Date.now());
683
+ if (next.options.intervalMs && next.options.maxPerInterval) {
684
+ bucket.startedAt.push(Date.now());
685
+ }
660
686
  void next.task().then(next.resolve, next.reject).finally(() => {
661
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);
662
692
  this.drain();
663
693
  });
664
694
  }
@@ -700,6 +730,31 @@ var FetchRateLimiter = class {
700
730
  this.buckets.set(bucketKey, bucket);
701
731
  return bucket;
702
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?.();
757
+ }
703
758
  };
704
759
 
705
760
  // ../../src/internal/MetricsCollector.ts
@@ -778,7 +833,30 @@ var MetricsCollector = class {
778
833
 
779
834
  // ../../src/internal/StoredValue.ts
780
835
  function isStoredValueEnvelope(value) {
781
- 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;
782
860
  }
783
861
  function createStoredValueEnvelope(options) {
784
862
  const now = options.now ?? Date.now();
@@ -1043,15 +1121,17 @@ var TagIndex = class {
1043
1121
  keyToTags = /* @__PURE__ */ new Map();
1044
1122
  knownKeys = /* @__PURE__ */ new Set();
1045
1123
  maxKnownKeys;
1124
+ nextNodeId = 1;
1125
+ root = this.createTrieNode();
1046
1126
  constructor(options = {}) {
1047
- this.maxKnownKeys = options.maxKnownKeys;
1127
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
1048
1128
  }
1049
1129
  async touch(key) {
1050
- this.knownKeys.add(key);
1130
+ this.insertKnownKey(key);
1051
1131
  this.pruneKnownKeysIfNeeded();
1052
1132
  }
1053
1133
  async track(key, tags) {
1054
- this.knownKeys.add(key);
1134
+ this.insertKnownKey(key);
1055
1135
  this.pruneKnownKeysIfNeeded();
1056
1136
  if (tags.length === 0) {
1057
1137
  return;
@@ -1077,18 +1157,104 @@ var TagIndex = class {
1077
1157
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
1078
1158
  }
1079
1159
  async keysForPrefix(prefix) {
1080
- 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;
1081
1167
  }
1082
1168
  async tagsForKey(key) {
1083
1169
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
1084
1170
  }
1085
1171
  async matchPattern(pattern) {
1086
- 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];
1087
1175
  }
1088
1176
  async clear() {
1089
1177
  this.tagToKeys.clear();
1090
1178
  this.keyToTags.clear();
1091
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
+ }
1092
1258
  }
1093
1259
  pruneKnownKeysIfNeeded() {
1094
1260
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -1105,7 +1271,7 @@ var TagIndex = class {
1105
1271
  }
1106
1272
  }
1107
1273
  removeKey(key) {
1108
- this.knownKeys.delete(key);
1274
+ this.removeKnownKey(key);
1109
1275
  const tags = this.keyToTags.get(key);
1110
1276
  if (!tags) {
1111
1277
  return;
@@ -1122,8 +1288,71 @@ var TagIndex = class {
1122
1288
  }
1123
1289
  this.keyToTags.delete(key);
1124
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
+ }
1125
1319
  };
1126
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
+ }
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
+ }
1355
+
1127
1356
  // ../../src/stampede/StampedeGuard.ts
1128
1357
  var StampedeGuard = class {
1129
1358
  mutexes = /* @__PURE__ */ new Map();
@@ -1133,7 +1362,8 @@ var StampedeGuard = class {
1133
1362
  return await entry.mutex.runExclusive(task);
1134
1363
  } finally {
1135
1364
  entry.references -= 1;
1136
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1365
+ const current = this.mutexes.get(key);
1366
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1137
1367
  this.mutexes.delete(key);
1138
1368
  }
1139
1369
  }
@@ -1163,8 +1393,10 @@ var CacheMissError = class extends Error {
1163
1393
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1164
1394
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1165
1395
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1396
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1166
1397
  var MAX_CACHE_KEY_LENGTH = 1024;
1167
1398
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1399
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1168
1400
  var DebugLogger = class {
1169
1401
  enabled;
1170
1402
  constructor(enabled) {
@@ -1211,6 +1443,21 @@ var CacheStack = class extends EventEmitter {
1211
1443
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
1212
1444
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
1213
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
+ }
1214
1461
  this.initializeWriteBehind(options.writeBehind);
1215
1462
  this.startup = this.initialize();
1216
1463
  }
@@ -1224,6 +1471,7 @@ var CacheStack = class extends EventEmitter {
1224
1471
  logger;
1225
1472
  tagIndex;
1226
1473
  fetchRateLimiter = new FetchRateLimiter();
1474
+ snapshotSerializer = new JsonSerializer();
1227
1475
  backgroundRefreshes = /* @__PURE__ */ new Map();
1228
1476
  layerDegradedUntil = /* @__PURE__ */ new Map();
1229
1477
  ttlResolver;
@@ -1232,6 +1480,7 @@ var CacheStack = class extends EventEmitter {
1232
1480
  writeBehindQueue = [];
1233
1481
  writeBehindTimer;
1234
1482
  writeBehindFlushPromise;
1483
+ generationCleanupPromise;
1235
1484
  isDisconnecting = false;
1236
1485
  disconnectPromise;
1237
1486
  /**
@@ -1244,6 +1493,9 @@ var CacheStack = class extends EventEmitter {
1244
1493
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1245
1494
  this.validateWriteOptions(options);
1246
1495
  await this.awaitStartup("get");
1496
+ return this.getPrepared(normalizedKey, fetcher, options);
1497
+ }
1498
+ async getPrepared(normalizedKey, fetcher, options) {
1247
1499
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1248
1500
  if (hit.found) {
1249
1501
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1321,6 +1573,7 @@ var CacheStack = class extends EventEmitter {
1321
1573
  return true;
1322
1574
  }
1323
1575
  } catch {
1576
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1324
1577
  }
1325
1578
  } else {
1326
1579
  try {
@@ -1328,7 +1581,8 @@ var CacheStack = class extends EventEmitter {
1328
1581
  if (value !== null) {
1329
1582
  return true;
1330
1583
  }
1331
- } catch {
1584
+ } catch (error) {
1585
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1332
1586
  }
1333
1587
  }
1334
1588
  }
@@ -1420,13 +1674,14 @@ var CacheStack = class extends EventEmitter {
1420
1674
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1421
1675
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1422
1676
  if (!canFastPath) {
1677
+ await this.awaitStartup("mget");
1423
1678
  const pendingReads = /* @__PURE__ */ new Map();
1424
1679
  return Promise.all(
1425
1680
  normalizedEntries.map((entry) => {
1426
1681
  const optionsSignature = this.serializeOptions(entry.options);
1427
1682
  const existing = pendingReads.get(entry.key);
1428
1683
  if (!existing) {
1429
- const promise = this.get(entry.key, entry.fetch, entry.options);
1684
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1430
1685
  pendingReads.set(entry.key, {
1431
1686
  promise,
1432
1687
  fetch: entry.fetch,
@@ -1565,14 +1820,14 @@ var CacheStack = class extends EventEmitter {
1565
1820
  }
1566
1821
  async invalidateByPattern(pattern) {
1567
1822
  await this.awaitStartup("invalidateByPattern");
1568
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1823
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1569
1824
  await this.deleteKeys(keys);
1570
1825
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1571
1826
  }
1572
1827
  async invalidateByPrefix(prefix) {
1573
1828
  await this.awaitStartup("invalidateByPrefix");
1574
1829
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1575
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1830
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1576
1831
  await this.deleteKeys(keys);
1577
1832
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1578
1833
  }
@@ -1622,9 +1877,18 @@ var CacheStack = class extends EventEmitter {
1622
1877
  })
1623
1878
  );
1624
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
+ */
1625
1885
  bumpGeneration(nextGeneration) {
1626
1886
  const current = this.currentGeneration ?? 0;
1887
+ const previousGeneration = this.currentGeneration;
1627
1888
  this.currentGeneration = nextGeneration ?? current + 1;
1889
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1890
+ this.scheduleGenerationCleanup(previousGeneration);
1891
+ }
1628
1892
  return this.currentGeneration;
1629
1893
  }
1630
1894
  /**
@@ -1708,27 +1972,28 @@ var CacheStack = class extends EventEmitter {
1708
1972
  this.assertActive("persistToFile");
1709
1973
  const snapshot = await this.exportState();
1710
1974
  const { promises: fs } = await import("fs");
1711
- await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1975
+ await fs.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1712
1976
  }
1713
1977
  async restoreFromFile(filePath) {
1714
1978
  this.assertActive("restoreFromFile");
1715
1979
  const { promises: fs } = await import("fs");
1716
- const raw = await fs.readFile(filePath, "utf8");
1980
+ const raw = await fs.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1717
1981
  let parsed;
1718
1982
  try {
1719
- parsed = JSON.parse(raw, (_key, value) => {
1720
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1721
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1722
- }
1723
- return value;
1724
- });
1983
+ parsed = JSON.parse(raw);
1725
1984
  } catch (cause) {
1726
1985
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1727
1986
  }
1728
1987
  if (!this.isCacheSnapshotEntries(parsed)) {
1729
1988
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1730
1989
  }
1731
- 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
+ );
1732
1997
  }
1733
1998
  async disconnect() {
1734
1999
  if (!this.disconnectPromise) {
@@ -1737,6 +2002,7 @@ var CacheStack = class extends EventEmitter {
1737
2002
  await this.startup;
1738
2003
  await this.unsubscribeInvalidation?.();
1739
2004
  await this.flushWriteBehindQueue();
2005
+ await this.generationCleanupPromise;
1740
2006
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1741
2007
  if (this.writeBehindTimer) {
1742
2008
  clearInterval(this.writeBehindTimer);
@@ -1820,8 +2086,14 @@ var CacheStack = class extends EventEmitter {
1820
2086
  await this.storeEntry(key, "empty", null, options);
1821
2087
  return null;
1822
2088
  }
1823
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1824
- 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
+ }
1825
2097
  }
1826
2098
  await this.storeEntry(key, "value", fetched, options);
1827
2099
  return fetched;
@@ -2048,7 +2320,7 @@ var CacheStack = class extends EventEmitter {
2048
2320
  const refresh = (async () => {
2049
2321
  this.metricsCollector.increment("refreshes");
2050
2322
  try {
2051
- await this.fetchWithGuards(key, fetcher, options);
2323
+ await this.runBackgroundRefresh(key, fetcher, options);
2052
2324
  } catch (error) {
2053
2325
  this.metricsCollector.increment("refreshErrors");
2054
2326
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -2058,6 +2330,16 @@ var CacheStack = class extends EventEmitter {
2058
2330
  })();
2059
2331
  this.backgroundRefreshes.set(key, refresh);
2060
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
+ }
2061
2343
  resolveSingleFlightOptions() {
2062
2344
  return {
2063
2345
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -2125,8 +2407,120 @@ var CacheStack = class extends EventEmitter {
2125
2407
  sleep(ms) {
2126
2408
  return new Promise((resolve) => setTimeout(resolve, ms));
2127
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
+ }
2128
2440
  shouldBroadcastL1Invalidation() {
2129
- 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
+ }
2130
2524
  }
2131
2525
  initializeWriteBehind(options) {
2132
2526
  if (this.options.writeStrategy !== "write-behind") {
@@ -2164,7 +2558,17 @@ var CacheStack = class extends EventEmitter {
2164
2558
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
2165
2559
  const batch = this.writeBehindQueue.splice(0, batchSize);
2166
2560
  this.writeBehindFlushPromise = (async () => {
2167
- 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
+ }
2168
2572
  })();
2169
2573
  await this.writeBehindFlushPromise;
2170
2574
  this.writeBehindFlushPromise = void 0;
@@ -2269,9 +2673,13 @@ var CacheStack = class extends EventEmitter {
2269
2673
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2270
2674
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2271
2675
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2676
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2272
2677
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2273
2678
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2274
2679
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2680
+ if (typeof this.options.generationCleanup === "object") {
2681
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2682
+ }
2275
2683
  if (this.options.generation !== void 0) {
2276
2684
  this.validateNonNegativeNumber("generation", this.options.generation);
2277
2685
  }
@@ -2343,6 +2751,9 @@ var CacheStack = class extends EventEmitter {
2343
2751
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2344
2752
  throw new Error("Cache key contains unsupported control characters.");
2345
2753
  }
2754
+ if (/[\uD800-\uDFFF]/.test(key)) {
2755
+ throw new Error("Cache key contains unsupported surrogate code points.");
2756
+ }
2346
2757
  return key;
2347
2758
  }
2348
2759
  validateTtlPolicy(name, policy) {
@@ -2420,6 +2831,14 @@ var CacheStack = class extends EventEmitter {
2420
2831
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2421
2832
  return null;
2422
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
+ }
2423
2842
  isGracefulDegradationEnabled() {
2424
2843
  return Boolean(this.options.gracefulDegradation);
2425
2844
  }
@@ -2443,10 +2862,16 @@ var CacheStack = class extends EventEmitter {
2443
2862
  }
2444
2863
  }
2445
2864
  serializeKeyPart(value) {
2446
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2447
- return String(value);
2865
+ if (typeof value === "string") {
2866
+ return `s:${value}`;
2867
+ }
2868
+ if (typeof value === "number") {
2869
+ return `n:${value}`;
2448
2870
  }
2449
- return JSON.stringify(this.normalizeForSerialization(value));
2871
+ if (typeof value === "boolean") {
2872
+ return `b:${value}`;
2873
+ }
2874
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2450
2875
  }
2451
2876
  isCacheSnapshotEntries(value) {
2452
2877
  return Array.isArray(value) && value.every((entry) => {
@@ -2454,15 +2879,39 @@ var CacheStack = class extends EventEmitter {
2454
2879
  return false;
2455
2880
  }
2456
2881
  const candidate = entry;
2457
- 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);
2458
2883
  });
2459
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
+ }
2460
2906
  normalizeForSerialization(value) {
2461
2907
  if (Array.isArray(value)) {
2462
2908
  return value.map((entry) => this.normalizeForSerialization(entry));
2463
2909
  }
2464
2910
  if (value && typeof value === "object") {
2465
2911
  return Object.keys(value).sort().reduce((normalized, key) => {
2912
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2913
+ return normalized;
2914
+ }
2466
2915
  normalized[key] = this.normalizeForSerialization(value[key]);
2467
2916
  return normalized;
2468
2917
  }, {});