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.cjs CHANGED
@@ -22,7 +22,9 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CacheNamespace: () => CacheNamespace,
24
24
  CacheStack: () => CacheStack,
25
+ DiskLayer: () => DiskLayer,
25
26
  JsonSerializer: () => JsonSerializer,
27
+ MemcachedLayer: () => MemcachedLayer,
26
28
  MemoryLayer: () => MemoryLayer,
27
29
  MsgpackSerializer: () => MsgpackSerializer,
28
30
  PatternMatcher: () => PatternMatcher,
@@ -36,14 +38,236 @@ __export(index_exports, {
36
38
  createCacheStatsHandler: () => createCacheStatsHandler,
37
39
  createCachedMethodDecorator: () => createCachedMethodDecorator,
38
40
  createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
41
+ createPrometheusMetricsExporter: () => createPrometheusMetricsExporter,
39
42
  createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
40
43
  });
41
44
  module.exports = __toCommonJS(index_exports);
42
45
 
43
46
  // src/CacheStack.ts
44
47
  var import_node_crypto = require("crypto");
45
- var import_node_fs = require("fs");
46
48
  var import_node_events = require("events");
49
+ var import_node_fs = require("fs");
50
+
51
+ // src/CacheNamespace.ts
52
+ var CacheNamespace = class {
53
+ constructor(cache, prefix) {
54
+ this.cache = cache;
55
+ this.prefix = prefix;
56
+ }
57
+ cache;
58
+ prefix;
59
+ async get(key, fetcher, options) {
60
+ return this.cache.get(this.qualify(key), fetcher, options);
61
+ }
62
+ async getOrSet(key, fetcher, options) {
63
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
64
+ }
65
+ async has(key) {
66
+ return this.cache.has(this.qualify(key));
67
+ }
68
+ async ttl(key) {
69
+ return this.cache.ttl(this.qualify(key));
70
+ }
71
+ async set(key, value, options) {
72
+ await this.cache.set(this.qualify(key), value, options);
73
+ }
74
+ async delete(key) {
75
+ await this.cache.delete(this.qualify(key));
76
+ }
77
+ async mdelete(keys) {
78
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
79
+ }
80
+ async clear() {
81
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
82
+ }
83
+ async mget(entries) {
84
+ return this.cache.mget(
85
+ entries.map((entry) => ({
86
+ ...entry,
87
+ key: this.qualify(entry.key)
88
+ }))
89
+ );
90
+ }
91
+ async mset(entries) {
92
+ await this.cache.mset(
93
+ entries.map((entry) => ({
94
+ ...entry,
95
+ key: this.qualify(entry.key)
96
+ }))
97
+ );
98
+ }
99
+ async invalidateByTag(tag) {
100
+ await this.cache.invalidateByTag(tag);
101
+ }
102
+ async invalidateByPattern(pattern) {
103
+ await this.cache.invalidateByPattern(this.qualify(pattern));
104
+ }
105
+ wrap(keyPrefix, fetcher, options) {
106
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
107
+ }
108
+ warm(entries, options) {
109
+ return this.cache.warm(
110
+ entries.map((entry) => ({
111
+ ...entry,
112
+ key: this.qualify(entry.key)
113
+ })),
114
+ options
115
+ );
116
+ }
117
+ getMetrics() {
118
+ return this.cache.getMetrics();
119
+ }
120
+ getHitRate() {
121
+ return this.cache.getHitRate();
122
+ }
123
+ qualify(key) {
124
+ return `${this.prefix}:${key}`;
125
+ }
126
+ };
127
+
128
+ // src/internal/CircuitBreakerManager.ts
129
+ var CircuitBreakerManager = class {
130
+ breakers = /* @__PURE__ */ new Map();
131
+ maxEntries;
132
+ constructor(options) {
133
+ this.maxEntries = options.maxEntries;
134
+ }
135
+ /**
136
+ * Throws if the circuit is open for the given key.
137
+ * Automatically resets if the cooldown has elapsed.
138
+ */
139
+ assertClosed(key, options) {
140
+ const state = this.breakers.get(key);
141
+ if (!state?.openUntil) {
142
+ return;
143
+ }
144
+ const now = Date.now();
145
+ if (state.openUntil <= now) {
146
+ state.openUntil = null;
147
+ state.failures = 0;
148
+ this.breakers.set(key, state);
149
+ return;
150
+ }
151
+ const remainingMs = state.openUntil - now;
152
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
153
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
154
+ }
155
+ recordFailure(key, options) {
156
+ if (!options) {
157
+ return;
158
+ }
159
+ const failureThreshold = options.failureThreshold ?? 3;
160
+ const cooldownMs = options.cooldownMs ?? 3e4;
161
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
162
+ state.failures += 1;
163
+ if (state.failures >= failureThreshold) {
164
+ state.openUntil = Date.now() + cooldownMs;
165
+ }
166
+ this.breakers.set(key, state);
167
+ this.pruneIfNeeded();
168
+ }
169
+ recordSuccess(key) {
170
+ this.breakers.delete(key);
171
+ }
172
+ isOpen(key) {
173
+ const state = this.breakers.get(key);
174
+ if (!state?.openUntil) {
175
+ return false;
176
+ }
177
+ if (state.openUntil <= Date.now()) {
178
+ state.openUntil = null;
179
+ state.failures = 0;
180
+ return false;
181
+ }
182
+ return true;
183
+ }
184
+ delete(key) {
185
+ this.breakers.delete(key);
186
+ }
187
+ clear() {
188
+ this.breakers.clear();
189
+ }
190
+ tripCount() {
191
+ let count = 0;
192
+ for (const state of this.breakers.values()) {
193
+ if (state.openUntil !== null) {
194
+ count += 1;
195
+ }
196
+ }
197
+ return count;
198
+ }
199
+ pruneIfNeeded() {
200
+ if (this.breakers.size <= this.maxEntries) {
201
+ return;
202
+ }
203
+ for (const [key, state] of this.breakers.entries()) {
204
+ if (this.breakers.size <= this.maxEntries) {
205
+ break;
206
+ }
207
+ if (!state.openUntil || state.openUntil <= Date.now()) {
208
+ this.breakers.delete(key);
209
+ }
210
+ }
211
+ for (const key of this.breakers.keys()) {
212
+ if (this.breakers.size <= this.maxEntries) {
213
+ break;
214
+ }
215
+ this.breakers.delete(key);
216
+ }
217
+ }
218
+ };
219
+
220
+ // src/internal/MetricsCollector.ts
221
+ var MetricsCollector = class {
222
+ data = this.empty();
223
+ get snapshot() {
224
+ return { ...this.data };
225
+ }
226
+ increment(field, amount = 1) {
227
+ ;
228
+ this.data[field] += amount;
229
+ }
230
+ incrementLayer(map, layerName) {
231
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
232
+ }
233
+ reset() {
234
+ this.data = this.empty();
235
+ }
236
+ hitRate() {
237
+ const total = this.data.hits + this.data.misses;
238
+ const overall = total === 0 ? 0 : this.data.hits / total;
239
+ const byLayer = {};
240
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
241
+ for (const layer of allLayers) {
242
+ const h = this.data.hitsByLayer[layer] ?? 0;
243
+ const m = this.data.missesByLayer[layer] ?? 0;
244
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
245
+ }
246
+ return { overall, byLayer };
247
+ }
248
+ empty() {
249
+ return {
250
+ hits: 0,
251
+ misses: 0,
252
+ fetches: 0,
253
+ sets: 0,
254
+ deletes: 0,
255
+ backfills: 0,
256
+ invalidations: 0,
257
+ staleHits: 0,
258
+ refreshes: 0,
259
+ refreshErrors: 0,
260
+ writeFailures: 0,
261
+ singleFlightWaits: 0,
262
+ negativeCacheHits: 0,
263
+ circuitBreakerTrips: 0,
264
+ degradedOperations: 0,
265
+ hitsByLayer: {},
266
+ missesByLayer: {},
267
+ resetAt: Date.now()
268
+ };
269
+ }
270
+ };
47
271
 
