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.
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";
@@ -360,8 +360,9 @@ var CircuitBreakerManager = class {
360
360
 
361
361
  // src/internal/FetchRateLimiter.ts
362
362
  var FetchRateLimiter = class {
363
- queue = [];
364
363
  buckets = /* @__PURE__ */ new Map();
364
+ queuesByBucket = /* @__PURE__ */ new Map();
365
+ pendingBuckets = /* @__PURE__ */ new Set();
365
366
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
366
367
  nextFetcherBucketId = 0;
367
368
  drainTimer;
@@ -374,13 +375,17 @@ var FetchRateLimiter = class {
374
375
  return task();
375
376
  }
376
377
  return new Promise((resolve2, reject) => {
377
- this.queue.push({
378
- bucketKey: this.resolveBucketKey(normalized, context),
378
+ const bucketKey = this.resolveBucketKey(normalized, context);
379
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
380
+ queue.push({
381
+ bucketKey,
379
382
  options: normalized,
380
383
  task,
381
384
  resolve: resolve2,
382
385
  reject
383
386
  });
387
+ this.queuesByBucket.set(bucketKey, queue);
388
+ this.pendingBuckets.add(bucketKey);
384
389
  this.drain();
385
390
  });
386
391
  }
@@ -423,22 +428,30 @@ var FetchRateLimiter = class {
423
428
  clearTimeout(this.drainTimer);
424
429
  this.drainTimer = void 0;
425
430
  }
426
- while (this.queue.length > 0) {
427
- let nextIndex = -1;
431
+ while (this.pendingBuckets.size > 0) {
432
+ let nextBucketKey;
428
433
  let nextWaitMs = Number.POSITIVE_INFINITY;
429
- for (let index = 0; index < this.queue.length; index += 1) {
430
- const next2 = this.queue[index];
434
+ for (const bucketKey of this.pendingBuckets) {
435
+ const queue2 = this.queuesByBucket.get(bucketKey);
436
+ if (!queue2 || queue2.length === 0) {
437
+ this.pendingBuckets.delete(bucketKey);
438
+ this.queuesByBucket.delete(bucketKey);
439
+ continue;
440
+ }
441
+ const next2 = queue2[0];
431
442
  if (!next2) {
443
+ this.pendingBuckets.delete(bucketKey);
444
+ this.queuesByBucket.delete(bucketKey);
432
445
  continue;
433
446
  }
434
- const waitMs = this.waitTime(next2.bucketKey, next2.options);
447
+ const waitMs = this.waitTime(bucketKey, next2.options);
435
448
  if (waitMs <= 0) {
436
- nextIndex = index;
449
+ nextBucketKey = bucketKey;
437
450
  break;
438
451
  }
439
452
  nextWaitMs = Math.min(nextWaitMs, waitMs);
440
453
  }
441
- if (nextIndex < 0) {
454
+ if (!nextBucketKey) {
442
455
  if (Number.isFinite(nextWaitMs)) {
443
456
  this.drainTimer = setTimeout(() => {
444
457
  this.drainTimer = void 0;
@@ -448,15 +461,32 @@ var FetchRateLimiter = class {
448
461
  }
449
462
  return;
450
463
  }
451
- const next = this.queue.splice(nextIndex, 1)[0];
464
+ const queue = this.queuesByBucket.get(nextBucketKey);
465
+ const next = queue?.shift();
452
466
  if (!next) {
453
- return;
467
+ this.pendingBuckets.delete(nextBucketKey);
468
+ this.queuesByBucket.delete(nextBucketKey);
469
+ continue;
470
+ }
471
+ if (!queue || queue.length === 0) {
472
+ this.pendingBuckets.delete(nextBucketKey);
473
+ this.queuesByBucket.delete(nextBucketKey);
454
474
  }
455
475
  const bucket = this.bucketState(next.bucketKey);
476
+ if (bucket.cleanupTimer) {
477
+ clearTimeout(bucket.cleanupTimer);
478
+ bucket.cleanupTimer = void 0;
479
+ }
456
480
  bucket.active += 1;
457
- bucket.startedAt.push(Date.now());
481
+ if (next.options.intervalMs && next.options.maxPerInterval) {
482
+ bucket.startedAt.push(Date.now());
483
+ }
458
484
  void next.task().then(next.resolve, next.reject).finally(() => {
459
485
  bucket.active -= 1;
486
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
487
+ this.pendingBuckets.add(next.bucketKey);
488
+ }
489
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
460
490
  this.drain();
461
491
  });
462
492
  }
@@ -498,6 +528,31 @@ var FetchRateLimiter = class {
498
528
  this.buckets.set(bucketKey, bucket);
499
529
  return bucket;
500
530
  }
531
+ cleanupBucket(bucketKey, bucket, intervalMs) {
532
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
533
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
534
+ this.buckets.delete(bucketKey);
535
+ this.queuesByBucket.delete(bucketKey);
536
+ this.pendingBuckets.delete(bucketKey);
537
+ return;
538
+ }
539
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
540
+ return;
541
+ }
542
+ if (bucket.cleanupTimer) {
543
+ clearTimeout(bucket.cleanupTimer);
544
+ }
545
+ bucket.cleanupTimer = setTimeout(() => {
546
+ bucket.cleanupTimer = void 0;
547
+ this.prune(bucket, Date.now(), intervalMs);
548
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
549
+ this.buckets.delete(bucketKey);
550
+ this.queuesByBucket.delete(bucketKey);
551
+ this.pendingBuckets.delete(bucketKey);
552
+ }
553
+ }, intervalMs);
554
+ bucket.cleanupTimer.unref?.();
555
+ }
501
556
  };
502
557
 
503
558
  // src/internal/MetricsCollector.ts
@@ -686,6 +741,41 @@ var TtlResolver = class {
686
741
  }
687
742
  };
688
743
 
744
+ // src/serialization/JsonSerializer.ts
745
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
746
+ var JsonSerializer = class {
747
+ serialize(value) {
748
+ return JSON.stringify(value);
749
+ }
750
+ deserialize(payload) {
751
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
752
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
753
+ }
754
+ };
755
+ var MAX_SANITIZE_DEPTH = 200;
756
+ function sanitizeJsonValue(value, depth) {
757
+ if (depth > MAX_SANITIZE_DEPTH) {
758
+ return value;
759
+ }
760
+ if (Array.isArray(value)) {
761
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
762
+ }
763
+ if (!isPlainObject(value)) {
764
+ return value;
765
+ }
766
+ const sanitized = {};
767
+ for (const [key, entry] of Object.entries(value)) {
768
+ if (DANGEROUS_JSON_KEYS.has(key)) {
769
+ continue;
770
+ }
771
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
772
+ }
773
+ return sanitized;
774
+ }
775
+ function isPlainObject(value) {
776
+ return Object.prototype.toString.call(value) === "[object Object]";
777
+ }
778
+
689
779
  // src/stampede/StampedeGuard.ts
690
780
  import { Mutex as Mutex2 } from "async-mutex";
691
781
  var StampedeGuard = class {
@@ -696,7 +786,8 @@ var StampedeGuard = class {
696
786
  return await entry.mutex.runExclusive(task);
697
787
  } finally {
698
788
  entry.references -= 1;
699
- if (entry.references === 0 && !entry.mutex.isLocked()) {
789
+ const current = this.mutexes.get(key);
790
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
700
791
  this.mutexes.delete(key);
701
792
  }
702
793
  }
@@ -726,8 +817,10 @@ var CacheMissError = class extends Error {
726
817
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
727
818
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
728
819
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
820
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
729
821
  var MAX_CACHE_KEY_LENGTH = 1024;
730
822
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
823
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
731
824
  var DebugLogger = class {
732
825
  enabled;
733
826
  constructor(enabled) {
@@ -774,6 +867,21 @@ var CacheStack = class extends EventEmitter {
774
867
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
775
868
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
776
869
  this.tagIndex = options.tagIndex ?? new TagIndex();
870
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
871
+ this.logger.warn?.(
872
+ "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."
873
+ );
874
+ }
875
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
876
+ this.logger.warn?.(
877
+ "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."
878
+ );
879
+ }
880
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
881
+ this.logger.warn?.(
882
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
883
+ );
884
+ }
777
885
  this.initializeWriteBehind(options.writeBehind);
778
886
  this.startup = this.initialize();
779
887
  }
@@ -787,6 +895,7 @@ var CacheStack = class extends EventEmitter {
787
895
  logger;
788
896
  tagIndex;
789
897
  fetchRateLimiter = new FetchRateLimiter();
898
+ snapshotSerializer = new JsonSerializer();
790
899
  backgroundRefreshes = /* @__PURE__ */ new Map();
791
900
  layerDegradedUntil = /* @__PURE__ */ new Map();
792
901
  ttlResolver;
@@ -795,6 +904,7 @@ var CacheStack = class extends EventEmitter {
795
904
  writeBehindQueue = [];
796
905
  writeBehindTimer;
797
906
  writeBehindFlushPromise;
907
+ generationCleanupPromise;
798
908
  isDisconnecting = false;
799
909
  disconnectPromise;
800
910
  /**
@@ -807,6 +917,9 @@ var CacheStack = class extends EventEmitter {
807
917
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
808
918
  this.validateWriteOptions(options);
809
919
  await this.awaitStartup("get");
920
+ return this.getPrepared(normalizedKey, fetcher, options);
921
+ }
922
+ async getPrepared(normalizedKey, fetcher, options) {
810
923
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
811
924
  if (hit.found) {
812
925
  this.ttlResolver.recordAccess(normalizedKey);
@@ -884,6 +997,7 @@ var CacheStack = class extends EventEmitter {
884
997
  return true;
885
998
  }
886
999
  } catch {
1000
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
887
1001
  }
888
1002
  } else {
889
1003
  try {
@@ -891,7 +1005,8 @@ var CacheStack = class extends EventEmitter {
891
1005
  if (value !== null) {
892
1006
  return true;
893
1007
  }
894
- } catch {
1008
+ } catch (error) {
1009
+ await this.reportRecoverableLayerFailure(layer, "has", error);
895
1010
  }
896
1011
  }
897
1012
  }
@@ -983,13 +1098,14 @@ var CacheStack = class extends EventEmitter {
983
1098
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
984
1099
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
985
1100
  if (!canFastPath) {
1101
+ await this.awaitStartup("mget");
986
1102
  const pendingReads = /* @__PURE__ */ new Map();
987
1103
  return Promise.all(
988
1104
  normalizedEntries.map((entry) => {
989
1105
  const optionsSignature = this.serializeOptions(entry.options);
990
1106
  const existing = pendingReads.get(entry.key);
991
1107
  if (!existing) {
992
- const promise = this.get(entry.key, entry.fetch, entry.options);
1108
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
993
1109
  pendingReads.set(entry.key, {
994
1110
  promise,
995
1111
  fetch: entry.fetch,
@@ -1128,14 +1244,14 @@ var CacheStack = class extends EventEmitter {
1128
1244
  }
1129
1245
  async invalidateByPattern(pattern) {
1130
1246
  await this.awaitStartup("invalidateByPattern");
1131
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1247
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1132
1248
  await this.deleteKeys(keys);
1133
1249
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1134
1250
  }
1135
1251
  async invalidateByPrefix(prefix) {
1136
1252
  await this.awaitStartup("invalidateByPrefix");
1137
1253
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1138
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1254
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1139
1255
  await this.deleteKeys(keys);
1140
1256
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1141
1257
  }
@@ -1185,9 +1301,18 @@ var CacheStack = class extends EventEmitter {
1185
1301
  })
1186
1302
  );
1187
1303
  }
1304
+ /**
1305
+ * Rotates the active generation prefix used for all future cache keys.
1306
+ * Previous-generation keys remain in the underlying layers until they expire,
1307
+ * unless `generationCleanup` is enabled to prune them in the background.
1308
+ */
1188
1309
  bumpGeneration(nextGeneration) {
1189
1310
  const current = this.currentGeneration ?? 0;
1311
+ const previousGeneration = this.currentGeneration;
1190
1312
  this.currentGeneration = nextGeneration ?? current + 1;
1313
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1314
+ this.scheduleGenerationCleanup(previousGeneration);
1315
+ }
1191
1316
  return this.currentGeneration;
1192
1317
  }
1193
1318
  /**
@@ -1271,27 +1396,28 @@ var CacheStack = class extends EventEmitter {
1271
1396
  this.assertActive("persistToFile");
1272
1397
  const snapshot = await this.exportState();
1273
1398
  const { promises: fs2 } = await import("fs");
1274
- await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1399
+ await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1275
1400
  }
1276
1401
  async restoreFromFile(filePath) {
1277
1402
  this.assertActive("restoreFromFile");
1278
1403
  const { promises: fs2 } = await import("fs");
1279
- const raw = await fs2.readFile(filePath, "utf8");
1404
+ const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1280
1405
  let parsed;
1281
1406
  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
- });
1407
+ parsed = JSON.parse(raw);
1288
1408
  } catch (cause) {
1289
1409
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1290
1410
  }
1291
1411
  if (!this.isCacheSnapshotEntries(parsed)) {
1292
1412
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1293
1413
  }
1294
- await this.importState(parsed);
1414
+ await this.importState(
1415
+ parsed.map((entry) => ({
1416
+ key: entry.key,
1417
+ value: this.sanitizeSnapshotValue(entry.value),
1418
+ ttl: entry.ttl
1419
+ }))
1420
+ );
1295
1421
  }
1296
1422
  async disconnect() {
1297
1423
  if (!this.disconnectPromise) {
@@ -1300,6 +1426,7 @@ var CacheStack = class extends EventEmitter {
1300
1426
  await this.startup;
1301
1427
  await this.unsubscribeInvalidation?.();
1302
1428
  await this.flushWriteBehindQueue();
1429
+ await this.generationCleanupPromise;
1303
1430
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1304
1431
  if (this.writeBehindTimer) {
1305
1432
  clearInterval(this.writeBehindTimer);
@@ -1383,8 +1510,14 @@ var CacheStack = class extends EventEmitter {
1383
1510
  await this.storeEntry(key, "empty", null, options);
1384
1511
  return null;
1385
1512
  }
1386
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1387
- return fetched;
1513
+ if (options?.shouldCache) {
1514
+ try {
1515
+ if (!options.shouldCache(fetched)) {
1516
+ return fetched;
1517
+ }
1518
+ } catch (error) {
1519
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1520
+ }
1388
1521
  }
1389
1522
  await this.storeEntry(key, "value", fetched, options);
1390
1523
  return fetched;
@@ -1611,7 +1744,7 @@ var CacheStack = class extends EventEmitter {
1611
1744
  const refresh = (async () => {
1612
1745
  this.metricsCollector.increment("refreshes");
1613
1746
  try {
1614
- await this.fetchWithGuards(key, fetcher, options);
1747
+ await this.runBackgroundRefresh(key, fetcher, options);
1615
1748
  } catch (error) {
1616
1749
  this.metricsCollector.increment("refreshErrors");
1617
1750
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1621,6 +1754,16 @@ var CacheStack = class extends EventEmitter {
1621
1754
  })();
1622
1755
  this.backgroundRefreshes.set(key, refresh);
1623
1756
  }
1757
+ async runBackgroundRefresh(key, fetcher, options) {
1758
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
1759
+ await this.fetchWithGuards(
1760
+ key,
1761
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
1762
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
1763
+ }),
1764
+ options
1765
+ );
1766
+ }
1624
1767
  resolveSingleFlightOptions() {
1625
1768
  return {
1626
1769
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
@@ -1688,8 +1831,120 @@ var CacheStack = class extends EventEmitter {
1688
1831
  sleep(ms) {
1689
1832
  return new Promise((resolve2) => setTimeout(resolve2, ms));
1690
1833
  }
1834
+ async withTimeout(promise, timeoutMs, onTimeout) {
1835
+ if (timeoutMs <= 0) {
1836
+ return promise;
1837
+ }
1838
+ let timer;
1839
+ const observedPromise = promise.then(
1840
+ (value) => ({ kind: "value", value }),
1841
+ (error) => ({ kind: "error", error })
1842
+ );
1843
+ try {
1844
+ const result = await Promise.race([
1845
+ observedPromise,
1846
+ new Promise((_, reject) => {
1847
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
1848
+ timer.unref?.();
1849
+ })
1850
+ ]);
1851
+ if (result && typeof result === "object" && "kind" in result) {
1852
+ if (result.kind === "error") {
1853
+ throw result.error;
1854
+ }
1855
+ return result.value;
1856
+ }
1857
+ return result;
1858
+ } finally {
1859
+ if (timer) {
1860
+ clearTimeout(timer);
1861
+ }
1862
+ }
1863
+ }
1691
1864
  shouldBroadcastL1Invalidation() {
1692
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1865
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1866
+ }
1867
+ async collectKeysWithPrefix(prefix) {
1868
+ const matches = new Set(
1869
+ this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
1870
+ );
1871
+ await Promise.all(
1872
+ this.layers.map(async (layer) => {
1873
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
1874
+ return;
1875
+ }
1876
+ try {
1877
+ const keys = await layer.keys();
1878
+ for (const key of keys) {
1879
+ if (key.startsWith(prefix)) {
1880
+ matches.add(key);
1881
+ }
1882
+ }
1883
+ } catch (error) {
1884
+ await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
1885
+ }
1886
+ })
1887
+ );
1888
+ return [...matches];
1889
+ }
1890
+ async collectKeysMatchingPattern(pattern) {
1891
+ const matches = new Set(await this.tagIndex.matchPattern(pattern));
1892
+ await Promise.all(
1893
+ this.layers.map(async (layer) => {
1894
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
1895
+ return;
1896
+ }
1897
+ try {
1898
+ const keys = await layer.keys();
1899
+ for (const key of keys) {
1900
+ if (PatternMatcher.matches(pattern, key)) {
1901
+ matches.add(key);
1902
+ }
1903
+ }
1904
+ } catch (error) {
1905
+ await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
1906
+ }
1907
+ })
1908
+ );
1909
+ return [...matches];
1910
+ }
1911
+ shouldCleanupGenerations() {
1912
+ return Boolean(this.options.generationCleanup);
1913
+ }
1914
+ generationCleanupBatchSize() {
1915
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
1916
+ return configured ?? 500;
1917
+ }
1918
+ scheduleGenerationCleanup(generation) {
1919
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
1920
+ this.logger.warn?.("generation-cleanup-error", {
1921
+ generation,
1922
+ error: this.formatError(error)
1923
+ });
1924
+ });
1925
+ this.generationCleanupPromise = task.finally(() => {
1926
+ if (this.generationCleanupPromise === task) {
1927
+ this.generationCleanupPromise = void 0;
1928
+ }
1929
+ });
1930
+ }
1931
+ async cleanupGeneration(generation) {
1932
+ const prefix = `v${generation}:`;
1933
+ const keys = await this.collectKeysWithPrefix(prefix);
1934
+ if (keys.length === 0) {
1935
+ return;
1936
+ }
1937
+ const batchSize = this.generationCleanupBatchSize();
1938
+ for (let index = 0; index < keys.length; index += batchSize) {
1939
+ const batch = keys.slice(index, index + batchSize);
1940
+ await this.deleteKeys(batch);
1941
+ await this.publishInvalidation({
1942
+ scope: "keys",
1943
+ keys: batch,
1944
+ sourceId: this.instanceId,
1945
+ operation: "invalidate"
1946
+ });
1947
+ }
1693
1948
  }
1694
1949
  initializeWriteBehind(options) {
1695
1950
  if (this.options.writeStrategy !== "write-behind") {
@@ -1727,7 +1982,17 @@ var CacheStack = class extends EventEmitter {
1727
1982
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
1728
1983
  const batch = this.writeBehindQueue.splice(0, batchSize);
1729
1984
  this.writeBehindFlushPromise = (async () => {
1730
- await Promise.allSettled(batch.map((operation) => operation()));
1985
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
1986
+ const failures = results.filter((result) => result.status === "rejected");
1987
+ if (failures.length > 0) {
1988
+ this.metricsCollector.increment("writeFailures", failures.length);
1989
+ this.logger.error?.("write-behind-flush-failure", {
1990
+ failed: failures.length,
1991
+ total: batch.length,
1992
+ errors: failures.map((failure) => this.formatError(failure.reason))
1993
+ });
1994
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
1995
+ }
1731
1996
  })();
1732
1997
  await this.writeBehindFlushPromise;
1733
1998
  this.writeBehindFlushPromise = void 0;
@@ -1832,9 +2097,13 @@ var CacheStack = class extends EventEmitter {
1832
2097
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1833
2098
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1834
2099
  this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2100
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
1835
2101
  this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
1836
2102
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1837
2103
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2104
+ if (typeof this.options.generationCleanup === "object") {
2105
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2106
+ }
1838
2107
  if (this.options.generation !== void 0) {
1839
2108
  this.validateNonNegativeNumber("generation", this.options.generation);
1840
2109
  }
@@ -1906,6 +2175,9 @@ var CacheStack = class extends EventEmitter {
1906
2175
  if (/[\u0000-\u001F\u007F]/.test(key)) {
1907
2176
  throw new Error("Cache key contains unsupported control characters.");
1908
2177
  }
2178
+ if (/[\uD800-\uDFFF]/.test(key)) {
2179
+ throw new Error("Cache key contains unsupported surrogate code points.");
2180
+ }
1909
2181
  return key;
1910
2182
  }
1911
2183
  validateTtlPolicy(name, policy) {
@@ -1983,6 +2255,14 @@ var CacheStack = class extends EventEmitter {
1983
2255
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1984
2256
  return null;
1985
2257
  }
2258
+ async reportRecoverableLayerFailure(layer, operation, error) {
2259
+ if (this.isGracefulDegradationEnabled()) {
2260
+ await this.handleLayerFailure(layer, operation, error);
2261
+ return;
2262
+ }
2263
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2264
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2265
+ }
1986
2266
  isGracefulDegradationEnabled() {
1987
2267
  return Boolean(this.options.gracefulDegradation);
1988
2268
  }
@@ -2006,10 +2286,16 @@ var CacheStack = class extends EventEmitter {
2006
2286
  }
2007
2287
  }
2008
2288
  serializeKeyPart(value) {
2009
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2010
- return String(value);
2289
+ if (typeof value === "string") {
2290
+ return `s:${value}`;
2291
+ }
2292
+ if (typeof value === "number") {
2293
+ return `n:${value}`;
2011
2294
  }
2012
- return JSON.stringify(this.normalizeForSerialization(value));
2295
+ if (typeof value === "boolean") {
2296
+ return `b:${value}`;
2297
+ }
2298
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2013
2299
  }
2014
2300
  isCacheSnapshotEntries(value) {
2015
2301
  return Array.isArray(value) && value.every((entry) => {
@@ -2017,15 +2303,39 @@ var CacheStack = class extends EventEmitter {
2017
2303
  return false;
2018
2304
  }
2019
2305
  const candidate = entry;
2020
- return typeof candidate.key === "string";
2306
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2021
2307
  });
2022
2308
  }
2309
+ sanitizeSnapshotValue(value) {
2310
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2311
+ }
2312
+ async validateSnapshotFilePath(filePath) {
2313
+ if (filePath.length === 0) {
2314
+ throw new Error("filePath must not be empty.");
2315
+ }
2316
+ if (filePath.includes("\0")) {
2317
+ throw new Error("filePath must not contain null bytes.");
2318
+ }
2319
+ const path = await import("path");
2320
+ const resolved = path.resolve(filePath);
2321
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2322
+ if (baseDir !== false) {
2323
+ const relative = path.relative(baseDir, resolved);
2324
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2325
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2326
+ }
2327
+ }
2328
+ return resolved;
2329
+ }
2023
2330
  normalizeForSerialization(value) {
2024
2331
  if (Array.isArray(value)) {
2025
2332
  return value.map((entry) => this.normalizeForSerialization(entry));
2026
2333
  }
2027
2334
  if (value && typeof value === "object") {
2028
2335
  return Object.keys(value).sort().reduce((normalized, key) => {
2336
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2337
+ return normalized;
2338
+ }
2029
2339
  normalized[key] = this.normalizeForSerialization(value[key]);
2030
2340
  return normalized;
2031
2341
  }, {});
@@ -2152,7 +2462,7 @@ function createCachedMethodDecorator(options) {
2152
2462
  function createFastifyLayercachePlugin(cache, options = {}) {
2153
2463
  return async (fastify) => {
2154
2464
  fastify.decorate("cache", cache);
2155
- if (options.exposeStatsRoute !== false && fastify.get) {
2465
+ if (options.exposeStatsRoute === true && fastify.get) {
2156
2466
  fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2157
2467
  }
2158
2468
  };
@@ -2168,7 +2478,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
2168
2478
  next();
2169
2479
  return;
2170
2480
  }
2171
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
2481
+ const rawUrl = req.originalUrl ?? req.url ?? "/";
2482
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2172
2483
  const cached = await cache.get(key, void 0, options);
2173
2484
  if (cached !== null) {
2174
2485
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -2184,7 +2495,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
2184
2495
  if (originalJson) {
2185
2496
  res.json = (body) => {
2186
2497
  res.setHeader?.("x-cache", "MISS");
2187
- void cache.set(key, body, options);
2498
+ cache.set(key, body, options).catch((err) => {
2499
+ cache.emit("error", {
2500
+ operation: "set",
2501
+ error: err instanceof Error ? err.message : String(err)
2502
+ });
2503
+ });
2188
2504
  return originalJson(body);
2189
2505
  };
2190
2506
  }
@@ -2194,6 +2510,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
2194
2510
  }
2195
2511
  };
2196
2512
  }
2513
+ function normalizeUrl(url) {
2514
+ try {
2515
+ const parsed = new URL(url, "http://localhost");
2516
+ parsed.searchParams.sort();
2517
+ return decodeURIComponent(parsed.pathname) + parsed.search;
2518
+ } catch {
2519
+ return url;
2520
+ }
2521
+ }
2197
2522
 
2198
2523
  // src/integrations/graphql.ts
2199
2524
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
@@ -2294,39 +2619,6 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
2294
2619
  // src/layers/RedisLayer.ts
2295
2620
  import { promisify } from "util";
2296
2621
  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
2622
  var BATCH_DELETE_SIZE = 500;
2331
2623
  var gzipAsync = promisify(gzip);
2332
2624
  var gunzipAsync = promisify(gunzip);
@@ -2343,6 +2635,7 @@ var RedisLayer = class {
2343
2635
  scanCount;
2344
2636
  compression;
2345
2637
  compressionThreshold;
2638
+ decompressionMaxBytes;
2346
2639
  disconnectOnDispose;
2347
2640
  constructor(options) {
2348
2641
  this.client = options.client;
@@ -2354,6 +2647,7 @@ var RedisLayer = class {
2354
2647
  this.scanCount = options.scanCount ?? 100;
2355
2648
  this.compression = options.compression;
2356
2649
  this.compressionThreshold = options.compressionThreshold ?? 1024;
2650
+ this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
2357
2651
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
2358
2652
  }
2359
2653
  async get(key) {
@@ -2553,16 +2847,29 @@ var RedisLayer = class {
2553
2847
  }
2554
2848
  /**
2555
2849
  * Decompresses the payload asynchronously if a compression header is present.
2850
+ * Enforces a maximum decompressed size to prevent decompression bomb attacks.
2556
2851
  */
2557
2852
  async decodePayload(payload) {
2558
2853
  if (!Buffer.isBuffer(payload)) {
2559
2854
  return payload;
2560
2855
  }
2561
2856
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2562
- return gunzipAsync(payload.subarray(10));
2857
+ const decompressed = await gunzipAsync(payload.subarray(10));
2858
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2859
+ throw new Error(
2860
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2861
+ );
2862
+ }
2863
+ return decompressed;
2563
2864
  }
2564
2865
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2565
- return brotliDecompressAsync(payload.subarray(12));
2866
+ const decompressed = await brotliDecompressAsync(payload.subarray(12));
2867
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
2868
+ throw new Error(
2869
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2870
+ );
2871
+ }
2872
+ return decompressed;
2566
2873
  }
2567
2874
  return payload;
2568
2875
  }
@@ -2622,8 +2929,13 @@ var DiskLayer = class {
2622
2929
  const payload = this.serializer.serialize(entry);
2623
2930
  const targetPath = this.keyToPath(key);
2624
2931
  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);
2932
+ try {
2933
+ await fs.writeFile(tempPath, payload);
2934
+ await fs.rename(tempPath, targetPath);
2935
+ } catch (error) {
2936
+ await this.safeDelete(tempPath);
2937
+ throw error;
2938
+ }
2627
2939
  if (this.maxFiles !== void 0) {
2628
2940
  await this.enforceMaxFiles();
2629
2941
  }
@@ -2633,9 +2945,7 @@ var DiskLayer = class {
2633
2945
  return Promise.all(keys.map((key) => this.getEntry(key)));
2634
2946
  }
2635
2947
  async setMany(entries) {
2636
- for (const entry of entries) {
2637
- await this.set(entry.key, entry.value, entry.ttl);
2638
- }
2948
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
2639
2949
  }
2640
2950
  async has(key) {
2641
2951
  const value = await this.getEntry(key);
@@ -2839,6 +3149,7 @@ var MemcachedLayer = class {
2839
3149
  return unwrapStoredValue(await this.getEntry(key));
2840
3150
  }
2841
3151
  async getEntry(key) {
3152
+ this.validateKey(key);
2842
3153
  const result = await this.client.get(this.withPrefix(key));
2843
3154
  if (!result || result.value === null) {
2844
3155
  return null;
@@ -2853,16 +3164,19 @@ var MemcachedLayer = class {
2853
3164
  return Promise.all(keys.map((key) => this.getEntry(key)));
2854
3165
  }
2855
3166
  async set(key, value, ttl = this.defaultTtl) {
3167
+ this.validateKey(key);
2856
3168
  const payload = this.serializer.serialize(value);
2857
3169
  await this.client.set(this.withPrefix(key), payload, {
2858
3170
  expires: ttl && ttl > 0 ? ttl : void 0
2859
3171
  });
2860
3172
  }
2861
3173
  async has(key) {
3174
+ this.validateKey(key);
2862
3175
  const result = await this.client.get(this.withPrefix(key));
2863
3176
  return result !== null && result.value !== null;
2864
3177
  }
2865
3178
  async delete(key) {
3179
+ this.validateKey(key);
2866
3180
  await this.client.delete(this.withPrefix(key));
2867
3181
  }
2868
3182
  async deleteMany(keys) {
@@ -2876,19 +3190,50 @@ var MemcachedLayer = class {
2876
3190
  withPrefix(key) {
2877
3191
  return `${this.keyPrefix}${key}`;
2878
3192
  }
3193
+ validateKey(key) {
3194
+ const fullKey = this.withPrefix(key);
3195
+ if (Buffer.byteLength(fullKey, "utf8") > 250) {
3196
+ throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
3197
+ }
3198
+ if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
3199
+ throw new Error(
3200
+ "MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
3201
+ );
3202
+ }
3203
+ }
2879
3204
  };
2880
3205
 
2881
3206
  // src/serialization/MsgpackSerializer.ts
2882
3207
  import { decode, encode } from "@msgpack/msgpack";
3208
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2883
3209
  var MsgpackSerializer = class {
2884
3210
  serialize(value) {
2885
3211
  return Buffer.from(encode(value));
2886
3212
  }
2887
3213
  deserialize(payload) {
2888
3214
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
2889
- return decode(normalized);
3215
+ return sanitizeMsgpackValue(decode(normalized));
2890
3216
  }
2891
3217
  };
3218
+ function sanitizeMsgpackValue(value) {
3219
+ if (Array.isArray(value)) {
3220
+ return value.map((entry) => sanitizeMsgpackValue(entry));
3221
+ }
3222
+ if (!isPlainObject2(value)) {
3223
+ return value;
3224
+ }
3225
+ const sanitized = {};
3226
+ for (const [key, entry] of Object.entries(value)) {
3227
+ if (DANGEROUS_KEYS.has(key)) {
3228
+ continue;
3229
+ }
3230
+ sanitized[key] = sanitizeMsgpackValue(entry);
3231
+ }
3232
+ return sanitized;
3233
+ }
3234
+ function isPlainObject2(value) {
3235
+ return Object.prototype.toString.call(value) === "[object Object]";
3236
+ }
2892
3237
 
2893
3238
  // src/singleflight/RedisSingleFlightCoordinator.ts
2894
3239
  import { randomUUID } from "crypto";
@@ -3017,7 +3362,7 @@ function createPrometheusMetricsExporter(stacks) {
3017
3362
  };
3018
3363
  }
3019
3364
  function sanitizeLabel(value) {
3020
- return value.replace(/["\\\n]/g, "_");
3365
+ return value.replace(/["\\\n\r]/g, "_");
3021
3366
  }
3022
3367
  export {
3023
3368
  CacheMissError,