layercache 1.0.2 → 1.1.0

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,12 +1,233 @@
1
1
  import {
2
2
  PatternMatcher,
3
3
  RedisTagIndex
4
- } from "./chunk-IILH5XTS.js";
4
+ } from "./chunk-QUB5VZFZ.js";
5
5
 
6
6
  // src/CacheStack.ts
7
7
  import { randomUUID } from "crypto";
8
- import { promises as fs } from "fs";
9
8
  import { EventEmitter } from "events";
9
+ import { promises as fs } from "fs";
10
+
11
+ // src/CacheNamespace.ts
12
+ var CacheNamespace = class {
13
+ constructor(cache, prefix) {
14
+ this.cache = cache;
15
+ this.prefix = prefix;
16
+ }
17
+ cache;
18
+ prefix;
19
+ async get(key, fetcher, options) {
20
+ return this.cache.get(this.qualify(key), fetcher, options);
21
+ }
22
+ async getOrSet(key, fetcher, options) {
23
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
24
+ }
25
+ async has(key) {
26
+ return this.cache.has(this.qualify(key));
27
+ }
28
+ async ttl(key) {
29
+ return this.cache.ttl(this.qualify(key));
30
+ }
31
+ async set(key, value, options) {
32
+ await this.cache.set(this.qualify(key), value, options);
33
+ }
34
+ async delete(key) {
35
+ await this.cache.delete(this.qualify(key));
36
+ }
37
+ async mdelete(keys) {
38
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
39
+ }
40
+ async clear() {
41
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
42
+ }
43
+ async mget(entries) {
44
+ return this.cache.mget(
45
+ entries.map((entry) => ({
46
+ ...entry,
47
+ key: this.qualify(entry.key)
48
+ }))
49
+ );
50
+ }
51
+ async mset(entries) {
52
+ await this.cache.mset(
53
+ entries.map((entry) => ({
54
+ ...entry,
55
+ key: this.qualify(entry.key)
56
+ }))
57
+ );
58
+ }
59
+ async invalidateByTag(tag) {
60
+ await this.cache.invalidateByTag(tag);
61
+ }
62
+ async invalidateByPattern(pattern) {
63
+ await this.cache.invalidateByPattern(this.qualify(pattern));
64
+ }
65
+ wrap(keyPrefix, fetcher, options) {
66
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
67
+ }
68
+ warm(entries, options) {
69
+ return this.cache.warm(
70
+ entries.map((entry) => ({
71
+ ...entry,
72
+ key: this.qualify(entry.key)
73
+ })),
74
+ options
75
+ );
76
+ }
77
+ getMetrics() {
78
+ return this.cache.getMetrics();
79
+ }
80
+ getHitRate() {
81
+ return this.cache.getHitRate();
82
+ }
83
+ qualify(key) {
84
+ return `${this.prefix}:${key}`;
85
+ }
86
+ };
87
+
88
+ // src/internal/CircuitBreakerManager.ts
89
+ var CircuitBreakerManager = class {
90
+ breakers = /* @__PURE__ */ new Map();
91
+ maxEntries;
92
+ constructor(options) {
93
+ this.maxEntries = options.maxEntries;
94
+ }
95
+ /**
96
+ * Throws if the circuit is open for the given key.
97
+ * Automatically resets if the cooldown has elapsed.
98
+ */
99
+ assertClosed(key, options) {
100
+ const state = this.breakers.get(key);
101
+ if (!state?.openUntil) {
102
+ return;
103
+ }
104
+ const now = Date.now();
105
+ if (state.openUntil <= now) {
106
+ state.openUntil = null;
107
+ state.failures = 0;
108
+ this.breakers.set(key, state);
109
+ return;
110
+ }
111
+ const remainingMs = state.openUntil - now;
112
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
113
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
114
+ }
115
+ recordFailure(key, options) {
116
+ if (!options) {
117
+ return;
118
+ }
119
+ const failureThreshold = options.failureThreshold ?? 3;
120
+ const cooldownMs = options.cooldownMs ?? 3e4;
121
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
122
+ state.failures += 1;
123
+ if (state.failures >= failureThreshold) {
124
+ state.openUntil = Date.now() + cooldownMs;
125
+ }
126
+ this.breakers.set(key, state);
127
+ this.pruneIfNeeded();
128
+ }
129
+ recordSuccess(key) {
130
+ this.breakers.delete(key);
131
+ }
132
+ isOpen(key) {
133
+ const state = this.breakers.get(key);
134
+ if (!state?.openUntil) {
135
+ return false;
136
+ }
137
+ if (state.openUntil <= Date.now()) {
138
+ state.openUntil = null;
139
+ state.failures = 0;
140
+ return false;
141
+ }
142
+ return true;
143
+ }
144
+ delete(key) {
145
+ this.breakers.delete(key);
146
+ }
147
+ clear() {
148
+ this.breakers.clear();
149
+ }
150
+ tripCount() {
151
+ let count = 0;
152
+ for (const state of this.breakers.values()) {
153
+ if (state.openUntil !== null) {
154
+ count += 1;
155
+ }
156
+ }
157
+ return count;
158
+ }
159
+ pruneIfNeeded() {
160
+ if (this.breakers.size <= this.maxEntries) {
161
+ return;
162
+ }
163
+ for (const [key, state] of this.breakers.entries()) {
164
+ if (this.breakers.size <= this.maxEntries) {
165
+ break;
166
+ }
167
+ if (!state.openUntil || state.openUntil <= Date.now()) {
168
+ this.breakers.delete(key);
169
+ }
170
+ }
171
+ for (const key of this.breakers.keys()) {
172
+ if (this.breakers.size <= this.maxEntries) {
173
+ break;
174
+ }
175
+ this.breakers.delete(key);
176
+ }
177
+ }
178
+ };
179
+
180
+ // src/internal/MetricsCollector.ts
181
+ var MetricsCollector = class {
182
+ data = this.empty();
183
+ get snapshot() {
184
+ return { ...this.data };
185
+ }
186
+ increment(field, amount = 1) {
187
+ ;
188
+ this.data[field] += amount;
189
+ }
190
+ incrementLayer(map, layerName) {
191
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
192
+ }
193
+ reset() {
194
+ this.data = this.empty();
195
+ }
196
+ hitRate() {
197
+ const total = this.data.hits + this.data.misses;
198
+ const overall = total === 0 ? 0 : this.data.hits / total;
199
+ const byLayer = {};
200
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
201
+ for (const layer of allLayers) {
202
+ const h = this.data.hitsByLayer[layer] ?? 0;
203
+ const m = this.data.missesByLayer[layer] ?? 0;
204
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
205
+ }
206
+ return { overall, byLayer };
207
+ }
208
+ empty() {
209
+ return {
210
+ hits: 0,
211
+ misses: 0,
212
+ fetches: 0,
213
+ sets: 0,
214
+ deletes: 0,
215
+ backfills: 0,
216
+ invalidations: 0,
217
+ staleHits: 0,
218
+ refreshes: 0,
219
+ refreshErrors: 0,
220
+ writeFailures: 0,
221
+ singleFlightWaits: 0,
222
+ negativeCacheHits: 0,
223
+ circuitBreakerTrips: 0,
224
+ degradedOperations: 0,
225
+ hitsByLayer: {},
226
+ missesByLayer: {},
227
+ resetAt: Date.now()
228
+ };
229
+ }
230
+ };
10
231
 
