layercache 1.2.5 → 1.2.7

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,33 +15,155 @@ 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";
22
22
 
23
23
  // src/CacheNamespace.ts
24
24
  import { Mutex } from "async-mutex";
25
+
26
+ // src/internal/CacheNamespaceMetrics.ts
27
+ function createEmptyNamespaceMetrics(resetAt = Date.now()) {
28
+ return {
29
+ hits: 0,
30
+ misses: 0,
31
+ fetches: 0,
32
+ sets: 0,
33
+ deletes: 0,
34
+ backfills: 0,
35
+ invalidations: 0,
36
+ staleHits: 0,
37
+ refreshes: 0,
38
+ refreshErrors: 0,
39
+ writeFailures: 0,
40
+ singleFlightWaits: 0,
41
+ negativeCacheHits: 0,
42
+ circuitBreakerTrips: 0,
43
+ degradedOperations: 0,
44
+ hitsByLayer: {},
45
+ missesByLayer: {},
46
+ latencyByLayer: {},
47
+ resetAt
48
+ };
49
+ }
50
+ function cloneNamespaceMetrics(metrics) {
51
+ return {
52
+ ...metrics,
53
+ hitsByLayer: { ...metrics.hitsByLayer },
54
+ missesByLayer: { ...metrics.missesByLayer },
55
+ latencyByLayer: Object.fromEntries(
56
+ Object.entries(metrics.latencyByLayer).map(([key, value]) => [key, { ...value }])
57
+ )
58
+ };
59
+ }
60
+ function diffNamespaceMetrics(before, after) {
61
+ const latencyByLayer = Object.fromEntries(
62
+ Object.entries(after.latencyByLayer).map(([layer, value]) => [
63
+ layer,
64
+ {
65
+ avgMs: value.avgMs,
66
+ maxMs: value.maxMs,
67
+ count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
68
+ }
69
+ ])
70
+ );
71
+ return {
72
+ hits: after.hits - before.hits,
73
+ misses: after.misses - before.misses,
74
+ fetches: after.fetches - before.fetches,
75
+ sets: after.sets - before.sets,
76
+ deletes: after.deletes - before.deletes,
77
+ backfills: after.backfills - before.backfills,
78
+ invalidations: after.invalidations - before.invalidations,
79
+ staleHits: after.staleHits - before.staleHits,
80
+ refreshes: after.refreshes - before.refreshes,
81
+ refreshErrors: after.refreshErrors - before.refreshErrors,
82
+ writeFailures: after.writeFailures - before.writeFailures,
83
+ singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
84
+ negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
85
+ circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
86
+ degradedOperations: after.degradedOperations - before.degradedOperations,
87
+ hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
88
+ missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
89
+ latencyByLayer,
90
+ resetAt: after.resetAt
91
+ };
92
+ }
93
+ function addNamespaceMetrics(base, delta) {
94
+ return {
95
+ hits: base.hits + delta.hits,
96
+ misses: base.misses + delta.misses,
97
+ fetches: base.fetches + delta.fetches,
98
+ sets: base.sets + delta.sets,
99
+ deletes: base.deletes + delta.deletes,
100
+ backfills: base.backfills + delta.backfills,
101
+ invalidations: base.invalidations + delta.invalidations,
102
+ staleHits: base.staleHits + delta.staleHits,
103
+ refreshes: base.refreshes + delta.refreshes,
104
+ refreshErrors: base.refreshErrors + delta.refreshErrors,
105
+ writeFailures: base.writeFailures + delta.writeFailures,
106
+ singleFlightWaits: base.singleFlightWaits + delta.singleFlightWaits,
107
+ negativeCacheHits: base.negativeCacheHits + delta.negativeCacheHits,
108
+ circuitBreakerTrips: base.circuitBreakerTrips + delta.circuitBreakerTrips,
109
+ degradedOperations: base.degradedOperations + delta.degradedOperations,
110
+ hitsByLayer: addMetricMap(base.hitsByLayer, delta.hitsByLayer),
111
+ missesByLayer: addMetricMap(base.missesByLayer, delta.missesByLayer),
112
+ latencyByLayer: cloneNamespaceMetrics(delta).latencyByLayer,
113
+ resetAt: base.resetAt
114
+ };
115
+ }
116
+ function computeNamespaceHitRate(metrics) {
117
+ const total = metrics.hits + metrics.misses;
118
+ const overall = total === 0 ? 0 : metrics.hits / total;
119
+ const byLayer = {};
120
+ const layers = /* @__PURE__ */ new Set([...Object.keys(metrics.hitsByLayer), ...Object.keys(metrics.missesByLayer)]);
121
+ for (const layer of layers) {
122
+ const hits = metrics.hitsByLayer[layer] ?? 0;
123
+ const misses = metrics.missesByLayer[layer] ?? 0;
124
+ byLayer[layer] = hits + misses === 0 ? 0 : hits / (hits + misses);
125
+ }
126
+ return { overall, byLayer };
127
+ }
128
+ function diffMetricMap(before, after) {
129
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
130
+ const result = {};
131
+ for (const key of keys) {
132
+ result[key] = (after[key] ?? 0) - (before[key] ?? 0);
133
+ }
134
+ return result;
135
+ }
136
+ function addMetricMap(base, delta) {
137
+ const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
138
+ const result = {};
139
+ for (const key of keys) {
140
+ result[key] = (base[key] ?? 0) + (delta[key] ?? 0);
141
+ }
142
+ return result;
143
+ }
144
+
145
+ // src/CacheNamespace.ts
25
146
  var CacheNamespace = class _CacheNamespace {
26
147
  constructor(cache, prefix) {
27
148
  this.cache = cache;
28
149
  this.prefix = prefix;
150
+ validateNamespaceKey(prefix);
29
151
  }
30
152
  cache;
31
153
  prefix;
32
154
  static metricsMutexes = /* @__PURE__ */ new WeakMap();
33
- metrics = emptyMetrics();
155
+ metrics = createEmptyNamespaceMetrics();
34
156
  async get(key, fetcher, options) {
35
- return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, options));
157
+ return this.trackMetrics(() => this.cache.get(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
36
158
  }
37
159
  async getOrSet(key, fetcher, options) {
38
- return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, options));
160
+ return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
39
161
  }
40
162
  /**
41
163
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
42
164
  */
