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.
@@ -73,8 +73,229 @@ var import_common = require("@nestjs/common");
73
73
 
74
74
  // ../../src/CacheStack.ts
75
75
  var import_node_crypto = require("crypto");
76
- var import_node_fs = require("fs");
77
76
  var import_node_events = require("events");
77
+ var import_node_fs = require("fs");
78
+
79
+ // ../../src/CacheNamespace.ts
80
+ var CacheNamespace = class {
81
+ constructor(cache, prefix) {
82
+ this.cache = cache;
83
+ this.prefix = prefix;
84
+ }
85
+ cache;
86
+ prefix;
87
+ async get(key, fetcher, options) {
88
+ return this.cache.get(this.qualify(key), fetcher, options);
89
+ }
90
+ async getOrSet(key, fetcher, options) {
91
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
92
+ }
93
+ async has(key) {
94
+ return this.cache.has(this.qualify(key));
95
+ }
96
+ async ttl(key) {
97
+ return this.cache.ttl(this.qualify(key));
98
+ }
99
+ async set(key, value, options) {
100
+ await this.cache.set(this.qualify(key), value, options);
101
+ }
102
+ async delete(key) {
103
+ await this.cache.delete(this.qualify(key));
104
+ }
105
+ async mdelete(keys) {
106
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
107
+ }
108
+ async clear() {
109
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
110
+ }
111
+ async mget(entries) {
112
+ return this.cache.mget(
113
+ entries.map((entry) => ({
114
+ ...entry,
115
+ key: this.qualify(entry.key)
116
+ }))
117
+ );
118
+ }
119
+ async mset(entries) {
120
+ await this.cache.mset(
121
+ entries.map((entry) => ({
122
+ ...entry,
123
+ key: this.qualify(entry.key)
124
+ }))
125
+ );
126
+ }
127
+ async invalidateByTag(tag) {
128
+ await this.cache.invalidateByTag(tag);
129
+ }
130
+ async invalidateByPattern(pattern) {
131
+ await this.cache.invalidateByPattern(this.qualify(pattern));
132
+ }
133
+ wrap(keyPrefix, fetcher, options) {
134
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
135
+ }
136
+ warm(entries, options) {
137
+ return this.cache.warm(
138
+ entries.map((entry) => ({
139
+ ...entry,
140
+ key: this.qualify(entry.key)
141
+ })),
142
+ options
143
+ );
144
+ }
145
+ getMetrics() {
146
+ return this.cache.getMetrics();
147
+ }
148
+ getHitRate() {
149
+ return this.cache.getHitRate();
150
+ }
151
+ qualify(key) {
152
+ return `${this.prefix}:${key}`;
153
+ }
154
+ };
155
+
156
+ // ../../src/internal/CircuitBreakerManager.ts
157
+ var CircuitBreakerManager = class {
158
+ breakers = /* @__PURE__ */ new Map();
159
+ maxEntries;
160
+ constructor(options) {
161
+ this.maxEntries = options.maxEntries;
162
+ }
163
+ /**
164
+ * Throws if the circuit is open for the given key.
165
+ * Automatically resets if the cooldown has elapsed.
166
+ */
167
+ assertClosed(key, options) {
168
+ const state = this.breakers.get(key);
169
+ if (!state?.openUntil) {
170
+ return;
171
+ }
172
+ const now = Date.now();
173
+ if (state.openUntil <= now) {
174
+ state.openUntil = null;
175
+ state.failures = 0;
176
+ this.breakers.set(key, state);
177
+ return;
178
+ }
179
+ const remainingMs = state.openUntil - now;
180
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
181
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
182
+ }
183
+ recordFailure(key, options) {
184
+ if (!options) {
185
+ return;
186
+ }
187
+ const failureThreshold = options.failureThreshold ?? 3;
188
+ const cooldownMs = options.cooldownMs ?? 3e4;
189
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
190
+ state.failures += 1;
191
+ if (state.failures >= failureThreshold) {
192
+ state.openUntil = Date.now() + cooldownMs;
193
+ }
194
+ this.breakers.set(key, state);
195
+ this.pruneIfNeeded();
196
+ }
197
+ recordSuccess(key) {
198
+ this.breakers.delete(key);
199
+ }
200
+ isOpen(key) {
201
+ const state = this.breakers.get(key);
202
+ if (!state?.openUntil) {
203
+ return false;
204
+ }
205
+ if (state.openUntil <= Date.now()) {
206
+ state.openUntil = null;
207
+ state.failures = 0;
208
+ return false;
209
+ }
210
+ return true;
211
+ }
212
+ delete(key) {
213
+ this.breakers.delete(key);
214
+ }
215
+ clear() {
216
+ this.breakers.clear();
217
+ }
218
+ tripCount() {
219
+ let count = 0;
220
+ for (const state of this.breakers.values()) {
221
+ if (state.openUntil !== null) {
222
+ count += 1;
223
+ }
224
+ }
225
+ return count;
226
+ }
227
+ pruneIfNeeded() {
228
+ if (this.breakers.size <= this.maxEntries) {
229
+ return;
230
+ }
231
+ for (const [key, state] of this.breakers.entries()) {
232
+ if (this.breakers.size <= this.maxEntries) {
233
+ break;
234
+ }
235
+ if (!state.openUntil || state.openUntil <= Date.now()) {
236
+ this.breakers.delete(key);
237
+ }
238
+ }
239
+ for (const key of this.breakers.keys()) {
240
+ if (this.breakers.size <= this.maxEntries) {
241
+ break;
242
+ }
243
+ this.breakers.delete(key);
244
+ }
245
+ }
246
+ };
247
+
248
+ // ../../src/internal/MetricsCollector.ts
249
+ var MetricsCollector = class {
250
+ data = this.empty();
251
+ get snapshot() {
252
+ return { ...this.data };
253
+ }
254
+ increment(field, amount = 1) {
255
+ ;
256
+ this.data[field] += amount;
257
+ }
258
+ incrementLayer(map, layerName) {
259
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
260
+ }
261
+ reset() {
262
+ this.data = this.empty();
263
+ }
264
+ hitRate() {
265
+ const total = this.data.hits + this.data.misses;
266
+ const overall = total === 0 ? 0 : this.data.hits / total;
267
+ const byLayer = {};
268
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
269
+ for (const layer of allLayers) {
270
+ const h = this.data.hitsByLayer[layer] ?? 0;
271
+ const m = this.data.missesByLayer[layer] ?? 0;
272
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
273
+ }
274
+ return { overall, byLayer };
275
+ }
276
+ empty() {
277
+ return {
278
+ hits: 0,
279
+ misses: 0,
280
+ fetches: 0,
281
+ sets: 0,
282
+ deletes: 0,
283
+ backfills: 0,
284
+ invalidations: 0,
285
+ staleHits: 0,
286
+ refreshes: 0,
287
+ refreshErrors: 0,
288
+ writeFailures: 0,
289
+ singleFlightWaits: 0,
290
+ negativeCacheHits: 0,
291
+ circuitBreakerTrips: 0,
292
+ degradedOperations: 0,
293
+ hitsByLayer: {},
294
+ missesByLayer: {},
295
+ resetAt: Date.now()
296
+ };
297
+ }
298
+ };
78
299
 