11
232
  // src/internal/StoredValue.ts
12
233
  function isStoredValueEnvelope(value) {
@@ -109,58 +330,91 @@ function normalizePositiveSeconds(value) {
109
330
  return value;
110
331
  }
111
332
 
112
- // src/CacheNamespace.ts
113
- var CacheNamespace = class {
114
- constructor(cache, prefix) {
115
- this.cache = cache;
116
- this.prefix = prefix;
117
- }
118
- cache;
119
- prefix;
120
- async get(key, fetcher, options) {
121
- return this.cache.get(this.qualify(key), fetcher, options);
122
- }
123
- async set(key, value, options) {
124
- await this.cache.set(this.qualify(key), value, options);
125
- }
126
- async delete(key) {
127
- await this.cache.delete(this.qualify(key));
333
+ // src/internal/TtlResolver.ts
334
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
335
+ var TtlResolver = class {
336
+ accessProfiles = /* @__PURE__ */ new Map();
337
+ maxProfileEntries;
338
+ constructor(options) {
339
+ this.maxProfileEntries = options.maxProfileEntries;
128
340
  }
129
- async clear() {
130
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
341
+ recordAccess(key) {
342
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
343
+ profile.hits += 1;
344
+ profile.lastAccessAt = Date.now();
345
+ this.accessProfiles.set(key, profile);
346
+ this.pruneIfNeeded();
131
347
  }
132
- async mget(entries) {
133
- return this.cache.mget(entries.map((entry) => ({
134
- ...entry,
135
- key: this.qualify(entry.key)
136
- })));
348
+ deleteProfile(key) {
349
+ this.accessProfiles.delete(key);
137
350
  }
138
- async mset(entries) {
139
- await this.cache.mset(entries.map((entry) => ({
140
- ...entry,
141
- key: this.qualify(entry.key)
142
- })));
351
+ clearProfiles() {
352
+ this.accessProfiles.clear();
143
353
  }
144
- async invalidateByTag(tag) {
145
- await this.cache.invalidateByTag(tag);
354
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
355
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
356
+ layerName,
357
+ options?.negativeTtl,
358
+ globalNegativeTtl,
359
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
360
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
361
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
362
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
363
+ return this.applyJitter(adaptiveTtl, jitter);
146
364
  }
147
- async invalidateByPattern(pattern) {
148
- await this.cache.invalidateByPattern(this.qualify(pattern));
365
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
366
+ if (override !== void 0) {
367
+ return this.readLayerNumber(layerName, override) ?? fallback;
368
+ }
369
+ if (globalDefault !== void 0) {
370
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
371
+ }
372
+ return fallback;
149
373
  }
150
- wrap(keyPrefix, fetcher, options) {
151
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
374
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
375
+ if (!ttl || !adaptiveTtl) {
376
+ return ttl;
377
+ }
378
+ const profile = this.accessProfiles.get(key);
379
+ if (!profile) {
380
+ return ttl;
381
+ }
382
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
383
+ const hotAfter = config.hotAfter ?? 3;
384
+ if (profile.hits < hotAfter) {
385
+ return ttl;
386
+ }
387
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
388
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
389
+ const multiplier = Math.floor(profile.hits / hotAfter);
390
+ return Math.min(maxTtl, ttl + step * multiplier);
152
391
  }
153
- warm(entries, options) {
154
- return this.cache.warm(entries.map((entry) => ({
155
- ...entry,
156
- key: this.qualify(entry.key)
157
- })), options);
392
+ applyJitter(ttl, jitter) {
393
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
394
+ return ttl;
395
+ }
396
+ const delta = (Math.random() * 2 - 1) * jitter;
397
+ return Math.max(1, Math.round(ttl + delta));
158
398
  }
159
- getMetrics() {
160
- return this.cache.getMetrics();
399
+ readLayerNumber(layerName, value) {
400
+ if (typeof value === "number") {
401
+ return value;
402
+ }
403
+ return value[layerName];
161
404
  }
162
- qualify(key) {
163
- return `${this.prefix}:${key}`;
405
+ pruneIfNeeded() {
406
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
407
+ return;
408
+ }
409
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
410
+ let removed = 0;
411
+ for (const key of this.accessProfiles.keys()) {
412
+ if (removed >= toRemove) {
413
+ break;
414
+ }
415
+ this.accessProfiles.delete(key);
416
+ removed += 1;
417
+ }
164
418
  }
165
419
  };
166
420
 
@@ -249,30 +503,11 @@ var StampedeGuard = class {
249
503
  };
250
504
 
251
505
  // src/CacheStack.ts
252
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
253
506
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
254
507
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
255
508
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
256
509
  var MAX_CACHE_KEY_LENGTH = 1024;
257
- var EMPTY_METRICS = () => ({
258
- hits: 0,
259
- misses: 0,
260
- fetches: 0,
261
- sets: 0,
262
- deletes: 0,
263
- backfills: 0,
264
- invalidations: 0,
265
- staleHits: 0,
266
- refreshes: 0,
267
- refreshErrors: 0,
268
- writeFailures: 0,
269
- singleFlightWaits: 0,
270
- negativeCacheHits: 0,
271
- circuitBreakerTrips: 0,
272
- degradedOperations: 0,
273
- hitsByLayer: {},
274
- missesByLayer: {}
275
- });
510
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
276
511
  var DebugLogger = class {
277
512
  enabled;
278
513
  constructor(enabled) {
@@ -307,6 +542,14 @@ var CacheStack = class extends EventEmitter {
307
542
  throw new Error("CacheStack requires at least one cache layer.");
308
543
  }
309
544
  this.validateConfiguration();
545
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
546
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
547
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
548
+ if (options.publishSetInvalidation !== void 0) {
549
+ console.warn(
550
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
551
+ );
552
+ }
310
553
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
311
554
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
312
555
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -315,36 +558,42 @@ var CacheStack = class extends EventEmitter {
315
558
  layers;
316
559
  options;
317
560
  stampedeGuard = new StampedeGuard();
318
- metrics = EMPTY_METRICS();
561
+ metricsCollector = new MetricsCollector();
319
562
  instanceId = randomUUID();
320
563
  startup;
321
564
  unsubscribeInvalidation;
322
565
  logger;
323
566
  tagIndex;
324
567
  backgroundRefreshes = /* @__PURE__ */ new Map();
325
- accessProfiles = /* @__PURE__ */ new Map();
326
568
  layerDegradedUntil = /* @__PURE__ */ new Map();
327
- circuitBreakers = /* @__PURE__ */ new Map();
569
+ ttlResolver;
570
+ circuitBreakerManager;
328
571
  isDisconnecting = false;
329
572
  disconnectPromise;
573
+ /**
574
+ * Read-through cache get.
575
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
576
+ * and stores the result across all layers. Returns `null` if the key is not found
577
+ * and no `fetcher` is provided.
578
+ */
330
579
  async get(key, fetcher, options) {
331
580
  const normalizedKey = this.validateCacheKey(key);
332
581
  this.validateWriteOptions(options);
333
582
  await this.startup;
334
583
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
335
584
  if (hit.found) {
336
- this.recordAccess(normalizedKey);
585
+ this.ttlResolver.recordAccess(normalizedKey);
337
586
  if (this.isNegativeStoredValue(hit.stored)) {
338
- this.metrics.negativeCacheHits += 1;
587
+ this.metricsCollector.increment("negativeCacheHits");
339
588
  }
340
589
  if (hit.state === "fresh") {
341
- this.metrics.hits += 1;
590
+ this.metricsCollector.increment("hits");
342
591
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
343
592
  return hit.value;
344
593
  }
345
594
  if (hit.state === "stale-while-revalidate") {
346
- this.metrics.hits += 1;
347
- this.metrics.staleHits += 1;
595
+ this.metricsCollector.increment("hits");
596
+ this.metricsCollector.increment("staleHits");
348
597
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
349
598
  if (fetcher) {
350
599
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -352,47 +601,136 @@ var CacheStack = class extends EventEmitter {
352
601
  return hit.value;
353
602
  }
354
603
  if (!fetcher) {
355
- this.metrics.hits += 1;
356
- this.metrics.staleHits += 1;
604
+ this.metricsCollector.increment("hits");
605
+ this.metricsCollector.increment("staleHits");
357
606
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
358
607
  return hit.value;
359
608
  }
360
609
  try {
361
610
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
362
611
  } catch (error) {
363
- this.metrics.staleHits += 1;
364
- this.metrics.refreshErrors += 1;
612
+ this.metricsCollector.increment("staleHits");
613
+ this.metricsCollector.increment("refreshErrors");
365
614
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
366
615
  return hit.value;
367
616
  }
368
617
  }
369
- this.metrics.misses += 1;
618
+ this.metricsCollector.increment("misses");
370
619
  if (!fetcher) {
371
620
  return null;
372
621
  }
373
622
  return this.fetchWithGuards(normalizedKey, fetcher, options);
374
623
  }
624
+ /**
625
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
626
+ * Fetches and caches the value if not already present.
627
+ */
628
+ async getOrSet(key, fetcher, options) {
629
+ return this.get(key, fetcher, options);
630
+ }
631
+ /**
632
+ * Returns true if the given key exists and is not expired in any layer.
633
+ */
634
+ async has(key) {
635
+ const normalizedKey = this.validateCacheKey(key);
636
+ await this.startup;
637
+ for (const layer of this.layers) {
638
+ if (this.shouldSkipLayer(layer)) {
639
+ continue;
640
+ }
641
+ if (layer.has) {
642
+ try {
643
+ const exists = await layer.has(normalizedKey);
644
+ if (exists) {
645
+ return true;
646
+ }
647
+ } catch {
648
+ }
649
+ } else {
650
+ try {
651
+ const value = await layer.get(normalizedKey);
652
+ if (value !== null) {
653
+ return true;
654
+ }
655
+ } catch {
656
+ }
657
+ }
658
+ }
659
+ return false;
660
+ }
661
+ /**
662
+ * Returns the remaining TTL in seconds for the key in the fastest layer
663
+ * that has it, or null if the key is not found / has no TTL.
664
+ */
665
+ async ttl(key) {
666
+ const normalizedKey = this.validateCacheKey(key);
667
+ await this.startup;
668
+ for (const layer of this.layers) {
669
+ if (this.shouldSkipLayer(layer)) {
670
+ continue;
671
+ }
672
+ if (layer.ttl) {
673
+ try {
674
+ const remaining = await layer.ttl(normalizedKey);
675
+ if (remaining !== null) {
676
+ return remaining;
677
+ }
678
+ } catch {
679
+ }
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+ /**
685
+ * Stores a value in all cache layers. Overwrites any existing value.
686
+ */
375
687
  async set(key, value, options) {
376
688
  const normalizedKey = this.validateCacheKey(key);
377
689
  this.validateWriteOptions(options);
378
690
  await this.startup;
379
691
  await this.storeEntry(normalizedKey, "value", value, options);
380
692
  }
693
+ /**
694
+ * Deletes the key from all layers and publishes an invalidation message.
695
+ */
381
696
  async delete(key) {
382
697
  const normalizedKey = this.validateCacheKey(key);
383
698
  await this.startup;
384
699
  await this.deleteKeys([normalizedKey]);
385
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
700
+ await this.publishInvalidation({
701
+ scope: "key",
702
+ keys: [normalizedKey],
703
+ sourceId: this.instanceId,
704
+ operation: "delete"
705
+ });
386
706
  }
387
707
  async clear() {
388
708
  await this.startup;
389
709
  await Promise.all(this.layers.map((layer) => layer.clear()));
390
710
  await this.tagIndex.clear();
391
- this.accessProfiles.clear();
392
- this.metrics.invalidations += 1;
711
+ this.ttlResolver.clearProfiles();
712
+ this.circuitBreakerManager.clear();
713
+ this.metricsCollector.increment("invalidations");
393
714
  this.logger.debug?.("clear");
394
715
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
395
716
  }
717
+ /**
718
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
719
+ */
720
+ async mdelete(keys) {
721
+ if (keys.length === 0) {
722
+ return;
723
+ }
724
+ await this.startup;
725
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
726
+ await this.deleteKeys(normalizedKeys);
727
+ await this.publishInvalidation({
728
+ scope: "keys",
729
+ keys: normalizedKeys,
730
+ sourceId: this.instanceId,
731
+ operation: "delete"
732
+ });
733
+ }
396
734
  async mget(entries) {
397
735
  if (entries.length === 0) {
398
736
  return [];
@@ -430,7 +768,9 @@ var CacheStack = class extends EventEmitter {
430
768
  const indexesByKey = /* @__PURE__ */ new Map();
431
769
  const resultsByKey = /* @__PURE__ */ new Map();
432
770
  for (let index = 0; index < normalizedEntries.length; index += 1) {
433
- const key = normalizedEntries[index].key;
771
+ const entry = normalizedEntries[index];
772
+ if (!entry) continue;
773
+ const key = entry.key;
434
774
  const indexes = indexesByKey.get(key) ?? [];
435
775
  indexes.push(index);
436
776
  indexesByKey.set(key, indexes);
@@ -438,6 +778,7 @@ var CacheStack = class extends EventEmitter {
438
778
  }
439
779
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
440
780
  const layer = this.layers[layerIndex];
781
+ if (!layer) continue;
441
782
  const keys = [...pending];
442
783
  if (keys.length === 0) {
443
784
  break;
@@ -446,7 +787,7 @@ var CacheStack = class extends EventEmitter {
446
787
  for (let offset = 0; offset < values.length; offset += 1) {
447
788
  const key = keys[offset];
448
789
  const stored = values[offset];
449
- if (stored === null) {
790
+ if (!key || stored === null) {
450
791
  continue;
451
792
  }
452
793
  const resolved = resolveStoredValue(stored);
@@ -458,13 +799,13 @@ var CacheStack = class extends EventEmitter {
458
799
  await this.backfill(key, stored, layerIndex - 1);
459
800
  resultsByKey.set(key, resolved.value);
460
801
  pending.delete(key);
461
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
802
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
462
803
  }
463
804
  }
464
805
  if (pending.size > 0) {
465
806
  for (const key of pending) {
466
807
  await this.tagIndex.remove(key);
467
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
808
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
468
809
  }
469
810
  }
470
811
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -479,26 +820,38 @@ var CacheStack = class extends EventEmitter {
479
820
  }
480
821
  async warm(entries, options = {}) {
481
822
  const concurrency = Math.max(1, options.concurrency ?? 4);
823
+ const total = entries.length;
824
+ let completed = 0;
482
825
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
483
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
826
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
484
827
  while (queue.length > 0) {
485
828
  const entry = queue.shift();
486
829
  if (!entry) {
487
830
  return;
488
831
  }
832
+ let success = false;
489
833
  try {
490
834
  await this.get(entry.key, entry.fetcher, entry.options);
491
835
  this.emit("warm", { key: entry.key });
836
+ success = true;
492
837
  } catch (error) {
493
838
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
494
839
  if (!options.continueOnError) {
495
840
  throw error;
496
841
  }
842
+ } finally {
843
+ completed += 1;
844
+ const progress = { completed, total, key: entry.key, success };
845
+ options.onProgress?.(progress);
497
846
  }
498
847
  }
499
848
  });
500
849
  await Promise.all(workers);
501
850
  }
851
+ /**
852
+ * Returns a cached version of `fetcher`. The cache key is derived from
853
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
854
+ */
502
855
  wrap(prefix, fetcher, options = {}) {
503
856
  return (...args) => {
504
857
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -506,6 +859,10 @@ var CacheStack = class extends EventEmitter {
506
859
  return this.get(key, () => fetcher(...args), options);
507
860
  };
508
861
  }
862
+ /**
863
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
864
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
865
+ */
509
866
  namespace(prefix) {
510
867
  return new CacheNamespace(this, prefix);
511
868
  }
@@ -522,7 +879,7 @@ var CacheStack = class extends EventEmitter {
522
879
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
523
880
  }
524
881
  getMetrics() {
525
- return { ...this.metrics };
882
+ return this.metricsCollector.snapshot;
526
883
  }
527
884
  getStats() {
528
885
  return {
@@ -536,7 +893,13 @@ var CacheStack = class extends EventEmitter {
536
893
  };
537
894
  }
538
895
  resetMetrics() {
539
- Object.assign(this.metrics, EMPTY_METRICS());
896
+ this.metricsCollector.reset();
897
+ }
898
+ /**
899
+ * Returns computed hit-rate statistics (overall and per-layer).
900
+ */
901
+ getHitRate() {
902
+ return this.metricsCollector.hitRate();
540
903
  }
541
904
  async exportState() {
542
905
  await this.startup;
@@ -565,10 +928,12 @@ var CacheStack = class extends EventEmitter {
565
928
  }
566
929
  async importState(entries) {
567
930
  await this.startup;
568
- await Promise.all(entries.map(async (entry) => {
569
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
570
- await this.tagIndex.touch(entry.key);
571
- }));
931
+ await Promise.all(
932
+ entries.map(async (entry) => {
933
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
934
+ await this.tagIndex.touch(entry.key);
935
+ })
936
+ );
572
937
  }
573
938
  async persistToFile(filePath) {
574
939
  const snapshot = await this.exportState();
@@ -576,11 +941,21 @@ var CacheStack = class extends EventEmitter {
576
941
  }
577
942
  async restoreFromFile(filePath) {
578
943
  const raw = await fs.readFile(filePath, "utf8");
579
- const snapshot = JSON.parse(raw);
580
- if (!this.isCacheSnapshotEntries(snapshot)) {
581
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
944
+ let parsed;
945
+ try {
946
+ parsed = JSON.parse(raw, (_key, value) => {
947
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
948
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
949
+ }
950
+ return value;
951
+ });
952
+ } catch (cause) {
953
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
954
+ }
955
+ if (!this.isCacheSnapshotEntries(parsed)) {
956
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
582
957
  }
583
- await this.importState(snapshot);
958
+ await this.importState(parsed);
584
959
  }
585
960
  async disconnect() {
586
961
  if (!this.disconnectPromise) {
@@ -605,7 +980,7 @@ var CacheStack = class extends EventEmitter {
605
980
  const fetchTask = async () => {
606
981
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
607
982
  if (secondHit.found) {
608
- this.metrics.hits += 1;
983
+ this.metricsCollector.increment("hits");
609
984
  return secondHit.value;
610
985
  }
611
986
  return this.fetchAndPopulate(key, fetcher, options);
@@ -630,12 +1005,12 @@ var CacheStack = class extends EventEmitter {
630
1005
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
631
1006
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
632
1007
  const deadline = Date.now() + timeoutMs;
633
- this.metrics.singleFlightWaits += 1;
1008
+ this.metricsCollector.increment("singleFlightWaits");
634
1009
  this.emit("stampede-dedupe", { key });
635
1010
  while (Date.now() < deadline) {
636
1011
  const hit = await this.readFromLayers(key, options, "fresh-only");
637
1012
  if (hit.found) {
638
- this.metrics.hits += 1;
1013
+ this.metricsCollector.increment("hits");
639
1014
  return hit.value;
640
1015
  }
641
1016
  await this.sleep(pollIntervalMs);
@@ -643,12 +1018,14 @@ var CacheStack = class extends EventEmitter {
643
1018
  return this.fetchAndPopulate(key, fetcher, options);
644
1019
  }
645
1020
  async fetchAndPopulate(key, fetcher, options) {
646
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
647
- this.metrics.fetches += 1;
1021
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1022
+ this.metricsCollector.increment("fetches");
1023
+ const fetchStart = Date.now();
648
1024
  let fetched;
649
1025
  try {
650
1026
  fetched = await fetcher();
651
- this.resetCircuitBreaker(key);
1027
+ this.circuitBreakerManager.recordSuccess(key);
1028
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
652
1029
  } catch (error) {
653
1030
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
654
1031
  throw error;
@@ -670,7 +1047,7 @@ var CacheStack = class extends EventEmitter {
670
1047
  } else {
671
1048
  await this.tagIndex.touch(key);
672
1049
  }
673
- this.metrics.sets += 1;
1050
+ this.metricsCollector.increment("sets");
674
1051
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
675
1052
  this.emit("set", { key, kind, tags: options?.tags });
676
1053
  if (this.shouldBroadcastL1Invalidation()) {
@@ -681,9 +1058,10 @@ var CacheStack = class extends EventEmitter {
681
1058
  let sawRetainableValue = false;
682
1059
  for (let index = 0; index < this.layers.length; index += 1) {
683
1060
  const layer = this.layers[index];
1061
+ if (!layer) continue;
684
1062
  const stored = await this.readLayerEntry(layer, key);
685
1063
  if (stored === null) {
686
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1064
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
687
1065
  continue;
688
1066
  }
689
1067
  const resolved = resolveStoredValue(stored);
@@ -697,10 +1075,17 @@ var CacheStack = class extends EventEmitter {
697
1075
  }
698
1076
  await this.tagIndex.touch(key);
699
1077
  await this.backfill(key, stored, index - 1, options);
700
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1078
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
701
1079
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
702
1080
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
703
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1081
+ return {
1082
+ found: true,
1083
+ value: resolved.value,
1084
+ stored,
1085
+ state: resolved.state,
1086
+ layerIndex: index,
1087
+ layerName: layer.name
1088
+ };
704
1089
  }
705
1090
  if (!sawRetainableValue) {
706
1091
  await this.tagIndex.remove(key);
@@ -732,7 +1117,7 @@ var CacheStack = class extends EventEmitter {
732
1117
  }
733
1118
  for (let index = 0; index <= upToIndex; index += 1) {
734
1119
  const layer = this.layers[index];
735
- if (this.shouldSkipLayer(layer)) {
1120
+ if (!layer || this.shouldSkipLayer(layer)) {
736
1121
  continue;
737
1122
  }
738
1123
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -742,7 +1127,7 @@ var CacheStack = class extends EventEmitter {
742
1127
  await this.handleLayerFailure(layer, "backfill", error);
743
1128
  continue;
744
1129
  }
745
- this.metrics.backfills += 1;
1130
+ this.metricsCollector.increment("backfills");
746
1131
  this.logger.debug?.("backfill", { key, layer: layer.name });
747
1132
  this.emit("backfill", { key, layer: layer.name });
748
1133
  }
@@ -759,11 +1144,7 @@ var CacheStack = class extends EventEmitter {
759
1144
  options?.staleWhileRevalidate,
760
1145
  this.options.staleWhileRevalidate
761
1146
  );
762
- const staleIfError = this.resolveLayerSeconds(
763
- layer.name,
764
- options?.staleIfError,
765
- this.options.staleIfError
766
- );
1147
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
767
1148
  const payload = createStoredValueEnvelope({
768
1149
  kind,
769
1150
  value,
@@ -791,7 +1172,7 @@ var CacheStack = class extends EventEmitter {
791
1172
  if (failures.length === 0) {
792
1173
  return;
793
1174
  }
794
- this.metrics.writeFailures += failures.length;
1175
+ this.metricsCollector.increment("writeFailures", failures.length);
795
1176
  this.logger.debug?.("write-failure", {
796
1177
  ...context,
797
1178
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -804,42 +1185,10 @@ var CacheStack = class extends EventEmitter {
804
1185
  }
805
1186
  }
806
1187
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
807
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
808
- layerName,
809
- options?.negativeTtl,
810
- this.options.negativeTtl,
811
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
812
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
813
- const adaptiveTtl = this.applyAdaptiveTtl(
814
- key,
815
- layerName,
816
- baseTtl,
817
- options?.adaptiveTtl ?? this.options.adaptiveTtl
818
- );
819
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
820
- return this.applyJitter(adaptiveTtl, jitter);
1188
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
821
1189
  }
822
1190
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
823
- if (override !== void 0) {
824
- return this.readLayerNumber(layerName, override) ?? fallback;
825
- }
826
- if (globalDefault !== void 0) {
827
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
828
- }
829
- return fallback;
830
- }
831
- readLayerNumber(layerName, value) {
832
- if (typeof value === "number") {
833
- return value;
834
- }
835
- return value[layerName];
836
- }
837
- applyJitter(ttl, jitter) {
838
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
839
- return ttl;
840
- }
841
- const delta = (Math.random() * 2 - 1) * jitter;
842
- return Math.max(1, Math.round(ttl + delta));
1191
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
843
1192
  }
844
1193
  shouldNegativeCache(options) {
845
1194
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -849,11 +1198,11 @@ var CacheStack = class extends EventEmitter {
849
1198
  return;
850
1199
  }
851
1200
  const refresh = (async () => {
852
- this.metrics.refreshes += 1;
1201
+ this.metricsCollector.increment("refreshes");
853
1202
  try {
854
1203
  await this.fetchWithGuards(key, fetcher, options);
855
1204
  } catch (error) {
856
- this.metrics.refreshErrors += 1;
1205
+ this.metricsCollector.increment("refreshErrors");
857
1206
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
858
1207
  } finally {
859
1208
  this.backgroundRefreshes.delete(key);
@@ -875,10 +1224,11 @@ var CacheStack = class extends EventEmitter {
875
1224
  await this.deleteKeysFromLayers(this.layers, keys);
876
1225
  for (const key of keys) {
877
1226
  await this.tagIndex.remove(key);
878
- this.accessProfiles.delete(key);
1227
+ this.ttlResolver.deleteProfile(key);
1228
+ this.circuitBreakerManager.delete(key);
879
1229
  }
880
- this.metrics.deletes += keys.length;
881
- this.metrics.invalidations += 1;
1230
+ this.metricsCollector.increment("deletes", keys.length);
1231
+ this.metricsCollector.increment("invalidations");
882
1232
  this.logger.debug?.("delete", { keys });
883
1233
  this.emit("delete", { keys });
884
1234
  }
@@ -899,7 +1249,7 @@ var CacheStack = class extends EventEmitter {
899
1249
  if (message.scope === "clear") {
900
1250
  await Promise.all(localLayers.map((layer) => layer.clear()));
901
1251
  await this.tagIndex.clear();
902
- this.accessProfiles.clear();
1252
+ this.ttlResolver.clearProfiles();
903
1253
  return;
904
1254
  }
905
1255
  const keys = message.keys ?? [];
@@ -907,7 +1257,7 @@ var CacheStack = class extends EventEmitter {
907
1257
  if (message.operation !== "write") {
908
1258
  for (const key of keys) {
909
1259
  await this.tagIndex.remove(key);
910
- this.accessProfiles.delete(key);
1260
+ this.ttlResolver.deleteProfile(key);
911
1261
  }
912
1262
  }
913
1263
  }
@@ -937,13 +1287,15 @@ var CacheStack = class extends EventEmitter {
937
1287
  }
938
1288
  return;
939
1289
  }
940
- await Promise.all(keys.map(async (key) => {
941
- try {
942
- await layer.delete(key);
943
- } catch (error) {
944
- await this.handleLayerFailure(layer, "delete", error);
945
- }
946
- }));
1290
+ await Promise.all(
1291
+ keys.map(async (key) => {
1292
+ try {
1293
+ await layer.delete(key);
1294
+ } catch (error) {
1295
+ await this.handleLayerFailure(layer, "delete", error);
1296
+ }
1297
+ })
1298
+ );
947
1299
  })
948
1300
  );
949
1301
  }
@@ -1044,7 +1396,7 @@ var CacheStack = class extends EventEmitter {
1044
1396
  const ttl = remainingStoredTtlSeconds(refreshed);
1045
1397
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1046
1398
  const layer = this.layers[index];
1047
- if (this.shouldSkipLayer(layer)) {
1399
+ if (!layer || this.shouldSkipLayer(layer)) {
1048
1400
  continue;
1049
1401
  }
1050
1402
  try {
@@ -1058,33 +1410,6 @@ var CacheStack = class extends EventEmitter {
1058
1410
  this.scheduleBackgroundRefresh(key, fetcher, options);
1059
1411
  }
1060
1412
  }
1061
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1062
- if (!ttl || !adaptiveTtl) {
1063
- return ttl;
1064
- }
1065
- const profile = this.accessProfiles.get(key);
1066
- if (!profile) {
1067
- return ttl;
1068
- }
1069
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1070
- const hotAfter = config.hotAfter ?? 3;
1071
- if (profile.hits < hotAfter) {
1072
- return ttl;
1073
- }
1074
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1075
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1076
- const multiplier = Math.floor(profile.hits / hotAfter);
1077
- return Math.min(maxTtl, ttl + step * multiplier);
1078
- }
1079
- recordAccess(key) {
1080
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1081
- profile.hits += 1;
1082
- profile.lastAccessAt = Date.now();
1083
- this.accessProfiles.set(key, profile);
1084
- }
1085
- incrementMetricMap(target, key) {
1086
- target[key] = (target[key] ?? 0) + 1;
1087
- }
1088
1413
  shouldSkipLayer(layer) {
1089
1414
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1090
1415
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1095,7 +1420,7 @@ var CacheStack = class extends EventEmitter {
1095
1420
  }
1096
1421
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1097
1422
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1098
- this.metrics.degradedOperations += 1;
1423
+ this.metricsCollector.increment("degradedOperations");
1099
1424
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1100
1425
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1101
1426
  return null;
@@ -1103,37 +1428,15 @@ var CacheStack = class extends EventEmitter {
1103
1428
  isGracefulDegradationEnabled() {
1104
1429
  return Boolean(this.options.gracefulDegradation);
1105
1430
  }
1106
- assertCircuitClosed(key, options) {
1107
- const state = this.circuitBreakers.get(key);
1108
- if (!state?.openUntil) {
1109
- return;
1110
- }
1111
- if (state.openUntil <= Date.now()) {
1112
- state.openUntil = null;
1113
- state.failures = 0;
1114
- this.circuitBreakers.set(key, state);
1115
- return;
1116
- }
1117
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1118
- throw new Error(`Circuit breaker is open for key "${key}".`);
1119
- }
1120
1431
  recordCircuitFailure(key, options, error) {
1121
1432
  if (!options) {
1122
1433
  return;
1123
1434
  }
1124
- const failureThreshold = options.failureThreshold ?? 3;
1125
- const cooldownMs = options.cooldownMs ?? 3e4;
1126
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1127
- state.failures += 1;
1128
- if (state.failures >= failureThreshold) {
1129
- state.openUntil = Date.now() + cooldownMs;
1130
- this.metrics.circuitBreakerTrips += 1;
1435
+ this.circuitBreakerManager.recordFailure(key, options);
1436
+ if (this.circuitBreakerManager.isOpen(key)) {
1437
+ this.metricsCollector.increment("circuitBreakerTrips");
1131
1438
  }
1132
- this.circuitBreakers.set(key, state);
1133
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1134
- }
1135
- resetCircuitBreaker(key) {
1136
- this.circuitBreakers.delete(key);
1439
+ this.emitError("fetch", { key, error: this.formatError(error) });
1137
1440
  }
1138
1441
  isNegativeStoredValue(stored) {
1139
1442
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
@@ -1323,11 +1626,13 @@ var MemoryLayer = class {
1323
1626
  defaultTtl;
1324
1627
  isLocal = true;
1325
1628
  maxSize;
1629
+ evictionPolicy;
1326
1630
  entries = /* @__PURE__ */ new Map();
1327
1631
  constructor(options = {}) {
1328
1632
  this.name = options.name ?? "memory";
1329
1633
  this.defaultTtl = options.ttl;
1330
1634
  this.maxSize = options.maxSize ?? 1e3;
1635
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
1331
1636
  }
1332
1637
  async get(key) {
1333
1638
  const value = await this.getEntry(key);
@@ -1342,8 +1647,13 @@ var MemoryLayer = class {
1342
1647
  this.entries.delete(key);
1343
1648
  return null;
1344
1649
  }
1345
- this.entries.delete(key);
1346
- this.entries.set(key, entry);
1650
+ if (this.evictionPolicy === "lru") {
1651
+ this.entries.delete(key);
1652
+ entry.frequency += 1;
1653
+ this.entries.set(key, entry);
1654
+ } else {
1655
+ entry.frequency += 1;
1656
+ }
1347
1657
  return entry.value;
1348
1658
  }
1349
1659
  async getMany(keys) {
@@ -1357,16 +1667,43 @@ var MemoryLayer = class {
1357
1667
  this.entries.delete(key);
1358
1668
  this.entries.set(key, {
1359
1669
  value,
1360
- expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
1670
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1671
+ frequency: 0,
1672
+ insertedAt: Date.now()
1361
1673
  });
1362
1674
  while (this.entries.size > this.maxSize) {
1363
- const oldestKey = this.entries.keys().next().value;
1364
- if (!oldestKey) {
1365
- break;
1366
- }
1367
- this.entries.delete(oldestKey);
1675
+ this.evict();
1368
1676
  }
1369
1677
  }
1678
+ async has(key) {
1679
+ const entry = this.entries.get(key);
1680
+ if (!entry) {
1681
+ return false;
1682
+ }
1683
+ if (this.isExpired(entry)) {
1684
+ this.entries.delete(key);
1685
+ return false;
1686
+ }
1687
+ return true;
1688
+ }
1689
+ async ttl(key) {
1690
+ const entry = this.entries.get(key);
1691
+ if (!entry) {
1692
+ return null;
1693
+ }
1694
+ if (this.isExpired(entry)) {
1695
+ this.entries.delete(key);
1696
+ return null;
1697
+ }
1698
+ if (entry.expiresAt === null) {
1699
+ return null;
1700
+ }
1701
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
1702
+ }
1703
+ async size() {
1704
+ this.pruneExpired();
1705
+ return this.entries.size;
1706
+ }
1370
1707
  async delete(key) {
1371
1708
  this.entries.delete(key);
1372
1709
  }
@@ -1397,15 +1734,35 @@ var MemoryLayer = class {
1397
1734
  }
1398
1735
  this.entries.set(entry.key, {
1399
1736
  value: entry.value,
1400
- expiresAt: entry.expiresAt
1737
+ expiresAt: entry.expiresAt,
1738
+ frequency: 0,
1739
+ insertedAt: Date.now()
1401
1740
  });
1402
1741
  }
1403
1742
  while (this.entries.size > this.maxSize) {
1743
+ this.evict();
1744
+ }
1745
+ }
1746
+ evict() {
1747
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
1404
1748
  const oldestKey = this.entries.keys().next().value;
1405
- if (!oldestKey) {
1406
- break;
1749
+ if (oldestKey !== void 0) {
1750
+ this.entries.delete(oldestKey);
1407
1751
  }
1408
- this.entries.delete(oldestKey);
1752
+ return;
1753
+ }
1754
+ let victimKey;
1755
+ let minFreq = Number.POSITIVE_INFINITY;
1756
+ let minInsertedAt = Number.POSITIVE_INFINITY;
1757
+ for (const [key, entry] of this.entries.entries()) {
1758
+ if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1759
+ minFreq = entry.frequency;
1760
+ minInsertedAt = entry.insertedAt;
1761
+ victimKey = key;
1762
+ }
1763
+ }
1764
+ if (victimKey !== void 0) {
1765
+ this.entries.delete(victimKey);
1409
1766
  }
1410
1767
  }
1411
1768
  pruneExpired() {
@@ -1421,7 +1778,7 @@ var MemoryLayer = class {
1421
1778
  };
1422
1779
 
1423
1780
  // src/layers/RedisLayer.ts
1424
- import { brotliCompressSync, brotliDecompressSync, gzipSync, gunzipSync } from "zlib";
1781
+ import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "zlib";
1425
1782
 
1426
1783
  // src/serialization/JsonSerializer.ts
1427
1784
  var JsonSerializer = class {
@@ -1435,6 +1792,7 @@ var JsonSerializer = class {
1435
1792
  };
1436
1793
 
1437
1794
  // src/layers/RedisLayer.ts
1795
+ var BATCH_DELETE_SIZE = 500;
1438
1796
  var RedisLayer = class {
1439
1797
  name;
1440
1798
  defaultTtl;
@@ -1486,7 +1844,7 @@ var RedisLayer = class {
1486
1844
  if (error || payload === null || !this.isSerializablePayload(payload)) {
1487
1845
  return null;
1488
1846
  }
1489
- return this.deserializeOrDelete(keys[index], payload);
1847
+ return this.deserializeOrDelete(keys[index] ?? "", payload);
1490
1848
  })
1491
1849
  );
1492
1850
  }
@@ -1508,14 +1866,44 @@ var RedisLayer = class {
1508
1866
  }
1509
1867
  await this.client.del(...keys.map((key) => this.withPrefix(key)));
1510
1868
  }
1511
- async clear() {
1512
- if (!this.prefix && !this.allowUnprefixedClear) {
1513
- throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
1869
+ async has(key) {
1870
+ const exists = await this.client.exists(this.withPrefix(key));
1871
+ return exists > 0;
1872
+ }
1873
+ async ttl(key) {
1874
+ const remaining = await this.client.ttl(this.withPrefix(key));
1875
+ if (remaining < 0) {
1876
+ return null;
1514
1877
  }
1878
+ return remaining;
1879
+ }
1880
+ async size() {
1515
1881
  const keys = await this.keys();
1516
- if (keys.length > 0) {
1517
- await this.deleteMany(keys);
1882
+ return keys.length;
1883
+ }
1884
+ /**
1885
+ * Deletes all keys matching the layer's prefix in batches to avoid
1886
+ * loading millions of keys into memory at once.
1887
+ */
1888
+ async clear() {
1889
+ if (!this.prefix && !this.allowUnprefixedClear) {
1890
+ throw new Error(
1891
+ "RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
1892
+ );
1518
1893
  }
1894
+ const pattern = `${this.prefix}*`;
1895
+ let cursor = "0";
1896
+ do {
1897
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
1898
+ cursor = nextCursor;
1899
+ if (keys.length === 0) {
1900
+ continue;
1901
+ }
1902
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
1903
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
1904
+ await this.client.del(...batch);
1905
+ }
1906
+ } while (cursor !== "0");
1519
1907
  }
1520
1908
  async keys() {
1521
1909
  const pattern = `${this.prefix}*`;
@@ -1575,6 +1963,170 @@ var RedisLayer = class {
1575
1963
  }
1576
1964
  };
1577
1965
 
1966
+ // src/layers/DiskLayer.ts
1967
+ import { createHash } from "crypto";
1968
+ import { promises as fs2 } from "fs";
1969
+ import { join } from "path";
1970
+ var DiskLayer = class {
1971
+ name;
1972
+ defaultTtl;
1973
+ isLocal = true;
1974
+ directory;
1975
+ serializer;
1976
+ constructor(options) {
1977
+ this.directory = options.directory;
1978
+ this.defaultTtl = options.ttl;
1979
+ this.name = options.name ?? "disk";
1980
+ this.serializer = options.serializer ?? new JsonSerializer();
1981
+ }
1982
+ async get(key) {
1983
+ return unwrapStoredValue(await this.getEntry(key));
1984
+ }
1985
+ async getEntry(key) {
1986
+ const filePath = this.keyToPath(key);
1987
+ let raw;
1988
+ try {
1989
+ raw = await fs2.readFile(filePath);
1990
+ } catch {
1991
+ return null;
1992
+ }
1993
+ let entry;
1994
+ try {
1995
+ entry = this.serializer.deserialize(raw);
1996
+ } catch {
1997
+ await this.safeDelete(filePath);
1998
+ return null;
1999
+ }
2000
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2001
+ await this.safeDelete(filePath);
2002
+ return null;
2003
+ }
2004
+ return entry.value;
2005
+ }
2006
+ async set(key, value, ttl = this.defaultTtl) {
2007
+ await fs2.mkdir(this.directory, { recursive: true });
2008
+ const entry = {
2009
+ value,
2010
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2011
+ };
2012
+ const payload = this.serializer.serialize(entry);
2013
+ await fs2.writeFile(this.keyToPath(key), payload);
2014
+ }
2015
+ async has(key) {
2016
+ const value = await this.getEntry(key);
2017
+ return value !== null;
2018
+ }
2019
+ async ttl(key) {
2020
+ const filePath = this.keyToPath(key);
2021
+ let raw;
2022
+ try {
2023
+ raw = await fs2.readFile(filePath);
2024
+ } catch {
2025
+ return null;
2026
+ }
2027
+ let entry;
2028
+ try {
2029
+ entry = this.serializer.deserialize(raw);
2030
+ } catch {
2031
+ return null;
2032
+ }
2033
+ if (entry.expiresAt === null) {
2034
+ return null;
2035
+ }
2036
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
2037
+ if (remaining <= 0) {
2038
+ return null;
2039
+ }
2040
+ return remaining;
2041
+ }
2042
+ async delete(key) {
2043
+ await this.safeDelete(this.keyToPath(key));
2044
+ }
2045
+ async deleteMany(keys) {
2046
+ await Promise.all(keys.map((key) => this.delete(key)));
2047
+ }
2048
+ async clear() {
2049
+ let entries;
2050
+ try {
2051
+ entries = await fs2.readdir(this.directory);
2052
+ } catch {
2053
+ return;
2054
+ }
2055
+ await Promise.all(
2056
+ entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
2057
+ );
2058
+ }
2059
+ async keys() {
2060
+ let entries;
2061
+ try {
2062
+ entries = await fs2.readdir(this.directory);
2063
+ } catch {
2064
+ return [];
2065
+ }
2066
+ return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2067
+ }
2068
+ async size() {
2069
+ const keys = await this.keys();
2070
+ return keys.length;
2071
+ }
2072
+ keyToPath(key) {
2073
+ const hash = createHash("sha256").update(key).digest("hex");
2074
+ return join(this.directory, `${hash}.lc`);
2075
+ }
2076
+ async safeDelete(filePath) {
2077
+ try {
2078
+ await fs2.unlink(filePath);
2079
+ } catch {
2080
+ }
2081
+ }
2082
+ };
2083
+
2084
+ // src/layers/MemcachedLayer.ts
2085
+ var MemcachedLayer = class {
2086
+ name;
2087
+ defaultTtl;
2088
+ isLocal = false;
2089
+ client;
2090
+ keyPrefix;
2091
+ constructor(options) {
2092
+ this.client = options.client;
2093
+ this.defaultTtl = options.ttl;
2094
+ this.name = options.name ?? "memcached";
2095
+ this.keyPrefix = options.keyPrefix ?? "";
2096
+ }
2097
+ async get(key) {
2098
+ const result = await this.client.get(this.withPrefix(key));
2099
+ if (!result || result.value === null) {
2100
+ return null;
2101
+ }
2102
+ try {
2103
+ return JSON.parse(result.value.toString("utf8"));
2104
+ } catch {
2105
+ return null;
2106
+ }
2107
+ }
2108
+ async set(key, value, ttl = this.defaultTtl) {
2109
+ const payload = JSON.stringify(value);
2110
+ await this.client.set(this.withPrefix(key), payload, {
2111
+ expires: ttl && ttl > 0 ? ttl : void 0
2112
+ });
2113
+ }
2114
+ async delete(key) {
2115
+ await this.client.delete(this.withPrefix(key));
2116
+ }
2117
+ async deleteMany(keys) {
2118
+ await Promise.all(keys.map((key) => this.delete(key)));
2119
+ }
2120
+ async clear() {
2121
+ throw new Error(
2122
+ "MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
2123
+ );
2124
+ }
2125
+ withPrefix(key) {
2126
+ return `${this.keyPrefix}${key}`;
2127
+ }
2128
+ };
2129
+
1578
2130
  // src/serialization/MsgpackSerializer.ts
1579
2131
  import { decode, encode } from "@msgpack/msgpack";
1580
2132
  var MsgpackSerializer = class {
@@ -1616,10 +2168,79 @@ var RedisSingleFlightCoordinator = class {
1616
2168
  return waiter();
1617
2169
  }
1618
2170
  };
2171
+
2172
+ // src/metrics/PrometheusExporter.ts
2173
+ function createPrometheusMetricsExporter(stacks) {
2174
+ return () => {
2175
+ const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
2176
+ const lines = [];
2177
+ lines.push("# HELP layercache_hits_total Total number of cache hits");
2178
+ lines.push("# TYPE layercache_hits_total counter");
2179
+ lines.push("# HELP layercache_misses_total Total number of cache misses");
2180
+ lines.push("# TYPE layercache_misses_total counter");
2181
+ lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
2182
+ lines.push("# TYPE layercache_fetches_total counter");
2183
+ lines.push("# HELP layercache_sets_total Total number of cache sets");
2184
+ lines.push("# TYPE layercache_sets_total counter");
2185
+ lines.push("# HELP layercache_deletes_total Total number of cache deletes");
2186
+ lines.push("# TYPE layercache_deletes_total counter");
2187
+ lines.push("# HELP layercache_backfills_total Total number of backfill operations");
2188
+ lines.push("# TYPE layercache_backfills_total counter");
2189
+ lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
2190
+ lines.push("# TYPE layercache_stale_hits_total counter");
2191
+ lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
2192
+ lines.push("# TYPE layercache_refreshes_total counter");
2193
+ lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
2194
+ lines.push("# TYPE layercache_refresh_errors_total counter");
2195
+ lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
2196
+ lines.push("# TYPE layercache_negative_cache_hits_total counter");
2197
+ lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
2198
+ lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
2199
+ lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
2200
+ lines.push("# TYPE layercache_degraded_operations_total counter");
2201
+ lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
2202
+ lines.push("# TYPE layercache_hit_rate gauge");
2203
+ lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
2204
+ lines.push("# TYPE layercache_hits_by_layer_total counter");
2205
+ lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2206
+ lines.push("# TYPE layercache_misses_by_layer_total counter");
2207
+ for (const { stack, name } of entries) {
2208
+ const m = stack.getMetrics();
2209
+ const hr = stack.getHitRate();
2210
+ const label = `cache="${sanitizeLabel(name)}"`;
2211
+ lines.push(`layercache_hits_total{${label}} ${m.hits}`);
2212
+ lines.push(`layercache_misses_total{${label}} ${m.misses}`);
2213
+ lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
2214
+ lines.push(`layercache_sets_total{${label}} ${m.sets}`);
2215
+ lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
2216
+ lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
2217
+ lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
2218
+ lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
2219
+ lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
2220
+ lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
2221
+ lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
2222
+ lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
2223
+ lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
2224
+ for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
2225
+ lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2226
+ }
2227
+ for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2228
+ lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2229
+ }
2230
+ }
2231
+ lines.push("");
2232
+ return lines.join("\n");
2233
+ };
2234
+ }
2235
+ function sanitizeLabel(value) {
2236
+ return value.replace(/["\\\n]/g, "_");
2237
+ }
1619
2238
  export {
1620
2239
  CacheNamespace,
1621
2240
  CacheStack,
2241
+ DiskLayer,
1622
2242
  JsonSerializer,
2243
+ MemcachedLayer,
1623
2244
  MemoryLayer,
1624
2245
  MsgpackSerializer,
1625
2246
  PatternMatcher,
@@ -1633,5 +2254,6 @@ export {
1633
2254
  createCacheStatsHandler,
1634
2255
  createCachedMethodDecorator,
1635
2256
  createFastifyLayercachePlugin,
2257
+ createPrometheusMetricsExporter,
1636
2258
  createTrpcCacheMiddleware
1637
2259
  };