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.
package/dist/index.js CHANGED
@@ -1,12 +1,279 @@
1
1
  import {
2
2
  PatternMatcher,
3
3
  RedisTagIndex
4
- } from "./chunk-IILH5XTS.js";
4
+ } from "./chunk-BWM4MU2X.js";
5
5
 
6
6
  // src/CacheStack.ts
7
7
  import { randomUUID } from "crypto";
8
- import { promises as fs } from "fs";
9
8
  import { EventEmitter } from "events";
9
+ import { promises as fs } from "fs";
10
+
11
+ // src/CacheNamespace.ts
12
+ var CacheNamespace = class _CacheNamespace {
13
+ constructor(cache, prefix) {
14
+ this.cache = cache;
15
+ this.prefix = prefix;
16
+ }
17
+ cache;
18
+ prefix;
19
+ async get(key, fetcher, options) {
20
+ return this.cache.get(this.qualify(key), fetcher, options);
21
+ }
22
+ async getOrSet(key, fetcher, options) {
23
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
24
+ }
25
+ /**
26
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
27
+ */
28
+ async getOrThrow(key, fetcher, options) {
29
+ return this.cache.getOrThrow(this.qualify(key), fetcher, options);
30
+ }
31
+ async has(key) {
32
+ return this.cache.has(this.qualify(key));
33
+ }
34
+ async ttl(key) {
35
+ return this.cache.ttl(this.qualify(key));
36
+ }
37
+ async set(key, value, options) {
38
+ await this.cache.set(this.qualify(key), value, options);
39
+ }
40
+ async delete(key) {
41
+ await this.cache.delete(this.qualify(key));
42
+ }
43
+ async mdelete(keys) {
44
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
45
+ }
46
+ async clear() {
47
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
48
+ }
49
+ async mget(entries) {
50
+ return this.cache.mget(
51
+ entries.map((entry) => ({
52
+ ...entry,
53
+ key: this.qualify(entry.key)
54
+ }))
55
+ );
56
+ }
57
+ async mset(entries) {
58
+ await this.cache.mset(
59
+ entries.map((entry) => ({
60
+ ...entry,
61
+ key: this.qualify(entry.key)
62
+ }))
63
+ );
64
+ }
65
+ async invalidateByTag(tag) {
66
+ await this.cache.invalidateByTag(tag);
67
+ }
68
+ async invalidateByPattern(pattern) {
69
+ await this.cache.invalidateByPattern(this.qualify(pattern));
70
+ }
71
+ /**
72
+ * Returns detailed metadata about a single cache key within this namespace.
73
+ */
74
+ async inspect(key) {
75
+ return this.cache.inspect(this.qualify(key));
76
+ }
77
+ wrap(keyPrefix, fetcher, options) {
78
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
79
+ }
80
+ warm(entries, options) {
81
+ return this.cache.warm(
82
+ entries.map((entry) => ({
83
+ ...entry,
84
+ key: this.qualify(entry.key)
85
+ })),
86
+ options
87
+ );
88
+ }
89
+ getMetrics() {
90
+ return this.cache.getMetrics();
91
+ }
92
+ getHitRate() {
93
+ return this.cache.getHitRate();
94
+ }
95
+ /**
96
+ * Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
97
+ *
98
+ * ```ts
99
+ * const tenant = cache.namespace('tenant:abc')
100
+ * const posts = tenant.namespace('posts')
101
+ * // keys become: "tenant:abc:posts:mykey"
102
+ * ```
103
+ */
104
+ namespace(childPrefix) {
105
+ return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
106
+ }
107
+ qualify(key) {
108
+ return `${this.prefix}:${key}`;
109
+ }
110
+ };
111
+
112
+ // src/internal/CircuitBreakerManager.ts
113
+ var CircuitBreakerManager = class {
114
+ breakers = /* @__PURE__ */ new Map();
115
+ maxEntries;
116
+ constructor(options) {
117
+ this.maxEntries = options.maxEntries;
118
+ }
119
+ /**
120
+ * Throws if the circuit is open for the given key.
121
+ * Automatically resets if the cooldown has elapsed.
122
+ */
123
+ assertClosed(key, options) {
124
+ const state = this.breakers.get(key);
125
+ if (!state?.openUntil) {
126
+ return;
127
+ }
128
+ const now = Date.now();
129
+ if (state.openUntil <= now) {
130
+ state.openUntil = null;
131
+ state.failures = 0;
132
+ this.breakers.set(key, state);
133
+ return;
134
+ }
135
+ const remainingMs = state.openUntil - now;
136
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
137
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
138
+ }
139
+ recordFailure(key, options) {
140
+ if (!options) {
141
+ return;
142
+ }
143
+ const failureThreshold = options.failureThreshold ?? 3;
144
+ const cooldownMs = options.cooldownMs ?? 3e4;
145
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
146
+ state.failures += 1;
147
+ if (state.failures >= failureThreshold) {
148
+ state.openUntil = Date.now() + cooldownMs;
149
+ }
150
+ this.breakers.set(key, state);
151
+ this.pruneIfNeeded();
152
+ }
153
+ recordSuccess(key) {
154
+ this.breakers.delete(key);
155
+ }
156
+ isOpen(key) {
157
+ const state = this.breakers.get(key);
158
+ if (!state?.openUntil) {
159
+ return false;
160
+ }
161
+ if (state.openUntil <= Date.now()) {
162
+ state.openUntil = null;
163
+ state.failures = 0;
164
+ return false;
165
+ }
166
+ return true;
167
+ }
168
+ delete(key) {
169
+ this.breakers.delete(key);
170
+ }
171
+ clear() {
172
+ this.breakers.clear();
173
+ }
174
+ tripCount() {
175
+ let count = 0;
176
+ for (const state of this.breakers.values()) {
177
+ if (state.openUntil !== null) {
178
+ count += 1;
179
+ }
180
+ }
181
+ return count;
182
+ }
183
+ pruneIfNeeded() {
184
+ if (this.breakers.size <= this.maxEntries) {
185
+ return;
186
+ }
187
+ for (const [key, state] of this.breakers.entries()) {
188
+ if (this.breakers.size <= this.maxEntries) {
189
+ break;
190
+ }
191
+ if (!state.openUntil || state.openUntil <= Date.now()) {
192
+ this.breakers.delete(key);
193
+ }
194
+ }
195
+ for (const key of this.breakers.keys()) {
196
+ if (this.breakers.size <= this.maxEntries) {
197
+ break;
198
+ }
199
+ this.breakers.delete(key);
200
+ }
201
+ }
202
+ };
203
+
204
+ // src/internal/MetricsCollector.ts
205
+ var MetricsCollector = class {
206
+ data = this.empty();
207
+ get snapshot() {
208
+ return {
209
+ ...this.data,
210
+ hitsByLayer: { ...this.data.hitsByLayer },
211
+ missesByLayer: { ...this.data.missesByLayer },
212
+ latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
213
+ };
214
+ }
215
+ increment(field, amount = 1) {
216
+ ;
217
+ this.data[field] += amount;
218
+ }
219
+ incrementLayer(map, layerName) {
220
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
221
+ }
222
+ /**
223
+ * Records a read latency sample for the given layer.
224
+ * Maintains a rolling average and max using Welford's online algorithm.
225
+ */
226
+ recordLatency(layerName, durationMs) {
227
+ const existing = this.data.latencyByLayer[layerName];
228
+ if (!existing) {
229
+ this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
230
+ return;
231
+ }
232
+ existing.count += 1;
233
+ existing.avgMs += (durationMs - existing.avgMs) / existing.count;
234
+ if (durationMs > existing.maxMs) {
235
+ existing.maxMs = durationMs;
236
+ }
237
+ }
238
+ reset() {
239
+ this.data = this.empty();
240
+ }
241
+ hitRate() {
242
+ const total = this.data.hits + this.data.misses;
243
+ const overall = total === 0 ? 0 : this.data.hits / total;
244
+ const byLayer = {};
245
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
246
+ for (const layer of allLayers) {
247
+ const h = this.data.hitsByLayer[layer] ?? 0;
248
+ const m = this.data.missesByLayer[layer] ?? 0;
249
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
250
+ }
251
+ return { overall, byLayer };
252
+ }
253
+ empty() {
254
+ return {
255
+ hits: 0,
256
+ misses: 0,
257
+ fetches: 0,
258
+ sets: 0,
259
+ deletes: 0,
260
+ backfills: 0,
261
+ invalidations: 0,
262
+ staleHits: 0,
263
+ refreshes: 0,
264
+ refreshErrors: 0,
265
+ writeFailures: 0,
266
+ singleFlightWaits: 0,
267
+ negativeCacheHits: 0,
268
+ circuitBreakerTrips: 0,
269
+ degradedOperations: 0,
270
+ hitsByLayer: {},
271
+ missesByLayer: {},
272
+ latencyByLayer: {},
273
+ resetAt: Date.now()
274
+ };
275
+ }
276
+ };
10
277
 
11
278
  // src/internal/StoredValue.ts