79
300
  // ../../src/internal/StoredValue.ts
80
301
  function isStoredValueEnvelope(value) {
@@ -177,67 +398,129 @@ function normalizePositiveSeconds(value) {
177
398
  return value;
178
399
  }
179
400
 
180
- // ../../src/CacheNamespace.ts
181
- var CacheNamespace = class {
182
- constructor(cache, prefix) {
183
- this.cache = cache;
184
- this.prefix = prefix;
185
- }
186
- cache;
187
- prefix;
188
- async get(key, fetcher, options) {
189
- return this.cache.get(this.qualify(key), fetcher, options);
190
- }
191
- async set(key, value, options) {
192
- await this.cache.set(this.qualify(key), value, options);
193
- }
194
- async delete(key) {
195
- await this.cache.delete(this.qualify(key));
401
+ // ../../src/internal/TtlResolver.ts
402
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
403
+ var TtlResolver = class {
404
+ accessProfiles = /* @__PURE__ */ new Map();
405
+ maxProfileEntries;
406
+ constructor(options) {
407
+ this.maxProfileEntries = options.maxProfileEntries;
196
408
  }
197
- async clear() {
198
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
409
+ recordAccess(key) {
410
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
411
+ profile.hits += 1;
412
+ profile.lastAccessAt = Date.now();
413
+ this.accessProfiles.set(key, profile);
414
+ this.pruneIfNeeded();
199
415
  }
200
- async mget(entries) {
201
- return this.cache.mget(entries.map((entry) => ({
202
- ...entry,
203
- key: this.qualify(entry.key)
204
- })));
416
+ deleteProfile(key) {
417
+ this.accessProfiles.delete(key);
205
418
  }
206
- async mset(entries) {
207
- await this.cache.mset(entries.map((entry) => ({
208
- ...entry,
209
- key: this.qualify(entry.key)
210
- })));
419
+ clearProfiles() {
420
+ this.accessProfiles.clear();
211
421
  }
212
- async invalidateByTag(tag) {
213
- await this.cache.invalidateByTag(tag);
422
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
423
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
424
+ layerName,
425
+ options?.negativeTtl,
426
+ globalNegativeTtl,
427
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
428
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
429
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
430
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
431
+ return this.applyJitter(adaptiveTtl, jitter);
214
432
  }
215
- async invalidateByPattern(pattern) {
216
- await this.cache.invalidateByPattern(this.qualify(pattern));
433
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
434
+ if (override !== void 0) {
435
+ return this.readLayerNumber(layerName, override) ?? fallback;
436
+ }
437
+ if (globalDefault !== void 0) {
438
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
439
+ }
440
+ return fallback;
217
441
  }
218
- wrap(keyPrefix, fetcher, options) {
219
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
442
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
443
+ if (!ttl || !adaptiveTtl) {
444
+ return ttl;
445
+ }
446
+ const profile = this.accessProfiles.get(key);
447
+ if (!profile) {
448
+ return ttl;
449
+ }
450
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
451
+ const hotAfter = config.hotAfter ?? 3;
452
+ if (profile.hits < hotAfter) {
453
+ return ttl;
454
+ }
455
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
456
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
457
+ const multiplier = Math.floor(profile.hits / hotAfter);
458
+ return Math.min(maxTtl, ttl + step * multiplier);
220
459
  }
221
- warm(entries, options) {
222
- return this.cache.warm(entries.map((entry) => ({
223
- ...entry,
224
- key: this.qualify(entry.key)
225
- })), options);
460
+ applyJitter(ttl, jitter) {
461
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
462
+ return ttl;
463
+ }
464
+ const delta = (Math.random() * 2 - 1) * jitter;
465
+ return Math.max(1, Math.round(ttl + delta));
226
466
  }
227
- getMetrics() {
228
- return this.cache.getMetrics();
467
+ readLayerNumber(layerName, value) {
468
+ if (typeof value === "number") {
469
+ return value;
470
+ }
471
+ return value[layerName];
229
472
  }
230
- qualify(key) {
231
- return `${this.prefix}:${key}`;
473
+ pruneIfNeeded() {
474
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
475
+ return;
476
+ }
477
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
478
+ let removed = 0;
479
+ for (const key of this.accessProfiles.keys()) {
480
+ if (removed >= toRemove) {
481
+ break;
482
+ }
483
+ this.accessProfiles.delete(key);
484
+ removed += 1;
485
+ }
232
486
  }
233
487
  };
234
488
 
235
489
  // ../../src/invalidation/PatternMatcher.ts
236
- var PatternMatcher = class {
490
+ var PatternMatcher = class _PatternMatcher {
491
+ /**
492
+ * Tests whether a glob-style pattern matches a value.
493
+ * Supports `*` (any sequence of characters) and `?` (any single character).
494
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
495
+ */
237
496
  static matches(pattern, value) {
238
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
239
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
240
- return regex.test(value);
497
+ return _PatternMatcher.matchLinear(pattern, value);
498
+ }
499
+ /**
500
+ * Linear-time glob matching using dynamic programming.
501
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
502
+ */
503
+ static matchLinear(pattern, value) {
504
+ const m = pattern.length;
505
+ const n = value.length;
506
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
507
+ dp[0][0] = true;
508
+ for (let i = 1; i <= m; i++) {
509
+ if (pattern[i - 1] === "*") {
510
+ dp[i][0] = dp[i - 1]?.[0];
511
+ }
512
+ }
513
+ for (let i = 1; i <= m; i++) {
514
+ for (let j = 1; j <= n; j++) {
515
+ const pc = pattern[i - 1];
516
+ if (pc === "*") {
517
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
518
+ } else if (pc === "?" || pc === value[j - 1]) {
519
+ dp[i][j] = dp[i - 1]?.[j - 1];
520
+ }
521
+ }
522
+ }
523
+ return dp[m]?.[n];
241
524
  }
242
525
  };
243
526
 
@@ -500,30 +783,11 @@ var StampedeGuard = class {
500
783
  };
501
784
 
502
785
  // ../../src/CacheStack.ts
503
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
504
786
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
505
787
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
506
788
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
507
789
  var MAX_CACHE_KEY_LENGTH = 1024;
508
- var EMPTY_METRICS = () => ({
509
- hits: 0,
510
- misses: 0,
511
- fetches: 0,
512
- sets: 0,
513
- deletes: 0,
514
- backfills: 0,
515
- invalidations: 0,
516
- staleHits: 0,
517
- refreshes: 0,
518
- refreshErrors: 0,
519
- writeFailures: 0,
520
- singleFlightWaits: 0,
521
- negativeCacheHits: 0,
522
- circuitBreakerTrips: 0,
523
- degradedOperations: 0,
524
- hitsByLayer: {},
525
- missesByLayer: {}
526
- });
790
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
527
791
  var DebugLogger = class {
528
792
  enabled;
529
793
  constructor(enabled) {
@@ -558,6 +822,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
558
822
  throw new Error("CacheStack requires at least one cache layer.");
559
823
  }
560
824
  this.validateConfiguration();
825
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
826
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
827
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
828
+ if (options.publishSetInvalidation !== void 0) {
829
+ console.warn(
830
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
831
+ );
832
+ }
561
833
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
562
834
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
563
835
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -566,36 +838,42 @@ var CacheStack = class extends import_node_events.EventEmitter {
566
838
  layers;
567
839
  options;
568
840
  stampedeGuard = new StampedeGuard();
569
- metrics = EMPTY_METRICS();
841
+ metricsCollector = new MetricsCollector();
570
842
  instanceId = (0, import_node_crypto.randomUUID)();
571
843
  startup;
572
844
  unsubscribeInvalidation;
573
845
  logger;
574
846
  tagIndex;
575
847
  backgroundRefreshes = /* @__PURE__ */ new Map();
576
- accessProfiles = /* @__PURE__ */ new Map();
577
848
  layerDegradedUntil = /* @__PURE__ */ new Map();
578
- circuitBreakers = /* @__PURE__ */ new Map();
849
+ ttlResolver;
850
+ circuitBreakerManager;
579
851
  isDisconnecting = false;
580
852
  disconnectPromise;
853
+ /**
854
+ * Read-through cache get.
855
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
856
+ * and stores the result across all layers. Returns `null` if the key is not found
857
+ * and no `fetcher` is provided.
858
+ */
581
859
  async get(key, fetcher, options) {
582
860
  const normalizedKey = this.validateCacheKey(key);
583
861
  this.validateWriteOptions(options);
584
862
  await this.startup;
585
863
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
586
864
  if (hit.found) {
587
- this.recordAccess(normalizedKey);
865
+ this.ttlResolver.recordAccess(normalizedKey);
588
866
  if (this.isNegativeStoredValue(hit.stored)) {
589
- this.metrics.negativeCacheHits += 1;
867
+ this.metricsCollector.increment("negativeCacheHits");
590
868
  }
591
869
  if (hit.state === "fresh") {
592
- this.metrics.hits += 1;
870
+ this.metricsCollector.increment("hits");
593
871
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
594
872
  return hit.value;
595
873
  }
596
874
  if (hit.state === "stale-while-revalidate") {
597
- this.metrics.hits += 1;
598
- this.metrics.staleHits += 1;
875
+ this.metricsCollector.increment("hits");
876
+ this.metricsCollector.increment("staleHits");
599
877
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
600
878
  if (fetcher) {
601
879
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -603,47 +881,136 @@ var CacheStack = class extends import_node_events.EventEmitter {
603
881
  return hit.value;
604
882
  }
605
883
  if (!fetcher) {
606
- this.metrics.hits += 1;
607
- this.metrics.staleHits += 1;
884
+ this.metricsCollector.increment("hits");
885
+ this.metricsCollector.increment("staleHits");
608
886
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
609
887
  return hit.value;
610
888
  }
611
889
  try {
612
890
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
613
891
  } catch (error) {
614
- this.metrics.staleHits += 1;
615
- this.metrics.refreshErrors += 1;
892
+ this.metricsCollector.increment("staleHits");
893
+ this.metricsCollector.increment("refreshErrors");
616
894
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
617
895
  return hit.value;
618
896
  }
619
897
  }
620
- this.metrics.misses += 1;
898
+ this.metricsCollector.increment("misses");
621
899
  if (!fetcher) {
622
900
  return null;
623
901
  }
624
902
  return this.fetchWithGuards(normalizedKey, fetcher, options);
625
903
  }
904
+ /**
905
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
906
+ * Fetches and caches the value if not already present.
907
+ */
908
+ async getOrSet(key, fetcher, options) {
909
+ return this.get(key, fetcher, options);
910
+ }
911
+ /**
912
+ * Returns true if the given key exists and is not expired in any layer.
913
+ */
914
+ async has(key) {
915
+ const normalizedKey = this.validateCacheKey(key);
916
+ await this.startup;
917
+ for (const layer of this.layers) {
918
+ if (this.shouldSkipLayer(layer)) {
919
+ continue;
920
+ }
921
+ if (layer.has) {
922
+ try {
923
+ const exists = await layer.has(normalizedKey);
924
+ if (exists) {
925
+ return true;
926
+ }
927
+ } catch {
928
+ }
929
+ } else {
930
+ try {
931
+ const value = await layer.get(normalizedKey);
932
+ if (value !== null) {
933
+ return true;
934
+ }
935
+ } catch {
936
+ }
937
+ }
938
+ }
939
+ return false;
940
+ }
941
+ /**
942
+ * Returns the remaining TTL in seconds for the key in the fastest layer
943
+ * that has it, or null if the key is not found / has no TTL.
944
+ */
945
+ async ttl(key) {
946
+ const normalizedKey = this.validateCacheKey(key);
947
+ await this.startup;
948
+ for (const layer of this.layers) {
949
+ if (this.shouldSkipLayer(layer)) {
950
+ continue;
951
+ }
952
+ if (layer.ttl) {
953
+ try {
954
+ const remaining = await layer.ttl(normalizedKey);
955
+ if (remaining !== null) {
956
+ return remaining;
957
+ }
958
+ } catch {
959
+ }
960
+ }
961
+ }
962
+ return null;
963
+ }
964
+ /**
965
+ * Stores a value in all cache layers. Overwrites any existing value.
966
+ */
626
967
  async set(key, value, options) {
627
968
  const normalizedKey = this.validateCacheKey(key);
628
969
  this.validateWriteOptions(options);
629
970
  await this.startup;
630
971
  await this.storeEntry(normalizedKey, "value", value, options);
631
972
  }
973
+ /**
974
+ * Deletes the key from all layers and publishes an invalidation message.
975
+ */
632
976
  async delete(key) {
633
977
  const normalizedKey = this.validateCacheKey(key);
634
978
  await this.startup;
635
979
  await this.deleteKeys([normalizedKey]);
636
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
980
+ await this.publishInvalidation({
981
+ scope: "key",
982
+ keys: [normalizedKey],
983
+ sourceId: this.instanceId,
984
+ operation: "delete"
985
+ });
637
986
  }
638
987
  async clear() {
639
988
  await this.startup;
640
989
  await Promise.all(this.layers.map((layer) => layer.clear()));
641
990
  await this.tagIndex.clear();
642
- this.accessProfiles.clear();
643
- this.metrics.invalidations += 1;
991
+ this.ttlResolver.clearProfiles();
992
+ this.circuitBreakerManager.clear();
993
+ this.metricsCollector.increment("invalidations");
644
994
  this.logger.debug?.("clear");
645
995
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
646
996
  }
997
+ /**
998
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
999
+ */
1000
+ async mdelete(keys) {
1001
+ if (keys.length === 0) {
1002
+ return;
1003
+ }
1004
+ await this.startup;
1005
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1006
+ await this.deleteKeys(normalizedKeys);
1007
+ await this.publishInvalidation({
1008
+ scope: "keys",
1009
+ keys: normalizedKeys,
1010
+ sourceId: this.instanceId,
1011
+ operation: "delete"
1012
+ });
1013
+ }
647
1014
  async mget(entries) {
648
1015
  if (entries.length === 0) {
649
1016
  return [];
@@ -681,7 +1048,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
681
1048
  const indexesByKey = /* @__PURE__ */ new Map();
682
1049
  const resultsByKey = /* @__PURE__ */ new Map();
683
1050
  for (let index = 0; index < normalizedEntries.length; index += 1) {
684
- const key = normalizedEntries[index].key;
1051
+ const entry = normalizedEntries[index];
1052
+ if (!entry) continue;
1053
+ const key = entry.key;
685
1054
  const indexes = indexesByKey.get(key) ?? [];
686
1055
  indexes.push(index);
687
1056
  indexesByKey.set(key, indexes);
@@ -689,6 +1058,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
689
1058
  }
690
1059
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
691
1060
  const layer = this.layers[layerIndex];
1061
+ if (!layer) continue;
692
1062
  const keys = [...pending];
693
1063
  if (keys.length === 0) {
694
1064
  break;
@@ -697,7 +1067,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
697
1067
  for (let offset = 0; offset < values.length; offset += 1) {
698
1068
  const key = keys[offset];
699
1069
  const stored = values[offset];
700
- if (stored === null) {
1070
+ if (!key || stored === null) {
701
1071
  continue;
702
1072
  }
703
1073
  const resolved = resolveStoredValue(stored);
@@ -709,13 +1079,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
709
1079
  await this.backfill(key, stored, layerIndex - 1);
710
1080
  resultsByKey.set(key, resolved.value);
711
1081
  pending.delete(key);
712
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
1082
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
713
1083
  }
714
1084
  }
715
1085
  if (pending.size > 0) {
716
1086
  for (const key of pending) {
717
1087
  await this.tagIndex.remove(key);
718
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
1088
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
719
1089
  }
720
1090
  }
721
1091
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -730,26 +1100,38 @@ var CacheStack = class extends import_node_events.EventEmitter {
730
1100
  }
731
1101
  async warm(entries, options = {}) {
732
1102
  const concurrency = Math.max(1, options.concurrency ?? 4);
1103
+ const total = entries.length;
1104
+ let completed = 0;
733
1105
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
734
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
1106
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
735
1107
  while (queue.length > 0) {
736
1108
  const entry = queue.shift();
737
1109
  if (!entry) {
738
1110
  return;
739
1111
  }
1112
+ let success = false;
740
1113
  try {
741
1114
  await this.get(entry.key, entry.fetcher, entry.options);
742
1115
  this.emit("warm", { key: entry.key });
1116
+ success = true;
743
1117
  } catch (error) {
744
1118
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
745
1119
  if (!options.continueOnError) {
746
1120
  throw error;
747
1121
  }
1122
+ } finally {
1123
+ completed += 1;
1124
+ const progress = { completed, total, key: entry.key, success };
1125
+ options.onProgress?.(progress);
748
1126
  }
749
1127
  }
750
1128
  });
751
1129
  await Promise.all(workers);
752
1130
  }
1131
+ /**
1132
+ * Returns a cached version of `fetcher`. The cache key is derived from
1133
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
1134
+ */
753
1135
  wrap(prefix, fetcher, options = {}) {
754
1136
  return (...args) => {
755
1137
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -757,6 +1139,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
757
1139
  return this.get(key, () => fetcher(...args), options);
758
1140
  };
759
1141
  }
1142
+ /**
1143
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
1144
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
1145
+ */
760
1146
  namespace(prefix) {
761
1147
  return new CacheNamespace(this, prefix);
762
1148
  }
@@ -773,7 +1159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
773
1159
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
774
1160
  }
775
1161
  getMetrics() {
776
- return { ...this.metrics };
1162
+ return this.metricsCollector.snapshot;
777
1163
  }
778
1164
  getStats() {
779
1165
  return {
@@ -787,7 +1173,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
787
1173
  };
788
1174
  }
789
1175
  resetMetrics() {
790
- Object.assign(this.metrics, EMPTY_METRICS());
1176
+ this.metricsCollector.reset();
1177
+ }
1178
+ /**
1179
+ * Returns computed hit-rate statistics (overall and per-layer).
1180
+ */
1181
+ getHitRate() {
1182
+ return this.metricsCollector.hitRate();
791
1183
  }
792
1184
  async exportState() {
793
1185
  await this.startup;
@@ -816,10 +1208,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
816
1208
  }
817
1209
  async importState(entries) {
818
1210
  await this.startup;
819
- await Promise.all(entries.map(async (entry) => {
820
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
821
- await this.tagIndex.touch(entry.key);
822
- }));
1211
+ await Promise.all(
1212
+ entries.map(async (entry) => {
1213
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1214
+ await this.tagIndex.touch(entry.key);
1215
+ })
1216
+ );
823
1217
  }
824
1218
  async persistToFile(filePath) {
825
1219
  const snapshot = await this.exportState();
@@ -827,11 +1221,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
827
1221
  }
828
1222
  async restoreFromFile(filePath) {
829
1223
  const raw = await import_node_fs.promises.readFile(filePath, "utf8");
830
- const snapshot = JSON.parse(raw);
831
- if (!this.isCacheSnapshotEntries(snapshot)) {
832
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
1224
+ let parsed;
1225
+ try {
1226
+ parsed = JSON.parse(raw, (_key, value) => {
1227
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1228
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1229
+ }
1230
+ return value;
1231
+ });
1232
+ } catch (cause) {
1233
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
833
1234
  }
834
- await this.importState(snapshot);
1235
+ if (!this.isCacheSnapshotEntries(parsed)) {
1236
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1237
+ }
1238
+ await this.importState(parsed);
835
1239
  }
836
1240
  async disconnect() {
837
1241
  if (!this.disconnectPromise) {
@@ -856,7 +1260,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
856
1260
  const fetchTask = async () => {
857
1261
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
858
1262
  if (secondHit.found) {
859
- this.metrics.hits += 1;
1263
+ this.metricsCollector.increment("hits");
860
1264
  return secondHit.value;
861
1265
  }
862
1266
  return this.fetchAndPopulate(key, fetcher, options);
@@ -881,12 +1285,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
881
1285
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
882
1286
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
883
1287
  const deadline = Date.now() + timeoutMs;
884
- this.metrics.singleFlightWaits += 1;
1288
+ this.metricsCollector.increment("singleFlightWaits");
885
1289
  this.emit("stampede-dedupe", { key });
886
1290
  while (Date.now() < deadline) {
887
1291
  const hit = await this.readFromLayers(key, options, "fresh-only");
888
1292
  if (hit.found) {
889
- this.metrics.hits += 1;
1293
+ this.metricsCollector.increment("hits");
890
1294
  return hit.value;
891
1295
  }
892
1296
  await this.sleep(pollIntervalMs);
@@ -894,12 +1298,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
894
1298
  return this.fetchAndPopulate(key, fetcher, options);
895
1299
  }
896
1300
  async fetchAndPopulate(key, fetcher, options) {
897
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
898
- this.metrics.fetches += 1;
1301
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1302
+ this.metricsCollector.increment("fetches");
1303
+ const fetchStart = Date.now();
899
1304
  let fetched;
900
1305
  try {
901
1306
  fetched = await fetcher();
902
- this.resetCircuitBreaker(key);
1307
+ this.circuitBreakerManager.recordSuccess(key);
1308
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
903
1309
  } catch (error) {
904
1310
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
905
1311
  throw error;
@@ -921,7 +1327,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
921
1327
  } else {
922
1328
  await this.tagIndex.touch(key);
923
1329
  }
924
- this.metrics.sets += 1;
1330
+ this.metricsCollector.increment("sets");
925
1331
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
926
1332
  this.emit("set", { key, kind, tags: options?.tags });
927
1333
  if (this.shouldBroadcastL1Invalidation()) {
@@ -932,9 +1338,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
932
1338
  let sawRetainableValue = false;
933
1339
  for (let index = 0; index < this.layers.length; index += 1) {
934
1340
  const layer = this.layers[index];
1341
+ if (!layer) continue;
935
1342
  const stored = await this.readLayerEntry(layer, key);
936
1343
  if (stored === null) {
937
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1344
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
938
1345
  continue;
939
1346
  }
940
1347
  const resolved = resolveStoredValue(stored);
@@ -948,10 +1355,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
948
1355
  }
949
1356
  await this.tagIndex.touch(key);
950
1357
  await this.backfill(key, stored, index - 1, options);
951
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1358
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
952
1359
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
953
1360
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
954
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1361
+ return {
1362
+ found: true,
1363
+ value: resolved.value,
1364
+ stored,
1365
+ state: resolved.state,
1366
+ layerIndex: index,
1367
+ layerName: layer.name
1368
+ };
955
1369
  }
956
1370
  if (!sawRetainableValue) {
957
1371
  await this.tagIndex.remove(key);
@@ -983,7 +1397,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
983
1397
  }
984
1398
  for (let index = 0; index <= upToIndex; index += 1) {
985
1399
  const layer = this.layers[index];
986
- if (this.shouldSkipLayer(layer)) {
1400
+ if (!layer || this.shouldSkipLayer(layer)) {
987
1401
  continue;
988
1402
  }
989
1403
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -993,7 +1407,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
993
1407
  await this.handleLayerFailure(layer, "backfill", error);
994
1408
  continue;
995
1409
  }
996
- this.metrics.backfills += 1;
1410
+ this.metricsCollector.increment("backfills");
997
1411
  this.logger.debug?.("backfill", { key, layer: layer.name });
998
1412
  this.emit("backfill", { key, layer: layer.name });
999
1413
  }
@@ -1010,11 +1424,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1010
1424
  options?.staleWhileRevalidate,
1011
1425
  this.options.staleWhileRevalidate
1012
1426
  );
1013
- const staleIfError = this.resolveLayerSeconds(
1014
- layer.name,
1015
- options?.staleIfError,
1016
- this.options.staleIfError
1017
- );
1427
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
1018
1428
  const payload = createStoredValueEnvelope({
1019
1429
  kind,
1020
1430
  value,
@@ -1042,7 +1452,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1042
1452
  if (failures.length === 0) {
1043
1453
  return;
1044
1454
  }
1045
- this.metrics.writeFailures += failures.length;
1455
+ this.metricsCollector.increment("writeFailures", failures.length);
1046
1456
  this.logger.debug?.("write-failure", {
1047
1457
  ...context,
1048
1458
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -1055,42 +1465,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
1055
1465
  }
1056
1466
  }
1057
1467
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1058
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
1059
- layerName,
1060
- options?.negativeTtl,
1061
- this.options.negativeTtl,
1062
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
1063
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
1064
- const adaptiveTtl = this.applyAdaptiveTtl(
1065
- key,
1066
- layerName,
1067
- baseTtl,
1068
- options?.adaptiveTtl ?? this.options.adaptiveTtl
1069
- );
1070
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
1071
- return this.applyJitter(adaptiveTtl, jitter);
1468
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
1072
1469
  }
1073
1470
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
1074
- if (override !== void 0) {
1075
- return this.readLayerNumber(layerName, override) ?? fallback;
1076
- }
1077
- if (globalDefault !== void 0) {
1078
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
1079
- }
1080
- return fallback;
1081
- }
1082
- readLayerNumber(layerName, value) {
1083
- if (typeof value === "number") {
1084
- return value;
1085
- }
1086
- return value[layerName];
1087
- }
1088
- applyJitter(ttl, jitter) {
1089
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
1090
- return ttl;
1091
- }
1092
- const delta = (Math.random() * 2 - 1) * jitter;
1093
- return Math.max(1, Math.round(ttl + delta));
1471
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
1094
1472
  }
1095
1473
  shouldNegativeCache(options) {
1096
1474
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -1100,11 +1478,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
1100
1478
  return;
1101
1479
  }
1102
1480
  const refresh = (async () => {
1103
- this.metrics.refreshes += 1;
1481
+ this.metricsCollector.increment("refreshes");
1104
1482
  try {
1105
1483
  await this.fetchWithGuards(key, fetcher, options);
1106
1484
  } catch (error) {
1107
- this.metrics.refreshErrors += 1;
1485
+ this.metricsCollector.increment("refreshErrors");
1108
1486
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
1109
1487
  } finally {
1110
1488
  this.backgroundRefreshes.delete(key);
@@ -1126,10 +1504,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
1126
1504
  await this.deleteKeysFromLayers(this.layers, keys);
1127
1505
  for (const key of keys) {
1128
1506
  await this.tagIndex.remove(key);
1129
- this.accessProfiles.delete(key);
1507
+ this.ttlResolver.deleteProfile(key);
1508
+ this.circuitBreakerManager.delete(key);
1130
1509
  }
1131
- this.metrics.deletes += keys.length;
1132
- this.metrics.invalidations += 1;
1510
+ this.metricsCollector.increment("deletes", keys.length);
1511
+ this.metricsCollector.increment("invalidations");
1133
1512
  this.logger.debug?.("delete", { keys });
1134
1513
  this.emit("delete", { keys });
1135
1514
  }
@@ -1150,7 +1529,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1150
1529
  if (message.scope === "clear") {
1151
1530
  await Promise.all(localLayers.map((layer) => layer.clear()));
1152
1531
  await this.tagIndex.clear();
1153
- this.accessProfiles.clear();
1532
+ this.ttlResolver.clearProfiles();
1154
1533
  return;
1155
1534
  }
1156
1535
  const keys = message.keys ?? [];
@@ -1158,7 +1537,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1158
1537
  if (message.operation !== "write") {
1159
1538
  for (const key of keys) {
1160
1539
  await this.tagIndex.remove(key);
1161
- this.accessProfiles.delete(key);
1540
+ this.ttlResolver.deleteProfile(key);
1162
1541
  }
1163
1542
  }
1164
1543
  }
@@ -1188,13 +1567,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
1188
1567
  }
1189
1568
  return;
1190
1569
  }
1191
- await Promise.all(keys.map(async (key) => {
1192
- try {
1193
- await layer.delete(key);
1194
- } catch (error) {
1195
- await this.handleLayerFailure(layer, "delete", error);
1196
- }
1197
- }));
1570
+ await Promise.all(
1571
+ keys.map(async (key) => {
1572
+ try {
1573
+ await layer.delete(key);
1574
+ } catch (error) {
1575
+ await this.handleLayerFailure(layer, "delete", error);
1576
+ }
1577
+ })
1578
+ );
1198
1579
  })
1199
1580
  );
1200
1581
  }
@@ -1295,7 +1676,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1295
1676
  const ttl = remainingStoredTtlSeconds(refreshed);
1296
1677
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1297
1678
  const layer = this.layers[index];
1298
- if (this.shouldSkipLayer(layer)) {
1679
+ if (!layer || this.shouldSkipLayer(layer)) {
1299
1680
  continue;
1300
1681
  }
1301
1682
  try {
@@ -1309,33 +1690,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
1309
1690
  this.scheduleBackgroundRefresh(key, fetcher, options);
1310
1691
  }
1311
1692
  }
1312
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1313
- if (!ttl || !adaptiveTtl) {
1314
- return ttl;
1315
- }
1316
- const profile = this.accessProfiles.get(key);
1317
- if (!profile) {
1318
- return ttl;
1319
- }
1320
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1321
- const hotAfter = config.hotAfter ?? 3;
1322
- if (profile.hits < hotAfter) {
1323
- return ttl;
1324
- }
1325
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1326
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1327
- const multiplier = Math.floor(profile.hits / hotAfter);
1328
- return Math.min(maxTtl, ttl + step * multiplier);
1329
- }
1330
- recordAccess(key) {
1331
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1332
- profile.hits += 1;
1333
- profile.lastAccessAt = Date.now();
1334
- this.accessProfiles.set(key, profile);
1335
- }
1336
- incrementMetricMap(target, key) {
1337
- target[key] = (target[key] ?? 0) + 1;
1338
- }
1339
1693
  shouldSkipLayer(layer) {
1340
1694
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1341
1695
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1346,7 +1700,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1346
1700
  }
1347
1701
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1348
1702
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1349
- this.metrics.degradedOperations += 1;
1703
+ this.metricsCollector.increment("degradedOperations");
1350
1704
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1351
1705
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1352
1706
  return null;
@@ -1354,37 +1708,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
1354
1708
  isGracefulDegradationEnabled() {
1355
1709
  return Boolean(this.options.gracefulDegradation);
1356
1710
  }
1357
- assertCircuitClosed(key, options) {
1358
- const state = this.circuitBreakers.get(key);
1359
- if (!state?.openUntil) {
1360
- return;
1361
- }
1362
- if (state.openUntil <= Date.now()) {
1363
- state.openUntil = null;
1364
- state.failures = 0;
1365
- this.circuitBreakers.set(key, state);
1366
- return;
1367
- }
1368
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1369
- throw new Error(`Circuit breaker is open for key "${key}".`);
1370
- }
1371
1711
  recordCircuitFailure(key, options, error) {
1372
1712
  if (!options) {
1373
1713
  return;
1374
1714
  }
1375
- const failureThreshold = options.failureThreshold ?? 3;
1376
- const cooldownMs = options.cooldownMs ?? 3e4;
1377
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1378
- state.failures += 1;
1379
- if (state.failures >= failureThreshold) {
1380
- state.openUntil = Date.now() + cooldownMs;
1381
- this.metrics.circuitBreakerTrips += 1;
1715
+ this.circuitBreakerManager.recordFailure(key, options);
1716
+ if (this.circuitBreakerManager.isOpen(key)) {
1717
+ this.metricsCollector.increment("circuitBreakerTrips");
1382
1718
  }
1383
- this.circuitBreakers.set(key, state);
1384
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1385
- }
1386
- resetCircuitBreaker(key) {
1387
- this.circuitBreakers.delete(key);
1719
+ this.emitError("fetch", { key, error: this.formatError(error) });
1388
1720
  }
1389
1721
  isNegativeStoredValue(stored) {
1390
1722
  return isStoredValueEnvelope(stored) && stored.kind === "empty";