48
272
  // src/internal/StoredValue.ts
49
273
  function isStoredValueEnvelope(value) {
@@ -146,67 +370,129 @@ function normalizePositiveSeconds(value) {
146
370
  return value;
147
371
  }
148
372
 
149
- // src/CacheNamespace.ts
150
- var CacheNamespace = class {
151
- constructor(cache, prefix) {
152
- this.cache = cache;
153
- this.prefix = prefix;
154
- }
155
- cache;
156
- prefix;
157
- async get(key, fetcher, options) {
158
- return this.cache.get(this.qualify(key), fetcher, options);
159
- }
160
- async set(key, value, options) {
161
- await this.cache.set(this.qualify(key), value, options);
162
- }
163
- async delete(key) {
164
- await this.cache.delete(this.qualify(key));
373
+ // src/internal/TtlResolver.ts
374
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
375
+ var TtlResolver = class {
376
+ accessProfiles = /* @__PURE__ */ new Map();
377
+ maxProfileEntries;
378
+ constructor(options) {
379
+ this.maxProfileEntries = options.maxProfileEntries;
165
380
  }
166
- async clear() {
167
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
381
+ recordAccess(key) {
382
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
383
+ profile.hits += 1;
384
+ profile.lastAccessAt = Date.now();
385
+ this.accessProfiles.set(key, profile);
386
+ this.pruneIfNeeded();
168
387
  }
169
- async mget(entries) {
170
- return this.cache.mget(entries.map((entry) => ({
171
- ...entry,
172
- key: this.qualify(entry.key)
173
- })));
388
+ deleteProfile(key) {
389
+ this.accessProfiles.delete(key);
174
390
  }
175
- async mset(entries) {
176
- await this.cache.mset(entries.map((entry) => ({
177
- ...entry,
178
- key: this.qualify(entry.key)
179
- })));
391
+ clearProfiles() {
392
+ this.accessProfiles.clear();
180
393
  }
181
- async invalidateByTag(tag) {
182
- await this.cache.invalidateByTag(tag);
394
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
395
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
396
+ layerName,
397
+ options?.negativeTtl,
398
+ globalNegativeTtl,
399
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
400
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
401
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
402
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
403
+ return this.applyJitter(adaptiveTtl, jitter);
183
404
  }
184
- async invalidateByPattern(pattern) {
185
- await this.cache.invalidateByPattern(this.qualify(pattern));
405
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
406
+ if (override !== void 0) {
407
+ return this.readLayerNumber(layerName, override) ?? fallback;
408
+ }
409
+ if (globalDefault !== void 0) {
410
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
411
+ }
412
+ return fallback;
186
413
  }
187
- wrap(keyPrefix, fetcher, options) {
188
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
414
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
415
+ if (!ttl || !adaptiveTtl) {
416
+ return ttl;
417
+ }
418
+ const profile = this.accessProfiles.get(key);
419
+ if (!profile) {
420
+ return ttl;
421
+ }
422
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
423
+ const hotAfter = config.hotAfter ?? 3;
424
+ if (profile.hits < hotAfter) {
425
+ return ttl;
426
+ }
427
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
428
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
429
+ const multiplier = Math.floor(profile.hits / hotAfter);
430
+ return Math.min(maxTtl, ttl + step * multiplier);
189
431
  }
190
- warm(entries, options) {
191
- return this.cache.warm(entries.map((entry) => ({
192
- ...entry,
193
- key: this.qualify(entry.key)
194
- })), options);
432
+ applyJitter(ttl, jitter) {
433
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
434
+ return ttl;
435
+ }
436
+ const delta = (Math.random() * 2 - 1) * jitter;
437
+ return Math.max(1, Math.round(ttl + delta));
195
438
  }
196
- getMetrics() {
197
- return this.cache.getMetrics();
439
+ readLayerNumber(layerName, value) {
440
+ if (typeof value === "number") {
441
+ return value;
442
+ }
443
+ return value[layerName];
198
444
  }
199
- qualify(key) {
200
- return `${this.prefix}:${key}`;
445
+ pruneIfNeeded() {
446
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
447
+ return;
448
+ }
449
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
450
+ let removed = 0;
451
+ for (const key of this.accessProfiles.keys()) {
452
+ if (removed >= toRemove) {
453
+ break;
454
+ }
455
+ this.accessProfiles.delete(key);
456
+ removed += 1;
457
+ }
201
458
  }
202
459
  };
203
460
 
204
461
  // src/invalidation/PatternMatcher.ts
205
- var PatternMatcher = class {
462
+ var PatternMatcher = class _PatternMatcher {
463
+ /**
464
+ * Tests whether a glob-style pattern matches a value.
465
+ * Supports `*` (any sequence of characters) and `?` (any single character).
466
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
467
+ */
206
468
  static matches(pattern, value) {
207
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
208
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
209
- return regex.test(value);
469
+ return _PatternMatcher.matchLinear(pattern, value);
470
+ }
471
+ /**
472
+ * Linear-time glob matching using dynamic programming.
473
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
474
+ */
475
+ static matchLinear(pattern, value) {
476
+ const m = pattern.length;
477
+ const n = value.length;
478
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
479
+ dp[0][0] = true;
480
+ for (let i = 1; i <= m; i++) {
481
+ if (pattern[i - 1] === "*") {
482
+ dp[i][0] = dp[i - 1]?.[0];
483
+ }
484
+ }
485
+ for (let i = 1; i <= m; i++) {
486
+ for (let j = 1; j <= n; j++) {
487
+ const pc = pattern[i - 1];
488
+ if (pc === "*") {
489
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
490
+ } else if (pc === "?" || pc === value[j - 1]) {
491
+ dp[i][j] = dp[i - 1]?.[j - 1];
492
+ }
493
+ }
494
+ }
495
+ return dp[m]?.[n];
210
496
  }
211
497
  };
212
498
 
@@ -295,30 +581,11 @@ var StampedeGuard = class {
295
581
  };
296
582
 
297
583
  // src/CacheStack.ts
298
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
299
584
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
300
585
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
301
586
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
302
587
  var MAX_CACHE_KEY_LENGTH = 1024;
303
- var EMPTY_METRICS = () => ({
304
- hits: 0,
305
- misses: 0,
306
- fetches: 0,
307
- sets: 0,
308
- deletes: 0,
309
- backfills: 0,
310
- invalidations: 0,
311
- staleHits: 0,
312
- refreshes: 0,
313
- refreshErrors: 0,
314
- writeFailures: 0,
315
- singleFlightWaits: 0,
316
- negativeCacheHits: 0,
317
- circuitBreakerTrips: 0,
318
- degradedOperations: 0,
319
- hitsByLayer: {},
320
- missesByLayer: {}
321
- });
588
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
322
589
  var DebugLogger = class {
323
590
  enabled;
324
591
  constructor(enabled) {
@@ -353,6 +620,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
353
620
  throw new Error("CacheStack requires at least one cache layer.");
354
621
  }
355
622
  this.validateConfiguration();
623
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
624
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
625
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
626
+ if (options.publishSetInvalidation !== void 0) {
627
+ console.warn(
628
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
629
+ );
630
+ }
356
631
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
357
632
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
358
633
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -361,36 +636,42 @@ var CacheStack = class extends import_node_events.EventEmitter {
361
636
  layers;
362
637
  options;
363
638
  stampedeGuard = new StampedeGuard();
364
- metrics = EMPTY_METRICS();
639
+ metricsCollector = new MetricsCollector();
365
640
  instanceId = (0, import_node_crypto.randomUUID)();
366
641
  startup;
367
642
  unsubscribeInvalidation;
368
643
  logger;
369
644
  tagIndex;
370
645
  backgroundRefreshes = /* @__PURE__ */ new Map();
371
- accessProfiles = /* @__PURE__ */ new Map();
372
646
  layerDegradedUntil = /* @__PURE__ */ new Map();
373
- circuitBreakers = /* @__PURE__ */ new Map();
647
+ ttlResolver;
648
+ circuitBreakerManager;
374
649
  isDisconnecting = false;
375
650
  disconnectPromise;
651
+ /**
652
+ * Read-through cache get.
653
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
654
+ * and stores the result across all layers. Returns `null` if the key is not found
655
+ * and no `fetcher` is provided.
656
+ */
376
657
  async get(key, fetcher, options) {
377
658
  const normalizedKey = this.validateCacheKey(key);
378
659
  this.validateWriteOptions(options);
379
660
  await this.startup;
380
661
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
381
662
  if (hit.found) {
382
- this.recordAccess(normalizedKey);
663
+ this.ttlResolver.recordAccess(normalizedKey);
383
664
  if (this.isNegativeStoredValue(hit.stored)) {
384
- this.metrics.negativeCacheHits += 1;
665
+ this.metricsCollector.increment("negativeCacheHits");
385
666
  }
386
667
  if (hit.state === "fresh") {
387
- this.metrics.hits += 1;
668
+ this.metricsCollector.increment("hits");
388
669
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
389
670
  return hit.value;
390
671
  }
391
672
  if (hit.state === "stale-while-revalidate") {
392
- this.metrics.hits += 1;
393
- this.metrics.staleHits += 1;
673
+ this.metricsCollector.increment("hits");
674
+ this.metricsCollector.increment("staleHits");
394
675
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
395
676
  if (fetcher) {
396
677
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -398,47 +679,136 @@ var CacheStack = class extends import_node_events.EventEmitter {
398
679
  return hit.value;
399
680
  }
400
681
  if (!fetcher) {
401
- this.metrics.hits += 1;
402
- this.metrics.staleHits += 1;
682
+ this.metricsCollector.increment("hits");
683
+ this.metricsCollector.increment("staleHits");
403
684
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
404
685
  return hit.value;
405
686
  }
406
687
  try {
407
688
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
408
689
  } catch (error) {
409
- this.metrics.staleHits += 1;
410
- this.metrics.refreshErrors += 1;
690
+ this.metricsCollector.increment("staleHits");
691
+ this.metricsCollector.increment("refreshErrors");
411
692
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
412
693
  return hit.value;
413
694
  }
414
695
  }
415
- this.metrics.misses += 1;
696
+ this.metricsCollector.increment("misses");
416
697
  if (!fetcher) {
417
698
  return null;
418
699
  }
419
700
  return this.fetchWithGuards(normalizedKey, fetcher, options);
420
701
  }
702
+ /**
703
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
704
+ * Fetches and caches the value if not already present.
705
+ */
706
+ async getOrSet(key, fetcher, options) {
707
+ return this.get(key, fetcher, options);
708
+ }
709
+ /**
710
+ * Returns true if the given key exists and is not expired in any layer.
711
+ */
712
+ async has(key) {
713
+ const normalizedKey = this.validateCacheKey(key);
714
+ await this.startup;
715
+ for (const layer of this.layers) {
716
+ if (this.shouldSkipLayer(layer)) {
717
+ continue;
718
+ }
719
+ if (layer.has) {
720
+ try {
721
+ const exists = await layer.has(normalizedKey);
722
+ if (exists) {
723
+ return true;
724
+ }
725
+ } catch {
726
+ }
727
+ } else {
728
+ try {
729
+ const value = await layer.get(normalizedKey);
730
+ if (value !== null) {
731
+ return true;
732
+ }
733
+ } catch {
734
+ }
735
+ }
736
+ }
737
+ return false;
738
+ }
739
+ /**
740
+ * Returns the remaining TTL in seconds for the key in the fastest layer
741
+ * that has it, or null if the key is not found / has no TTL.
742
+ */
743
+ async ttl(key) {
744
+ const normalizedKey = this.validateCacheKey(key);
745
+ await this.startup;
746
+ for (const layer of this.layers) {
747
+ if (this.shouldSkipLayer(layer)) {
748
+ continue;
749
+ }
750
+ if (layer.ttl) {
751
+ try {
752
+ const remaining = await layer.ttl(normalizedKey);
753
+ if (remaining !== null) {
754
+ return remaining;
755
+ }
756
+ } catch {
757
+ }
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+ /**
763
+ * Stores a value in all cache layers. Overwrites any existing value.
764
+ */
421
765
  async set(key, value, options) {
422
766
  const normalizedKey = this.validateCacheKey(key);
423
767
  this.validateWriteOptions(options);
424
768
  await this.startup;
425
769
  await this.storeEntry(normalizedKey, "value", value, options);
426
770
  }
771
+ /**
772
+ * Deletes the key from all layers and publishes an invalidation message.
773
+ */
427
774
  async delete(key) {
428
775
  const normalizedKey = this.validateCacheKey(key);
429
776
  await this.startup;
430
777
  await this.deleteKeys([normalizedKey]);
431
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
778
+ await this.publishInvalidation({
779
+ scope: "key",
780
+ keys: [normalizedKey],
781
+ sourceId: this.instanceId,
782
+ operation: "delete"
783
+ });
432
784
  }
433
785
  async clear() {
434
786
  await this.startup;
435
787
  await Promise.all(this.layers.map((layer) => layer.clear()));
436
788
  await this.tagIndex.clear();
437
- this.accessProfiles.clear();
438
- this.metrics.invalidations += 1;
789
+ this.ttlResolver.clearProfiles();
790
+ this.circuitBreakerManager.clear();
791
+ this.metricsCollector.increment("invalidations");
439
792
  this.logger.debug?.("clear");
440
793
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
441
794
  }
795
+ /**
796
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
797
+ */
798
+ async mdelete(keys) {
799
+ if (keys.length === 0) {
800
+ return;
801
+ }
802
+ await this.startup;
803
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
804
+ await this.deleteKeys(normalizedKeys);
805
+ await this.publishInvalidation({
806
+ scope: "keys",
807
+ keys: normalizedKeys,
808
+ sourceId: this.instanceId,
809
+ operation: "delete"
810
+ });
811
+ }
442
812
  async mget(entries) {
443
813
  if (entries.length === 0) {
444
814
  return [];
@@ -476,7 +846,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
476
846
  const indexesByKey = /* @__PURE__ */ new Map();
477
847
  const resultsByKey = /* @__PURE__ */ new Map();
478
848
  for (let index = 0; index < normalizedEntries.length; index += 1) {
479
- const key = normalizedEntries[index].key;
849
+ const entry = normalizedEntries[index];
850
+ if (!entry) continue;
851
+ const key = entry.key;
480
852
  const indexes = indexesByKey.get(key) ?? [];
481
853
  indexes.push(index);
482
854
  indexesByKey.set(key, indexes);
@@ -484,6 +856,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
484
856
  }
485
857
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
486
858
  const layer = this.layers[layerIndex];
859
+ if (!layer) continue;
487
860
  const keys = [...pending];
488
861
  if (keys.length === 0) {
489
862
  break;
@@ -492,7 +865,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
492
865
  for (let offset = 0; offset < values.length; offset += 1) {
493
866
  const key = keys[offset];
494
867
  const stored = values[offset];
495
- if (stored === null) {
868
+ if (!key || stored === null) {
496
869
  continue;
497
870
  }
498
871
  const resolved = resolveStoredValue(stored);
@@ -504,13 +877,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
504
877
  await this.backfill(key, stored, layerIndex - 1);
505
878
  resultsByKey.set(key, resolved.value);
506
879
  pending.delete(key);
507
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
880
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
508
881
  }
509
882
  }
510
883
  if (pending.size > 0) {
511
884
  for (const key of pending) {
512
885
  await this.tagIndex.remove(key);
513
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
886
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
514
887
  }
515
888
  }
516
889
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -525,26 +898,38 @@ var CacheStack = class extends import_node_events.EventEmitter {
525
898
  }
526
899
  async warm(entries, options = {}) {
527
900
  const concurrency = Math.max(1, options.concurrency ?? 4);
901
+ const total = entries.length;
902
+ let completed = 0;
528
903
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
529
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
904
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
530
905
  while (queue.length > 0) {
531
906
  const entry = queue.shift();
532
907
  if (!entry) {
533
908
  return;
534
909
  }
910
+ let success = false;
535
911
  try {
536
912
  await this.get(entry.key, entry.fetcher, entry.options);
537
913
  this.emit("warm", { key: entry.key });
914
+ success = true;
538
915
  } catch (error) {
539
916
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
540
917
  if (!options.continueOnError) {
541
918
  throw error;
542
919
  }
920
+ } finally {
921
+ completed += 1;
922
+ const progress = { completed, total, key: entry.key, success };
923
+ options.onProgress?.(progress);
543
924
  }
544
925
  }
545
926
  });
546
927
  await Promise.all(workers);
547
928
  }
929
+ /**
930
+ * Returns a cached version of `fetcher`. The cache key is derived from
931
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
932
+ */
548
933
  wrap(prefix, fetcher, options = {}) {
549
934
  return (...args) => {
550
935
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -552,6 +937,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
552
937
  return this.get(key, () => fetcher(...args), options);
553
938
  };
554
939
  }
940
+ /**
941
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
942
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
943
+ */
555
944
  namespace(prefix) {
556
945
  return new CacheNamespace(this, prefix);
557
946
  }
@@ -568,7 +957,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
568
957
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
569
958
  }
570
959
  getMetrics() {
571
- return { ...this.metrics };
960
+ return this.metricsCollector.snapshot;
572
961
  }
573
962
  getStats() {
574
963
  return {
@@ -582,7 +971,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
582
971
  };
583
972
  }
584
973
  resetMetrics() {
585
- Object.assign(this.metrics, EMPTY_METRICS());
974
+ this.metricsCollector.reset();
975
+ }
976
+ /**
977
+ * Returns computed hit-rate statistics (overall and per-layer).
978
+ */
979
+ getHitRate() {
980
+ return this.metricsCollector.hitRate();
586
981
  }
587
982
  async exportState() {
588
983
  await this.startup;
@@ -611,10 +1006,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
611
1006
  }
612
1007
  async importState(entries) {
613
1008
  await this.startup;
614
- await Promise.all(entries.map(async (entry) => {
615
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
616
- await this.tagIndex.touch(entry.key);
617
- }));
1009
+ await Promise.all(
1010
+ entries.map(async (entry) => {
1011
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1012
+ await this.tagIndex.touch(entry.key);
1013
+ })
1014
+ );
618
1015
  }
619
1016
  async persistToFile(filePath) {
620
1017
  const snapshot = await this.exportState();
@@ -622,11 +1019,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
622
1019
  }
623
1020
  async restoreFromFile(filePath) {
624
1021
  const raw = await import_node_fs.promises.readFile(filePath, "utf8");
625
- const snapshot = JSON.parse(raw);
626
- if (!this.isCacheSnapshotEntries(snapshot)) {
627
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
1022
+ let parsed;
1023
+ try {
1024
+ parsed = JSON.parse(raw, (_key, value) => {
1025
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1026
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1027
+ }
1028
+ return value;
1029
+ });
1030
+ } catch (cause) {
1031
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1032
+ }
1033
+ if (!this.isCacheSnapshotEntries(parsed)) {
1034
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
628
1035
  }
629
- await this.importState(snapshot);
1036
+ await this.importState(parsed);
630
1037
  }
631
1038
  async disconnect() {
632
1039
  if (!this.disconnectPromise) {
@@ -651,7 +1058,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
651
1058
  const fetchTask = async () => {
652
1059
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
653
1060
  if (secondHit.found) {
654
- this.metrics.hits += 1;
1061
+ this.metricsCollector.increment("hits");
655
1062
  return secondHit.value;
656
1063
  }
657
1064
  return this.fetchAndPopulate(key, fetcher, options);
@@ -676,12 +1083,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
676
1083
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
677
1084
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
678
1085
  const deadline = Date.now() + timeoutMs;
679
- this.metrics.singleFlightWaits += 1;
1086
+ this.metricsCollector.increment("singleFlightWaits");
680
1087
  this.emit("stampede-dedupe", { key });
681
1088
  while (Date.now() < deadline) {
682
1089
  const hit = await this.readFromLayers(key, options, "fresh-only");
683
1090
  if (hit.found) {
684
- this.metrics.hits += 1;
1091
+ this.metricsCollector.increment("hits");
685
1092
  return hit.value;
686
1093
  }
687
1094
  await this.sleep(pollIntervalMs);
@@ -689,12 +1096,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
689
1096
  return this.fetchAndPopulate(key, fetcher, options);
690
1097
  }
691
1098
  async fetchAndPopulate(key, fetcher, options) {
692
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
693
- this.metrics.fetches += 1;
1099
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1100
+ this.metricsCollector.increment("fetches");
1101
+ const fetchStart = Date.now();
694
1102
  let fetched;
695
1103
  try {
696
1104
  fetched = await fetcher();
697
- this.resetCircuitBreaker(key);
1105
+ this.circuitBreakerManager.recordSuccess(key);
1106
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
698
1107
  } catch (error) {
699
1108
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
700
1109
  throw error;
@@ -716,7 +1125,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
716
1125
  } else {
717
1126
  await this.tagIndex.touch(key);
718
1127
  }
719
- this.metrics.sets += 1;
1128
+ this.metricsCollector.increment("sets");
720
1129
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
721
1130
  this.emit("set", { key, kind, tags: options?.tags });
722
1131
  if (this.shouldBroadcastL1Invalidation()) {
@@ -727,9 +1136,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
727
1136
  let sawRetainableValue = false;
728
1137
  for (let index = 0; index < this.layers.length; index += 1) {
729
1138
  const layer = this.layers[index];
1139
+ if (!layer) continue;
730
1140
  const stored = await this.readLayerEntry(layer, key);
731
1141
  if (stored === null) {
732
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1142
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
733
1143
  continue;
734
1144
  }
735
1145
  const resolved = resolveStoredValue(stored);
@@ -743,10 +1153,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
743
1153
  }
744
1154
  await this.tagIndex.touch(key);
745
1155
  await this.backfill(key, stored, index - 1, options);
746
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1156
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
747
1157
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
748
1158
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
749
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1159
+ return {
1160
+ found: true,
1161
+ value: resolved.value,
1162
+ stored,
1163
+ state: resolved.state,
1164
+ layerIndex: index,
1165
+ layerName: layer.name
1166
+ };
750
1167
  }
751
1168
  if (!sawRetainableValue) {
752
1169
  await this.tagIndex.remove(key);
@@ -778,7 +1195,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
778
1195
  }
779
1196
  for (let index = 0; index <= upToIndex; index += 1) {
780
1197
  const layer = this.layers[index];
781
- if (this.shouldSkipLayer(layer)) {
1198
+ if (!layer || this.shouldSkipLayer(layer)) {
782
1199
  continue;
783
1200
  }
784
1201
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -788,7 +1205,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
788
1205
  await this.handleLayerFailure(layer, "backfill", error);
789
1206
  continue;
790
1207
  }
791
- this.metrics.backfills += 1;
1208
+ this.metricsCollector.increment("backfills");
792
1209
  this.logger.debug?.("backfill", { key, layer: layer.name });
793
1210
  this.emit("backfill", { key, layer: layer.name });
794
1211
  }
@@ -805,11 +1222,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
805
1222
  options?.staleWhileRevalidate,
806
1223
  this.options.staleWhileRevalidate
807
1224
  );
808
- const staleIfError = this.resolveLayerSeconds(
809
- layer.name,
810
- options?.staleIfError,
811
- this.options.staleIfError
812
- );
1225
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
813
1226
  const payload = createStoredValueEnvelope({
814
1227
  kind,
815
1228
  value,
@@ -837,7 +1250,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
837
1250
  if (failures.length === 0) {
838
1251
  return;
839
1252
  }
840
- this.metrics.writeFailures += failures.length;
1253
+ this.metricsCollector.increment("writeFailures", failures.length);
841
1254
  this.logger.debug?.("write-failure", {
842
1255
  ...context,
843
1256
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -850,42 +1263,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
850
1263
  }
851
1264
  }
852
1265
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
853
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
854
- layerName,
855
- options?.negativeTtl,
856
- this.options.negativeTtl,
857
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
858
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
859
- const adaptiveTtl = this.applyAdaptiveTtl(
860
- key,
861
- layerName,
862
- baseTtl,
863
- options?.adaptiveTtl ?? this.options.adaptiveTtl
864
- );
865
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
866
- return this.applyJitter(adaptiveTtl, jitter);
1266
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
867
1267
  }
868
1268
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
869
- if (override !== void 0) {
870
- return this.readLayerNumber(layerName, override) ?? fallback;
871
- }
872
- if (globalDefault !== void 0) {
873
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
874
- }
875
- return fallback;
876
- }
877
- readLayerNumber(layerName, value) {
878
- if (typeof value === "number") {
879
- return value;
880
- }
881
- return value[layerName];
882
- }
883
- applyJitter(ttl, jitter) {
884
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
885
- return ttl;
886
- }
887
- const delta = (Math.random() * 2 - 1) * jitter;
888
- return Math.max(1, Math.round(ttl + delta));
1269
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
889
1270
  }
890
1271
  shouldNegativeCache(options) {
891
1272
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -895,11 +1276,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
895
1276
  return;
896
1277
  }
897
1278
  const refresh = (async () => {
898
- this.metrics.refreshes += 1;
1279
+ this.metricsCollector.increment("refreshes");
899
1280
  try {
900
1281
  await this.fetchWithGuards(key, fetcher, options);
901
1282
  } catch (error) {
902
- this.metrics.refreshErrors += 1;
1283
+ this.metricsCollector.increment("refreshErrors");
903
1284
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
904
1285
  } finally {
905
1286
  this.backgroundRefreshes.delete(key);
@@ -921,10 +1302,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
921
1302
  await this.deleteKeysFromLayers(this.layers, keys);
922
1303
  for (const key of keys) {
923
1304
  await this.tagIndex.remove(key);
924
- this.accessProfiles.delete(key);
1305
+ this.ttlResolver.deleteProfile(key);
1306
+ this.circuitBreakerManager.delete(key);
925
1307
  }
926
- this.metrics.deletes += keys.length;
927
- this.metrics.invalidations += 1;
1308
+ this.metricsCollector.increment("deletes", keys.length);
1309
+ this.metricsCollector.increment("invalidations");
928
1310
  this.logger.debug?.("delete", { keys });
929
1311
  this.emit("delete", { keys });
930
1312
  }
@@ -945,7 +1327,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
945
1327
  if (message.scope === "clear") {
946
1328
  await Promise.all(localLayers.map((layer) => layer.clear()));
947
1329
  await this.tagIndex.clear();
948
- this.accessProfiles.clear();
1330
+ this.ttlResolver.clearProfiles();
949
1331
  return;
950
1332
  }
951
1333
  const keys = message.keys ?? [];
@@ -953,7 +1335,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
953
1335
  if (message.operation !== "write") {
954
1336
  for (const key of keys) {
955
1337
  await this.tagIndex.remove(key);
956
- this.accessProfiles.delete(key);
1338
+ this.ttlResolver.deleteProfile(key);
957
1339
  }
958
1340
  }
959
1341
  }
@@ -983,13 +1365,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
983
1365
  }
984
1366
  return;
985
1367
  }
986
- await Promise.all(keys.map(async (key) => {
987
- try {
988
- await layer.delete(key);
989
- } catch (error) {
990
- await this.handleLayerFailure(layer, "delete", error);
991
- }
992
- }));
1368
+ await Promise.all(
1369
+ keys.map(async (key) => {
1370
+ try {
1371
+ await layer.delete(key);
1372
+ } catch (error) {
1373
+ await this.handleLayerFailure(layer, "delete", error);
1374
+ }
1375
+ })
1376
+ );
993
1377
  })
994
1378
  );
995
1379
  }
@@ -1090,7 +1474,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1090
1474
  const ttl = remainingStoredTtlSeconds(refreshed);
1091
1475
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1092
1476
  const layer = this.layers[index];
1093
- if (this.shouldSkipLayer(layer)) {
1477
+ if (!layer || this.shouldSkipLayer(layer)) {
1094
1478
  continue;
1095
1479
  }
1096
1480
  try {
@@ -1104,33 +1488,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
1104
1488
  this.scheduleBackgroundRefresh(key, fetcher, options);
1105
1489
  }
1106
1490
  }
1107
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1108
- if (!ttl || !adaptiveTtl) {
1109
- return ttl;
1110
- }
1111
- const profile = this.accessProfiles.get(key);
1112
- if (!profile) {
1113
- return ttl;
1114
- }
1115
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1116
- const hotAfter = config.hotAfter ?? 3;
1117
- if (profile.hits < hotAfter) {
1118
- return ttl;
1119
- }
1120
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1121
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1122
- const multiplier = Math.floor(profile.hits / hotAfter);
1123
- return Math.min(maxTtl, ttl + step * multiplier);
1124
- }
1125
- recordAccess(key) {
1126
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1127
- profile.hits += 1;
1128
- profile.lastAccessAt = Date.now();
1129
- this.accessProfiles.set(key, profile);
1130
- }
1131
- incrementMetricMap(target, key) {
1132
- target[key] = (target[key] ?? 0) + 1;
1133
- }
1134
1491
  shouldSkipLayer(layer) {
1135
1492
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1136
1493
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1141,7 +1498,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1141
1498
  }
1142
1499
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1143
1500
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1144
- this.metrics.degradedOperations += 1;
1501
+ this.metricsCollector.increment("degradedOperations");
1145
1502
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1146
1503
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1147
1504
  return null;
@@ -1149,37 +1506,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
1149
1506
  isGracefulDegradationEnabled() {
1150
1507
  return Boolean(this.options.gracefulDegradation);
1151
1508
  }
1152
- assertCircuitClosed(key, options) {
1153
- const state = this.circuitBreakers.get(key);
1154
- if (!state?.openUntil) {
1155
- return;
1156
- }
1157
- if (state.openUntil <= Date.now()) {
1158
- state.openUntil = null;
1159
- state.failures = 0;
1160
- this.circuitBreakers.set(key, state);
1161
- return;
1162
- }
1163
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1164
- throw new Error(`Circuit breaker is open for key "${key}".`);
1165
- }
1166
1509
  recordCircuitFailure(key, options, error) {
1167
1510
  if (!options) {
1168
1511
  return;
1169
1512
  }
1170
- const failureThreshold = options.failureThreshold ?? 3;
1171
- const cooldownMs = options.cooldownMs ?? 3e4;
1172
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1173
- state.failures += 1;
1174
- if (state.failures >= failureThreshold) {
1175
- state.openUntil = Date.now() + cooldownMs;
1176
- this.metrics.circuitBreakerTrips += 1;
1513
+ this.circuitBreakerManager.recordFailure(key, options);
1514
+ if (this.circuitBreakerManager.isOpen(key)) {
1515
+ this.metricsCollector.increment("circuitBreakerTrips");
1177
1516
  }
1178
- this.circuitBreakers.set(key, state);
1179
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1180
- }
1181
- resetCircuitBreaker(key) {
1182
- this.circuitBreakers.delete(key);
1517
+ this.emitError("fetch", { key, error: this.formatError(error) });
1183
1518
  }
1184
1519
  isNegativeStoredValue(stored) {
1185
1520
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
@@ -1459,11 +1794,13 @@ var MemoryLayer = class {
1459
1794
  defaultTtl;
1460
1795
  isLocal = true;
1461
1796
  maxSize;
1797
+ evictionPolicy;
1462
1798
  entries = /* @__PURE__ */ new Map();
1463
1799
  constructor(options = {}) {
1464
1800
  this.name = options.name ?? "memory";
1465
1801
  this.defaultTtl = options.ttl;
1466
1802
  this.maxSize = options.maxSize ?? 1e3;
1803
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
1467
1804
  }
1468
1805
  async get(key) {
1469
1806
  const value = await this.getEntry(key);
@@ -1478,8 +1815,13 @@ var MemoryLayer = class {
1478
1815
  this.entries.delete(key);
1479
1816
  return null;
1480
1817
  }
1481
- this.entries.delete(key);
1482
- this.entries.set(key, entry);
1818
+ if (this.evictionPolicy === "lru") {
1819
+ this.entries.delete(key);
1820
+ entry.frequency += 1;
1821
+ this.entries.set(key, entry);
1822
+ } else {
1823
+ entry.frequency += 1;
1824
+ }
1483
1825
  return entry.value;
1484
1826
  }
1485
1827
  async getMany(keys) {
@@ -1493,15 +1835,42 @@ var MemoryLayer = class {
1493
1835
  this.entries.delete(key);
1494
1836
  this.entries.set(key, {
1495
1837
  value,
1496
- expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
1838
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1839
+ frequency: 0,
1840
+ insertedAt: Date.now()
1497
1841
  });
1498
1842
  while (this.entries.size > this.maxSize) {
1499
- const oldestKey = this.entries.keys().next().value;
1500
- if (!oldestKey) {
1501
- break;
1502
- }
1503
- this.entries.delete(oldestKey);
1843
+ this.evict();
1844
+ }
1845
+ }
1846
+ async has(key) {
1847
+ const entry = this.entries.get(key);
1848
+ if (!entry) {
1849
+ return false;
1850
+ }
1851
+ if (this.isExpired(entry)) {
1852
+ this.entries.delete(key);
1853
+ return false;
1854
+ }
1855
+ return true;
1856
+ }
1857
+ async ttl(key) {
1858
+ const entry = this.entries.get(key);
1859
+ if (!entry) {
1860
+ return null;
1861
+ }
1862
+ if (this.isExpired(entry)) {
1863
+ this.entries.delete(key);
1864
+ return null;
1865
+ }
1866
+ if (entry.expiresAt === null) {
1867
+ return null;
1504
1868
  }
1869
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
1870
+ }
1871
+ async size() {
1872
+ this.pruneExpired();
1873
+ return this.entries.size;
1505
1874
  }
1506
1875
  async delete(key) {
1507
1876
  this.entries.delete(key);
@@ -1533,15 +1902,35 @@ var MemoryLayer = class {
1533
1902
  }
1534
1903
  this.entries.set(entry.key, {
1535
1904
  value: entry.value,
1536
- expiresAt: entry.expiresAt
1905
+ expiresAt: entry.expiresAt,
1906
+ frequency: 0,
1907
+ insertedAt: Date.now()
1537
1908
  });
1538
1909
  }
1539
1910
  while (this.entries.size > this.maxSize) {
1911
+ this.evict();
1912
+ }
1913
+ }
1914
+ evict() {
1915
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
1540
1916
  const oldestKey = this.entries.keys().next().value;
1541
- if (!oldestKey) {
1542
- break;
1917
+ if (oldestKey !== void 0) {
1918
+ this.entries.delete(oldestKey);
1543
1919
  }
1544
- this.entries.delete(oldestKey);
1920
+ return;
1921
+ }
1922
+ let victimKey;
1923
+ let minFreq = Number.POSITIVE_INFINITY;
1924
+ let minInsertedAt = Number.POSITIVE_INFINITY;
1925
+ for (const [key, entry] of this.entries.entries()) {
1926
+ if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1927
+ minFreq = entry.frequency;
1928
+ minInsertedAt = entry.insertedAt;
1929
+ victimKey = key;
1930
+ }
1931
+ }
1932
+ if (victimKey !== void 0) {
1933
+ this.entries.delete(victimKey);
1545
1934
  }
1546
1935
  }
1547
1936
  pruneExpired() {
@@ -1571,6 +1960,7 @@ var JsonSerializer = class {
1571
1960
  };
1572
1961
 
1573
1962
  // src/layers/RedisLayer.ts
1963
+ var BATCH_DELETE_SIZE = 500;
1574
1964
  var RedisLayer = class {
1575
1965
  name;
1576
1966
  defaultTtl;
@@ -1622,7 +2012,7 @@ var RedisLayer = class {
1622
2012
  if (error || payload === null || !this.isSerializablePayload(payload)) {
1623
2013
  return null;
1624
2014
  }
1625
- return this.deserializeOrDelete(keys[index], payload);
2015
+ return this.deserializeOrDelete(keys[index] ?? "", payload);
1626
2016
  })
1627
2017
  );
1628
2018
  }
@@ -1644,14 +2034,44 @@ var RedisLayer = class {
1644
2034
  }
1645
2035
  await this.client.del(...keys.map((key) => this.withPrefix(key)));
1646
2036
  }
1647
- async clear() {
1648
- if (!this.prefix && !this.allowUnprefixedClear) {
1649
- throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
2037
+ async has(key) {
2038
+ const exists = await this.client.exists(this.withPrefix(key));
2039
+ return exists > 0;
2040
+ }
2041
+ async ttl(key) {
2042
+ const remaining = await this.client.ttl(this.withPrefix(key));
2043
+ if (remaining < 0) {
2044
+ return null;
1650
2045
  }
2046
+ return remaining;
2047
+ }
2048
+ async size() {
1651
2049
  const keys = await this.keys();
1652
- if (keys.length > 0) {
1653
- await this.deleteMany(keys);
2050
+ return keys.length;
2051
+ }
2052
+ /**
2053
+ * Deletes all keys matching the layer's prefix in batches to avoid
2054
+ * loading millions of keys into memory at once.
2055
+ */
2056
+ async clear() {
2057
+ if (!this.prefix && !this.allowUnprefixedClear) {
2058
+ throw new Error(
2059
+ "RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
2060
+ );
1654
2061
  }
2062
+ const pattern = `${this.prefix}*`;
2063
+ let cursor = "0";
2064
+ do {
2065
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
2066
+ cursor = nextCursor;
2067
+ if (keys.length === 0) {
2068
+ continue;
2069
+ }
2070
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
2071
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
2072
+ await this.client.del(...batch);
2073
+ }
2074
+ } while (cursor !== "0");
1655
2075
  }
1656
2076
  async keys() {
1657
2077
  const pattern = `${this.prefix}*`;
@@ -1711,6 +2131,170 @@ var RedisLayer = class {
1711
2131
  }
1712
2132
  };
1713
2133
 
2134
+ // src/layers/DiskLayer.ts
2135
+ var import_node_crypto2 = require("crypto");
2136
+ var import_node_fs2 = require("fs");
2137
+ var import_node_path = require("path");
2138
+ var DiskLayer = class {
2139
+ name;
2140
+ defaultTtl;
2141
+ isLocal = true;
2142
+ directory;
2143
+ serializer;
2144
+ constructor(options) {
2145
+ this.directory = options.directory;
2146
+ this.defaultTtl = options.ttl;
2147
+ this.name = options.name ?? "disk";
2148
+ this.serializer = options.serializer ?? new JsonSerializer();
2149
+ }
2150
+ async get(key) {
2151
+ return unwrapStoredValue(await this.getEntry(key));
2152
+ }
2153
+ async getEntry(key) {
2154
+ const filePath = this.keyToPath(key);
2155
+ let raw;
2156
+ try {
2157
+ raw = await import_node_fs2.promises.readFile(filePath);
2158
+ } catch {
2159
+ return null;
2160
+ }
2161
+ let entry;
2162
+ try {
2163
+ entry = this.serializer.deserialize(raw);
2164
+ } catch {
2165
+ await this.safeDelete(filePath);
2166
+ return null;
2167
+ }
2168
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2169
+ await this.safeDelete(filePath);
2170
+ return null;
2171
+ }
2172
+ return entry.value;
2173
+ }
2174
+ async set(key, value, ttl = this.defaultTtl) {
2175
+ await import_node_fs2.promises.mkdir(this.directory, { recursive: true });
2176
+ const entry = {
2177
+ value,
2178
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2179
+ };
2180
+ const payload = this.serializer.serialize(entry);
2181
+ await import_node_fs2.promises.writeFile(this.keyToPath(key), payload);
2182
+ }
2183
+ async has(key) {
2184
+ const value = await this.getEntry(key);
2185
+ return value !== null;
2186
+ }
2187
+ async ttl(key) {
2188
+ const filePath = this.keyToPath(key);
2189
+ let raw;
2190
+ try {
2191
+ raw = await import_node_fs2.promises.readFile(filePath);
2192
+ } catch {
2193
+ return null;
2194
+ }
2195
+ let entry;
2196
+ try {
2197
+ entry = this.serializer.deserialize(raw);
2198
+ } catch {
2199
+ return null;
2200
+ }
2201
+ if (entry.expiresAt === null) {
2202
+ return null;
2203
+ }
2204
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
2205
+ if (remaining <= 0) {
2206
+ return null;
2207
+ }
2208
+ return remaining;
2209
+ }
2210
+ async delete(key) {
2211
+ await this.safeDelete(this.keyToPath(key));
2212
+ }
2213
+ async deleteMany(keys) {
2214
+ await Promise.all(keys.map((key) => this.delete(key)));
2215
+ }
2216
+ async clear() {
2217
+ let entries;
2218
+ try {
2219
+ entries = await import_node_fs2.promises.readdir(this.directory);
2220
+ } catch {
2221
+ return;
2222
+ }
2223
+ await Promise.all(
2224
+ entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete((0, import_node_path.join)(this.directory, name)))
2225
+ );
2226
+ }
2227
+ async keys() {
2228
+ let entries;
2229
+ try {
2230
+ entries = await import_node_fs2.promises.readdir(this.directory);
2231
+ } catch {
2232
+ return [];
2233
+ }
2234
+ return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2235
+ }
2236
+ async size() {
2237
+ const keys = await this.keys();
2238
+ return keys.length;
2239
+ }
2240
+ keyToPath(key) {
2241
+ const hash = (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex");
2242
+ return (0, import_node_path.join)(this.directory, `${hash}.lc`);
2243
+ }
2244
+ async safeDelete(filePath) {
2245
+ try {
2246
+ await import_node_fs2.promises.unlink(filePath);
2247
+ } catch {
2248
+ }
2249
+ }
2250
+ };
2251
+
2252
+ // src/layers/MemcachedLayer.ts
2253
+ var MemcachedLayer = class {
2254
+ name;
2255
+ defaultTtl;
2256
+ isLocal = false;
2257
+ client;
2258
+ keyPrefix;
2259
+ constructor(options) {
2260
+ this.client = options.client;
2261
+ this.defaultTtl = options.ttl;
2262
+ this.name = options.name ?? "memcached";
2263
+ this.keyPrefix = options.keyPrefix ?? "";
2264
+ }
2265
+ async get(key) {
2266
+ const result = await this.client.get(this.withPrefix(key));
2267
+ if (!result || result.value === null) {
2268
+ return null;
2269
+ }
2270
+ try {
2271
+ return JSON.parse(result.value.toString("utf8"));
2272
+ } catch {
2273
+ return null;
2274
+ }
2275
+ }
2276
+ async set(key, value, ttl = this.defaultTtl) {
2277
+ const payload = JSON.stringify(value);
2278
+ await this.client.set(this.withPrefix(key), payload, {
2279
+ expires: ttl && ttl > 0 ? ttl : void 0
2280
+ });
2281
+ }
2282
+ async delete(key) {
2283
+ await this.client.delete(this.withPrefix(key));
2284
+ }
2285
+ async deleteMany(keys) {
2286
+ await Promise.all(keys.map((key) => this.delete(key)));
2287
+ }
2288
+ async clear() {
2289
+ throw new Error(
2290
+ "MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
2291
+ );
2292
+ }
2293
+ withPrefix(key) {
2294
+ return `${this.keyPrefix}${key}`;
2295
+ }
2296
+ };
2297
+
1714
2298
  // src/serialization/MsgpackSerializer.ts
1715
2299
  var import_msgpack = require("@msgpack/msgpack");
1716
2300
  var MsgpackSerializer = class {
@@ -1724,7 +2308,7 @@ var MsgpackSerializer = class {
1724
2308
  };
1725
2309
 
1726
2310
  // src/singleflight/RedisSingleFlightCoordinator.ts
1727
- var import_node_crypto2 = require("crypto");
2311
+ var import_node_crypto3 = require("crypto");
1728
2312
  var RELEASE_SCRIPT = `
1729
2313
  if redis.call("get", KEYS[1]) == ARGV[1] then
1730
2314
  return redis.call("del", KEYS[1])
@@ -1740,7 +2324,7 @@ var RedisSingleFlightCoordinator = class {
1740
2324
  }
1741
2325
  async execute(key, options, worker, waiter) {
1742
2326
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
1743
- const token = (0, import_node_crypto2.randomUUID)();
2327
+ const token = (0, import_node_crypto3.randomUUID)();
1744
2328
  const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
1745
2329
  if (acquired === "OK") {
1746
2330
  try {
@@ -1752,11 +2336,80 @@ var RedisSingleFlightCoordinator = class {
1752
2336
  return waiter();
1753
2337
  }
1754
2338
  };
2339
+
2340
+ // src/metrics/PrometheusExporter.ts
2341
+ function createPrometheusMetricsExporter(stacks) {
2342
+ return () => {
2343
+ const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
2344
+ const lines = [];
2345
+ lines.push("# HELP layercache_hits_total Total number of cache hits");
2346
+ lines.push("# TYPE layercache_hits_total counter");
2347
+ lines.push("# HELP layercache_misses_total Total number of cache misses");
2348
+ lines.push("# TYPE layercache_misses_total counter");
2349
+ lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
2350
+ lines.push("# TYPE layercache_fetches_total counter");
2351
+ lines.push("# HELP layercache_sets_total Total number of cache sets");
2352
+ lines.push("# TYPE layercache_sets_total counter");
2353
+ lines.push("# HELP layercache_deletes_total Total number of cache deletes");
2354
+ lines.push("# TYPE layercache_deletes_total counter");
2355
+ lines.push("# HELP layercache_backfills_total Total number of backfill operations");
2356
+ lines.push("# TYPE layercache_backfills_total counter");
2357
+ lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
2358
+ lines.push("# TYPE layercache_stale_hits_total counter");
2359
+ lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
2360
+ lines.push("# TYPE layercache_refreshes_total counter");
2361
+ lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
2362
+ lines.push("# TYPE layercache_refresh_errors_total counter");
2363
+ lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
2364
+ lines.push("# TYPE layercache_negative_cache_hits_total counter");
2365
+ lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
2366
+ lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
2367
+ lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
2368
+ lines.push("# TYPE layercache_degraded_operations_total counter");
2369
+ lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
2370
+ lines.push("# TYPE layercache_hit_rate gauge");
2371
+ lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
2372
+ lines.push("# TYPE layercache_hits_by_layer_total counter");
2373
+ lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2374
+ lines.push("# TYPE layercache_misses_by_layer_total counter");
2375
+ for (const { stack, name } of entries) {
2376
+ const m = stack.getMetrics();
2377
+ const hr = stack.getHitRate();
2378
+ const label = `cache="${sanitizeLabel(name)}"`;
2379
+ lines.push(`layercache_hits_total{${label}} ${m.hits}`);
2380
+ lines.push(`layercache_misses_total{${label}} ${m.misses}`);
2381
+ lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
2382
+ lines.push(`layercache_sets_total{${label}} ${m.sets}`);
2383
+ lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
2384
+ lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
2385
+ lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
2386
+ lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
2387
+ lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
2388
+ lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
2389
+ lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
2390
+ lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
2391
+ lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
2392
+ for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
2393
+ lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2394
+ }
2395
+ for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2396
+ lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2397
+ }
2398
+ }
2399
+ lines.push("");
2400
+ return lines.join("\n");
2401
+ };
2402
+ }
2403
+ function sanitizeLabel(value) {
2404
+ return value.replace(/["\\\n]/g, "_");
2405
+ }
1755
2406
  // Annotate the CommonJS export names for ESM import in node:
1756
2407
  0 && (module.exports = {
1757
2408
  CacheNamespace,
1758
2409
  CacheStack,
2410
+ DiskLayer,
1759
2411
  JsonSerializer,
2412
+ MemcachedLayer,
1760
2413
  MemoryLayer,
1761
2414
  MsgpackSerializer,
1762
2415
  PatternMatcher,
@@ -1770,5 +2423,6 @@ var RedisSingleFlightCoordinator = class {
1770
2423
  createCacheStatsHandler,
1771
2424
  createCachedMethodDecorator,
1772
2425
  createFastifyLayercachePlugin,
2426
+ createPrometheusMetricsExporter,
1773
2427
  createTrpcCacheMiddleware
1774
2428
  });