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.
@@ -73,8 +73,275 @@ var import_common = require("@nestjs/common");
73
73
 
74
74
  // ../../src/CacheStack.ts
75
75
  var import_node_crypto = require("crypto");
76
- var import_node_fs = require("fs");
77
76
  var import_node_events = require("events");
77
+ var import_node_fs = require("fs");
78
+
79
+ // ../../src/CacheNamespace.ts
80
+ var CacheNamespace = class _CacheNamespace {
81
+ constructor(cache, prefix) {
82
+ this.cache = cache;
83
+ this.prefix = prefix;
84
+ }
85
+ cache;
86
+ prefix;
87
+ async get(key, fetcher, options) {
88
+ return this.cache.get(this.qualify(key), fetcher, options);
89
+ }
90
+ async getOrSet(key, fetcher, options) {
91
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
92
+ }
93
+ /**
94
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
95
+ */
96
+ async getOrThrow(key, fetcher, options) {
97
+ return this.cache.getOrThrow(this.qualify(key), fetcher, options);
98
+ }
99
+ async has(key) {
100
+ return this.cache.has(this.qualify(key));
101
+ }
102
+ async ttl(key) {
103
+ return this.cache.ttl(this.qualify(key));
104
+ }
105
+ async set(key, value, options) {
106
+ await this.cache.set(this.qualify(key), value, options);
107
+ }
108
+ async delete(key) {
109
+ await this.cache.delete(this.qualify(key));
110
+ }
111
+ async mdelete(keys) {
112
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
113
+ }
114
+ async clear() {
115
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
116
+ }
117
+ async mget(entries) {
118
+ return this.cache.mget(
119
+ entries.map((entry) => ({
120
+ ...entry,
121
+ key: this.qualify(entry.key)
122
+ }))
123
+ );
124
+ }
125
+ async mset(entries) {
126
+ await this.cache.mset(
127
+ entries.map((entry) => ({
128
+ ...entry,
129
+ key: this.qualify(entry.key)
130
+ }))
131
+ );
132
+ }
133
+ async invalidateByTag(tag) {
134
+ await this.cache.invalidateByTag(tag);
135
+ }
136
+ async invalidateByPattern(pattern) {
137
+ await this.cache.invalidateByPattern(this.qualify(pattern));
138
+ }
139
+ /**
140
+ * Returns detailed metadata about a single cache key within this namespace.
141
+ */
142
+ async inspect(key) {
143
+ return this.cache.inspect(this.qualify(key));
144
+ }
145
+ wrap(keyPrefix, fetcher, options) {
146
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
147
+ }
148
+ warm(entries, options) {
149
+ return this.cache.warm(
150
+ entries.map((entry) => ({
151
+ ...entry,
152
+ key: this.qualify(entry.key)
153
+ })),
154
+ options
155
+ );
156
+ }
157
+ getMetrics() {
158
+ return this.cache.getMetrics();
159
+ }
160
+ getHitRate() {
161
+ return this.cache.getHitRate();
162
+ }
163
+ /**
164
+ * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
165
+ *
166
+ * ```ts
167
+ * const tenant = cache.namespace('tenant:abc')
168
+ * const posts = tenant.namespace('posts')
169
+ * // keys become: "tenant:abc:posts:mykey"
170
+ * ```
171
+ */
172
+ namespace(childPrefix) {
173
+ return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
174
+ }
175
+ qualify(key) {
176
+ return `${this.prefix}:${key}`;
177
+ }
178
+ };
179
+
180
+ // ../../src/internal/CircuitBreakerManager.ts
181
+ var CircuitBreakerManager = class {
182
+ breakers = /* @__PURE__ */ new Map();
183
+ maxEntries;
184
+ constructor(options) {
185
+ this.maxEntries = options.maxEntries;
186
+ }
187
+ /**
188
+ * Throws if the circuit is open for the given key.
189
+ * Automatically resets if the cooldown has elapsed.
190
+ */
191
+ assertClosed(key, options) {
192
+ const state = this.breakers.get(key);
193
+ if (!state?.openUntil) {
194
+ return;
195
+ }
196
+ const now = Date.now();
197
+ if (state.openUntil <= now) {
198
+ state.openUntil = null;
199
+ state.failures = 0;
200
+ this.breakers.set(key, state);
201
+ return;
202
+ }
203
+ const remainingMs = state.openUntil - now;
204
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
205
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
206
+ }
207
+ recordFailure(key, options) {
208
+ if (!options) {
209
+ return;
210
+ }
211
+ const failureThreshold = options.failureThreshold ?? 3;
212
+ const cooldownMs = options.cooldownMs ?? 3e4;
213
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
214
+ state.failures += 1;
215
+ if (state.failures >= failureThreshold) {
216
+ state.openUntil = Date.now() + cooldownMs;
217
+ }
218
+ this.breakers.set(key, state);
219
+ this.pruneIfNeeded();
220
+ }
221
+ recordSuccess(key) {
222
+ this.breakers.delete(key);
223
+ }
224
+ isOpen(key) {
225
+ const state = this.breakers.get(key);
226
+ if (!state?.openUntil) {
227
+ return false;
228
+ }
229
+ if (state.openUntil <= Date.now()) {
230
+ state.openUntil = null;
231
+ state.failures = 0;
232
+ return false;
233
+ }
234
+ return true;
235
+ }
236
+ delete(key) {
237
+ this.breakers.delete(key);
238
+ }
239
+ clear() {
240
+ this.breakers.clear();
241
+ }
242
+ tripCount() {
243
+ let count = 0;
244
+ for (const state of this.breakers.values()) {
245
+ if (state.openUntil !== null) {
246
+ count += 1;
247
+ }
248
+ }
249
+ return count;
250
+ }
251
+ pruneIfNeeded() {
252
+ if (this.breakers.size <= this.maxEntries) {
253
+ return;
254
+ }
255
+ for (const [key, state] of this.breakers.entries()) {
256
+ if (this.breakers.size <= this.maxEntries) {
257
+ break;
258
+ }
259
+ if (!state.openUntil || state.openUntil <= Date.now()) {
260
+ this.breakers.delete(key);
261
+ }
262
+ }
263
+ for (const key of this.breakers.keys()) {
264
+ if (this.breakers.size <= this.maxEntries) {
265
+ break;
266
+ }
267
+ this.breakers.delete(key);
268
+ }
269
+ }
270
+ };
271
+
272
+ // ../../src/internal/MetricsCollector.ts
273
+ var MetricsCollector = class {
274
+ data = this.empty();
275
+ get snapshot() {
276
+ return {
277
+ ...this.data,
278
+ hitsByLayer: { ...this.data.hitsByLayer },
279
+ missesByLayer: { ...this.data.missesByLayer },
280
+ latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
281
+ };
282
+ }
283
+ increment(field, amount = 1) {
284
+ ;
285
+ this.data[field] += amount;
286
+ }
287
+ incrementLayer(map, layerName) {
288
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
289
+ }
290
+ /**
291
+ * Records a read latency sample for the given layer.
292
+ * Maintains a rolling average and max using Welford's online algorithm.
293
+ */
294
+ recordLatency(layerName, durationMs) {
295
+ const existing = this.data.latencyByLayer[layerName];
296
+ if (!existing) {
297
+ this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
298
+ return;
299
+ }
300
+ existing.count += 1;
301
+ existing.avgMs += (durationMs - existing.avgMs) / existing.count;
302
+ if (durationMs > existing.maxMs) {
303
+ existing.maxMs = durationMs;
304
+ }
305
+ }
306
+ reset() {
307
+ this.data = this.empty();
308
+ }
309
+ hitRate() {
310
+ const total = this.data.hits + this.data.misses;
311
+ const overall = total === 0 ? 0 : this.data.hits / total;
312
+ const byLayer = {};
313
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
314
+ for (const layer of allLayers) {
315
+ const h = this.data.hitsByLayer[layer] ?? 0;
316
+ const m = this.data.missesByLayer[layer] ?? 0;
317
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
318
+ }
319
+ return { overall, byLayer };
320
+ }
321
+ empty() {
322
+ return {
323
+ hits: 0,
324
+ misses: 0,
325
+ fetches: 0,
326
+ sets: 0,
327
+ deletes: 0,
328
+ backfills: 0,
329
+ invalidations: 0,
330
+ staleHits: 0,
331
+ refreshes: 0,
332
+ refreshErrors: 0,
333
+ writeFailures: 0,
334
+ singleFlightWaits: 0,
335
+ negativeCacheHits: 0,
336
+ circuitBreakerTrips: 0,
337
+ degradedOperations: 0,
338
+ hitsByLayer: {},
339
+ missesByLayer: {},
340
+ latencyByLayer: {},
341
+ resetAt: Date.now()
342
+ };
343
+ }
344
+ };
78
345
 
