layercache 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  RedisTagIndex
3
- } from "./chunk-IXCMHVHP.js";
3
+ } from "./chunk-QHWG7QS5.js";
4
4
  import {
5
5
  MemoryLayer,
6
6
  TagIndex,
7
7
  createHonoCacheMiddleware
8
- } from "./chunk-46UH7LNM.js";
8
+ } from "./chunk-KOYGHLVP.js";
9
9
  import {
10
10
  PatternMatcher,
11
11
  createStoredValueEnvelope,
@@ -15,7 +15,7 @@ import {
15
15
  remainingStoredTtlSeconds,
16
16
  resolveStoredValue,
17
17
  unwrapStoredValue
18
- } from "./chunk-ZMDB5KOK.js";
18
+ } from "./chunk-7V7XAB74.js";
19
19
 
20
20
  // src/CacheStack.ts
21
21
  import { EventEmitter } from "events";
@@ -266,6 +266,59 @@ function addMap(base, delta) {
266
266
  return result;
267
267
  }
268
268
 
269
+ // src/internal/CacheKeyDiscovery.ts
270
+ var CacheKeyDiscovery = class {
271
+ constructor(options) {
272
+ this.options = options;
273
+ }
274
+ options;
275
+ async collectKeysWithPrefix(prefix) {
276
+ const { tagIndex } = this.options;
277
+ const matches = new Set(
278
+ tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
279
+ );
280
+ await Promise.all(
281
+ this.options.layers.map(async (layer) => {
282
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
283
+ return;
284
+ }
285
+ try {
286
+ const keys = await layer.keys();
287
+ for (const key of keys) {
288
+ if (key.startsWith(prefix)) {
289
+ matches.add(key);
290
+ }
291
+ }
292
+ } catch (error) {
293
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
294
+ }
295
+ })
296
+ );
297
+ return [...matches];
298
+ }
299
+ async collectKeysMatchingPattern(pattern) {
300
+ const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
301
+ await Promise.all(
302
+ this.options.layers.map(async (layer) => {
303
+ if (!layer.keys || this.options.shouldSkipLayer(layer)) {
304
+ return;
305
+ }
306
+ try {
307
+ const keys = await layer.keys();
308
+ for (const key of keys) {
309
+ if (PatternMatcher.matches(pattern, key)) {
310
+ matches.add(key);
311
+ }
312
+ }
313
+ } catch (error) {
314
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
315
+ }
316
+ })
317
+ );
318
+ return [...matches];
319
+ }
320
+ };
321
+
269
322
  // src/internal/CircuitBreakerManager.ts
