layercache 1.2.5 → 1.2.6

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-QHWG7QS5.js";
3
+ } from "./chunk-BQLL6IM5.js";
4
4
  import {
5
5
  MemoryLayer,
6
6
  TagIndex,
7
7
  createHonoCacheMiddleware
8
- } from "./chunk-JC26W3KK.js";
8
+ } from "./chunk-GJBKCFE6.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-7V7XAB74.js";
18
+ } from "./chunk-4PPBOOXT.js";
19
19
 
20
20
  // src/CacheStack.ts
21
21
  import { EventEmitter } from "events";
@@ -26,22 +26,23 @@ var CacheNamespace = class _CacheNamespace {
26
26
  constructor(cache, prefix) {
27
27
  this.cache = cache;
28
28
  this.prefix = prefix;
29
+ validateNamespaceKey(prefix);
29
30
  }
30
31
  cache;
31
32
  prefix;
32
33
  static metricsMutexes = /* @__PURE__ */ new WeakMap();
33
34
  metrics = emptyMetrics();
34
35
  async get(key, fetcher, options) {
35
- return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, options));
36
+ return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
36
37
  }
37
38
  async getOrSet(key, fetcher, options) {
38
- return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, options));
39
+ return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
39
40
  }
40
41
  /**
41
42
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
42
43
  */
43
44
  async getOrThrow(key, fetcher, options) {
44
- return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, options));
45
+ return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
45
46
  }
