layercache 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -400,11 +400,13 @@ var CircuitBreakerManager = class {
400
400
 
401
401
  // src/internal/FetchRateLimiter.ts
402
402
  var FetchRateLimiter = class {
403
- active = 0;
404
- queue = [];
405
- startedAt = [];
403
+ buckets = /* @__PURE__ */ new Map();
404
+ queuesByBucket = /* @__PURE__ */ new Map();
405
+ pendingBuckets = /* @__PURE__ */ new Set();
406
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
407
+ nextFetcherBucketId = 0;
406
408
  drainTimer;
407
- async schedule(options, task) {
409
+ async schedule(options, context, task) {
408
410
  if (!options) {
409
411
  return task();
410
412
  }
@@ -412,8 +414,18 @@ var FetchRateLimiter = class {
412
414
  if (!normalized) {
413
415
  return task();
414
416
  }
415
- return new Promise((resolve, reject) => {
416
- this.queue.push({ options: normalized, task, resolve, reject });
417
+ return new Promise((resolve2, reject) => {
418
+ const bucketKey = this.resolveBucketKey(normalized, context);
419
+ const queue = this.queuesByBucket.get(bucketKey) ?? [];
420
+ queue.push({
421
+ bucketKey,
422
+ options: normalized,
423
+ task,
424
+ resolve: resolve2,
425
+ reject
426
+ });
427
+ this.queuesByBucket.set(bucketKey, queue);
428
+ this.pendingBuckets.add(bucketKey);
417
429
  this.drain();
418
430
  });
419
431
  }
@@ -427,63 +439,159 @@ var FetchRateLimiter = class {
427
439
  return {
428
440
  maxConcurrent,
429
441
  intervalMs,
430
- maxPerInterval
442
+ maxPerInterval,
443
+ scope: options.scope ?? "global",
444
+ bucketKey: options.bucketKey
431
445
  };
432
446
  }
447
+ resolveBucketKey(options, context) {
448
+ if (options.bucketKey) {
449
+ return `custom:${options.bucketKey}`;
450
+ }
451
+ if (options.scope === "key") {
452
+ return `key:${context.key}`;
453
+ }
454
+ if (options.scope === "fetcher") {
455
+ const existing = this.fetcherBuckets.get(context.fetcher);
456
+ if (existing) {
457
+ return existing;
458
+ }
459
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
460
+ this.nextFetcherBucketId += 1;
461
+ this.fetcherBuckets.set(context.fetcher, bucket);
462
+ return bucket;
463
+ }
464
+ return "global";
465
+ }
433
466
  drain() {
434
467
  if (this.drainTimer) {
435
468
  clearTimeout(this.drainTimer);
436
469
  this.drainTimer = void 0;
437
470
  }
438
- while (this.queue.length > 0) {
439
- const next = this.queue[0];
440
- if (!next) {
441
- return;
471
+ while (this.pendingBuckets.size > 0) {
472
+ let nextBucketKey;
473
+ let nextWaitMs = Number.POSITIVE_INFINITY;
474
+ for (const bucketKey of this.pendingBuckets) {
475
+ const queue2 = this.queuesByBucket.get(bucketKey);
476
+ if (!queue2 || queue2.length === 0) {
477
+ this.pendingBuckets.delete(bucketKey);
478
+ this.queuesByBucket.delete(bucketKey);
479
+ continue;
480
+ }
481
+ const next2 = queue2[0];
482
+ if (!next2) {
483
+ this.pendingBuckets.delete(bucketKey);
484
+ this.queuesByBucket.delete(bucketKey);
485
+ continue;
486
+ }
487
+ const waitMs = this.waitTime(bucketKey, next2.options);
488
+ if (waitMs <= 0) {
489
+ nextBucketKey = bucketKey;
490
+ break;
491
+ }
492
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
442
493
  }
443
- const waitMs = this.waitTime(next.options);
444
- if (waitMs > 0) {
445
- this.drainTimer = setTimeout(() => {
446
- this.drainTimer = void 0;
447
- this.drain();
448
- }, waitMs);
449
- this.drainTimer.unref?.();
494
+ if (!nextBucketKey) {
495
+ if (Number.isFinite(nextWaitMs)) {
496
+ this.drainTimer = setTimeout(() => {
497
+ this.drainTimer = void 0;
498
+ this.drain();
499
+ }, nextWaitMs);
500
+ this.drainTimer.unref?.();
501
+ }
450
502
  return;
451
503
  }
452
- this.queue.shift();
453
- this.active += 1;
454
- this.startedAt.push(Date.now());
504
+ const queue = this.queuesByBucket.get(nextBucketKey);
505
+ const next = queue?.shift();
506
+ if (!next) {
507
+ this.pendingBuckets.delete(nextBucketKey);
508
+ this.queuesByBucket.delete(nextBucketKey);
509
+ continue;
510
+ }
511
+ if (!queue || queue.length === 0) {
512
+ this.pendingBuckets.delete(nextBucketKey);
513
+ this.queuesByBucket.delete(nextBucketKey);
514
+ }
515
+ const bucket = this.bucketState(next.bucketKey);
516
+ if (bucket.cleanupTimer) {
517
+ clearTimeout(bucket.cleanupTimer);
518
+ bucket.cleanupTimer = void 0;
519
+ }
520
+ bucket.active += 1;
521
+ if (next.options.intervalMs && next.options.maxPerInterval) {
522
+ bucket.startedAt.push(Date.now());
523
+ }
455
524
  void next.task().then(next.resolve, next.reject).finally(() => {
456
- this.active -= 1;
525
+ bucket.active -= 1;
526
+ if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
527
+ this.pendingBuckets.add(next.bucketKey);
528
+ }
529
+ this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
457
530
  this.drain();
458
531
  });
459
532
  }
460
533
  }
461
- waitTime(options) {
534
+ waitTime(bucketKey, options) {
535
+ const bucket = this.bucketState(bucketKey);
462
536
  const now = Date.now();
463
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
537
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
464
538
  return 1;
465
539
  }
466
540
  if (!options.intervalMs || !options.maxPerInterval) {
467
541
  return 0;
468
542
  }
469
- this.prune(now, options.intervalMs);
470
- if (this.startedAt.length < options.maxPerInterval) {
543
+ this.prune(bucket, now, options.intervalMs);
544
+ if (bucket.startedAt.length < options.maxPerInterval) {
471
545
  return 0;
472
546
  }
473
- const oldest = this.startedAt[0];
547
+ const oldest = bucket.startedAt[0];
474
548
  if (!oldest) {
475
549
  return 0;
476
550
  }
477
551
  return Math.max(1, options.intervalMs - (now - oldest));
478
552
  }
479
- prune(now, intervalMs) {
480
- while (this.startedAt.length > 0) {
481
- const startedAt = this.startedAt[0];
553
+ prune(bucket, now, intervalMs) {
554
+ while (bucket.startedAt.length > 0) {
555
+ const startedAt = bucket.startedAt[0];
482
556
  if (startedAt === void 0 || now - startedAt < intervalMs) {
483
557
  break;
484
558
  }
485
- this.startedAt.shift();
559
+ bucket.startedAt.shift();
560
+ }
561
+ }
562
+ bucketState(bucketKey) {
563
+ const existing = this.buckets.get(bucketKey);
564
+ if (existing) {
565
+ return existing;
486
566
  }
567
+ const bucket = { active: 0, startedAt: [] };
568
+ this.buckets.set(bucketKey, bucket);
569
+ return bucket;
570
+ }
571
+ cleanupBucket(bucketKey, bucket, intervalMs) {
572
+ const queued = this.queuesByBucket.get(bucketKey)?.length ?? 0;
573
+ if (queued === 0 && bucket.active === 0 && bucket.startedAt.length === 0) {
574
+ this.buckets.delete(bucketKey);
575
+ this.queuesByBucket.delete(bucketKey);
576
+ this.pendingBuckets.delete(bucketKey);
577
+ return;
578
+ }
579
+ if (!intervalMs || bucket.active > 0 || queued > 0) {
580
+ return;
581
+ }
582
+ if (bucket.cleanupTimer) {
583
+ clearTimeout(bucket.cleanupTimer);
584
+ }
585
+ bucket.cleanupTimer = setTimeout(() => {
586
+ bucket.cleanupTimer = void 0;
587
+ this.prune(bucket, Date.now(), intervalMs);
588
+ if (bucket.active === 0 && bucket.startedAt.length === 0 && (this.queuesByBucket.get(bucketKey)?.length ?? 0) === 0) {
589
+ this.buckets.delete(bucketKey);
590
+ this.queuesByBucket.delete(bucketKey);
591
+ this.pendingBuckets.delete(bucketKey);
592
+ }
593
+ }, intervalMs);
594
+ bucket.cleanupTimer.unref?.();
487
595
  }
488
596
  };
489
597
 
@@ -563,7 +671,30 @@ var MetricsCollector = class {
563
671
 
564
672
  // src/internal/StoredValue.ts
565
673
  function isStoredValueEnvelope(value) {
566
- return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
674
+ if (typeof value !== "object" || value === null) {
675
+ return false;
676
+ }
677
+ const v = value;
678
+ if (v.__layercache !== 1) {
679
+ return false;
680
+ }
681
+ if (v.kind !== "value" && v.kind !== "empty") {
682
+ return false;
683
+ }
684
+ if (v.freshUntil !== null && typeof v.freshUntil !== "number") {
685
+ return false;
686
+ }
687
+ if (v.staleUntil !== null && typeof v.staleUntil !== "number") {
688
+ return false;
689
+ }
690
+ if (v.errorUntil !== null && typeof v.errorUntil !== "number") {
691
+ return false;
692
+ }
693
+ const maxTimestamp = Date.now() + 10 * 365 * 24 * 60 * 60 * 1e3;
694
+ if (typeof v.freshUntil === "number" && v.freshUntil > maxTimestamp) {
695
+ return false;
696
+ }
697
+ return true;
567
698
  }
568
699
  function createStoredValueEnvelope(options) {
569
700
  const now = options.now ?? Date.now();
@@ -828,15 +959,17 @@ var TagIndex = class {
828
959
  keyToTags = /* @__PURE__ */ new Map();
829
960
  knownKeys = /* @__PURE__ */ new Set();
830
961
  maxKnownKeys;
962
+ nextNodeId = 1;
963
+ root = this.createTrieNode();
831
964
  constructor(options = {}) {
832
- this.maxKnownKeys = options.maxKnownKeys;
965
+ this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
833
966
  }
834
967
  async touch(key) {
835
- this.knownKeys.add(key);
968
+ this.insertKnownKey(key);
836
969
  this.pruneKnownKeysIfNeeded();
837
970
  }
838
971
  async track(key, tags) {
839
- this.knownKeys.add(key);
972
+ this.insertKnownKey(key);
840
973
  this.pruneKnownKeysIfNeeded();
841
974
  if (tags.length === 0) {
842
975
  return;
@@ -862,18 +995,104 @@ var TagIndex = class {
862
995
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
863
996
  }
864
997
  async keysForPrefix(prefix) {
865
- return [...this.knownKeys].filter((key) => key.startsWith(prefix));
998
+ const node = this.findNode(prefix);
999
+ if (!node) {
1000
+ return [];
1001
+ }
1002
+ const matches = [];
1003
+ this.collectFromNode(node, prefix, matches);
1004
+ return matches;
866
1005
  }
867
1006
  async tagsForKey(key) {
868
1007
  return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
869
1008
  }
870
1009
  async matchPattern(pattern) {
871
- return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
1010
+ const matches = /* @__PURE__ */ new Set();
1011
+ this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set());
1012
+ return [...matches];
872
1013
  }
873
1014
  async clear() {
874
1015
  this.tagToKeys.clear();
875
1016
  this.keyToTags.clear();
876
1017
  this.knownKeys.clear();
1018
+ this.root.children.clear();
1019
+ this.root.terminal = false;
1020
+ this.nextNodeId = this.root.id + 1;
1021
+ }
1022
+ createTrieNode() {
1023
+ return {
1024
+ id: this.nextNodeId++,
1025
+ terminal: false,
1026
+ children: /* @__PURE__ */ new Map()
1027
+ };
1028
+ }
1029
+ insertKnownKey(key) {
1030
+ if (this.knownKeys.has(key)) {
1031
+ return;
1032
+ }
1033
+ this.knownKeys.add(key);
1034
+ let node = this.root;
1035
+ for (const character of key) {
1036
+ let child = node.children.get(character);
1037
+ if (!child) {
1038
+ child = this.createTrieNode();
1039
+ node.children.set(character, child);
1040
+ }
1041
+ node = child;
1042
+ }
1043
+ node.terminal = true;
1044
+ }
1045
+ findNode(prefix) {
1046
+ let node = this.root;
1047
+ for (const character of prefix) {
1048
+ node = node.children.get(character);
1049
+ if (!node) {
1050
+ return void 0;
1051
+ }
1052
+ }
1053
+ return node;
1054
+ }
1055
+ collectFromNode(node, prefix, matches) {
1056
+ if (node.terminal) {
1057
+ matches.push(prefix);
1058
+ }
1059
+ for (const [character, child] of node.children) {
1060
+ this.collectFromNode(child, `${prefix}${character}`, matches);
1061
+ }
1062
+ }
1063
+ collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited) {
1064
+ const stateKey = `${node.id}:${patternIndex}`;
1065
+ if (visited.has(stateKey)) {
1066
+ return;
1067
+ }
1068
+ visited.add(stateKey);
1069
+ if (patternIndex === pattern.length) {
1070
+ if (node.terminal) {
1071
+ matches.add(prefix);
1072
+ }
1073
+ return;
1074
+ }
1075
+ const patternChar = pattern[patternIndex];
1076
+ if (patternChar === void 0) {
1077
+ return;
1078
+ }
1079
+ if (patternChar === "*") {
1080
+ this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited);
1081
+ for (const [character, child2] of node.children) {
1082
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited);
1083
+ }
1084
+ return;
1085
+ }
1086
+ if (patternChar === "?") {
1087
+ for (const [character, child2] of node.children) {
1088
+ this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex + 1, matches, visited);
1089
+ }
1090
+ return;
1091
+ }
1092
+ const child = node.children.get(patternChar);
1093
+ if (child) {
1094
+ this.collectPatternMatches(child, `${prefix}${patternChar}`, pattern, patternIndex + 1, matches, visited);
1095
+ }
877
1096
  }
878
1097
  pruneKnownKeysIfNeeded() {
879
1098
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
@@ -890,7 +1109,7 @@ var TagIndex = class {
890
1109
  }
891
1110
  }
892
1111
  removeKey(key) {
893
- this.knownKeys.delete(key);
1112
+ this.removeKnownKey(key);
894
1113
  const tags = this.keyToTags.get(key);
895
1114
  if (!tags) {
896
1115
  return;
@@ -907,7 +1126,70 @@ var TagIndex = class {
907
1126
  }
908
1127
  this.keyToTags.delete(key);
909
1128
  }
1129
+ removeKnownKey(key) {
1130
+ if (!this.knownKeys.delete(key)) {
1131
+ return;
1132
+ }
1133
+ const path = [];
1134
+ let node = this.root;
1135
+ for (const character of key) {
1136
+ const child = node.children.get(character);
1137
+ if (!child) {
1138
+ return;
1139
+ }
1140
+ path.push([node, character]);
1141
+ node = child;
1142
+ }
1143
+ node.terminal = false;
1144
+ for (let index = path.length - 1; index >= 0; index -= 1) {
1145
+ const entry = path[index];
1146
+ if (!entry) {
1147
+ continue;
1148
+ }
1149
+ const [parent, character] = entry;
1150
+ const child = parent.children.get(character);
1151
+ if (!child || child.terminal || child.children.size > 0) {
1152
+ break;
1153
+ }
1154
+ parent.children.delete(character);
1155
+ }
1156
+ }
1157
+ };
1158
+
1159
+ // src/serialization/JsonSerializer.ts
1160
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1161
+ var JsonSerializer = class {
1162
+ serialize(value) {
1163
+ return JSON.stringify(value);
1164
+ }
1165
+ deserialize(payload) {
1166
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1167
+ return sanitizeJsonValue(JSON.parse(normalized), 0);
1168
+ }
910
1169
  };
1170
+ var MAX_SANITIZE_DEPTH = 200;
1171
+ function sanitizeJsonValue(value, depth) {
1172
+ if (depth > MAX_SANITIZE_DEPTH) {
1173
+ return value;
1174
+ }
1175
+ if (Array.isArray(value)) {
1176
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1177
+ }
1178
+ if (!isPlainObject(value)) {
1179
+ return value;
1180
+ }
1181
+ const sanitized = {};
1182
+ for (const [key, entry] of Object.entries(value)) {
1183
+ if (DANGEROUS_JSON_KEYS.has(key)) {
1184
+ continue;
1185
+ }
1186
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1187
+ }
1188
+ return sanitized;
1189
+ }
1190
+ function isPlainObject(value) {
1191
+ return Object.prototype.toString.call(value) === "[object Object]";
1192
+ }
911
1193
 
912
1194
  // src/stampede/StampedeGuard.ts
913
1195
  var import_async_mutex2 = require("async-mutex");
@@ -919,7 +1201,8 @@ var StampedeGuard = class {
919
1201
  return await entry.mutex.runExclusive(task);
920
1202
  } finally {
921
1203
  entry.references -= 1;
922
- if (entry.references === 0 && !entry.mutex.isLocked()) {
1204
+ const current = this.mutexes.get(key);
1205
+ if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
923
1206
  this.mutexes.delete(key);
924
1207
  }
925
1208
  }
@@ -949,8 +1232,10 @@ var CacheMissError = class extends Error {
949
1232
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
950
1233
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
951
1234
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1235
+ var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
952
1236
  var MAX_CACHE_KEY_LENGTH = 1024;
953
1237
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1238
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
954
1239
  var DebugLogger = class {
955
1240
  enabled;
956
1241
  constructor(enabled) {
@@ -997,6 +1282,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
997
1282
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
998
1283
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
999
1284
  this.tagIndex = options.tagIndex ?? new TagIndex();
1285
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1286
+ this.logger.warn?.(
1287
+ "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."
1288
+ );
1289
+ }
1290
+ if (!options.tagIndex && layers.some((layer) => layer.isLocal === false && !layer.keys)) {
1291
+ this.logger.warn?.(
1292
+ "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."
1293
+ );
1294
+ }
1295
+ if (options.invalidationBus && options.broadcastL1Invalidation === void 0 && options.publishSetInvalidation === void 0) {
1296
+ this.logger.warn?.(
1297
+ "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1298
+ );
1299
+ }
1000
1300
  this.initializeWriteBehind(options.writeBehind);
1001
1301
  this.startup = this.initialize();
1002
1302
  }
@@ -1010,6 +1310,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1010
1310
  logger;
1011
1311
  tagIndex;
1012
1312
  fetchRateLimiter = new FetchRateLimiter();
1313
+ snapshotSerializer = new JsonSerializer();
1013
1314
  backgroundRefreshes = /* @__PURE__ */ new Map();
1014
1315
  layerDegradedUntil = /* @__PURE__ */ new Map();
1015
1316
  ttlResolver;
@@ -1018,6 +1319,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1018
1319
  writeBehindQueue = [];
1019
1320
  writeBehindTimer;
1020
1321
  writeBehindFlushPromise;
1322
+ generationCleanupPromise;
1021
1323
  isDisconnecting = false;
1022
1324
  disconnectPromise;
1023
1325
  /**
@@ -1030,6 +1332,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
1030
1332
  const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1031
1333
  this.validateWriteOptions(options);
1032
1334
  await this.awaitStartup("get");
1335
+ return this.getPrepared(normalizedKey, fetcher, options);
1336
+ }
1337
+ async getPrepared(normalizedKey, fetcher, options) {
1033
1338
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
1034
1339
  if (hit.found) {
1035
1340
  this.ttlResolver.recordAccess(normalizedKey);
@@ -1107,6 +1412,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1107
1412
  return true;
1108
1413
  }
1109
1414
  } catch {
1415
+ await this.reportRecoverableLayerFailure(layer, "has", new Error(`has() failed for layer "${layer.name}"`));
1110
1416
  }
1111
1417
  } else {
1112
1418
  try {
@@ -1114,7 +1420,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
1114
1420
  if (value !== null) {
1115
1421
  return true;
1116
1422
  }
1117
- } catch {
1423
+ } catch (error) {
1424
+ await this.reportRecoverableLayerFailure(layer, "has", error);
1118
1425
  }
1119
1426
  }
1120
1427
  }
@@ -1206,13 +1513,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1206
1513
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1207
1514
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1208
1515
  if (!canFastPath) {
1516
+ await this.awaitStartup("mget");
1209
1517
  const pendingReads = /* @__PURE__ */ new Map();
1210
1518
  return Promise.all(
1211
1519
  normalizedEntries.map((entry) => {
1212
1520
  const optionsSignature = this.serializeOptions(entry.options);
1213
1521
  const existing = pendingReads.get(entry.key);
1214
1522
  if (!existing) {
1215
- const promise = this.get(entry.key, entry.fetch, entry.options);
1523
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1216
1524
  pendingReads.set(entry.key, {
1217
1525
  promise,
1218
1526
  fetch: entry.fetch,
@@ -1351,14 +1659,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1351
1659
  }
1352
1660
  async invalidateByPattern(pattern) {
1353
1661
  await this.awaitStartup("invalidateByPattern");
1354
- const keys = await this.tagIndex.matchPattern(this.qualifyPattern(pattern));
1662
+ const keys = await this.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1355
1663
  await this.deleteKeys(keys);
1356
1664
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1357
1665
  }
1358
1666
  async invalidateByPrefix(prefix) {
1359
1667
  await this.awaitStartup("invalidateByPrefix");
1360
1668
  const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1361
- const keys = this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(qualifiedPrefix) : await this.tagIndex.matchPattern(`${qualifiedPrefix}*`);
1669
+ const keys = await this.collectKeysWithPrefix(qualifiedPrefix);
1362
1670
  await this.deleteKeys(keys);
1363
1671
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1364
1672
  }
@@ -1408,9 +1716,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
1408
1716
  })
1409
1717
  );
1410
1718
  }
1719
+ /**
1720
+ * Rotates the active generation prefix used for all future cache keys.
1721
+ * Previous-generation keys remain in the underlying layers until they expire,
1722
+ * unless `generationCleanup` is enabled to prune them in the background.
1723
+ */
1411
1724
  bumpGeneration(nextGeneration) {
1412
1725
  const current = this.currentGeneration ?? 0;
1726
+ const previousGeneration = this.currentGeneration;
1413
1727
  this.currentGeneration = nextGeneration ?? current + 1;
1728
+ if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1729
+ this.scheduleGenerationCleanup(previousGeneration);
1730
+ }
1414
1731
  return this.currentGeneration;
1415
1732
  }
1416
1733
  /**
@@ -1494,27 +1811,28 @@ var CacheStack = class extends import_node_events.EventEmitter {
1494
1811
  this.assertActive("persistToFile");
1495
1812
  const snapshot = await this.exportState();
1496
1813
  const { promises: fs2 } = await import("fs");
1497
- await fs2.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1814
+ await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1498
1815
  }
1499
1816
  async restoreFromFile(filePath) {
1500
1817
  this.assertActive("restoreFromFile");
1501
1818
  const { promises: fs2 } = await import("fs");
1502
- const raw = await fs2.readFile(filePath, "utf8");
1819
+ const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1503
1820
  let parsed;
1504
1821
  try {
1505
- parsed = JSON.parse(raw, (_key, value) => {
1506
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1507
- return Object.assign(/* @__PURE__ */ Object.create(null), value);
1508
- }
1509
- return value;
1510
- });
1822
+ parsed = JSON.parse(raw);
1511
1823
  } catch (cause) {
1512
1824
  throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1513
1825
  }
1514
1826
  if (!this.isCacheSnapshotEntries(parsed)) {
1515
1827
  throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1516
1828
  }
1517
- await this.importState(parsed);
1829
+ await this.importState(
1830
+ parsed.map((entry) => ({
1831
+ key: entry.key,
1832
+ value: this.sanitizeSnapshotValue(entry.value),
1833
+ ttl: entry.ttl
1834
+ }))
1835
+ );
1518
1836
  }
1519
1837
  async disconnect() {
1520
1838
  if (!this.disconnectPromise) {
@@ -1523,6 +1841,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1523
1841
  await this.startup;
1524
1842
  await this.unsubscribeInvalidation?.();
1525
1843
  await this.flushWriteBehindQueue();
1844
+ await this.generationCleanupPromise;
1526
1845
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1527
1846
  if (this.writeBehindTimer) {
1528
1847
  clearInterval(this.writeBehindTimer);
@@ -1590,6 +1909,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1590
1909
  try {
1591
1910
  fetched = await this.fetchRateLimiter.schedule(
1592
1911
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
1912
+ { key, fetcher },
1593
1913
  fetcher
1594
1914
  );
1595
1915
  this.circuitBreakerManager.recordSuccess(key);
@@ -1605,8 +1925,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
1605
1925
  await this.storeEntry(key, "empty", null, options);
1606
1926
  return null;
1607
1927
  }
1608
- if (options?.shouldCache && !options.shouldCache(fetched)) {
1609
- return fetched;
1928
+ if (options?.shouldCache) {
1929
+ try {
1930
+ if (!options.shouldCache(fetched)) {
1931
+ return fetched;
1932
+ }
1933
+ } catch (error) {
1934
+ this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1935
+ }
1610
1936
  }
1611
1937
  await this.storeEntry(key, "value", fetched, options);
1612
1938
  return fetched;
@@ -1833,7 +2159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1833
2159
  const refresh = (async () => {
1834
2160
  this.metricsCollector.increment("refreshes");
1835
2161
  try {
1836
- await this.fetchWithGuards(key, fetcher, options);
2162
+ await this.runBackgroundRefresh(key, fetcher, options);
1837
2163
  } catch (error) {
1838
2164
  this.metricsCollector.increment("refreshErrors");
1839
2165
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1843,11 +2169,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
1843
2169
  })();
1844
2170
  this.backgroundRefreshes.set(key, refresh);
1845
2171
  }
2172
+ async runBackgroundRefresh(key, fetcher, options) {
2173
+ const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
2174
+ await this.fetchWithGuards(
2175
+ key,
2176
+ () => this.withTimeout(fetcher(), timeoutMs, () => {
2177
+ return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
2178
+ }),
2179
+ options
2180
+ );
2181
+ }
1846
2182
  resolveSingleFlightOptions() {
1847
2183
  return {
1848
2184
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
1849
2185
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
1850
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2186
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
2187
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
1851
2188
  };
1852
2189
  }
1853
2190
  async deleteKeys(keys) {
@@ -1907,10 +2244,122 @@ var CacheStack = class extends import_node_events.EventEmitter {
1907
2244
  return String(error);
1908
2245
  }
1909
2246
  sleep(ms) {
1910
- return new Promise((resolve) => setTimeout(resolve, ms));
2247
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2248
+ }
2249
+ async withTimeout(promise, timeoutMs, onTimeout) {
2250
+ if (timeoutMs <= 0) {
2251
+ return promise;
2252
+ }
2253
+ let timer;
2254
+ const observedPromise = promise.then(
2255
+ (value) => ({ kind: "value", value }),
2256
+ (error) => ({ kind: "error", error })
2257
+ );
2258
+ try {
2259
+ const result = await Promise.race([
2260
+ observedPromise,
2261
+ new Promise((_, reject) => {
2262
+ timer = setTimeout(() => reject(onTimeout()), timeoutMs);
2263
+ timer.unref?.();
2264
+ })
2265
+ ]);
2266
+ if (result && typeof result === "object" && "kind" in result) {
2267
+ if (result.kind === "error") {
2268
+ throw result.error;
2269
+ }
2270
+ return result.value;
2271
+ }
2272
+ return result;
2273
+ } finally {
2274
+ if (timer) {
2275
+ clearTimeout(timer);
2276
+ }
2277
+ }
1911
2278
  }
1912
2279
  shouldBroadcastL1Invalidation() {
1913
- return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
2280
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2281
+ }
2282
+ async collectKeysWithPrefix(prefix) {
2283
+ const matches = new Set(
2284
+ this.tagIndex.keysForPrefix ? await this.tagIndex.keysForPrefix(prefix) : await this.tagIndex.matchPattern(`${prefix}*`)
2285
+ );
2286
+ await Promise.all(
2287
+ this.layers.map(async (layer) => {
2288
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2289
+ return;
2290
+ }
2291
+ try {
2292
+ const keys = await layer.keys();
2293
+ for (const key of keys) {
2294
+ if (key.startsWith(prefix)) {
2295
+ matches.add(key);
2296
+ }
2297
+ }
2298
+ } catch (error) {
2299
+ await this.handleLayerFailure(layer, "invalidate-prefix-scan", error);
2300
+ }
2301
+ })
2302
+ );
2303
+ return [...matches];
2304
+ }
2305
+ async collectKeysMatchingPattern(pattern) {
2306
+ const matches = new Set(await this.tagIndex.matchPattern(pattern));
2307
+ await Promise.all(
2308
+ this.layers.map(async (layer) => {
2309
+ if (!layer.keys || this.shouldSkipLayer(layer)) {
2310
+ return;
2311
+ }
2312
+ try {
2313
+ const keys = await layer.keys();
2314
+ for (const key of keys) {
2315
+ if (PatternMatcher.matches(pattern, key)) {
2316
+ matches.add(key);
2317
+ }
2318
+ }
2319
+ } catch (error) {
2320
+ await this.handleLayerFailure(layer, "invalidate-pattern-scan", error);
2321
+ }
2322
+ })
2323
+ );
2324
+ return [...matches];
2325
+ }
2326
+ shouldCleanupGenerations() {
2327
+ return Boolean(this.options.generationCleanup);
2328
+ }
2329
+ generationCleanupBatchSize() {
2330
+ const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
2331
+ return configured ?? 500;
2332
+ }
2333
+ scheduleGenerationCleanup(generation) {
2334
+ const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
2335
+ this.logger.warn?.("generation-cleanup-error", {
2336
+ generation,
2337
+ error: this.formatError(error)
2338
+ });
2339
+ });
2340
+ this.generationCleanupPromise = task.finally(() => {
2341
+ if (this.generationCleanupPromise === task) {
2342
+ this.generationCleanupPromise = void 0;
2343
+ }
2344
+ });
2345
+ }
2346
+ async cleanupGeneration(generation) {
2347
+ const prefix = `v${generation}:`;
2348
+ const keys = await this.collectKeysWithPrefix(prefix);
2349
+ if (keys.length === 0) {
2350
+ return;
2351
+ }
2352
+ const batchSize = this.generationCleanupBatchSize();
2353
+ for (let index = 0; index < keys.length; index += batchSize) {
2354
+ const batch = keys.slice(index, index + batchSize);
2355
+ await this.deleteKeys(batch);
2356
+ await this.publishInvalidation({
2357
+ scope: "keys",
2358
+ keys: batch,
2359
+ sourceId: this.instanceId,
2360
+ operation: "invalidate"
2361
+ });
2362
+ }
1914
2363
  }
1915
2364
  initializeWriteBehind(options) {
1916
2365
  if (this.options.writeStrategy !== "write-behind") {
@@ -1948,7 +2397,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
1948
2397
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
1949
2398
  const batch = this.writeBehindQueue.splice(0, batchSize);
1950
2399
  this.writeBehindFlushPromise = (async () => {
1951
- await Promise.allSettled(batch.map((operation) => operation()));
2400
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2401
+ const failures = results.filter((result) => result.status === "rejected");
2402
+ if (failures.length > 0) {
2403
+ this.metricsCollector.increment("writeFailures", failures.length);
2404
+ this.logger.error?.("write-behind-flush-failure", {
2405
+ failed: failures.length,
2406
+ total: batch.length,
2407
+ errors: failures.map((failure) => this.formatError(failure.reason))
2408
+ });
2409
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2410
+ }
1952
2411
  })();
1953
2412
  await this.writeBehindFlushPromise;
1954
2413
  this.writeBehindFlushPromise = void 0;
@@ -2052,8 +2511,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2052
2511
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2053
2512
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2054
2513
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2514
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2515
+ this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2516
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2055
2517
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2056
2518
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2519
+ if (typeof this.options.generationCleanup === "object") {
2520
+ this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2521
+ }
2057
2522
  if (this.options.generation !== void 0) {
2058
2523
  this.validateNonNegativeNumber("generation", this.options.generation);
2059
2524
  }
@@ -2071,6 +2536,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2071
2536
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2072
2537
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
2073
2538
  this.validateCircuitBreakerOptions(options.circuitBreaker);
2539
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2074
2540
  }
2075
2541
  validateLayerNumberOption(name, value) {
2076
2542
  if (value === void 0) {
@@ -2095,6 +2561,20 @@ var CacheStack = class extends import_node_events.EventEmitter {
2095
2561
  throw new Error(`${name} must be a positive finite number.`);
2096
2562
  }
2097
2563
  }
2564
+ validateRateLimitOptions(name, options) {
2565
+ if (!options) {
2566
+ return;
2567
+ }
2568
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2569
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2570
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2571
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2572
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2573
+ }
2574
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2575
+ throw new Error(`${name}.bucketKey must not be empty.`);
2576
+ }
2577
+ }
2098
2578
  validateNonNegativeNumber(name, value) {
2099
2579
  if (!Number.isFinite(value) || value < 0) {
2100
2580
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -2110,6 +2590,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2110
2590
  if (/[\u0000-\u001F\u007F]/.test(key)) {
2111
2591
  throw new Error("Cache key contains unsupported control characters.");
2112
2592
  }
2593
+ if (/[\uD800-\uDFFF]/.test(key)) {
2594
+ throw new Error("Cache key contains unsupported surrogate code points.");
2595
+ }
2113
2596
  return key;
2114
2597
  }
2115
2598
  validateTtlPolicy(name, policy) {
@@ -2187,6 +2670,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
2187
2670
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
2188
2671
  return null;
2189
2672
  }
2673
+ async reportRecoverableLayerFailure(layer, operation, error) {
2674
+ if (this.isGracefulDegradationEnabled()) {
2675
+ await this.handleLayerFailure(layer, operation, error);
2676
+ return;
2677
+ }
2678
+ this.logger.warn?.("layer-operation-failed", { layer: layer.name, operation, error: this.formatError(error) });
2679
+ this.emitError(operation, { layer: layer.name, degraded: false, error: this.formatError(error) });
2680
+ }
2190
2681
  isGracefulDegradationEnabled() {
2191
2682
  return Boolean(this.options.gracefulDegradation);
2192
2683
  }
@@ -2210,10 +2701,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
2210
2701
  }
2211
2702
  }
2212
2703
  serializeKeyPart(value) {
2213
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2214
- return String(value);
2704
+ if (typeof value === "string") {
2705
+ return `s:${value}`;
2706
+ }
2707
+ if (typeof value === "number") {
2708
+ return `n:${value}`;
2215
2709
  }
2216
- return JSON.stringify(this.normalizeForSerialization(value));
2710
+ if (typeof value === "boolean") {
2711
+ return `b:${value}`;
2712
+ }
2713
+ return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2217
2714
  }
2218
2715
  isCacheSnapshotEntries(value) {
2219
2716
  return Array.isArray(value) && value.every((entry) => {
@@ -2221,15 +2718,39 @@ var CacheStack = class extends import_node_events.EventEmitter {
2221
2718
  return false;
2222
2719
  }
2223
2720
  const candidate = entry;
2224
- return typeof candidate.key === "string";
2721
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2225
2722
  });
2226
2723
  }
2724
+ sanitizeSnapshotValue(value) {
2725
+ return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2726
+ }
2727
+ async validateSnapshotFilePath(filePath) {
2728
+ if (filePath.length === 0) {
2729
+ throw new Error("filePath must not be empty.");
2730
+ }
2731
+ if (filePath.includes("\0")) {
2732
+ throw new Error("filePath must not contain null bytes.");
2733
+ }
2734
+ const path = await import("path");
2735
+ const resolved = path.resolve(filePath);
2736
+ const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2737
+ if (baseDir !== false) {
2738
+ const relative = path.relative(baseDir, resolved);
2739
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2740
+ throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2741
+ }
2742
+ }
2743
+ return resolved;
2744
+ }
2227
2745
  normalizeForSerialization(value) {
2228
2746
  if (Array.isArray(value)) {
2229
2747
  return value.map((entry) => this.normalizeForSerialization(entry));
2230
2748
  }
2231
2749
  if (value && typeof value === "object") {
2232
2750
  return Object.keys(value).sort().reduce((normalized, key) => {
2751
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
2752
+ return normalized;
2753
+ }
2233
2754
  normalized[key] = this.normalizeForSerialization(value[key]);
2234
2755
  return normalized;
2235
2756
  }, {});
@@ -2323,19 +2844,21 @@ var RedisTagIndex = class {
2323
2844
  client;
2324
2845
  prefix;
2325
2846
  scanCount;
2847
+ knownKeysShards;
2326
2848
  constructor(options) {
2327
2849
  this.client = options.client;
2328
2850
  this.prefix = options.prefix ?? "layercache:tag-index";
2329
2851
  this.scanCount = options.scanCount ?? 100;
2852
+ this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
2330
2853
  }
2331
2854
  async touch(key) {
2332
- await this.client.sadd(this.knownKeysKey(), key);
2855
+ await this.client.sadd(this.knownKeysKeyFor(key), key);
2333
2856
  }
2334
2857
  async track(key, tags) {
2335
2858
  const keyTagsKey = this.keyTagsKey(key);
2336
2859
  const existingTags = await this.client.smembers(keyTagsKey);
2337
2860
  const pipeline = this.client.pipeline();
2338
- pipeline.sadd(this.knownKeysKey(), key);
2861
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
2339
2862
  for (const tag of existingTags) {
2340
2863
  pipeline.srem(this.tagKeysKey(tag), key);
2341
2864
  }
@@ -2352,7 +2875,7 @@ var RedisTagIndex = class {
2352
2875
  const keyTagsKey = this.keyTagsKey(key);
2353
2876
  const existingTags = await this.client.smembers(keyTagsKey);
2354
2877
  const pipeline = this.client.pipeline();
2355
- pipeline.srem(this.knownKeysKey(), key);
2878
+ pipeline.srem(this.knownKeysKeyFor(key), key);
2356
2879
  pipeline.del(keyTagsKey);
2357
2880
  for (const tag of existingTags) {
2358
2881
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -2364,12 +2887,14 @@ var RedisTagIndex = class {
2364
2887
  }
2365
2888
  async keysForPrefix(prefix) {
2366
2889
  const matches = [];
2367
- let cursor = "0";
2368
- do {
2369
- const [nextCursor, keys] = await this.client.sscan(this.knownKeysKey(), cursor, "COUNT", this.scanCount);
2370
- cursor = nextCursor;
2371
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
2372
- } while (cursor !== "0");
2890
+ for (const knownKeysKey of this.knownKeysKeys()) {
2891
+ let cursor = "0";
2892
+ do {
2893
+ const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
2894
+ cursor = nextCursor;
2895
+ matches.push(...keys.filter((key) => key.startsWith(prefix)));
2896
+ } while (cursor !== "0");
2897
+ }
2373
2898
  return matches;
2374
2899
  }
2375
2900
  async tagsForKey(key) {
@@ -2377,19 +2902,21 @@ var RedisTagIndex = class {
2377
2902
  }
2378
2903
  async matchPattern(pattern) {
2379
2904
  const matches = [];
2380
- let cursor = "0";
2381
- do {
2382
- const [nextCursor, keys] = await this.client.sscan(
2383
- this.knownKeysKey(),
2384
- cursor,
2385
- "MATCH",
2386
- pattern,
2387
- "COUNT",
2388
- this.scanCount
2389
- );
2390
- cursor = nextCursor;
2391
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
2392
- } while (cursor !== "0");
2905
+ for (const knownKeysKey of this.knownKeysKeys()) {
2906
+ let cursor = "0";
2907
+ do {
2908
+ const [nextCursor, keys] = await this.client.sscan(
2909
+ knownKeysKey,
2910
+ cursor,
2911
+ "MATCH",
2912
+ pattern,
2913
+ "COUNT",
2914
+ this.scanCount
2915
+ );
2916
+ cursor = nextCursor;
2917
+ matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
2918
+ } while (cursor !== "0");
2919
+ }
2393
2920
  return matches;
2394
2921
  }
2395
2922
  async clear() {
@@ -2410,8 +2937,17 @@ var RedisTagIndex = class {
2410
2937
  } while (cursor !== "0");
2411
2938
  return matches;
2412
2939
  }
2413
- knownKeysKey() {
2414
- return `${this.prefix}:keys`;
2940
+ knownKeysKeyFor(key) {
2941
+ if (this.knownKeysShards === 1) {
2942
+ return `${this.prefix}:keys`;
2943
+ }
2944
+ return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
2945
+ }
2946
+ knownKeysKeys() {
2947
+ if (this.knownKeysShards === 1) {
2948
+ return [`${this.prefix}:keys`];
2949
+ }
2950
+ return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
2415
2951
  }
2416
2952
  keyTagsKey(key) {
2417
2953
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
@@ -2420,6 +2956,22 @@ var RedisTagIndex = class {
2420
2956
  return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
2421
2957
  }
2422
2958
  };
2959
+ function normalizeKnownKeysShards(value) {
2960
+ if (value === void 0) {
2961
+ return 1;
2962
+ }
2963
+ if (!Number.isInteger(value) || value <= 0) {
2964
+ throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
2965
+ }
2966
+ return value;
2967
+ }
2968
+ function simpleHash(value) {
2969
+ let hash = 0;
2970
+ for (let index = 0; index < value.length; index += 1) {
2971
+ hash = hash * 31 + value.charCodeAt(index) >>> 0;
2972
+ }
2973
+ return hash;
2974
+ }
2423
2975
 
2424
2976
  // src/http/createCacheStatsHandler.ts
2425
2977
  function createCacheStatsHandler(cache) {
@@ -2459,7 +3011,7 @@ function createCachedMethodDecorator(options) {
2459
3011
  function createFastifyLayercachePlugin(cache, options = {}) {
2460
3012
  return async (fastify) => {
2461
3013
  fastify.decorate("cache", cache);
2462
- if (options.exposeStatsRoute !== false && fastify.get) {
3014
+ if (options.exposeStatsRoute === true && fastify.get) {
2463
3015
  fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2464
3016
  }
2465
3017
  };
@@ -2475,7 +3027,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
2475
3027
  next();
2476
3028
  return;
2477
3029
  }
2478
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
3030
+ const rawUrl = req.originalUrl ?? req.url ?? "/";
3031
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2479
3032
  const cached = await cache.get(key, void 0, options);
2480
3033
  if (cached !== null) {
2481
3034
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -2491,7 +3044,12 @@ function createExpressCacheMiddleware(cache, options = {}) {
2491
3044
  if (originalJson) {
2492
3045
  res.json = (body) => {
2493
3046
  res.setHeader?.("x-cache", "MISS");
2494
- void cache.set(key, body, options);
3047
+ cache.set(key, body, options).catch((err) => {
3048
+ cache.emit("error", {
3049
+ operation: "set",
3050
+ error: err instanceof Error ? err.message : String(err)
3051
+ });
3052
+ });
2495
3053
  return originalJson(body);
2496
3054
  };
2497
3055
  }
@@ -2501,6 +3059,15 @@ function createExpressCacheMiddleware(cache, options = {}) {
2501
3059
  }
2502
3060
  };
2503
3061
  }
3062
+ function normalizeUrl(url) {
3063
+ try {
3064
+ const parsed = new URL(url, "http://localhost");
3065
+ parsed.searchParams.sort();
3066
+ return decodeURIComponent(parsed.pathname) + parsed.search;
3067
+ } catch {
3068
+ return url;
3069
+ }
3070
+ }
2504
3071
 
2505
3072
  // src/integrations/graphql.ts
2506
3073
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
@@ -2520,7 +3087,8 @@ function createHonoCacheMiddleware(cache, options = {}) {
2520
3087
  await next();
2521
3088
  return;
2522
3089
  }
2523
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${context.req.path ?? context.req.url ?? "/"}`;
3090
+ const rawPath = context.req.path ?? context.req.url ?? "/";
3091
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl2(rawPath)}`;
2524
3092
  const cached = await cache.get(key, void 0, options);
2525
3093
  if (cached !== null) {
2526
3094
  context.header?.("x-cache", "HIT");
@@ -2531,12 +3099,26 @@ function createHonoCacheMiddleware(cache, options = {}) {
2531
3099
  const originalJson = context.json.bind(context);
2532
3100
  context.json = (body, status) => {
2533
3101
  context.header?.("x-cache", "MISS");
2534
- void cache.set(key, body, options);
3102
+ cache.set(key, body, options).catch((err) => {
3103
+ cache.emit("error", {
3104
+ operation: "set",
3105
+ error: err instanceof Error ? err.message : String(err)
3106
+ });
3107
+ });
2535
3108
  return originalJson(body, status);
2536
3109
  };
2537
3110
  await next();
2538
3111
  };
2539
3112
  }
3113
+ function normalizeUrl2(url) {
3114
+ try {
3115
+ const parsed = new URL(url, "http://localhost");
3116
+ parsed.searchParams.sort();
3117
+ return decodeURIComponent(parsed.pathname) + parsed.search;
3118
+ } catch {
3119
+ return url;
3120
+ }
3121
+ }
2540
3122
 
2541
3123
  // src/integrations/opentelemetry.ts
2542
3124
  function createOpenTelemetryPlugin(cache, tracer) {
@@ -2671,16 +3253,10 @@ var MemoryLayer = class {
2671
3253
  return entry.value;
2672
3254
  }
2673
3255
  async getMany(keys) {
2674
- const values = [];
2675
- for (const key of keys) {
2676
- values.push(await this.getEntry(key));
2677
- }
2678
- return values;
3256
+ return Promise.all(keys.map((key) => this.getEntry(key)));
2679
3257
  }
2680
3258
  async setMany(entries) {
2681
- for (const entry of entries) {
2682
- await this.set(entry.key, entry.value, entry.ttl);
2683
- }
3259
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
2684
3260
  }
2685
3261
  async set(key, value, ttl = this.defaultTtl) {
2686
3262
  this.entries.delete(key);
@@ -2816,19 +3392,6 @@ var MemoryLayer = class {
2816
3392
  // src/layers/RedisLayer.ts
2817
3393
  var import_node_util = require("util");
2818
3394
  var import_node_zlib = require("zlib");
2819
-
2820
- // src/serialization/JsonSerializer.ts
2821
- var JsonSerializer = class {
2822
- serialize(value) {
2823
- return JSON.stringify(value);
2824
- }
2825
- deserialize(payload) {
2826
- const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2827
- return JSON.parse(normalized);
2828
- }
2829
- };
2830
-
2831
- // src/layers/RedisLayer.ts
2832
3395
  var BATCH_DELETE_SIZE = 500;
2833
3396
  var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
2834
3397
  var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
@@ -2845,6 +3408,7 @@ var RedisLayer = class {
2845
3408
  scanCount;
2846
3409
  compression;
2847
3410
  compressionThreshold;
3411
+ decompressionMaxBytes;
2848
3412
  disconnectOnDispose;
2849
3413
  constructor(options) {
2850
3414
  this.client = options.client;
@@ -2856,6 +3420,7 @@ var RedisLayer = class {
2856
3420
  this.scanCount = options.scanCount ?? 100;
2857
3421
  this.compression = options.compression;
2858
3422
  this.compressionThreshold = options.compressionThreshold ?? 1024;
3423
+ this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
2859
3424
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
2860
3425
  }
2861
3426
  async get(key) {
@@ -3055,16 +3620,29 @@ var RedisLayer = class {
3055
3620
  }
3056
3621
  /**
3057
3622
  * Decompresses the payload asynchronously if a compression header is present.
3623
+ * Enforces a maximum decompressed size to prevent decompression bomb attacks.
3058
3624
  */
3059
3625
  async decodePayload(payload) {
3060
3626
  if (!Buffer.isBuffer(payload)) {
3061
3627
  return payload;
3062
3628
  }
3063
3629
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
3064
- return gunzipAsync(payload.subarray(10));
3630
+ const decompressed = await gunzipAsync(payload.subarray(10));
3631
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
3632
+ throw new Error(
3633
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3634
+ );
3635
+ }
3636
+ return decompressed;
3065
3637
  }
3066
3638
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
3067
- return brotliDecompressAsync(payload.subarray(12));
3639
+ const decompressed = await brotliDecompressAsync(payload.subarray(12));
3640
+ if (decompressed.byteLength > this.decompressionMaxBytes) {
3641
+ throw new Error(
3642
+ `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3643
+ );
3644
+ }
3645
+ return decompressed;
3068
3646
  }
3069
3647
  return payload;
3070
3648
  }
@@ -3083,11 +3661,11 @@ var DiskLayer = class {
3083
3661
  maxFiles;
3084
3662
  writeQueue = Promise.resolve();
3085
3663
  constructor(options) {
3086
- this.directory = options.directory;
3664
+ this.directory = this.resolveDirectory(options.directory);
3087
3665
  this.defaultTtl = options.ttl;
3088
3666
  this.name = options.name ?? "disk";
3089
3667
  this.serializer = options.serializer ?? new JsonSerializer();
3090
- this.maxFiles = options.maxFiles;
3668
+ this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
3091
3669
  }
3092
3670
  async get(key) {
3093
3671
  return unwrapStoredValue(await this.getEntry(key));
@@ -3102,7 +3680,7 @@ var DiskLayer = class {
3102
3680
  }
3103
3681
  let entry;
3104
3682
  try {
3105
- entry = this.serializer.deserialize(raw);
3683
+ entry = this.deserializeEntry(raw);
3106
3684
  } catch {
3107
3685
  await this.safeDelete(filePath);
3108
3686
  return null;
@@ -3124,8 +3702,13 @@ var DiskLayer = class {
3124
3702
  const payload = this.serializer.serialize(entry);
3125
3703
  const targetPath = this.keyToPath(key);
3126
3704
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3127
- await import_node_fs.promises.writeFile(tempPath, payload);
3128
- await import_node_fs.promises.rename(tempPath, targetPath);
3705
+ try {
3706
+ await import_node_fs.promises.writeFile(tempPath, payload);
3707
+ await import_node_fs.promises.rename(tempPath, targetPath);
3708
+ } catch (error) {
3709
+ await this.safeDelete(tempPath);
3710
+ throw error;
3711
+ }
3129
3712
  if (this.maxFiles !== void 0) {
3130
3713
  await this.enforceMaxFiles();
3131
3714
  }
@@ -3135,9 +3718,7 @@ var DiskLayer = class {
3135
3718
  return Promise.all(keys.map((key) => this.getEntry(key)));
3136
3719
  }
3137
3720
  async setMany(entries) {
3138
- for (const entry of entries) {
3139
- await this.set(entry.key, entry.value, entry.ttl);
3140
- }
3721
+ await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.ttl)));
3141
3722
  }
3142
3723
  async has(key) {
3143
3724
  const value = await this.getEntry(key);
@@ -3153,8 +3734,9 @@ var DiskLayer = class {
3153
3734
  }
3154
3735
  let entry;
3155
3736
  try {
3156
- entry = this.serializer.deserialize(raw);
3737
+ entry = this.deserializeEntry(raw);
3157
3738
  } catch {
3739
+ await this.safeDelete(filePath);
3158
3740
  return null;
3159
3741
  }
3160
3742
  if (entry.expiresAt === null) {
@@ -3211,7 +3793,7 @@ var DiskLayer = class {
3211
3793
  }
3212
3794
  let entry;
3213
3795
  try {
3214
- entry = this.serializer.deserialize(raw);
3796
+ entry = this.deserializeEntry(raw);
3215
3797
  } catch {
3216
3798
  await this.safeDelete(filePath);
3217
3799
  return;
@@ -3243,6 +3825,31 @@ var DiskLayer = class {
3243
3825
  const hash = (0, import_node_crypto.createHash)("sha256").update(key).digest("hex");
3244
3826
  return (0, import_node_path.join)(this.directory, `${hash}.lc`);
3245
3827
  }
3828
+ resolveDirectory(directory) {
3829
+ if (typeof directory !== "string" || directory.trim().length === 0) {
3830
+ throw new Error("DiskLayer.directory must be a non-empty path.");
3831
+ }
3832
+ if (directory.includes("\0")) {
3833
+ throw new Error("DiskLayer.directory must not contain null bytes.");
3834
+ }
3835
+ return (0, import_node_path.resolve)(directory);
3836
+ }
3837
+ normalizeMaxFiles(maxFiles) {
3838
+ if (maxFiles === void 0) {
3839
+ return void 0;
3840
+ }
3841
+ if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
3842
+ throw new Error("DiskLayer.maxFiles must be a positive integer.");
3843
+ }
3844
+ return maxFiles;
3845
+ }
3846
+ deserializeEntry(raw) {
3847
+ const entry = this.serializer.deserialize(raw);
3848
+ if (!isDiskEntry(entry)) {
3849
+ throw new Error("Invalid disk cache entry.");
3850
+ }
3851
+ return entry;
3852
+ }
3246
3853
  async safeDelete(filePath) {
3247
3854
  try {
3248
3855
  await import_node_fs.promises.unlink(filePath);
@@ -3287,6 +3894,14 @@ var DiskLayer = class {
3287
3894
  await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
3288
3895
  }
3289
3896
  };
3897
+ function isDiskEntry(value) {
3898
+ if (!value || typeof value !== "object") {
3899
+ return false;
3900
+ }
3901
+ const candidate = value;
3902
+ const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
3903
+ return typeof candidate.key === "string" && validExpiry && "value" in candidate;
3904
+ }
3290
3905
 
3291
3906
  // src/layers/MemcachedLayer.ts
3292
3907
  var MemcachedLayer = class {
@@ -3307,6 +3922,7 @@ var MemcachedLayer = class {
3307
3922
  return unwrapStoredValue(await this.getEntry(key));
3308
3923
  }
3309
3924
  async getEntry(key) {
3925
+ this.validateKey(key);
3310
3926
  const result = await this.client.get(this.withPrefix(key));
3311
3927
  if (!result || result.value === null) {
3312
3928
  return null;
@@ -3321,16 +3937,19 @@ var MemcachedLayer = class {
3321
3937
  return Promise.all(keys.map((key) => this.getEntry(key)));
3322
3938
  }
3323
3939
  async set(key, value, ttl = this.defaultTtl) {
3940
+ this.validateKey(key);
3324
3941
  const payload = this.serializer.serialize(value);
3325
3942
  await this.client.set(this.withPrefix(key), payload, {
3326
3943
  expires: ttl && ttl > 0 ? ttl : void 0
3327
3944
  });
3328
3945
  }
3329
3946
  async has(key) {
3947
+ this.validateKey(key);
3330
3948
  const result = await this.client.get(this.withPrefix(key));
3331
3949
  return result !== null && result.value !== null;
3332
3950
  }
3333
3951
  async delete(key) {
3952
+ this.validateKey(key);
3334
3953
  await this.client.delete(this.withPrefix(key));
3335
3954
  }
3336
3955
  async deleteMany(keys) {
@@ -3344,19 +3963,50 @@ var MemcachedLayer = class {
3344
3963
  withPrefix(key) {
3345
3964
  return `${this.keyPrefix}${key}`;
3346
3965
  }
3966
+ validateKey(key) {
3967
+ const fullKey = this.withPrefix(key);
3968
+ if (Buffer.byteLength(fullKey, "utf8") > 250) {
3969
+ throw new Error(`MemcachedLayer: key exceeds 250-byte Memcached limit: "${fullKey.slice(0, 60)}..."`);
3970
+ }
3971
+ if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
3972
+ throw new Error(
3973
+ "MemcachedLayer: key contains invalid characters (whitespace or control characters are not allowed)."
3974
+ );
3975
+ }
3976
+ }
3347
3977
  };
3348
3978
 
3349
3979
  // src/serialization/MsgpackSerializer.ts
3350
3980
  var import_msgpack = require("@msgpack/msgpack");
3981
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3351
3982
  var MsgpackSerializer = class {
3352
3983
  serialize(value) {
3353
3984
  return Buffer.from((0, import_msgpack.encode)(value));
3354
3985
  }
3355
3986
  deserialize(payload) {
3356
3987
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
3357
- return (0, import_msgpack.decode)(normalized);
3988
+ return sanitizeMsgpackValue((0, import_msgpack.decode)(normalized));
3358
3989
  }
3359
3990
  };
3991
+ function sanitizeMsgpackValue(value) {
3992
+ if (Array.isArray(value)) {
3993
+ return value.map((entry) => sanitizeMsgpackValue(entry));
3994
+ }
3995
+ if (!isPlainObject2(value)) {
3996
+ return value;
3997
+ }
3998
+ const sanitized = {};
3999
+ for (const [key, entry] of Object.entries(value)) {
4000
+ if (DANGEROUS_KEYS.has(key)) {
4001
+ continue;
4002
+ }
4003
+ sanitized[key] = sanitizeMsgpackValue(entry);
4004
+ }
4005
+ return sanitized;
4006
+ }
4007
+ function isPlainObject2(value) {
4008
+ return Object.prototype.toString.call(value) === "[object Object]";
4009
+ }
3360
4010
 
3361
4011
  // src/singleflight/RedisSingleFlightCoordinator.ts
3362
4012
  var import_node_crypto2 = require("crypto");
@@ -3366,6 +4016,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
3366
4016
  end
3367
4017
  return 0
3368
4018
  `;
4019
+ var RENEW_SCRIPT = `
4020
+ if redis.call("get", KEYS[1]) == ARGV[1] then
4021
+ return redis.call("pexpire", KEYS[1], ARGV[2])
4022
+ end
4023
+ return 0
4024
+ `;
3369
4025
  var RedisSingleFlightCoordinator = class {
3370
4026
  client;
3371
4027
  prefix;
@@ -3378,14 +4034,29 @@ var RedisSingleFlightCoordinator = class {
3378
4034
  const token = (0, import_node_crypto2.randomUUID)();
3379
4035
  const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
3380
4036
  if (acquired === "OK") {
4037
+ const renewTimer = this.startLeaseRenewal(lockKey, token, options);
3381
4038
  try {
3382
4039
  return await worker();
3383
4040
  } finally {
4041
+ if (renewTimer) {
4042
+ clearInterval(renewTimer);
4043
+ }
3384
4044
  await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
3385
4045
  }
3386
4046
  }
3387
4047
  return waiter();
3388
4048
  }
4049
+ startLeaseRenewal(lockKey, token, options) {
4050
+ const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
4051
+ if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
4052
+ return void 0;
4053
+ }
4054
+ const timer = setInterval(() => {
4055
+ void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
4056
+ }, renewIntervalMs);
4057
+ timer.unref?.();
4058
+ return timer;
4059
+ }
3389
4060
  };
3390
4061
 
3391
4062
  // src/metrics/PrometheusExporter.ts
@@ -3464,7 +4135,7 @@ function createPrometheusMetricsExporter(stacks) {
3464
4135
  };
3465
4136
  }
3466
4137
  function sanitizeLabel(value) {
3467
- return value.replace(/["\\\n]/g, "_");
4138
+ return value.replace(/["\\\n\r]/g, "_");
3468
4139
  }
3469
4140
  // Annotate the CommonJS export names for ESM import in node:
3470
4141
  0 && (module.exports = {