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.
@@ -47,8 +47,229 @@ import { Global, Inject, Module } from "@nestjs/common";
47
47
 
48
48
  // ../../src/CacheStack.ts
49
49
  import { randomUUID } from "crypto";
50
- import { promises as fs } from "fs";
51
50
  import { EventEmitter } from "events";
51
+ import { promises as fs } from "fs";
52
+
53
+ // ../../src/CacheNamespace.ts
54
+ var CacheNamespace = class {
55
+ constructor(cache, prefix) {
56
+ this.cache = cache;
57
+ this.prefix = prefix;
58
+ }
59
+ cache;
60
+ prefix;
61
+ async get(key, fetcher, options) {
62
+ return this.cache.get(this.qualify(key), fetcher, options);
63
+ }
64
+ async getOrSet(key, fetcher, options) {
65
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
66
+ }
67
+ async has(key) {
68
+ return this.cache.has(this.qualify(key));
69
+ }
70
+ async ttl(key) {
71
+ return this.cache.ttl(this.qualify(key));
72
+ }
73
+ async set(key, value, options) {
74
+ await this.cache.set(this.qualify(key), value, options);
75
+ }
76
+ async delete(key) {
77
+ await this.cache.delete(this.qualify(key));
78
+ }
79
+ async mdelete(keys) {
80
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
81
+ }
82
+ async clear() {
83
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
84
+ }
85
+ async mget(entries) {
86
+ return this.cache.mget(
87
+ entries.map((entry) => ({
88
+ ...entry,
89
+ key: this.qualify(entry.key)
90
+ }))
91
+ );
92
+ }
93
+ async mset(entries) {
94
+ await this.cache.mset(
95
+ entries.map((entry) => ({
96
+ ...entry,
97
+ key: this.qualify(entry.key)
98
+ }))
99
+ );
100
+ }
101
+ async invalidateByTag(tag) {
102
+ await this.cache.invalidateByTag(tag);
103
+ }
104
+ async invalidateByPattern(pattern) {
105
+ await this.cache.invalidateByPattern(this.qualify(pattern));
106
+ }
107
+ wrap(keyPrefix, fetcher, options) {
108
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
109
+ }
110
+ warm(entries, options) {
111
+ return this.cache.warm(
112
+ entries.map((entry) => ({
113
+ ...entry,
114
+ key: this.qualify(entry.key)
115
+ })),
116
+ options
117
+ );
118
+ }
119
+ getMetrics() {
120
+ return this.cache.getMetrics();
121
+ }
122
+ getHitRate() {
123
+ return this.cache.getHitRate();
124
+ }
125
+ qualify(key) {
126
+ return `${this.prefix}:${key}`;
127
+ }
128
+ };
129
+
130
+ // ../../src/internal/CircuitBreakerManager.ts
131
+ var CircuitBreakerManager = class {
132
+ breakers = /* @__PURE__ */ new Map();
133
+ maxEntries;
134
+ constructor(options) {
135
+ this.maxEntries = options.maxEntries;
136
+ }
137
+ /**
138
+ * Throws if the circuit is open for the given key.
139
+ * Automatically resets if the cooldown has elapsed.
140
+ */
141
+ assertClosed(key, options) {
142
+ const state = this.breakers.get(key);
143
+ if (!state?.openUntil) {
144
+ return;
145
+ }
146
+ const now = Date.now();
147
+ if (state.openUntil <= now) {
148
+ state.openUntil = null;
149
+ state.failures = 0;
150
+ this.breakers.set(key, state);
151
+ return;
152
+ }
153
+ const remainingMs = state.openUntil - now;
154
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
155
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
156
+ }
157
+ recordFailure(key, options) {
158
+ if (!options) {
159
+ return;
160
+ }
161
+ const failureThreshold = options.failureThreshold ?? 3;
162
+ const cooldownMs = options.cooldownMs ?? 3e4;
163
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
164
+ state.failures += 1;
165
+ if (state.failures >= failureThreshold) {
166
+ state.openUntil = Date.now() + cooldownMs;
167
+ }
168
+ this.breakers.set(key, state);
169
+ this.pruneIfNeeded();
170
+ }
171
+ recordSuccess(key) {
172
+ this.breakers.delete(key);
173
+ }
174
+ isOpen(key) {
175
+ const state = this.breakers.get(key);
176
+ if (!state?.openUntil) {
177
+ return false;
178
+ }
179
+ if (state.openUntil <= Date.now()) {
180
+ state.openUntil = null;
181
+ state.failures = 0;
182
+ return false;
183
+ }
184
+ return true;
185
+ }
186
+ delete(key) {
187
+ this.breakers.delete(key);
188
+ }
189
+ clear() {
190
+ this.breakers.clear();
191
+ }
192
+ tripCount() {
193
+ let count = 0;
194
+ for (const state of this.breakers.values()) {
195
+ if (state.openUntil !== null) {
196
+ count += 1;
197
+ }
198
+ }
199
+ return count;
200
+ }
201
+ pruneIfNeeded() {
202
+ if (this.breakers.size <= this.maxEntries) {
203
+ return;
204
+ }
205
+ for (const [key, state] of this.breakers.entries()) {
206
+ if (this.breakers.size <= this.maxEntries) {
207
+ break;
208
+ }
209
+ if (!state.openUntil || state.openUntil <= Date.now()) {
210
+ this.breakers.delete(key);
211
+ }
212
+ }
213
+ for (const key of this.breakers.keys()) {
214
+ if (this.breakers.size <= this.maxEntries) {
215
+ break;
216
+ }
217
+ this.breakers.delete(key);
218
+ }
219
+ }
220
+ };
221
+
222
+ // ../../src/internal/MetricsCollector.ts
223
+ var MetricsCollector = class {
224
+ data = this.empty();
225
+ get snapshot() {
226
+ return { ...this.data };
227
+ }
228
+ increment(field, amount = 1) {
229
+ ;
230
+ this.data[field] += amount;
231
+ }
232
+ incrementLayer(map, layerName) {
233
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
234
+ }
235
+ reset() {
236
+ this.data = this.empty();
237
+ }
238
+ hitRate() {
239
+ const total = this.data.hits + this.data.misses;
240
+ const overall = total === 0 ? 0 : this.data.hits / total;
241
+ const byLayer = {};
242
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
243
+ for (const layer of allLayers) {
244
+ const h = this.data.hitsByLayer[layer] ?? 0;
245
+ const m = this.data.missesByLayer[layer] ?? 0;
246
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
247
+ }
248
+ return { overall, byLayer };
249
+ }
250
+ empty() {
251
+ return {
252
+ hits: 0,
253
+ misses: 0,
254
+ fetches: 0,
255
+ sets: 0,
256
+ deletes: 0,
257
+ backfills: 0,
258
+ invalidations: 0,
259
+ staleHits: 0,
260
+ refreshes: 0,
261
+ refreshErrors: 0,
262
+ writeFailures: 0,
263
+ singleFlightWaits: 0,
264
+ negativeCacheHits: 0,
265
+ circuitBreakerTrips: 0,
266
+ degradedOperations: 0,
267
+ hitsByLayer: {},
268
+ missesByLayer: {},
269
+ resetAt: Date.now()
270
+ };
271
+ }
272
+ };
52
273
 
