layercache 1.0.2 → 1.2.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,275 @@ 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 _CacheNamespace {
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
+ /**
68
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
69
+ */
70
+ async getOrThrow(key, fetcher, options) {
71
+ return this.cache.getOrThrow(this.qualify(key), fetcher, options);
72
+ }
73
+ async has(key) {
74
+ return this.cache.has(this.qualify(key));
75
+ }
76
+ async ttl(key) {
77
+ return this.cache.ttl(this.qualify(key));
78
+ }
79
+ async set(key, value, options) {
80
+ await this.cache.set(this.qualify(key), value, options);
81
+ }
82
+ async delete(key) {
83
+ await this.cache.delete(this.qualify(key));
84
+ }
85
+ async mdelete(keys) {
86
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
87
+ }
88
+ async clear() {
89
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
90
+ }
91
+ async mget(entries) {
92
+ return this.cache.mget(
93
+ entries.map((entry) => ({
94
+ ...entry,
95
+ key: this.qualify(entry.key)
96
+ }))
97
+ );
98
+ }
99
+ async mset(entries) {
100
+ await this.cache.mset(
101
+ entries.map((entry) => ({
102
+ ...entry,
103
+ key: this.qualify(entry.key)
104
+ }))
105
+ );
106
+ }
107
+ async invalidateByTag(tag) {
108
+ await this.cache.invalidateByTag(tag);
109
+ }
110
+ async invalidateByPattern(pattern) {
111
+ await this.cache.invalidateByPattern(this.qualify(pattern));
112
+ }
113
+ /**
114
+ * Returns detailed metadata about a single cache key within this namespace.
115
+ */
116
+ async inspect(key) {
117
+ return this.cache.inspect(this.qualify(key));
118
+ }
119
+ wrap(keyPrefix, fetcher, options) {
120
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
121
+ }
122
+ warm(entries, options) {
123
+ return this.cache.warm(
124
+ entries.map((entry) => ({
125
+ ...entry,
126
+ key: this.qualify(entry.key)
127
+ })),
128
+ options
129
+ );
130
+ }
131
+ getMetrics() {
132
+ return this.cache.getMetrics();
133
+ }
134
+ getHitRate() {
135
+ return this.cache.getHitRate();
136
+ }
137
+ /**
138
+ * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
139
+ *
140
+ * ```ts
141
+ * const tenant = cache.namespace('tenant:abc')
142
+ * const posts = tenant.namespace('posts')
143
+ * // keys become: "tenant:abc:posts:mykey"
144
+ * ```
145
+ */
146
+ namespace(childPrefix) {
147
+ return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
148
+ }
149
+ qualify(key) {
150
+ return `${this.prefix}:${key}`;
151
+ }
152
+ };
153
+
154
+ // ../../src/internal/CircuitBreakerManager.ts
155
+ var CircuitBreakerManager = class {
156
+ breakers = /* @__PURE__ */ new Map();
157
+ maxEntries;
158
+ constructor(options) {
159
+ this.maxEntries = options.maxEntries;
160
+ }
161
+ /**
162
+ * Throws if the circuit is open for the given key.
163
+ * Automatically resets if the cooldown has elapsed.
164
+ */
165
+ assertClosed(key, options) {
166
+ const state = this.breakers.get(key);
167
+ if (!state?.openUntil) {
168
+ return;
169
+ }
170
+ const now = Date.now();
171
+ if (state.openUntil <= now) {
172
+ state.openUntil = null;
173
+ state.failures = 0;
174
+ this.breakers.set(key, state);
175
+ return;
176
+ }
177
+ const remainingMs = state.openUntil - now;
178
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
179
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
180
+ }
181
+ recordFailure(key, options) {
182
+ if (!options) {
183
+ return;
184
+ }
185
+ const failureThreshold = options.failureThreshold ?? 3;
186
+ const cooldownMs = options.cooldownMs ?? 3e4;
187
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
188
+ state.failures += 1;
189
+ if (state.failures >= failureThreshold) {
190
+ state.openUntil = Date.now() + cooldownMs;
191
+ }
192
+ this.breakers.set(key, state);
193
+ this.pruneIfNeeded();
194
+ }
195
+ recordSuccess(key) {
196
+ this.breakers.delete(key);
197
+ }
198
+ isOpen(key) {
199
+ const state = this.breakers.get(key);
200
+ if (!state?.openUntil) {
201
+ return false;
202
+ }
203
+ if (state.openUntil <= Date.now()) {
204
+ state.openUntil = null;
205
+ state.failures = 0;
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ delete(key) {
211
+ this.breakers.delete(key);
212
+ }
213
+ clear() {
214
+ this.breakers.clear();
215
+ }
216
+ tripCount() {
217
+ let count = 0;
218
+ for (const state of this.breakers.values()) {
219
+ if (state.openUntil !== null) {
220
+ count += 1;
221
+ }
222
+ }
223
+ return count;
224
+ }
225
+ pruneIfNeeded() {
226
+ if (this.breakers.size <= this.maxEntries) {
227
+ return;
228
+ }
229
+ for (const [key, state] of this.breakers.entries()) {
230
+ if (this.breakers.size <= this.maxEntries) {
231
+ break;
232
+ }
233
+ if (!state.openUntil || state.openUntil <= Date.now()) {
234
+ this.breakers.delete(key);
235
+ }
236
+ }
237
+ for (const key of this.breakers.keys()) {
238
+ if (this.breakers.size <= this.maxEntries) {
239
+ break;
240
+ }
241
+ this.breakers.delete(key);
242
+ }
243
+ }
244
+ };
245
+
246
+ // ../../src/internal/MetricsCollector.ts
247
+ var MetricsCollector = class {
248
+ data = this.empty();
249
+ get snapshot() {
250
+ return {
251
+ ...this.data,
252
+ hitsByLayer: { ...this.data.hitsByLayer },
253
+ missesByLayer: { ...this.data.missesByLayer },
254
+ latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
255
+ };
256
+ }
257
+ increment(field, amount = 1) {
258
+ ;
259
+ this.data[field] += amount;
260
+ }
261
+ incrementLayer(map, layerName) {
262
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
263
+ }
264
+ /**
265
+ * Records a read latency sample for the given layer.
266
+ * Maintains a rolling average and max using Welford's online algorithm.
267
+ */
268
+ recordLatency(layerName, durationMs) {
269
+ const existing = this.data.latencyByLayer[layerName];
270
+ if (!existing) {
271
+ this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
272
+ return;
273
+ }
274
+ existing.count += 1;
275
+ existing.avgMs += (durationMs - existing.avgMs) / existing.count;
276
+ if (durationMs > existing.maxMs) {
277
+ existing.maxMs = durationMs;
278
+ }
279
+ }
280
+ reset() {
281
+ this.data = this.empty();
282
+ }
283
+ hitRate() {
284
+ const total = this.data.hits + this.data.misses;
285
+ const overall = total === 0 ? 0 : this.data.hits / total;
286
+ const byLayer = {};
287
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
288
+ for (const layer of allLayers) {
289
+ const h = this.data.hitsByLayer[layer] ?? 0;
290
+ const m = this.data.missesByLayer[layer] ?? 0;
291
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
292
+ }
293
+ return { overall, byLayer };
294
+ }
295
+ empty() {
296
+ return {
297
+ hits: 0,
298
+ misses: 0,
299
+ fetches: 0,
300
+ sets: 0,
301
+ deletes: 0,
302
+ backfills: 0,
303
+ invalidations: 0,
304
+ staleHits: 0,
305
+ refreshes: 0,
306
+ refreshErrors: 0,
307
+ writeFailures: 0,
308
+ singleFlightWaits: 0,
309
+ negativeCacheHits: 0,
310
+ circuitBreakerTrips: 0,
311
+ degradedOperations: 0,
312
+ hitsByLayer: {},
313
+ missesByLayer: {},
314
+ latencyByLayer: {},
315
+ resetAt: Date.now()
316
+ };
317
+ }
318
+ };
52
319
 
53
320
  // ../../src/internal/StoredValue.ts
54
321
  function isStoredValueEnvelope(value) {
@@ -151,67 +418,129 @@ function normalizePositiveSeconds(value) {
151
418
  return value;
152
419
  }
153
420
 
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));
421
+ // ../../src/internal/TtlResolver.ts
422
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
423
+ var TtlResolver = class {
424
+ accessProfiles = /* @__PURE__ */ new Map();
425
+ maxProfileEntries;
426
+ constructor(options) {
427
+ this.maxProfileEntries = options.maxProfileEntries;
170
428
  }
171
- async clear() {
172
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
429
+ recordAccess(key) {
430
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
431
+ profile.hits += 1;
432
+ profile.lastAccessAt = Date.now();
433
+ this.accessProfiles.set(key, profile);
434
+ this.pruneIfNeeded();
173
435
  }
174
- async mget(entries) {
175
- return this.cache.mget(entries.map((entry) => ({
176
- ...entry,
177
- key: this.qualify(entry.key)
178
- })));
436
+ deleteProfile(key) {
437
+ this.accessProfiles.delete(key);
179
438
  }
180
- async mset(entries) {
181
- await this.cache.mset(entries.map((entry) => ({
182
- ...entry,
183
- key: this.qualify(entry.key)
184
- })));
439
+ clearProfiles() {
440
+ this.accessProfiles.clear();
185
441
  }
186
- async invalidateByTag(tag) {
187
- await this.cache.invalidateByTag(tag);
442
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
443
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
444
+ layerName,
445
+ options?.negativeTtl,
446
+ globalNegativeTtl,
447
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
448
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
449
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
450
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
451
+ return this.applyJitter(adaptiveTtl, jitter);
188
452
  }
189
- async invalidateByPattern(pattern) {
190
- await this.cache.invalidateByPattern(this.qualify(pattern));
453
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
454
+ if (override !== void 0) {
455
+ return this.readLayerNumber(layerName, override) ?? fallback;
456
+ }
457
+ if (globalDefault !== void 0) {
458
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
459
+ }
460
+ return fallback;
191
461
  }
192
- wrap(keyPrefix, fetcher, options) {
193
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
462
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
463
+ if (!ttl || !adaptiveTtl) {
464
+ return ttl;
465
+ }
466
+ const profile = this.accessProfiles.get(key);
467
+ if (!profile) {
468
+ return ttl;
469
+ }
470
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
471
+ const hotAfter = config.hotAfter ?? 3;
472
+ if (profile.hits < hotAfter) {
473
+ return ttl;
474
+ }
475
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
476
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
477
+ const multiplier = Math.floor(profile.hits / hotAfter);
478
+ return Math.min(maxTtl, ttl + step * multiplier);
194
479
  }
195
- warm(entries, options) {
196
- return this.cache.warm(entries.map((entry) => ({
197
- ...entry,
198
- key: this.qualify(entry.key)
199
- })), options);
480
+ applyJitter(ttl, jitter) {
481
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
482
+ return ttl;
483
+ }
484
+ const delta = (Math.random() * 2 - 1) * jitter;
485
+ return Math.max(1, Math.round(ttl + delta));
200
486
  }
201
- getMetrics() {
202
- return this.cache.getMetrics();
487
+ readLayerNumber(layerName, value) {
488
+ if (typeof value === "number") {
489
+ return value;
490
+ }
491
+ return value[layerName];
203
492
  }
204
- qualify(key) {
205
- return `${this.prefix}:${key}`;
493
+ pruneIfNeeded() {
494
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
495
+ return;
496
+ }
497
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
498
+ let removed = 0;
499
+ for (const key of this.accessProfiles.keys()) {
500
+ if (removed >= toRemove) {
501
+ break;
502
+ }
503
+ this.accessProfiles.delete(key);
504
+ removed += 1;
505
+ }
206
506
  }
207
507
  };
208
508
 
209
509
  // ../../src/invalidation/PatternMatcher.ts
210
- var PatternMatcher = class {
510
+ var PatternMatcher = class _PatternMatcher {
511
+ /**
512
+ * Tests whether a glob-style pattern matches a value.
513
+ * Supports `*` (any sequence of characters) and `?` (any single character).
514
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
515
+ */
211
516
  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);
517
+ return _PatternMatcher.matchLinear(pattern, value);
518
+ }
519
+ /**
520
+ * Linear-time glob matching using dynamic programming.
521
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
522
+ */
523
+ static matchLinear(pattern, value) {
524
+ const m = pattern.length;
525
+ const n = value.length;
526
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
527
+ dp[0][0] = true;
528
+ for (let i = 1; i <= m; i++) {
529
+ if (pattern[i - 1] === "*") {
530
+ dp[i][0] = dp[i - 1]?.[0];
531
+ }
532
+ }
533
+ for (let i = 1; i <= m; i++) {
534
+ for (let j = 1; j <= n; j++) {
535
+ const pc = pattern[i - 1];
536
+ if (pc === "*") {
537
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
538
+ } else if (pc === "?" || pc === value[j - 1]) {
539
+ dp[i][j] = dp[i - 1]?.[j - 1];
540
+ }
541
+ }
542
+ }
543
+ return dp[m]?.[n];
215
544
  }
216
545
  };
217
546
 
@@ -220,11 +549,17 @@ var TagIndex = class {
220
549
  tagToKeys = /* @__PURE__ */ new Map();
221
550
  keyToTags = /* @__PURE__ */ new Map();
222
551
  knownKeys = /* @__PURE__ */ new Set();
552
+ maxKnownKeys;
553
+ constructor(options = {}) {
554
+ this.maxKnownKeys = options.maxKnownKeys;
555
+ }
223
556
  async touch(key) {
224
557
  this.knownKeys.add(key);
558
+ this.pruneKnownKeysIfNeeded();
225
559
  }
226
560
  async track(key, tags) {
227
561
  this.knownKeys.add(key);
562
+ this.pruneKnownKeysIfNeeded();
228
563
  if (tags.length === 0) {
229
564
  return;
230
565
  }
@@ -263,6 +598,9 @@ var TagIndex = class {
263
598
  async keysForTag(tag) {
264
599
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
265
600
  }
601
+ async tagsForKey(key) {
602
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
603
+ }
266
604
  async matchPattern(pattern) {
267
605
  return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
268
606
  }
@@ -271,6 +609,21 @@ var TagIndex = class {
271
609
  this.keyToTags.clear();
272
610
  this.knownKeys.clear();
273
611
  }
612
+ pruneKnownKeysIfNeeded() {
613
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
614
+ return;
615
+ }
616
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
617
+ let removed = 0;
618
+ for (const key of this.knownKeys) {
619
+ if (removed >= toRemove) {
620
+ break;
621
+ }
622
+ this.knownKeys.delete(key);
623
+ this.keyToTags.delete(key);
624
+ removed += 1;
625
+ }
626
+ }
274
627
  };
275
628
 
276
629
  // ../../node_modules/async-mutex/index.mjs
@@ -473,31 +826,22 @@ var StampedeGuard = class {
473
826
  }
474
827
  };
475
828
 
829
+ // ../../src/types.ts
830
+ var CacheMissError = class extends Error {
831
+ key;
832
+ constructor(key) {
833
+ super(`Cache miss for key "${key}".`);
834
+ this.name = "CacheMissError";
835
+ this.key = key;
836
+ }
837
+ };
838
+
476
839
  // ../../src/CacheStack.ts
477
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
478
840
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
479
841
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
480
842
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
481
843
  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
- });
844
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
501
845
  var DebugLogger = class {
502
846
  enabled;
503
847
  constructor(enabled) {
@@ -532,6 +876,14 @@ var CacheStack = class extends EventEmitter {
532
876
  throw new Error("CacheStack requires at least one cache layer.");
533
877
  }
534
878
  this.validateConfiguration();
879
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
880
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
881
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
882
+ if (options.publishSetInvalidation !== void 0) {
883
+ console.warn(
884
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
885
+ );
886
+ }
535
887
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
536
888
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
537
889
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -540,36 +892,42 @@ var CacheStack = class extends EventEmitter {
540
892
  layers;
541
893
  options;
542
894
  stampedeGuard = new StampedeGuard();
543
- metrics = EMPTY_METRICS();
895
+ metricsCollector = new MetricsCollector();
544
896
  instanceId = randomUUID();
545
897
  startup;
546
898
  unsubscribeInvalidation;
547
899
  logger;
548
900
  tagIndex;
549
901
  backgroundRefreshes = /* @__PURE__ */ new Map();
550
- accessProfiles = /* @__PURE__ */ new Map();
551
902
  layerDegradedUntil = /* @__PURE__ */ new Map();
552
- circuitBreakers = /* @__PURE__ */ new Map();
903
+ ttlResolver;
904
+ circuitBreakerManager;
553
905
  isDisconnecting = false;
554
906
  disconnectPromise;
907
+ /**
908
+ * Read-through cache get.
909
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
910
+ * and stores the result across all layers. Returns `null` if the key is not found
911
+ * and no `fetcher` is provided.
912
+ */
555
913
  async get(key, fetcher, options) {
556
914
  const normalizedKey = this.validateCacheKey(key);
557
915
  this.validateWriteOptions(options);
558
916
  await this.startup;
559
917
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
560
918
  if (hit.found) {
561
- this.recordAccess(normalizedKey);
919
+ this.ttlResolver.recordAccess(normalizedKey);
562
920
  if (this.isNegativeStoredValue(hit.stored)) {
563
- this.metrics.negativeCacheHits += 1;
921
+ this.metricsCollector.increment("negativeCacheHits");
564
922
  }
565
923
  if (hit.state === "fresh") {
566
- this.metrics.hits += 1;
924
+ this.metricsCollector.increment("hits");
567
925
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
568
926
  return hit.value;
569
927
  }
570
928
  if (hit.state === "stale-while-revalidate") {
571
- this.metrics.hits += 1;
572
- this.metrics.staleHits += 1;
929
+ this.metricsCollector.increment("hits");
930
+ this.metricsCollector.increment("staleHits");
573
931
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
574
932
  if (fetcher) {
575
933
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -577,47 +935,148 @@ var CacheStack = class extends EventEmitter {
577
935
  return hit.value;
578
936
  }
579
937
  if (!fetcher) {
580
- this.metrics.hits += 1;
581
- this.metrics.staleHits += 1;
938
+ this.metricsCollector.increment("hits");
939
+ this.metricsCollector.increment("staleHits");
582
940
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
583
941
  return hit.value;
584
942
  }
585
943
  try {
586
944
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
587
945
  } catch (error) {
588
- this.metrics.staleHits += 1;
589
- this.metrics.refreshErrors += 1;
946
+ this.metricsCollector.increment("staleHits");
947
+ this.metricsCollector.increment("refreshErrors");
590
948
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
591
949
  return hit.value;
592
950
  }
593
951
  }
594
- this.metrics.misses += 1;
952
+ this.metricsCollector.increment("misses");
595
953
  if (!fetcher) {
596
954
  return null;
597
955
  }
598
956
  return this.fetchWithGuards(normalizedKey, fetcher, options);
599
957
  }
958
+ /**
959
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
960
+ * Fetches and caches the value if not already present.
961
+ */
962
+ async getOrSet(key, fetcher, options) {
963
+ return this.get(key, fetcher, options);
964
+ }
965
+ /**
966
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
967
+ * Useful when the value is expected to exist or the fetcher is expected to
968
+ * return non-null.
969
+ */
970
+ async getOrThrow(key, fetcher, options) {
971
+ const value = await this.get(key, fetcher, options);
972
+ if (value === null) {
973
+ throw new CacheMissError(key);
974
+ }
975
+ return value;
976
+ }
977
+ /**
978
+ * Returns true if the given key exists and is not expired in any layer.
979
+ */
980
+ async has(key) {
981
+ const normalizedKey = this.validateCacheKey(key);
982
+ await this.startup;
983
+ for (const layer of this.layers) {
984
+ if (this.shouldSkipLayer(layer)) {
985
+ continue;
986
+ }
987
+ if (layer.has) {
988
+ try {
989
+ const exists = await layer.has(normalizedKey);
990
+ if (exists) {
991
+ return true;
992
+ }
993
+ } catch {
994
+ }
995
+ } else {
996
+ try {
997
+ const value = await layer.get(normalizedKey);
998
+ if (value !== null) {
999
+ return true;
1000
+ }
1001
+ } catch {
1002
+ }
1003
+ }
1004
+ }
1005
+ return false;
1006
+ }
1007
+ /**
1008
+ * Returns the remaining TTL in seconds for the key in the fastest layer
1009
+ * that has it, or null if the key is not found / has no TTL.
1010
+ */
1011
+ async ttl(key) {
1012
+ const normalizedKey = this.validateCacheKey(key);
1013
+ await this.startup;
1014
+ for (const layer of this.layers) {
1015
+ if (this.shouldSkipLayer(layer)) {
1016
+ continue;
1017
+ }
1018
+ if (layer.ttl) {
1019
+ try {
1020
+ const remaining = await layer.ttl(normalizedKey);
1021
+ if (remaining !== null) {
1022
+ return remaining;
1023
+ }
1024
+ } catch {
1025
+ }
1026
+ }
1027
+ }
1028
+ return null;
1029
+ }
1030
+ /**
1031
+ * Stores a value in all cache layers. Overwrites any existing value.
1032
+ */
600
1033
  async set(key, value, options) {
601
1034
  const normalizedKey = this.validateCacheKey(key);
602
1035
  this.validateWriteOptions(options);
603
1036
  await this.startup;
604
1037
  await this.storeEntry(normalizedKey, "value", value, options);
605
1038
  }
1039
+ /**
1040
+ * Deletes the key from all layers and publishes an invalidation message.
1041
+ */
606
1042
  async delete(key) {
607
1043
  const normalizedKey = this.validateCacheKey(key);
608
1044
  await this.startup;
609
1045
  await this.deleteKeys([normalizedKey]);
610
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
1046
+ await this.publishInvalidation({
1047
+ scope: "key",
1048
+ keys: [normalizedKey],
1049
+ sourceId: this.instanceId,
1050
+ operation: "delete"
1051
+ });
611
1052
  }
612
1053
  async clear() {
613
1054
  await this.startup;
614
1055
  await Promise.all(this.layers.map((layer) => layer.clear()));
615
1056
  await this.tagIndex.clear();
616
- this.accessProfiles.clear();
617
- this.metrics.invalidations += 1;
1057
+ this.ttlResolver.clearProfiles();
1058
+ this.circuitBreakerManager.clear();
1059
+ this.metricsCollector.increment("invalidations");
618
1060
  this.logger.debug?.("clear");
619
1061
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
620
1062
  }
1063
+ /**
1064
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
1065
+ */
1066
+ async mdelete(keys) {
1067
+ if (keys.length === 0) {
1068
+ return;
1069
+ }
1070
+ await this.startup;
1071
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1072
+ await this.deleteKeys(normalizedKeys);
1073
+ await this.publishInvalidation({
1074
+ scope: "keys",
1075
+ keys: normalizedKeys,
1076
+ sourceId: this.instanceId,
1077
+ operation: "delete"
1078
+ });
1079
+ }
621
1080
  async mget(entries) {
622
1081
  if (entries.length === 0) {
623
1082
  return [];
@@ -655,7 +1114,9 @@ var CacheStack = class extends EventEmitter {
655
1114
  const indexesByKey = /* @__PURE__ */ new Map();
656
1115
  const resultsByKey = /* @__PURE__ */ new Map();
657
1116
  for (let index = 0; index < normalizedEntries.length; index += 1) {
658
- const key = normalizedEntries[index].key;
1117
+ const entry = normalizedEntries[index];
1118
+ if (!entry) continue;
1119
+ const key = entry.key;
659
1120
  const indexes = indexesByKey.get(key) ?? [];
660
1121
  indexes.push(index);
661
1122
  indexesByKey.set(key, indexes);
@@ -663,6 +1124,7 @@ var CacheStack = class extends EventEmitter {
663
1124
  }
664
1125
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
665
1126
  const layer = this.layers[layerIndex];
1127
+ if (!layer) continue;
666
1128
  const keys = [...pending];
667
1129
  if (keys.length === 0) {
668
1130
  break;
@@ -671,7 +1133,7 @@ var CacheStack = class extends EventEmitter {
671
1133
  for (let offset = 0; offset < values.length; offset += 1) {
672
1134
  const key = keys[offset];
673
1135
  const stored = values[offset];
674
- if (stored === null) {
1136
+ if (!key || stored === null) {
675
1137
  continue;
676
1138
  }
677
1139
  const resolved = resolveStoredValue(stored);
@@ -683,13 +1145,13 @@ var CacheStack = class extends EventEmitter {
683
1145
  await this.backfill(key, stored, layerIndex - 1);
684
1146
  resultsByKey.set(key, resolved.value);
685
1147
  pending.delete(key);
686
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
1148
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
687
1149
  }
688
1150
  }
689
1151
  if (pending.size > 0) {
690
1152
  for (const key of pending) {
691
1153
  await this.tagIndex.remove(key);
692
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
1154
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
693
1155
  }
694
1156
  }
695
1157
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -704,26 +1166,38 @@ var CacheStack = class extends EventEmitter {
704
1166
  }
705
1167
  async warm(entries, options = {}) {
706
1168
  const concurrency = Math.max(1, options.concurrency ?? 4);
1169
+ const total = entries.length;
1170
+ let completed = 0;
707
1171
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
708
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
1172
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
709
1173
  while (queue.length > 0) {
710
1174
  const entry = queue.shift();
711
1175
  if (!entry) {
712
1176
  return;
713
1177
  }
1178
+ let success = false;
714
1179
  try {
715
1180
  await this.get(entry.key, entry.fetcher, entry.options);
716
1181
  this.emit("warm", { key: entry.key });
1182
+ success = true;
717
1183
  } catch (error) {
718
1184
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
719
1185
  if (!options.continueOnError) {
720
1186
  throw error;
721
1187
  }
1188
+ } finally {
1189
+ completed += 1;
1190
+ const progress = { completed, total, key: entry.key, success };
1191
+ options.onProgress?.(progress);
722
1192
  }
723
1193
  }
724
1194
  });
725
1195
  await Promise.all(workers);
726
1196
  }
1197
+ /**
1198
+ * Returns a cached version of `fetcher`. The cache key is derived from
1199
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
1200
+ */
727
1201
  wrap(prefix, fetcher, options = {}) {
728
1202
  return (...args) => {
729
1203
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -731,6 +1205,10 @@ var CacheStack = class extends EventEmitter {
731
1205
  return this.get(key, () => fetcher(...args), options);
732
1206
  };
733
1207
  }
1208
+ /**
1209
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
1210
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
1211
+ */
734
1212
  namespace(prefix) {
735
1213
  return new CacheNamespace(this, prefix);
736
1214
  }
@@ -747,7 +1225,7 @@ var CacheStack = class extends EventEmitter {
747
1225
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
748
1226
  }
749
1227
  getMetrics() {
750
- return { ...this.metrics };
1228
+ return this.metricsCollector.snapshot;
751
1229
  }
752
1230
  getStats() {
753
1231
  return {
@@ -761,7 +1239,53 @@ var CacheStack = class extends EventEmitter {
761
1239
  };
762
1240
  }
763
1241
  resetMetrics() {
764
- Object.assign(this.metrics, EMPTY_METRICS());
1242
+ this.metricsCollector.reset();
1243
+ }
1244
+ /**
1245
+ * Returns computed hit-rate statistics (overall and per-layer).
1246
+ */
1247
+ getHitRate() {
1248
+ return this.metricsCollector.hitRate();
1249
+ }
1250
+ /**
1251
+ * Returns detailed metadata about a single cache key: which layers contain it,
1252
+ * remaining fresh/stale/error TTLs, and associated tags.
1253
+ * Returns `null` if the key does not exist in any layer.
1254
+ */
1255
+ async inspect(key) {
1256
+ const normalizedKey = this.validateCacheKey(key);
1257
+ await this.startup;
1258
+ const foundInLayers = [];
1259
+ let freshTtlSeconds = null;
1260
+ let staleTtlSeconds = null;
1261
+ let errorTtlSeconds = null;
1262
+ let isStale = false;
1263
+ for (const layer of this.layers) {
1264
+ if (this.shouldSkipLayer(layer)) {
1265
+ continue;
1266
+ }
1267
+ const stored = await this.readLayerEntry(layer, normalizedKey);
1268
+ if (stored === null) {
1269
+ continue;
1270
+ }
1271
+ const resolved = resolveStoredValue(stored);
1272
+ if (resolved.state === "expired") {
1273
+ continue;
1274
+ }
1275
+ foundInLayers.push(layer.name);
1276
+ if (foundInLayers.length === 1 && resolved.envelope) {
1277
+ const now = Date.now();
1278
+ freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
1279
+ staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1280
+ errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1281
+ isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1282
+ }
1283
+ }
1284
+ if (foundInLayers.length === 0) {
1285
+ return null;
1286
+ }
1287
+ const tags = await this.getTagsForKey(normalizedKey);
1288
+ return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
765
1289
  }
766
1290
  async exportState() {
767
1291
  await this.startup;
@@ -790,10 +1314,12 @@ var CacheStack = class extends EventEmitter {
790
1314
  }
791
1315
  async importState(entries) {
792
1316
  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
- }));
1317
+ await Promise.all(
1318
+ entries.map(async (entry) => {
1319
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1320
+ await this.tagIndex.touch(entry.key);
1321
+ })
1322
+ );
797
1323
  }
798
1324
  async persistToFile(filePath) {
799
1325
  const snapshot = await this.exportState();
@@ -801,11 +1327,21 @@ var CacheStack = class extends EventEmitter {
801
1327
  }
802
1328
  async restoreFromFile(filePath) {
803
1329
  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[]");
1330
+ let parsed;
1331
+ try {
1332
+ parsed = JSON.parse(raw, (_key, value) => {
1333
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1334
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1335
+ }
1336
+ return value;
1337
+ });
1338
+ } catch (cause) {
1339
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
807
1340
  }
808
- await this.importState(snapshot);
1341
+ if (!this.isCacheSnapshotEntries(parsed)) {
1342
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1343
+ }
1344
+ await this.importState(parsed);
809
1345
  }
810
1346
  async disconnect() {
811
1347
  if (!this.disconnectPromise) {
@@ -830,7 +1366,7 @@ var CacheStack = class extends EventEmitter {
830
1366
  const fetchTask = async () => {
831
1367
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
832
1368
  if (secondHit.found) {
833
- this.metrics.hits += 1;
1369
+ this.metricsCollector.increment("hits");
834
1370
  return secondHit.value;
835
1371
  }
836
1372
  return this.fetchAndPopulate(key, fetcher, options);
@@ -855,12 +1391,12 @@ var CacheStack = class extends EventEmitter {
855
1391
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
856
1392
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
857
1393
  const deadline = Date.now() + timeoutMs;
858
- this.metrics.singleFlightWaits += 1;
1394
+ this.metricsCollector.increment("singleFlightWaits");
859
1395
  this.emit("stampede-dedupe", { key });
860
1396
  while (Date.now() < deadline) {
861
1397
  const hit = await this.readFromLayers(key, options, "fresh-only");
862
1398
  if (hit.found) {
863
- this.metrics.hits += 1;
1399
+ this.metricsCollector.increment("hits");
864
1400
  return hit.value;
865
1401
  }
866
1402
  await this.sleep(pollIntervalMs);
@@ -868,12 +1404,14 @@ var CacheStack = class extends EventEmitter {
868
1404
  return this.fetchAndPopulate(key, fetcher, options);
869
1405
  }
870
1406
  async fetchAndPopulate(key, fetcher, options) {
871
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
872
- this.metrics.fetches += 1;
1407
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1408
+ this.metricsCollector.increment("fetches");
1409
+ const fetchStart = Date.now();
873
1410
  let fetched;
874
1411
  try {
875
1412
  fetched = await fetcher();
876
- this.resetCircuitBreaker(key);
1413
+ this.circuitBreakerManager.recordSuccess(key);
1414
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
877
1415
  } catch (error) {
878
1416
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
879
1417
  throw error;
@@ -885,6 +1423,9 @@ var CacheStack = class extends EventEmitter {
885
1423
  await this.storeEntry(key, "empty", null, options);
886
1424
  return null;
887
1425
  }
1426
+ if (options?.shouldCache && !options.shouldCache(fetched)) {
1427
+ return fetched;
1428
+ }
888
1429
  await this.storeEntry(key, "value", fetched, options);
889
1430
  return fetched;
890
1431
  }
@@ -895,7 +1436,7 @@ var CacheStack = class extends EventEmitter {
895
1436
  } else {
896
1437
  await this.tagIndex.touch(key);
897
1438
  }
898
- this.metrics.sets += 1;
1439
+ this.metricsCollector.increment("sets");
899
1440
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
900
1441
  this.emit("set", { key, kind, tags: options?.tags });
901
1442
  if (this.shouldBroadcastL1Invalidation()) {
@@ -906,9 +1447,13 @@ var CacheStack = class extends EventEmitter {
906
1447
  let sawRetainableValue = false;
907
1448
  for (let index = 0; index < this.layers.length; index += 1) {
908
1449
  const layer = this.layers[index];
1450
+ if (!layer) continue;
1451
+ const readStart = performance.now();
909
1452
  const stored = await this.readLayerEntry(layer, key);
1453
+ const readDuration = performance.now() - readStart;
1454
+ this.metricsCollector.recordLatency(layer.name, readDuration);
910
1455
  if (stored === null) {
911
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1456
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
912
1457
  continue;
913
1458
  }
914
1459
  const resolved = resolveStoredValue(stored);
@@ -922,10 +1467,17 @@ var CacheStack = class extends EventEmitter {
922
1467
  }
923
1468
  await this.tagIndex.touch(key);
924
1469
  await this.backfill(key, stored, index - 1, options);
925
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1470
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
926
1471
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
927
1472
  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 };
1473
+ return {
1474
+ found: true,
1475
+ value: resolved.value,
1476
+ stored,
1477
+ state: resolved.state,
1478
+ layerIndex: index,
1479
+ layerName: layer.name
1480
+ };
929
1481
  }
930
1482
  if (!sawRetainableValue) {
931
1483
  await this.tagIndex.remove(key);
@@ -957,7 +1509,7 @@ var CacheStack = class extends EventEmitter {
957
1509
  }
958
1510
  for (let index = 0; index <= upToIndex; index += 1) {
959
1511
  const layer = this.layers[index];
960
- if (this.shouldSkipLayer(layer)) {
1512
+ if (!layer || this.shouldSkipLayer(layer)) {
961
1513
  continue;
962
1514
  }
963
1515
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -967,7 +1519,7 @@ var CacheStack = class extends EventEmitter {
967
1519
  await this.handleLayerFailure(layer, "backfill", error);
968
1520
  continue;
969
1521
  }
970
- this.metrics.backfills += 1;
1522
+ this.metricsCollector.increment("backfills");
971
1523
  this.logger.debug?.("backfill", { key, layer: layer.name });
972
1524
  this.emit("backfill", { key, layer: layer.name });
973
1525
  }
@@ -984,11 +1536,7 @@ var CacheStack = class extends EventEmitter {
984
1536
  options?.staleWhileRevalidate,
985
1537
  this.options.staleWhileRevalidate
986
1538
  );
987
- const staleIfError = this.resolveLayerSeconds(
988
- layer.name,
989
- options?.staleIfError,
990
- this.options.staleIfError
991
- );
1539
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
992
1540
  const payload = createStoredValueEnvelope({
993
1541
  kind,
994
1542
  value,
@@ -1016,7 +1564,7 @@ var CacheStack = class extends EventEmitter {
1016
1564
  if (failures.length === 0) {
1017
1565
  return;
1018
1566
  }
1019
- this.metrics.writeFailures += failures.length;
1567
+ this.metricsCollector.increment("writeFailures", failures.length);
1020
1568
  this.logger.debug?.("write-failure", {
1021
1569
  ...context,
1022
1570
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -1029,42 +1577,10 @@ var CacheStack = class extends EventEmitter {
1029
1577
  }
1030
1578
  }
1031
1579
  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);
1580
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
1046
1581
  }
1047
1582
  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));
1583
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
1068
1584
  }
1069
1585
  shouldNegativeCache(options) {
1070
1586
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -1074,11 +1590,11 @@ var CacheStack = class extends EventEmitter {
1074
1590
  return;
1075
1591
  }
1076
1592
  const refresh = (async () => {
1077
- this.metrics.refreshes += 1;
1593
+ this.metricsCollector.increment("refreshes");
1078
1594
  try {
1079
1595
  await this.fetchWithGuards(key, fetcher, options);
1080
1596
  } catch (error) {
1081
- this.metrics.refreshErrors += 1;
1597
+ this.metricsCollector.increment("refreshErrors");
1082
1598
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
1083
1599
  } finally {
1084
1600
  this.backgroundRefreshes.delete(key);
@@ -1100,10 +1616,11 @@ var CacheStack = class extends EventEmitter {
1100
1616
  await this.deleteKeysFromLayers(this.layers, keys);
1101
1617
  for (const key of keys) {
1102
1618
  await this.tagIndex.remove(key);
1103
- this.accessProfiles.delete(key);
1619
+ this.ttlResolver.deleteProfile(key);
1620
+ this.circuitBreakerManager.delete(key);
1104
1621
  }
1105
- this.metrics.deletes += keys.length;
1106
- this.metrics.invalidations += 1;
1622
+ this.metricsCollector.increment("deletes", keys.length);
1623
+ this.metricsCollector.increment("invalidations");
1107
1624
  this.logger.debug?.("delete", { keys });
1108
1625
  this.emit("delete", { keys });
1109
1626
  }
@@ -1124,7 +1641,7 @@ var CacheStack = class extends EventEmitter {
1124
1641
  if (message.scope === "clear") {
1125
1642
  await Promise.all(localLayers.map((layer) => layer.clear()));
1126
1643
  await this.tagIndex.clear();
1127
- this.accessProfiles.clear();
1644
+ this.ttlResolver.clearProfiles();
1128
1645
  return;
1129
1646
  }
1130
1647
  const keys = message.keys ?? [];
@@ -1132,10 +1649,16 @@ var CacheStack = class extends EventEmitter {
1132
1649
  if (message.operation !== "write") {
1133
1650
  for (const key of keys) {
1134
1651
  await this.tagIndex.remove(key);
1135
- this.accessProfiles.delete(key);
1652
+ this.ttlResolver.deleteProfile(key);
1136
1653
  }
1137
1654
  }
1138
1655
  }
1656
+ async getTagsForKey(key) {
1657
+ if (this.tagIndex.tagsForKey) {
1658
+ return this.tagIndex.tagsForKey(key);
1659
+ }
1660
+ return [];
1661
+ }
1139
1662
  formatError(error) {
1140
1663
  if (error instanceof Error) {
1141
1664
  return error.message;
@@ -1162,13 +1685,15 @@ var CacheStack = class extends EventEmitter {
1162
1685
  }
1163
1686
  return;
1164
1687
  }
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
- }));
1688
+ await Promise.all(
1689
+ keys.map(async (key) => {
1690
+ try {
1691
+ await layer.delete(key);
1692
+ } catch (error) {
1693
+ await this.handleLayerFailure(layer, "delete", error);
1694
+ }
1695
+ })
1696
+ );
1172
1697
  })
1173
1698
  );
1174
1699
  }
@@ -1269,7 +1794,7 @@ var CacheStack = class extends EventEmitter {
1269
1794
  const ttl = remainingStoredTtlSeconds(refreshed);
1270
1795
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1271
1796
  const layer = this.layers[index];
1272
- if (this.shouldSkipLayer(layer)) {
1797
+ if (!layer || this.shouldSkipLayer(layer)) {
1273
1798
  continue;
1274
1799
  }
1275
1800
  try {
@@ -1283,33 +1808,6 @@ var CacheStack = class extends EventEmitter {
1283
1808
  this.scheduleBackgroundRefresh(key, fetcher, options);
1284
1809
  }
1285
1810
  }
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
1811
  shouldSkipLayer(layer) {
1314
1812
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1315
1813
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1320,7 +1818,7 @@ var CacheStack = class extends EventEmitter {
1320
1818
  }
1321
1819
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1322
1820
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1323
- this.metrics.degradedOperations += 1;
1821
+ this.metricsCollector.increment("degradedOperations");
1324
1822
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1325
1823
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1326
1824
  return null;
@@ -1328,37 +1826,15 @@ var CacheStack = class extends EventEmitter {
1328
1826
  isGracefulDegradationEnabled() {
1329
1827
  return Boolean(this.options.gracefulDegradation);
1330
1828
  }
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
1829
  recordCircuitFailure(key, options, error) {
1346
1830
  if (!options) {
1347
1831
  return;
1348
1832
  }
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;
1833
+ this.circuitBreakerManager.recordFailure(key, options);
1834
+ if (this.circuitBreakerManager.isOpen(key)) {
1835
+ this.metricsCollector.increment("circuitBreakerTrips");
1356
1836
  }
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);
1837
+ this.emitError("fetch", { key, error: this.formatError(error) });
1362
1838
  }
1363
1839
  isNegativeStoredValue(stored) {
1364
1840
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
@@ -1413,6 +1889,22 @@ var CacheStackModule = class {
1413
1889
  exports: [provider]
1414
1890
  };
1415
1891
  }
1892
+ static forRootAsync(options) {
1893
+ const provider = {
1894
+ provide: CACHE_STACK,
1895
+ inject: options.inject ?? [],
1896
+ useFactory: async (...args) => {
1897
+ const resolved = await options.useFactory(...args);
1898
+ return new CacheStack(resolved.layers, resolved.bridgeOptions);
1899
+ }
1900
+ };
1901
+ return {
1902
+ global: true,
1903
+ module: CacheStackModule,
1904
+ providers: [provider],
1905
+ exports: [provider]
1906
+ };
1907
+ }
1416
1908
  };
1417
1909
  CacheStackModule = __decorateClass([
1418
1910
  Global(),