270
323
  var CircuitBreakerManager = class {
271
324
  breakers = /* @__PURE__ */ new Map();
@@ -360,8 +413,9 @@ var CircuitBreakerManager = class {
360
413
 
361
414
  // src/internal/FetchRateLimiter.ts
362
415
  var FetchRateLimiter = class {
363
- queue = [];
364
416
  buckets = /* @__PURE__ */ new Map();
417
+ queuesByBucket = /* @__PURE__ */ new Map();
418
+ pendingBuckets = /* @__PURE__ */ new Set();
365
419
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
366
420
  nextFetcherBucketId = 0;
367
421
  drainTimer;
@@ -374,13 +428,17 @@ var FetchRateLimiter = class {
374
428
  return task();
375
429
  }
376
430
  return new Promise((resolve2, reject) => {
377
- this.queue.push({
378
- bucketKey: this.resolveBucketKey(normalized, context),
431
+ const bucketKey = this.resolveBucketKey(normalized, context);
432
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
433
+ queue.push({
434
+ bucketKey,
379
435
  options: normalized,
380
436
  task,
381
437
  resolve: resolve2,
382
438
  reject
383
439
  });
440
+ this.queuesByBucket.set(bucketKey, queue);
441
+ this.pendingBuckets.add(bucketKey);
384
442
  this.drain();
385
443
  });
386
444
  }
@@ -423,22 +481,30 @@ var FetchRateLimiter = class {
423
481
  clearTimeout(this.drainTimer);
424
482
  this.drainTimer = void 0;
425
483
  }
426
- while (this.queue.length > 0) {
427
- let nextIndex = -1;
484
+ while (this.pendingBuckets.size > 0) {
485
+ let nextBucketKey;
428
486
  let nextWaitMs = Number.POSITIVE_INFINITY;
429
- for (let index = 0; index < this.queue.length; index += 1) {
430
- const next2 = this.queue[index];
487
+ for (const bucketKey of this.pendingBuckets) {
488
+ const queue2 = this.queuesByBucket.get(bucketKey);
489
+ if (!queue2 || queue2.length === 0) {
490
+ this.pendingBuckets.delete(bucketKey);
491
+ this.queuesByBucket.delete(bucketKey);
492
+ continue;
493
+ }
494
+ const next2 = queue2[0];
431
495
  if (!next2) {
496
+ this.pendingBuckets.delete(bucketKey);
497
+ this.queuesByBucket.delete(bucketKey);
432
498
  continue;
433
499
  }
434
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
500
+ const waitMs = this.waitTime(bucketKey, next2.options);
435
501
  if (waitMs <= 0) {
436
- nextIndex = index;
502
+ nextBucketKey = bucketKey;
437
503
  break;
438
504
  }
439
505
  nextWaitMs = Math.min(nextWaitMs, waitMs);
440
506
  }
441
- if (nextIndex < 0) {
507
+ if (!nextBucketKey) {
442
508
  if (Number.isFinite(nextWaitMs)) {
443
509
  this.drainTimer = setTimeout(() => {
444
510
  this.drainTimer = void 0;
@@ -448,15 +514,32 @@ var FetchRateLimiter = class {
448
514
  }
449
515
  return;
450
516
  }
451
- const next = this.queue.splice(nextIndex, 1)[0];
517
+ const queue = this.queuesByBucket.get(nextBucketKey);
518
+ const next = queue?.shift();
452
519
  if (!next) {
453
- return;
520
+ this.pendingBuckets.delete(nextBucketKey);
521
+ this.queuesByBucket.delete(nextBucketKey);
522
+ continue;
523
+ }
524
+ if (!queue || queue.length === 0) {
525
+ this.pendingBuckets.delete(nextBucketKey);
526
+ this.queuesByBucket.delete(nextBucketKey);
454
527
  }
455
528
  const bucket = this.bucketState(next.bucketKey);
529
+ if (bucket.cleanupTimer) {
530
+ clearTimeout(bucket.cleanupTimer);
531
+ bucket.cleanupTimer = void 0;
532
+ }
456
533
  bucket.active += 1;
457
- bucket.startedAt.push(Date.now());
534
+ if (next.options.intervalMs && next.options.maxPerInterval) {
535
+ bucket.startedAt.push(Date.now());
536
+ }
458
537
  void next.task().then(next.resolve, next.reject).finally(() => {
459
538
  bucket.active -= 1;
539
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
540
+ this.pendingBuckets.add(next.bucketKey);
541
+ }
542
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
460
543
  this.drain();
461
544
  });
462
545
  }
@@ -498,6 +581,31 @@ var FetchRateLimiter = class {
498
581
  this.buckets.set(bucketKey, bucket);
499
582
  return bucket;
500
583
  }
584
+ cleanupBucket(bucketKey, bucket, intervalMs) {
585
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
586
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
587
+ this.buckets.delete(bucketKey);
588
+ this.queuesByBucket.delete(bucketKey);
589
+ this.pendingBuckets.delete(bucketKey);
590
+ return;
591
+ }
592
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
593
+ return;
594
+ }
595
+ if (bucket.cleanupTimer) {
596
+ clearTimeout(bucket.cleanupTimer);
597
+ }
598
+ bucket.cleanupTimer = setTimeout(() => {
599
+ bucket.cleanupTimer = void 0;
600
+ this.prune(bucket, Date.now(), intervalMs);
601
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
602
+ this.buckets.delete(bucketKey);
603
+ this.queuesByBucket.delete(bucketKey);
604
+ this.pendingBuckets.delete(bucketKey);
605
+ }
606
+ }, intervalMs);
607
+ bucket.cleanupTimer.unref?.();
608
+ }
501
609
  };
502
610
 
503
611
  // src/internal/MetricsCollector.ts
@@ -686,6 +794,41 @@ var TtlResolver = class {
686
794
  }
687
795
  };
688
796
 
797
+ // src/serialization/JsonSerializer.ts
798
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
799
+ var JsonSerializer = class {
800
+ serialize(value) {
801
+ return JSON.stringify(value);
802
+ }
803
+ deserialize(payload) {
804
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
805
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
806
+ }
807
+ };
808
+ var MAX_SANITIZE_DEPTH = 200;
809
+ function sanitizeJsonValue(value, depth) {
810
+ if (depth > MAX_SANITIZE_DEPTH) {
811
+ return value;
812
+ }
813
+ if (Array.isArray(value)) {
814
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
815
+ }
816
+ if (!isPlainObject(value)) {
817
+ return value;
818
+ }
819
+ const sanitized = {};
820
+ for (const [key, entry] of Object.entries(value)) {
821
+ if (DANGEROUS_JSON_KEYS.has(key)) {
822
+ continue;
823
+ }
824
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
825
+ }
826
+ return sanitized;
827
+ }
828
+ function isPlainObject(value) {
829
+ return Object.prototype.toString.call(value) === "[object Object]";
830
+ }
831
+
689
832
  // src/stampede/StampedeGuard.ts
690
833
  import { Mutex as Mutex2 } from "async-mutex";
691
834
  var StampedeGuard = class {
@@ -696,7 +839,8 @@ var StampedeGuard = class {
696
839
  return await entry.mutex.runExclusive(task);
697
840
  } finally {
698
841
  entry.references -= 1;
699
- if (entry.references === 0 && !entry.mutex.isLocked()) {
842
+ const current = this.mutexes.get(key);
843
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
700
844
  this.mutexes.delete(key);
701
845
  }
702
846
  }
@@ -726,8 +870,10 @@ var CacheMissError = class extends Error {
726
870
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
727
871
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
728
872
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
873
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
729
874
  var MAX_CACHE_KEY_LENGTH = 1024;
730
875
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
876
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
731
877
  var DebugLogger = class {
732
878
  enabled;
733
879
  constructor(enabled) {
@@ -774,6 +920,29 @@ var CacheStack = class extends EventEmitter {
774
920
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
775
921
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
776
922
  this.tagIndex = options.tagIndex ?? new TagIndex();
923
+ this.keyDiscovery = new CacheKeyDiscovery({
924
+ layers: this.layers,
925
+ tagIndex: this.tagIndex,
926
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
927
+ handleLayerFailure: async (layer, operation, error) => {
928
+ await this.handleLayerFailure(layer, operation, error);
929
+ }
930
+ });
931
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
932
+ this.logger.warn?.(
933
+ "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."
934
+ );
935
+ }
936
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
937
+ this.logger.warn?.(
938
+ "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."
939
+ );
940
+ }
941
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
942
+ this.logger.warn?.(
943
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
944
+ );
945
+ }
777
946
  this.initializeWriteBehind(options.writeBehind);
778
947
  this.startup = this.initialize();
779
948
  }
@@ -786,7 +955,9 @@ var CacheStack = class extends EventEmitter {
786
955
  unsubscribeInvalidation;
787
956
  logger;
788
957
  tagIndex;
958
+ keyDiscovery;
789
959
  fetchRateLimiter = new FetchRateLimiter();
960
+ snapshotSerializer = new JsonSerializer();
790
961
  backgroundRefreshes = /* @__PURE__ */ new Map();
791
962
  layerDegradedUntil = /* @__PURE__ */ new Map();
792
963
  ttlResolver;
@@ -795,6 +966,7 @@ var CacheStack = class extends EventEmitter {
795
966
  writeBehindQueue = [];
796
967
  writeBehindTimer;
797
968
  writeBehindFlushPromise;
969
+ generationCleanupPromise;
798
970
  isDisconnecting = false;
799
971
  disconnectPromise;
800
972
  /**
@@ -807,6 +979,9 @@ var CacheStack = class extends EventEmitter {
807
979
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
808
980
  this.validateWriteOptions(options);
809
981
  await this.awaitStartup("get");
982
+ return this.getPrepared(normalizedKey, fetcher, options);
983
+ }
984
+ async getPrepared(normalizedKey, fetcher, options) {
810
985
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
811
986
  if (hit.found) {
812
987
  this.ttlResolver.recordAccess(normalizedKey);
@@ -884,6 +1059,7 @@ var CacheStack = class extends EventEmitter {
884
1059
  return true;
885
1060
  }
886
1061
  } catch {
1062
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
887
1063
  }
888
1064
  } else {
889
1065
  try {
@@ -891,7 +1067,8 @@ var CacheStack = class extends EventEmitter {
891
1067
  if (value !== null) {
892
1068
  return true;
893
1069
  }
894
- } catch {
1070
+ } catch (error) {
1071
+ await this.reportRecoverableLayerFailure(layer, "has", error);
895
1072
  }
896
1073
  }
897
1074
  }
@@ -983,13 +1160,14 @@ var CacheStack = class extends EventEmitter {
983
1160
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
984
1161
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
985
1162
  if (!canFastPath) {
1163
+ await this.awaitStartup("mget");
986
1164
  const pendingReads = /* @__PURE__ */ new Map();
987
1165
  return Promise.all(
988
1166
  normalizedEntries.map((entry) => {
989
1167
  const optionsSignature = this.serializeOptions(entry.options);
990
1168
  const existing = pendingReads.get(entry.key);
991
1169
  if (!existing) {
992
- const promise = this.get(entry.key, entry.fetch, entry.options);
1170
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
993
1171
  pendingReads.set(entry.key, {
994
1172
  promise,
995
1173
  fetch: entry.fetch,
@@ -1128,14 +1306,14 @@ var CacheStack = class extends EventEmitter {
1128
1306
  }
1129
1307
  async invalidateByPattern(pattern) {
1130
1308
  await this.awaitStartup("invalidateByPattern");
1131
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1309
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1132
1310
  await this.deleteKeys(keys);
1133
1311
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1134
1312
  }
1135
1313
  async invalidateByPrefix(prefix) {
1136
1314
  await this.awaitStartup("invalidateByPrefix");
1137
1315
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1138
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1316
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1139
1317
  await this.deleteKeys(keys);
1140
1318
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1141
1319
  }
@@ -1185,9 +1363,18 @@ var CacheStack = class extends EventEmitter {
1185
1363
  })
1186
1364
  );
1187
1365
  }
1366
+ /**
1367
+ * Rotates the active generation prefix used for all future cache keys.
1368
+ * Previous-generation keys remain in the underlying layers until they expire,
1369
+ * unless `generationCleanup` is enabled to prune them in the background.
1370
+ */
1188
1371
  bumpGeneration(nextGeneration) {
1189
1372
  const current = this.currentGeneration ?? 0;
1373
+ const previousGeneration = this.currentGeneration;
1190
1374
  this.currentGeneration = nextGeneration ?? current + 1;
1375
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1376
+ this.scheduleGenerationCleanup(previousGeneration);
1377
+ }
1191
1378
  return this.currentGeneration;
1192
1379
  }
1193
1380
  /**
@@ -1271,27 +1458,28 @@ var CacheStack = class extends EventEmitter {
1271
1458
  this.assertActive("persistToFile");
1272
1459
  const snapshot = await this.exportState();
1273
1460
  const { promises: fs2 } = await import("fs");
1274
- await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1461
+ await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1275
1462
  }
1276
1463
  async restoreFromFile(filePath) {
1277
1464
  this.assertActive("restoreFromFile");
1278
1465
  const { promises: fs2 } = await import("fs");
1279
- const raw = await fs2.readFile(filePath, "utf8");
1466
+ const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1280
1467
  let parsed;
1281
1468
  try {
1282
- parsed = JSON.parse(raw, (_key, value) => {
1283
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1284
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1285
- }
1286
- return value;
1287
- });
1469
+ parsed = JSON.parse(raw);
1288
1470
  } catch (cause) {
1289
1471
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1290
1472
  }
1291
1473
  if (!this.isCacheSnapshotEntries(parsed)) {
1292
1474
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1293
1475
  }
1294
- await this.importState(parsed);
1476
+ await this.importState(
1477
+ parsed.map((entry) => ({
1478
+ key: entry.key,
1479
+ value: this.sanitizeSnapshotValue(entry.value),
1480
+ ttl: entry.ttl
1481
+ }))
1482
+ );
1295
1483
  }
1296
1484
  async disconnect() {
1297
1485
  if (!this.disconnectPromise) {
@@ -1300,6 +1488,7 @@ var CacheStack = class extends EventEmitter {
1300
1488
  await this.startup;
1301
1489
  await this.unsubscribeInvalidation?.();
1302
1490
  await this.flushWriteBehindQueue();
1491
+ await this.generationCleanupPromise;
1303
1492
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1304
1493
  if (this.writeBehindTimer) {
1305
1494
  clearInterval(this.writeBehindTimer);
@@ -1383,8 +1572,14 @@ var CacheStack = class extends EventEmitter {
1383
1572
  await this.storeEntry(key, "empty", null, options);
1384
1573
  return null;
1385
1574
  }
1386
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1387
- return fetched;
1575
+ if (options?.shouldCache) {
1576
+ try {
1577
+ if (!options.shouldCache(fetched)) {
1578
+ return fetched;
1579
+ }
1580
+ } catch (error) {
1581
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1582
+ }
1388
1583
  }
1389
1584
  await this.storeEntry(key, "value", fetched, options);
1390
1585
  return fetched;
@@ -1611,7 +1806,7 @@ var CacheStack = class extends EventEmitter {
1611
1806
  const refresh = (async () => {
1612
1807
  this.metricsCollector.increment("refreshes");
1613
1808
  try {
1614
- await this.fetchWithGuards(key, fetcher, options);
1809
+ await this.runBackgroundRefresh(key, fetcher, options);
1615
1810
  } catch (error) {
1616
1811
  this.metricsCollector.increment("refreshErrors");
1617
1812
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1621,6 +1816,16 @@ var CacheStack = class extends EventEmitter {
1621
1816
  })();
1622
1817
  this.backgroundRefreshes.set(key, refresh);
1623
1818
  }
1819
+ async runBackgroundRefresh(key, fetcher, options) {
1820
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
1821
+ await this.fetchWithGuards(
1822
+ key,
1823
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
1824
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
1825
+ }),
1826
+ options
1827
+ );
1828
+ }
1624
1829
  resolveSingleFlightOptions() {
1625
1830
  return {
1626
1831
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -1688,8 +1893,76 @@ var CacheStack = class extends EventEmitter {
1688
1893
  sleep(ms) {
1689
1894
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1690
1895
  }
1896
+ async withTimeout(promise, timeoutMs, onTimeout) {
1897
+ if (timeoutMs <= 0) {
1898
+ return promise;
1899
+ }
1900
+ let timer;
1901
+ const observedPromise = promise.then(
1902
+ (value) => ({ kind: "value", value }),
1903
+ (error) => ({ kind: "error", error })
1904
+ );
1905
+ try {
1906
+ const result = await Promise.race([
1907
+ observedPromise,
1908
+ new Promise((_, reject) => {
1909
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
1910
+ timer.unref?.();
1911
+ })
1912
+ ]);
1913
+ if (result && typeof result === "object" && "kind" in result) {
1914
+ if (result.kind === "error") {
1915
+ throw result.error;
1916
+ }
1917
+ return result.value;
1918
+ }
1919
+ return result;
1920
+ } finally {
1921
+ if (timer) {
1922
+ clearTimeout(timer);
1923
+ }
1924
+ }
1925
+ }
1691
1926
  shouldBroadcastL1Invalidation() {
1692
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1927
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1928
+ }
1929
+ shouldCleanupGenerations() {
1930
+ return Boolean(this.options.generationCleanup);
1931
+ }
1932
+ generationCleanupBatchSize() {
1933
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
1934
+ return configured ?? 500;
1935
+ }
1936
+ scheduleGenerationCleanup(generation) {
1937
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
1938
+ this.logger.warn?.("generation-cleanup-error", {
1939
+ generation,
1940
+ error: this.formatError(error)
1941
+ });
1942
+ });
1943
+ this.generationCleanupPromise = task.finally(() => {
1944
+ if (this.generationCleanupPromise === task) {
1945
+ this.generationCleanupPromise = void 0;
1946
+ }
1947
+ });
1948
+ }
1949
+ async cleanupGeneration(generation) {
1950
+ const prefix = `v${generation}:`;
1951
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
1952
+ if (keys.length === 0) {
1953
+ return;
1954
+ }
1955
+ const batchSize = this.generationCleanupBatchSize();
1956
+ for (let index = 0; index < keys.length; index += batchSize) {
1957
+ const batch = keys.slice(index, index + batchSize);
1958
+ await this.deleteKeys(batch);
1959
+ await this.publishInvalidation({
1960
+ scope: "keys",
1961
+ keys: batch,
1962
+ sourceId: this.instanceId,
1963
+ operation: "invalidate"
1964
+ });
1965
+ }
1693
1966
  }
1694
1967
  initializeWriteBehind(options) {
1695
1968
  if (this.options.writeStrategy !== "write-behind") {
@@ -1727,7 +2000,17 @@ var CacheStack = class extends EventEmitter {
1727
2000
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
1728
2001
  const batch = this.writeBehindQueue.splice(0, batchSize);
1729
2002
  this.writeBehindFlushPromise = (async () => {
1730
- await Promise.allSettled(batch.map((operation) => operation()));
2003
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2004
+ const failures = results.filter((result) => result.status === "rejected");
2005
+ if (failures.length > 0) {
2006
+ this.metricsCollector.increment("writeFailures", failures.length);
2007
+ this.logger.error?.("write-behind-flush-failure", {
2008
+ failed: failures.length,
2009
+ total: batch.length,
2010
+ errors: failures.map((failure) => this.formatError(failure.reason))
2011
+ });
2012
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2013
+ }
1731
2014
  })();
1732
2015
  await this.writeBehindFlushPromise;
1733
2016
  this.writeBehindFlushPromise = void 0;
@@ -1832,9 +2115,13 @@ var CacheStack = class extends EventEmitter {
1832
2115
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1833
2116
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1834
2117
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2118
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
1835
2119
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
1836
2120
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1837
2121
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2122
+ if (typeof this.options.generationCleanup === "object") {
2123
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2124
+ }
1838
2125
  if (this.options.generation !== void 0) {
1839
2126
  this.validateNonNegativeNumber("generation", this.options.generation);
1840
2127
  }
@@ -1906,6 +2193,9 @@ var CacheStack = class extends EventEmitter {
1906
2193
  if (/[\u0000-\u001F\u007F]/.test(key)) {
1907
2194
  throw new Error("Cache key contains unsupported control characters.");
1908
2195
  }
2196
+ if (/[\uD800-\uDFFF]/.test(key)) {
2197
+ throw new Error("Cache key contains unsupported surrogate code points.");
2198
+ }
1909
2199
  return key;
1910
2200
  }
1911
2201
  validateTtlPolicy(name, policy) {
@@ -1983,6 +2273,14 @@ var CacheStack = class extends EventEmitter {
1983
2273
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1984
2274
  return null;
1985
2275
  }
2276
+ async reportRecoverableLayerFailure(layer, operation, error) {
2277
+ if (this.isGracefulDegradationEnabled()) {
2278
+ await this.handleLayerFailure(layer, operation, error);
2279
+ return;
2280
+ }
2281
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2282
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2283
+ }
1986
2284
  isGracefulDegradationEnabled() {
1987
2285
  return Boolean(this.options.gracefulDegradation);
1988
2286
  }
@@ -2006,10 +2304,16 @@ var CacheStack = class extends EventEmitter {
2006
2304
  }
2007
2305
  }
2008
2306
  serializeKeyPart(value) {
2009
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2010
- return String(value);
2307
+ if (typeof value === "string") {
2308
+ return `s:${value}`;
2011
2309
  }
2012
- return JSON.stringify(this.normalizeForSerialization(value));
2310
+ if (typeof value === "number") {
2311
+ return `n:${value}`;
2312
+ }
2313
+ if (typeof value === "boolean") {
2314
+ return `b:${value}`;
2315
+ }
2316
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2013
2317
  }
2014
2318
  isCacheSnapshotEntries(value) {
2015
2319
  return Array.isArray(value) && value.every((entry) => {
@@ -2017,15 +2321,39 @@ var CacheStack = class extends EventEmitter {
2017
2321
  return false;
2018
2322
  }
2019
2323
  const candidate = entry;
2020
- return typeof candidate.key === "string";
2324
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2021
2325
  });
2022
2326
  }
2327
+ sanitizeSnapshotValue(value) {
2328
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2329
+ }
2330
+ async validateSnapshotFilePath(filePath) {
2331
+ if (filePath.length === 0) {
2332
+ throw new Error("filePath must not be empty.");
2333
+ }
2334
+ if (filePath.includes("\0")) {
2335
+ throw new Error("filePath must not contain null bytes.");
2336
+ }
2337
+ const path = await import("path");
2338
+ const resolved = path.resolve(filePath);
2339
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2340
+ if (baseDir !== false) {
2341
+ const relative = path.relative(baseDir, resolved);
2342
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2343
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2344
+ }
2345
+ }
2346
+ return resolved;
2347
+ }
2023
2348
  normalizeForSerialization(value) {
2024
2349
  if (Array.isArray(value)) {
2025
2350
  return value.map((entry) => this.normalizeForSerialization(entry));
2026
2351
  }
2027
2352
  if (value && typeof value === "object") {
2028
2353
  return Object.keys(value).sort().reduce((normalized, key) => {
2354
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2355
+ return normalized;
2356
+ }
2029
2357
  normalized[key] = this.normalizeForSerialization(value[key]);
2030
2358
  return normalized;
2031
2359
  }, {});
@@ -2152,7 +2480,7 @@ function createCachedMethodDecorator(options) {
2152
2480
  function createFastifyLayercachePlugin(cache, options = {}) {
2153
2481
  return async (fastify) => {
2154
2482
  fastify.decorate("cache", cache);
2155
- if (options.exposeStatsRoute !== false && fastify.get) {
2483
+ if (options.exposeStatsRoute === true && fastify.get) {
2156
2484
  fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2157
2485
  }
2158
2486
  };
@@ -2168,7 +2496,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
2168
2496
  next();
2169
2497
  return;
2170
2498
  }
2171
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
2499
+ const rawUrl = req.originalUrl ?? req.url ?? "/";
2500
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2172
2501
  const cached = await cache.get(key, void 0, options);
2173
2502
  if (cached !== null) {
2174
2503
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -2184,7 +2513,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
2184
2513
  if (originalJson) {
2185
2514
  res.json = (body) => {
2186
2515
  res.setHeader?.("x-cache", "MISS");
2187
- void cache.set(key, body, options);
2516
+ cache.set(key, body, options).catch((err) => {
2517
+ cache.emit("error", {
2518
+ operation: "set",
2519
+ error: err instanceof Error ? err.message : String(err)
2520
+ });
2521
+ });
2188
2522
  return originalJson(body);
2189
2523
  };
2190
2524
  }
@@ -2194,6 +2528,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
2194
2528
  }
2195
2529
  };
2196
2530
  }
2531
+ function normalizeUrl(url) {
2532
+ try {
2533
+ const parsed = new URL(url, "http://localhost");
2534
+ parsed.searchParams.sort();
2535
+ return decodeURIComponent(parsed.pathname) + parsed.search;
2536
+ } catch {
2537
+ return url;
2538
+ }
2539
+ }
2197
2540
 
2198
2541
  // src/integrations/graphql.ts
2199
2542
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
@@ -2294,39 +2637,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
2294
2637
  // src/layers/RedisLayer.ts
2295
2638
  import { promisify } from "util";
2296
2639
  import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
2297
-
2298
- // src/serialization/JsonSerializer.ts
2299
- var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2300
- var JsonSerializer = class {
2301
- serialize(value) {
2302
- return JSON.stringify(value);
2303
- }
2304
- deserialize(payload) {
2305
- const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2306
- return sanitizeJsonValue(JSON.parse(normalized));
2307
- }
2308
- };
2309
- function sanitizeJsonValue(value) {
2310
- if (Array.isArray(value)) {
2311
- return value.map((entry) => sanitizeJsonValue(entry));
2312
- }
2313
- if (!isPlainObject(value)) {
2314
- return value;
2315
- }
2316
- const sanitized = {};
2317
- for (const [key, entry] of Object.entries(value)) {
2318
- if (DANGEROUS_JSON_KEYS.has(key)) {
2319
- continue;
2320
- }
2321
- sanitized[key] = sanitizeJsonValue(entry);
2322
- }
2323
- return sanitized;
2324
- }
2325
- function isPlainObject(value) {
2326
- return Object.prototype.toString.call(value) === "[object Object]";
2327
- }
2328
-
2329
- // src/layers/RedisLayer.ts
2330
2640
  var BATCH_DELETE_SIZE = 500;
2331
2641
  var gzipAsync = promisify(gzip);
2332
2642
  var gunzipAsync = promisify(gunzip);
@@ -2343,6 +2653,7 @@ var RedisLayer = class {
2343
2653
  scanCount;
2344
2654
  compression;
2345
2655
  compressionThreshold;
2656
+ decompressionMaxBytes;
2346
2657
  disconnectOnDispose;
2347
2658
  constructor(options) {
2348
2659
  this.client = options.client;
@@ -2354,6 +2665,7 @@ var RedisLayer = class {
2354
2665
  this.scanCount = options.scanCount ?? 100;
2355
2666
  this.compression = options.compression;
2356
2667
  this.compressionThreshold = options.compressionThreshold ?? 1024;
2668
+ this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
2357
2669
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
2358
2670
  }
2359
2671
  async get(key) {
@@ -2553,16 +2865,29 @@ var RedisLayer = class {
2553
2865
  }
2554
2866
  /**
2555
2867
  * Decompresses the payload asynchronously if a compression header is present.
2868
+ * Enforces a maximum decompressed size to prevent decompression bomb attacks.
2556
2869
  */
2557
2870
  async decodePayload(payload) {
2558
2871
  if (!Buffer.isBuffer(payload)) {
2559
2872
  return payload;
2560
2873
  }
2561
2874
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2562
- return gunzipAsync(payload.subarray(10));
2875
+ const decompressed = await gunzipAsync(payload.subarray(10));
2876
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2877
+ throw new Error(
2878
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2879
+ );
2880
+ }
2881
+ return decompressed;
2563
2882
  }
2564
2883
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2565
- return brotliDecompressAsync(payload.subarray(12));
2884
+ const decompressed = await brotliDecompressAsync(payload.subarray(12));
2885
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2886
+ throw new Error(
2887
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2888
+ );
2889
+ }
2890
+ return decompressed;
2566
2891
  }
2567
2892
  return payload;
2568
2893
  }
@@ -2622,8 +2947,13 @@ var DiskLayer = class {
2622
2947
  const payload = this.serializer.serialize(entry);
2623
2948
  const targetPath = this.keyToPath(key);
2624
2949
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
2625
- await fs.writeFile(tempPath, payload);
2626
- await fs.rename(tempPath, targetPath);
2950
+ try {
2951
+ await fs.writeFile(tempPath, payload);
2952
+ await fs.rename(tempPath, targetPath);
2953
+ } catch (error) {
2954
+ await this.safeDelete(tempPath);
2955
+ throw error;
2956
+ }
2627
2957
  if (this.maxFiles !== void 0) {
2628
2958
  await this.enforceMaxFiles();
2629
2959
  }
@@ -2633,9 +2963,7 @@ var DiskLayer = class {
2633
2963
  return Promise.all(keys.map((key) => this.getEntry(key)));
2634
2964
  }
2635
2965
  async setMany(entries) {
2636
- for (const entry of entries) {
2637
- await this.set(entry.key, entry.value, entry.ttl);
2638
- }
2966
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
2639
2967
  }
2640
2968
  async has(key) {
2641
2969
  const value = await this.getEntry(key);
@@ -2839,6 +3167,7 @@ var MemcachedLayer = class {
2839
3167
  return unwrapStoredValue(await this.getEntry(key));
2840
3168
  }
2841
3169
  async getEntry(key) {
3170
+ this.validateKey(key);
2842
3171
  const result = await this.client.get(this.withPrefix(key));
2843
3172
  if (!result || result.value === null) {
2844
3173
  return null;
@@ -2853,16 +3182,19 @@ var MemcachedLayer = class {
2853
3182
  return Promise.all(keys.map((key) => this.getEntry(key)));
2854
3183
  }
2855
3184
  async set(key, value, ttl = this.defaultTtl) {
3185
+ this.validateKey(key);
2856
3186
  const payload = this.serializer.serialize(value);
2857
3187
  await this.client.set(this.withPrefix(key), payload, {
2858
3188
  expires: ttl && ttl > 0 ? ttl : void 0
2859
3189
  });
2860
3190
  }
2861
3191
  async has(key) {
3192
+ this.validateKey(key);
2862
3193
  const result = await this.client.get(this.withPrefix(key));
2863
3194
  return result !== null && result.value !== null;
2864
3195
  }
2865
3196
  async delete(key) {
3197
+ this.validateKey(key);
2866
3198
  await this.client.delete(this.withPrefix(key));
2867
3199
  }
2868
3200
  async deleteMany(keys) {
@@ -2876,19 +3208,50 @@ var MemcachedLayer = class {
2876
3208
  withPrefix(key) {
2877
3209
  return `${this.keyPrefix}${key}`;
2878
3210
  }
3211
+ validateKey(key) {
3212
+ const fullKey = this.withPrefix(key);
3213
+ if (Buffer.byteLength(fullKey, "utf8") > 250) {
3214
+ throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
3215
+ }
3216
+ if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
3217
+ throw new Error(
3218
+ "MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
3219
+ );
3220
+ }
3221
+ }
2879
3222
  };
2880
3223
 
2881
3224
  // src/serialization/MsgpackSerializer.ts
2882
3225
  import { decode, encode } from "@msgpack/msgpack";
3226
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2883
3227
  var MsgpackSerializer = class {
2884
3228
  serialize(value) {
2885
3229
  return Buffer.from(encode(value));
2886
3230
  }
2887
3231
  deserialize(payload) {
2888
3232
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
2889
- return decode(normalized);
3233
+ return sanitizeMsgpackValue(decode(normalized));
2890
3234
  }
2891
3235
  };
3236
+ function sanitizeMsgpackValue(value) {
3237
+ if (Array.isArray(value)) {
3238
+ return value.map((entry) => sanitizeMsgpackValue(entry));
3239
+ }
3240
+ if (!isPlainObject2(value)) {
3241
+ return value;
3242
+ }
3243
+ const sanitized = {};
3244
+ for (const [key, entry] of Object.entries(value)) {
3245
+ if (DANGEROUS_KEYS.has(key)) {
3246
+ continue;
3247
+ }
3248
+ sanitized[key] = sanitizeMsgpackValue(entry);
3249
+ }
3250
+ return sanitized;
3251
+ }
3252
+ function isPlainObject2(value) {
3253
+ return Object.prototype.toString.call(value) === "[object Object]";
3254
+ }
2892
3255
 
2893
3256
  // src/singleflight/RedisSingleFlightCoordinator.ts
2894
3257
  import { randomUUID } from "crypto";
@@ -3017,7 +3380,7 @@ function createPrometheusMetricsExporter(stacks) {
3017
3380
  };
3018
3381
  }
3019
3382
  function sanitizeLabel(value) {
3020
- return value.replace(/["\\\n]/g, "_");
3383
+ return value.replace(/["\\\n\r]/g, "_");
3021
3384
  }
3022
3385
  export {
3023
3386
  CacheMissError,