53
274
  // ../../src/internal/StoredValue.ts
54
275
  function isStoredValueEnvelope(value) {
@@ -151,67 +372,129 @@ function normalizePositiveSeconds(value) {
151
372
  return value;
152
373
  }
153
374
 
154
- // ../../src/CacheNamespace.ts
155
- var CacheNamespace = class {
156
- constructor(cache, prefix) {
157
- this.cache = cache;
158
- this.prefix = prefix;
159
- }
160
- cache;
161
- prefix;
162
- async get(key, fetcher, options) {
163
- return this.cache.get(this.qualify(key), fetcher, options);
164
- }
165
- async set(key, value, options) {
166
- await this.cache.set(this.qualify(key), value, options);
167
- }
168
- async delete(key) {
169
- await this.cache.delete(this.qualify(key));
375
+ // ../../src/internal/TtlResolver.ts
376
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
377
+ var TtlResolver = class {
378
+ accessProfiles = /* @__PURE__ */ new Map();
379
+ maxProfileEntries;
380
+ constructor(options) {
381
+ this.maxProfileEntries = options.maxProfileEntries;
170
382
  }
171
- async clear() {
172
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
383
+ recordAccess(key) {
384
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
385
+ profile.hits += 1;
386
+ profile.lastAccessAt = Date.now();
387
+ this.accessProfiles.set(key, profile);
388
+ this.pruneIfNeeded();
173
389
  }
174
- async mget(entries) {
175
- return this.cache.mget(entries.map((entry) => ({
176
- ...entry,
177
- key: this.qualify(entry.key)
178
- })));
390
+ deleteProfile(key) {
391
+ this.accessProfiles.delete(key);
179
392
  }
180
- async mset(entries) {
181
- await this.cache.mset(entries.map((entry) => ({
182
- ...entry,
183
- key: this.qualify(entry.key)
184
- })));
393
+ clearProfiles() {
394
+ this.accessProfiles.clear();
185
395
  }
186
- async invalidateByTag(tag) {
187
- await this.cache.invalidateByTag(tag);
396
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
397
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
398
+ layerName,
399
+ options?.negativeTtl,
400
+ globalNegativeTtl,
401
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
402
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
403
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
404
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
405
+ return this.applyJitter(adaptiveTtl, jitter);
188
406
  }
189
- async invalidateByPattern(pattern) {
190
- await this.cache.invalidateByPattern(this.qualify(pattern));
407
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
408
+ if (override !== void 0) {
409
+ return this.readLayerNumber(layerName, override) ?? fallback;
410
+ }
411
+ if (globalDefault !== void 0) {
412
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
413
+ }
414
+ return fallback;
191
415
  }
192
- wrap(keyPrefix, fetcher, options) {
193
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
416
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
417
+ if (!ttl || !adaptiveTtl) {
418
+ return ttl;
419
+ }
420
+ const profile = this.accessProfiles.get(key);
421
+ if (!profile) {
422
+ return ttl;
423
+ }
424
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
425
+ const hotAfter = config.hotAfter ?? 3;
426
+ if (profile.hits < hotAfter) {
427
+ return ttl;
428
+ }
429
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
430
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
431
+ const multiplier = Math.floor(profile.hits / hotAfter);
432
+ return Math.min(maxTtl, ttl + step * multiplier);
194
433
  }
195
- warm(entries, options) {
196
- return this.cache.warm(entries.map((entry) => ({
197
- ...entry,
198
- key: this.qualify(entry.key)
199
- })), options);
434
+ applyJitter(ttl, jitter) {
435
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
436
+ return ttl;
437
+ }
438
+ const delta = (Math.random() * 2 - 1) * jitter;
439
+ return Math.max(1, Math.round(ttl + delta));
200
440
  }
201
- getMetrics() {
202
- return this.cache.getMetrics();
441
+ readLayerNumber(layerName, value) {
442
+ if (typeof value === "number") {
443
+ return value;
444
+ }
445
+ return value[layerName];
203
446
  }
204
- qualify(key) {
205
- return `${this.prefix}:${key}`;
447
+ pruneIfNeeded() {
448
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
449
+ return;
450
+ }
451
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
452
+ let removed = 0;
453
+ for (const key of this.accessProfiles.keys()) {
454
+ if (removed >= toRemove) {
455
+ break;
456
+ }
457
+ this.accessProfiles.delete(key);
458
+ removed += 1;
459
+ }
206
460
  }
207
461
  };
208
462
 
209
463
  // ../../src/invalidation/PatternMatcher.ts
210
- var PatternMatcher = class {
464
+ var PatternMatcher = class _PatternMatcher {
465
+ /**
466
+ * Tests whether a glob-style pattern matches a value.
467
+ * Supports `*` (any sequence of characters) and `?` (any single character).
468
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
469
+ */
211
470
  static matches(pattern, value) {
212
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
213
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
214
- return regex.test(value);
471
+ return _PatternMatcher.matchLinear(pattern, value);
472
+ }
473
+ /**
474
+ * Linear-time glob matching using dynamic programming.
475
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
476
+ */
477
+ static matchLinear(pattern, value) {
478
+ const m = pattern.length;
479
+ const n = value.length;
480
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
481
+ dp[0][0] = true;
482
+ for (let i = 1; i <= m; i++) {
483
+ if (pattern[i - 1] === "*") {
484
+ dp[i][0] = dp[i - 1]?.[0];
485
+ }
486
+ }
487
+ for (let i = 1; i <= m; i++) {
488
+ for (let j = 1; j <= n; j++) {
489
+ const pc = pattern[i - 1];
490
+ if (pc === "*") {
491
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
492
+ } else if (pc === "?" || pc === value[j - 1]) {
493
+ dp[i][j] = dp[i - 1]?.[j - 1];
494
+ }
495
+ }
496
+ }
497
+ return dp[m]?.[n];
215
498
  }
216
499
  };
217
500
 
@@ -474,30 +757,11 @@ var StampedeGuard = class {
474
757
  };
475
758
 
476
759
  // ../../src/CacheStack.ts
477
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
478
760
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
479
761
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
480
762
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
481
763
  var MAX_CACHE_KEY_LENGTH = 1024;
482
- var EMPTY_METRICS = () => ({
483
- hits: 0,
484
- misses: 0,
485
- fetches: 0,
486
- sets: 0,
487
- deletes: 0,
488
- backfills: 0,
489
- invalidations: 0,
490
- staleHits: 0,
491
- refreshes: 0,
492
- refreshErrors: 0,
493
- writeFailures: 0,
494
- singleFlightWaits: 0,
495
- negativeCacheHits: 0,
496
- circuitBreakerTrips: 0,
497
- degradedOperations: 0,
498
- hitsByLayer: {},
499
- missesByLayer: {}
500
- });
764
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
501
765
  var DebugLogger = class {
502
766
  enabled;
503
767
  constructor(enabled) {
@@ -532,6 +796,14 @@ var CacheStack = class extends EventEmitter {
532
796
  throw new Error("CacheStack requires at least one cache layer.");
533
797
  }
534
798
  this.validateConfiguration();
799
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
800
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
801
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
802
+ if (options.publishSetInvalidation !== void 0) {
803
+ console.warn(
804
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
805
+ );
806
+ }
535
807
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
536
808
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
537
809
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -540,36 +812,42 @@ var CacheStack = class extends EventEmitter {
540
812
  layers;
541
813
  options;
542
814
  stampedeGuard = new StampedeGuard();
543
- metrics = EMPTY_METRICS();
815
+ metricsCollector = new MetricsCollector();
544
816
  instanceId = randomUUID();
545
817
  startup;
546
818
  unsubscribeInvalidation;
547
819
  logger;
548
820
  tagIndex;
549
821
  backgroundRefreshes = /* @__PURE__ */ new Map();
550
- accessProfiles = /* @__PURE__ */ new Map();
551
822
  layerDegradedUntil = /* @__PURE__ */ new Map();
552
- circuitBreakers = /* @__PURE__ */ new Map();
823
+ ttlResolver;
824
+ circuitBreakerManager;
553
825
  isDisconnecting = false;
554
826
  disconnectPromise;
827
+ /**
828
+ * Read-through cache get.
829
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
830
+ * and stores the result across all layers. Returns `null` if the key is not found
831
+ * and no `fetcher` is provided.
832
+ */
555
833
  async get(key, fetcher, options) {
556
834
  const normalizedKey = this.validateCacheKey(key);
557
835
  this.validateWriteOptions(options);
558
836
  await this.startup;
559
837
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
560
838
  if (hit.found) {
561
- this.recordAccess(normalizedKey);
839
+ this.ttlResolver.recordAccess(normalizedKey);
562
840
  if (this.isNegativeStoredValue(hit.stored)) {
563
- this.metrics.negativeCacheHits += 1;
841
+ this.metricsCollector.increment("negativeCacheHits");
564
842
  }
565
843
  if (hit.state === "fresh") {
566
- this.metrics.hits += 1;
844
+ this.metricsCollector.increment("hits");
567
845
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
568
846
  return hit.value;
569
847
  }
570
848
  if (hit.state === "stale-while-revalidate") {
571
- this.metrics.hits += 1;
572
- this.metrics.staleHits += 1;
849
+ this.metricsCollector.increment("hits");
850
+ this.metricsCollector.increment("staleHits");
573
851
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
574
852
  if (fetcher) {
575
853
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -577,47 +855,136 @@ var CacheStack = class extends EventEmitter {
577
855
  return hit.value;
578
856
  }
579
857
  if (!fetcher) {
580
- this.metrics.hits += 1;
581
- this.metrics.staleHits += 1;
858
+ this.metricsCollector.increment("hits");
859
+ this.metricsCollector.increment("staleHits");
582
860
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
583
861
  return hit.value;
584
862
  }
585
863
  try {
586
864
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
587
865
  } catch (error) {
588
- this.metrics.staleHits += 1;
589
- this.metrics.refreshErrors += 1;
866
+ this.metricsCollector.increment("staleHits");
867
+ this.metricsCollector.increment("refreshErrors");
590
868
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
591
869
  return hit.value;
592
870
  }
593
871
  }
594
- this.metrics.misses += 1;
872
+ this.metricsCollector.increment("misses");
595
873
  if (!fetcher) {
596
874
  return null;
597
875
  }
598
876
  return this.fetchWithGuards(normalizedKey, fetcher, options);
599
877
  }
878
+ /**
879
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
880
+ * Fetches and caches the value if not already present.
881
+ */
882
+ async getOrSet(key, fetcher, options) {
883
+ return this.get(key, fetcher, options);
884
+ }
885
+ /**
886
+ * Returns true if the given key exists and is not expired in any layer.
887
+ */
888
+ async has(key) {
889
+ const normalizedKey = this.validateCacheKey(key);
890
+ await this.startup;
891
+ for (const layer of this.layers) {
892
+ if (this.shouldSkipLayer(layer)) {
893
+ continue;
894
+ }
895
+ if (layer.has) {
896
+ try {
897
+ const exists = await layer.has(normalizedKey);
898
+ if (exists) {
899
+ return true;
900
+ }
901
+ } catch {
902
+ }
903
+ } else {
904
+ try {
905
+ const value = await layer.get(normalizedKey);
906
+ if (value !== null) {
907
+ return true;
908
+ }
909
+ } catch {
910
+ }
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+ /**
916
+ * Returns the remaining TTL in seconds for the key in the fastest layer
917
+ * that has it, or null if the key is not found / has no TTL.
918
+ */
919
+ async ttl(key) {
920
+ const normalizedKey = this.validateCacheKey(key);
921
+ await this.startup;
922
+ for (const layer of this.layers) {
923
+ if (this.shouldSkipLayer(layer)) {
924
+ continue;
925
+ }
926
+ if (layer.ttl) {
927
+ try {
928
+ const remaining = await layer.ttl(normalizedKey);
929
+ if (remaining !== null) {
930
+ return remaining;
931
+ }
932
+ } catch {
933
+ }
934
+ }
935
+ }
936
+ return null;
937
+ }
938
+ /**
939
+ * Stores a value in all cache layers. Overwrites any existing value.
940
+ */
600
941
  async set(key, value, options) {
601
942
  const normalizedKey = this.validateCacheKey(key);
602
943
  this.validateWriteOptions(options);
603
944
  await this.startup;
604
945
  await this.storeEntry(normalizedKey, "value", value, options);
605
946
  }
947
+ /**
948
+ * Deletes the key from all layers and publishes an invalidation message.
949
+ */
606
950
  async delete(key) {
607
951
  const normalizedKey = this.validateCacheKey(key);
608
952
  await this.startup;
609
953
  await this.deleteKeys([normalizedKey]);
610
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
954
+ await this.publishInvalidation({
955
+ scope: "key",
956
+ keys: [normalizedKey],
957
+ sourceId: this.instanceId,
958
+ operation: "delete"
959
+ });
611
960
  }
612
961
  async clear() {
613
962
  await this.startup;
614
963
  await Promise.all(this.layers.map((layer) => layer.clear()));
615
964
  await this.tagIndex.clear();
616
- this.accessProfiles.clear();
617
- this.metrics.invalidations += 1;
965
+ this.ttlResolver.clearProfiles();
966
+ this.circuitBreakerManager.clear();
967
+ this.metricsCollector.increment("invalidations");
618
968
  this.logger.debug?.("clear");
619
969
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
620
970
  }
971
+ /**
972
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
973
+ */
974
+ async mdelete(keys) {
975
+ if (keys.length === 0) {
976
+ return;
977
+ }
978
+ await this.startup;
979
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
980
+ await this.deleteKeys(normalizedKeys);
981
+ await this.publishInvalidation({
982
+ scope: "keys",
983
+ keys: normalizedKeys,
984
+ sourceId: this.instanceId,
985
+ operation: "delete"
986
+ });
987
+ }
621
988
  async mget(entries) {
622
989
  if (entries.length === 0) {
623
990
  return [];
@@ -655,7 +1022,9 @@ var CacheStack = class extends EventEmitter {
655
1022
  const indexesByKey = /* @__PURE__ */ new Map();
656
1023
  const resultsByKey = /* @__PURE__ */ new Map();
657
1024
  for (let index = 0; index < normalizedEntries.length; index += 1) {
658
- const key = normalizedEntries[index].key;
1025
+ const entry = normalizedEntries[index];
1026
+ if (!entry) continue;
1027
+ const key = entry.key;
659
1028
  const indexes = indexesByKey.get(key) ?? [];
660
1029
  indexes.push(index);
661
1030
  indexesByKey.set(key, indexes);
@@ -663,6 +1032,7 @@ var CacheStack = class extends EventEmitter {
663
1032
  }
664
1033
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
665
1034
  const layer = this.layers[layerIndex];
1035
+ if (!layer) continue;
666
1036
  const keys = [...pending];
667
1037
  if (keys.length === 0) {
668
1038
  break;
@@ -671,7 +1041,7 @@ var CacheStack = class extends EventEmitter {
671
1041
  for (let offset = 0; offset < values.length; offset += 1) {
672
1042
  const key = keys[offset];
673
1043
  const stored = values[offset];
674
- if (stored === null) {
1044
+ if (!key || stored === null) {
675
1045
  continue;
676
1046
  }
677
1047
  const resolved = resolveStoredValue(stored);
@@ -683,13 +1053,13 @@ var CacheStack = class extends EventEmitter {
683
1053
  await this.backfill(key, stored, layerIndex - 1);
684
1054
  resultsByKey.set(key, resolved.value);
685
1055
  pending.delete(key);
686
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
1056
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
687
1057
  }
688
1058
  }
689
1059
  if (pending.size > 0) {
690
1060
  for (const key of pending) {
691
1061
  await this.tagIndex.remove(key);
692
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
1062
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
693
1063
  }
694
1064
  }
695
1065
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -704,26 +1074,38 @@ var CacheStack = class extends EventEmitter {
704
1074
  }
705
1075
  async warm(entries, options = {}) {
706
1076
  const concurrency = Math.max(1, options.concurrency ?? 4);
1077
+ const total = entries.length;
1078
+ let completed = 0;
707
1079
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
708
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
1080
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
709
1081
  while (queue.length > 0) {
710
1082
  const entry = queue.shift();
711
1083
  if (!entry) {
712
1084
  return;
713
1085
  }
1086
+ let success = false;
714
1087
  try {
715
1088
  await this.get(entry.key, entry.fetcher, entry.options);
716
1089
  this.emit("warm", { key: entry.key });
1090
+ success = true;
717
1091
  } catch (error) {
718
1092
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
719
1093
  if (!options.continueOnError) {
720
1094
  throw error;
721
1095
  }
1096
+ } finally {
1097
+ completed += 1;
1098
+ const progress = { completed, total, key: entry.key, success };
1099
+ options.onProgress?.(progress);
722
1100
  }
723
1101
  }
724
1102
  });
725
1103
  await Promise.all(workers);
726
1104
  }
1105
+ /**
1106
+ * Returns a cached version of `fetcher`. The cache key is derived from
1107
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
1108
+ */
727
1109
  wrap(prefix, fetcher, options = {}) {
728
1110
  return (...args) => {
729
1111
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -731,6 +1113,10 @@ var CacheStack = class extends EventEmitter {
731
1113
  return this.get(key, () => fetcher(...args), options);
732
1114
  };
733
1115
  }
1116
+ /**
1117
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
1118
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
1119
+ */
734
1120
  namespace(prefix) {
735
1121
  return new CacheNamespace(this, prefix);
736
1122
  }
@@ -747,7 +1133,7 @@ var CacheStack = class extends EventEmitter {
747
1133
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
748
1134
  }
749
1135
  getMetrics() {
750
- return { ...this.metrics };
1136
+ return this.metricsCollector.snapshot;
751
1137
  }
752
1138
  getStats() {
753
1139
  return {
@@ -761,7 +1147,13 @@ var CacheStack = class extends EventEmitter {
761
1147
  };
762
1148
  }
763
1149
  resetMetrics() {
764
- Object.assign(this.metrics, EMPTY_METRICS());
1150
+ this.metricsCollector.reset();
1151
+ }
1152
+ /**
1153
+ * Returns computed hit-rate statistics (overall and per-layer).
1154
+ */
1155
+ getHitRate() {
1156
+ return this.metricsCollector.hitRate();
765
1157
  }
766
1158
  async exportState() {
767
1159
  await this.startup;
@@ -790,10 +1182,12 @@ var CacheStack = class extends EventEmitter {
790
1182
  }
791
1183
  async importState(entries) {
792
1184
  await this.startup;
793
- await Promise.all(entries.map(async (entry) => {
794
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
795
- await this.tagIndex.touch(entry.key);
796
- }));
1185
+ await Promise.all(
1186
+ entries.map(async (entry) => {
1187
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1188
+ await this.tagIndex.touch(entry.key);
1189
+ })
1190
+ );
797
1191
  }
798
1192
  async persistToFile(filePath) {
799
1193
  const snapshot = await this.exportState();
@@ -801,11 +1195,21 @@ var CacheStack = class extends EventEmitter {
801
1195
  }
802
1196
  async restoreFromFile(filePath) {
803
1197
  const raw = await fs.readFile(filePath, "utf8");
804
- const snapshot = JSON.parse(raw);
805
- if (!this.isCacheSnapshotEntries(snapshot)) {
806
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
1198
+ let parsed;
1199
+ try {
1200
+ parsed = JSON.parse(raw, (_key, value) => {
1201
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1202
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1203
+ }
1204
+ return value;
1205
+ });
1206
+ } catch (cause) {
1207
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
807
1208
  }
808
- await this.importState(snapshot);
1209
+ if (!this.isCacheSnapshotEntries(parsed)) {
1210
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1211
+ }
1212
+ await this.importState(parsed);
809
1213
  }
810
1214
  async disconnect() {
811
1215
  if (!this.disconnectPromise) {
@@ -830,7 +1234,7 @@ var CacheStack = class extends EventEmitter {
830
1234
  const fetchTask = async () => {
831
1235
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
832
1236
  if (secondHit.found) {
833
- this.metrics.hits += 1;
1237
+ this.metricsCollector.increment("hits");
834
1238
  return secondHit.value;
835
1239
  }
836
1240
  return this.fetchAndPopulate(key, fetcher, options);
@@ -855,12 +1259,12 @@ var CacheStack = class extends EventEmitter {
855
1259
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
856
1260
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
857
1261
  const deadline = Date.now() + timeoutMs;
858
- this.metrics.singleFlightWaits += 1;
1262
+ this.metricsCollector.increment("singleFlightWaits");
859
1263
  this.emit("stampede-dedupe", { key });
860
1264
  while (Date.now() < deadline) {
861
1265
  const hit = await this.readFromLayers(key, options, "fresh-only");
862
1266
  if (hit.found) {
863
- this.metrics.hits += 1;
1267
+ this.metricsCollector.increment("hits");
864
1268
  return hit.value;
865
1269
  }
866
1270
  await this.sleep(pollIntervalMs);
@@ -868,12 +1272,14 @@ var CacheStack = class extends EventEmitter {
868
1272
  return this.fetchAndPopulate(key, fetcher, options);
869
1273
  }
870
1274
  async fetchAndPopulate(key, fetcher, options) {
871
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
872
- this.metrics.fetches += 1;
1275
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1276
+ this.metricsCollector.increment("fetches");
1277
+ const fetchStart = Date.now();
873
1278
  let fetched;
874
1279
  try {
875
1280
  fetched = await fetcher();
876
- this.resetCircuitBreaker(key);
1281
+ this.circuitBreakerManager.recordSuccess(key);
1282
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
877
1283
  } catch (error) {
878
1284
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
879
1285
  throw error;
@@ -895,7 +1301,7 @@ var CacheStack = class extends EventEmitter {
895
1301
  } else {
896
1302
  await this.tagIndex.touch(key);
897
1303
  }
898
- this.metrics.sets += 1;
1304
+ this.metricsCollector.increment("sets");
899
1305
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
900
1306
  this.emit("set", { key, kind, tags: options?.tags });
901
1307
  if (this.shouldBroadcastL1Invalidation()) {
@@ -906,9 +1312,10 @@ var CacheStack = class extends EventEmitter {
906
1312
  let sawRetainableValue = false;
907
1313
  for (let index = 0; index < this.layers.length; index += 1) {
908
1314
  const layer = this.layers[index];
1315
+ if (!layer) continue;
909
1316
  const stored = await this.readLayerEntry(layer, key);
910
1317
  if (stored === null) {
911
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1318
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
912
1319
  continue;
913
1320
  }
914
1321
  const resolved = resolveStoredValue(stored);
@@ -922,10 +1329,17 @@ var CacheStack = class extends EventEmitter {
922
1329
  }
923
1330
  await this.tagIndex.touch(key);
924
1331
  await this.backfill(key, stored, index - 1, options);
925
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1332
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
926
1333
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
927
1334
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
928
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1335
+ return {
1336
+ found: true,
1337
+ value: resolved.value,
1338
+ stored,
1339
+ state: resolved.state,
1340
+ layerIndex: index,
1341
+ layerName: layer.name
1342
+ };
929
1343
  }
930
1344
  if (!sawRetainableValue) {
931
1345
  await this.tagIndex.remove(key);
@@ -957,7 +1371,7 @@ var CacheStack = class extends EventEmitter {
957
1371
  }
958
1372
  for (let index = 0; index <= upToIndex; index += 1) {
959
1373
  const layer = this.layers[index];
960
- if (this.shouldSkipLayer(layer)) {
1374
+ if (!layer || this.shouldSkipLayer(layer)) {
961
1375
  continue;
962
1376
  }
963
1377
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -967,7 +1381,7 @@ var CacheStack = class extends EventEmitter {
967
1381
  await this.handleLayerFailure(layer, "backfill", error);
968
1382
  continue;
969
1383
  }
970
- this.metrics.backfills += 1;
1384
+ this.metricsCollector.increment("backfills");
971
1385
  this.logger.debug?.("backfill", { key, layer: layer.name });
972
1386
  this.emit("backfill", { key, layer: layer.name });
973
1387
  }
@@ -984,11 +1398,7 @@ var CacheStack = class extends EventEmitter {
984
1398
  options?.staleWhileRevalidate,
985
1399
  this.options.staleWhileRevalidate
986
1400
  );
987
- const staleIfError = this.resolveLayerSeconds(
988
- layer.name,
989
- options?.staleIfError,
990
- this.options.staleIfError
991
- );
1401
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
992
1402
  const payload = createStoredValueEnvelope({
993
1403
  kind,
994
1404
  value,
@@ -1016,7 +1426,7 @@ var CacheStack = class extends EventEmitter {
1016
1426
  if (failures.length === 0) {
1017
1427
  return;
1018
1428
  }
1019
- this.metrics.writeFailures += failures.length;
1429
+ this.metricsCollector.increment("writeFailures", failures.length);
1020
1430
  this.logger.debug?.("write-failure", {
1021
1431
  ...context,
1022
1432
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -1029,42 +1439,10 @@ var CacheStack = class extends EventEmitter {
1029
1439
  }
1030
1440
  }
1031
1441
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1032
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
1033
- layerName,
1034
- options?.negativeTtl,
1035
- this.options.negativeTtl,
1036
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
1037
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
1038
- const adaptiveTtl = this.applyAdaptiveTtl(
1039
- key,
1040
- layerName,
1041
- baseTtl,
1042
- options?.adaptiveTtl ?? this.options.adaptiveTtl
1043
- );
1044
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
1045
- return this.applyJitter(adaptiveTtl, jitter);
1442
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
1046
1443
  }
1047
1444
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
1048
- if (override !== void 0) {
1049
- return this.readLayerNumber(layerName, override) ?? fallback;
1050
- }
1051
- if (globalDefault !== void 0) {
1052
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
1053
- }
1054
- return fallback;
1055
- }
1056
- readLayerNumber(layerName, value) {
1057
- if (typeof value === "number") {
1058
- return value;
1059
- }
1060
- return value[layerName];
1061
- }
1062
- applyJitter(ttl, jitter) {
1063
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
1064
- return ttl;
1065
- }
1066
- const delta = (Math.random() * 2 - 1) * jitter;
1067
- return Math.max(1, Math.round(ttl + delta));
1445
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
1068
1446
  }
1069
1447
  shouldNegativeCache(options) {
1070
1448
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -1074,11 +1452,11 @@ var CacheStack = class extends EventEmitter {
1074
1452
  return;
1075
1453
  }
1076
1454
  const refresh = (async () => {
1077
- this.metrics.refreshes += 1;
1455
+ this.metricsCollector.increment("refreshes");
1078
1456
  try {
1079
1457
  await this.fetchWithGuards(key, fetcher, options);
1080
1458
  } catch (error) {
1081
- this.metrics.refreshErrors += 1;
1459
+ this.metricsCollector.increment("refreshErrors");
1082
1460
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
1083
1461
  } finally {
1084
1462
  this.backgroundRefreshes.delete(key);
@@ -1100,10 +1478,11 @@ var CacheStack = class extends EventEmitter {
1100
1478
  await this.deleteKeysFromLayers(this.layers, keys);
1101
1479
  for (const key of keys) {
1102
1480
  await this.tagIndex.remove(key);
1103
- this.accessProfiles.delete(key);
1481
+ this.ttlResolver.deleteProfile(key);
1482
+ this.circuitBreakerManager.delete(key);
1104
1483
  }
1105
- this.metrics.deletes += keys.length;
1106
- this.metrics.invalidations += 1;
1484
+ this.metricsCollector.increment("deletes", keys.length);
1485
+ this.metricsCollector.increment("invalidations");
1107
1486
  this.logger.debug?.("delete", { keys });
1108
1487
  this.emit("delete", { keys });
1109
1488
  }
@@ -1124,7 +1503,7 @@ var CacheStack = class extends EventEmitter {
1124
1503
  if (message.scope === "clear") {
1125
1504
  await Promise.all(localLayers.map((layer) => layer.clear()));
1126
1505
  await this.tagIndex.clear();
1127
- this.accessProfiles.clear();
1506
+ this.ttlResolver.clearProfiles();
1128
1507
  return;
1129
1508
  }
1130
1509
  const keys = message.keys ?? [];
@@ -1132,7 +1511,7 @@ var CacheStack = class extends EventEmitter {
1132
1511
  if (message.operation !== "write") {
1133
1512
  for (const key of keys) {
1134
1513
  await this.tagIndex.remove(key);
1135
- this.accessProfiles.delete(key);
1514
+ this.ttlResolver.deleteProfile(key);
1136
1515
  }
1137
1516
  }
1138
1517
  }
@@ -1162,13 +1541,15 @@ var CacheStack = class extends EventEmitter {
1162
1541
  }
1163
1542
  return;
1164
1543
  }
1165
- await Promise.all(keys.map(async (key) => {
1166
- try {
1167
- await layer.delete(key);
1168
- } catch (error) {
1169
- await this.handleLayerFailure(layer, "delete", error);
1170
- }
1171
- }));
1544
+ await Promise.all(
1545
+ keys.map(async (key) => {
1546
+ try {
1547
+ await layer.delete(key);
1548
+ } catch (error) {
1549
+ await this.handleLayerFailure(layer, "delete", error);
1550
+ }
1551
+ })
1552
+ );
1172
1553
  })
1173
1554
  );
1174
1555
  }
@@ -1269,7 +1650,7 @@ var CacheStack = class extends EventEmitter {
1269
1650
  const ttl = remainingStoredTtlSeconds(refreshed);
1270
1651
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1271
1652
  const layer = this.layers[index];
1272
- if (this.shouldSkipLayer(layer)) {
1653
+ if (!layer || this.shouldSkipLayer(layer)) {
1273
1654
  continue;
1274
1655
  }
1275
1656
  try {
@@ -1283,33 +1664,6 @@ var CacheStack = class extends EventEmitter {
1283
1664
  this.scheduleBackgroundRefresh(key, fetcher, options);
1284
1665
  }
1285
1666
  }
1286
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1287
- if (!ttl || !adaptiveTtl) {
1288
- return ttl;
1289
- }
1290
- const profile = this.accessProfiles.get(key);
1291
- if (!profile) {
1292
- return ttl;
1293
- }
1294
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1295
- const hotAfter = config.hotAfter ?? 3;
1296
- if (profile.hits < hotAfter) {
1297
- return ttl;
1298
- }
1299
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1300
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1301
- const multiplier = Math.floor(profile.hits / hotAfter);
1302
- return Math.min(maxTtl, ttl + step * multiplier);
1303
- }
1304
- recordAccess(key) {
1305
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1306
- profile.hits += 1;
1307
- profile.lastAccessAt = Date.now();
1308
- this.accessProfiles.set(key, profile);
1309
- }
1310
- incrementMetricMap(target, key) {
1311
- target[key] = (target[key] ?? 0) + 1;
1312
- }
1313
1667
  shouldSkipLayer(layer) {
1314
1668
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1315
1669
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1320,7 +1674,7 @@ var CacheStack = class extends EventEmitter {
1320
1674
  }
1321
1675
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1322
1676
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1323
- this.metrics.degradedOperations += 1;
1677
+ this.metricsCollector.increment("degradedOperations");
1324
1678
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1325
1679
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1326
1680
  return null;
@@ -1328,37 +1682,15 @@ var CacheStack = class extends EventEmitter {
1328
1682
  isGracefulDegradationEnabled() {
1329
1683
  return Boolean(this.options.gracefulDegradation);
1330
1684
  }
1331
- assertCircuitClosed(key, options) {
1332
- const state = this.circuitBreakers.get(key);
1333
- if (!state?.openUntil) {
1334
- return;
1335
- }
1336
- if (state.openUntil <= Date.now()) {
1337
- state.openUntil = null;
1338
- state.failures = 0;
1339
- this.circuitBreakers.set(key, state);
1340
- return;
1341
- }
1342
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1343
- throw new Error(`Circuit breaker is open for key "${key}".`);
1344
- }
1345
1685
  recordCircuitFailure(key, options, error) {
1346
1686
  if (!options) {
1347
1687
  return;
1348
1688
  }
1349
- const failureThreshold = options.failureThreshold ?? 3;
1350
- const cooldownMs = options.cooldownMs ?? 3e4;
1351
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1352
- state.failures += 1;
1353
- if (state.failures >= failureThreshold) {
1354
- state.openUntil = Date.now() + cooldownMs;
1355
- this.metrics.circuitBreakerTrips += 1;
1689
+ this.circuitBreakerManager.recordFailure(key, options);
1690
+ if (this.circuitBreakerManager.isOpen(key)) {
1691
+ this.metricsCollector.increment("circuitBreakerTrips");
1356
1692
  }
1357
- this.circuitBreakers.set(key, state);
1358
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1359
- }
1360
- resetCircuitBreaker(key) {
1361
- this.circuitBreakers.delete(key);
1693
+ this.emitError("fetch", { key, error: this.formatError(error) });
1362
1694
  }
1363
1695
  isNegativeStoredValue(stored) {
1364
1696
  return isStoredValueEnvelope(stored) && stored.kind === "empty";