79
346
  // ../../src/internal/StoredValue.ts
80
347
  function isStoredValueEnvelope(value) {
@@ -177,67 +444,129 @@ function normalizePositiveSeconds(value) {
177
444
  return value;
178
445
  }
179
446
 
180
- // ../../src/CacheNamespace.ts
181
- var CacheNamespace = class {
182
- constructor(cache, prefix) {
183
- this.cache = cache;
184
- this.prefix = prefix;
185
- }
186
- cache;
187
- prefix;
188
- async get(key, fetcher, options) {
189
- return this.cache.get(this.qualify(key), fetcher, options);
190
- }
191
- async set(key, value, options) {
192
- await this.cache.set(this.qualify(key), value, options);
193
- }
194
- async delete(key) {
195
- await this.cache.delete(this.qualify(key));
447
+ // ../../src/internal/TtlResolver.ts
448
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
449
+ var TtlResolver = class {
450
+ accessProfiles = /* @__PURE__ */ new Map();
451
+ maxProfileEntries;
452
+ constructor(options) {
453
+ this.maxProfileEntries = options.maxProfileEntries;
196
454
  }
197
- async clear() {
198
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
455
+ recordAccess(key) {
456
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
457
+ profile.hits += 1;
458
+ profile.lastAccessAt = Date.now();
459
+ this.accessProfiles.set(key, profile);
460
+ this.pruneIfNeeded();
199
461
  }
200
- async mget(entries) {
201
- return this.cache.mget(entries.map((entry) => ({
202
- ...entry,
203
- key: this.qualify(entry.key)
204
- })));
462
+ deleteProfile(key) {
463
+ this.accessProfiles.delete(key);
205
464
  }
206
- async mset(entries) {
207
- await this.cache.mset(entries.map((entry) => ({
208
- ...entry,
209
- key: this.qualify(entry.key)
210
- })));
465
+ clearProfiles() {
466
+ this.accessProfiles.clear();
211
467
  }
212
- async invalidateByTag(tag) {
213
- await this.cache.invalidateByTag(tag);
468
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
469
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
470
+ layerName,
471
+ options?.negativeTtl,
472
+ globalNegativeTtl,
473
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
474
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
475
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
476
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
477
+ return this.applyJitter(adaptiveTtl, jitter);
214
478
  }
215
- async invalidateByPattern(pattern) {
216
- await this.cache.invalidateByPattern(this.qualify(pattern));
479
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
480
+ if (override !== void 0) {
481
+ return this.readLayerNumber(layerName, override) ?? fallback;
482
+ }
483
+ if (globalDefault !== void 0) {
484
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
485
+ }
486
+ return fallback;
217
487
  }
218
- wrap(keyPrefix, fetcher, options) {
219
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
488
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
489
+ if (!ttl || !adaptiveTtl) {
490
+ return ttl;
491
+ }
492
+ const profile = this.accessProfiles.get(key);
493
+ if (!profile) {
494
+ return ttl;
495
+ }
496
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
497
+ const hotAfter = config.hotAfter ?? 3;
498
+ if (profile.hits < hotAfter) {
499
+ return ttl;
500
+ }
501
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
502
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
503
+ const multiplier = Math.floor(profile.hits / hotAfter);
504
+ return Math.min(maxTtl, ttl + step * multiplier);
220
505
  }
221
- warm(entries, options) {
222
- return this.cache.warm(entries.map((entry) => ({
223
- ...entry,
224
- key: this.qualify(entry.key)
225
- })), options);
506
+ applyJitter(ttl, jitter) {
507
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
508
+ return ttl;
509
+ }
510
+ const delta = (Math.random() * 2 - 1) * jitter;
511
+ return Math.max(1, Math.round(ttl + delta));
226
512
  }
227
- getMetrics() {
228
- return this.cache.getMetrics();
513
+ readLayerNumber(layerName, value) {
514
+ if (typeof value === "number") {
515
+ return value;
516
+ }
517
+ return value[layerName];
229
518
  }
230
- qualify(key) {
231
- return `${this.prefix}:${key}`;
519
+ pruneIfNeeded() {
520
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
521
+ return;
522
+ }
523
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
524
+ let removed = 0;
525
+ for (const key of this.accessProfiles.keys()) {
526
+ if (removed >= toRemove) {
527
+ break;
528
+ }
529
+ this.accessProfiles.delete(key);
530
+ removed += 1;
531
+ }
232
532
  }
233
533
  };
234
534
 
235
535
  // ../../src/invalidation/PatternMatcher.ts
236
- var PatternMatcher = class {
536
+ var PatternMatcher = class _PatternMatcher {
537
+ /**
538
+ * Tests whether a glob-style pattern matches a value.
539
+ * Supports `*` (any sequence of characters) and `?` (any single character).
540
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
541
+ */
237
542
  static matches(pattern, value) {
238
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
239
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
240
- return regex.test(value);
543
+ return _PatternMatcher.matchLinear(pattern, value);
544
+ }
545
+ /**
546
+ * Linear-time glob matching using dynamic programming.
547
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
548
+ */
549
+ static matchLinear(pattern, value) {
550
+ const m = pattern.length;
551
+ const n = value.length;
552
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
553
+ dp[0][0] = true;
554
+ for (let i = 1; i <= m; i++) {
555
+ if (pattern[i - 1] === "*") {
556
+ dp[i][0] = dp[i - 1]?.[0];
557
+ }
558
+ }
559
+ for (let i = 1; i <= m; i++) {
560
+ for (let j = 1; j <= n; j++) {
561
+ const pc = pattern[i - 1];
562
+ if (pc === "*") {
563
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
564
+ } else if (pc === "?" || pc === value[j - 1]) {
565
+ dp[i][j] = dp[i - 1]?.[j - 1];
566
+ }
567
+ }
568
+ }
569
+ return dp[m]?.[n];
241
570
  }
242
571
  };
243
572
 
@@ -246,11 +575,17 @@ var TagIndex = class {
246
575
  tagToKeys = /* @__PURE__ */ new Map();
247
576
  keyToTags = /* @__PURE__ */ new Map();
248
577
  knownKeys = /* @__PURE__ */ new Set();
578
+ maxKnownKeys;
579
+ constructor(options = {}) {
580
+ this.maxKnownKeys = options.maxKnownKeys;
581
+ }
249
582
  async touch(key) {
250
583
  this.knownKeys.add(key);
584
+ this.pruneKnownKeysIfNeeded();
251
585
  }
252
586
  async track(key, tags) {
253
587
  this.knownKeys.add(key);
588
+ this.pruneKnownKeysIfNeeded();
254
589
  if (tags.length === 0) {
255
590
  return;
256
591
  }
@@ -289,6 +624,9 @@ var TagIndex = class {
289
624
  async keysForTag(tag) {
290
625
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
291
626
  }
627
+ async tagsForKey(key) {
628
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
629
+ }
292
630
  async matchPattern(pattern) {
293
631
  return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
294
632
  }
@@ -297,6 +635,21 @@ var TagIndex = class {
297
635
  this.keyToTags.clear();
298
636
  this.knownKeys.clear();
299
637
  }
638
+ pruneKnownKeysIfNeeded() {
639
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
640
+ return;
641
+ }
642
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
643
+ let removed = 0;
644
+ for (const key of this.knownKeys) {
645
+ if (removed >= toRemove) {
646
+ break;
647
+ }
648
+ this.knownKeys.delete(key);
649
+ this.keyToTags.delete(key);
650
+ removed += 1;
651
+ }
652
+ }
300
653
  };
301
654
 
302
655
  // ../../node_modules/async-mutex/index.mjs
@@ -499,31 +852,22 @@ var StampedeGuard = class {
499
852
  }
500
853
  };
501
854
 
855
+ // ../../src/types.ts
856
+ var CacheMissError = class extends Error {
857
+ key;
858
+ constructor(key) {
859
+ super(`Cache miss for key "${key}".`);
860
+ this.name = "CacheMissError";
861
+ this.key = key;
862
+ }
863
+ };
864
+
502
865
  // ../../src/CacheStack.ts
503
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
504
866
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
505
867
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
506
868
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
507
869
  var MAX_CACHE_KEY_LENGTH = 1024;
508
- var EMPTY_METRICS = () => ({
509
- hits: 0,
510
- misses: 0,
511
- fetches: 0,
512
- sets: 0,
513
- deletes: 0,
514
- backfills: 0,
515
- invalidations: 0,
516
- staleHits: 0,
517
- refreshes: 0,
518
- refreshErrors: 0,
519
- writeFailures: 0,
520
- singleFlightWaits: 0,
521
- negativeCacheHits: 0,
522
- circuitBreakerTrips: 0,
523
- degradedOperations: 0,
524
- hitsByLayer: {},
525
- missesByLayer: {}
526
- });
870
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
527
871
  var DebugLogger = class {
528
872
  enabled;
529
873
  constructor(enabled) {
@@ -558,6 +902,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
558
902
  throw new Error("CacheStack requires at least one cache layer.");
559
903
  }
560
904
  this.validateConfiguration();
905
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
906
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
907
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
908
+ if (options.publishSetInvalidation !== void 0) {
909
+ console.warn(
910
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
911
+ );
912
+ }
561
913
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
562
914
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
563
915
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -566,36 +918,42 @@ var CacheStack = class extends import_node_events.EventEmitter {
566
918
  layers;
567
919
  options;
568
920
  stampedeGuard = new StampedeGuard();
569
- metrics = EMPTY_METRICS();
921
+ metricsCollector = new MetricsCollector();
570
922
  instanceId = (0, import_node_crypto.randomUUID)();
571
923
  startup;
572
924
  unsubscribeInvalidation;
573
925
  logger;
574
926
  tagIndex;
575
927
  backgroundRefreshes = /* @__PURE__ */ new Map();
576
- accessProfiles = /* @__PURE__ */ new Map();
577
928
  layerDegradedUntil = /* @__PURE__ */ new Map();
578
- circuitBreakers = /* @__PURE__ */ new Map();
929
+ ttlResolver;
930
+ circuitBreakerManager;
579
931
  isDisconnecting = false;
580
932
  disconnectPromise;
933
+ /**
934
+ * Read-through cache get.
935
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
936
+ * and stores the result across all layers. Returns `null` if the key is not found
937
+ * and no `fetcher` is provided.
938
+ */
581
939
  async get(key, fetcher, options) {
582
940
  const normalizedKey = this.validateCacheKey(key);
583
941
  this.validateWriteOptions(options);
584
942
  await this.startup;
585
943
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
586
944
  if (hit.found) {
587
- this.recordAccess(normalizedKey);
945
+ this.ttlResolver.recordAccess(normalizedKey);
588
946
  if (this.isNegativeStoredValue(hit.stored)) {
589
- this.metrics.negativeCacheHits += 1;
947
+ this.metricsCollector.increment("negativeCacheHits");
590
948
  }
591
949
  if (hit.state === "fresh") {
592
- this.metrics.hits += 1;
950
+ this.metricsCollector.increment("hits");
593
951
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
594
952
  return hit.value;
595
953
  }
596
954
  if (hit.state === "stale-while-revalidate") {
597
- this.metrics.hits += 1;
598
- this.metrics.staleHits += 1;
955
+ this.metricsCollector.increment("hits");
956
+ this.metricsCollector.increment("staleHits");
599
957
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
600
958
  if (fetcher) {
601
959
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -603,47 +961,148 @@ var CacheStack = class extends import_node_events.EventEmitter {
603
961
  return hit.value;
604
962
  }
605
963
  if (!fetcher) {
606
- this.metrics.hits += 1;
607
- this.metrics.staleHits += 1;
964
+ this.metricsCollector.increment("hits");
965
+ this.metricsCollector.increment("staleHits");
608
966
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
609
967
  return hit.value;
610
968
  }
611
969
  try {
612
970
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
613
971
  } catch (error) {
614
- this.metrics.staleHits += 1;
615
- this.metrics.refreshErrors += 1;
972
+ this.metricsCollector.increment("staleHits");
973
+ this.metricsCollector.increment("refreshErrors");
616
974
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
617
975
  return hit.value;
618
976
  }
619
977
  }
620
- this.metrics.misses += 1;
978
+ this.metricsCollector.increment("misses");
621
979
  if (!fetcher) {
622
980
  return null;
623
981
  }
624
982
  return this.fetchWithGuards(normalizedKey, fetcher, options);
625
983
  }
984
+ /**
985
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
986
+ * Fetches and caches the value if not already present.
987
+ */
988
+ async getOrSet(key, fetcher, options) {
989
+ return this.get(key, fetcher, options);
990
+ }
991
+ /**
992
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
993
+ * Useful when the value is expected to exist or the fetcher is expected to
994
+ * return non-null.
995
+ */
996
+ async getOrThrow(key, fetcher, options) {
997
+ const value = await this.get(key, fetcher, options);
998
+ if (value === null) {
999
+ throw new CacheMissError(key);
1000
+ }
1001
+ return value;
1002
+ }
1003
+ /**
1004
+ * Returns true if the given key exists and is not expired in any layer.
1005
+ */
1006
+ async has(key) {
1007
+ const normalizedKey = this.validateCacheKey(key);
1008
+ await this.startup;
1009
+ for (const layer of this.layers) {
1010
+ if (this.shouldSkipLayer(layer)) {
1011
+ continue;
1012
+ }
1013
+ if (layer.has) {
1014
+ try {
1015
+ const exists = await layer.has(normalizedKey);
1016
+ if (exists) {
1017
+ return true;
1018
+ }
1019
+ } catch {
1020
+ }
1021
+ } else {
1022
+ try {
1023
+ const value = await layer.get(normalizedKey);
1024
+ if (value !== null) {
1025
+ return true;
1026
+ }
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ }
1031
+ return false;
1032
+ }
1033
+ /**
1034
+ * Returns the remaining TTL in seconds for the key in the fastest layer
1035
+ * that has it, or null if the key is not found / has no TTL.
1036
+ */
1037
+ async ttl(key) {
1038
+ const normalizedKey = this.validateCacheKey(key);
1039
+ await this.startup;
1040
+ for (const layer of this.layers) {
1041
+ if (this.shouldSkipLayer(layer)) {
1042
+ continue;
1043
+ }
1044
+ if (layer.ttl) {
1045
+ try {
1046
+ const remaining = await layer.ttl(normalizedKey);
1047
+ if (remaining !== null) {
1048
+ return remaining;
1049
+ }
1050
+ } catch {
1051
+ }
1052
+ }
1053
+ }
1054
+ return null;
1055
+ }
1056
+ /**
1057
+ * Stores a value in all cache layers. Overwrites any existing value.
1058
+ */
626
1059
  async set(key, value, options) {
627
1060
  const normalizedKey = this.validateCacheKey(key);
628
1061
  this.validateWriteOptions(options);
629
1062
  await this.startup;
630
1063
  await this.storeEntry(normalizedKey, "value", value, options);
631
1064
  }
1065
+ /**
1066
+ * Deletes the key from all layers and publishes an invalidation message.
1067
+ */
632
1068
  async delete(key) {
633
1069
  const normalizedKey = this.validateCacheKey(key);
634
1070
  await this.startup;
635
1071
  await this.deleteKeys([normalizedKey]);
636
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
1072
+ await this.publishInvalidation({
1073
+ scope: "key",
1074
+ keys: [normalizedKey],
1075
+ sourceId: this.instanceId,
1076
+ operation: "delete"
1077
+ });
637
1078
  }
638
1079
  async clear() {
639
1080
  await this.startup;
640
1081
  await Promise.all(this.layers.map((layer) => layer.clear()));
641
1082
  await this.tagIndex.clear();
642
- this.accessProfiles.clear();
643
- this.metrics.invalidations += 1;
1083
+ this.ttlResolver.clearProfiles();
1084
+ this.circuitBreakerManager.clear();
1085
+ this.metricsCollector.increment("invalidations");
644
1086
  this.logger.debug?.("clear");
645
1087
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
646
1088
  }
1089
+ /**
1090
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
1091
+ */
1092
+ async mdelete(keys) {
1093
+ if (keys.length === 0) {
1094
+ return;
1095
+ }
1096
+ await this.startup;
1097
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1098
+ await this.deleteKeys(normalizedKeys);
1099
+ await this.publishInvalidation({
1100
+ scope: "keys",
1101
+ keys: normalizedKeys,
1102
+ sourceId: this.instanceId,
1103
+ operation: "delete"
1104
+ });
1105
+ }
647
1106
  async mget(entries) {
648
1107
  if (entries.length === 0) {
649
1108
  return [];
@@ -681,7 +1140,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
681
1140
  const indexesByKey = /* @__PURE__ */ new Map();
682
1141
  const resultsByKey = /* @__PURE__ */ new Map();
683
1142
  for (let index = 0; index < normalizedEntries.length; index += 1) {
684
- const key = normalizedEntries[index].key;
1143
+ const entry = normalizedEntries[index];
1144
+ if (!entry) continue;
1145
+ const key = entry.key;
685
1146
  const indexes = indexesByKey.get(key) ?? [];
686
1147
  indexes.push(index);
687
1148
  indexesByKey.set(key, indexes);
@@ -689,6 +1150,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
689
1150
  }
690
1151
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
691
1152
  const layer = this.layers[layerIndex];
1153
+ if (!layer) continue;
692
1154
  const keys = [...pending];
693
1155
  if (keys.length === 0) {
694
1156
  break;
@@ -697,7 +1159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
697
1159
  for (let offset = 0; offset < values.length; offset += 1) {
698
1160
  const key = keys[offset];
699
1161
  const stored = values[offset];
700
- if (stored === null) {
1162
+ if (!key || stored === null) {
701
1163
  continue;
702
1164
  }
703
1165
  const resolved = resolveStoredValue(stored);
@@ -709,13 +1171,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
709
1171
  await this.backfill(key, stored, layerIndex - 1);
710
1172
  resultsByKey.set(key, resolved.value);
711
1173
  pending.delete(key);
712
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
1174
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
713
1175
  }
714
1176
  }
715
1177
  if (pending.size > 0) {
716
1178
  for (const key of pending) {
717
1179
  await this.tagIndex.remove(key);
718
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
1180
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
719
1181
  }
720
1182
  }
721
1183
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -730,26 +1192,38 @@ var CacheStack = class extends import_node_events.EventEmitter {
730
1192
  }
731
1193
  async warm(entries, options = {}) {
732
1194
  const concurrency = Math.max(1, options.concurrency ?? 4);
1195
+ const total = entries.length;
1196
+ let completed = 0;
733
1197
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
734
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
1198
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
735
1199
  while (queue.length > 0) {
736
1200
  const entry = queue.shift();
737
1201
  if (!entry) {
738
1202
  return;
739
1203
  }
1204
+ let success = false;
740
1205
  try {
741
1206
  await this.get(entry.key, entry.fetcher, entry.options);
742
1207
  this.emit("warm", { key: entry.key });
1208
+ success = true;
743
1209
  } catch (error) {
744
1210
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
745
1211
  if (!options.continueOnError) {
746
1212
  throw error;
747
1213
  }
1214
+ } finally {
1215
+ completed += 1;
1216
+ const progress = { completed, total, key: entry.key, success };
1217
+ options.onProgress?.(progress);
748
1218
  }
749
1219
  }
750
1220
  });
751
1221
  await Promise.all(workers);
752
1222
  }
1223
+ /**
1224
+ * Returns a cached version of `fetcher`. The cache key is derived from
1225
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
1226
+ */
753
1227
  wrap(prefix, fetcher, options = {}) {
754
1228
  return (...args) => {
755
1229
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -757,6 +1231,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
757
1231
  return this.get(key, () => fetcher(...args), options);
758
1232
  };
759
1233
  }
1234
+ /**
1235
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
1236
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
1237
+ */
760
1238
  namespace(prefix) {
761
1239
  return new CacheNamespace(this, prefix);
762
1240
  }
@@ -773,7 +1251,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
773
1251
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
774
1252
  }
775
1253
  getMetrics() {
776
- return { ...this.metrics };
1254
+ return this.metricsCollector.snapshot;
777
1255
  }
778
1256
  getStats() {
779
1257
  return {
@@ -787,7 +1265,53 @@ var CacheStack = class extends import_node_events.EventEmitter {
787
1265
  };
788
1266
  }
789
1267
  resetMetrics() {
790
- Object.assign(this.metrics, EMPTY_METRICS());
1268
+ this.metricsCollector.reset();
1269
+ }
1270
+ /**
1271
+ * Returns computed hit-rate statistics (overall and per-layer).
1272
+ */
1273
+ getHitRate() {
1274
+ return this.metricsCollector.hitRate();
1275
+ }
1276
+ /**
1277
+ * Returns detailed metadata about a single cache key: which layers contain it,
1278
+ * remaining fresh/stale/error TTLs, and associated tags.
1279
+ * Returns `null` if the key does not exist in any layer.
1280
+ */
1281
+ async inspect(key) {
1282
+ const normalizedKey = this.validateCacheKey(key);
1283
+ await this.startup;
1284
+ const foundInLayers = [];
1285
+ let freshTtlSeconds = null;
1286
+ let staleTtlSeconds = null;
1287
+ let errorTtlSeconds = null;
1288
+ let isStale = false;
1289
+ for (const layer of this.layers) {
1290
+ if (this.shouldSkipLayer(layer)) {
1291
+ continue;
1292
+ }
1293
+ const stored = await this.readLayerEntry(layer, normalizedKey);
1294
+ if (stored === null) {
1295
+ continue;
1296
+ }
1297
+ const resolved = resolveStoredValue(stored);
1298
+ if (resolved.state === "expired") {
1299
+ continue;
1300
+ }
1301
+ foundInLayers.push(layer.name);
1302
+ if (foundInLayers.length === 1 && resolved.envelope) {
1303
+ const now = Date.now();
1304
+ freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
1305
+ staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1306
+ errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1307
+ isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1308
+ }
1309
+ }
1310
+ if (foundInLayers.length === 0) {
1311
+ return null;
1312
+ }
1313
+ const tags = await this.getTagsForKey(normalizedKey);
1314
+ return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
791
1315
  }
792
1316
  async exportState() {
793
1317
  await this.startup;
@@ -816,10 +1340,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
816
1340
  }
817
1341
  async importState(entries) {
818
1342
  await this.startup;
819
- await Promise.all(entries.map(async (entry) => {
820
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
821
- await this.tagIndex.touch(entry.key);
822
- }));
1343
+ await Promise.all(
1344
+ entries.map(async (entry) => {
1345
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1346
+ await this.tagIndex.touch(entry.key);
1347
+ })
1348
+ );
823
1349
  }
824
1350
  async persistToFile(filePath) {
825
1351
  const snapshot = await this.exportState();
@@ -827,11 +1353,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
827
1353
  }
828
1354
  async restoreFromFile(filePath) {
829
1355
  const raw = await import_node_fs.promises.readFile(filePath, "utf8");
830
- const snapshot = JSON.parse(raw);
831
- if (!this.isCacheSnapshotEntries(snapshot)) {
832
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
1356
+ let parsed;
1357
+ try {
1358
+ parsed = JSON.parse(raw, (_key, value) => {
1359
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1360
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1361
+ }
1362
+ return value;
1363
+ });
1364
+ } catch (cause) {
1365
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
833
1366
  }
834
- await this.importState(snapshot);
1367
+ if (!this.isCacheSnapshotEntries(parsed)) {
1368
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1369
+ }
1370
+ await this.importState(parsed);
835
1371
  }
836
1372
  async disconnect() {
837
1373
  if (!this.disconnectPromise) {
@@ -856,7 +1392,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
856
1392
  const fetchTask = async () => {
857
1393
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
858
1394
  if (secondHit.found) {
859
- this.metrics.hits += 1;
1395
+ this.metricsCollector.increment("hits");
860
1396
  return secondHit.value;
861
1397
  }
862
1398
  return this.fetchAndPopulate(key, fetcher, options);
@@ -881,12 +1417,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
881
1417
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
882
1418
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
883
1419
  const deadline = Date.now() + timeoutMs;
884
- this.metrics.singleFlightWaits += 1;
1420
+ this.metricsCollector.increment("singleFlightWaits");
885
1421
  this.emit("stampede-dedupe", { key });
886
1422
  while (Date.now() < deadline) {
887
1423
  const hit = await this.readFromLayers(key, options, "fresh-only");
888
1424
  if (hit.found) {
889
- this.metrics.hits += 1;
1425
+ this.metricsCollector.increment("hits");
890
1426
  return hit.value;
891
1427
  }
892
1428
  await this.sleep(pollIntervalMs);
@@ -894,12 +1430,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
894
1430
  return this.fetchAndPopulate(key, fetcher, options);
895
1431
  }
896
1432
  async fetchAndPopulate(key, fetcher, options) {
897
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
898
- this.metrics.fetches += 1;
1433
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1434
+ this.metricsCollector.increment("fetches");
1435
+ const fetchStart = Date.now();
899
1436
  let fetched;
900
1437
  try {
901
1438
  fetched = await fetcher();
902
- this.resetCircuitBreaker(key);
1439
+ this.circuitBreakerManager.recordSuccess(key);
1440
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
903
1441
  } catch (error) {
904
1442
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
905
1443
  throw error;
@@ -911,6 +1449,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
911
1449
  await this.storeEntry(key, "empty", null, options);
912
1450
  return null;
913
1451
  }
1452
+ if (options?.shouldCache && !options.shouldCache(fetched)) {
1453
+ return fetched;
1454
+ }
914
1455
  await this.storeEntry(key, "value", fetched, options);
915
1456
  return fetched;
916
1457
  }
@@ -921,7 +1462,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
921
1462
  } else {
922
1463
  await this.tagIndex.touch(key);
923
1464
  }
924
- this.metrics.sets += 1;
1465
+ this.metricsCollector.increment("sets");
925
1466
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
926
1467
  this.emit("set", { key, kind, tags: options?.tags });
927
1468
  if (this.shouldBroadcastL1Invalidation()) {
@@ -932,9 +1473,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
932
1473
  let sawRetainableValue = false;
933
1474
  for (let index = 0; index < this.layers.length; index += 1) {
934
1475
  const layer = this.layers[index];
1476
+ if (!layer) continue;
1477
+ const readStart = performance.now();
935
1478
  const stored = await this.readLayerEntry(layer, key);
1479
+ const readDuration = performance.now() - readStart;
1480
+ this.metricsCollector.recordLatency(layer.name, readDuration);
936
1481
  if (stored === null) {
937
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1482
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
938
1483
  continue;
939
1484
  }
940
1485
  const resolved = resolveStoredValue(stored);
@@ -948,10 +1493,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
948
1493
  }
949
1494
  await this.tagIndex.touch(key);
950
1495
  await this.backfill(key, stored, index - 1, options);
951
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1496
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
952
1497
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
953
1498
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
954
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1499
+ return {
1500
+ found: true,
1501
+ value: resolved.value,
1502
+ stored,
1503
+ state: resolved.state,
1504
+ layerIndex: index,
1505
+ layerName: layer.name
1506
+ };
955
1507
  }
956
1508
  if (!sawRetainableValue) {
957
1509
  await this.tagIndex.remove(key);
@@ -983,7 +1535,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
983
1535
  }
984
1536
  for (let index = 0; index <= upToIndex; index += 1) {
985
1537
  const layer = this.layers[index];
986
- if (this.shouldSkipLayer(layer)) {
1538
+ if (!layer || this.shouldSkipLayer(layer)) {
987
1539
  continue;
988
1540
  }
989
1541
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -993,7 +1545,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
993
1545
  await this.handleLayerFailure(layer, "backfill", error);
994
1546
  continue;
995
1547
  }
996
- this.metrics.backfills += 1;
1548
+ this.metricsCollector.increment("backfills");
997
1549
  this.logger.debug?.("backfill", { key, layer: layer.name });
998
1550
  this.emit("backfill", { key, layer: layer.name });
999
1551
  }
@@ -1010,11 +1562,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1010
1562
  options?.staleWhileRevalidate,
1011
1563
  this.options.staleWhileRevalidate
1012
1564
  );
1013
- const staleIfError = this.resolveLayerSeconds(
1014
- layer.name,
1015
- options?.staleIfError,
1016
- this.options.staleIfError
1017
- );
1565
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
1018
1566
  const payload = createStoredValueEnvelope({
1019
1567
  kind,
1020
1568
  value,
@@ -1042,7 +1590,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1042
1590
  if (failures.length === 0) {
1043
1591
  return;
1044
1592
  }
1045
- this.metrics.writeFailures += failures.length;
1593
+ this.metricsCollector.increment("writeFailures", failures.length);
1046
1594
  this.logger.debug?.("write-failure", {
1047
1595
  ...context,
1048
1596
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -1055,42 +1603,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
1055
1603
  }
1056
1604
  }
1057
1605
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1058
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
1059
- layerName,
1060
- options?.negativeTtl,
1061
- this.options.negativeTtl,
1062
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
1063
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
1064
- const adaptiveTtl = this.applyAdaptiveTtl(
1065
- key,
1066
- layerName,
1067
- baseTtl,
1068
- options?.adaptiveTtl ?? this.options.adaptiveTtl
1069
- );
1070
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
1071
- return this.applyJitter(adaptiveTtl, jitter);
1606
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
1072
1607
  }
1073
1608
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
1074
- if (override !== void 0) {
1075
- return this.readLayerNumber(layerName, override) ?? fallback;
1076
- }
1077
- if (globalDefault !== void 0) {
1078
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
1079
- }
1080
- return fallback;
1081
- }
1082
- readLayerNumber(layerName, value) {
1083
- if (typeof value === "number") {
1084
- return value;
1085
- }
1086
- return value[layerName];
1087
- }
1088
- applyJitter(ttl, jitter) {
1089
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
1090
- return ttl;
1091
- }
1092
- const delta = (Math.random() * 2 - 1) * jitter;
1093
- return Math.max(1, Math.round(ttl + delta));
1609
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
1094
1610
  }
1095
1611
  shouldNegativeCache(options) {
1096
1612
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -1100,11 +1616,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
1100
1616
  return;
1101
1617
  }
1102
1618
  const refresh = (async () => {
1103
- this.metrics.refreshes += 1;
1619
+ this.metricsCollector.increment("refreshes");
1104
1620
  try {
1105
1621
  await this.fetchWithGuards(key, fetcher, options);
1106
1622
  } catch (error) {
1107
- this.metrics.refreshErrors += 1;
1623
+ this.metricsCollector.increment("refreshErrors");
1108
1624
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
1109
1625
  } finally {
1110
1626
  this.backgroundRefreshes.delete(key);
@@ -1126,10 +1642,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
1126
1642
  await this.deleteKeysFromLayers(this.layers, keys);
1127
1643
  for (const key of keys) {
1128
1644
  await this.tagIndex.remove(key);
1129
- this.accessProfiles.delete(key);
1645
+ this.ttlResolver.deleteProfile(key);
1646
+ this.circuitBreakerManager.delete(key);
1130
1647
  }
1131
- this.metrics.deletes += keys.length;
1132
- this.metrics.invalidations += 1;
1648
+ this.metricsCollector.increment("deletes", keys.length);
1649
+ this.metricsCollector.increment("invalidations");
1133
1650
  this.logger.debug?.("delete", { keys });
1134
1651
  this.emit("delete", { keys });
1135
1652
  }
@@ -1150,7 +1667,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1150
1667
  if (message.scope === "clear") {
1151
1668
  await Promise.all(localLayers.map((layer) => layer.clear()));
1152
1669
  await this.tagIndex.clear();
1153
- this.accessProfiles.clear();
1670
+ this.ttlResolver.clearProfiles();
1154
1671
  return;
1155
1672
  }
1156
1673
  const keys = message.keys ?? [];
@@ -1158,10 +1675,16 @@ var CacheStack = class extends import_node_events.EventEmitter {
1158
1675
  if (message.operation !== "write") {
1159
1676
  for (const key of keys) {
1160
1677
  await this.tagIndex.remove(key);
1161
- this.accessProfiles.delete(key);
1678
+ this.ttlResolver.deleteProfile(key);
1162
1679
  }
1163
1680
  }
1164
1681
  }
1682
+ async getTagsForKey(key) {
1683
+ if (this.tagIndex.tagsForKey) {
1684
+ return this.tagIndex.tagsForKey(key);
1685
+ }
1686
+ return [];
1687
+ }
1165
1688
  formatError(error) {
1166
1689
  if (error instanceof Error) {
1167
1690
  return error.message;
@@ -1188,13 +1711,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
1188
1711
  }
1189
1712
  return;
1190
1713
  }
1191
- await Promise.all(keys.map(async (key) => {
1192
- try {
1193
- await layer.delete(key);
1194
- } catch (error) {
1195
- await this.handleLayerFailure(layer, "delete", error);
1196
- }
1197
- }));
1714
+ await Promise.all(
1715
+ keys.map(async (key) => {
1716
+ try {
1717
+ await layer.delete(key);
1718
+ } catch (error) {
1719
+ await this.handleLayerFailure(layer, "delete", error);
1720
+ }
1721
+ })
1722
+ );
1198
1723
  })
1199
1724
  );
1200
1725
  }
@@ -1295,7 +1820,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1295
1820
  const ttl = remainingStoredTtlSeconds(refreshed);
1296
1821
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1297
1822
  const layer = this.layers[index];
1298
- if (this.shouldSkipLayer(layer)) {
1823
+ if (!layer || this.shouldSkipLayer(layer)) {
1299
1824
  continue;
1300
1825
  }
1301
1826
  try {
@@ -1309,33 +1834,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
1309
1834
  this.scheduleBackgroundRefresh(key, fetcher, options);
1310
1835
  }
1311
1836
  }
1312
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1313
- if (!ttl || !adaptiveTtl) {
1314
- return ttl;
1315
- }
1316
- const profile = this.accessProfiles.get(key);
1317
- if (!profile) {
1318
- return ttl;
1319
- }
1320
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1321
- const hotAfter = config.hotAfter ?? 3;
1322
- if (profile.hits < hotAfter) {
1323
- return ttl;
1324
- }
1325
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1326
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1327
- const multiplier = Math.floor(profile.hits / hotAfter);
1328
- return Math.min(maxTtl, ttl + step * multiplier);
1329
- }
1330
- recordAccess(key) {
1331
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1332
- profile.hits += 1;
1333
- profile.lastAccessAt = Date.now();
1334
- this.accessProfiles.set(key, profile);
1335
- }
1336
- incrementMetricMap(target, key) {
1337
- target[key] = (target[key] ?? 0) + 1;
1338
- }
1339
1837
  shouldSkipLayer(layer) {
1340
1838
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
1341
1839
  return degradedUntil !== void 0 && degradedUntil > Date.now();
@@ -1346,7 +1844,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1346
1844
  }
1347
1845
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1348
1846
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1349
- this.metrics.degradedOperations += 1;
1847
+ this.metricsCollector.increment("degradedOperations");
1350
1848
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1351
1849
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1352
1850
  return null;
@@ -1354,37 +1852,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
1354
1852
  isGracefulDegradationEnabled() {
1355
1853
  return Boolean(this.options.gracefulDegradation);
1356
1854
  }
1357
- assertCircuitClosed(key, options) {
1358
- const state = this.circuitBreakers.get(key);
1359
- if (!state?.openUntil) {
1360
- return;
1361
- }
1362
- if (state.openUntil <= Date.now()) {
1363
- state.openUntil = null;
1364
- state.failures = 0;
1365
- this.circuitBreakers.set(key, state);
1366
- return;
1367
- }
1368
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1369
- throw new Error(`Circuit breaker is open for key "${key}".`);
1370
- }
1371
1855
  recordCircuitFailure(key, options, error) {
1372
1856
  if (!options) {
1373
1857
  return;
1374
1858
  }
1375
- const failureThreshold = options.failureThreshold ?? 3;
1376
- const cooldownMs = options.cooldownMs ?? 3e4;
1377
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1378
- state.failures += 1;
1379
- if (state.failures >= failureThreshold) {
1380
- state.openUntil = Date.now() + cooldownMs;
1381
- this.metrics.circuitBreakerTrips += 1;
1859
+ this.circuitBreakerManager.recordFailure(key, options);
1860
+ if (this.circuitBreakerManager.isOpen(key)) {
1861
+ this.metricsCollector.increment("circuitBreakerTrips");
1382
1862
  }
1383
- this.circuitBreakers.set(key, state);
1384
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1385
- }
1386
- resetCircuitBreaker(key) {
1387
- this.circuitBreakers.delete(key);
1863
+ this.emitError("fetch", { key, error: this.formatError(error) });
1388
1864
  }
1389
1865
  isNegativeStoredValue(stored) {
1390
1866
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
@@ -1439,6 +1915,22 @@ var CacheStackModule = class {
1439
1915
  exports: [provider]
1440
1916
  };
1441
1917
  }
1918
+ static forRootAsync(options) {
1919
+ const provider = {
1920
+ provide: CACHE_STACK,
1921
+ inject: options.inject ?? [],
1922
+ useFactory: async (...args) => {
1923
+ const resolved = await options.useFactory(...args);
1924
+ return new CacheStack(resolved.layers, resolved.bridgeOptions);
1925
+ }
1926
+ };
1927
+ return {
1928
+ global: true,
1929
+ module: CacheStackModule,
1930
+ providers: [provider],
1931
+ exports: [provider]
1932
+ };
1933
+ }
1442
1934
  };
1443
1935
  CacheStackModule = __decorateClass([
1444
1936
  (0, import_common.Global)(),