46
47
  async has(key) {
47
48
  return this.trackMetrics(() => this.cache.has(this.qualify(key)));
@@ -50,7 +51,7 @@ var CacheNamespace = class _CacheNamespace {
50
51
  return this.trackMetrics(() => this.cache.ttl(this.qualify(key)));
51
52
  }
52
53
  async set(key, value, options) {
53
- await this.trackMetrics(() => this.cache.set(this.qualify(key), value, options));
54
+ await this.trackMetrics(() => this.cache.set(this.qualify(key), value, this.qualifyWriteOptions(options)));
54
55
  }
55
56
  async delete(key) {
56
57
  await this.trackMetrics(() => this.cache.delete(this.qualify(key)));
@@ -66,7 +67,8 @@ var CacheNamespace = class _CacheNamespace {
66
67
  () => this.cache.mget(
67
68
  entries.map((entry) => ({
68
69
  ...entry,
69
- key: this.qualify(entry.key)
70
+ key: this.qualify(entry.key),
71
+ options: this.qualifyGetOptions(entry.options)
70
72
  }))
71
73
  )
72
74
  );
@@ -76,16 +78,22 @@ var CacheNamespace = class _CacheNamespace {
76
78
  () => this.cache.mset(
77
79
  entries.map((entry) => ({
78
80
  ...entry,
79
- key: this.qualify(entry.key)
81
+ key: this.qualify(entry.key),
82
+ options: this.qualifyWriteOptions(entry.options)
80
83
  }))
81
84
  )
82
85
  );
83
86
  }
84
87
  async invalidateByTag(tag) {
85
- await this.trackMetrics(() => this.cache.invalidateByTag(tag));
88
+ await this.trackMetrics(() => this.cache.invalidateByTag(this.qualifyTag(tag)));
86
89
  }
87
90
  async invalidateByTags(tags, mode = "any") {
88
- await this.trackMetrics(() => this.cache.invalidateByTags(tags, mode));
91
+ await this.trackMetrics(
92
+ () => this.cache.invalidateByTags(
93
+ tags.map((tag) => this.qualifyTag(tag)),
94
+ mode
95
+ )
96
+ );
89
97
  }
90
98
  async invalidateByPattern(pattern) {
91
99
  await this.trackMetrics(() => this.cache.invalidateByPattern(this.qualify(pattern)));
@@ -97,16 +105,24 @@ var CacheNamespace = class _CacheNamespace {
97
105
  * Returns detailed metadata about a single cache key within this namespace.
98
106
  */
99
107
  async inspect(key) {
100
- return this.cache.inspect(this.qualify(key));
108
+ const result = await this.cache.inspect(this.qualify(key));
109
+ if (result === null) {
110
+ return null;
111
+ }
112
+ return {
113
+ ...result,
114
+ tags: result.tags.filter((tag) => tag.startsWith(`${this.prefix}:`)).map((tag) => tag.slice(this.prefix.length + 1))
115
+ };
101
116
  }
102
117
  wrap(keyPrefix, fetcher, options) {
103
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
118
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, this.qualifyWrapOptions(options));
104
119
  }
105
120
  warm(entries, options) {
106
121
  return this.cache.warm(
107
122
  entries.map((entry) => ({
108
123
  ...entry,
109
- key: this.qualify(entry.key)
124
+ key: this.qualify(entry.key),
125
+ options: this.qualifyGetOptions(entry.options)
110
126
  })),
111
127
  options
112
128
  );
@@ -142,6 +158,24 @@ var CacheNamespace = class _CacheNamespace {
142
158
  qualify(key) {
143
159
  return `${this.prefix}:${key}`;
144
160
  }
161
+ qualifyTag(tag) {
162
+ return `${this.prefix}:${tag}`;
163
+ }
164
+ qualifyGetOptions(options) {
165
+ return this.qualifyWriteOptions(options);
166
+ }
167
+ qualifyWrapOptions(options) {
168
+ return this.qualifyWriteOptions(options);
169
+ }
170
+ qualifyWriteOptions(options) {
171
+ if (!options?.tags || options.tags.length === 0) {
172
+ return options;
173
+ }
174
+ return {
175
+ ...options,
176
+ tags: options.tags.map((tag) => this.qualifyTag(tag))
177
+ };
178
+ }
145
179
  async trackMetrics(operation) {
146
180
  return this.getMetricsMutex().runExclusive(async () => {
147
181
  const before = this.cache.getMetrics();
@@ -276,6 +310,9 @@ function validateNamespaceKey(key) {
276
310
  if (/[\u0000-\u001F\u007F]/.test(key)) {
277
311
  throw new Error("Namespace prefix contains unsupported control characters.");
278
312
  }
313
+ if (/[\uD800-\uDFFF]/.test(key)) {
314
+ throw new Error("Namespace prefix contains unsupported surrogate code points.");
315
+ }
279
316
  }
280
317
 
281
318
  // src/internal/CacheKeyDiscovery.ts
@@ -284,21 +321,41 @@ var CacheKeyDiscovery = class {
284
321
  this.options = options;
285
322
  }
286
323
  options;
287
- async collectKeysWithPrefix(prefix) {
324
+ async collectKeysWithPrefix(prefix, maxMatches = false) {
288
325
  const { tagIndex } = this.options;
289
- const matches = new Set(
290
- tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
291
- );
326
+ const matches = /* @__PURE__ */ new Set();
327
+ if (tagIndex.forEachKeyForPrefix) {
328
+ await tagIndex.forEachKeyForPrefix(prefix, async (key) => {
329
+ matches.add(key);
330
+ this.assertWithinMatchLimit(matches, maxMatches);
331
+ });
332
+ } else {
333
+ const initialMatches = tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`);
334
+ for (const key of initialMatches) {
335
+ matches.add(key);
336
+ this.assertWithinMatchLimit(matches, maxMatches);
337
+ }
338
+ }
292
339
  await Promise.all(
293
340
  this.options.layers.map(async (layer) => {
294
- if (!layer.keys || this.options.shouldSkipLayer(layer)) {
341
+ if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
295
342
  return;
296
343
  }
297
344
  try {
298
- const keys = await layer.keys();
299
- for (const key of keys) {
345
+ if (layer.forEachKey) {
346
+ await layer.forEachKey(async (key) => {
347
+ if (key.startsWith(prefix)) {
348
+ matches.add(key);
349
+ this.assertWithinMatchLimit(matches, maxMatches);
350
+ }
351
+ });
352
+ return;
353
+ }
354
+ const keys = await layer.keys?.();
355
+ for (const key of keys ?? []) {
300
356
  if (key.startsWith(prefix)) {
301
357
  matches.add(key);
358
+ this.assertWithinMatchLimit(matches, maxMatches);
302
359
  }
303
360
  }
304
361
  } catch (error) {
@@ -308,18 +365,39 @@ var CacheKeyDiscovery = class {
308
365
  );
309
366
  return [...matches];
310
367
  }
311
- async collectKeysMatchingPattern(pattern) {
312
- const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
368
+ async collectKeysMatchingPattern(pattern, maxMatches = false) {
369
+ const matches = /* @__PURE__ */ new Set();
370
+ if (this.options.tagIndex.forEachKeyMatchingPattern) {
371
+ await this.options.tagIndex.forEachKeyMatchingPattern(pattern, async (key) => {
372
+ matches.add(key);
373
+ this.assertWithinMatchLimit(matches, maxMatches);
374
+ });
375
+ } else {
376
+ for (const key of await this.options.tagIndex.matchPattern(pattern)) {
377
+ matches.add(key);
378
+ this.assertWithinMatchLimit(matches, maxMatches);
379
+ }
380
+ }
313
381
  await Promise.all(
314
382
  this.options.layers.map(async (layer) => {
315
- if (!layer.keys || this.options.shouldSkipLayer(layer)) {
383
+ if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
316
384
  return;
317
385
  }
318
386
  try {
319
- const keys = await layer.keys();
320
- for (const key of keys) {
387
+ if (layer.forEachKey) {
388
+ await layer.forEachKey(async (key) => {
389
+ if (PatternMatcher.matches(pattern, key)) {
390
+ matches.add(key);
391
+ this.assertWithinMatchLimit(matches, maxMatches);
392
+ }
393
+ });
394
+ return;
395
+ }
396
+ const keys = await layer.keys?.();
397
+ for (const key of keys ?? []) {
321
398
  if (PatternMatcher.matches(pattern, key)) {
322
399
  matches.add(key);
400
+ this.assertWithinMatchLimit(matches, maxMatches);
323
401
  }
324
402
  }
325
403
  } catch (error) {
@@ -329,8 +407,280 @@ var CacheKeyDiscovery = class {
329
407
  );
330
408
  return [...matches];
331
409
  }
410
+ assertWithinMatchLimit(matches, maxMatches) {
411
+ if (maxMatches !== false && matches.size > maxMatches) {
412
+ throw new Error(`Invalidation matched too many keys (${matches.size} > ${maxMatches}).`);
413
+ }
414
+ }
332
415
  };
333
416
 
417
+ // src/internal/CacheKeySerialization.ts
418
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
419
+ function normalizeForSerialization(value) {
420
+ if (Array.isArray(value)) {
421
+ return value.map((entry) => normalizeForSerialization(entry));
422
+ }
423
+ if (value && typeof value === "object") {
424
+ return Object.keys(value).sort().reduce((normalized, key) => {
425
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
426
+ return normalized;
427
+ }
428
+ normalized[key] = normalizeForSerialization(value[key]);
429
+ return normalized;
430
+ }, {});
431
+ }
432
+ return value;
433
+ }
434
+ function serializeKeyPart(value) {
435
+ if (typeof value === "string") {
436
+ return `s:${value}`;
437
+ }
438
+ if (typeof value === "number") {
439
+ return `n:${value}`;
440
+ }
441
+ if (typeof value === "boolean") {
442
+ return `b:${value}`;
443
+ }
444
+ return `j:${JSON.stringify(normalizeForSerialization(value))}`;
445
+ }
446
+ function serializeOptions(options) {
447
+ return JSON.stringify(normalizeForSerialization(options) ?? null);
448
+ }
449
+ function createInstanceId() {
450
+ if (globalThis.crypto?.randomUUID) {
451
+ return globalThis.crypto.randomUUID();
452
+ }
453
+ const bytes = new Uint8Array(16);
454
+ if (globalThis.crypto?.getRandomValues) {
455
+ globalThis.crypto.getRandomValues(bytes);
456
+ } else {
457
+ for (let i = 0; i < bytes.length; i += 1) {
458
+ bytes[i] = Math.floor(Math.random() * 256);
459
+ }
460
+ }
461
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
462
+ }
463
+
464
+ // src/internal/CacheSnapshotFile.ts
465
+ function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
466
+ const relative = path.relative(realBaseDir, candidatePath);
467
+ return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
468
+ }
469
+ async function findExistingAncestor(directory, fs2, path) {
470
+ let current = directory;
471
+ while (true) {
472
+ try {
473
+ await fs2.lstat(current);
474
+ return current;
475
+ } catch (error) {
476
+ if (error.code !== "ENOENT") {
477
+ throw error;
478
+ }
479
+ }
480
+ const parent = path.dirname(current);
481
+ if (parent === current) {
482
+ return current;
483
+ }
484
+ current = parent;
485
+ }
486
+ }
487
+ async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
488
+ if (filePath.length === 0) {
489
+ throw new Error("filePath must not be empty.");
490
+ }
491
+ if (filePath.includes("\0")) {
492
+ throw new Error("filePath must not contain null bytes.");
493
+ }
494
+ const { promises: fs2 } = await import("fs");
495
+ const path = await import("path");
496
+ const resolved = path.resolve(filePath);
497
+ const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
498
+ if (baseDir === false) {
499
+ return resolved;
500
+ }
501
+ await fs2.mkdir(baseDir, { recursive: true });
502
+ const realBaseDir = await fs2.realpath(baseDir);
503
+ if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
504
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
505
+ }
506
+ if (mode === "read") {
507
+ const realTarget = await fs2.realpath(resolved);
508
+ if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
509
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
510
+ }
511
+ return realTarget;
512
+ }
513
+ const parentDir = path.dirname(resolved);
514
+ const existingAncestor = await findExistingAncestor(parentDir, fs2, path);
515
+ const realExistingAncestor = await fs2.realpath(existingAncestor);
516
+ if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
517
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
518
+ }
519
+ await fs2.mkdir(parentDir, { recursive: true });
520
+ const realParentDir = await fs2.realpath(parentDir);
521
+ if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
522
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
523
+ }
524
+ const targetPath = path.join(realParentDir, path.basename(resolved));
525
+ try {
526
+ const existing = await fs2.lstat(targetPath);
527
+ if (existing.isSymbolicLink()) {
528
+ throw new Error("filePath must not point to a symbolic link.");
529
+ }
530
+ } catch (error) {
531
+ if (error.code !== "ENOENT") {
532
+ throw error;
533
+ }
534
+ }
535
+ return targetPath;
536
+ }
537
+ async function readUtf8HandleWithLimit(handle, byteLimit) {
538
+ if (byteLimit === false) {
539
+ return handle.readFile({ encoding: "utf8" });
540
+ }
541
+ const chunks = [];
542
+ let totalBytes = 0;
543
+ let position = 0;
544
+ while (true) {
545
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
546
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
547
+ if (bytesRead === 0) {
548
+ break;
549
+ }
550
+ totalBytes += bytesRead;
551
+ if (totalBytes > byteLimit) {
552
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
553
+ }
554
+ chunks.push(buffer.subarray(0, bytesRead));
555
+ position += bytesRead;
556
+ }
557
+ return Buffer.concat(chunks).toString("utf8");
558
+ }
559
+
560
+ // src/internal/CacheStackValidation.ts
561
+ var MAX_CACHE_KEY_LENGTH = 1024;
562
+ var MAX_PATTERN_LENGTH = 1024;
563
+ var MAX_TAGS_PER_OPERATION = 128;
564
+ function validatePositiveNumber(name, value) {
565
+ if (value === void 0) {
566
+ return;
567
+ }
568
+ if (!Number.isFinite(value) || value <= 0) {
569
+ throw new Error(`${name} must be a positive finite number.`);
570
+ }
571
+ }
572
+ function validateNonNegativeNumber(name, value) {
573
+ if (!Number.isFinite(value) || value < 0) {
574
+ throw new Error(`${name} must be a non-negative finite number.`);
575
+ }
576
+ }
577
+ function validateLayerNumberOption(name, value) {
578
+ if (value === void 0) {
579
+ return;
580
+ }
581
+ if (typeof value === "number") {
582
+ validateNonNegativeNumber(name, value);
583
+ return;
584
+ }
585
+ for (const [layerName, layerValue] of Object.entries(value)) {
586
+ if (layerValue === void 0) {
587
+ continue;
588
+ }
589
+ validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
590
+ }
591
+ }
592
+ function validateRateLimitOptions(name, options) {
593
+ if (!options) {
594
+ return;
595
+ }
596
+ validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
597
+ validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
598
+ validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
599
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
600
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
601
+ }
602
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
603
+ throw new Error(`${name}.bucketKey must not be empty.`);
604
+ }
605
+ }
606
+ function validateCacheKey(key) {
607
+ if (key.length === 0) {
608
+ throw new Error("Cache key must not be empty.");
609
+ }
610
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
611
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
612
+ }
613
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
614
+ throw new Error("Cache key contains unsupported control characters.");
615
+ }
616
+ if (/[\uD800-\uDFFF]/.test(key)) {
617
+ throw new Error("Cache key contains unsupported surrogate code points.");
618
+ }
619
+ return key;
620
+ }
621
+ function validateTag(tag) {
622
+ if (tag.length === 0) {
623
+ throw new Error("Cache tag must not be empty.");
624
+ }
625
+ if (tag.length > MAX_CACHE_KEY_LENGTH) {
626
+ throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
627
+ }
628
+ if (/[\u0000-\u001F\u007F]/.test(tag)) {
629
+ throw new Error("Cache tag contains unsupported control characters.");
630
+ }
631
+ if (/[\uD800-\uDFFF]/.test(tag)) {
632
+ throw new Error("Cache tag contains unsupported surrogate code points.");
633
+ }
634
+ return tag;
635
+ }
636
+ function validateTags(tags) {
637
+ if (!tags) {
638
+ return;
639
+ }
640
+ if (tags.length > MAX_TAGS_PER_OPERATION) {
641
+ throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
642
+ }
643
+ for (const tag of tags) {
644
+ validateTag(tag);
645
+ }
646
+ }
647
+ function validatePattern(pattern) {
648
+ if (pattern.length === 0) {
649
+ throw new Error("Pattern must not be empty.");
650
+ }
651
+ if (pattern.length > MAX_PATTERN_LENGTH) {
652
+ throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
653
+ }
654
+ if (/[\u0000-\u001F\u007F]/.test(pattern)) {
655
+ throw new Error("Pattern contains unsupported control characters.");
656
+ }
657
+ }
658
+ function validateTtlPolicy(name, policy) {
659
+ if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
660
+ return;
661
+ }
662
+ if ("alignTo" in policy) {
663
+ validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
664
+ return;
665
+ }
666
+ throw new Error(`${name} is invalid.`);
667
+ }
668
+ function validateAdaptiveTtlOptions(options) {
669
+ if (!options || options === true) {
670
+ return;
671
+ }
672
+ validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
673
+ validateLayerNumberOption("adaptiveTtl.step", options.step);
674
+ validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
675
+ }
676
+ function validateCircuitBreakerOptions(options) {
677
+ if (!options) {
678
+ return;
679
+ }
680
+ validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
681
+ validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
682
+ }
683
+
334
684
  // src/internal/CircuitBreakerManager.ts
335
685
  var CircuitBreakerManager = class {
336
686
  breakers = /* @__PURE__ */ new Map();
@@ -825,22 +1175,27 @@ var TtlResolver = class {
825
1175
 
826
1176
  // src/serialization/JsonSerializer.ts
827
1177
  var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1178
+ var MAX_SANITIZE_NODES = 1e4;
828
1179
  var JsonSerializer = class {
829
1180
  serialize(value) {
830
1181
  return JSON.stringify(value);
831
1182
  }
832
1183
  deserialize(payload) {
833
1184
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
834
- return sanitizeJsonValue(JSON.parse(normalized), 0);
1185
+ return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
835
1186
  }
836
1187
  };
837
1188
  var MAX_SANITIZE_DEPTH = 200;
838
- function sanitizeJsonValue(value, depth) {
1189
+ function sanitizeJsonValue(value, depth, state) {
1190
+ state.count += 1;
1191
+ if (state.count > MAX_SANITIZE_NODES) {
1192
+ throw new Error(`JSON payload exceeds max node count of ${MAX_SANITIZE_NODES}.`);
1193
+ }
839
1194
  if (depth > MAX_SANITIZE_DEPTH) {
840
- return value;
1195
+ throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
841
1196
  }
842
1197
  if (Array.isArray(value)) {
843
- return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1198
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
844
1199
  }
845
1200
  if (!isPlainObject(value)) {
846
1201
  return value;
@@ -850,7 +1205,7 @@ function sanitizeJsonValue(value, depth) {
850
1205
  if (DANGEROUS_JSON_KEYS.has(key)) {
851
1206
  continue;
852
1207
  }
853
- sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1208
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
854
1209
  }
855
1210
  return sanitized;
856
1211
  }
@@ -900,10 +1255,11 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
900
1255
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
901
1256
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
902
1257
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
903
- var MAX_CACHE_KEY_LENGTH = 1024;
904
- var MAX_PATTERN_LENGTH = 1024;
1258
+ var DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
1259
+ var DEFAULT_SNAPSHOT_MAX_ENTRIES = 1e4;
1260
+ var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1261
+ var DEFAULT_INVALIDATION_MAX_KEYS = 1e4;
905
1262
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
906
- var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
907
1263
  var DebugLogger = class {
908
1264
  enabled;
909
1265
  constructor(enabled) {
@@ -990,6 +1346,7 @@ var CacheStack = class extends EventEmitter {
990
1346
  snapshotSerializer = new JsonSerializer();
991
1347
  backgroundRefreshes = /* @__PURE__ */ new Map();
992
1348
  layerDegradedUntil = /* @__PURE__ */ new Map();
1349
+ keyEpochs = /* @__PURE__ */ new Map();
993
1350
  ttlResolver;
994
1351
  circuitBreakerManager;
995
1352
  currentGeneration;
@@ -997,6 +1354,7 @@ var CacheStack = class extends EventEmitter {
997
1354
  writeBehindTimer;
998
1355
  writeBehindFlushPromise;
999
1356
  generationCleanupPromise;
1357
+ clearEpoch = 0;
1000
1358
  isDisconnecting = false;
1001
1359
  disconnectPromise;
1002
1360
  /**
@@ -1006,7 +1364,7 @@ var CacheStack = class extends EventEmitter {
1006
1364
  * and no `fetcher` is provided.
1007
1365
  */
1008
1366
  async get(key, fetcher, options) {
1009
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1367
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1010
1368
  this.validateWriteOptions(options);
1011
1369
  await this.awaitStartup("get");
1012
1370
  return this.getPrepared(normalizedKey, fetcher, options);
@@ -1076,7 +1434,7 @@ var CacheStack = class extends EventEmitter {
1076
1434
  * Returns true if the given key exists and is not expired in any layer.
1077
1435
  */
1078
1436
  async has(key) {
1079
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1437
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1080
1438
  await this.awaitStartup("has");
1081
1439
  for (const layer of this.layers) {
1082
1440
  if (this.shouldSkipLayer(layer)) {
@@ -1109,7 +1467,7 @@ var CacheStack = class extends EventEmitter {
1109
1467
  * that has it, or null if the key is not found / has no TTL.
1110
1468
  */
1111
1469
  async ttl(key) {
1112
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1470
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1113
1471
  await this.awaitStartup("ttl");
1114
1472
  for (const layer of this.layers) {
1115
1473
  if (this.shouldSkipLayer(layer)) {
@@ -1131,7 +1489,7 @@ var CacheStack = class extends EventEmitter {
1131
1489
  * Stores a value in all cache layers. Overwrites any existing value.
1132
1490
  */
1133
1491
  async set(key, value, options) {
1134
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1492
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1135
1493
  this.validateWriteOptions(options);
1136
1494
  await this.awaitStartup("set");
1137
1495
  await this.storeEntry(normalizedKey, "value", value, options);
@@ -1140,7 +1498,7 @@ var CacheStack = class extends EventEmitter {
1140
1498
  * Deletes the key from all layers and publishes an invalidation message.
1141
1499
  */
1142
1500
  async delete(key) {
1143
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1501
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1144
1502
  await this.awaitStartup("delete");
1145
1503
  await this.deleteKeys([normalizedKey]);
1146
1504
  await this.publishInvalidation({
@@ -1152,6 +1510,7 @@ var CacheStack = class extends EventEmitter {
1152
1510
  }
1153
1511
  async clear() {
1154
1512
  await this.awaitStartup("clear");
1513
+ this.beginClearEpoch();
1155
1514
  await Promise.all(this.layers.map((layer) => layer.clear()));
1156
1515
  await this.tagIndex.clear();
1157
1516
  this.ttlResolver.clearProfiles();
@@ -1168,7 +1527,7 @@ var CacheStack = class extends EventEmitter {
1168
1527
  return;
1169
1528
  }
1170
1529
  await this.awaitStartup("mdelete");
1171
- const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1530
+ const normalizedKeys = keys.map((k) => validateCacheKey(k));
1172
1531
  const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key));
1173
1532
  await this.deleteKeys(cacheKeys);
1174
1533
  await this.publishInvalidation({
@@ -1185,7 +1544,7 @@ var CacheStack = class extends EventEmitter {
1185
1544
  }
1186
1545
  const normalizedEntries = entries.map((entry) => ({
1187
1546
  ...entry,
1188
- key: this.qualifyKey(this.validateCacheKey(entry.key))
1547
+ key: this.qualifyKey(validateCacheKey(entry.key))
1189
1548
  }));
1190
1549
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1191
1550
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
@@ -1194,7 +1553,7 @@ var CacheStack = class extends EventEmitter {
1194
1553
  const pendingReads = /* @__PURE__ */ new Map();
1195
1554
  return Promise.all(
1196
1555
  normalizedEntries.map((entry) => {
1197
- const optionsSignature = this.serializeOptions(entry.options);
1556
+ const optionsSignature = serializeOptions(entry.options);
1198
1557
  const existing = pendingReads.get(entry.key);
1199
1558
  if (!existing) {
1200
1559
  const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
@@ -1263,7 +1622,7 @@ var CacheStack = class extends EventEmitter {
1263
1622
  this.assertActive("mset");
1264
1623
  const normalizedEntries = entries.map((entry) => ({
1265
1624
  ...entry,
1266
- key: this.qualifyKey(this.validateCacheKey(entry.key))
1625
+ key: this.qualifyKey(validateCacheKey(entry.key))
1267
1626
  }));
1268
1627
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1269
1628
  await this.awaitStartup("mset");
@@ -1306,7 +1665,7 @@ var CacheStack = class extends EventEmitter {
1306
1665
  */
1307
1666
  wrap(prefix, fetcher, options = {}) {
1308
1667
  return (...args) => {
1309
- const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
1668
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => serializeKeyPart(argument)).join(":");
1310
1669
  const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
1311
1670
  return this.get(key, () => fetcher(...args), options);
1312
1671
  };
@@ -1316,11 +1675,13 @@ var CacheStack = class extends EventEmitter {
1316
1675
  * `prefix:`. Useful for multi-tenant or module-level isolation.
1317
1676
  */
1318
1677
  namespace(prefix) {
1678
+ validateNamespaceKey(prefix);
1319
1679
  return new CacheNamespace(this, prefix);
1320
1680
  }
1321
1681
  async invalidateByTag(tag) {
1682
+ validateTag(tag);
1322
1683
  await this.awaitStartup("invalidateByTag");
1323
- const keys = await this.tagIndex.keysForTag(tag);
1684
+ const keys = await this.collectKeysForTag(tag);
1324
1685
  await this.deleteKeys(keys);
1325
1686
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1326
1687
  }
@@ -1328,23 +1689,28 @@ var CacheStack = class extends EventEmitter {
1328
1689
  if (tags.length === 0) {
1329
1690
  return;
1330
1691
  }
1692
+ validateTags(tags);
1331
1693
  await this.awaitStartup("invalidateByTags");
1332
- const keysByTag = await Promise.all(tags.map((tag) => this.tagIndex.keysForTag(tag)));
1694
+ const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
1333
1695
  const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
1696
+ this.assertWithinInvalidationKeyLimit(keys.length);
1334
1697
  await this.deleteKeys(keys);
1335
1698
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1336
1699
  }
1337
1700
  async invalidateByPattern(pattern) {
1338
- this.validatePattern(pattern);
1701
+ validatePattern(pattern);
1339
1702
  await this.awaitStartup("invalidateByPattern");
1340
- const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1703
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(
1704
+ this.qualifyPattern(pattern),
1705
+ this.invalidationMaxKeys()
1706
+ );
1341
1707
  await this.deleteKeys(keys);
1342
1708
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1343
1709
  }
1344
1710
  async invalidateByPrefix(prefix) {
1345
1711
  await this.awaitStartup("invalidateByPrefix");
1346
- const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1347
- const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1712
+ const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
1713
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
1348
1714
  await this.deleteKeys(keys);
1349
1715
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1350
1716
  }
@@ -1414,7 +1780,7 @@ var CacheStack = class extends EventEmitter {
1414
1780
  * Returns `null` if the key does not exist in any layer.
1415
1781
  */
1416
1782
  async inspect(key) {
1417
- const userKey = this.validateCacheKey(key);
1783
+ const userKey = validateCacheKey(key);
1418
1784
  const normalizedKey = this.qualifyKey(userKey);
1419
1785
  await this.awaitStartup("inspect");
1420
1786
  const foundInLayers = [];
@@ -1451,50 +1817,79 @@ var CacheStack = class extends EventEmitter {
1451
1817
  }
1452
1818
  async exportState() {
1453
1819
  await this.awaitStartup("exportState");
1454
- const exported = /* @__PURE__ */ new Map();
1455
- for (const layer of this.layers) {
1456
- if (!layer.keys) {
1457
- continue;
1458
- }
1459
- const keys = await layer.keys();
1460
- for (const key of keys) {
1461
- const exportedKey = this.stripQualifiedKey(key);
1462
- if (exported.has(exportedKey)) {
1463
- continue;
1464
- }
1465
- const stored = await this.readLayerEntry(layer, key);
1466
- if (stored === null) {
1467
- continue;
1468
- }
1469
- exported.set(exportedKey, {
1470
- key: exportedKey,
1471
- value: stored,
1472
- ttl: remainingStoredTtlSeconds(stored)
1473
- });
1474
- }
1475
- }
1476
- return [...exported.values()];
1820
+ const entries = [];
1821
+ await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
1822
+ entries.push(entry);
1823
+ });
1824
+ return entries;
1477
1825
  }
1478
1826
  async importState(entries) {
1479
1827
  await this.awaitStartup("importState");
1480
- await Promise.all(
1481
- entries.map(async (entry) => {
1482
- const qualifiedKey = this.qualifyKey(entry.key);
1483
- await Promise.all(this.layers.map((layer) => layer.set(qualifiedKey, entry.value, entry.ttl)));
1484
- await this.tagIndex.touch(qualifiedKey);
1485
- })
1486
- );
1828
+ const normalizedEntries = entries.map((entry) => ({
1829
+ key: this.qualifyKey(validateCacheKey(entry.key)),
1830
+ value: entry.value,
1831
+ ttl: entry.ttl
1832
+ }));
1833
+ for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
1834
+ const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
1835
+ await Promise.all(
1836
+ batch.map(async (entry) => {
1837
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1838
+ await this.tagIndex.touch(entry.key);
1839
+ })
1840
+ );
1841
+ }
1487
1842
  }
1488
1843
  async persistToFile(filePath) {
1489
1844
  this.assertActive("persistToFile");
1490
- const snapshot = await this.exportState();
1491
1845
  const { promises: fs2 } = await import("fs");
1492
- await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
1846
+ const path = await import("path");
1847
+ const targetPath = await validateSnapshotFilePath(filePath, "write", this.options.snapshotBaseDir);
1848
+ const tempPath = path.join(
1849
+ path.dirname(targetPath),
1850
+ `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1851
+ );
1852
+ let handle;
1853
+ try {
1854
+ handle = await fs2.open(tempPath, "wx");
1855
+ const openedHandle = handle;
1856
+ await openedHandle.writeFile("[", "utf8");
1857
+ let wroteAny = false;
1858
+ await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
1859
+ await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
1860
+ await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
1861
+ wroteAny = true;
1862
+ });
1863
+ await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
1864
+ await openedHandle.close();
1865
+ handle = void 0;
1866
+ await fs2.rename(tempPath, targetPath);
1867
+ } catch (error) {
1868
+ await handle?.close().catch(() => void 0);
1869
+ await fs2.unlink(tempPath).catch(() => void 0);
1870
+ throw error;
1871
+ }
1493
1872
  }
1494
1873
  async restoreFromFile(filePath) {
1495
1874
  this.assertActive("restoreFromFile");
1496
- const { promises: fs2 } = await import("fs");
1497
- const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
1875
+ const { promises: fs2, constants } = await import("fs");
1876
+ const validatedPath = await validateSnapshotFilePath(filePath, "read", this.options.snapshotBaseDir);
1877
+ const handle = await fs2.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1878
+ const snapshotMaxBytes = this.snapshotMaxBytes();
1879
+ let raw;
1880
+ try {
1881
+ if (snapshotMaxBytes !== false) {
1882
+ const stat = await handle.stat();
1883
+ if (stat.size > snapshotMaxBytes) {
1884
+ throw new Error(
1885
+ `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
1886
+ );
1887
+ }
1888
+ }
1889
+ raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes);
1890
+ } finally {
1891
+ await handle.close();
1892
+ }
1498
1893
  let parsed;
1499
1894
  try {
1500
1895
  parsed = JSON.parse(raw);
@@ -1538,14 +1933,14 @@ var CacheStack = class extends EventEmitter {
1538
1933
  await this.handleInvalidationMessage(message);
1539
1934
  });
1540
1935
  }
1541
- async fetchWithGuards(key, fetcher, options) {
1936
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1542
1937
  const fetchTask = async () => {
1543
1938
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
1544
1939
  if (secondHit.found) {
1545
1940
  this.metricsCollector.increment("hits");
1546
1941
  return secondHit.value;
1547
1942
  }
1548
- return this.fetchAndPopulate(key, fetcher, options);
1943
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
1549
1944
  };
1550
1945
  const singleFlightTask = async () => {
1551
1946
  if (!this.options.singleFlightCoordinator) {
@@ -1555,7 +1950,7 @@ var CacheStack = class extends EventEmitter {
1555
1950
  key,
1556
1951
  this.resolveSingleFlightOptions(),
1557
1952
  fetchTask,
1558
- () => this.waitForFreshValue(key, fetcher, options)
1953
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
1559
1954
  );
1560
1955
  };
1561
1956
  if (this.options.stampedePrevention === false) {
@@ -1563,7 +1958,7 @@ var CacheStack = class extends EventEmitter {
1563
1958
  }
1564
1959
  return this.stampedeGuard.execute(key, singleFlightTask);
1565
1960
  }
1566
- async waitForFreshValue(key, fetcher, options) {
1961
+ async waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1567
1962
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1568
1963
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1569
1964
  const deadline = Date.now() + timeoutMs;
@@ -1577,9 +1972,9 @@ var CacheStack = class extends EventEmitter {
1577
1972
  }
1578
1973
  await this.sleep(pollIntervalMs);
1579
1974
  }
1580
- return this.fetchAndPopulate(key, fetcher, options);
1975
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
1581
1976
  }
1582
- async fetchAndPopulate(key, fetcher, options) {
1977
+ async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1583
1978
  this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1584
1979
  this.metricsCollector.increment("fetches");
1585
1980
  const fetchStart = Date.now();
@@ -1600,6 +1995,16 @@ var CacheStack = class extends EventEmitter {
1600
1995
  if (!this.shouldNegativeCache(options)) {
1601
1996
  return null;
1602
1997
  }
1998
+ if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
1999
+ this.logger.debug?.("skip-negative-store-after-invalidation", {
2000
+ key,
2001
+ expectedClearEpoch,
2002
+ clearEpoch: this.clearEpoch,
2003
+ expectedKeyEpoch,
2004
+ keyEpoch: this.currentKeyEpoch(key)
2005
+ });
2006
+ return null;
2007
+ }
1603
2008
  await this.storeEntry(key, "empty", null, options);
1604
2009
  return null;
1605
2010
  }
@@ -1612,11 +2017,26 @@ var CacheStack = class extends EventEmitter {
1612
2017
  this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1613
2018
  }
1614
2019
  }
2020
+ if (this.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2021
+ this.logger.debug?.("skip-store-after-invalidation", {
2022
+ key,
2023
+ expectedClearEpoch,
2024
+ clearEpoch: this.clearEpoch,
2025
+ expectedKeyEpoch,
2026
+ keyEpoch: this.currentKeyEpoch(key)
2027
+ });
2028
+ return fetched;
2029
+ }
1615
2030
  await this.storeEntry(key, "value", fetched, options);
1616
2031
  return fetched;
1617
2032
  }
1618
2033
  async storeEntry(key, kind, value, options) {
2034
+ const clearEpoch = this.clearEpoch;
2035
+ const keyEpoch = this.currentKeyEpoch(key);
1619
2036
  await this.writeAcrossLayers(key, kind, value, options);
2037
+ if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2038
+ return;
2039
+ }
1620
2040
  if (options?.tags) {
1621
2041
  await this.tagIndex.track(key, options.tags);
1622
2042
  } else {
@@ -1631,6 +2051,8 @@ var CacheStack = class extends EventEmitter {
1631
2051
  }
1632
2052
  async writeBatch(entries) {
1633
2053
  const now = Date.now();
2054
+ const clearEpoch = this.clearEpoch;
2055
+ const entryEpochs = new Map(entries.map((entry) => [entry.key, this.currentKeyEpoch(entry.key)]));
1634
2056
  const entriesByLayer = /* @__PURE__ */ new Map();
1635
2057
  const immediateOperations = [];
1636
2058
  const deferredOperations = [];
@@ -1647,12 +2069,21 @@ var CacheStack = class extends EventEmitter {
1647
2069
  }
1648
2070
  for (const [layer, layerEntries] of entriesByLayer.entries()) {
1649
2071
  const operation = async () => {
2072
+ if (clearEpoch !== this.clearEpoch) {
2073
+ return;
2074
+ }
2075
+ const activeEntries = layerEntries.filter(
2076
+ (entry) => (entryEpochs.get(entry.key) ?? 0) === this.currentKeyEpoch(entry.key)
2077
+ );
2078
+ if (activeEntries.length === 0) {
2079
+ return;
2080
+ }
1650
2081
  try {
1651
2082
  if (layer.setMany) {
1652
- await layer.setMany(layerEntries);
2083
+ await layer.setMany(activeEntries);
1653
2084
  return;
1654
2085
  }
1655
- await Promise.all(layerEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
2086
+ await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
1656
2087
  } catch (error) {
1657
2088
  await this.handleLayerFailure(layer, "write", error);
1658
2089
  }
@@ -1665,7 +2096,13 @@ var CacheStack = class extends EventEmitter {
1665
2096
  }
1666
2097
  await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
1667
2098
  await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
2099
+ if (clearEpoch !== this.clearEpoch) {
2100
+ return;
2101
+ }
1668
2102
  for (const entry of entries) {
2103
+ if (this.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
2104
+ continue;
2105
+ }
1669
2106
  if (entry.options?.tags) {
1670
2107
  await this.tagIndex.track(entry.key, entry.options.tags);
1671
2108
  } else {
@@ -1767,10 +2204,15 @@ var CacheStack = class extends EventEmitter {
1767
2204
  }
1768
2205
  async writeAcrossLayers(key, kind, value, options) {
1769
2206
  const now = Date.now();
2207
+ const clearEpoch = this.clearEpoch;
2208
+ const keyEpoch = this.currentKeyEpoch(key);
1770
2209
  const immediateOperations = [];
1771
2210
  const deferredOperations = [];
1772
2211
  for (const layer of this.layers) {
1773
2212
  const operation = async () => {
2213
+ if (this.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2214
+ return;
2215
+ }
1774
2216
  if (this.shouldSkipLayer(layer)) {
1775
2217
  return;
1776
2218
  }
@@ -1834,10 +2276,12 @@ var CacheStack = class extends EventEmitter {
1834
2276
  if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
1835
2277
  return;
1836
2278
  }
2279
+ const clearEpoch = this.clearEpoch;
2280
+ const keyEpoch = this.currentKeyEpoch(key);
1837
2281
  const refresh = (async () => {
1838
2282
  this.metricsCollector.increment("refreshes");
1839
2283
  try {
1840
- await this.runBackgroundRefresh(key, fetcher, options);
2284
+ await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
1841
2285
  } catch (error) {
1842
2286
  this.metricsCollector.increment("refreshErrors");
1843
2287
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1847,14 +2291,16 @@ var CacheStack = class extends EventEmitter {
1847
2291
  })();
1848
2292
  this.backgroundRefreshes.set(key, refresh);
1849
2293
  }
1850
- async runBackgroundRefresh(key, fetcher, options) {
2294
+ async runBackgroundRefresh(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1851
2295
  const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
1852
2296
  await this.fetchWithGuards(
1853
2297
  key,
1854
2298
  () => this.withTimeout(fetcher(), timeoutMs, () => {
1855
2299
  return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
1856
2300
  }),
1857
- options
2301
+ options,
2302
+ expectedClearEpoch,
2303
+ expectedKeyEpoch
1858
2304
  );
1859
2305
  }
1860
2306
  resolveSingleFlightOptions() {
@@ -1869,6 +2315,7 @@ var CacheStack = class extends EventEmitter {
1869
2315
  if (keys.length === 0) {
1870
2316
  return;
1871
2317
  }
2318
+ this.bumpKeyEpochs(keys);
1872
2319
  await this.deleteKeysFromLayers(this.layers, keys);
1873
2320
  for (const key of keys) {
1874
2321
  await this.tagIndex.remove(key);
@@ -1891,21 +2338,22 @@ var CacheStack = class extends EventEmitter {
1891
2338
  return;
1892
2339
  }
1893
2340
  const localLayers = this.layers.filter((layer) => layer.isLocal);
1894
- if (localLayers.length === 0) {
1895
- return;
1896
- }
1897
2341
  if (message.scope === "clear") {
2342
+ this.beginClearEpoch();
1898
2343
  await Promise.all(localLayers.map((layer) => layer.clear()));
1899
2344
  await this.tagIndex.clear();
1900
2345
  this.ttlResolver.clearProfiles();
2346
+ this.circuitBreakerManager.clear();
1901
2347
  return;
1902
2348
  }
1903
2349
  const keys = message.keys ?? [];
2350
+ this.bumpKeyEpochs(keys);
1904
2351
  await this.deleteKeysFromLayers(localLayers, keys);
1905
2352
  if (message.operation !== "write") {
1906
2353
  for (const key of keys) {
1907
2354
  await this.tagIndex.remove(key);
1908
2355
  this.ttlResolver.deleteProfile(key);
2356
+ this.circuitBreakerManager.delete(key);
1909
2357
  }
1910
2358
  }
1911
2359
  }
@@ -2011,6 +2459,28 @@ var CacheStack = class extends EventEmitter {
2011
2459
  shouldWriteBehind(layer) {
2012
2460
  return this.options.writeStrategy === "write-behind" && !layer.isLocal;
2013
2461
  }
2462
+ beginClearEpoch() {
2463
+ this.clearEpoch += 1;
2464
+ this.keyEpochs.clear();
2465
+ this.writeBehindQueue.length = 0;
2466
+ }
2467
+ currentKeyEpoch(key) {
2468
+ return this.keyEpochs.get(key) ?? 0;
2469
+ }
2470
+ bumpKeyEpochs(keys) {
2471
+ for (const key of keys) {
2472
+ this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
2473
+ }
2474
+ }
2475
+ isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
2476
+ if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
2477
+ return true;
2478
+ }
2479
+ if (expectedKeyEpoch !== void 0 && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
2480
+ return true;
2481
+ }
2482
+ return false;
2483
+ }
2014
2484
  async enqueueWriteBehind(operation) {
2015
2485
  this.writeBehindQueue.push(operation);
2016
2486
  const batchSize = this.options.writeBehind?.batchSize ?? 100;
@@ -2137,118 +2607,50 @@ var CacheStack = class extends EventEmitter {
2137
2607
  if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
2138
2608
  throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
2139
2609
  }
2140
- this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
2141
- this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
2142
- this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
2143
- this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
2144
- this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
2145
- this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2146
- this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2147
- this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2148
- this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2149
- this.validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2150
- this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2151
- this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2152
- this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2610
+ validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
2611
+ validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
2612
+ validateLayerNumberOption("staleIfError", this.options.staleIfError);
2613
+ validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
2614
+ validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
2615
+ validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2616
+ validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2617
+ validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2618
+ validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2619
+ validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2620
+ if (this.options.snapshotMaxBytes !== false) {
2621
+ validatePositiveNumber("snapshotMaxBytes", this.options.snapshotMaxBytes);
2622
+ }
2623
+ if (this.options.snapshotMaxEntries !== false) {
2624
+ validatePositiveNumber("snapshotMaxEntries", this.options.snapshotMaxEntries);
2625
+ }
2626
+ if (this.options.invalidationMaxKeys !== false) {
2627
+ validatePositiveNumber("invalidationMaxKeys", this.options.invalidationMaxKeys);
2628
+ }
2629
+ validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2630
+ validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2631
+ validateCircuitBreakerOptions(this.options.circuitBreaker);
2153
2632
  if (typeof this.options.generationCleanup === "object") {
2154
- this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2633
+ validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2155
2634
  }
2156
2635
  if (this.options.generation !== void 0) {
2157
- this.validateNonNegativeNumber("generation", this.options.generation);
2636
+ validateNonNegativeNumber("generation", this.options.generation);
2158
2637
  }
2159
2638
  }
2160
2639
  validateWriteOptions(options) {
2161
2640
  if (!options) {
2162
2641
  return;
2163
2642
  }
2164
- this.validateLayerNumberOption("options.ttl", options.ttl);
2165
- this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
2166
- this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
2167
- this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
2168
- this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
2169
- this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
2170
- this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2171
- this.validateAdaptiveTtlOptions(options.adaptiveTtl);
2172
- this.validateCircuitBreakerOptions(options.circuitBreaker);
2173
- this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2174
- }
2175
- validateLayerNumberOption(name, value) {
2176
- if (value === void 0) {
2177
- return;
2178
- }
2179
- if (typeof value === "number") {
2180
- this.validateNonNegativeNumber(name, value);
2181
- return;
2182
- }
2183
- for (const [layerName, layerValue] of Object.entries(value)) {
2184
- if (layerValue === void 0) {
2185
- continue;
2186
- }
2187
- this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
2188
- }
2189
- }
2190
- validatePositiveNumber(name, value) {
2191
- if (value === void 0) {
2192
- return;
2193
- }
2194
- if (!Number.isFinite(value) || value <= 0) {
2195
- throw new Error(`${name} must be a positive finite number.`);
2196
- }
2197
- }
2198
- validateRateLimitOptions(name, options) {
2199
- if (!options) {
2200
- return;
2201
- }
2202
- this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2203
- this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2204
- this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2205
- if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2206
- throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2207
- }
2208
- if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2209
- throw new Error(`${name}.bucketKey must not be empty.`);
2210
- }
2211
- }
2212
- validateNonNegativeNumber(name, value) {
2213
- if (!Number.isFinite(value) || value < 0) {
2214
- throw new Error(`${name} must be a non-negative finite number.`);
2215
- }
2216
- }
2217
- validateCacheKey(key) {
2218
- if (key.length === 0) {
2219
- throw new Error("Cache key must not be empty.");
2220
- }
2221
- if (key.length > MAX_CACHE_KEY_LENGTH) {
2222
- throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
2223
- }
2224
- if (/[\u0000-\u001F\u007F]/.test(key)) {
2225
- throw new Error("Cache key contains unsupported control characters.");
2226
- }
2227
- if (/[\uD800-\uDFFF]/.test(key)) {
2228
- throw new Error("Cache key contains unsupported surrogate code points.");
2229
- }
2230
- return key;
2231
- }
2232
- validatePattern(pattern) {
2233
- if (pattern.length === 0) {
2234
- throw new Error("Pattern must not be empty.");
2235
- }
2236
- if (pattern.length > MAX_PATTERN_LENGTH) {
2237
- throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
2238
- }
2239
- if (/[\u0000-\u001F\u007F]/.test(pattern)) {
2240
- throw new Error("Pattern contains unsupported control characters.");
2241
- }
2242
- }
2243
- validateTtlPolicy(name, policy) {
2244
- if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
2245
- return;
2246
- }
2247
- if ("alignTo" in policy) {
2248
- this.validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
2249
- return;
2250
- }
2251
- throw new Error(`${name} is invalid.`);
2643
+ validateLayerNumberOption("options.ttl", options.ttl);
2644
+ validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
2645
+ validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
2646
+ validateLayerNumberOption("options.staleIfError", options.staleIfError);
2647
+ validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
2648
+ validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
2649
+ validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2650
+ validateAdaptiveTtlOptions(options.adaptiveTtl);
2651
+ validateCircuitBreakerOptions(options.circuitBreaker);
2652
+ validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2653
+ validateTags(options.tags);
2252
2654
  }
2253
2655
  assertActive(operation) {
2254
2656
  if (this.isDisconnecting) {
@@ -2260,24 +2662,6 @@ var CacheStack = class extends EventEmitter {
2260
2662
  await this.startup;
2261
2663
  this.assertActive(operation);
2262
2664
  }
2263
- serializeOptions(options) {
2264
- return JSON.stringify(this.normalizeForSerialization(options) ?? null);
2265
- }
2266
- validateAdaptiveTtlOptions(options) {
2267
- if (!options || options === true) {
2268
- return;
2269
- }
2270
- this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
2271
- this.validateLayerNumberOption("adaptiveTtl.step", options.step);
2272
- this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
2273
- }
2274
- validateCircuitBreakerOptions(options) {
2275
- if (!options) {
2276
- return;
2277
- }
2278
- this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
2279
- this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
2280
- }
2281
2665
  async applyFreshReadPolicies(key, hit, options, fetcher) {
2282
2666
  const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
2283
2667
  const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
@@ -2345,18 +2729,6 @@ var CacheStack = class extends EventEmitter {
2345
2729
  this.emit("error", { operation, ...context });
2346
2730
  }
2347
2731
  }
2348
- serializeKeyPart(value) {
2349
- if (typeof value === "string") {
2350
- return `s:${value}`;
2351
- }
2352
- if (typeof value === "number") {
2353
- return `n:${value}`;
2354
- }
2355
- if (typeof value === "boolean") {
2356
- return `b:${value}`;
2357
- }
2358
- return `j:${JSON.stringify(this.normalizeForSerialization(value))}`;
2359
- }
2360
2732
  isCacheSnapshotEntries(value) {
2361
2733
  return Array.isArray(value) && value.every((entry) => {
2362
2734
  if (!entry || typeof entry !== "object") {
@@ -2369,54 +2741,72 @@ var CacheStack = class extends EventEmitter {
2369
2741
  sanitizeSnapshotValue(value) {
2370
2742
  return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2371
2743
  }
2372
- async validateSnapshotFilePath(filePath) {
2373
- if (filePath.length === 0) {
2374
- throw new Error("filePath must not be empty.");
2375
- }
2376
- if (filePath.includes("\0")) {
2377
- throw new Error("filePath must not contain null bytes.");
2744
+ snapshotMaxBytes() {
2745
+ return this.options.snapshotMaxBytes === false ? false : this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES;
2746
+ }
2747
+ snapshotMaxEntries() {
2748
+ return this.options.snapshotMaxEntries === false ? false : this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES;
2749
+ }
2750
+ invalidationMaxKeys() {
2751
+ return this.options.invalidationMaxKeys === false ? false : this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS;
2752
+ }
2753
+ async collectKeysForTag(tag) {
2754
+ const keys = /* @__PURE__ */ new Set();
2755
+ if (this.tagIndex.forEachKeyForTag) {
2756
+ await this.tagIndex.forEachKeyForTag(tag, async (key) => {
2757
+ keys.add(key);
2758
+ this.assertWithinInvalidationKeyLimit(keys.size);
2759
+ });
2760
+ return [...keys];
2378
2761
  }
2379
- const path = await import("path");
2380
- const resolved = path.resolve(filePath);
2381
- const baseDir = this.options.snapshotBaseDir === false ? false : path.resolve(this.options.snapshotBaseDir ?? process.cwd());
2382
- if (baseDir !== false) {
2383
- const relative = path.relative(baseDir, resolved);
2384
- if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2385
- throw new Error(`filePath is outside the allowed snapshot directory: ${baseDir}`);
2386
- }
2762
+ for (const key of await this.tagIndex.keysForTag(tag)) {
2763
+ keys.add(key);
2764
+ this.assertWithinInvalidationKeyLimit(keys.size);
2387
2765
  }
2388
- return resolved;
2766
+ return [...keys];
2389
2767
  }
2390
- normalizeForSerialization(value) {
2391
- if (Array.isArray(value)) {
2392
- return value.map((entry) => this.normalizeForSerialization(entry));
2768
+ assertWithinInvalidationKeyLimit(size) {
2769
+ const maxKeys = this.invalidationMaxKeys();
2770
+ if (maxKeys !== false && size > maxKeys) {
2771
+ throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
2393
2772
  }
2394
- if (value && typeof value === "object") {
2395
- return Object.keys(value).sort().reduce((normalized, key) => {
2396
- if (DANGEROUS_OBJECT_KEYS.has(key)) {
2397
- return normalized;
2773
+ }
2774
+ async visitExportEntries(maxEntries, visitor) {
2775
+ const exported = /* @__PURE__ */ new Set();
2776
+ for (const layer of this.layers) {
2777
+ if (!layer.keys && !layer.forEachKey) {
2778
+ continue;
2779
+ }
2780
+ const visitKey = async (key) => {
2781
+ const exportedKey = this.stripQualifiedKey(key);
2782
+ if (exported.has(exportedKey)) {
2783
+ return;
2398
2784
  }
2399
- normalized[key] = this.normalizeForSerialization(value[key]);
2400
- return normalized;
2401
- }, {});
2785
+ const stored = await this.readLayerEntry(layer, key);
2786
+ if (stored === null) {
2787
+ return;
2788
+ }
2789
+ exported.add(exportedKey);
2790
+ if (maxEntries !== false && exported.size > maxEntries) {
2791
+ throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
2792
+ }
2793
+ await visitor({
2794
+ key: exportedKey,
2795
+ value: stored,
2796
+ ttl: remainingStoredTtlSeconds(stored)
2797
+ });
2798
+ };
2799
+ if (layer.forEachKey) {
2800
+ await layer.forEachKey(visitKey);
2801
+ continue;
2802
+ }
2803
+ const keys = await layer.keys?.();
2804
+ for (const key of keys ?? []) {
2805
+ await visitKey(key);
2806
+ }
2402
2807
  }
2403
- return value;
2404
2808
  }
2405
2809
  };
2406
- function createInstanceId() {
2407
- if (globalThis.crypto?.randomUUID) {
2408
- return globalThis.crypto.randomUUID();
2409
- }
2410
- const bytes = new Uint8Array(16);
2411
- if (globalThis.crypto?.getRandomValues) {
2412
- globalThis.crypto.getRandomValues(bytes);
2413
- } else {
2414
- for (let i = 0; i < bytes.length; i++) {
2415
- bytes[i] = Math.floor(Math.random() * 256);
2416
- }
2417
- }
2418
- return `layercache-${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
2419
- }
2420
2810
 
2421
2811
  // src/invalidation/RedisInvalidationBus.ts
2422
2812
  var RedisInvalidationBus = class {
@@ -2495,15 +2885,24 @@ var RedisInvalidationBus = class {
2495
2885
  }
2496
2886
  };
2497
2887
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2498
- function sanitizeJsonValue2(value) {
2888
+ var MAX_SANITIZE_DEPTH2 = 64;
2889
+ var MAX_SANITIZE_NODES2 = 1e4;
2890
+ function sanitizeJsonValue2(value, depth = 0, state = { count: 0 }) {
2891
+ state.count += 1;
2892
+ if (state.count > MAX_SANITIZE_NODES2) {
2893
+ throw new Error(`Invalidation payload exceeds max node count of ${MAX_SANITIZE_NODES2}.`);
2894
+ }
2895
+ if (depth > MAX_SANITIZE_DEPTH2) {
2896
+ throw new Error(`Invalidation payload exceeds max depth of ${MAX_SANITIZE_DEPTH2}.`);
2897
+ }
2499
2898
  if (Array.isArray(value)) {
2500
- return value.map(sanitizeJsonValue2);
2899
+ return value.map((entry) => sanitizeJsonValue2(entry, depth + 1, state));
2501
2900
  }
2502
2901
  if (value && typeof value === "object") {
2503
2902
  const result = /* @__PURE__ */ Object.create(null);
2504
2903
  for (const key of Object.keys(value)) {
2505
2904
  if (!DANGEROUS_KEYS.has(key)) {
2506
- result[key] = sanitizeJsonValue2(value[key]);
2905
+ result[key] = sanitizeJsonValue2(value[key], depth + 1, state);
2507
2906
  }
2508
2907
  }
2509
2908
  return result;
@@ -2512,12 +2911,18 @@ function sanitizeJsonValue2(value) {
2512
2911
  }
2513
2912
 
2514
2913
  // src/http/createCacheStatsHandler.ts
2515
- function createCacheStatsHandler(cache) {
2516
- return async (_request, response) => {
2517
- response.statusCode = 200;
2914
+ function createCacheStatsHandler(cache, options = {}) {
2915
+ return async (request, response) => {
2518
2916
  response.setHeader?.("content-type", "application/json; charset=utf-8");
2519
2917
  response.setHeader?.("cache-control", "no-store");
2520
2918
  response.setHeader?.("x-content-type-options", "nosniff");
2919
+ const isAuthorized = options.allowPublicAccess === true || (options.authorize ? await options.authorize(request) : false);
2920
+ if (!isAuthorized) {
2921
+ response.statusCode = options.unauthorizedStatusCode ?? 403;
2922
+ response.end(JSON.stringify({ error: "Forbidden" }));
2923
+ return;
2924
+ }
2925
+ response.statusCode = 200;
2521
2926
  response.end(JSON.stringify(cache.getStats(), null, 2));
2522
2927
  };
2523
2928
  }
@@ -2552,7 +2957,26 @@ function createFastifyLayercachePlugin(cache, options = {}) {
2552
2957
  return async (fastify) => {
2553
2958
  fastify.decorate("cache", cache);
2554
2959
  if (options.exposeStatsRoute === true && fastify.get) {
2555
- fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
2960
+ fastify.get(options.statsPath ?? "/cache/stats", async (request, reply) => {
2961
+ const isAuthorized = options.allowPublicStatsRoute === true || (options.authorizeStatsRoute ? await options.authorizeStatsRoute(request) : false);
2962
+ reply.header?.("cache-control", "no-store");
2963
+ reply.header?.("x-content-type-options", "nosniff");
2964
+ if (!isAuthorized) {
2965
+ reply.statusCode = options.unauthorizedStatusCode ?? 403;
2966
+ const body2 = { error: "Forbidden" };
2967
+ if (reply.send) {
2968
+ reply.send(body2);
2969
+ return;
2970
+ }
2971
+ return body2;
2972
+ }
2973
+ const body = cache.getStats();
2974
+ if (reply.send) {
2975
+ reply.send(body);
2976
+ return;
2977
+ }
2978
+ return body;
2979
+ });
2556
2980
  }
2557
2981
  };
2558
2982
  }
@@ -2567,6 +2991,10 @@ function createExpressCacheMiddleware(cache, options = {}) {
2567
2991
  next();
2568
2992
  return;
2569
2993
  }
2994
+ if (!options.keyResolver && options.allowPrivateCaching !== true) {
2995
+ next();
2996
+ return;
2997
+ }
2570
2998
  const rawUrl = req.originalUrl ?? req.url ?? "/";
2571
2999
  const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2572
3000
  const cached = await cache.get(key, void 0, options);
@@ -2611,6 +3039,11 @@ function normalizeUrl(url) {
2611
3039
 
2612
3040
  // src/integrations/graphql.ts
2613
3041
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
3042
+ if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
3043
+ throw new Error(
3044
+ "cacheGraphqlResolver requires a keyResolver or allowImplicitContextCaching=true because resolver output may depend on request context."
3045
+ );
3046
+ }
2614
3047
  const wrapped = cache.wrap(prefix, resolver, {
2615
3048
  ...options,
2616
3049
  keyResolver: options.keyResolver
@@ -2682,6 +3115,11 @@ function instrument(name, tracer, method, attributes) {
2682
3115
 
2683
3116
  // src/integrations/trpc.ts
2684
3117
  function createTrpcCacheMiddleware(cache, prefix, options = {}) {
3118
+ if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
3119
+ throw new Error(
3120
+ "createTrpcCacheMiddleware requires a keyResolver or allowImplicitContextCaching=true because procedure output may depend on request context."
3121
+ );
3122
+ }
2685
3123
  return async (context) => {
2686
3124
  const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
2687
3125
  let didFetch = false;
@@ -2706,13 +3144,12 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
2706
3144
  }
2707
3145
 
2708
3146
  // src/layers/RedisLayer.ts
3147
+ import { Readable } from "stream";
2709
3148
  import { promisify } from "util";
2710
- import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
3149
+ import { brotliCompress, createBrotliDecompress, createGunzip, gzip } from "zlib";
2711
3150
  var BATCH_DELETE_SIZE = 500;
2712
3151
  var gzipAsync = promisify(gzip);
2713
- var gunzipAsync = promisify(gunzip);
2714
3152
  var brotliCompressAsync = promisify(brotliCompress);
2715
- var brotliDecompressAsync = promisify(brotliDecompress);
2716
3153
  var RedisLayer = class {
2717
3154
  name;
2718
3155
  defaultTtl;
@@ -2820,8 +3257,18 @@ var RedisLayer = class {
2820
3257
  return remaining;
2821
3258
  }
2822
3259
  async size() {
2823
- const keys = await this.keys();
2824
- return keys.length;
3260
+ if (!this.prefix) {
3261
+ return this.client.dbsize();
3262
+ }
3263
+ const pattern = `${this.prefix}*`;
3264
+ let cursor = "0";
3265
+ let count = 0;
3266
+ do {
3267
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3268
+ cursor = nextCursor;
3269
+ count += keys.length;
3270
+ } while (cursor !== "0");
3271
+ return count;
2825
3272
  }
2826
3273
  async ping() {
2827
3274
  try {
@@ -2867,6 +3314,17 @@ var RedisLayer = class {
2867
3314
  }
2868
3315
  return keys.map((key) => key.slice(this.prefix.length));
2869
3316
  }
3317
+ async forEachKey(visitor) {
3318
+ const pattern = `${this.prefix}*`;
3319
+ let cursor = "0";
3320
+ do {
3321
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3322
+ cursor = nextCursor;
3323
+ for (const key of keys) {
3324
+ await visitor(this.prefix ? key.slice(this.prefix.length) : key);
3325
+ }
3326
+ } while (cursor !== "0");
3327
+ }
2870
3328
  async scanKeys(pattern) {
2871
3329
  const matches = [];
2872
3330
  let cursor = "0";
@@ -2881,7 +3339,13 @@ var RedisLayer = class {
2881
3339
  return `${this.prefix}${key}`;
2882
3340
  }
2883
3341
  async deserializeOrDelete(key, payload) {
2884
- const decodedPayload = await this.decodePayload(payload);
3342
+ let decodedPayload;
3343
+ try {
3344
+ decodedPayload = await this.decodePayload(payload);
3345
+ } catch {
3346
+ await this.deleteCorruptedKey(key);
3347
+ return null;
3348
+ }
2885
3349
  for (const serializer of this.serializers) {
2886
3350
  try {
2887
3351
  const value = serializer.deserialize(decodedPayload);
@@ -2892,12 +3356,15 @@ var RedisLayer = class {
2892
3356
  } catch {
2893
3357
  }
2894
3358
  }
3359
+ await this.deleteCorruptedKey(key);
3360
+ return null;
3361
+ }
3362
+ async deleteCorruptedKey(key) {
2895
3363
  try {
2896
3364
  await this.client.del(this.withPrefix(key));
2897
3365
  } catch (deleteError) {
2898
3366
  console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
2899
3367
  }
2900
- return null;
2901
3368
  }
2902
3369
  async rewriteWithPrimarySerializer(key, value) {
2903
3370
  const serialized = this.primarySerializer().serialize(value);
@@ -2944,31 +3411,72 @@ var RedisLayer = class {
2944
3411
  return payload;
2945
3412
  }
2946
3413
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2947
- const decompressed = await gunzipAsync(payload.subarray(10));
2948
- if (decompressed.byteLength > this.decompressionMaxBytes) {
2949
- throw new Error(
2950
- `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2951
- );
2952
- }
2953
- return decompressed;
3414
+ return this.decompressWithLimit(createGunzip(), payload.subarray(10));
2954
3415
  }
2955
3416
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2956
- const decompressed = await brotliDecompressAsync(payload.subarray(12));
2957
- if (decompressed.byteLength > this.decompressionMaxBytes) {
2958
- throw new Error(
2959
- `Decompressed payload (${decompressed.byteLength} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
2960
- );
2961
- }
2962
- return decompressed;
3417
+ return this.decompressWithLimit(createBrotliDecompress(), payload.subarray(12));
2963
3418
  }
2964
3419
  return payload;
2965
3420
  }
3421
+ async decompressWithLimit(decompressor, payload) {
3422
+ return new Promise((resolve2, reject) => {
3423
+ const source = Readable.from(payload);
3424
+ const chunks = [];
3425
+ let totalBytes = 0;
3426
+ let settled = false;
3427
+ const cleanup = () => {
3428
+ decompressor.removeAllListeners();
3429
+ };
3430
+ const fail = (error) => {
3431
+ if (settled) {
3432
+ return;
3433
+ }
3434
+ settled = true;
3435
+ cleanup();
3436
+ source.unpipe(decompressor);
3437
+ source.destroy();
3438
+ decompressor.destroy();
3439
+ reject(error);
3440
+ };
3441
+ decompressor.on("data", (chunk) => {
3442
+ const normalized = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3443
+ totalBytes += normalized.byteLength;
3444
+ if (totalBytes > this.decompressionMaxBytes) {
3445
+ fail(
3446
+ new Error(
3447
+ `Decompressed payload (${totalBytes} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3448
+ )
3449
+ );
3450
+ return;
3451
+ }
3452
+ chunks.push(normalized);
3453
+ });
3454
+ decompressor.once("error", (error) => {
3455
+ if (settled) {
3456
+ return;
3457
+ }
3458
+ settled = true;
3459
+ cleanup();
3460
+ reject(error);
3461
+ });
3462
+ decompressor.once("end", () => {
3463
+ if (settled) {
3464
+ return;
3465
+ }
3466
+ settled = true;
3467
+ cleanup();
3468
+ resolve2(Buffer.concat(chunks));
3469
+ });
3470
+ source.pipe(decompressor);
3471
+ });
3472
+ }
2966
3473
  };
2967
3474
 
2968
3475
  // src/layers/DiskLayer.ts
2969
3476
  import { createHash } from "crypto";
2970
3477
  import { promises as fs } from "fs";
2971
3478
  import { join, resolve } from "path";
3479
+ var FILE_SCAN_CONCURRENCY = 32;
2972
3480
  var DiskLayer = class {
2973
3481
  name;
2974
3482
  defaultTtl;
@@ -2976,6 +3484,7 @@ var DiskLayer = class {
2976
3484
  directory;
2977
3485
  serializer;
2978
3486
  maxFiles;
3487
+ maxEntryBytes;
2979
3488
  writeQueue = Promise.resolve();
2980
3489
  constructor(options) {
2981
3490
  this.directory = this.resolveDirectory(options.directory);
@@ -2983,16 +3492,15 @@ var DiskLayer = class {
2983
3492
  this.name = options.name ?? "disk";
2984
3493
  this.serializer = options.serializer ?? new JsonSerializer();
2985
3494
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
3495
+ this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
2986
3496
  }
2987
3497
  async get(key) {
2988
3498
  return unwrapStoredValue(await this.getEntry(key));
2989
3499
  }
2990
3500
  async getEntry(key) {
2991
3501
  const filePath = this.keyToPath(key);
2992
- let raw;
2993
- try {
2994
- raw = await fs.readFile(filePath);
2995
- } catch {
3502
+ const raw = await this.readEntryFile(filePath);
3503
+ if (raw === null) {
2996
3504
  return null;
2997
3505
  }
2998
3506
  let entry;
@@ -3043,10 +3551,8 @@ var DiskLayer = class {
3043
3551
  }
3044
3552
  async ttl(key) {
3045
3553
  const filePath = this.keyToPath(key);
3046
- let raw;
3047
- try {
3048
- raw = await fs.readFile(filePath);
3049
- } catch {
3554
+ const raw = await this.readEntryFile(filePath);
3555
+ if (raw === null) {
3050
3556
  return null;
3051
3557
  }
3052
3558
  let entry;
@@ -3070,7 +3576,7 @@ var DiskLayer = class {
3070
3576
  }
3071
3577
  async deleteMany(keys) {
3072
3578
  await this.enqueueWrite(async () => {
3073
- await Promise.all(keys.map((key) => this.safeDelete(this.keyToPath(key))));
3579
+ await this.deletePathsWithConcurrency(keys.map((key) => this.keyToPath(key)));
3074
3580
  });
3075
3581
  }
3076
3582
  async clear() {
@@ -3081,8 +3587,8 @@ var DiskLayer = class {
3081
3587
  } catch {
3082
3588
  return;
3083
3589
  }
3084
- await Promise.all(
3085
- entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
3590
+ await this.deletePathsWithConcurrency(
3591
+ entries.filter((name) => name.endsWith(".lc")).map((name) => join(this.directory, name))
3086
3592
  );
3087
3593
  });
3088
3594
  }
@@ -3091,42 +3597,23 @@ var DiskLayer = class {
3091
3597
  * Expired entries are skipped and cleaned up during the scan.
3092
3598
  */
3093
3599
  async keys() {
3094
- let entries;
3095
- try {
3096
- entries = await fs.readdir(this.directory);
3097
- } catch {
3098
- return [];
3099
- }
3100
- const lcFiles = entries.filter((name) => name.endsWith(".lc"));
3101
3600
  const keys = [];
3102
- await Promise.all(
3103
- lcFiles.map(async (name) => {
3104
- const filePath = join(this.directory, name);
3105
- let raw;
3106
- try {
3107
- raw = await fs.readFile(filePath);
3108
- } catch {
3109
- return;
3110
- }
3111
- let entry;
3112
- try {
3113
- entry = this.deserializeEntry(raw);
3114
- } catch {
3115
- await this.safeDelete(filePath);
3116
- return;
3117
- }
3118
- if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
3119
- await this.safeDelete(filePath);
3120
- return;
3121
- }
3122
- keys.push(entry.key);
3123
- })
3124
- );
3601
+ await this.scanEntries(async (entry) => {
3602
+ keys.push(entry.key);
3603
+ });
3125
3604
  return keys;
3126
3605
  }
3606
+ async forEachKey(visitor) {
3607
+ await this.scanEntries(async (entry) => {
3608
+ await visitor(entry.key);
3609
+ });
3610
+ }
3127
3611
  async size() {
3128
- const keys = await this.keys();
3129
- return keys.length;
3612
+ let count = 0;
3613
+ await this.scanEntries(async () => {
3614
+ count += 1;
3615
+ });
3616
+ return count;
3130
3617
  }
3131
3618
  async ping() {
3132
3619
  try {
@@ -3160,6 +3647,113 @@ var DiskLayer = class {
3160
3647
  }
3161
3648
  return maxFiles;
3162
3649
  }
3650
+ normalizeMaxEntryBytes(maxEntryBytes) {
3651
+ if (maxEntryBytes === false) {
3652
+ return false;
3653
+ }
3654
+ const normalized = maxEntryBytes ?? 16 * 1024 * 1024;
3655
+ if (!Number.isFinite(normalized) || normalized <= 0) {
3656
+ throw new Error("DiskLayer.maxEntryBytes must be a positive number or false.");
3657
+ }
3658
+ return normalized;
3659
+ }
3660
+ async readEntryFile(filePath) {
3661
+ let handle;
3662
+ try {
3663
+ handle = await fs.open(filePath, "r");
3664
+ return await this.readHandleWithLimit(handle);
3665
+ } catch {
3666
+ await this.safeDelete(filePath);
3667
+ return null;
3668
+ } finally {
3669
+ await handle?.close().catch(() => void 0);
3670
+ }
3671
+ }
3672
+ async readHandleWithLimit(handle) {
3673
+ if (this.maxEntryBytes === false) {
3674
+ return handle.readFile();
3675
+ }
3676
+ const stat = await handle.stat();
3677
+ if (stat.size > this.maxEntryBytes) {
3678
+ throw new Error(`DiskLayer entry exceeds maxEntryBytes limit (${stat.size} bytes > ${this.maxEntryBytes} bytes).`);
3679
+ }
3680
+ const chunks = [];
3681
+ let totalBytes = 0;
3682
+ let position = 0;
3683
+ while (true) {
3684
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, this.maxEntryBytes - totalBytes + 1));
3685
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
3686
+ if (bytesRead === 0) {
3687
+ break;
3688
+ }
3689
+ totalBytes += bytesRead;
3690
+ if (totalBytes > this.maxEntryBytes) {
3691
+ throw new Error(
3692
+ `DiskLayer entry exceeds maxEntryBytes limit (${totalBytes} bytes > ${this.maxEntryBytes} bytes).`
3693
+ );
3694
+ }
3695
+ chunks.push(buffer.subarray(0, bytesRead));
3696
+ position += bytesRead;
3697
+ }
3698
+ return Buffer.concat(chunks);
3699
+ }
3700
+ async scanEntries(visitor) {
3701
+ let entries;
3702
+ try {
3703
+ entries = await fs.readdir(this.directory);
3704
+ } catch {
3705
+ return;
3706
+ }
3707
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
3708
+ let nextIndex = 0;
3709
+ const workerCount = Math.min(FILE_SCAN_CONCURRENCY, lcFiles.length);
3710
+ await Promise.all(
3711
+ Array.from({ length: workerCount }, async () => {
3712
+ while (true) {
3713
+ const currentIndex = nextIndex;
3714
+ nextIndex += 1;
3715
+ const name = lcFiles[currentIndex];
3716
+ if (name === void 0) {
3717
+ return;
3718
+ }
3719
+ const filePath = join(this.directory, name);
3720
+ const raw = await this.readEntryFile(filePath);
3721
+ if (raw === null) {
3722
+ continue;
3723
+ }
3724
+ let entry;
3725
+ try {
3726
+ entry = this.deserializeEntry(raw);
3727
+ } catch {
3728
+ await this.safeDelete(filePath);
3729
+ continue;
3730
+ }
3731
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
3732
+ await this.safeDelete(filePath);
3733
+ continue;
3734
+ }
3735
+ await visitor(entry);
3736
+ }
3737
+ })
3738
+ );
3739
+ }
3740
+ async deletePathsWithConcurrency(paths) {
3741
+ let nextIndex = 0;
3742
+ const workerCount = Math.min(FILE_SCAN_CONCURRENCY, paths.length);
3743
+ await Promise.all(
3744
+ Array.from({ length: workerCount }, async () => {
3745
+ while (true) {
3746
+ const currentIndex = nextIndex;
3747
+ nextIndex += 1;
3748
+ const filePath = paths[currentIndex];
3749
+ if (filePath === void 0) {
3750
+ return;
3751
+ }
3752
+ await this.safeDelete(filePath);
3753
+ }
3754
+ })
3755
+ );
3756
+ }
3163
3757
  deserializeEntry(raw) {
3164
3758
  const entry = this.serializer.deserialize(raw);
3165
3759
  if (!isDiskEntry(entry)) {
@@ -3296,18 +3890,27 @@ var MemcachedLayer = class {
3296
3890
  // src/serialization/MsgpackSerializer.ts
3297
3891
  import { decode, encode } from "@msgpack/msgpack";
3298
3892
  var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3893
+ var MAX_SANITIZE_DEPTH3 = 64;
3894
+ var MAX_SANITIZE_NODES3 = 1e4;
3299
3895
  var MsgpackSerializer = class {
3300
3896
  serialize(value) {
3301
3897
  return Buffer.from(encode(value));
3302
3898
  }
3303
3899
  deserialize(payload) {
3304
3900
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
3305
- return sanitizeMsgpackValue(decode(normalized));
3901
+ return sanitizeMsgpackValue(decode(normalized), 0, { count: 0 });
3306
3902
  }
3307
3903
  };
3308
- function sanitizeMsgpackValue(value) {
3904
+ function sanitizeMsgpackValue(value, depth, state) {
3905
+ state.count += 1;
3906
+ if (state.count > MAX_SANITIZE_NODES3) {
3907
+ throw new Error(`MessagePack payload exceeds max node count of ${MAX_SANITIZE_NODES3}.`);
3908
+ }
3909
+ if (depth > MAX_SANITIZE_DEPTH3) {
3910
+ throw new Error(`MessagePack payload exceeds max depth of ${MAX_SANITIZE_DEPTH3}.`);
3911
+ }
3309
3912
  if (Array.isArray(value)) {
3310
- return value.map((entry) => sanitizeMsgpackValue(entry));
3913
+ return value.map((entry) => sanitizeMsgpackValue(entry, depth + 1, state));
3311
3914
  }
3312
3915
  if (!isPlainObject2(value)) {
3313
3916
  return value;
@@ -3317,7 +3920,7 @@ function sanitizeMsgpackValue(value) {
3317
3920
  if (DANGEROUS_KEYS2.has(key)) {
3318
3921
  continue;
3319
3922
  }
3320
- sanitized[key] = sanitizeMsgpackValue(entry);
3923
+ sanitized[key] = sanitizeMsgpackValue(entry, depth + 1, state);
3321
3924
  }
3322
3925
  return sanitized;
3323
3926
  }