12
279
  function isStoredValueEnvelope(value) {
@@ -109,58 +376,91 @@ function normalizePositiveSeconds(value) {
109
376
  return value;
110
377
  }
111
378
 
112
- // src/CacheNamespace.ts
113
- var CacheNamespace = class {
114
- constructor(cache, prefix) {
115
- this.cache = cache;
116
- this.prefix = prefix;
117
- }
118
- cache;
119
- prefix;
120
- async get(key, fetcher, options) {
121
- return this.cache.get(this.qualify(key), fetcher, options);
122
- }
123
- async set(key, value, options) {
124
- await this.cache.set(this.qualify(key), value, options);
125
- }
126
- async delete(key) {
127
- await this.cache.delete(this.qualify(key));
379
+ // src/internal/TtlResolver.ts
380
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
381
+ var TtlResolver = class {
382
+ accessProfiles = /* @__PURE__ */ new Map();
383
+ maxProfileEntries;
384
+ constructor(options) {
385
+ this.maxProfileEntries = options.maxProfileEntries;
128
386
  }
129
- async clear() {
130
- await this.cache.invalidateByPattern(`${this.prefix}:*`);
387
+ recordAccess(key) {
388
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
389
+ profile.hits += 1;
390
+ profile.lastAccessAt = Date.now();
391
+ this.accessProfiles.set(key, profile);
392
+ this.pruneIfNeeded();
131
393
  }
132
- async mget(entries) {
133
- return this.cache.mget(entries.map((entry) => ({
134
- ...entry,
135
- key: this.qualify(entry.key)
136
- })));
394
+ deleteProfile(key) {
395
+ this.accessProfiles.delete(key);
137
396
  }
138
- async mset(entries) {
139
- await this.cache.mset(entries.map((entry) => ({
140
- ...entry,
141
- key: this.qualify(entry.key)
142
- })));
397
+ clearProfiles() {
398
+ this.accessProfiles.clear();
143
399
  }
144
- async invalidateByTag(tag) {
145
- await this.cache.invalidateByTag(tag);
400
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
401
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
402
+ layerName,
403
+ options?.negativeTtl,
404
+ globalNegativeTtl,
405
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
406
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
407
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
408
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
409
+ return this.applyJitter(adaptiveTtl, jitter);
146
410
  }
147
- async invalidateByPattern(pattern) {
148
- await this.cache.invalidateByPattern(this.qualify(pattern));
411
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
412
+ if (override !== void 0) {
413
+ return this.readLayerNumber(layerName, override) ?? fallback;
414
+ }
415
+ if (globalDefault !== void 0) {
416
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
417
+ }
418
+ return fallback;
149
419
  }
150
- wrap(keyPrefix, fetcher, options) {
151
- return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
420
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
421
+ if (!ttl || !adaptiveTtl) {
422
+ return ttl;
423
+ }
424
+ const profile = this.accessProfiles.get(key);
425
+ if (!profile) {
426
+ return ttl;
427
+ }
428
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
429
+ const hotAfter = config.hotAfter ?? 3;
430
+ if (profile.hits < hotAfter) {
431
+ return ttl;
432
+ }
433
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
434
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
435
+ const multiplier = Math.floor(profile.hits / hotAfter);
436
+ return Math.min(maxTtl, ttl + step * multiplier);
152
437
  }
153
- warm(entries, options) {
154
- return this.cache.warm(entries.map((entry) => ({
155
- ...entry,
156
- key: this.qualify(entry.key)
157
- })), options);
438
+ applyJitter(ttl, jitter) {
439
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
440
+ return ttl;
441
+ }
442
+ const delta = (Math.random() * 2 - 1) * jitter;
443
+ return Math.max(1, Math.round(ttl + delta));
158
444
  }
159
- getMetrics() {
160
- return this.cache.getMetrics();
445
+ readLayerNumber(layerName, value) {
446
+ if (typeof value === "number") {
447
+ return value;
448
+ }
449
+ return value[layerName];
161
450
  }
162
- qualify(key) {
163
- return `${this.prefix}:${key}`;
451
+ pruneIfNeeded() {
452
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
453
+ return;
454
+ }
455
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
456
+ let removed = 0;
457
+ for (const key of this.accessProfiles.keys()) {
458
+ if (removed >= toRemove) {
459
+ break;
460
+ }
461
+ this.accessProfiles.delete(key);
462
+ removed += 1;
463
+ }
164
464
  }
165
465
  };
166
466
 
@@ -169,11 +469,17 @@ var TagIndex = class {
169
469
  tagToKeys = /* @__PURE__ */ new Map();
170
470
  keyToTags = /* @__PURE__ */ new Map();
171
471
  knownKeys = /* @__PURE__ */ new Set();
472
+ maxKnownKeys;
473
+ constructor(options = {}) {
474
+ this.maxKnownKeys = options.maxKnownKeys;
475
+ }
172
476
  async touch(key) {
173
477
  this.knownKeys.add(key);
478
+ this.pruneKnownKeysIfNeeded();
174
479
  }
175
480
  async track(key, tags) {
176
481
  this.knownKeys.add(key);
482
+ this.pruneKnownKeysIfNeeded();
177
483
  if (tags.length === 0) {
178
484
  return;
179
485
  }
@@ -212,6 +518,9 @@ var TagIndex = class {
212
518
  async keysForTag(tag) {
213
519
  return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
214
520
  }
521
+ async tagsForKey(key) {
522
+ return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
523
+ }
215
524
  async matchPattern(pattern) {
216
525
  return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
217
526
  }
@@ -220,6 +529,21 @@ var TagIndex = class {
220
529
  this.keyToTags.clear();
221
530
  this.knownKeys.clear();
222
531
  }
532
+ pruneKnownKeysIfNeeded() {
533
+ if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
534
+ return;
535
+ }
536
+ const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
537
+ let removed = 0;
538
+ for (const key of this.knownKeys) {
539
+ if (removed >= toRemove) {
540
+ break;
541
+ }
542
+ this.knownKeys.delete(key);
543
+ this.keyToTags.delete(key);
544
+ removed += 1;
545
+ }
546
+ }
223
547
  };
224
548
 
225
549
  // src/stampede/StampedeGuard.ts
@@ -248,31 +572,22 @@ var StampedeGuard = class {
248
572
  }
249
573
  };
250
574
 
575
+ // src/types.ts
576
+ var CacheMissError = class extends Error {
577
+ key;
578
+ constructor(key) {
579
+ super(`Cache miss for key "${key}".`);
580
+ this.name = "CacheMissError";
581
+ this.key = key;
582
+ }
583
+ };
584
+
251
585
  // src/CacheStack.ts
252
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
253
586
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
254
587
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
255
588
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
256
589
  var MAX_CACHE_KEY_LENGTH = 1024;
257
- var EMPTY_METRICS = () => ({
258
- hits: 0,
259
- misses: 0,
260
- fetches: 0,
261
- sets: 0,
262
- deletes: 0,
263
- backfills: 0,
264
- invalidations: 0,
265
- staleHits: 0,
266
- refreshes: 0,
267
- refreshErrors: 0,
268
- writeFailures: 0,
269
- singleFlightWaits: 0,
270
- negativeCacheHits: 0,
271
- circuitBreakerTrips: 0,
272
- degradedOperations: 0,
273
- hitsByLayer: {},
274
- missesByLayer: {}
275
- });
590
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
276
591
  var DebugLogger = class {
277
592
  enabled;
278
593
  constructor(enabled) {
@@ -307,6 +622,14 @@ var CacheStack = class extends EventEmitter {
307
622
  throw new Error("CacheStack requires at least one cache layer.");
308
623
  }
309
624
  this.validateConfiguration();
625
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
626
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
627
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
628
+ if (options.publishSetInvalidation !== void 0) {
629
+ console.warn(
630
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
631
+ );
632
+ }
310
633
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
311
634
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
312
635
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -315,36 +638,42 @@ var CacheStack = class extends EventEmitter {
315
638
  layers;
316
639
  options;
317
640
  stampedeGuard = new StampedeGuard();
318
- metrics = EMPTY_METRICS();
641
+ metricsCollector = new MetricsCollector();
319
642
  instanceId = randomUUID();
320
643
  startup;
321
644
  unsubscribeInvalidation;
322
645
  logger;
323
646
  tagIndex;
324
647
  backgroundRefreshes = /* @__PURE__ */ new Map();
325
- accessProfiles = /* @__PURE__ */ new Map();
326
648
  layerDegradedUntil = /* @__PURE__ */ new Map();
327
- circuitBreakers = /* @__PURE__ */ new Map();
649
+ ttlResolver;
650
+ circuitBreakerManager;
328
651
  isDisconnecting = false;
329
652
  disconnectPromise;
653
+ /**
654
+ * Read-through cache get.
655
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
656
+ * and stores the result across all layers. Returns `null` if the key is not found
657
+ * and no `fetcher` is provided.
658
+ */
330
659
  async get(key, fetcher, options) {
331
660
  const normalizedKey = this.validateCacheKey(key);
332
661
  this.validateWriteOptions(options);
333
662
  await this.startup;
334
663
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
335
664
  if (hit.found) {
336
- this.recordAccess(normalizedKey);
665
+ this.ttlResolver.recordAccess(normalizedKey);
337
666
  if (this.isNegativeStoredValue(hit.stored)) {
338
- this.metrics.negativeCacheHits += 1;
667
+ this.metricsCollector.increment("negativeCacheHits");
339
668
  }
340
669
  if (hit.state === "fresh") {
341
- this.metrics.hits += 1;
670
+ this.metricsCollector.increment("hits");
342
671
  await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
343
672
  return hit.value;
344
673
  }
345
674
  if (hit.state === "stale-while-revalidate") {
346
- this.metrics.hits += 1;
347
- this.metrics.staleHits += 1;
675
+ this.metricsCollector.increment("hits");
676
+ this.metricsCollector.increment("staleHits");
348
677
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
349
678
  if (fetcher) {
350
679
  this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
@@ -352,47 +681,148 @@ var CacheStack = class extends EventEmitter {
352
681
  return hit.value;
353
682
  }
354
683
  if (!fetcher) {
355
- this.metrics.hits += 1;
356
- this.metrics.staleHits += 1;
684
+ this.metricsCollector.increment("hits");
685
+ this.metricsCollector.increment("staleHits");
357
686
  this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
358
687
  return hit.value;
359
688
  }
360
689
  try {
361
690
  return await this.fetchWithGuards(normalizedKey, fetcher, options);
362
691
  } catch (error) {
363
- this.metrics.staleHits += 1;
364
- this.metrics.refreshErrors += 1;
692
+ this.metricsCollector.increment("staleHits");
693
+ this.metricsCollector.increment("refreshErrors");
365
694
  this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
366
695
  return hit.value;
367
696
  }
368
697
  }
369
- this.metrics.misses += 1;
698
+ this.metricsCollector.increment("misses");
370
699
  if (!fetcher) {
371
700
  return null;
372
701
  }
373
702
  return this.fetchWithGuards(normalizedKey, fetcher, options);
374
703
  }
704
+ /**
705
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
706
+ * Fetches and caches the value if not already present.
707
+ */
708
+ async getOrSet(key, fetcher, options) {
709
+ return this.get(key, fetcher, options);
710
+ }
711
+ /**
712
+ * Like `get()`, but throws `CacheMissError` instead of returning `null`.
713
+ * Useful when the value is expected to exist or the fetcher is expected to
714
+ * return non-null.
715
+ */
716
+ async getOrThrow(key, fetcher, options) {
717
+ const value = await this.get(key, fetcher, options);
718
+ if (value === null) {
719
+ throw new CacheMissError(key);
720
+ }
721
+ return value;
722
+ }
723
+ /**
724
+ * Returns true if the given key exists and is not expired in any layer.
725
+ */
726
+ async has(key) {
727
+ const normalizedKey = this.validateCacheKey(key);
728
+ await this.startup;
729
+ for (const layer of this.layers) {
730
+ if (this.shouldSkipLayer(layer)) {
731
+ continue;
732
+ }
733
+ if (layer.has) {
734
+ try {
735
+ const exists = await layer.has(normalizedKey);
736
+ if (exists) {
737
+ return true;
738
+ }
739
+ } catch {
740
+ }
741
+ } else {
742
+ try {
743
+ const value = await layer.get(normalizedKey);
744
+ if (value !== null) {
745
+ return true;
746
+ }
747
+ } catch {
748
+ }
749
+ }
750
+ }
751
+ return false;
752
+ }
753
+ /**
754
+ * Returns the remaining TTL in seconds for the key in the fastest layer
755
+ * that has it, or null if the key is not found / has no TTL.
756
+ */
757
+ async ttl(key) {
758
+ const normalizedKey = this.validateCacheKey(key);
759
+ await this.startup;
760
+ for (const layer of this.layers) {
761
+ if (this.shouldSkipLayer(layer)) {
762
+ continue;
763
+ }
764
+ if (layer.ttl) {
765
+ try {
766
+ const remaining = await layer.ttl(normalizedKey);
767
+ if (remaining !== null) {
768
+ return remaining;
769
+ }
770
+ } catch {
771
+ }
772
+ }
773
+ }
774
+ return null;
775
+ }
776
+ /**
777
+ * Stores a value in all cache layers. Overwrites any existing value.
778
+ */
375
779
  async set(key, value, options) {
376
780
  const normalizedKey = this.validateCacheKey(key);
377
781
  this.validateWriteOptions(options);
378
782
  await this.startup;
379
783
  await this.storeEntry(normalizedKey, "value", value, options);
380
784
  }
785
+ /**
786
+ * Deletes the key from all layers and publishes an invalidation message.
787
+ */
381
788
  async delete(key) {
382
789
  const normalizedKey = this.validateCacheKey(key);
383
790
  await this.startup;
384
791
  await this.deleteKeys([normalizedKey]);
385
- await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
792
+ await this.publishInvalidation({
793
+ scope: "key",
794
+ keys: [normalizedKey],
795
+ sourceId: this.instanceId,
796
+ operation: "delete"
797
+ });
386
798
  }
387
799
  async clear() {
388
800
  await this.startup;
389
801
  await Promise.all(this.layers.map((layer) => layer.clear()));
390
802
  await this.tagIndex.clear();
391
- this.accessProfiles.clear();
392
- this.metrics.invalidations += 1;
803
+ this.ttlResolver.clearProfiles();
804
+ this.circuitBreakerManager.clear();
805
+ this.metricsCollector.increment("invalidations");
393
806
  this.logger.debug?.("clear");
394
807
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
395
808
  }
809
+ /**
810
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
811
+ */
812
+ async mdelete(keys) {
813
+ if (keys.length === 0) {
814
+ return;
815
+ }
816
+ await this.startup;
817
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
818
+ await this.deleteKeys(normalizedKeys);
819
+ await this.publishInvalidation({
820
+ scope: "keys",
821
+ keys: normalizedKeys,
822
+ sourceId: this.instanceId,
823
+ operation: "delete"
824
+ });
825
+ }
396
826
  async mget(entries) {
397
827
  if (entries.length === 0) {
398
828
  return [];
@@ -430,7 +860,9 @@ var CacheStack = class extends EventEmitter {
430
860
  const indexesByKey = /* @__PURE__ */ new Map();
431
861
  const resultsByKey = /* @__PURE__ */ new Map();
432
862
  for (let index = 0; index < normalizedEntries.length; index += 1) {
433
- const key = normalizedEntries[index].key;
863
+ const entry = normalizedEntries[index];
864
+ if (!entry) continue;
865
+ const key = entry.key;
434
866
  const indexes = indexesByKey.get(key) ?? [];
435
867
  indexes.push(index);
436
868
  indexesByKey.set(key, indexes);
@@ -438,6 +870,7 @@ var CacheStack = class extends EventEmitter {
438
870
  }
439
871
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
440
872
  const layer = this.layers[layerIndex];
873
+ if (!layer) continue;
441
874
  const keys = [...pending];
442
875
  if (keys.length === 0) {
443
876
  break;
@@ -446,7 +879,7 @@ var CacheStack = class extends EventEmitter {
446
879
  for (let offset = 0; offset < values.length; offset += 1) {
447
880
  const key = keys[offset];
448
881
  const stored = values[offset];
449
- if (stored === null) {
882
+ if (!key || stored === null) {
450
883
  continue;
451
884
  }
452
885
  const resolved = resolveStoredValue(stored);
@@ -458,13 +891,13 @@ var CacheStack = class extends EventEmitter {
458
891
  await this.backfill(key, stored, layerIndex - 1);
459
892
  resultsByKey.set(key, resolved.value);
460
893
  pending.delete(key);
461
- this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
894
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
462
895
  }
463
896
  }
464
897
  if (pending.size > 0) {
465
898
  for (const key of pending) {
466
899
  await this.tagIndex.remove(key);
467
- this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
900
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
468
901
  }
469
902
  }
470
903
  return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
@@ -479,26 +912,38 @@ var CacheStack = class extends EventEmitter {
479
912
  }
480
913
  async warm(entries, options = {}) {
481
914
  const concurrency = Math.max(1, options.concurrency ?? 4);
915
+ const total = entries.length;
916
+ let completed = 0;
482
917
  const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
483
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
918
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
484
919
  while (queue.length > 0) {
485
920
  const entry = queue.shift();
486
921
  if (!entry) {
487
922
  return;
488
923
  }
924
+ let success = false;
489
925
  try {
490
926
  await this.get(entry.key, entry.fetcher, entry.options);
491
927
  this.emit("warm", { key: entry.key });
928
+ success = true;
492
929
  } catch (error) {
493
930
  this.emitError("warm", { key: entry.key, error: this.formatError(error) });
494
931
  if (!options.continueOnError) {
495
932
  throw error;
496
933
  }
934
+ } finally {
935
+ completed += 1;
936
+ const progress = { completed, total, key: entry.key, success };
937
+ options.onProgress?.(progress);
497
938
  }
498
939
  }
499
940
  });
500
941
  await Promise.all(workers);
501
942
  }
943
+ /**
944
+ * Returns a cached version of `fetcher`. The cache key is derived from
945
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
946
+ */
502
947
  wrap(prefix, fetcher, options = {}) {
503
948
  return (...args) => {
504
949
  const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
@@ -506,6 +951,10 @@ var CacheStack = class extends EventEmitter {
506
951
  return this.get(key, () => fetcher(...args), options);
507
952
  };
508
953
  }
954
+ /**
955
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
956
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
957
+ */
509
958
  namespace(prefix) {
510
959
  return new CacheNamespace(this, prefix);
511
960
  }
@@ -522,7 +971,7 @@ var CacheStack = class extends EventEmitter {
522
971
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
523
972
  }
524
973
  getMetrics() {
525
- return { ...this.metrics };
974
+ return this.metricsCollector.snapshot;
526
975
  }
527
976
  getStats() {
528
977
  return {
@@ -536,7 +985,53 @@ var CacheStack = class extends EventEmitter {
536
985
  };
537
986
  }
538
987
  resetMetrics() {
539
- Object.assign(this.metrics, EMPTY_METRICS());
988
+ this.metricsCollector.reset();
989
+ }
990
+ /**
991
+ * Returns computed hit-rate statistics (overall and per-layer).
992
+ */
993
+ getHitRate() {
994
+ return this.metricsCollector.hitRate();
995
+ }
996
+ /**
997
+ * Returns detailed metadata about a single cache key: which layers contain it,
998
+ * remaining fresh/stale/error TTLs, and associated tags.
999
+ * Returns `null` if the key does not exist in any layer.
1000
+ */
1001
+ async inspect(key) {
1002
+ const normalizedKey = this.validateCacheKey(key);
1003
+ await this.startup;
1004
+ const foundInLayers = [];
1005
+ let freshTtlSeconds = null;
1006
+ let staleTtlSeconds = null;
1007
+ let errorTtlSeconds = null;
1008
+ let isStale = false;
1009
+ for (const layer of this.layers) {
1010
+ if (this.shouldSkipLayer(layer)) {
1011
+ continue;
1012
+ }
1013
+ const stored = await this.readLayerEntry(layer, normalizedKey);
1014
+ if (stored === null) {
1015
+ continue;
1016
+ }
1017
+ const resolved = resolveStoredValue(stored);
1018
+ if (resolved.state === "expired") {
1019
+ continue;
1020
+ }
1021
+ foundInLayers.push(layer.name);
1022
+ if (foundInLayers.length === 1 && resolved.envelope) {
1023
+ const now = Date.now();
1024
+ freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
1025
+ staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1026
+ errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1027
+ isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1028
+ }
1029
+ }
1030
+ if (foundInLayers.length === 0) {
1031
+ return null;
1032
+ }
1033
+ const tags = await this.getTagsForKey(normalizedKey);
1034
+ return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
540
1035
  }
541
1036
  async exportState() {
542
1037
  await this.startup;
@@ -565,10 +1060,12 @@ var CacheStack = class extends EventEmitter {
565
1060
  }
566
1061
  async importState(entries) {
567
1062
  await this.startup;
568
- await Promise.all(entries.map(async (entry) => {
569
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
570
- await this.tagIndex.touch(entry.key);
571
- }));
1063
+ await Promise.all(
1064
+ entries.map(async (entry) => {
1065
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1066
+ await this.tagIndex.touch(entry.key);
1067
+ })
1068
+ );
572
1069
  }
573
1070
  async persistToFile(filePath) {
574
1071
  const snapshot = await this.exportState();
@@ -576,11 +1073,21 @@ var CacheStack = class extends EventEmitter {
576
1073
  }
577
1074
  async restoreFromFile(filePath) {
578
1075
  const raw = await fs.readFile(filePath, "utf8");
579
- const snapshot = JSON.parse(raw);
580
- if (!this.isCacheSnapshotEntries(snapshot)) {
581
- throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
1076
+ let parsed;
1077
+ try {
1078
+ parsed = JSON.parse(raw, (_key, value) => {
1079
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1080
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1081
+ }
1082
+ return value;
1083
+ });
1084
+ } catch (cause) {
1085
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
582
1086
  }
583
- await this.importState(snapshot);
1087
+ if (!this.isCacheSnapshotEntries(parsed)) {
1088
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1089
+ }
1090
+ await this.importState(parsed);
584
1091
  }
585
1092
  async disconnect() {
586
1093
  if (!this.disconnectPromise) {
@@ -605,7 +1112,7 @@ var CacheStack = class extends EventEmitter {
605
1112
  const fetchTask = async () => {
606
1113
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
607
1114
  if (secondHit.found) {
608
- this.metrics.hits += 1;
1115
+ this.metricsCollector.increment("hits");
609
1116
  return secondHit.value;
610
1117
  }
611
1118
  return this.fetchAndPopulate(key, fetcher, options);
@@ -630,12 +1137,12 @@ var CacheStack = class extends EventEmitter {
630
1137
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
631
1138
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
632
1139
  const deadline = Date.now() + timeoutMs;
633
- this.metrics.singleFlightWaits += 1;
1140
+ this.metricsCollector.increment("singleFlightWaits");
634
1141
  this.emit("stampede-dedupe", { key });
635
1142
  while (Date.now() < deadline) {
636
1143
  const hit = await this.readFromLayers(key, options, "fresh-only");
637
1144
  if (hit.found) {
638
- this.metrics.hits += 1;
1145
+ this.metricsCollector.increment("hits");
639
1146
  return hit.value;
640
1147
  }
641
1148
  await this.sleep(pollIntervalMs);
@@ -643,12 +1150,14 @@ var CacheStack = class extends EventEmitter {
643
1150
  return this.fetchAndPopulate(key, fetcher, options);
644
1151
  }
645
1152
  async fetchAndPopulate(key, fetcher, options) {
646
- this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
647
- this.metrics.fetches += 1;
1153
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1154
+ this.metricsCollector.increment("fetches");
1155
+ const fetchStart = Date.now();
648
1156
  let fetched;
649
1157
  try {
650
1158
  fetched = await fetcher();
651
- this.resetCircuitBreaker(key);
1159
+ this.circuitBreakerManager.recordSuccess(key);
1160
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
652
1161
  } catch (error) {
653
1162
  this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
654
1163
  throw error;
@@ -660,6 +1169,9 @@ var CacheStack = class extends EventEmitter {
660
1169
  await this.storeEntry(key, "empty", null, options);
661
1170
  return null;
662
1171
  }
1172
+ if (options?.shouldCache && !options.shouldCache(fetched)) {
1173
+ return fetched;
1174
+ }
663
1175
  await this.storeEntry(key, "value", fetched, options);
664
1176
  return fetched;
665
1177
  }
@@ -670,7 +1182,7 @@ var CacheStack = class extends EventEmitter {
670
1182
  } else {
671
1183
  await this.tagIndex.touch(key);
672
1184
  }
673
- this.metrics.sets += 1;
1185
+ this.metricsCollector.increment("sets");
674
1186
  this.logger.debug?.("set", { key, kind, tags: options?.tags });
675
1187
  this.emit("set", { key, kind, tags: options?.tags });
676
1188
  if (this.shouldBroadcastL1Invalidation()) {
@@ -681,9 +1193,13 @@ var CacheStack = class extends EventEmitter {
681
1193
  let sawRetainableValue = false;
682
1194
  for (let index = 0; index < this.layers.length; index += 1) {
683
1195
  const layer = this.layers[index];
1196
+ if (!layer) continue;
1197
+ const readStart = performance.now();
684
1198
  const stored = await this.readLayerEntry(layer, key);
1199
+ const readDuration = performance.now() - readStart;
1200
+ this.metricsCollector.recordLatency(layer.name, readDuration);
685
1201
  if (stored === null) {
686
- this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
1202
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
687
1203
  continue;
688
1204
  }
689
1205
  const resolved = resolveStoredValue(stored);
@@ -697,10 +1213,17 @@ var CacheStack = class extends EventEmitter {
697
1213
  }
698
1214
  await this.tagIndex.touch(key);
699
1215
  await this.backfill(key, stored, index - 1, options);
700
- this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
1216
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
701
1217
  this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
702
1218
  this.emit("hit", { key, layer: layer.name, state: resolved.state });
703
- return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
1219
+ return {
1220
+ found: true,
1221
+ value: resolved.value,
1222
+ stored,
1223
+ state: resolved.state,
1224
+ layerIndex: index,
1225
+ layerName: layer.name
1226
+ };
704
1227
  }
705
1228
  if (!sawRetainableValue) {
706
1229
  await this.tagIndex.remove(key);
@@ -732,7 +1255,7 @@ var CacheStack = class extends EventEmitter {
732
1255
  }
733
1256
  for (let index = 0; index <= upToIndex; index += 1) {
734
1257
  const layer = this.layers[index];
735
- if (this.shouldSkipLayer(layer)) {
1258
+ if (!layer || this.shouldSkipLayer(layer)) {
736
1259
  continue;
737
1260
  }
738
1261
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
@@ -742,7 +1265,7 @@ var CacheStack = class extends EventEmitter {
742
1265
  await this.handleLayerFailure(layer, "backfill", error);
743
1266
  continue;
744
1267
  }
745
- this.metrics.backfills += 1;
1268
+ this.metricsCollector.increment("backfills");
746
1269
  this.logger.debug?.("backfill", { key, layer: layer.name });
747
1270
  this.emit("backfill", { key, layer: layer.name });
748
1271
  }
@@ -759,11 +1282,7 @@ var CacheStack = class extends EventEmitter {
759
1282
  options?.staleWhileRevalidate,
760
1283
  this.options.staleWhileRevalidate
761
1284
  );
762
- const staleIfError = this.resolveLayerSeconds(
763
- layer.name,
764
- options?.staleIfError,
765
- this.options.staleIfError
766
- );
1285
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
767
1286
  const payload = createStoredValueEnvelope({
768
1287
  kind,
769
1288
  value,
@@ -791,7 +1310,7 @@ var CacheStack = class extends EventEmitter {
791
1310
  if (failures.length === 0) {
792
1311
  return;
793
1312
  }
794
- this.metrics.writeFailures += failures.length;
1313
+ this.metricsCollector.increment("writeFailures", failures.length);
795
1314
  this.logger.debug?.("write-failure", {
796
1315
  ...context,
797
1316
  failures: failures.map((failure) => this.formatError(failure.reason))
@@ -804,42 +1323,10 @@ var CacheStack = class extends EventEmitter {
804
1323
  }
805
1324
  }
806
1325
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
807
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
808
- layerName,
809
- options?.negativeTtl,
810
- this.options.negativeTtl,
811
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
812
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
813
- const adaptiveTtl = this.applyAdaptiveTtl(
814
- key,
815
- layerName,
816
- baseTtl,
817
- options?.adaptiveTtl ?? this.options.adaptiveTtl
818
- );
819
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
820
- return this.applyJitter(adaptiveTtl, jitter);
1326
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
821
1327
  }
822
1328
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
823
- if (override !== void 0) {
824
- return this.readLayerNumber(layerName, override) ?? fallback;
825
- }
826
- if (globalDefault !== void 0) {
827
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
828
- }
829
- return fallback;
830
- }
831
- readLayerNumber(layerName, value) {
832
- if (typeof value === "number") {
833
- return value;
834
- }
835
- return value[layerName];
836
- }
837
- applyJitter(ttl, jitter) {
838
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
839
- return ttl;
840
- }
841
- const delta = (Math.random() * 2 - 1) * jitter;
842
- return Math.max(1, Math.round(ttl + delta));
1329
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
843
1330
  }
844
1331
  shouldNegativeCache(options) {
845
1332
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
@@ -849,11 +1336,11 @@ var CacheStack = class extends EventEmitter {
849
1336
  return;
850
1337
  }
851
1338
  const refresh = (async () => {
852
- this.metrics.refreshes += 1;
1339
+ this.metricsCollector.increment("refreshes");
853
1340
  try {
854
1341
  await this.fetchWithGuards(key, fetcher, options);
855
1342
  } catch (error) {
856
- this.metrics.refreshErrors += 1;
1343
+ this.metricsCollector.increment("refreshErrors");
857
1344
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
858
1345
  } finally {
859
1346
  this.backgroundRefreshes.delete(key);
@@ -875,10 +1362,11 @@ var CacheStack = class extends EventEmitter {
875
1362
  await this.deleteKeysFromLayers(this.layers, keys);
876
1363
  for (const key of keys) {
877
1364
  await this.tagIndex.remove(key);
878
- this.accessProfiles.delete(key);
1365
+ this.ttlResolver.deleteProfile(key);
1366
+ this.circuitBreakerManager.delete(key);
879
1367
  }
880
- this.metrics.deletes += keys.length;
881
- this.metrics.invalidations += 1;
1368
+ this.metricsCollector.increment("deletes", keys.length);
1369
+ this.metricsCollector.increment("invalidations");
882
1370
  this.logger.debug?.("delete", { keys });
883
1371
  this.emit("delete", { keys });
884
1372
  }
@@ -899,7 +1387,7 @@ var CacheStack = class extends EventEmitter {
899
1387
  if (message.scope === "clear") {
900
1388
  await Promise.all(localLayers.map((layer) => layer.clear()));
901
1389
  await this.tagIndex.clear();
902
- this.accessProfiles.clear();
1390
+ this.ttlResolver.clearProfiles();
903
1391
  return;
904
1392
  }
905
1393
  const keys = message.keys ?? [];
@@ -907,10 +1395,16 @@ var CacheStack = class extends EventEmitter {
907
1395
  if (message.operation !== "write") {
908
1396
  for (const key of keys) {
909
1397
  await this.tagIndex.remove(key);
910
- this.accessProfiles.delete(key);
1398
+ this.ttlResolver.deleteProfile(key);
911
1399
  }
912
1400
  }
913
1401
  }
1402
+ async getTagsForKey(key) {
1403
+ if (this.tagIndex.tagsForKey) {
1404
+ return this.tagIndex.tagsForKey(key);
1405
+ }
1406
+ return [];
1407
+ }
914
1408
  formatError(error) {
915
1409
  if (error instanceof Error) {
916
1410
  return error.message;
@@ -937,13 +1431,15 @@ var CacheStack = class extends EventEmitter {
937
1431
  }
938
1432
  return;
939
1433
  }
940
- await Promise.all(keys.map(async (key) => {
941
- try {
942
- await layer.delete(key);
943
- } catch (error) {
944
- await this.handleLayerFailure(layer, "delete", error);
945
- }
946
- }));
1434
+ await Promise.all(
1435
+ keys.map(async (key) => {
1436
+ try {
1437
+ await layer.delete(key);
1438
+ } catch (error) {
1439
+ await this.handleLayerFailure(layer, "delete", error);
1440
+ }
1441
+ })
1442
+ );
947
1443
  })
948
1444
  );
949
1445
  }
@@ -1044,46 +1540,19 @@ var CacheStack = class extends EventEmitter {
1044
1540
  const ttl = remainingStoredTtlSeconds(refreshed);
1045
1541
  for (let index = 0; index <= hit.layerIndex; index += 1) {
1046
1542
  const layer = this.layers[index];
1047
- if (this.shouldSkipLayer(layer)) {
1543
+ if (!layer || this.shouldSkipLayer(layer)) {
1048
1544
  continue;
1049
1545
  }
1050
1546
  try {
1051
- await layer.set(key, refreshed, ttl);
1052
- } catch (error) {
1053
- await this.handleLayerFailure(layer, "sliding-ttl", error);
1054
- }
1055
- }
1056
- }
1057
- if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1058
- this.scheduleBackgroundRefresh(key, fetcher, options);
1059
- }
1060
- }
1061
- applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1062
- if (!ttl || !adaptiveTtl) {
1063
- return ttl;
1064
- }
1065
- const profile = this.accessProfiles.get(key);
1066
- if (!profile) {
1067
- return ttl;
1547
+ await layer.set(key, refreshed, ttl);
1548
+ } catch (error) {
1549
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1550
+ }
1551
+ }
1068
1552
  }
1069
- const config = adaptiveTtl === true ? {} : adaptiveTtl;
1070
- const hotAfter = config.hotAfter ?? 3;
1071
- if (profile.hits < hotAfter) {
1072
- return ttl;
1553
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1554
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1073
1555
  }
1074
- const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1075
- const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1076
- const multiplier = Math.floor(profile.hits / hotAfter);
1077
- return Math.min(maxTtl, ttl + step * multiplier);
1078
- }
1079
- recordAccess(key) {
1080
- const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1081
- profile.hits += 1;
1082
- profile.lastAccessAt = Date.now();
1083
- this.accessProfiles.set(key, profile);
1084
- }
1085
- incrementMetricMap(target, key) {
1086
- target[key] = (target[key] ?? 0) + 1;
1087
1556
  }
1088
1557
  shouldSkipLayer(layer) {
1089
1558
  const degradedUntil = this.layerDegradedUntil.get(layer.name);
@@ -1095,7 +1564,7 @@ var CacheStack = class extends EventEmitter {
1095
1564
  }
1096
1565
  const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1097
1566
  this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1098
- this.metrics.degradedOperations += 1;
1567
+ this.metricsCollector.increment("degradedOperations");
1099
1568
  this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1100
1569
  this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1101
1570
  return null;
@@ -1103,37 +1572,15 @@ var CacheStack = class extends EventEmitter {
1103
1572
  isGracefulDegradationEnabled() {
1104
1573
  return Boolean(this.options.gracefulDegradation);
1105
1574
  }
1106
- assertCircuitClosed(key, options) {
1107
- const state = this.circuitBreakers.get(key);
1108
- if (!state?.openUntil) {
1109
- return;
1110
- }
1111
- if (state.openUntil <= Date.now()) {
1112
- state.openUntil = null;
1113
- state.failures = 0;
1114
- this.circuitBreakers.set(key, state);
1115
- return;
1116
- }
1117
- this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1118
- throw new Error(`Circuit breaker is open for key "${key}".`);
1119
- }
1120
1575
  recordCircuitFailure(key, options, error) {
1121
1576
  if (!options) {
1122
1577
  return;
1123
1578
  }
1124
- const failureThreshold = options.failureThreshold ?? 3;
1125
- const cooldownMs = options.cooldownMs ?? 3e4;
1126
- const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1127
- state.failures += 1;
1128
- if (state.failures >= failureThreshold) {
1129
- state.openUntil = Date.now() + cooldownMs;
1130
- this.metrics.circuitBreakerTrips += 1;
1579
+ this.circuitBreakerManager.recordFailure(key, options);
1580
+ if (this.circuitBreakerManager.isOpen(key)) {
1581
+ this.metricsCollector.increment("circuitBreakerTrips");
1131
1582
  }
1132
- this.circuitBreakers.set(key, state);
1133
- this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1134
- }
1135
- resetCircuitBreaker(key) {
1136
- this.circuitBreakers.delete(key);
1583
+ this.emitError("fetch", { key, error: this.formatError(error) });
1137
1584
  }
1138
1585
  isNegativeStoredValue(stored) {
1139
1586
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
@@ -1178,35 +1625,36 @@ var RedisInvalidationBus = class {
1178
1625
  channel;
1179
1626
  publisher;
1180
1627
  subscriber;
1181
- activeListener;
1628
+ handlers = /* @__PURE__ */ new Set();
1629
+ sharedListener;
1182
1630
  constructor(options) {
1183
1631
  this.publisher = options.publisher;
1184
1632
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
1185
1633
  this.channel = options.channel ?? "layercache:invalidation";
1186
1634
  }
1187
1635
  async subscribe(handler) {
1188
- if (this.activeListener) {
1189
- throw new Error("RedisInvalidationBus already has an active subscription.");
1190
- }
1191
- const listener = (_channel, payload) => {
1192
- void this.handleMessage(payload, handler);
1193
- };
1194
- this.activeListener = listener;
1195
- this.subscriber.on("message", listener);
1196
- await this.subscriber.subscribe(this.channel);
1636
+ if (this.handlers.size === 0) {
1637
+ const listener = (_channel, payload) => {
1638
+ void this.dispatchToHandlers(payload);
1639
+ };
1640
+ this.sharedListener = listener;
1641
+ this.subscriber.on("message", listener);
1642
+ await this.subscriber.subscribe(this.channel);
1643
+ }
1644
+ this.handlers.add(handler);
1197
1645
  return async () => {
1198
- if (this.activeListener !== listener) {
1199
- return;
1646
+ this.handlers.delete(handler);
1647
+ if (this.handlers.size === 0 && this.sharedListener) {
1648
+ this.subscriber.off("message", this.sharedListener);
1649
+ this.sharedListener = void 0;
1650
+ await this.subscriber.unsubscribe(this.channel);
1200
1651
  }
1201
- this.activeListener = void 0;
1202
- this.subscriber.off("message", listener);
1203
- await this.subscriber.unsubscribe(this.channel);
1204
1652
  };
1205
1653
  }
1206
1654
  async publish(message) {
1207
1655
  await this.publisher.publish(this.channel, JSON.stringify(message));
1208
1656
  }
1209
- async handleMessage(payload, handler) {
1657
+ async dispatchToHandlers(payload) {
1210
1658
  let message;
1211
1659
  try {
1212
1660
  const parsed = JSON.parse(payload);
@@ -1218,11 +1666,15 @@ var RedisInvalidationBus = class {
1218
1666
  this.reportError("invalid invalidation payload", error);
1219
1667
  return;
1220
1668
  }
1221
- try {
1222
- await handler(message);
1223
- } catch (error) {
1224
- this.reportError("invalidation handler failed", error);
1225
- }
1669
+ await Promise.all(
1670
+ [...this.handlers].map(async (handler) => {
1671
+ try {
1672
+ await handler(message);
1673
+ } catch (error) {
1674
+ this.reportError("invalidation handler failed", error);
1675
+ }
1676
+ })
1677
+ );
1226
1678
  }
1227
1679
  isInvalidationMessage(value) {
1228
1680
  if (!value || typeof value !== "object") {
@@ -1283,6 +1735,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
1283
1735
  };
1284
1736
  }
1285
1737
 
1738
+ // src/integrations/express.ts
1739
+ function createExpressCacheMiddleware(cache, options = {}) {
1740
+ const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
1741
+ return async (req, res, next) => {
1742
+ const method = (req.method ?? "GET").toUpperCase();
1743
+ if (!allowedMethods.has(method)) {
1744
+ next();
1745
+ return;
1746
+ }
1747
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
1748
+ const cached = await cache.get(key, void 0, options);
1749
+ if (cached !== null) {
1750
+ res.setHeader?.("content-type", "application/json; charset=utf-8");
1751
+ res.setHeader?.("x-cache", "HIT");
1752
+ if (res.json) {
1753
+ res.json(cached);
1754
+ } else {
1755
+ res.end?.(JSON.stringify(cached));
1756
+ }
1757
+ return;
1758
+ }
1759
+ const originalJson = res.json?.bind(res);
1760
+ if (originalJson) {
1761
+ res.json = (body) => {
1762
+ res.setHeader?.("x-cache", "MISS");
1763
+ void cache.set(key, body, options);
1764
+ return originalJson(body);
1765
+ };
1766
+ }
1767
+ next();
1768
+ };
1769
+ }
1770
+
1286
1771
  // src/integrations/graphql.ts
1287
1772
  function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1288
1773
  const wrapped = cache.wrap(prefix, resolver, {
@@ -1323,11 +1808,13 @@ var MemoryLayer = class {
1323
1808
  defaultTtl;
1324
1809
  isLocal = true;
1325
1810
  maxSize;
1811
+ evictionPolicy;
1326
1812
  entries = /* @__PURE__ */ new Map();
1327
1813
  constructor(options = {}) {
1328
1814
  this.name = options.name ?? "memory";
1329
1815
  this.defaultTtl = options.ttl;
1330
1816
  this.maxSize = options.maxSize ?? 1e3;
1817
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
1331
1818
  }
1332
1819
  async get(key) {
1333
1820
  const value = await this.getEntry(key);
@@ -1342,8 +1829,13 @@ var MemoryLayer = class {
1342
1829
  this.entries.delete(key);
1343
1830
  return null;
1344
1831
  }
1345
- this.entries.delete(key);
1346
- this.entries.set(key, entry);
1832
+ if (this.evictionPolicy === "lru") {
1833
+ this.entries.delete(key);
1834
+ entry.accessCount += 1;
1835
+ this.entries.set(key, entry);
1836
+ } else if (this.evictionPolicy === "lfu") {
1837
+ entry.accessCount += 1;
1838
+ }
1347
1839
  return entry.value;
1348
1840
  }
1349
1841
  async getMany(keys) {
@@ -1357,15 +1849,42 @@ var MemoryLayer = class {
1357
1849
  this.entries.delete(key);
1358
1850
  this.entries.set(key, {
1359
1851
  value,
1360
- expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
1852
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1853
+ accessCount: 0,
1854
+ insertedAt: Date.now()
1361
1855
  });
1362
1856
  while (this.entries.size > this.maxSize) {
1363
- const oldestKey = this.entries.keys().next().value;
1364
- if (!oldestKey) {
1365
- break;
1366
- }
1367
- this.entries.delete(oldestKey);
1857
+ this.evict();
1858
+ }
1859
+ }
1860
+ async has(key) {
1861
+ const entry = this.entries.get(key);
1862
+ if (!entry) {
1863
+ return false;
1864
+ }
1865
+ if (this.isExpired(entry)) {
1866
+ this.entries.delete(key);
1867
+ return false;
1868
+ }
1869
+ return true;
1870
+ }
1871
+ async ttl(key) {
1872
+ const entry = this.entries.get(key);
1873
+ if (!entry) {
1874
+ return null;
1368
1875
  }
1876
+ if (this.isExpired(entry)) {
1877
+ this.entries.delete(key);
1878
+ return null;
1879
+ }
1880
+ if (entry.expiresAt === null) {
1881
+ return null;
1882
+ }
1883
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
1884
+ }
1885
+ async size() {
1886
+ this.pruneExpired();
1887
+ return this.entries.size;
1369
1888
  }
1370
1889
  async delete(key) {
1371
1890
  this.entries.delete(key);
@@ -1397,15 +1916,35 @@ var MemoryLayer = class {
1397
1916
  }
1398
1917
  this.entries.set(entry.key, {
1399
1918
  value: entry.value,
1400
- expiresAt: entry.expiresAt
1919
+ expiresAt: entry.expiresAt,
1920
+ accessCount: 0,
1921
+ insertedAt: Date.now()
1401
1922
  });
1402
1923
  }
1403
1924
  while (this.entries.size > this.maxSize) {
1925
+ this.evict();
1926
+ }
1927
+ }
1928
+ evict() {
1929
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
1404
1930
  const oldestKey = this.entries.keys().next().value;
1405
- if (!oldestKey) {
1406
- break;
1931
+ if (oldestKey !== void 0) {
1932
+ this.entries.delete(oldestKey);
1933
+ }
1934
+ return;
1935
+ }
1936
+ let victimKey;
1937
+ let minCount = Number.POSITIVE_INFINITY;
1938
+ let minInsertedAt = Number.POSITIVE_INFINITY;
1939
+ for (const [key, entry] of this.entries.entries()) {
1940
+ if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
1941
+ minCount = entry.accessCount;
1942
+ minInsertedAt = entry.insertedAt;
1943
+ victimKey = key;
1407
1944
  }
1408
- this.entries.delete(oldestKey);
1945
+ }
1946
+ if (victimKey !== void 0) {
1947
+ this.entries.delete(victimKey);
1409
1948
  }
1410
1949
  }
1411
1950
  pruneExpired() {
@@ -1421,7 +1960,8 @@ var MemoryLayer = class {
1421
1960
  };
1422
1961
 
1423
1962
  // src/layers/RedisLayer.ts
1424
- import { brotliCompressSync, brotliDecompressSync, gzipSync, gunzipSync } from "zlib";
1963
+ import { promisify } from "util";
1964
+ import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
1425
1965
 
1426
1966
  // src/serialization/JsonSerializer.ts
1427
1967
  var JsonSerializer = class {
@@ -1435,6 +1975,11 @@ var JsonSerializer = class {
1435
1975
  };
1436
1976
 
1437
1977
  // src/layers/RedisLayer.ts
1978
+ var BATCH_DELETE_SIZE = 500;
1979
+ var gzipAsync = promisify(gzip);
1980
+ var gunzipAsync = promisify(gunzip);
1981
+ var brotliCompressAsync = promisify(brotliCompress);
1982
+ var brotliDecompressAsync = promisify(brotliDecompress);
1438
1983
  var RedisLayer = class {
1439
1984
  name;
1440
1985
  defaultTtl;
@@ -1486,12 +2031,13 @@ var RedisLayer = class {
1486
2031
  if (error || payload === null || !this.isSerializablePayload(payload)) {
1487
2032
  return null;
1488
2033
  }
1489
- return this.deserializeOrDelete(keys[index], payload);
2034
+ return this.deserializeOrDelete(keys[index] ?? "", payload);
1490
2035
  })
1491
2036
  );
1492
2037
  }
1493
2038
  async set(key, value, ttl = this.defaultTtl) {
1494
- const payload = this.encodePayload(this.serializer.serialize(value));
2039
+ const serialized = this.serializer.serialize(value);
2040
+ const payload = await this.encodePayload(serialized);
1495
2041
  const normalizedKey = this.withPrefix(key);
1496
2042
  if (ttl && ttl > 0) {
1497
2043
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -1508,14 +2054,44 @@ var RedisLayer = class {
1508
2054
  }
1509
2055
  await this.client.del(...keys.map((key) => this.withPrefix(key)));
1510
2056
  }
1511
- async clear() {
1512
- if (!this.prefix && !this.allowUnprefixedClear) {
1513
- throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
2057
+ async has(key) {
2058
+ const exists = await this.client.exists(this.withPrefix(key));
2059
+ return exists > 0;
2060
+ }
2061
+ async ttl(key) {
2062
+ const remaining = await this.client.ttl(this.withPrefix(key));
2063
+ if (remaining < 0) {
2064
+ return null;
1514
2065
  }
2066
+ return remaining;
2067
+ }
2068
+ async size() {
1515
2069
  const keys = await this.keys();
1516
- if (keys.length > 0) {
1517
- await this.deleteMany(keys);
2070
+ return keys.length;
2071
+ }
2072
+ /**
2073
+ * Deletes all keys matching the layer's prefix in batches to avoid
2074
+ * loading millions of keys into memory at once.
2075
+ */
2076
+ async clear() {
2077
+ if (!this.prefix && !this.allowUnprefixedClear) {
2078
+ throw new Error(
2079
+ "RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
2080
+ );
1518
2081
  }
2082
+ const pattern = `${this.prefix}*`;
2083
+ let cursor = "0";
2084
+ do {
2085
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
2086
+ cursor = nextCursor;
2087
+ if (keys.length === 0) {
2088
+ continue;
2089
+ }
2090
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
2091
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
2092
+ await this.client.del(...batch);
2093
+ }
2094
+ } while (cursor !== "0");
1519
2095
  }
1520
2096
  async keys() {
1521
2097
  const pattern = `${this.prefix}*`;
@@ -1540,7 +2116,7 @@ var RedisLayer = class {
1540
2116
  }
1541
2117
  async deserializeOrDelete(key, payload) {
1542
2118
  try {
1543
- return this.serializer.deserialize(this.decodePayload(payload));
2119
+ return this.serializer.deserialize(await this.decodePayload(payload));
1544
2120
  } catch {
1545
2121
  await this.client.del(this.withPrefix(key)).catch(() => void 0);
1546
2122
  return null;
@@ -1549,7 +2125,11 @@ var RedisLayer = class {
1549
2125
  isSerializablePayload(payload) {
1550
2126
  return typeof payload === "string" || Buffer.isBuffer(payload);
1551
2127
  }
1552
- encodePayload(payload) {
2128
+ /**
2129
+ * Compresses the payload asynchronously if compression is enabled and the
2130
+ * payload exceeds the threshold. This avoids blocking the event loop.
2131
+ */
2132
+ async encodePayload(payload) {
1553
2133
  if (!this.compression) {
1554
2134
  return payload;
1555
2135
  }
@@ -1558,23 +2138,269 @@ var RedisLayer = class {
1558
2138
  return payload;
1559
2139
  }
1560
2140
  const header = Buffer.from(`LCZ1:${this.compression}:`);
1561
- const compressed = this.compression === "gzip" ? gzipSync(source) : brotliCompressSync(source);
2141
+ const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
1562
2142
  return Buffer.concat([header, compressed]);
1563
2143
  }
1564
- decodePayload(payload) {
2144
+ /**
2145
+ * Decompresses the payload asynchronously if a compression header is present.
2146
+ */
2147
+ async decodePayload(payload) {
1565
2148
  if (!Buffer.isBuffer(payload)) {
1566
2149
  return payload;
1567
2150
  }
1568
2151
  if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
1569
- return gunzipSync(payload.subarray(10));
2152
+ return gunzipAsync(payload.subarray(10));
1570
2153
  }
1571
2154
  if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
1572
- return brotliDecompressSync(payload.subarray(12));
2155
+ return brotliDecompressAsync(payload.subarray(12));
1573
2156
  }
1574
2157
  return payload;
1575
2158
  }
1576
2159
  };
1577
2160
 
2161
+ // src/layers/DiskLayer.ts
2162
+ import { createHash } from "crypto";
2163
+ import { promises as fs2 } from "fs";
2164
+ import { join } from "path";
2165
+ var DiskLayer = class {
2166
+ name;
2167
+ defaultTtl;
2168
+ isLocal = true;
2169
+ directory;
2170
+ serializer;
2171
+ maxFiles;
2172
+ constructor(options) {
2173
+ this.directory = options.directory;
2174
+ this.defaultTtl = options.ttl;
2175
+ this.name = options.name ?? "disk";
2176
+ this.serializer = options.serializer ?? new JsonSerializer();
2177
+ this.maxFiles = options.maxFiles;
2178
+ }
2179
+ async get(key) {
2180
+ return unwrapStoredValue(await this.getEntry(key));
2181
+ }
2182
+ async getEntry(key) {
2183
+ const filePath = this.keyToPath(key);
2184
+ let raw;
2185
+ try {
2186
+ raw = await fs2.readFile(filePath);
2187
+ } catch {
2188
+ return null;
2189
+ }
2190
+ let entry;
2191
+ try {
2192
+ entry = this.serializer.deserialize(raw);
2193
+ } catch {
2194
+ await this.safeDelete(filePath);
2195
+ return null;
2196
+ }
2197
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2198
+ await this.safeDelete(filePath);
2199
+ return null;
2200
+ }
2201
+ return entry.value;
2202
+ }
2203
+ async set(key, value, ttl = this.defaultTtl) {
2204
+ await fs2.mkdir(this.directory, { recursive: true });
2205
+ const entry = {
2206
+ key,
2207
+ value,
2208
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2209
+ };
2210
+ const payload = this.serializer.serialize(entry);
2211
+ await fs2.writeFile(this.keyToPath(key), payload);
2212
+ if (this.maxFiles !== void 0) {
2213
+ await this.enforceMaxFiles();
2214
+ }
2215
+ }
2216
+ async has(key) {
2217
+ const value = await this.getEntry(key);
2218
+ return value !== null;
2219
+ }
2220
+ async ttl(key) {
2221
+ const filePath = this.keyToPath(key);
2222
+ let raw;
2223
+ try {
2224
+ raw = await fs2.readFile(filePath);
2225
+ } catch {
2226
+ return null;
2227
+ }
2228
+ let entry;
2229
+ try {
2230
+ entry = this.serializer.deserialize(raw);
2231
+ } catch {
2232
+ return null;
2233
+ }
2234
+ if (entry.expiresAt === null) {
2235
+ return null;
2236
+ }
2237
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
2238
+ if (remaining <= 0) {
2239
+ return null;
2240
+ }
2241
+ return remaining;
2242
+ }
2243
+ async delete(key) {
2244
+ await this.safeDelete(this.keyToPath(key));
2245
+ }
2246
+ async deleteMany(keys) {
2247
+ await Promise.all(keys.map((key) => this.delete(key)));
2248
+ }
2249
+ async clear() {
2250
+ let entries;
2251
+ try {
2252
+ entries = await fs2.readdir(this.directory);
2253
+ } catch {
2254
+ return;
2255
+ }
2256
+ await Promise.all(
2257
+ entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
2258
+ );
2259
+ }
2260
+ /**
2261
+ * Returns the original cache key strings stored on disk.
2262
+ * Expired entries are skipped and cleaned up during the scan.
2263
+ */
2264
+ async keys() {
2265
+ let entries;
2266
+ try {
2267
+ entries = await fs2.readdir(this.directory);
2268
+ } catch {
2269
+ return [];
2270
+ }
2271
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2272
+ const keys = [];
2273
+ await Promise.all(
2274
+ lcFiles.map(async (name) => {
2275
+ const filePath = join(this.directory, name);
2276
+ let raw;
2277
+ try {
2278
+ raw = await fs2.readFile(filePath);
2279
+ } catch {
2280
+ return;
2281
+ }
2282
+ let entry;
2283
+ try {
2284
+ entry = this.serializer.deserialize(raw);
2285
+ } catch {
2286
+ await this.safeDelete(filePath);
2287
+ return;
2288
+ }
2289
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2290
+ await this.safeDelete(filePath);
2291
+ return;
2292
+ }
2293
+ keys.push(entry.key);
2294
+ })
2295
+ );
2296
+ return keys;
2297
+ }
2298
+ async size() {
2299
+ const keys = await this.keys();
2300
+ return keys.length;
2301
+ }
2302
+ keyToPath(key) {
2303
+ const hash = createHash("sha256").update(key).digest("hex");
2304
+ return join(this.directory, `${hash}.lc`);
2305
+ }
2306
+ async safeDelete(filePath) {
2307
+ try {
2308
+ await fs2.unlink(filePath);
2309
+ } catch {
2310
+ }
2311
+ }
2312
+ /**
2313
+ * Removes the oldest files (by mtime) when the directory exceeds maxFiles.
2314
+ */
2315
+ async enforceMaxFiles() {
2316
+ if (this.maxFiles === void 0) {
2317
+ return;
2318
+ }
2319
+ let entries;
2320
+ try {
2321
+ entries = await fs2.readdir(this.directory);
2322
+ } catch {
2323
+ return;
2324
+ }
2325
+ const lcFiles = entries.filter((name) => name.endsWith(".lc"));
2326
+ if (lcFiles.length <= this.maxFiles) {
2327
+ return;
2328
+ }
2329
+ const withStats = await Promise.all(
2330
+ lcFiles.map(async (name) => {
2331
+ const filePath = join(this.directory, name);
2332
+ try {
2333
+ const stat = await fs2.stat(filePath);
2334
+ return { filePath, mtimeMs: stat.mtimeMs };
2335
+ } catch {
2336
+ return { filePath, mtimeMs: 0 };
2337
+ }
2338
+ })
2339
+ );
2340
+ withStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
2341
+ const toEvict = withStats.slice(0, lcFiles.length - this.maxFiles);
2342
+ await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
2343
+ }
2344
+ };
2345
+
2346
+ // src/layers/MemcachedLayer.ts
2347
+ var MemcachedLayer = class {
2348
+ name;
2349
+ defaultTtl;
2350
+ isLocal = false;
2351
+ client;
2352
+ keyPrefix;
2353
+ serializer;
2354
+ constructor(options) {
2355
+ this.client = options.client;
2356
+ this.defaultTtl = options.ttl;
2357
+ this.name = options.name ?? "memcached";
2358
+ this.keyPrefix = options.keyPrefix ?? "";
2359
+ this.serializer = options.serializer ?? new JsonSerializer();
2360
+ }
2361
+ async get(key) {
2362
+ return unwrapStoredValue(await this.getEntry(key));
2363
+ }
2364
+ async getEntry(key) {
2365
+ const result = await this.client.get(this.withPrefix(key));
2366
+ if (!result || result.value === null) {
2367
+ return null;
2368
+ }
2369
+ try {
2370
+ return this.serializer.deserialize(result.value);
2371
+ } catch {
2372
+ return null;
2373
+ }
2374
+ }
2375
+ async getMany(keys) {
2376
+ return Promise.all(keys.map((key) => this.getEntry(key)));
2377
+ }
2378
+ async set(key, value, ttl = this.defaultTtl) {
2379
+ const payload = this.serializer.serialize(value);
2380
+ await this.client.set(this.withPrefix(key), payload, {
2381
+ expires: ttl && ttl > 0 ? ttl : void 0
2382
+ });
2383
+ }
2384
+ async has(key) {
2385
+ const result = await this.client.get(this.withPrefix(key));
2386
+ return result !== null && result.value !== null;
2387
+ }
2388
+ async delete(key) {
2389
+ await this.client.delete(this.withPrefix(key));
2390
+ }
2391
+ async deleteMany(keys) {
2392
+ await Promise.all(keys.map((key) => this.delete(key)));
2393
+ }
2394
+ async clear() {
2395
+ throw new Error(
2396
+ "MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
2397
+ );
2398
+ }
2399
+ withPrefix(key) {
2400
+ return `${this.keyPrefix}${key}`;
2401
+ }
2402
+ };
2403
+
1578
2404
  // src/serialization/MsgpackSerializer.ts
1579
2405
  import { decode, encode } from "@msgpack/msgpack";
1580
2406
  var MsgpackSerializer = class {
@@ -1616,10 +2442,92 @@ var RedisSingleFlightCoordinator = class {
1616
2442
  return waiter();
1617
2443
  }
1618
2444
  };
2445
+
2446
+ // src/metrics/PrometheusExporter.ts
2447
+ function createPrometheusMetricsExporter(stacks) {
2448
+ return () => {
2449
+ const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
2450
+ const lines = [];
2451
+ lines.push("# HELP layercache_hits_total Total number of cache hits");
2452
+ lines.push("# TYPE layercache_hits_total counter");
2453
+ lines.push("# HELP layercache_misses_total Total number of cache misses");
2454
+ lines.push("# TYPE layercache_misses_total counter");
2455
+ lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
2456
+ lines.push("# TYPE layercache_fetches_total counter");
2457
+ lines.push("# HELP layercache_sets_total Total number of cache sets");
2458
+ lines.push("# TYPE layercache_sets_total counter");
2459
+ lines.push("# HELP layercache_deletes_total Total number of cache deletes");
2460
+ lines.push("# TYPE layercache_deletes_total counter");
2461
+ lines.push("# HELP layercache_backfills_total Total number of backfill operations");
2462
+ lines.push("# TYPE layercache_backfills_total counter");
2463
+ lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
2464
+ lines.push("# TYPE layercache_stale_hits_total counter");
2465
+ lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
2466
+ lines.push("# TYPE layercache_refreshes_total counter");
2467
+ lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
2468
+ lines.push("# TYPE layercache_refresh_errors_total counter");
2469
+ lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
2470
+ lines.push("# TYPE layercache_negative_cache_hits_total counter");
2471
+ lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
2472
+ lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
2473
+ lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
2474
+ lines.push("# TYPE layercache_degraded_operations_total counter");
2475
+ lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
2476
+ lines.push("# TYPE layercache_hit_rate gauge");
2477
+ lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
2478
+ lines.push("# TYPE layercache_hits_by_layer_total counter");
2479
+ lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2480
+ lines.push("# TYPE layercache_misses_by_layer_total counter");
2481
+ lines.push("# HELP layercache_layer_latency_avg_ms Average read latency per layer in milliseconds");
2482
+ lines.push("# TYPE layercache_layer_latency_avg_ms gauge");
2483
+ lines.push("# HELP layercache_layer_latency_max_ms Maximum read latency per layer in milliseconds");
2484
+ lines.push("# TYPE layercache_layer_latency_max_ms gauge");
2485
+ lines.push("# HELP layercache_layer_latency_count Number of read latency samples per layer");
2486
+ lines.push("# TYPE layercache_layer_latency_count counter");
2487
+ for (const { stack, name } of entries) {
2488
+ const m = stack.getMetrics();
2489
+ const hr = stack.getHitRate();
2490
+ const label = `cache="${sanitizeLabel(name)}"`;
2491
+ lines.push(`layercache_hits_total{${label}} ${m.hits}`);
2492
+ lines.push(`layercache_misses_total{${label}} ${m.misses}`);
2493
+ lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
2494
+ lines.push(`layercache_sets_total{${label}} ${m.sets}`);
2495
+ lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
2496
+ lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
2497
+ lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
2498
+ lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
2499
+ lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
2500
+ lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
2501
+ lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
2502
+ lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
2503
+ lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
2504
+ for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
2505
+ lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2506
+ }
2507
+ for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2508
+ lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2509
+ }
2510
+ for (const [layerName, latency] of Object.entries(m.latencyByLayer)) {
2511
+ const layerLabel = `${label},layer="${sanitizeLabel(layerName)}"`;
2512
+ lines.push(`layercache_layer_latency_avg_ms{${layerLabel}} ${latency.avgMs.toFixed(4)}`);
2513
+ lines.push(`layercache_layer_latency_max_ms{${layerLabel}} ${latency.maxMs.toFixed(4)}`);
2514
+ lines.push(`layercache_layer_latency_count{${layerLabel}} ${latency.count}`);
2515
+ }
2516
+ }
2517
+ lines.push("");
2518
+ return lines.join("\n");
2519
+ };
2520
+ }
2521
+ function sanitizeLabel(value) {
2522
+ return value.replace(/["\\\n]/g, "_");
2523
+ }
1619
2524
  export {
2525
+ CacheMissError,
1620
2526
  CacheNamespace,
1621
2527
  CacheStack,
2528
+ DiskLayer,
1622
2529
  JsonSerializer,
2530
+ MemcachedLayer,
1623
2531
  MemoryLayer,
1624
2532
  MsgpackSerializer,
1625
2533
  PatternMatcher,
@@ -1632,6 +2540,8 @@ export {
1632
2540
  cacheGraphqlResolver,
1633
2541
  createCacheStatsHandler,
1634
2542
  createCachedMethodDecorator,
2543
+ createExpressCacheMiddleware,
1635
2544
  createFastifyLayercachePlugin,
2545
+ createPrometheusMetricsExporter,
1636
2546
  createTrpcCacheMiddleware
1637
2547
  };