43
165
  async getOrThrow(key, fetcher, options) {
44
- return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, options));
166
+ return this.trackMetrics(() => this.cache.getOrThrow(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
45
167
  }
46
168
  async has(key) {
47
169
  return this.trackMetrics(() => this.cache.has(this.qualify(key)));
@@ -50,7 +172,7 @@ var CacheNamespace = class _CacheNamespace {
50
172
  return this.trackMetrics(() => this.cache.ttl(this.qualify(key)));
51
173
  }
52
174
  async set(key, value, options) {
53
- await this.trackMetrics(() => this.cache.set(this.qualify(key), value, options));
175
+ await this.trackMetrics(() => this.cache.set(this.qualify(key), value, this.qualifyWriteOptions(options)));
54
176
  }
55
177
  async delete(key) {
56
178
  await this.trackMetrics(() => this.cache.delete(this.qualify(key)));
@@ -66,7 +188,8 @@ var CacheNamespace = class _CacheNamespace {
66
188
  () => this.cache.mget(
67
189
  entries.map((entry) => ({
68
190
  ...entry,
69
- key: this.qualify(entry.key)
191
+ key: this.qualify(entry.key),
192
+ options: this.qualifyGetOptions(entry.options)
70
193
  }))
71
194
  )
72
195
  );
@@ -76,16 +199,22 @@ var CacheNamespace = class _CacheNamespace {
76
199
  () => this.cache.mset(
77
200
  entries.map((entry) => ({
78
201
  ...entry,
79
- key: this.qualify(entry.key)
202
+ key: this.qualify(entry.key),
203
+ options: this.qualifyWriteOptions(entry.options)
80
204
  }))
81
205
  )
82
206
  );
83
207
  }
84
208
  async invalidateByTag(tag) {
85
- await this.trackMetrics(() => this.cache.invalidateByTag(tag));
209
+ await this.trackMetrics(() => this.cache.invalidateByTag(this.qualifyTag(tag)));
86
210
  }
87
211
  async invalidateByTags(tags, mode = "any") {
88
- await this.trackMetrics(() => this.cache.invalidateByTags(tags, mode));
212
+ await this.trackMetrics(
213
+ () => this.cache.invalidateByTags(
214
+ tags.map((tag) => this.qualifyTag(tag)),
215
+ mode
216
+ )
217
+ );
89
218
  }
90
219
  async invalidateByPattern(pattern) {
91
220
  await this.trackMetrics(() => this.cache.invalidateByPattern(this.qualify(pattern)));
@@ -97,34 +226,33 @@ var CacheNamespace = class _CacheNamespace {
97
226
  * Returns detailed metadata about a single cache key within this namespace.
98
227
  */
99
228
  async inspect(key) {
100
- return this.cache.inspect(this.qualify(key));
229
+ const result = await this.cache.inspect(this.qualify(key));
230
+ if (result === null) {
231
+ return null;
232
+ }
233
+ return {
234
+ ...result,
235
+ tags: result.tags.filter((tag) => tag.startsWith(`${this.prefix}:`)).map((tag) => tag.slice(this.prefix.length + 1))
236
+ };
101
237
  }
102
238
  wrap(keyPrefix, fetcher, options) {
103
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
239
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, this.qualifyWrapOptions(options));
104
240
  }
105
241
  warm(entries, options) {
106
242
  return this.cache.warm(
107
243
  entries.map((entry) => ({
108
244
  ...entry,
109
- key: this.qualify(entry.key)
245
+ key: this.qualify(entry.key),
246
+ options: this.qualifyGetOptions(entry.options)
110
247
  })),
111
248
  options
112
249
  );
113
250
  }
114
251
  getMetrics() {
115
- return cloneMetrics(this.metrics);
252
+ return cloneNamespaceMetrics(this.metrics);
116
253
  }
117
254
  getHitRate() {
118
- const total = this.metrics.hits + this.metrics.misses;
119
- const overall = total === 0 ? 0 : this.metrics.hits / total;
120
- const byLayer = {};
121
- const layers = /* @__PURE__ */ new Set([...Object.keys(this.metrics.hitsByLayer), ...Object.keys(this.metrics.missesByLayer)]);
122
- for (const layer of layers) {
123
- const hits = this.metrics.hitsByLayer[layer] ?? 0;
124
- const misses = this.metrics.missesByLayer[layer] ?? 0;
125
- byLayer[layer] = hits + misses === 0 ? 0 : hits / (hits + misses);
126
- }
127
- return { overall, byLayer };
255
+ return computeNamespaceHitRate(this.metrics);
128
256
  }
129
257
  /**
130
258
  * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
@@ -142,12 +270,30 @@ var CacheNamespace = class _CacheNamespace {
142
270
  qualify(key) {
143
271
  return `${this.prefix}:${key}`;
144
272
  }
273
+ qualifyTag(tag) {
274
+ return `${this.prefix}:${tag}`;
275
+ }
276
+ qualifyGetOptions(options) {
277
+ return this.qualifyWriteOptions(options);
278
+ }
279
+ qualifyWrapOptions(options) {
280
+ return this.qualifyWriteOptions(options);
281
+ }
282
+ qualifyWriteOptions(options) {
283
+ if (!options?.tags || options.tags.length === 0) {
284
+ return options;
285
+ }
286
+ return {
287
+ ...options,
288
+ tags: options.tags.map((tag) => this.qualifyTag(tag))
289
+ };
290
+ }
145
291
  async trackMetrics(operation) {
146
292
  return this.getMetricsMutex().runExclusive(async () => {
147
293
  const before = this.cache.getMetrics();
148
294
  const result = await operation();
149
295
  const after = this.cache.getMetrics();
150
- this.metrics = addMetrics(this.metrics, diffMetrics(before, after));
296
+ this.metrics = addNamespaceMetrics(this.metrics, diffNamespaceMetrics(before, after));
151
297
  return result;
152
298
  });
153
299
  }
@@ -161,175 +307,567 @@ var CacheNamespace = class _CacheNamespace {
161
307
  return mutex;
162
308
  }
163
309
  };
164
- function emptyMetrics() {
165
- return {
166
- hits: 0,
167
- misses: 0,
168
- fetches: 0,
169
- sets: 0,
170
- deletes: 0,
171
- backfills: 0,
172
- invalidations: 0,
173
- staleHits: 0,
174
- refreshes: 0,
175
- refreshErrors: 0,
176
- writeFailures: 0,
177
- singleFlightWaits: 0,
178
- negativeCacheHits: 0,
179
- circuitBreakerTrips: 0,
180
- degradedOperations: 0,
181
- hitsByLayer: {},
182
- missesByLayer: {},
183
- latencyByLayer: {},
184
- resetAt: Date.now()
185
- };
310
+ function validateNamespaceKey(key) {
311
+ if (key.length === 0) {
312
+ throw new Error("Namespace prefix must not be empty.");
313
+ }
314
+ if (key.length > 256) {
315
+ throw new Error("Namespace prefix must be at most 256 characters.");
316
+ }
317
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
318
+ throw new Error("Namespace prefix contains unsupported control characters.");
319
+ }
320
+ if (/[\uD800-\uDFFF]/.test(key)) {
321
+ throw new Error("Namespace prefix contains unsupported surrogate code points.");
322
+ }
323
+ }
324
+
325
+ // src/internal/CacheKeyDiscovery.ts
326
+ var CacheKeyDiscovery = class {
327
+ constructor(options) {
328
+ this.options = options;
329
+ }
330
+ options;
331
+ async collectKeysWithPrefix(prefix, maxMatches = false) {
332
+ const { tagIndex } = this.options;
333
+ const matches = /* @__PURE__ */ new Set();
334
+ if (tagIndex.forEachKeyForPrefix) {
335
+ await tagIndex.forEachKeyForPrefix(prefix, async (key) => {
336
+ matches.add(key);
337
+ this.assertWithinMatchLimit(matches, maxMatches);
338
+ });
339
+ } else {
340
+ const initialMatches = tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`);
341
+ for (const key of initialMatches) {
342
+ matches.add(key);
343
+ this.assertWithinMatchLimit(matches, maxMatches);
344
+ }
345
+ }
346
+ await Promise.all(
347
+ this.options.layers.map(async (layer) => {
348
+ if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
349
+ return;
350
+ }
351
+ try {
352
+ if (layer.forEachKey) {
353
+ await layer.forEachKey(async (key) => {
354
+ if (key.startsWith(prefix)) {
355
+ matches.add(key);
356
+ this.assertWithinMatchLimit(matches, maxMatches);
357
+ }
358
+ });
359
+ return;
360
+ }
361
+ const keys = await layer.keys?.();
362
+ for (const key of keys ?? []) {
363
+ if (key.startsWith(prefix)) {
364
+ matches.add(key);
365
+ this.assertWithinMatchLimit(matches, maxMatches);
366
+ }
367
+ }
368
+ } catch (error) {
369
+ await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
370
+ }
371
+ })
372
+ );
373
+ return [...matches];
374
+ }
375
+ async collectKeysMatchingPattern(pattern, maxMatches = false) {
376
+ const matches = /* @__PURE__ */ new Set();
377
+ if (this.options.tagIndex.forEachKeyMatchingPattern) {
378
+ await this.options.tagIndex.forEachKeyMatchingPattern(pattern, async (key) => {
379
+ matches.add(key);
380
+ this.assertWithinMatchLimit(matches, maxMatches);
381
+ });
382
+ } else {
383
+ for (const key of await this.options.tagIndex.matchPattern(pattern)) {
384
+ matches.add(key);
385
+ this.assertWithinMatchLimit(matches, maxMatches);
386
+ }
387
+ }
388
+ await Promise.all(
389
+ this.options.layers.map(async (layer) => {
390
+ if (!layer.keys && !layer.forEachKey || this.options.shouldSkipLayer(layer)) {
391
+ return;
392
+ }
393
+ try {
394
+ if (layer.forEachKey) {
395
+ await layer.forEachKey(async (key) => {
396
+ if (PatternMatcher.matches(pattern, key)) {
397
+ matches.add(key);
398
+ this.assertWithinMatchLimit(matches, maxMatches);
399
+ }
400
+ });
401
+ return;
402
+ }
403
+ const keys = await layer.keys?.();
404
+ for (const key of keys ?? []) {
405
+ if (PatternMatcher.matches(pattern, key)) {
406
+ matches.add(key);
407
+ this.assertWithinMatchLimit(matches, maxMatches);
408
+ }
409
+ }
410
+ } catch (error) {
411
+ await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
412
+ }
413
+ })
414
+ );
415
+ return [...matches];
416
+ }
417
+ assertWithinMatchLimit(matches, maxMatches) {
418
+ if (maxMatches !== false && matches.size > maxMatches) {
419
+ throw new Error(`Invalidation matched too many keys (${matches.size} > ${maxMatches}).`);
420
+ }
421
+ }
422
+ };
423
+
424
+ // src/internal/CacheKeySerialization.ts
425
+ var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
426
+ function normalizeForSerialization(value) {
427
+ if (Array.isArray(value)) {
428
+ return value.map((entry) => normalizeForSerialization(entry));
429
+ }
430
+ if (value && typeof value === "object") {
431
+ return Object.keys(value).sort().reduce((normalized, key) => {
432
+ if (DANGEROUS_OBJECT_KEYS.has(key)) {
433
+ return normalized;
434
+ }
435
+ normalized[key] = normalizeForSerialization(value[key]);
436
+ return normalized;
437
+ }, {});
438
+ }
439
+ return value;
440
+ }
441
+ function serializeKeyPart(value) {
442
+ if (typeof value === "string") {
443
+ return `s:${value}`;
444
+ }
445
+ if (typeof value === "number") {
446
+ return `n:${value}`;
447
+ }
448
+ if (typeof value === "boolean") {
449
+ return `b:${value}`;
450
+ }
451
+ return `j:${JSON.stringify(normalizeForSerialization(value))}`;
452
+ }
453
+ function serializeOptions(options) {
454
+ return JSON.stringify(normalizeForSerialization(options) ?? null);
455
+ }
456
+ function createInstanceId() {
457
+ if (globalThis.crypto?.randomUUID) {
458
+ return globalThis.crypto.randomUUID();
459
+ }
460
+ const bytes = new Uint8Array(16);
461
+ if (globalThis.crypto?.getRandomValues) {
462
+ globalThis.crypto.getRandomValues(bytes);
463
+ } else {
464
+ for (let i = 0; i < bytes.length; i += 1) {
465
+ bytes[i] = Math.floor(Math.random() * 256);
466
+ }
467
+ }
468
+ return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
469
+ }
470
+
471
+ // src/internal/CacheSnapshotFile.ts
472
+ function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
473
+ const relative = path.relative(realBaseDir, candidatePath);
474
+ return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
475
+ }
476
+ async function findExistingAncestor(directory, fs2, path) {
477
+ let current = directory;
478
+ while (true) {
479
+ try {
480
+ await fs2.lstat(current);
481
+ return current;
482
+ } catch (error) {
483
+ if (error.code !== "ENOENT") {
484
+ throw error;
485
+ }
486
+ }
487
+ const parent = path.dirname(current);
488
+ if (parent === current) {
489
+ return current;
490
+ }
491
+ current = parent;
492
+ }
493
+ }
494
+ async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
495
+ if (filePath.length === 0) {
496
+ throw new Error("filePath must not be empty.");
497
+ }
498
+ if (filePath.includes("\0")) {
499
+ throw new Error("filePath must not contain null bytes.");
500
+ }
501
+ const { promises: fs2 } = await import("fs");
502
+ const path = await import("path");
503
+ const resolved = path.resolve(filePath);
504
+ const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
505
+ if (baseDir === false) {
506
+ return resolved;
507
+ }
508
+ await fs2.mkdir(baseDir, { recursive: true });
509
+ const realBaseDir = await fs2.realpath(baseDir);
510
+ if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
511
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
512
+ }
513
+ if (mode === "read") {
514
+ const realTarget = await fs2.realpath(resolved);
515
+ if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
516
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
517
+ }
518
+ return realTarget;
519
+ }
520
+ const parentDir = path.dirname(resolved);
521
+ const existingAncestor = await findExistingAncestor(parentDir, fs2, path);
522
+ const realExistingAncestor = await fs2.realpath(existingAncestor);
523
+ if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
524
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
525
+ }
526
+ await fs2.mkdir(parentDir, { recursive: true });
527
+ const realParentDir = await fs2.realpath(parentDir);
528
+ if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
529
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
530
+ }
531
+ const targetPath = path.join(realParentDir, path.basename(resolved));
532
+ try {
533
+ const existing = await fs2.lstat(targetPath);
534
+ if (existing.isSymbolicLink()) {
535
+ throw new Error("filePath must not point to a symbolic link.");
536
+ }
537
+ } catch (error) {
538
+ if (error.code !== "ENOENT") {
539
+ throw error;
540
+ }
541
+ }
542
+ return targetPath;
543
+ }
544
+ async function readUtf8HandleWithLimit(handle, byteLimit) {
545
+ if (byteLimit === false) {
546
+ return handle.readFile({ encoding: "utf8" });
547
+ }
548
+ const chunks = [];
549
+ let totalBytes = 0;
550
+ let position = 0;
551
+ while (true) {
552
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
553
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
554
+ if (bytesRead === 0) {
555
+ break;
556
+ }
557
+ totalBytes += bytesRead;
558
+ if (totalBytes > byteLimit) {
559
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
560
+ }
561
+ chunks.push(buffer.subarray(0, bytesRead));
562
+ position += bytesRead;
563
+ }
564
+ return Buffer.concat(chunks).toString("utf8");
565
+ }
566
+
567
+ // src/internal/CacheStackGeneration.ts
568
+ var DEFAULT_GENERATION_CLEANUP_BATCH_SIZE = 500;
569
+ function generationPrefix(generation) {
570
+ return generation === void 0 ? "" : `v${generation}:`;
571
+ }
572
+ function qualifyGenerationKey(key, generation) {
573
+ const prefix = generationPrefix(generation);
574
+ return prefix ? `${prefix}${key}` : key;
575
+ }
576
+ function qualifyGenerationPattern(pattern, generation) {
577
+ return qualifyGenerationKey(pattern, generation);
578
+ }
579
+ function stripGenerationPrefix(key, generation) {
580
+ const prefix = generationPrefix(generation);
581
+ if (!prefix || !key.startsWith(prefix)) {
582
+ return key;
583
+ }
584
+ return key.slice(prefix.length);
585
+ }
586
+ function resolveGenerationCleanupTarget({
587
+ previousGeneration,
588
+ nextGeneration,
589
+ generationCleanup
590
+ }) {
591
+ if (!generationCleanup || previousGeneration === void 0 || previousGeneration === nextGeneration) {
592
+ return null;
593
+ }
594
+ return previousGeneration;
595
+ }
596
+ function resolveGenerationCleanupBatchSize(generationCleanup) {
597
+ if (typeof generationCleanup !== "object" || generationCleanup === null) {
598
+ return DEFAULT_GENERATION_CLEANUP_BATCH_SIZE;
599
+ }
600
+ return generationCleanup.batchSize ?? DEFAULT_GENERATION_CLEANUP_BATCH_SIZE;
601
+ }
602
+ function planGenerationCleanupBatches(keys, generationCleanup) {
603
+ if (keys.length === 0) {
604
+ return [];
605
+ }
606
+ const batchSize = resolveGenerationCleanupBatchSize(generationCleanup);
607
+ const batches = [];
608
+ for (let index = 0; index < keys.length; index += batchSize) {
609
+ batches.push(keys.slice(index, index + batchSize));
610
+ }
611
+ return batches;
612
+ }
613
+
614
+ // src/internal/CacheStackMaintenance.ts
615
+ var CacheStackMaintenance = class {
616
+ keyEpochs = /* @__PURE__ */ new Map();
617
+ writeBehindQueue = [];
618
+ writeBehindTimer;
619
+ writeBehindFlushPromise;
620
+ generationCleanupPromise;
621
+ clearEpoch = 0;
622
+ initializeWriteBehindTimer(writeStrategy, options, flush) {
623
+ if (writeStrategy !== "write-behind") {
624
+ return;
625
+ }
626
+ const flushIntervalMs = options?.flushIntervalMs;
627
+ if (!flushIntervalMs || flushIntervalMs <= 0) {
628
+ return;
629
+ }
630
+ this.disposeWriteBehindTimer();
631
+ this.writeBehindTimer = setInterval(() => {
632
+ void flush();
633
+ }, flushIntervalMs);
634
+ this.writeBehindTimer.unref?.();
635
+ }
636
+ disposeWriteBehindTimer() {
637
+ if (!this.writeBehindTimer) {
638
+ return;
639
+ }
640
+ clearInterval(this.writeBehindTimer);
641
+ this.writeBehindTimer = void 0;
642
+ }
643
+ beginClearEpoch() {
644
+ this.clearEpoch += 1;
645
+ this.keyEpochs.clear();
646
+ this.writeBehindQueue.length = 0;
647
+ }
648
+ currentClearEpoch() {
649
+ return this.clearEpoch;
650
+ }
651
+ currentKeyEpoch(key) {
652
+ return this.keyEpochs.get(key) ?? 0;
653
+ }
654
+ bumpKeyEpochs(keys) {
655
+ for (const key of keys) {
656
+ this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
657
+ }
658
+ }
659
+ isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
660
+ if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
661
+ return true;
662
+ }
663
+ if (expectedKeyEpoch !== void 0 && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
664
+ return true;
665
+ }
666
+ return false;
667
+ }
668
+ async enqueueWriteBehind(operation, options, flushBatch) {
669
+ this.writeBehindQueue.push(operation);
670
+ const batchSize = options?.batchSize ?? 100;
671
+ const maxQueueSize = options?.maxQueueSize ?? batchSize * 10;
672
+ if (this.writeBehindQueue.length >= batchSize) {
673
+ await this.flushWriteBehindQueue(options, flushBatch);
674
+ return;
675
+ }
676
+ if (this.writeBehindQueue.length >= maxQueueSize) {
677
+ await this.flushWriteBehindQueue(options, flushBatch);
678
+ }
679
+ }
680
+ async flushWriteBehindQueue(options, flushBatch) {
681
+ if (this.writeBehindFlushPromise || this.writeBehindQueue.length === 0) {
682
+ await this.writeBehindFlushPromise;
683
+ return;
684
+ }
685
+ const batchSize = options?.batchSize ?? 100;
686
+ const batch = this.writeBehindQueue.splice(0, batchSize);
687
+ this.writeBehindFlushPromise = flushBatch(batch);
688
+ try {
689
+ await this.writeBehindFlushPromise;
690
+ } finally {
691
+ this.writeBehindFlushPromise = void 0;
692
+ }
693
+ if (this.writeBehindQueue.length > 0) {
694
+ await this.flushWriteBehindQueue(options, flushBatch);
695
+ }
696
+ }
697
+ scheduleGenerationCleanup(generation, task, onError) {
698
+ const scheduledTask = (this.generationCleanupPromise ?? Promise.resolve()).then(() => task(generation)).catch((error) => {
699
+ onError(generation, error);
700
+ });
701
+ this.generationCleanupPromise = scheduledTask.finally(() => {
702
+ if (this.generationCleanupPromise === scheduledTask) {
703
+ this.generationCleanupPromise = void 0;
704
+ }
705
+ });
706
+ }
707
+ async waitForGenerationCleanup() {
708
+ await this.generationCleanupPromise;
709
+ }
710
+ };
711
+
712
+ // src/internal/CacheStackRuntimePolicy.ts
713
+ function shouldSkipLayer(degradedUntil, now = Date.now()) {
714
+ return degradedUntil !== void 0 && degradedUntil > now;
186
715
  }
187
- function cloneMetrics(metrics) {
188
- return {
189
- ...metrics,
190
- hitsByLayer: { ...metrics.hitsByLayer },
191
- missesByLayer: { ...metrics.missesByLayer },
192
- latencyByLayer: Object.fromEntries(
193
- Object.entries(metrics.latencyByLayer).map(([key, value]) => [key, { ...value }])
194
- )
195
- };
716
+ function shouldStartBackgroundRefresh({
717
+ isDisconnecting,
718
+ hasRefreshInFlight
719
+ }) {
720
+ return !isDisconnecting && !hasRefreshInFlight;
196
721
  }
197
- function diffMetrics(before, after) {
198
- const latencyByLayer = Object.fromEntries(
199
- Object.entries(after.latencyByLayer).map(([layer, value]) => [
200
- layer,
201
- {
202
- avgMs: value.avgMs,
203
- maxMs: value.maxMs,
204
- count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
205
- }
206
- ])
207
- );
722
+ function resolveRecoverableLayerFailure(gracefulDegradation, now = Date.now()) {
723
+ if (!gracefulDegradation) {
724
+ return { degrade: false };
725
+ }
726
+ const retryAfterMs = typeof gracefulDegradation === "object" ? gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
208
727
  return {
209
- hits: after.hits - before.hits,
210
- misses: after.misses - before.misses,
211
- fetches: after.fetches - before.fetches,
212
- sets: after.sets - before.sets,
213
- deletes: after.deletes - before.deletes,
214
- backfills: after.backfills - before.backfills,
215
- invalidations: after.invalidations - before.invalidations,
216
- staleHits: after.staleHits - before.staleHits,
217
- refreshes: after.refreshes - before.refreshes,
218
- refreshErrors: after.refreshErrors - before.refreshErrors,
219
- writeFailures: after.writeFailures - before.writeFailures,
220
- singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
221
- negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
222
- circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
223
- degradedOperations: after.degradedOperations - before.degradedOperations,
224
- hitsByLayer: diffMap(before.hitsByLayer, after.hitsByLayer),
225
- missesByLayer: diffMap(before.missesByLayer, after.missesByLayer),
226
- latencyByLayer,
227
- resetAt: after.resetAt
728
+ degrade: true,
729
+ degradedUntil: now + retryAfterMs
228
730
  };
229
731
  }
230
- function addMetrics(base, delta) {
732
+ function planFreshReadPolicies({
733
+ stored,
734
+ hasFetcher,
735
+ slidingTtl,
736
+ refreshAheadSeconds
737
+ }) {
738
+ const refreshedStored = slidingTtl && isStoredValueEnvelope(stored) ? refreshStoredEnvelope(stored) : void 0;
739
+ const refreshedStoredTtl = refreshedStored ? remainingStoredTtlSeconds(refreshedStored) ?? void 0 : void 0;
740
+ const remainingFreshTtl = remainingFreshTtlSeconds(stored) ?? 0;
231
741
  return {
232
- hits: base.hits + delta.hits,
233
- misses: base.misses + delta.misses,
234
- fetches: base.fetches + delta.fetches,
235
- sets: base.sets + delta.sets,
236
- deletes: base.deletes + delta.deletes,
237
- backfills: base.backfills + delta.backfills,
238
- invalidations: base.invalidations + delta.invalidations,
239
- staleHits: base.staleHits + delta.staleHits,
240
- refreshes: base.refreshes + delta.refreshes,
241
- refreshErrors: base.refreshErrors + delta.refreshErrors,
242
- writeFailures: base.writeFailures + delta.writeFailures,
243
- singleFlightWaits: base.singleFlightWaits + delta.singleFlightWaits,
244
- negativeCacheHits: base.negativeCacheHits + delta.negativeCacheHits,
245
- circuitBreakerTrips: base.circuitBreakerTrips + delta.circuitBreakerTrips,
246
- degradedOperations: base.degradedOperations + delta.degradedOperations,
247
- hitsByLayer: addMap(base.hitsByLayer, delta.hitsByLayer),
248
- missesByLayer: addMap(base.missesByLayer, delta.missesByLayer),
249
- latencyByLayer: cloneMetrics(delta).latencyByLayer,
250
- resetAt: base.resetAt
742
+ refreshedStored,
743
+ refreshedStoredTtl,
744
+ shouldScheduleBackgroundRefresh: hasFetcher && refreshAheadSeconds > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAheadSeconds
251
745
  };
252
746
  }
253
- function diffMap(before, after) {
254
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
255
- const result = {};
256
- for (const key of keys) {
257
- result[key] = (after[key] ?? 0) - (before[key] ?? 0);
747
+
748
+ // src/internal/CacheStackValidation.ts
749
+ var MAX_CACHE_KEY_LENGTH = 1024;
750
+ var MAX_PATTERN_LENGTH = 1024;
751
+ var MAX_TAGS_PER_OPERATION = 128;
752
+ function validatePositiveNumber(name, value) {
753
+ if (value === void 0) {
754
+ return;
755
+ }
756
+ if (!Number.isFinite(value) || value <= 0) {
757
+ throw new Error(`${name} must be a positive finite number.`);
258
758
  }
259
- return result;
260
759
  }
261
- function addMap(base, delta) {
262
- const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
263
- const result = {};
264
- for (const key of keys) {
265
- result[key] = (base[key] ?? 0) + (delta[key] ?? 0);
760
+ function validateNonNegativeNumber(name, value) {
761
+ if (!Number.isFinite(value) || value < 0) {
762
+ throw new Error(`${name} must be a non-negative finite number.`);
266
763
  }
267
- return result;
268
764
  }
269
- function validateNamespaceKey(key) {
765
+ function validateLayerNumberOption(name, value) {
766
+ if (value === void 0) {
767
+ return;
768
+ }
769
+ if (typeof value === "number") {
770
+ validateNonNegativeNumber(name, value);
771
+ return;
772
+ }
773
+ for (const [layerName, layerValue] of Object.entries(value)) {
774
+ if (layerValue === void 0) {
775
+ continue;
776
+ }
777
+ validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
778
+ }
779
+ }
780
+ function validateRateLimitOptions(name, options) {
781
+ if (!options) {
782
+ return;
783
+ }
784
+ validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
785
+ validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
786
+ validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
787
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
788
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
789
+ }
790
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
791
+ throw new Error(`${name}.bucketKey must not be empty.`);
792
+ }
793
+ }
794
+ function validateCacheKey(key) {
270
795
  if (key.length === 0) {
271
- throw new Error("Namespace prefix must not be empty.");
796
+ throw new Error("Cache key must not be empty.");
272
797
  }
273
- if (key.length > 256) {
274
- throw new Error("Namespace prefix must be at most 256 characters.");
798
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
799
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
275
800
  }
276
801
  if (/[\u0000-\u001F\u007F]/.test(key)) {
277
- throw new Error("Namespace prefix contains unsupported control characters.");
802
+ throw new Error("Cache key contains unsupported control characters.");
803
+ }
804
+ if (/[\uD800-\uDFFF]/.test(key)) {
805
+ throw new Error("Cache key contains unsupported surrogate code points.");
278
806
  }
807
+ return key;
279
808
  }
280
-
281
- // src/internal/CacheKeyDiscovery.ts
282
- var CacheKeyDiscovery = class {
283
- constructor(options) {
284
- this.options = options;
809
+ function validateTag(tag) {
810
+ if (tag.length === 0) {
811
+ throw new Error("Cache tag must not be empty.");
285
812
  }
286
- options;
287
- async collectKeysWithPrefix(prefix) {
288
- const { tagIndex } = this.options;
289
- const matches = new Set(
290
- tagIndex.keysForPrefix ? await tagIndex.keysForPrefix(prefix) : await tagIndex.matchPattern(`${prefix}*`)
291
- );
292
- await Promise.all(
293
- this.options.layers.map(async (layer) => {
294
- if (!layer.keys || this.options.shouldSkipLayer(layer)) {
295
- return;
296
- }
297
- try {
298
- const keys = await layer.keys();
299
- for (const key of keys) {
300
- if (key.startsWith(prefix)) {
301
- matches.add(key);
302
- }
303
- }
304
- } catch (error) {
305
- await this.options.handleLayerFailure(layer, "invalidate-prefix-scan", error);
306
- }
307
- })
308
- );
309
- return [...matches];
813
+ if (tag.length > MAX_CACHE_KEY_LENGTH) {
814
+ throw new Error(`Cache tag length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
310
815
  }
311
- async collectKeysMatchingPattern(pattern) {
312
- const matches = new Set(await this.options.tagIndex.matchPattern(pattern));
313
- await Promise.all(
314
- this.options.layers.map(async (layer) => {
315
- if (!layer.keys || this.options.shouldSkipLayer(layer)) {
316
- return;
317
- }
318
- try {
319
- const keys = await layer.keys();
320
- for (const key of keys) {
321
- if (PatternMatcher.matches(pattern, key)) {
322
- matches.add(key);
323
- }
324
- }
325
- } catch (error) {
326
- await this.options.handleLayerFailure(layer, "invalidate-pattern-scan", error);
327
- }
328
- })
329
- );
330
- return [...matches];
816
+ if (/[\u0000-\u001F\u007F]/.test(tag)) {
817
+ throw new Error("Cache tag contains unsupported control characters.");
331
818
  }
332
- };
819
+ if (/[\uD800-\uDFFF]/.test(tag)) {
820
+ throw new Error("Cache tag contains unsupported surrogate code points.");
821
+ }
822
+ return tag;
823
+ }
824
+ function validateTags(tags) {
825
+ if (!tags) {
826
+ return;
827
+ }
828
+ if (tags.length > MAX_TAGS_PER_OPERATION) {
829
+ throw new Error(`options.tags must contain at most ${MAX_TAGS_PER_OPERATION} tags.`);
830
+ }
831
+ for (const tag of tags) {
832
+ validateTag(tag);
833
+ }
834
+ }
835
+ function validatePattern(pattern) {
836
+ if (pattern.length === 0) {
837
+ throw new Error("Pattern must not be empty.");
838
+ }
839
+ if (pattern.length > MAX_PATTERN_LENGTH) {
840
+ throw new Error(`Pattern length must be at most ${MAX_PATTERN_LENGTH} characters.`);
841
+ }
842
+ if (/[\u0000-\u001F\u007F]/.test(pattern)) {
843
+ throw new Error("Pattern contains unsupported control characters.");
844
+ }
845
+ }
846
+ function validateTtlPolicy(name, policy) {
847
+ if (!policy || typeof policy === "function" || policy === "until-midnight" || policy === "next-hour") {
848
+ return;
849
+ }
850
+ if ("alignTo" in policy) {
851
+ validatePositiveNumber(`${name}.alignTo`, policy.alignTo);
852
+ return;
853
+ }
854
+ throw new Error(`${name} is invalid.`);
855
+ }
856
+ function validateAdaptiveTtlOptions(options) {
857
+ if (!options || options === true) {
858
+ return;
859
+ }
860
+ validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
861
+ validateLayerNumberOption("adaptiveTtl.step", options.step);
862
+ validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
863
+ }
864
+ function validateCircuitBreakerOptions(options) {
865
+ if (!options) {
866
+ return;
867
+ }
868
+ validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
869
+ validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
870
+ }
333
871
 
334
872
  // src/internal/CircuitBreakerManager.ts
335
873
  var CircuitBreakerManager = class {
@@ -360,7 +898,6 @@ var CircuitBreakerManager = class {
360
898
  if (!options) {
361
899
  return;
362
900
  }
363
- this.pruneIfNeeded();
364
901
  const failureThreshold = options.failureThreshold ?? 3;
365
902
  const cooldownMs = options.cooldownMs ?? 3e4;
366
903
  const state = this.breakers.get(key) ?? { failures: 0, openUntil: null, createdAt: Date.now() };
@@ -369,6 +906,7 @@ var CircuitBreakerManager = class {
369
906
  state.openUntil = Date.now() + cooldownMs;
370
907
  }
371
908
  this.breakers.set(key, state);
909
+ this.pruneIfNeeded();
372
910
  }
373
911
  recordSuccess(key) {
374
912
  this.breakers.delete(key);
@@ -825,22 +1363,27 @@ var TtlResolver = class {
825
1363
 
826
1364
  // src/serialization/JsonSerializer.ts
827
1365
  var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1366
+ var MAX_SANITIZE_NODES = 1e4;
828
1367
  var JsonSerializer = class {
829
1368
  serialize(value) {
830
1369
  return JSON.stringify(value);
831
1370
  }
832
1371
  deserialize(payload) {
833
1372
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
834
- return sanitizeJsonValue(JSON.parse(normalized), 0);
1373
+ return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
835
1374
  }
836
1375
  };
837
1376
  var MAX_SANITIZE_DEPTH = 200;
838
- function sanitizeJsonValue(value, depth) {
1377
+ function sanitizeJsonValue(value, depth, state) {
1378
+ state.count += 1;
1379
+ if (state.count > MAX_SANITIZE_NODES) {
1380
+ throw new Error(`JSON payload exceeds max node count of ${MAX_SANITIZE_NODES}.`);
1381
+ }
839
1382
  if (depth > MAX_SANITIZE_DEPTH) {
840
- return value;
1383
+ throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
841
1384
  }
842
1385
  if (Array.isArray(value)) {
843
- return value.map((entry) => sanitizeJsonValue(entry, depth + 1));
1386
+ return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
844
1387
  }
845
1388
  if (!isPlainObject(value)) {
846
1389
  return value;
@@ -850,7 +1393,7 @@ function sanitizeJsonValue(value, depth) {
850
1393
  if (DANGEROUS_JSON_KEYS.has(key)) {
851
1394
  continue;
852
1395
  }
853
- sanitized[key] = sanitizeJsonValue(entry, depth + 1);
1396
+ sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
854
1397
  }
855
1398
  return sanitized;
856
1399
  }
@@ -900,10 +1443,11 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
900
1443
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
901
1444
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
902
1445
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
903
- var MAX_CACHE_KEY_LENGTH = 1024;
904
- var MAX_PATTERN_LENGTH = 1024;
1446
+ var DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
1447
+ var DEFAULT_SNAPSHOT_MAX_ENTRIES = 1e4;
1448
+ var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1449
+ var DEFAULT_INVALIDATION_MAX_KEYS = 1e4;
905
1450
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
906
- var DANGEROUS_OBJECT_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
907
1451
  var DebugLogger = class {
908
1452
  enabled;
909
1453
  constructor(enabled) {
@@ -990,13 +1534,10 @@ var CacheStack = class extends EventEmitter {
990
1534
  snapshotSerializer = new JsonSerializer();
991
1535
  backgroundRefreshes = /* @__PURE__ */ new Map();
992
1536
  layerDegradedUntil = /* @__PURE__ */ new Map();
1537
+ maintenance = new CacheStackMaintenance();
993
1538
  ttlResolver;
994
1539
  circuitBreakerManager;
995
1540
  currentGeneration;
996
- writeBehindQueue = [];
997
- writeBehindTimer;
998
- writeBehindFlushPromise;
999
- generationCleanupPromise;
1000
1541
  isDisconnecting = false;
1001
1542
  disconnectPromise;
1002
1543
  /**
@@ -1006,7 +1547,7 @@ var CacheStack = class extends EventEmitter {
1006
1547
  * and no `fetcher` is provided.
1007
1548
  */
1008
1549
  async get(key, fetcher, options) {
1009
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1550
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1010
1551
  this.validateWriteOptions(options);
1011
1552
  await this.awaitStartup("get");
1012
1553
  return this.getPrepared(normalizedKey, fetcher, options);
@@ -1076,7 +1617,7 @@ var CacheStack = class extends EventEmitter {
1076
1617
  * Returns true if the given key exists and is not expired in any layer.
1077
1618
  */
1078
1619
  async has(key) {
1079
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1620
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1080
1621
  await this.awaitStartup("has");
1081
1622
  for (const layer of this.layers) {
1082
1623
  if (this.shouldSkipLayer(layer)) {
@@ -1109,7 +1650,7 @@ var CacheStack = class extends EventEmitter {
1109
1650
  * that has it, or null if the key is not found / has no TTL.
1110
1651
  */
1111
1652
  async ttl(key) {
1112
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1653
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1113
1654
  await this.awaitStartup("ttl");
1114
1655
  for (const layer of this.layers) {
1115
1656
  if (this.shouldSkipLayer(layer)) {
@@ -1131,7 +1672,7 @@ var CacheStack = class extends EventEmitter {
1131
1672
  * Stores a value in all cache layers. Overwrites any existing value.
1132
1673
  */
1133
1674
  async set(key, value, options) {
1134
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1675
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1135
1676
  this.validateWriteOptions(options);
1136
1677
  await this.awaitStartup("set");
1137
1678
  await this.storeEntry(normalizedKey, "value", value, options);
@@ -1140,7 +1681,7 @@ var CacheStack = class extends EventEmitter {
1140
1681
  * Deletes the key from all layers and publishes an invalidation message.
1141
1682
  */
1142
1683
  async delete(key) {
1143
- const normalizedKey = this.qualifyKey(this.validateCacheKey(key));
1684
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1144
1685
  await this.awaitStartup("delete");
1145
1686
  await this.deleteKeys([normalizedKey]);
1146
1687
  await this.publishInvalidation({
@@ -1152,6 +1693,7 @@ var CacheStack = class extends EventEmitter {
1152
1693
  }
1153
1694
  async clear() {
1154
1695
  await this.awaitStartup("clear");
1696
+ this.maintenance.beginClearEpoch();
1155
1697
  await Promise.all(this.layers.map((layer) => layer.clear()));
1156
1698
  await this.tagIndex.clear();
1157
1699
  this.ttlResolver.clearProfiles();
@@ -1168,7 +1710,7 @@ var CacheStack = class extends EventEmitter {
1168
1710
  return;
1169
1711
  }
1170
1712
  await this.awaitStartup("mdelete");
1171
- const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1713
+ const normalizedKeys = keys.map((k) => validateCacheKey(k));
1172
1714
  const cacheKeys = normalizedKeys.map((key) => this.qualifyKey(key));
1173
1715
  await this.deleteKeys(cacheKeys);
1174
1716
  await this.publishInvalidation({
@@ -1185,7 +1727,7 @@ var CacheStack = class extends EventEmitter {
1185
1727
  }
1186
1728
  const normalizedEntries = entries.map((entry) => ({
1187
1729
  ...entry,
1188
- key: this.qualifyKey(this.validateCacheKey(entry.key))
1730
+ key: this.qualifyKey(validateCacheKey(entry.key))
1189
1731
  }));
1190
1732
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1191
1733
  const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
@@ -1194,7 +1736,7 @@ var CacheStack = class extends EventEmitter {
1194
1736
  const pendingReads = /* @__PURE__ */ new Map();
1195
1737
  return Promise.all(
1196
1738
  normalizedEntries.map((entry) => {
1197
- const optionsSignature = this.serializeOptions(entry.options);
1739
+ const optionsSignature = serializeOptions(entry.options);
1198
1740
  const existing = pendingReads.get(entry.key);
1199
1741
  if (!existing) {
1200
1742
  const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
@@ -1263,7 +1805,7 @@ var CacheStack = class extends EventEmitter {
1263
1805
  this.assertActive("mset");
1264
1806
  const normalizedEntries = entries.map((entry) => ({
1265
1807
  ...entry,
1266
- key: this.qualifyKey(this.validateCacheKey(entry.key))
1808
+ key: this.qualifyKey(validateCacheKey(entry.key))
1267
1809
  }));
1268
1810
  normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1269
1811
  await this.awaitStartup("mset");
@@ -1306,7 +1848,7 @@ var CacheStack = class extends EventEmitter {
1306
1848
  */
1307
1849
  wrap(prefix, fetcher, options = {}) {
1308
1850
  return (...args) => {
1309
- const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
1851
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => serializeKeyPart(argument)).join(":");
1310
1852
  const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
1311
1853
  return this.get(key, () => fetcher(...args), options);
1312
1854
  };
@@ -1316,11 +1858,13 @@ var CacheStack = class extends EventEmitter {
1316
1858
  * `prefix:`. Useful for multi-tenant or module-level isolation.
1317
1859
  */
1318
1860
  namespace(prefix) {
1861
+ validateNamespaceKey(prefix);
1319
1862
  return new CacheNamespace(this, prefix);
1320
1863
  }
1321
1864
  async invalidateByTag(tag) {
1865
+ validateTag(tag);
1322
1866
  await this.awaitStartup("invalidateByTag");
1323
- const keys = await this.tagIndex.keysForTag(tag);
1867
+ const keys = await this.collectKeysForTag(tag);
1324
1868
  await this.deleteKeys(keys);
1325
1869
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1326
1870
  }
@@ -1328,23 +1872,28 @@ var CacheStack = class extends EventEmitter {
1328
1872
  if (tags.length === 0) {
1329
1873
  return;
1330
1874
  }
1875
+ validateTags(tags);
1331
1876
  await this.awaitStartup("invalidateByTags");
1332
- const keysByTag = await Promise.all(tags.map((tag) => this.tagIndex.keysForTag(tag)));
1877
+ const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
1333
1878
  const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
1879
+ this.assertWithinInvalidationKeyLimit(keys.length);
1334
1880
  await this.deleteKeys(keys);
1335
1881
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1336
1882
  }
1337
1883
  async invalidateByPattern(pattern) {
1338
- this.validatePattern(pattern);
1884
+ validatePattern(pattern);
1339
1885
  await this.awaitStartup("invalidateByPattern");
1340
- const keys = await this.keyDiscovery.collectKeysMatchingPattern(this.qualifyPattern(pattern));
1886
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(
1887
+ this.qualifyPattern(pattern),
1888
+ this.invalidationMaxKeys()
1889
+ );
1341
1890
  await this.deleteKeys(keys);
1342
1891
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1343
1892
  }
1344
1893
  async invalidateByPrefix(prefix) {
1345
1894
  await this.awaitStartup("invalidateByPrefix");
1346
- const qualifiedPrefix = this.qualifyKey(this.validateCacheKey(prefix));
1347
- const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix);
1895
+ const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
1896
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
1348
1897
  await this.deleteKeys(keys);
1349
1898
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
1350
1899
  }
@@ -1402,9 +1951,15 @@ var CacheStack = class extends EventEmitter {
1402
1951
  bumpGeneration(nextGeneration) {
1403
1952
  const current = this.currentGeneration ?? 0;
1404
1953
  const previousGeneration = this.currentGeneration;
1405
- this.currentGeneration = nextGeneration ?? current + 1;
1406
- if (previousGeneration !== void 0 && previousGeneration !== this.currentGeneration && this.shouldCleanupGenerations()) {
1407
- this.scheduleGenerationCleanup(previousGeneration);
1954
+ const updatedGeneration = nextGeneration ?? current + 1;
1955
+ const generationToCleanup = resolveGenerationCleanupTarget({
1956
+ previousGeneration,
1957
+ nextGeneration: updatedGeneration,
1958
+ generationCleanup: this.options.generationCleanup
1959
+ });
1960
+ this.currentGeneration = updatedGeneration;
1961
+ if (generationToCleanup !== null) {
1962
+ this.scheduleGenerationCleanup(generationToCleanup);
1408
1963
  }
1409
1964
  return this.currentGeneration;
1410
1965
  }
@@ -1414,7 +1969,7 @@ var CacheStack = class extends EventEmitter {
1414
1969
  * Returns `null` if the key does not exist in any layer.
1415
1970
  */
1416
1971
  async inspect(key) {
1417
- const userKey = this.validateCacheKey(key);
1972
+ const userKey = validateCacheKey(key);
1418
1973
  const normalizedKey = this.qualifyKey(userKey);
1419
1974
  await this.awaitStartup("inspect");
1420
1975
  const foundInLayers = [];
@@ -1451,50 +2006,79 @@ var CacheStack = class extends EventEmitter {
1451
2006
  }
1452
2007
  async exportState() {
1453
2008
  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()];
2009
+ const entries = [];
2010
+ await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2011
+ entries.push(entry);
2012
+ });
2013
+ return entries;
1477
2014
  }
1478
2015
  async importState(entries) {
1479
2016
  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
- );
2017
+ const normalizedEntries = entries.map((entry) => ({
2018
+ key: this.qualifyKey(validateCacheKey(entry.key)),
2019
+ value: entry.value,
2020
+ ttl: entry.ttl
2021
+ }));
2022
+ for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
2023
+ const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
2024
+ await Promise.all(
2025
+ batch.map(async (entry) => {
2026
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
2027
+ await this.tagIndex.touch(entry.key);
2028
+ })
2029
+ );
2030
+ }
1487
2031
  }
1488
2032
  async persistToFile(filePath) {
1489
2033
  this.assertActive("persistToFile");
1490
- const snapshot = await this.exportState();
1491
2034
  const { promises: fs2 } = await import("fs");
1492
- await fs2.writeFile(await this.validateSnapshotFilePath(filePath), JSON.stringify(snapshot, null, 2), "utf8");
2035
+ const path = await import("path");
2036
+ const targetPath = await validateSnapshotFilePath(filePath, "write", this.options.snapshotBaseDir);
2037
+ const tempPath = path.join(
2038
+ path.dirname(targetPath),
2039
+ `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
2040
+ );
2041
+ let handle;
2042
+ try {
2043
+ handle = await fs2.open(tempPath, "wx");
2044
+ const openedHandle = handle;
2045
+ await openedHandle.writeFile("[", "utf8");
2046
+ let wroteAny = false;
2047
+ await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2048
+ await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
2049
+ await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
2050
+ wroteAny = true;
2051
+ });
2052
+ await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
2053
+ await openedHandle.close();
2054
+ handle = void 0;
2055
+ await fs2.rename(tempPath, targetPath);
2056
+ } catch (error) {
2057
+ await handle?.close().catch(() => void 0);
2058
+ await fs2.unlink(tempPath).catch(() => void 0);
2059
+ throw error;
2060
+ }
1493
2061
  }
1494
2062
  async restoreFromFile(filePath) {
1495
2063
  this.assertActive("restoreFromFile");
1496
- const { promises: fs2 } = await import("fs");
1497
- const raw = await fs2.readFile(await this.validateSnapshotFilePath(filePath), "utf8");
2064
+ const { promises: fs2, constants } = await import("fs");
2065
+ const validatedPath = await validateSnapshotFilePath(filePath, "read", this.options.snapshotBaseDir);
2066
+ const handle = await fs2.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
2067
+ const snapshotMaxBytes = this.snapshotMaxBytes();
2068
+ let raw;
2069
+ try {
2070
+ if (snapshotMaxBytes !== false) {
2071
+ const stat = await handle.stat();
2072
+ if (stat.size > snapshotMaxBytes) {
2073
+ throw new Error(
2074
+ `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
2075
+ );
2076
+ }
2077
+ }
2078
+ raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes);
2079
+ } finally {
2080
+ await handle.close();
2081
+ }
1498
2082
  let parsed;
1499
2083
  try {
1500
2084
  parsed = JSON.parse(raw);
@@ -1519,12 +2103,9 @@ var CacheStack = class extends EventEmitter {
1519
2103
  await this.startup;
1520
2104
  await this.unsubscribeInvalidation?.();
1521
2105
  await this.flushWriteBehindQueue();
1522
- await this.generationCleanupPromise;
2106
+ await this.maintenance.waitForGenerationCleanup();
1523
2107
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
1524
- if (this.writeBehindTimer) {
1525
- clearInterval(this.writeBehindTimer);
1526
- this.writeBehindTimer = void 0;
1527
- }
2108
+ this.maintenance.disposeWriteBehindTimer();
1528
2109
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
1529
2110
  })();
1530
2111
  }
@@ -1538,14 +2119,14 @@ var CacheStack = class extends EventEmitter {
1538
2119
  await this.handleInvalidationMessage(message);
1539
2120
  });
1540
2121
  }
1541
- async fetchWithGuards(key, fetcher, options) {
2122
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1542
2123
  const fetchTask = async () => {
1543
2124
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
1544
2125
  if (secondHit.found) {
1545
2126
  this.metricsCollector.increment("hits");
1546
2127
  return secondHit.value;
1547
2128
  }
1548
- return this.fetchAndPopulate(key, fetcher, options);
2129
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
1549
2130
  };
1550
2131
  const singleFlightTask = async () => {
1551
2132
  if (!this.options.singleFlightCoordinator) {
@@ -1555,7 +2136,7 @@ var CacheStack = class extends EventEmitter {
1555
2136
  key,
1556
2137
  this.resolveSingleFlightOptions(),
1557
2138
  fetchTask,
1558
- () => this.waitForFreshValue(key, fetcher, options)
2139
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
1559
2140
  );
1560
2141
  };
1561
2142
  if (this.options.stampedePrevention === false) {
@@ -1563,7 +2144,7 @@ var CacheStack = class extends EventEmitter {
1563
2144
  }
1564
2145
  return this.stampedeGuard.execute(key, singleFlightTask);
1565
2146
  }
1566
- async waitForFreshValue(key, fetcher, options) {
2147
+ async waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1567
2148
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1568
2149
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1569
2150
  const deadline = Date.now() + timeoutMs;
@@ -1577,9 +2158,9 @@ var CacheStack = class extends EventEmitter {
1577
2158
  }
1578
2159
  await this.sleep(pollIntervalMs);
1579
2160
  }
1580
- return this.fetchAndPopulate(key, fetcher, options);
2161
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
1581
2162
  }
1582
- async fetchAndPopulate(key, fetcher, options) {
2163
+ async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1583
2164
  this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1584
2165
  this.metricsCollector.increment("fetches");
1585
2166
  const fetchStart = Date.now();
@@ -1600,6 +2181,16 @@ var CacheStack = class extends EventEmitter {
1600
2181
  if (!this.shouldNegativeCache(options)) {
1601
2182
  return null;
1602
2183
  }
2184
+ if (this.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2185
+ this.logger.debug?.("skip-negative-store-after-invalidation", {
2186
+ key,
2187
+ expectedClearEpoch,
2188
+ clearEpoch: this.maintenance.currentClearEpoch(),
2189
+ expectedKeyEpoch,
2190
+ keyEpoch: this.maintenance.currentKeyEpoch(key)
2191
+ });
2192
+ return null;
2193
+ }
1603
2194
  await this.storeEntry(key, "empty", null, options);
1604
2195
  return null;
1605
2196
  }
@@ -1612,11 +2203,26 @@ var CacheStack = class extends EventEmitter {
1612
2203
  this.logger.warn?.("shouldCache-error", { key, error: this.formatError(error) });
1613
2204
  }
1614
2205
  }
2206
+ if (this.maintenance.isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch)) {
2207
+ this.logger.debug?.("skip-store-after-invalidation", {
2208
+ key,
2209
+ expectedClearEpoch,
2210
+ clearEpoch: this.maintenance.currentClearEpoch(),
2211
+ expectedKeyEpoch,
2212
+ keyEpoch: this.maintenance.currentKeyEpoch(key)
2213
+ });
2214
+ return fetched;
2215
+ }
1615
2216
  await this.storeEntry(key, "value", fetched, options);
1616
2217
  return fetched;
1617
2218
  }
1618
2219
  async storeEntry(key, kind, value, options) {
2220
+ const clearEpoch = this.maintenance.currentClearEpoch();
2221
+ const keyEpoch = this.maintenance.currentKeyEpoch(key);
1619
2222
  await this.writeAcrossLayers(key, kind, value, options);
2223
+ if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2224
+ return;
2225
+ }
1620
2226
  if (options?.tags) {
1621
2227
  await this.tagIndex.track(key, options.tags);
1622
2228
  } else {
@@ -1631,6 +2237,8 @@ var CacheStack = class extends EventEmitter {
1631
2237
  }
1632
2238
  async writeBatch(entries) {
1633
2239
  const now = Date.now();
2240
+ const clearEpoch = this.maintenance.currentClearEpoch();
2241
+ const entryEpochs = new Map(entries.map((entry) => [entry.key, this.maintenance.currentKeyEpoch(entry.key)]));
1634
2242
  const entriesByLayer = /* @__PURE__ */ new Map();
1635
2243
  const immediateOperations = [];
1636
2244
  const deferredOperations = [];
@@ -1647,12 +2255,21 @@ var CacheStack = class extends EventEmitter {
1647
2255
  }
1648
2256
  for (const [layer, layerEntries] of entriesByLayer.entries()) {
1649
2257
  const operation = async () => {
2258
+ if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2259
+ return;
2260
+ }
2261
+ const activeEntries = layerEntries.filter(
2262
+ (entry) => (entryEpochs.get(entry.key) ?? 0) === this.maintenance.currentKeyEpoch(entry.key)
2263
+ );
2264
+ if (activeEntries.length === 0) {
2265
+ return;
2266
+ }
1650
2267
  try {
1651
2268
  if (layer.setMany) {
1652
- await layer.setMany(layerEntries);
2269
+ await layer.setMany(activeEntries);
1653
2270
  return;
1654
2271
  }
1655
- await Promise.all(layerEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
2272
+ await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
1656
2273
  } catch (error) {
1657
2274
  await this.handleLayerFailure(layer, "write", error);
1658
2275
  }
@@ -1665,7 +2282,13 @@ var CacheStack = class extends EventEmitter {
1665
2282
  }
1666
2283
  await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
1667
2284
  await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
2285
+ if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2286
+ return;
2287
+ }
1668
2288
  for (const entry of entries) {
2289
+ if (this.maintenance.isWriteOutdated(entry.key, clearEpoch, entryEpochs.get(entry.key))) {
2290
+ continue;
2291
+ }
1669
2292
  if (entry.options?.tags) {
1670
2293
  await this.tagIndex.track(entry.key, entry.options.tags);
1671
2294
  } else {
@@ -1767,10 +2390,15 @@ var CacheStack = class extends EventEmitter {
1767
2390
  }
1768
2391
  async writeAcrossLayers(key, kind, value, options) {
1769
2392
  const now = Date.now();
2393
+ const clearEpoch = this.maintenance.currentClearEpoch();
2394
+ const keyEpoch = this.maintenance.currentKeyEpoch(key);
1770
2395
  const immediateOperations = [];
1771
2396
  const deferredOperations = [];
1772
2397
  for (const layer of this.layers) {
1773
2398
  const operation = async () => {
2399
+ if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2400
+ return;
2401
+ }
1774
2402
  if (this.shouldSkipLayer(layer)) {
1775
2403
  return;
1776
2404
  }
@@ -1831,13 +2459,18 @@ var CacheStack = class extends EventEmitter {
1831
2459
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
1832
2460
  }
1833
2461
  scheduleBackgroundRefresh(key, fetcher, options) {
1834
- if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
2462
+ if (!shouldStartBackgroundRefresh({
2463
+ isDisconnecting: this.isDisconnecting,
2464
+ hasRefreshInFlight: this.backgroundRefreshes.has(key)
2465
+ })) {
1835
2466
  return;
1836
2467
  }
2468
+ const clearEpoch = this.maintenance.currentClearEpoch();
2469
+ const keyEpoch = this.maintenance.currentKeyEpoch(key);
1837
2470
  const refresh = (async () => {
1838
2471
  this.metricsCollector.increment("refreshes");
1839
2472
  try {
1840
- await this.runBackgroundRefresh(key, fetcher, options);
2473
+ await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
1841
2474
  } catch (error) {
1842
2475
  this.metricsCollector.increment("refreshErrors");
1843
2476
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
@@ -1847,14 +2480,16 @@ var CacheStack = class extends EventEmitter {
1847
2480
  })();
1848
2481
  this.backgroundRefreshes.set(key, refresh);
1849
2482
  }
1850
- async runBackgroundRefresh(key, fetcher, options) {
2483
+ async runBackgroundRefresh(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
1851
2484
  const timeoutMs = this.options.backgroundRefreshTimeoutMs ?? DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS;
1852
2485
  await this.fetchWithGuards(
1853
2486
  key,
1854
2487
  () => this.withTimeout(fetcher(), timeoutMs, () => {
1855
2488
  return new Error(`Background refresh timed out after ${timeoutMs}ms for key "${key}".`);
1856
2489
  }),
1857
- options
2490
+ options,
2491
+ expectedClearEpoch,
2492
+ expectedKeyEpoch
1858
2493
  );
1859
2494
  }
1860
2495
  resolveSingleFlightOptions() {
@@ -1869,6 +2504,7 @@ var CacheStack = class extends EventEmitter {
1869
2504
  if (keys.length === 0) {
1870
2505
  return;
1871
2506
  }
2507
+ this.maintenance.bumpKeyEpochs(keys);
1872
2508
  await this.deleteKeysFromLayers(this.layers, keys);
1873
2509
  for (const key of keys) {
1874
2510
  await this.tagIndex.remove(key);
@@ -1891,21 +2527,22 @@ var CacheStack = class extends EventEmitter {
1891
2527
  return;
1892
2528
  }
1893
2529
  const localLayers = this.layers.filter((layer) => layer.isLocal);
1894
- if (localLayers.length === 0) {
1895
- return;
1896
- }
1897
2530
  if (message.scope === "clear") {
2531
+ this.maintenance.beginClearEpoch();
1898
2532
  await Promise.all(localLayers.map((layer) => layer.clear()));
1899
2533
  await this.tagIndex.clear();
1900
2534
  this.ttlResolver.clearProfiles();
2535
+ this.circuitBreakerManager.clear();
1901
2536
  return;
1902
2537
  }
1903
2538
  const keys = message.keys ?? [];
2539
+ this.maintenance.bumpKeyEpochs(keys);
1904
2540
  await this.deleteKeysFromLayers(localLayers, keys);
1905
2541
  if (message.operation !== "write") {
1906
2542
  for (const key of keys) {
1907
2543
  await this.tagIndex.remove(key);
1908
2544
  this.ttlResolver.deleteProfile(key);
2545
+ this.circuitBreakerManager.delete(key);
1909
2546
  }
1910
2547
  }
1911
2548
  }
@@ -1957,35 +2594,22 @@ var CacheStack = class extends EventEmitter {
1957
2594
  shouldBroadcastL1Invalidation() {
1958
2595
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
1959
2596
  }
1960
- shouldCleanupGenerations() {
1961
- return Boolean(this.options.generationCleanup);
1962
- }
1963
- generationCleanupBatchSize() {
1964
- const configured = typeof this.options.generationCleanup === "object" ? this.options.generationCleanup.batchSize : void 0;
1965
- return configured ?? 500;
1966
- }
1967
2597
  scheduleGenerationCleanup(generation) {
1968
- const task = (this.generationCleanupPromise ?? Promise.resolve()).then(() => this.cleanupGeneration(generation)).catch((error) => {
1969
- this.logger.warn?.("generation-cleanup-error", {
1970
- generation,
1971
- error: this.formatError(error)
1972
- });
1973
- });
1974
- this.generationCleanupPromise = task.finally(() => {
1975
- if (this.generationCleanupPromise === task) {
1976
- this.generationCleanupPromise = void 0;
2598
+ this.maintenance.scheduleGenerationCleanup(
2599
+ generation,
2600
+ async (generationToClean) => this.cleanupGeneration(generationToClean),
2601
+ (failedGeneration, error) => {
2602
+ this.logger.warn?.("generation-cleanup-error", {
2603
+ generation: failedGeneration,
2604
+ error: this.formatError(error)
2605
+ });
1977
2606
  }
1978
- });
2607
+ );
1979
2608
  }
1980
2609
  async cleanupGeneration(generation) {
1981
2610
  const prefix = `v${generation}:`;
1982
2611
  const keys = await this.keyDiscovery.collectKeysWithPrefix(prefix);
1983
- if (keys.length === 0) {
1984
- return;
1985
- }
1986
- const batchSize = this.generationCleanupBatchSize();
1987
- for (let index = 0; index < keys.length; index += batchSize) {
1988
- const batch = keys.slice(index, index + batchSize);
2612
+ for (const batch of planGenerationCleanupBatches(keys, this.options.generationCleanup)) {
1989
2613
  await this.deleteKeys(batch);
1990
2614
  await this.publishInvalidation({
1991
2615
  scope: "keys",
@@ -1996,58 +2620,34 @@ var CacheStack = class extends EventEmitter {
1996
2620
  }
1997
2621
  }
1998
2622
  initializeWriteBehind(options) {
1999
- if (this.options.writeStrategy !== "write-behind") {
2000
- return;
2001
- }
2002
- const flushIntervalMs = options?.flushIntervalMs;
2003
- if (!flushIntervalMs || flushIntervalMs <= 0) {
2004
- return;
2005
- }
2006
- this.writeBehindTimer = setInterval(() => {
2007
- void this.flushWriteBehindQueue();
2008
- }, flushIntervalMs);
2009
- this.writeBehindTimer.unref?.();
2623
+ this.maintenance.initializeWriteBehindTimer(
2624
+ this.options.writeStrategy,
2625
+ options,
2626
+ this.flushWriteBehindQueue.bind(this)
2627
+ );
2010
2628
  }
2011
2629
  shouldWriteBehind(layer) {
2012
2630
  return this.options.writeStrategy === "write-behind" && !layer.isLocal;
2013
2631
  }
2014
2632
  async enqueueWriteBehind(operation) {
2015
- this.writeBehindQueue.push(operation);
2016
- const batchSize = this.options.writeBehind?.batchSize ?? 100;
2017
- const maxQueueSize = this.options.writeBehind?.maxQueueSize ?? batchSize * 10;
2018
- if (this.writeBehindQueue.length >= batchSize) {
2019
- await this.flushWriteBehindQueue();
2020
- return;
2021
- }
2022
- if (this.writeBehindQueue.length >= maxQueueSize) {
2023
- await this.flushWriteBehindQueue();
2024
- }
2633
+ await this.maintenance.enqueueWriteBehind(operation, this.options.writeBehind, this.runWriteBehindBatch.bind(this));
2025
2634
  }
2026
2635
  async flushWriteBehindQueue() {
2027
- if (this.writeBehindFlushPromise || this.writeBehindQueue.length === 0) {
2028
- await this.writeBehindFlushPromise;
2029
- return;
2030
- }
2031
- const batchSize = this.options.writeBehind?.batchSize ?? 100;
2032
- const batch = this.writeBehindQueue.splice(0, batchSize);
2033
- this.writeBehindFlushPromise = (async () => {
2034
- const results = await Promise.allSettled(batch.map((operation) => operation()));
2035
- const failures = results.filter((result) => result.status === "rejected");
2036
- if (failures.length > 0) {
2037
- this.metricsCollector.increment("writeFailures", failures.length);
2038
- this.logger.error?.("write-behind-flush-failure", {
2039
- failed: failures.length,
2040
- total: batch.length,
2041
- errors: failures.map((failure) => this.formatError(failure.reason))
2042
- });
2043
- this.emitError("write-behind", { failed: failures.length, total: batch.length });
2044
- }
2045
- })();
2046
- await this.writeBehindFlushPromise;
2047
- this.writeBehindFlushPromise = void 0;
2048
- if (this.writeBehindQueue.length > 0) {
2049
- await this.flushWriteBehindQueue();
2636
+ await this.maintenance.flushWriteBehindQueue(this.options.writeBehind, this.runWriteBehindBatch.bind(this));
2637
+ }
2638
+ async runWriteBehindBatch(batch) {
2639
+ const results = await Promise.allSettled(batch.map((operation) => operation()));
2640
+ const failures = results.filter((result) => result.status === "rejected");
2641
+ if (failures.length === 0) {
2642
+ return;
2050
2643
  }
2644
+ this.metricsCollector.increment("writeFailures", failures.length);
2645
+ this.logger.error?.("write-behind-flush-failure", {
2646
+ failed: failures.length,
2647
+ total: batch.length,
2648
+ errors: failures.map((failure) => this.formatError(failure.reason))
2649
+ });
2650
+ this.emitError("write-behind", { failed: failures.length, total: batch.length });
2051
2651
  }
2052
2652
  buildLayerSetEntry(layer, key, kind, value, options, now) {
2053
2653
  const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl, value);
@@ -2077,32 +2677,17 @@ var CacheStack = class extends EventEmitter {
2077
2677
  return [];
2078
2678
  }
2079
2679
  const [firstGroup, ...rest] = groups;
2080
- if (!firstGroup) {
2081
- return [];
2082
- }
2083
2680
  const restSets = rest.map((group) => new Set(group));
2084
2681
  return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)));
2085
2682
  }
2086
2683
  qualifyKey(key) {
2087
- const prefix = this.generationPrefix();
2088
- return prefix ? `${prefix}${key}` : key;
2684
+ return qualifyGenerationKey(key, this.currentGeneration);
2089
2685
  }
2090
2686
  qualifyPattern(pattern) {
2091
- const prefix = this.generationPrefix();
2092
- return prefix ? `${prefix}${pattern}` : pattern;
2687
+ return qualifyGenerationPattern(pattern, this.currentGeneration);
2093
2688
  }
2094
2689
  stripQualifiedKey(key) {
2095
- const prefix = this.generationPrefix();
2096
- if (!prefix || !key.startsWith(prefix)) {
2097
- return key;
2098
- }
2099
- return key.slice(prefix.length);
2100
- }
2101
- generationPrefix() {
2102
- if (this.currentGeneration === void 0) {
2103
- return "";
2104
- }
2105
- return `v${this.currentGeneration}:`;
2690
+ return stripGenerationPrefix(key, this.currentGeneration);
2106
2691
  }
2107
2692
  async deleteKeysFromLayers(layers, keys) {
2108
2693
  await Promise.all(
@@ -2137,118 +2722,50 @@ var CacheStack = class extends EventEmitter {
2137
2722
  if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
2138
2723
  throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
2139
2724
  }
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);
2725
+ validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
2726
+ validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
2727
+ validateLayerNumberOption("staleIfError", this.options.staleIfError);
2728
+ validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
2729
+ validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
2730
+ validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2731
+ validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2732
+ validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2733
+ validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2734
+ validatePositiveNumber("backgroundRefreshTimeoutMs", this.options.backgroundRefreshTimeoutMs);
2735
+ if (this.options.snapshotMaxBytes !== false) {
2736
+ validatePositiveNumber("snapshotMaxBytes", this.options.snapshotMaxBytes);
2737
+ }
2738
+ if (this.options.snapshotMaxEntries !== false) {
2739
+ validatePositiveNumber("snapshotMaxEntries", this.options.snapshotMaxEntries);
2740
+ }
2741
+ if (this.options.invalidationMaxKeys !== false) {
2742
+ validatePositiveNumber("invalidationMaxKeys", this.options.invalidationMaxKeys);
2743
+ }
2744
+ validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2745
+ validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2746
+ validateCircuitBreakerOptions(this.options.circuitBreaker);
2153
2747
  if (typeof this.options.generationCleanup === "object") {
2154
- this.validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2748
+ validatePositiveNumber("generationCleanup.batchSize", this.options.generationCleanup.batchSize);
2155
2749
  }
2156
2750
  if (this.options.generation !== void 0) {
2157
- this.validateNonNegativeNumber("generation", this.options.generation);
2751
+ validateNonNegativeNumber("generation", this.options.generation);
2158
2752
  }
2159
2753
  }
2160
2754
  validateWriteOptions(options) {
2161
2755
  if (!options) {
2162
2756
  return;
2163
2757
  }
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.`);
2758
+ validateLayerNumberOption("options.ttl", options.ttl);
2759
+ validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
2760
+ validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
2761
+ validateLayerNumberOption("options.staleIfError", options.staleIfError);
2762
+ validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
2763
+ validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
2764
+ validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2765
+ validateAdaptiveTtlOptions(options.adaptiveTtl);
2766
+ validateCircuitBreakerOptions(options.circuitBreaker);
2767
+ validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2768
+ validateTags(options.tags);
2252
2769
  }
2253
2770
  assertActive(operation) {
2254
2771
  if (this.isDisconnecting) {
@@ -2260,56 +2777,39 @@ var CacheStack = class extends EventEmitter {
2260
2777
  await this.startup;
2261
2778
  this.assertActive(operation);
2262
2779
  }
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
2780
  async applyFreshReadPolicies(key, hit, options, fetcher) {
2282
- const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
2283
- const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
2284
- if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
2285
- const refreshed = refreshStoredEnvelope(hit.stored);
2286
- const ttl = remainingStoredTtlSeconds(refreshed);
2781
+ const plan = planFreshReadPolicies({
2782
+ stored: hit.stored,
2783
+ hasFetcher: Boolean(fetcher),
2784
+ slidingTtl: options?.slidingTtl ?? false,
2785
+ refreshAheadSeconds: this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0
2786
+ });
2787
+ if (plan.refreshedStored) {
2287
2788
  for (let index = 0; index <= hit.layerIndex; index += 1) {
2288
2789
  const layer = this.layers[index];
2289
2790
  if (!layer || this.shouldSkipLayer(layer)) {
2290
2791
  continue;
2291
2792
  }
2292
2793
  try {
2293
- await layer.set(key, refreshed, ttl);
2794
+ await layer.set(key, plan.refreshedStored, plan.refreshedStoredTtl);
2294
2795
  } catch (error) {
2295
2796
  await this.handleLayerFailure(layer, "sliding-ttl", error);
2296
2797
  }
2297
2798
  }
2298
2799
  }
2299
- if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
2800
+ if (fetcher && plan.shouldScheduleBackgroundRefresh) {
2300
2801
  this.scheduleBackgroundRefresh(key, fetcher, options);
2301
2802
  }
2302
2803
  }
2303
2804
  shouldSkipLayer(layer) {
2304
- const degradedUntil = this.layerDegradedUntil.get(layer.name);
2305
- return degradedUntil !== void 0 && degradedUntil > Date.now();
2805
+ return shouldSkipLayer(this.layerDegradedUntil.get(layer.name));
2306
2806
  }
2307
2807
  async handleLayerFailure(layer, operation, error) {
2308
- if (!this.isGracefulDegradationEnabled()) {
2808
+ const recovery = resolveRecoverableLayerFailure(this.options.gracefulDegradation);
2809
+ if (!recovery.degrade) {
2309
2810
  throw error;
2310
2811
  }
2311
- const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
2312
- this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
2812
+ this.layerDegradedUntil.set(layer.name, recovery.degradedUntil);
2313
2813
  this.metricsCollector.increment("degradedOperations");
2314
2814
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
2315
2815
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
@@ -2345,18 +2845,6 @@ var CacheStack = class extends EventEmitter {
2345
2845
  this.emit("error", { operation, ...context });
2346
2846
  }
2347
2847
  }
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
2848
  isCacheSnapshotEntries(value) {
2361
2849
  return Array.isArray(value) && value.every((entry) => {
2362
2850
  if (!entry || typeof entry !== "object") {
@@ -2369,54 +2857,72 @@ var CacheStack = class extends EventEmitter {
2369
2857
  sanitizeSnapshotValue(value) {
2370
2858
  return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2371
2859
  }
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.");
2860
+ snapshotMaxBytes() {
2861
+ return this.options.snapshotMaxBytes === false ? false : this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES;
2862
+ }
2863
+ snapshotMaxEntries() {
2864
+ return this.options.snapshotMaxEntries === false ? false : this.options.snapshotMaxEntries ?? DEFAULT_SNAPSHOT_MAX_ENTRIES;
2865
+ }
2866
+ invalidationMaxKeys() {
2867
+ return this.options.invalidationMaxKeys === false ? false : this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS;
2868
+ }
2869
+ async collectKeysForTag(tag) {
2870
+ const keys = /* @__PURE__ */ new Set();
2871
+ if (this.tagIndex.forEachKeyForTag) {
2872
+ await this.tagIndex.forEachKeyForTag(tag, async (key) => {
2873
+ keys.add(key);
2874
+ this.assertWithinInvalidationKeyLimit(keys.size);
2875
+ });
2876
+ return [...keys];
2378
2877
  }
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
- }
2878
+ for (const key of await this.tagIndex.keysForTag(tag)) {
2879
+ keys.add(key);
2880
+ this.assertWithinInvalidationKeyLimit(keys.size);
2387
2881
  }
2388
- return resolved;
2882
+ return [...keys];
2389
2883
  }
2390
- normalizeForSerialization(value) {
2391
- if (Array.isArray(value)) {
2392
- return value.map((entry) => this.normalizeForSerialization(entry));
2884
+ assertWithinInvalidationKeyLimit(size) {
2885
+ const maxKeys = this.invalidationMaxKeys();
2886
+ if (maxKeys !== false && size > maxKeys) {
2887
+ throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
2393
2888
  }
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;
2889
+ }
2890
+ async visitExportEntries(maxEntries, visitor) {
2891
+ const exported = /* @__PURE__ */ new Set();
2892
+ for (const layer of this.layers) {
2893
+ if (!layer.keys && !layer.forEachKey) {
2894
+ continue;
2895
+ }
2896
+ const visitKey = async (key) => {
2897
+ const exportedKey = this.stripQualifiedKey(key);
2898
+ if (exported.has(exportedKey)) {
2899
+ return;
2398
2900
  }
2399
- normalized[key] = this.normalizeForSerialization(value[key]);
2400
- return normalized;
2401
- }, {});
2901
+ const stored = await this.readLayerEntry(layer, key);
2902
+ if (stored === null) {
2903
+ return;
2904
+ }
2905
+ exported.add(exportedKey);
2906
+ if (maxEntries !== false && exported.size > maxEntries) {
2907
+ throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
2908
+ }
2909
+ await visitor({
2910
+ key: exportedKey,
2911
+ value: stored,
2912
+ ttl: remainingStoredTtlSeconds(stored)
2913
+ });
2914
+ };
2915
+ if (layer.forEachKey) {
2916
+ await layer.forEachKey(visitKey);
2917
+ continue;
2918
+ }
2919
+ const keys = await layer.keys?.();
2920
+ for (const key of keys ?? []) {
2921
+ await visitKey(key);
2922
+ }
2402
2923
  }
2403
- return value;
2404
2924
  }
2405
2925
  };
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
2926
 
2421
2927
  // src/invalidation/RedisInvalidationBus.ts
2422
2928
  var RedisInvalidationBus = class {
@@ -2495,15 +3001,24 @@ var RedisInvalidationBus = class {
2495
3001
  }
2496
3002
  };
2497
3003
  var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2498
- function sanitizeJsonValue2(value) {
3004
+ var MAX_SANITIZE_DEPTH2 = 64;
3005
+ var MAX_SANITIZE_NODES2 = 1e4;
3006
+ function sanitizeJsonValue2(value, depth = 0, state = { count: 0 }) {
3007
+ state.count += 1;
3008
+ if (state.count > MAX_SANITIZE_NODES2) {
3009
+ throw new Error(`Invalidation payload exceeds max node count of ${MAX_SANITIZE_NODES2}.`);
3010
+ }
3011
+ if (depth > MAX_SANITIZE_DEPTH2) {
3012
+ throw new Error(`Invalidation payload exceeds max depth of ${MAX_SANITIZE_DEPTH2}.`);
3013
+ }
2499
3014
  if (Array.isArray(value)) {
2500
- return value.map(sanitizeJsonValue2);
3015
+ return value.map((entry) => sanitizeJsonValue2(entry, depth + 1, state));
2501
3016
  }
2502
3017
  if (value && typeof value === "object") {
2503
3018
  const result = /* @__PURE__ */ Object.create(null);
2504
3019
  for (const key of Object.keys(value)) {
2505
3020
  if (!DANGEROUS_KEYS.has(key)) {
2506
- result[key] = sanitizeJsonValue2(value[key]);
3021
+ result[key] = sanitizeJsonValue2(value[key], depth + 1, state);
2507
3022
  }
2508
3023
  }
2509
3024
  return result;
@@ -2512,12 +3027,18 @@ function sanitizeJsonValue2(value) {
2512
3027
  }
2513
3028
 
2514
3029
  // src/http/createCacheStatsHandler.ts
2515
- function createCacheStatsHandler(cache) {
2516
- return async (_request, response) => {
2517
- response.statusCode = 200;
3030
+ function createCacheStatsHandler(cache, options = {}) {
3031
+ return async (request, response) => {
2518
3032
  response.setHeader?.("content-type", "application/json; charset=utf-8");
2519
3033
  response.setHeader?.("cache-control", "no-store");
2520
3034
  response.setHeader?.("x-content-type-options", "nosniff");
3035
+ const isAuthorized = options.allowPublicAccess === true || (options.authorize ? await options.authorize(request) : false);
3036
+ if (!isAuthorized) {
3037
+ response.statusCode = options.unauthorizedStatusCode ?? 403;
3038
+ response.end(JSON.stringify({ error: "Forbidden" }));
3039
+ return;
3040
+ }
3041
+ response.statusCode = 200;
2521
3042
  response.end(JSON.stringify(cache.getStats(), null, 2));
2522
3043
  };
2523
3044
  }
@@ -2552,7 +3073,26 @@ function createFastifyLayercachePlugin(cache, options = {}) {
2552
3073
  return async (fastify) => {
2553
3074
  fastify.decorate("cache", cache);
2554
3075
  if (options.exposeStatsRoute === true && fastify.get) {
2555
- fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
3076
+ fastify.get(options.statsPath ?? "/cache/stats", async (request, reply) => {
3077
+ const isAuthorized = options.allowPublicStatsRoute === true || (options.authorizeStatsRoute ? await options.authorizeStatsRoute(request) : false);
3078
+ reply.header?.("cache-control", "no-store");
3079
+ reply.header?.("x-content-type-options", "nosniff");
3080
+ if (!isAuthorized) {
3081
+ reply.statusCode = options.unauthorizedStatusCode ?? 403;
3082
+ const body2 = { error: "Forbidden" };
3083
+ if (reply.send) {
3084
+ reply.send(body2);
3085
+ return;
3086
+ }
3087
+ return body2;
3088
+ }
3089
+ const body = cache.getStats();
3090
+ if (reply.send) {
3091
+ reply.send(body);
3092
+ return;
3093
+ }
3094
+ return body;
3095
+ });
2556
3096
  }
2557
3097
  };
2558
3098
  }
@@ -2567,6 +3107,10 @@ function createExpressCacheMiddleware(cache, options = {}) {
2567
3107
  next();
2568
3108
  return;
2569
3109
  }
3110
+ if (!options.keyResolver && options.allowPrivateCaching !== true) {
3111
+ next();
3112
+ return;
3113
+ }
2570
3114
  const rawUrl = req.originalUrl ?? req.url ?? "/";
2571
3115
  const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
2572
3116
  const cached = await cache.get(key, void 0, options);
@@ -2611,6 +3155,11 @@ function normalizeUrl(url) {
2611
3155
 
2612
3156
  // src/integrations/graphql.ts
2613
3157
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
3158
+ if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
3159
+ throw new Error(
3160
+ "cacheGraphqlResolver requires a keyResolver or allowImplicitContextCaching=true because resolver output may depend on request context."
3161
+ );
3162
+ }
2614
3163
  const wrapped = cache.wrap(prefix, resolver, {
2615
3164
  ...options,
2616
3165
  keyResolver: options.keyResolver
@@ -2682,6 +3231,11 @@ function instrument(name, tracer, method, attributes) {
2682
3231
 
2683
3232
  // src/integrations/trpc.ts
2684
3233
  function createTrpcCacheMiddleware(cache, prefix, options = {}) {
3234
+ if (!options.keyResolver && options.allowImplicitContextCaching !== true) {
3235
+ throw new Error(
3236
+ "createTrpcCacheMiddleware requires a keyResolver or allowImplicitContextCaching=true because procedure output may depend on request context."
3237
+ );
3238
+ }
2685
3239
  return async (context) => {
2686
3240
  const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
2687
3241
  let didFetch = false;
@@ -2706,13 +3260,12 @@ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
2706
3260
  }
2707
3261
 
2708
3262
  // src/layers/RedisLayer.ts
3263
+ import { Readable } from "stream";
2709
3264
  import { promisify } from "util";
2710
- import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
3265
+ import { brotliCompress, createBrotliDecompress, createGunzip, gzip } from "zlib";
2711
3266
  var BATCH_DELETE_SIZE = 500;
2712
3267
  var gzipAsync = promisify(gzip);
2713
- var gunzipAsync = promisify(gunzip);
2714
3268
  var brotliCompressAsync = promisify(brotliCompress);
2715
- var brotliDecompressAsync = promisify(brotliDecompress);
2716
3269
  var RedisLayer = class {
2717
3270
  name;
2718
3271
  defaultTtl;
@@ -2820,8 +3373,18 @@ var RedisLayer = class {
2820
3373
  return remaining;
2821
3374
  }
2822
3375
  async size() {
2823
- const keys = await this.keys();
2824
- return keys.length;
3376
+ if (!this.prefix) {
3377
+ return this.client.dbsize();
3378
+ }
3379
+ const pattern = `${this.prefix}*`;
3380
+ let cursor = "0";
3381
+ let count = 0;
3382
+ do {
3383
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3384
+ cursor = nextCursor;
3385
+ count += keys.length;
3386
+ } while (cursor !== "0");
3387
+ return count;
2825
3388
  }
2826
3389
  async ping() {
2827
3390
  try {
@@ -2867,6 +3430,17 @@ var RedisLayer = class {
2867
3430
  }
2868
3431
  return keys.map((key) => key.slice(this.prefix.length));
2869
3432
  }
3433
+ async forEachKey(visitor) {
3434
+ const pattern = `${this.prefix}*`;
3435
+ let cursor = "0";
3436
+ do {
3437
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3438
+ cursor = nextCursor;
3439
+ for (const key of keys) {
3440
+ await visitor(this.prefix ? key.slice(this.prefix.length) : key);
3441
+ }
3442
+ } while (cursor !== "0");
3443
+ }
2870
3444
  async scanKeys(pattern) {
2871
3445
  const matches = [];
2872
3446
  let cursor = "0";
@@ -2881,7 +3455,13 @@ var RedisLayer = class {
2881
3455
  return `${this.prefix}${key}`;
2882
3456
  }
2883
3457
  async deserializeOrDelete(key, payload) {
2884
- const decodedPayload = await this.decodePayload(payload);
3458
+ let decodedPayload;
3459
+ try {
3460
+ decodedPayload = await this.decodePayload(payload);
3461
+ } catch {
3462
+ await this.deleteCorruptedKey(key);
3463
+ return null;
3464
+ }
2885
3465
  for (const serializer of this.serializers) {
2886
3466
  try {
2887
3467
  const value = serializer.deserialize(decodedPayload);
@@ -2892,12 +3472,15 @@ var RedisLayer = class {
2892
3472
  } catch {
2893
3473
  }
2894
3474
  }
3475
+ await this.deleteCorruptedKey(key);
3476
+ return null;
3477
+ }
3478
+ async deleteCorruptedKey(key) {
2895
3479
  try {
2896
3480
  await this.client.del(this.withPrefix(key));
2897
3481
  } catch (deleteError) {
2898
3482
  console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
2899
3483
  }
2900
- return null;
2901
3484
  }
2902
3485
  async rewriteWithPrimarySerializer(key, value) {
2903
3486
  const serialized = this.primarySerializer().serialize(value);
@@ -2944,31 +3527,72 @@ var RedisLayer = class {
2944
3527
  return payload;
2945
3528
  }
2946
3529
  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;
3530
+ return this.decompressWithLimit(createGunzip(), payload.subarray(10));
2954
3531
  }
2955
3532
  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;
3533
+ return this.decompressWithLimit(createBrotliDecompress(), payload.subarray(12));
2963
3534
  }
2964
3535
  return payload;
2965
3536
  }
3537
+ async decompressWithLimit(decompressor, payload) {
3538
+ return new Promise((resolve2, reject) => {
3539
+ const source = Readable.from(payload);
3540
+ const chunks = [];
3541
+ let totalBytes = 0;
3542
+ let settled = false;
3543
+ const cleanup = () => {
3544
+ decompressor.removeAllListeners();
3545
+ };
3546
+ const fail = (error) => {
3547
+ if (settled) {
3548
+ return;
3549
+ }
3550
+ settled = true;
3551
+ cleanup();
3552
+ source.unpipe(decompressor);
3553
+ source.destroy();
3554
+ decompressor.destroy();
3555
+ reject(error);
3556
+ };
3557
+ decompressor.on("data", (chunk) => {
3558
+ const normalized = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3559
+ totalBytes += normalized.byteLength;
3560
+ if (totalBytes > this.decompressionMaxBytes) {
3561
+ fail(
3562
+ new Error(
3563
+ `Decompressed payload (${totalBytes} bytes) exceeds decompressionMaxBytes limit (${this.decompressionMaxBytes} bytes).`
3564
+ )
3565
+ );
3566
+ return;
3567
+ }
3568
+ chunks.push(normalized);
3569
+ });
3570
+ decompressor.once("error", (error) => {
3571
+ if (settled) {
3572
+ return;
3573
+ }
3574
+ settled = true;
3575
+ cleanup();
3576
+ reject(error);
3577
+ });
3578
+ decompressor.once("end", () => {
3579
+ if (settled) {
3580
+ return;
3581
+ }
3582
+ settled = true;
3583
+ cleanup();
3584
+ resolve2(Buffer.concat(chunks));
3585
+ });
3586
+ source.pipe(decompressor);
3587
+ });
3588
+ }
2966
3589
  };
2967
3590
 
2968
3591
  // src/layers/DiskLayer.ts
2969
3592
  import { createHash } from "crypto";
2970
3593
  import { promises as fs } from "fs";
2971
3594
  import { join, resolve } from "path";
3595
+ var FILE_SCAN_CONCURRENCY = 32;
2972
3596
  var DiskLayer = class {
2973
3597
  name;
2974
3598
  defaultTtl;
@@ -2976,6 +3600,7 @@ var DiskLayer = class {
2976
3600
  directory;
2977
3601
  serializer;
2978
3602
  maxFiles;
3603
+ maxEntryBytes;
2979
3604
  writeQueue = Promise.resolve();
2980
3605
  constructor(options) {
2981
3606
  this.directory = this.resolveDirectory(options.directory);
@@ -2983,16 +3608,15 @@ var DiskLayer = class {
2983
3608
  this.name = options.name ?? "disk";
2984
3609
  this.serializer = options.serializer ?? new JsonSerializer();
2985
3610
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
3611
+ this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
2986
3612
  }
2987
3613
  async get(key) {
2988
3614
  return unwrapStoredValue(await this.getEntry(key));
2989
3615
  }
2990
3616
  async getEntry(key) {
2991
3617
  const filePath = this.keyToPath(key);
2992
- let raw;
2993
- try {
2994
- raw = await fs.readFile(filePath);
2995
- } catch {
3618
+ const raw = await this.readEntryFile(filePath);
3619
+ if (raw === null) {
2996
3620
  return null;
2997
3621
  }
2998
3622
  let entry;
@@ -3043,10 +3667,8 @@ var DiskLayer = class {
3043
3667
  }
3044
3668
  async ttl(key) {
3045
3669
  const filePath = this.keyToPath(key);
3046
- let raw;
3047
- try {
3048
- raw = await fs.readFile(filePath);
3049
- } catch {
3670
+ const raw = await this.readEntryFile(filePath);
3671
+ if (raw === null) {
3050
3672
  return null;
3051
3673
  }
3052
3674
  let entry;
@@ -3070,7 +3692,7 @@ var DiskLayer = class {
3070
3692
  }
3071
3693
  async deleteMany(keys) {
3072
3694
  await this.enqueueWrite(async () => {
3073
- await Promise.all(keys.map((key) => this.safeDelete(this.keyToPath(key))));
3695
+ await this.deletePathsWithConcurrency(keys.map((key) => this.keyToPath(key)));
3074
3696
  });
3075
3697
  }
3076
3698
  async clear() {
@@ -3081,8 +3703,8 @@ var DiskLayer = class {
3081
3703
  } catch {
3082
3704
  return;
3083
3705
  }
3084
- await Promise.all(
3085
- entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
3706
+ await this.deletePathsWithConcurrency(
3707
+ entries.filter((name) => name.endsWith(".lc")).map((name) => join(this.directory, name))
3086
3708
  );
3087
3709
  });
3088
3710
  }
@@ -3091,42 +3713,23 @@ var DiskLayer = class {
3091
3713
  * Expired entries are skipped and cleaned up during the scan.
3092
3714
  */
3093
3715
  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
3716
  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
- );
3717
+ await this.scanEntries(async (entry) => {
3718
+ keys.push(entry.key);
3719
+ });
3125
3720
  return keys;
3126
3721
  }
3722
+ async forEachKey(visitor) {
3723
+ await this.scanEntries(async (entry) => {
3724
+ await visitor(entry.key);
3725
+ });
3726
+ }
3127
3727
  async size() {
3128
- const keys = await this.keys();
3129
- return keys.length;
3728
+ let count = 0;
3729
+ await this.scanEntries(async () => {
3730
+ count += 1;
3731
+ });
3732
+ return count;
3130
3733
  }
3131
3734
  async ping() {
3132
3735
  try {
@@ -3160,6 +3763,113 @@ var DiskLayer = class {
3160
3763
  }
3161
3764
  return maxFiles;
3162
3765
  }
3766
+ normalizeMaxEntryBytes(maxEntryBytes) {
3767
+ if (maxEntryBytes === false) {
3768
+ return false;
3769
+ }
3770
+ const normalized = maxEntryBytes ?? 16 * 1024 * 1024;
3771
+ if (!Number.isFinite(normalized) || normalized <= 0) {
3772
+ throw new Error("DiskLayer.maxEntryBytes must be a positive number or false.");
3773
+ }
3774
+ return normalized;
3775
+ }
3776
+ async readEntryFile(filePath) {
3777
+ let handle;
3778
+ try {
3779
+ handle = await fs.open(filePath, "r");
3780
+ return await this.readHandleWithLimit(handle);
3781
+ } catch {
3782
+ await this.safeDelete(filePath);
3783
+ return null;
3784
+ } finally {
3785
+ await handle?.close().catch(() => void 0);
3786
+ }
3787
+ }
3788
+ async readHandleWithLimit(handle) {
3789
+ if (this.maxEntryBytes === false) {
3790
+ return handle.readFile();
3791
+ }
3792
+ const stat = await handle.stat();
3793
+ if (stat.size > this.maxEntryBytes) {
3794
+ throw new Error(`DiskLayer entry exceeds maxEntryBytes limit (${stat.size} bytes > ${this.maxEntryBytes} bytes).`);
3795
+ }
3796
+ const chunks = [];
3797
+ let totalBytes = 0;
3798
+ let position = 0;
3799
+ while (true) {
3800
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, this.maxEntryBytes - totalBytes + 1));
3801
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
3802
+ if (bytesRead === 0) {
3803
+ break;
3804
+ }
3805
+ totalBytes += bytesRead;
3806
+ if (totalBytes > this.maxEntryBytes) {
3807
+ throw new Error(
3808
+ `DiskLayer entry exceeds maxEntryBytes limit (${totalBytes} bytes > ${this.maxEntryBytes} bytes).`
3809
+ );
3810
+ }
3811
+ chunks.push(buffer.subarray(0, bytesRead));
3812
+ position += bytesRead;
3813
+ }
3814
+ return Buffer.concat(chunks);
3815
+ }
3816
+ async scanEntries(visitor) {
3817
+ let entries;
3818
+ try {
3819
+ entries = await fs.readdir(this.directory);
3820
+ } catch {
3821
+ return;
3822
+ }
3823
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
3824
+ let nextIndex = 0;
3825
+ const workerCount = Math.min(FILE_SCAN_CONCURRENCY, lcFiles.length);
3826
+ await Promise.all(
3827
+ Array.from({ length: workerCount }, async () => {
3828
+ while (true) {
3829
+ const currentIndex = nextIndex;
3830
+ nextIndex += 1;
3831
+ const name = lcFiles[currentIndex];
3832
+ if (name === void 0) {
3833
+ return;
3834
+ }
3835
+ const filePath = join(this.directory, name);
3836
+ const raw = await this.readEntryFile(filePath);
3837
+ if (raw === null) {
3838
+ continue;
3839
+ }
3840
+ let entry;
3841
+ try {
3842
+ entry = this.deserializeEntry(raw);
3843
+ } catch {
3844
+ await this.safeDelete(filePath);
3845
+ continue;
3846
+ }
3847
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
3848
+ await this.safeDelete(filePath);
3849
+ continue;
3850
+ }
3851
+ await visitor(entry);
3852
+ }
3853
+ })
3854
+ );
3855
+ }
3856
+ async deletePathsWithConcurrency(paths) {
3857
+ let nextIndex = 0;
3858
+ const workerCount = Math.min(FILE_SCAN_CONCURRENCY, paths.length);
3859
+ await Promise.all(
3860
+ Array.from({ length: workerCount }, async () => {
3861
+ while (true) {
3862
+ const currentIndex = nextIndex;
3863
+ nextIndex += 1;
3864
+ const filePath = paths[currentIndex];
3865
+ if (filePath === void 0) {
3866
+ return;
3867
+ }
3868
+ await this.safeDelete(filePath);
3869
+ }
3870
+ })
3871
+ );
3872
+ }
3163
3873
  deserializeEntry(raw) {
3164
3874
  const entry = this.serializer.deserialize(raw);
3165
3875
  if (!isDiskEntry(entry)) {
@@ -3296,18 +4006,27 @@ var MemcachedLayer = class {
3296
4006
  // src/serialization/MsgpackSerializer.ts
3297
4007
  import { decode, encode } from "@msgpack/msgpack";
3298
4008
  var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
4009
+ var MAX_SANITIZE_DEPTH3 = 64;
4010
+ var MAX_SANITIZE_NODES3 = 1e4;
3299
4011
  var MsgpackSerializer = class {
3300
4012
  serialize(value) {
3301
4013
  return Buffer.from(encode(value));
3302
4014
  }
3303
4015
  deserialize(payload) {
3304
- const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
3305
- return sanitizeMsgpackValue(decode(normalized));
4016
+ const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "latin1");
4017
+ return sanitizeMsgpackValue(decode(normalized), 0, { count: 0 });
3306
4018
  }
3307
4019
  };
3308
- function sanitizeMsgpackValue(value) {
4020
+ function sanitizeMsgpackValue(value, depth, state) {
4021
+ state.count += 1;
4022
+ if (state.count > MAX_SANITIZE_NODES3) {
4023
+ throw new Error(`MessagePack payload exceeds max node count of ${MAX_SANITIZE_NODES3}.`);
4024
+ }
4025
+ if (depth > MAX_SANITIZE_DEPTH3) {
4026
+ throw new Error(`MessagePack payload exceeds max depth of ${MAX_SANITIZE_DEPTH3}.`);
4027
+ }
3309
4028
  if (Array.isArray(value)) {
3310
- return value.map((entry) => sanitizeMsgpackValue(entry));
4029
+ return value.map((entry) => sanitizeMsgpackValue(entry, depth + 1, state));
3311
4030
  }
3312
4031
  if (!isPlainObject2(value)) {
3313
4032
  return value;
@@ -3317,7 +4036,7 @@ function sanitizeMsgpackValue(value) {
3317
4036
  if (DANGEROUS_KEYS2.has(key)) {
3318
4037
  continue;
3319
4038
  }
3320
- sanitized[key] = sanitizeMsgpackValue(entry);
4039
+ sanitized[key] = sanitizeMsgpackValue(entry, depth + 1, state);
3321
4040
  }
3322
4041
  return sanitized;
3323
4042
  }