layercache 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -20,8 +20,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CacheNamespace: () => CacheNamespace,
23
24
  CacheStack: () => CacheStack,
25
+ DiskLayer: () => DiskLayer,
24
26
  JsonSerializer: () => JsonSerializer,
27
+ MemcachedLayer: () => MemcachedLayer,
25
28
  MemoryLayer: () => MemoryLayer,
26
29
  MsgpackSerializer: () => MsgpackSerializer,
27
30
  PatternMatcher: () => PatternMatcher,
@@ -30,12 +33,241 @@ __export(index_exports, {
30
33
  RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
31
34
  RedisTagIndex: () => RedisTagIndex,
32
35
  StampedeGuard: () => StampedeGuard,
33
- TagIndex: () => TagIndex
36
+ TagIndex: () => TagIndex,
37
+ cacheGraphqlResolver: () => cacheGraphqlResolver,
38
+ createCacheStatsHandler: () => createCacheStatsHandler,
39
+ createCachedMethodDecorator: () => createCachedMethodDecorator,
40
+ createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
41
+ createPrometheusMetricsExporter: () => createPrometheusMetricsExporter,
42
+ createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
34
43
  });
35
44
  module.exports = __toCommonJS(index_exports);
36
45
 
37
46
  // src/CacheStack.ts
38
47
  var import_node_crypto = require("crypto");
48
+ var import_node_events = require("events");
49
+ var import_node_fs = require("fs");
50
+
51
+ // src/CacheNamespace.ts
52
+ var CacheNamespace = class {
53
+ constructor(cache, prefix) {
54
+ this.cache = cache;
55
+ this.prefix = prefix;
56
+ }
57
+ cache;
58
+ prefix;
59
+ async get(key, fetcher, options) {
60
+ return this.cache.get(this.qualify(key), fetcher, options);
61
+ }
62
+ async getOrSet(key, fetcher, options) {
63
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
64
+ }
65
+ async has(key) {
66
+ return this.cache.has(this.qualify(key));
67
+ }
68
+ async ttl(key) {
69
+ return this.cache.ttl(this.qualify(key));
70
+ }
71
+ async set(key, value, options) {
72
+ await this.cache.set(this.qualify(key), value, options);
73
+ }
74
+ async delete(key) {
75
+ await this.cache.delete(this.qualify(key));
76
+ }
77
+ async mdelete(keys) {
78
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
79
+ }
80
+ async clear() {
81
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
82
+ }
83
+ async mget(entries) {
84
+ return this.cache.mget(
85
+ entries.map((entry) => ({
86
+ ...entry,
87
+ key: this.qualify(entry.key)
88
+ }))
89
+ );
90
+ }
91
+ async mset(entries) {
92
+ await this.cache.mset(
93
+ entries.map((entry) => ({
94
+ ...entry,
95
+ key: this.qualify(entry.key)
96
+ }))
97
+ );
98
+ }
99
+ async invalidateByTag(tag) {
100
+ await this.cache.invalidateByTag(tag);
101
+ }
102
+ async invalidateByPattern(pattern) {
103
+ await this.cache.invalidateByPattern(this.qualify(pattern));
104
+ }
105
+ wrap(keyPrefix, fetcher, options) {
106
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
107
+ }
108
+ warm(entries, options) {
109
+ return this.cache.warm(
110
+ entries.map((entry) => ({
111
+ ...entry,
112
+ key: this.qualify(entry.key)
113
+ })),
114
+ options
115
+ );
116
+ }
117
+ getMetrics() {
118
+ return this.cache.getMetrics();
119
+ }
120
+ getHitRate() {
121
+ return this.cache.getHitRate();
122
+ }
123
+ qualify(key) {
124
+ return `${this.prefix}:${key}`;
125
+ }
126
+ };
127
+
128
+ // src/internal/CircuitBreakerManager.ts
129
+ var CircuitBreakerManager = class {
130
+ breakers = /* @__PURE__ */ new Map();
131
+ maxEntries;
132
+ constructor(options) {
133
+ this.maxEntries = options.maxEntries;
134
+ }
135
+ /**
136
+ * Throws if the circuit is open for the given key.
137
+ * Automatically resets if the cooldown has elapsed.
138
+ */
139
+ assertClosed(key, options) {
140
+ const state = this.breakers.get(key);
141
+ if (!state?.openUntil) {
142
+ return;
143
+ }
144
+ const now = Date.now();
145
+ if (state.openUntil <= now) {
146
+ state.openUntil = null;
147
+ state.failures = 0;
148
+ this.breakers.set(key, state);
149
+ return;
150
+ }
151
+ const remainingMs = state.openUntil - now;
152
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
153
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
154
+ }
155
+ recordFailure(key, options) {
156
+ if (!options) {
157
+ return;
158
+ }
159
+ const failureThreshold = options.failureThreshold ?? 3;
160
+ const cooldownMs = options.cooldownMs ?? 3e4;
161
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
162
+ state.failures += 1;
163
+ if (state.failures >= failureThreshold) {
164
+ state.openUntil = Date.now() + cooldownMs;
165
+ }
166
+ this.breakers.set(key, state);
167
+ this.pruneIfNeeded();
168
+ }
169
+ recordSuccess(key) {
170
+ this.breakers.delete(key);
171
+ }
172
+ isOpen(key) {
173
+ const state = this.breakers.get(key);
174
+ if (!state?.openUntil) {
175
+ return false;
176
+ }
177
+ if (state.openUntil <= Date.now()) {
178
+ state.openUntil = null;
179
+ state.failures = 0;
180
+ return false;
181
+ }
182
+ return true;
183
+ }
184
+ delete(key) {
185
+ this.breakers.delete(key);
186
+ }
187
+ clear() {
188
+ this.breakers.clear();
189
+ }
190
+ tripCount() {
191
+ let count = 0;
192
+ for (const state of this.breakers.values()) {
193
+ if (state.openUntil !== null) {
194
+ count += 1;
195
+ }
196
+ }
197
+ return count;
198
+ }
199
+ pruneIfNeeded() {
200
+ if (this.breakers.size <= this.maxEntries) {
201
+ return;
202
+ }
203
+ for (const [key, state] of this.breakers.entries()) {
204
+ if (this.breakers.size <= this.maxEntries) {
205
+ break;
206
+ }
207
+ if (!state.openUntil || state.openUntil <= Date.now()) {
208
+ this.breakers.delete(key);
209
+ }
210
+ }
211
+ for (const key of this.breakers.keys()) {
212
+ if (this.breakers.size <= this.maxEntries) {
213
+ break;
214
+ }
215
+ this.breakers.delete(key);
216
+ }
217
+ }
218
+ };
219
+
220
+ // src/internal/MetricsCollector.ts
221
+ var MetricsCollector = class {
222
+ data = this.empty();
223
+ get snapshot() {
224
+ return { ...this.data };
225
+ }
226
+ increment(field, amount = 1) {
227
+ ;
228
+ this.data[field] += amount;
229
+ }
230
+ incrementLayer(map, layerName) {
231
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
232
+ }
233
+ reset() {
234
+ this.data = this.empty();
235
+ }
236
+ hitRate() {
237
+ const total = this.data.hits + this.data.misses;
238
+ const overall = total === 0 ? 0 : this.data.hits / total;
239
+ const byLayer = {};
240
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
241
+ for (const layer of allLayers) {
242
+ const h = this.data.hitsByLayer[layer] ?? 0;
243
+ const m = this.data.missesByLayer[layer] ?? 0;
244
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
245
+ }
246
+ return { overall, byLayer };
247
+ }
248
+ empty() {
249
+ return {
250
+ hits: 0,
251
+ misses: 0,
252
+ fetches: 0,
253
+ sets: 0,
254
+ deletes: 0,
255
+ backfills: 0,
256
+ invalidations: 0,
257
+ staleHits: 0,
258
+ refreshes: 0,
259
+ refreshErrors: 0,
260
+ writeFailures: 0,
261
+ singleFlightWaits: 0,
262
+ negativeCacheHits: 0,
263
+ circuitBreakerTrips: 0,
264
+ degradedOperations: 0,
265
+ hitsByLayer: {},
266
+ missesByLayer: {},
267
+ resetAt: Date.now()
268
+ };
269
+ }
270
+ };
39
271
 
40
272
  // src/internal/StoredValue.ts
41
273
  function isStoredValueEnvelope(value) {
@@ -55,7 +287,10 @@ function createStoredValueEnvelope(options) {
55
287
  value: options.value,
56
288
  freshUntil,
57
289
  staleUntil,
58
- errorUntil
290
+ errorUntil,
291
+ freshTtlSeconds: freshTtlSeconds ?? null,
292
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
293
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
59
294
  };
60
295
  }
61
296
  function resolveStoredValue(stored, now = Date.now()) {
@@ -96,6 +331,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
96
331
  }
97
332
  return Math.max(1, Math.ceil(remainingMs / 1e3));
98
333
  }
334
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
335
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
336
+ return void 0;
337
+ }
338
+ const remainingMs = stored.freshUntil - now;
339
+ if (remainingMs <= 0) {
340
+ return 0;
341
+ }
342
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
343
+ }
344
+ function refreshStoredEnvelope(stored, now = Date.now()) {
345
+ if (!isStoredValueEnvelope(stored)) {
346
+ return stored;
347
+ }
348
+ return createStoredValueEnvelope({
349
+ kind: stored.kind,
350
+ value: stored.value,
351
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
352
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
353
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
354
+ now
355
+ });
356
+ }
99
357
  function maxExpiry(stored) {
100
358
  const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
101
359
  (value) => value !== null
@@ -112,12 +370,129 @@ function normalizePositiveSeconds(value) {
112
370
  return value;
113
371
  }
114
372
 
373
+ // src/internal/TtlResolver.ts
374
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
375
+ var TtlResolver = class {
376
+ accessProfiles = /* @__PURE__ */ new Map();
377
+ maxProfileEntries;
378
+ constructor(options) {
379
+ this.maxProfileEntries = options.maxProfileEntries;
380
+ }
381
+ recordAccess(key) {
382
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
383
+ profile.hits += 1;
384
+ profile.lastAccessAt = Date.now();
385
+ this.accessProfiles.set(key, profile);
386
+ this.pruneIfNeeded();
387
+ }
388
+ deleteProfile(key) {
389
+ this.accessProfiles.delete(key);
390
+ }
391
+ clearProfiles() {
392
+ this.accessProfiles.clear();
393
+ }
394
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
395
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
396
+ layerName,
397
+ options?.negativeTtl,
398
+ globalNegativeTtl,
399
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
400
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
401
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
402
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
403
+ return this.applyJitter(adaptiveTtl, jitter);
404
+ }
405
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
406
+ if (override !== void 0) {
407
+ return this.readLayerNumber(layerName, override) ?? fallback;
408
+ }
409
+ if (globalDefault !== void 0) {
410
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
411
+ }
412
+ return fallback;
413
+ }
414
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
415
+ if (!ttl || !adaptiveTtl) {
416
+ return ttl;
417
+ }
418
+ const profile = this.accessProfiles.get(key);
419
+ if (!profile) {
420
+ return ttl;
421
+ }
422
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
423
+ const hotAfter = config.hotAfter ?? 3;
424
+ if (profile.hits < hotAfter) {
425
+ return ttl;
426
+ }
427
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
428
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
429
+ const multiplier = Math.floor(profile.hits / hotAfter);
430
+ return Math.min(maxTtl, ttl + step * multiplier);
431
+ }
432
+ applyJitter(ttl, jitter) {
433
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
434
+ return ttl;
435
+ }
436
+ const delta = (Math.random() * 2 - 1) * jitter;
437
+ return Math.max(1, Math.round(ttl + delta));
438
+ }
439
+ readLayerNumber(layerName, value) {
440
+ if (typeof value === "number") {
441
+ return value;
442
+ }
443
+ return value[layerName];
444
+ }
445
+ pruneIfNeeded() {
446
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
447
+ return;
448
+ }
449
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
450
+ let removed = 0;
451
+ for (const key of this.accessProfiles.keys()) {
452
+ if (removed >= toRemove) {
453
+ break;
454
+ }
455
+ this.accessProfiles.delete(key);
456
+ removed += 1;
457
+ }
458
+ }
459
+ };
460
+
115
461
  // src/invalidation/PatternMatcher.ts
116
- var PatternMatcher = class {
462
+ var PatternMatcher = class _PatternMatcher {
463
+ /**
464
+ * Tests whether a glob-style pattern matches a value.
465
+ * Supports `*` (any sequence of characters) and `?` (any single character).
466
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
467
+ */
117
468
  static matches(pattern, value) {
118
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
119
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
120
- return regex.test(value);
469
+ return _PatternMatcher.matchLinear(pattern, value);
470
+ }
471
+ /**
472
+ * Linear-time glob matching using dynamic programming.
473
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
474
+ */
475
+ static matchLinear(pattern, value) {
476
+ const m = pattern.length;
477
+ const n = value.length;
478
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
479
+ dp[0][0] = true;
480
+ for (let i = 1; i <= m; i++) {
481
+ if (pattern[i - 1] === "*") {
482
+ dp[i][0] = dp[i - 1]?.[0];
483
+ }
484
+ }
485
+ for (let i = 1; i <= m; i++) {
486
+ for (let j = 1; j <= n; j++) {
487
+ const pc = pattern[i - 1];
488
+ if (pc === "*") {
489
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
490
+ } else if (pc === "?" || pc === value[j - 1]) {
491
+ dp[i][j] = dp[i - 1]?.[j - 1];
492
+ }
493
+ }
494
+ }
495
+ return dp[m]?.[n];
121
496
  }
122
497
  };
123
498
 
@@ -184,64 +559,75 @@ var import_async_mutex = require("async-mutex");
184
559
  var StampedeGuard = class {
185
560
  mutexes = /* @__PURE__ */ new Map();
186
561
  async execute(key, task) {
187
- const mutex = this.getMutex(key);
562
+ const entry = this.getMutexEntry(key);
188
563
  try {
189
- return await mutex.runExclusive(task);
564
+ return await entry.mutex.runExclusive(task);
190
565
  } finally {
191
- if (!mutex.isLocked()) {
566
+ entry.references -= 1;
567
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
192
568
  this.mutexes.delete(key);
193
569
  }
194
570
  }
195
571
  }
196
- getMutex(key) {
197
- let mutex = this.mutexes.get(key);
198
- if (!mutex) {
199
- mutex = new import_async_mutex.Mutex();
200
- this.mutexes.set(key, mutex);
572
+ getMutexEntry(key) {
573
+ let entry = this.mutexes.get(key);
574
+ if (!entry) {
575
+ entry = { mutex: new import_async_mutex.Mutex(), references: 0 };
576
+ this.mutexes.set(key, entry);
201
577
  }
202
- return mutex;
578
+ entry.references += 1;
579
+ return entry;
203
580
  }
204
581
  };
205
582
 
206
583
  // src/CacheStack.ts
207
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
208
584
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
209
585
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
210
586
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
211
- var EMPTY_METRICS = () => ({
212
- hits: 0,
213
- misses: 0,
214
- fetches: 0,
215
- sets: 0,
216
- deletes: 0,
217
- backfills: 0,
218
- invalidations: 0,
219
- staleHits: 0,
220
- refreshes: 0,
221
- refreshErrors: 0,
222
- writeFailures: 0,
223
- singleFlightWaits: 0
224
- });
587
+ var MAX_CACHE_KEY_LENGTH = 1024;
588
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
225
589
  var DebugLogger = class {
226
590
  enabled;
227
591
  constructor(enabled) {
228
592
  this.enabled = enabled;
229
593
  }
230
594
  debug(message, context) {
595
+ this.write("debug", message, context);
596
+ }
597
+ info(message, context) {
598
+ this.write("info", message, context);
599
+ }
600
+ warn(message, context) {
601
+ this.write("warn", message, context);
602
+ }
603
+ error(message, context) {
604
+ this.write("error", message, context);
605
+ }
606
+ write(level, message, context) {
231
607
  if (!this.enabled) {
232
608
  return;
233
609
  }
234
610
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
235
- console.debug(`[layercache] ${message}${suffix}`);
611
+ console[level](`[layercache] ${message}${suffix}`);
236
612
  }
237
613
  };
238
- var CacheStack = class {
614
+ var CacheStack = class extends import_node_events.EventEmitter {
239
615
  constructor(layers, options = {}) {
616
+ super();
240
617
  this.layers = layers;
241
618
  this.options = options;
242
619
  if (layers.length === 0) {
243
620
  throw new Error("CacheStack requires at least one cache layer.");
244
621
  }
622
+ this.validateConfiguration();
623
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
624
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
625
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
626
+ if (options.publishSetInvalidation !== void 0) {
627
+ console.warn(
628
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
629
+ );
630
+ }
245
631
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
246
632
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
247
633
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -250,112 +636,313 @@ var CacheStack = class {
250
636
  layers;
251
637
  options;
252
638
  stampedeGuard = new StampedeGuard();
253
- metrics = EMPTY_METRICS();
639
+ metricsCollector = new MetricsCollector();
254
640
  instanceId = (0, import_node_crypto.randomUUID)();
255
641
  startup;
256
642
  unsubscribeInvalidation;
257
643
  logger;
258
644
  tagIndex;
259
645
  backgroundRefreshes = /* @__PURE__ */ new Map();
646
+ layerDegradedUntil = /* @__PURE__ */ new Map();
647
+ ttlResolver;
648
+ circuitBreakerManager;
649
+ isDisconnecting = false;
650
+ disconnectPromise;
651
+ /**
652
+ * Read-through cache get.
653
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
654
+ * and stores the result across all layers. Returns `null` if the key is not found
655
+ * and no `fetcher` is provided.
656
+ */
260
657
  async get(key, fetcher, options) {
658
+ const normalizedKey = this.validateCacheKey(key);
659
+ this.validateWriteOptions(options);
261
660
  await this.startup;
262
- const hit = await this.readFromLayers(key, options, "allow-stale");
661
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
263
662
  if (hit.found) {
663
+ this.ttlResolver.recordAccess(normalizedKey);
664
+ if (this.isNegativeStoredValue(hit.stored)) {
665
+ this.metricsCollector.increment("negativeCacheHits");
666
+ }
264
667
  if (hit.state === "fresh") {
265
- this.metrics.hits += 1;
668
+ this.metricsCollector.increment("hits");
669
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
266
670
  return hit.value;
267
671
  }
268
672
  if (hit.state === "stale-while-revalidate") {
269
- this.metrics.hits += 1;
270
- this.metrics.staleHits += 1;
673
+ this.metricsCollector.increment("hits");
674
+ this.metricsCollector.increment("staleHits");
675
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
271
676
  if (fetcher) {
272
- this.scheduleBackgroundRefresh(key, fetcher, options);
677
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
273
678
  }
274
679
  return hit.value;
275
680
  }
276
681
  if (!fetcher) {
277
- this.metrics.hits += 1;
278
- this.metrics.staleHits += 1;
682
+ this.metricsCollector.increment("hits");
683
+ this.metricsCollector.increment("staleHits");
684
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
279
685
  return hit.value;
280
686
  }
281
687
  try {
282
- return await this.fetchWithGuards(key, fetcher, options);
688
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
283
689
  } catch (error) {
284
- this.metrics.staleHits += 1;
285
- this.metrics.refreshErrors += 1;
286
- this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
690
+ this.metricsCollector.increment("staleHits");
691
+ this.metricsCollector.increment("refreshErrors");
692
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
287
693
  return hit.value;
288
694
  }
289
695
  }
290
- this.metrics.misses += 1;
696
+ this.metricsCollector.increment("misses");
291
697
  if (!fetcher) {
292
698
  return null;
293
699
  }
294
- return this.fetchWithGuards(key, fetcher, options);
700
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
701
+ }
702
+ /**
703
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
704
+ * Fetches and caches the value if not already present.
705
+ */
706
+ async getOrSet(key, fetcher, options) {
707
+ return this.get(key, fetcher, options);
708
+ }
709
+ /**
710
+ * Returns true if the given key exists and is not expired in any layer.
711
+ */
712
+ async has(key) {
713
+ const normalizedKey = this.validateCacheKey(key);
714
+ await this.startup;
715
+ for (const layer of this.layers) {
716
+ if (this.shouldSkipLayer(layer)) {
717
+ continue;
718
+ }
719
+ if (layer.has) {
720
+ try {
721
+ const exists = await layer.has(normalizedKey);
722
+ if (exists) {
723
+ return true;
724
+ }
725
+ } catch {
726
+ }
727
+ } else {
728
+ try {
729
+ const value = await layer.get(normalizedKey);
730
+ if (value !== null) {
731
+ return true;
732
+ }
733
+ } catch {
734
+ }
735
+ }
736
+ }
737
+ return false;
738
+ }
739
+ /**
740
+ * Returns the remaining TTL in seconds for the key in the fastest layer
741
+ * that has it, or null if the key is not found / has no TTL.
742
+ */
743
+ async ttl(key) {
744
+ const normalizedKey = this.validateCacheKey(key);
745
+ await this.startup;
746
+ for (const layer of this.layers) {
747
+ if (this.shouldSkipLayer(layer)) {
748
+ continue;
749
+ }
750
+ if (layer.ttl) {
751
+ try {
752
+ const remaining = await layer.ttl(normalizedKey);
753
+ if (remaining !== null) {
754
+ return remaining;
755
+ }
756
+ } catch {
757
+ }
758
+ }
759
+ }
760
+ return null;
295
761
  }
762
+ /**
763
+ * Stores a value in all cache layers. Overwrites any existing value.
764
+ */
296
765
  async set(key, value, options) {
766
+ const normalizedKey = this.validateCacheKey(key);
767
+ this.validateWriteOptions(options);
297
768
  await this.startup;
298
- await this.storeEntry(key, "value", value, options);
769
+ await this.storeEntry(normalizedKey, "value", value, options);
299
770
  }
771
+ /**
772
+ * Deletes the key from all layers and publishes an invalidation message.
773
+ */
300
774
  async delete(key) {
775
+ const normalizedKey = this.validateCacheKey(key);
301
776
  await this.startup;
302
- await this.deleteKeys([key]);
303
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
777
+ await this.deleteKeys([normalizedKey]);
778
+ await this.publishInvalidation({
779
+ scope: "key",
780
+ keys: [normalizedKey],
781
+ sourceId: this.instanceId,
782
+ operation: "delete"
783
+ });
304
784
  }
305
785
  async clear() {
306
786
  await this.startup;
307
787
  await Promise.all(this.layers.map((layer) => layer.clear()));
308
788
  await this.tagIndex.clear();
309
- this.metrics.invalidations += 1;
310
- this.logger.debug("clear");
789
+ this.ttlResolver.clearProfiles();
790
+ this.circuitBreakerManager.clear();
791
+ this.metricsCollector.increment("invalidations");
792
+ this.logger.debug?.("clear");
311
793
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
312
794
  }
795
+ /**
796
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
797
+ */
798
+ async mdelete(keys) {
799
+ if (keys.length === 0) {
800
+ return;
801
+ }
802
+ await this.startup;
803
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
804
+ await this.deleteKeys(normalizedKeys);
805
+ await this.publishInvalidation({
806
+ scope: "keys",
807
+ keys: normalizedKeys,
808
+ sourceId: this.instanceId,
809
+ operation: "delete"
810
+ });
811
+ }
313
812
  async mget(entries) {
314
813
  if (entries.length === 0) {
315
814
  return [];
316
815
  }
317
- const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
816
+ const normalizedEntries = entries.map((entry) => ({
817
+ ...entry,
818
+ key: this.validateCacheKey(entry.key)
819
+ }));
820
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
821
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
318
822
  if (!canFastPath) {
319
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
823
+ const pendingReads = /* @__PURE__ */ new Map();
824
+ return Promise.all(
825
+ normalizedEntries.map((entry) => {
826
+ const optionsSignature = this.serializeOptions(entry.options);
827
+ const existing = pendingReads.get(entry.key);
828
+ if (!existing) {
829
+ const promise = this.get(entry.key, entry.fetch, entry.options);
830
+ pendingReads.set(entry.key, {
831
+ promise,
832
+ fetch: entry.fetch,
833
+ optionsSignature
834
+ });
835
+ return promise;
836
+ }
837
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
838
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
839
+ }
840
+ return existing.promise;
841
+ })
842
+ );
320
843
  }
321
844
  await this.startup;
322
- const pending = new Set(entries.map((_, index) => index));
323
- const results = Array(entries.length).fill(null);
324
- for (const layer of this.layers) {
325
- const indexes = [...pending];
326
- if (indexes.length === 0) {
845
+ const pending = /* @__PURE__ */ new Set();
846
+ const indexesByKey = /* @__PURE__ */ new Map();
847
+ const resultsByKey = /* @__PURE__ */ new Map();
848
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
849
+ const entry = normalizedEntries[index];
850
+ if (!entry) continue;
851
+ const key = entry.key;
852
+ const indexes = indexesByKey.get(key) ?? [];
853
+ indexes.push(index);
854
+ indexesByKey.set(key, indexes);
855
+ pending.add(key);
856
+ }
857
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
858
+ const layer = this.layers[layerIndex];
859
+ if (!layer) continue;
860
+ const keys = [...pending];
861
+ if (keys.length === 0) {
327
862
  break;
328
863
  }
329
- const keys = indexes.map((index) => entries[index].key);
330
864
  const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
331
865
  for (let offset = 0; offset < values.length; offset += 1) {
332
- const index = indexes[offset];
866
+ const key = keys[offset];
333
867
  const stored = values[offset];
334
- if (stored === null) {
868
+ if (!key || stored === null) {
335
869
  continue;
336
870
  }
337
871
  const resolved = resolveStoredValue(stored);
338
872
  if (resolved.state === "expired") {
339
- await layer.delete(entries[index].key);
873
+ await layer.delete(key);
340
874
  continue;
341
875
  }
342
- await this.tagIndex.touch(entries[index].key);
343
- await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
344
- results[index] = resolved.value;
345
- pending.delete(index);
346
- this.metrics.hits += 1;
876
+ await this.tagIndex.touch(key);
877
+ await this.backfill(key, stored, layerIndex - 1);
878
+ resultsByKey.set(key, resolved.value);
879
+ pending.delete(key);
880
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
347
881
  }
348
882
  }
349
883
  if (pending.size > 0) {
350
- for (const index of pending) {
351
- await this.tagIndex.remove(entries[index].key);
352
- this.metrics.misses += 1;
884
+ for (const key of pending) {
885
+ await this.tagIndex.remove(key);
886
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
353
887
  }
354
888
  }
355
- return results;
889
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
356
890
  }
357
891
  async mset(entries) {
358
- await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
892
+ const normalizedEntries = entries.map((entry) => ({
893
+ ...entry,
894
+ key: this.validateCacheKey(entry.key)
895
+ }));
896
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
897
+ await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
898
+ }
899
+ async warm(entries, options = {}) {
900
+ const concurrency = Math.max(1, options.concurrency ?? 4);
901
+ const total = entries.length;
902
+ let completed = 0;
903
+ const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
904
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
905
+ while (queue.length > 0) {
906
+ const entry = queue.shift();
907
+ if (!entry) {
908
+ return;
909
+ }
910
+ let success = false;
911
+ try {
912
+ await this.get(entry.key, entry.fetcher, entry.options);
913
+ this.emit("warm", { key: entry.key });
914
+ success = true;
915
+ } catch (error) {
916
+ this.emitError("warm", { key: entry.key, error: this.formatError(error) });
917
+ if (!options.continueOnError) {
918
+ throw error;
919
+ }
920
+ } finally {
921
+ completed += 1;
922
+ const progress = { completed, total, key: entry.key, success };
923
+ options.onProgress?.(progress);
924
+ }
925
+ }
926
+ });
927
+ await Promise.all(workers);
928
+ }
929
+ /**
930
+ * Returns a cached version of `fetcher`. The cache key is derived from
931
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
932
+ */
933
+ wrap(prefix, fetcher, options = {}) {
934
+ return (...args) => {
935
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
936
+ const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
937
+ return this.get(key, () => fetcher(...args), options);
938
+ };
939
+ }
940
+ /**
941
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
942
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
943
+ */
944
+ namespace(prefix) {
945
+ return new CacheNamespace(this, prefix);
359
946
  }
360
947
  async invalidateByTag(tag) {
361
948
  await this.startup;
@@ -370,15 +957,94 @@ var CacheStack = class {
370
957
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
371
958
  }
372
959
  getMetrics() {
373
- return { ...this.metrics };
960
+ return this.metricsCollector.snapshot;
961
+ }
962
+ getStats() {
963
+ return {
964
+ metrics: this.getMetrics(),
965
+ layers: this.layers.map((layer) => ({
966
+ name: layer.name,
967
+ isLocal: Boolean(layer.isLocal),
968
+ degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
969
+ })),
970
+ backgroundRefreshes: this.backgroundRefreshes.size
971
+ };
374
972
  }
375
973
  resetMetrics() {
376
- Object.assign(this.metrics, EMPTY_METRICS());
974
+ this.metricsCollector.reset();
377
975
  }
378
- async disconnect() {
976
+ /**
977
+ * Returns computed hit-rate statistics (overall and per-layer).
978
+ */
979
+ getHitRate() {
980
+ return this.metricsCollector.hitRate();
981
+ }
982
+ async exportState() {
983
+ await this.startup;
984
+ const exported = /* @__PURE__ */ new Map();
985
+ for (const layer of this.layers) {
986
+ if (!layer.keys) {
987
+ continue;
988
+ }
989
+ const keys = await layer.keys();
990
+ for (const key of keys) {
991
+ if (exported.has(key)) {
992
+ continue;
993
+ }
994
+ const stored = await this.readLayerEntry(layer, key);
995
+ if (stored === null) {
996
+ continue;
997
+ }
998
+ exported.set(key, {
999
+ key,
1000
+ value: stored,
1001
+ ttl: remainingStoredTtlSeconds(stored)
1002
+ });
1003
+ }
1004
+ }
1005
+ return [...exported.values()];
1006
+ }
1007
+ async importState(entries) {
379
1008
  await this.startup;
380
- await this.unsubscribeInvalidation?.();
381
- await Promise.allSettled(this.backgroundRefreshes.values());
1009
+ await Promise.all(
1010
+ entries.map(async (entry) => {
1011
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1012
+ await this.tagIndex.touch(entry.key);
1013
+ })
1014
+ );
1015
+ }
1016
+ async persistToFile(filePath) {
1017
+ const snapshot = await this.exportState();
1018
+ await import_node_fs.promises.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1019
+ }
1020
+ async restoreFromFile(filePath) {
1021
+ const raw = await import_node_fs.promises.readFile(filePath, "utf8");
1022
+ let parsed;
1023
+ try {
1024
+ parsed = JSON.parse(raw, (_key, value) => {
1025
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1026
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1027
+ }
1028
+ return value;
1029
+ });
1030
+ } catch (cause) {
1031
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1032
+ }
1033
+ if (!this.isCacheSnapshotEntries(parsed)) {
1034
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1035
+ }
1036
+ await this.importState(parsed);
1037
+ }
1038
+ async disconnect() {
1039
+ if (!this.disconnectPromise) {
1040
+ this.isDisconnecting = true;
1041
+ this.disconnectPromise = (async () => {
1042
+ await this.startup;
1043
+ await this.unsubscribeInvalidation?.();
1044
+ await Promise.allSettled([...this.backgroundRefreshes.values()]);
1045
+ })();
1046
+ }
1047
+ await this.disconnectPromise;
382
1048
  }
383
1049
  async initialize() {
384
1050
  if (!this.options.invalidationBus) {
@@ -392,7 +1058,7 @@ var CacheStack = class {
392
1058
  const fetchTask = async () => {
393
1059
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
394
1060
  if (secondHit.found) {
395
- this.metrics.hits += 1;
1061
+ this.metricsCollector.increment("hits");
396
1062
  return secondHit.value;
397
1063
  }
398
1064
  return this.fetchAndPopulate(key, fetcher, options);
@@ -417,11 +1083,12 @@ var CacheStack = class {
417
1083
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
418
1084
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
419
1085
  const deadline = Date.now() + timeoutMs;
420
- this.metrics.singleFlightWaits += 1;
1086
+ this.metricsCollector.increment("singleFlightWaits");
1087
+ this.emit("stampede-dedupe", { key });
421
1088
  while (Date.now() < deadline) {
422
1089
  const hit = await this.readFromLayers(key, options, "fresh-only");
423
1090
  if (hit.found) {
424
- this.metrics.hits += 1;
1091
+ this.metricsCollector.increment("hits");
425
1092
  return hit.value;
426
1093
  }
427
1094
  await this.sleep(pollIntervalMs);
@@ -429,8 +1096,18 @@ var CacheStack = class {
429
1096
  return this.fetchAndPopulate(key, fetcher, options);
430
1097
  }
431
1098
  async fetchAndPopulate(key, fetcher, options) {
432
- this.metrics.fetches += 1;
433
- const fetched = await fetcher();
1099
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1100
+ this.metricsCollector.increment("fetches");
1101
+ const fetchStart = Date.now();
1102
+ let fetched;
1103
+ try {
1104
+ fetched = await fetcher();
1105
+ this.circuitBreakerManager.recordSuccess(key);
1106
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1107
+ } catch (error) {
1108
+ this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1109
+ throw error;
1110
+ }
434
1111
  if (fetched === null || fetched === void 0) {
435
1112
  if (!this.shouldNegativeCache(options)) {
436
1113
  return null;
@@ -448,9 +1125,10 @@ var CacheStack = class {
448
1125
  } else {
449
1126
  await this.tagIndex.touch(key);
450
1127
  }
451
- this.metrics.sets += 1;
452
- this.logger.debug("set", { key, kind, tags: options?.tags });
453
- if (this.options.publishSetInvalidation !== false) {
1128
+ this.metricsCollector.increment("sets");
1129
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
1130
+ this.emit("set", { key, kind, tags: options?.tags });
1131
+ if (this.shouldBroadcastL1Invalidation()) {
454
1132
  await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
455
1133
  }
456
1134
  }
@@ -458,8 +1136,10 @@ var CacheStack = class {
458
1136
  let sawRetainableValue = false;
459
1137
  for (let index = 0; index < this.layers.length; index += 1) {
460
1138
  const layer = this.layers[index];
1139
+ if (!layer) continue;
461
1140
  const stored = await this.readLayerEntry(layer, key);
462
1141
  if (stored === null) {
1142
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
463
1143
  continue;
464
1144
  }
465
1145
  const resolved = resolveStoredValue(stored);
@@ -473,20 +1153,41 @@ var CacheStack = class {
473
1153
  }
474
1154
  await this.tagIndex.touch(key);
475
1155
  await this.backfill(key, stored, index - 1, options);
476
- this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
477
- return { found: true, value: resolved.value, stored, state: resolved.state };
1156
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
1157
+ this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
1158
+ this.emit("hit", { key, layer: layer.name, state: resolved.state });
1159
+ return {
1160
+ found: true,
1161
+ value: resolved.value,
1162
+ stored,
1163
+ state: resolved.state,
1164
+ layerIndex: index,
1165
+ layerName: layer.name
1166
+ };
478
1167
  }
479
1168
  if (!sawRetainableValue) {
480
1169
  await this.tagIndex.remove(key);
481
1170
  }
482
- this.logger.debug("miss", { key, mode });
1171
+ this.logger.debug?.("miss", { key, mode });
1172
+ this.emit("miss", { key, mode });
483
1173
  return { found: false, value: null, stored: null, state: "miss" };
484
1174
  }
485
1175
  async readLayerEntry(layer, key) {
1176
+ if (this.shouldSkipLayer(layer)) {
1177
+ return null;
1178
+ }
486
1179
  if (layer.getEntry) {
487
- return layer.getEntry(key);
1180
+ try {
1181
+ return await layer.getEntry(key);
1182
+ } catch (error) {
1183
+ return this.handleLayerFailure(layer, "read", error);
1184
+ }
1185
+ }
1186
+ try {
1187
+ return await layer.get(key);
1188
+ } catch (error) {
1189
+ return this.handleLayerFailure(layer, "read", error);
488
1190
  }
489
- return layer.get(key);
490
1191
  }
491
1192
  async backfill(key, stored, upToIndex, options) {
492
1193
  if (upToIndex < 0) {
@@ -494,26 +1195,34 @@ var CacheStack = class {
494
1195
  }
495
1196
  for (let index = 0; index <= upToIndex; index += 1) {
496
1197
  const layer = this.layers[index];
1198
+ if (!layer || this.shouldSkipLayer(layer)) {
1199
+ continue;
1200
+ }
497
1201
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
498
- await layer.set(key, stored, ttl);
499
- this.metrics.backfills += 1;
500
- this.logger.debug("backfill", { key, layer: layer.name });
1202
+ try {
1203
+ await layer.set(key, stored, ttl);
1204
+ } catch (error) {
1205
+ await this.handleLayerFailure(layer, "backfill", error);
1206
+ continue;
1207
+ }
1208
+ this.metricsCollector.increment("backfills");
1209
+ this.logger.debug?.("backfill", { key, layer: layer.name });
1210
+ this.emit("backfill", { key, layer: layer.name });
501
1211
  }
502
1212
  }
503
1213
  async writeAcrossLayers(key, kind, value, options) {
504
1214
  const now = Date.now();
505
1215
  const operations = this.layers.map((layer) => async () => {
506
- const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
1216
+ if (this.shouldSkipLayer(layer)) {
1217
+ return;
1218
+ }
1219
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
507
1220
  const staleWhileRevalidate = this.resolveLayerSeconds(
508
1221
  layer.name,
509
1222
  options?.staleWhileRevalidate,
510
1223
  this.options.staleWhileRevalidate
511
1224
  );
512
- const staleIfError = this.resolveLayerSeconds(
513
- layer.name,
514
- options?.staleIfError,
515
- this.options.staleIfError
516
- );
1225
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
517
1226
  const payload = createStoredValueEnvelope({
518
1227
  kind,
519
1228
  value,
@@ -523,7 +1232,11 @@ var CacheStack = class {
523
1232
  now
524
1233
  });
525
1234
  const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
526
- await layer.set(key, payload, ttl);
1235
+ try {
1236
+ await layer.set(key, payload, ttl);
1237
+ } catch (error) {
1238
+ await this.handleLayerFailure(layer, "write", error);
1239
+ }
527
1240
  });
528
1241
  await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
529
1242
  }
@@ -537,8 +1250,8 @@ var CacheStack = class {
537
1250
  if (failures.length === 0) {
538
1251
  return;
539
1252
  }
540
- this.metrics.writeFailures += failures.length;
541
- this.logger.debug("write-failure", {
1253
+ this.metricsCollector.increment("writeFailures", failures.length);
1254
+ this.logger.debug?.("write-failure", {
542
1255
  ...context,
543
1256
  failures: failures.map((failure) => this.formatError(failure.reason))
544
1257
  });
@@ -549,52 +1262,26 @@ var CacheStack = class {
549
1262
  );
550
1263
  }
551
1264
  }
552
- resolveFreshTtl(layerName, kind, options, fallbackTtl) {
553
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
554
- layerName,
555
- options?.negativeTtl,
556
- this.options.negativeTtl,
557
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
558
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
559
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
560
- return this.applyJitter(baseTtl, jitter);
1265
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1266
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
561
1267
  }
562
1268
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
563
- if (override !== void 0) {
564
- return this.readLayerNumber(layerName, override) ?? fallback;
565
- }
566
- if (globalDefault !== void 0) {
567
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
568
- }
569
- return fallback;
570
- }
571
- readLayerNumber(layerName, value) {
572
- if (typeof value === "number") {
573
- return value;
574
- }
575
- return value[layerName];
576
- }
577
- applyJitter(ttl, jitter) {
578
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
579
- return ttl;
580
- }
581
- const delta = (Math.random() * 2 - 1) * jitter;
582
- return Math.max(1, Math.round(ttl + delta));
1269
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
583
1270
  }
584
1271
  shouldNegativeCache(options) {
585
1272
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
586
1273
  }
587
1274
  scheduleBackgroundRefresh(key, fetcher, options) {
588
- if (this.backgroundRefreshes.has(key)) {
1275
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
589
1276
  return;
590
1277
  }
591
1278
  const refresh = (async () => {
592
- this.metrics.refreshes += 1;
1279
+ this.metricsCollector.increment("refreshes");
593
1280
  try {
594
1281
  await this.fetchWithGuards(key, fetcher, options);
595
1282
  } catch (error) {
596
- this.metrics.refreshErrors += 1;
597
- this.logger.debug("refresh-error", { key, error: this.formatError(error) });
1283
+ this.metricsCollector.increment("refreshErrors");
1284
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
598
1285
  } finally {
599
1286
  this.backgroundRefreshes.delete(key);
600
1287
  }
@@ -612,21 +1299,16 @@ var CacheStack = class {
612
1299
  if (keys.length === 0) {
613
1300
  return;
614
1301
  }
615
- await Promise.all(
616
- this.layers.map(async (layer) => {
617
- if (layer.deleteMany) {
618
- await layer.deleteMany(keys);
619
- return;
620
- }
621
- await Promise.all(keys.map((key) => layer.delete(key)));
622
- })
623
- );
1302
+ await this.deleteKeysFromLayers(this.layers, keys);
624
1303
  for (const key of keys) {
625
1304
  await this.tagIndex.remove(key);
1305
+ this.ttlResolver.deleteProfile(key);
1306
+ this.circuitBreakerManager.delete(key);
626
1307
  }
627
- this.metrics.deletes += keys.length;
628
- this.metrics.invalidations += 1;
629
- this.logger.debug("delete", { keys });
1308
+ this.metricsCollector.increment("deletes", keys.length);
1309
+ this.metricsCollector.increment("invalidations");
1310
+ this.logger.debug?.("delete", { keys });
1311
+ this.emit("delete", { keys });
630
1312
  }
631
1313
  async publishInvalidation(message) {
632
1314
  if (!this.options.invalidationBus) {
@@ -645,21 +1327,15 @@ var CacheStack = class {
645
1327
  if (message.scope === "clear") {
646
1328
  await Promise.all(localLayers.map((layer) => layer.clear()));
647
1329
  await this.tagIndex.clear();
1330
+ this.ttlResolver.clearProfiles();
648
1331
  return;
649
1332
  }
650
1333
  const keys = message.keys ?? [];
651
- await Promise.all(
652
- localLayers.map(async (layer) => {
653
- if (layer.deleteMany) {
654
- await layer.deleteMany(keys);
655
- return;
656
- }
657
- await Promise.all(keys.map((key) => layer.delete(key)));
658
- })
659
- );
1334
+ await this.deleteKeysFromLayers(localLayers, keys);
660
1335
  if (message.operation !== "write") {
661
1336
  for (const key of keys) {
662
1337
  await this.tagIndex.remove(key);
1338
+ this.ttlResolver.deleteProfile(key);
663
1339
  }
664
1340
  }
665
1341
  }
@@ -672,6 +1348,210 @@ var CacheStack = class {
672
1348
  sleep(ms) {
673
1349
  return new Promise((resolve) => setTimeout(resolve, ms));
674
1350
  }
1351
+ shouldBroadcastL1Invalidation() {
1352
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1353
+ }
1354
+ async deleteKeysFromLayers(layers, keys) {
1355
+ await Promise.all(
1356
+ layers.map(async (layer) => {
1357
+ if (this.shouldSkipLayer(layer)) {
1358
+ return;
1359
+ }
1360
+ if (layer.deleteMany) {
1361
+ try {
1362
+ await layer.deleteMany(keys);
1363
+ } catch (error) {
1364
+ await this.handleLayerFailure(layer, "delete", error);
1365
+ }
1366
+ return;
1367
+ }
1368
+ await Promise.all(
1369
+ keys.map(async (key) => {
1370
+ try {
1371
+ await layer.delete(key);
1372
+ } catch (error) {
1373
+ await this.handleLayerFailure(layer, "delete", error);
1374
+ }
1375
+ })
1376
+ );
1377
+ })
1378
+ );
1379
+ }
1380
+ validateConfiguration() {
1381
+ if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
1382
+ throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
1383
+ }
1384
+ if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
1385
+ throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
1386
+ }
1387
+ this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
1388
+ this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
1389
+ this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
1390
+ this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
1391
+ this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
1392
+ this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1393
+ this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1394
+ this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1395
+ this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1396
+ this.validateCircuitBreakerOptions(this.options.circuitBreaker);
1397
+ }
1398
+ validateWriteOptions(options) {
1399
+ if (!options) {
1400
+ return;
1401
+ }
1402
+ this.validateLayerNumberOption("options.ttl", options.ttl);
1403
+ this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
1404
+ this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
1405
+ this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
1406
+ this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
1407
+ this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
1408
+ this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1409
+ this.validateCircuitBreakerOptions(options.circuitBreaker);
1410
+ }
1411
+ validateLayerNumberOption(name, value) {
1412
+ if (value === void 0) {
1413
+ return;
1414
+ }
1415
+ if (typeof value === "number") {
1416
+ this.validateNonNegativeNumber(name, value);
1417
+ return;
1418
+ }
1419
+ for (const [layerName, layerValue] of Object.entries(value)) {
1420
+ if (layerValue === void 0) {
1421
+ continue;
1422
+ }
1423
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
1424
+ }
1425
+ }
1426
+ validatePositiveNumber(name, value) {
1427
+ if (value === void 0) {
1428
+ return;
1429
+ }
1430
+ if (!Number.isFinite(value) || value <= 0) {
1431
+ throw new Error(`${name} must be a positive finite number.`);
1432
+ }
1433
+ }
1434
+ validateNonNegativeNumber(name, value) {
1435
+ if (!Number.isFinite(value) || value < 0) {
1436
+ throw new Error(`${name} must be a non-negative finite number.`);
1437
+ }
1438
+ }
1439
+ validateCacheKey(key) {
1440
+ if (key.length === 0) {
1441
+ throw new Error("Cache key must not be empty.");
1442
+ }
1443
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
1444
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1445
+ }
1446
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
1447
+ throw new Error("Cache key contains unsupported control characters.");
1448
+ }
1449
+ return key;
1450
+ }
1451
+ serializeOptions(options) {
1452
+ return JSON.stringify(this.normalizeForSerialization(options) ?? null);
1453
+ }
1454
+ validateAdaptiveTtlOptions(options) {
1455
+ if (!options || options === true) {
1456
+ return;
1457
+ }
1458
+ this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1459
+ this.validateLayerNumberOption("adaptiveTtl.step", options.step);
1460
+ this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1461
+ }
1462
+ validateCircuitBreakerOptions(options) {
1463
+ if (!options) {
1464
+ return;
1465
+ }
1466
+ this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1467
+ this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1468
+ }
1469
+ async applyFreshReadPolicies(key, hit, options, fetcher) {
1470
+ const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
1471
+ const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
1472
+ if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
1473
+ const refreshed = refreshStoredEnvelope(hit.stored);
1474
+ const ttl = remainingStoredTtlSeconds(refreshed);
1475
+ for (let index = 0; index <= hit.layerIndex; index += 1) {
1476
+ const layer = this.layers[index];
1477
+ if (!layer || this.shouldSkipLayer(layer)) {
1478
+ continue;
1479
+ }
1480
+ try {
1481
+ await layer.set(key, refreshed, ttl);
1482
+ } catch (error) {
1483
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1484
+ }
1485
+ }
1486
+ }
1487
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1488
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1489
+ }
1490
+ }
1491
+ shouldSkipLayer(layer) {
1492
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
1493
+ return degradedUntil !== void 0 && degradedUntil > Date.now();
1494
+ }
1495
+ async handleLayerFailure(layer, operation, error) {
1496
+ if (!this.isGracefulDegradationEnabled()) {
1497
+ throw error;
1498
+ }
1499
+ const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1500
+ this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1501
+ this.metricsCollector.increment("degradedOperations");
1502
+ this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1503
+ this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1504
+ return null;
1505
+ }
1506
+ isGracefulDegradationEnabled() {
1507
+ return Boolean(this.options.gracefulDegradation);
1508
+ }
1509
+ recordCircuitFailure(key, options, error) {
1510
+ if (!options) {
1511
+ return;
1512
+ }
1513
+ this.circuitBreakerManager.recordFailure(key, options);
1514
+ if (this.circuitBreakerManager.isOpen(key)) {
1515
+ this.metricsCollector.increment("circuitBreakerTrips");
1516
+ }
1517
+ this.emitError("fetch", { key, error: this.formatError(error) });
1518
+ }
1519
+ isNegativeStoredValue(stored) {
1520
+ return isStoredValueEnvelope(stored) && stored.kind === "empty";
1521
+ }
1522
+ emitError(operation, context) {
1523
+ this.logger.error?.(operation, context);
1524
+ if (this.listenerCount("error") > 0) {
1525
+ this.emit("error", { operation, ...context });
1526
+ }
1527
+ }
1528
+ serializeKeyPart(value) {
1529
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1530
+ return String(value);
1531
+ }
1532
+ return JSON.stringify(this.normalizeForSerialization(value));
1533
+ }
1534
+ isCacheSnapshotEntries(value) {
1535
+ return Array.isArray(value) && value.every((entry) => {
1536
+ if (!entry || typeof entry !== "object") {
1537
+ return false;
1538
+ }
1539
+ const candidate = entry;
1540
+ return typeof candidate.key === "string";
1541
+ });
1542
+ }
1543
+ normalizeForSerialization(value) {
1544
+ if (Array.isArray(value)) {
1545
+ return value.map((entry) => this.normalizeForSerialization(entry));
1546
+ }
1547
+ if (value && typeof value === "object") {
1548
+ return Object.keys(value).sort().reduce((normalized, key) => {
1549
+ normalized[key] = this.normalizeForSerialization(value[key]);
1550
+ return normalized;
1551
+ }, {});
1552
+ }
1553
+ return value;
1554
+ }
675
1555
  };
676
1556
 
677
1557
  // src/invalidation/RedisInvalidationBus.ts
@@ -679,19 +1559,27 @@ var RedisInvalidationBus = class {
679
1559
  channel;
680
1560
  publisher;
681
1561
  subscriber;
1562
+ activeListener;
682
1563
  constructor(options) {
683
1564
  this.publisher = options.publisher;
684
1565
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
685
1566
  this.channel = options.channel ?? "layercache:invalidation";
686
1567
  }
687
1568
  async subscribe(handler) {
688
- const listener = async (_channel, payload) => {
689
- const message = JSON.parse(payload);
690
- await handler(message);
1569
+ if (this.activeListener) {
1570
+ throw new Error("RedisInvalidationBus already has an active subscription.");
1571
+ }
1572
+ const listener = (_channel, payload) => {
1573
+ void this.handleMessage(payload, handler);
691
1574
  };
1575
+ this.activeListener = listener;
692
1576
  this.subscriber.on("message", listener);
693
1577
  await this.subscriber.subscribe(this.channel);
694
1578
  return async () => {
1579
+ if (this.activeListener !== listener) {
1580
+ return;
1581
+ }
1582
+ this.activeListener = void 0;
695
1583
  this.subscriber.off("message", listener);
696
1584
  await this.subscriber.unsubscribe(this.channel);
697
1585
  };
@@ -699,6 +1587,37 @@ var RedisInvalidationBus = class {
699
1587
  async publish(message) {
700
1588
  await this.publisher.publish(this.channel, JSON.stringify(message));
701
1589
  }
1590
+ async handleMessage(payload, handler) {
1591
+ let message;
1592
+ try {
1593
+ const parsed = JSON.parse(payload);
1594
+ if (!this.isInvalidationMessage(parsed)) {
1595
+ throw new Error("Invalid invalidation payload shape.");
1596
+ }
1597
+ message = parsed;
1598
+ } catch (error) {
1599
+ this.reportError("invalid invalidation payload", error);
1600
+ return;
1601
+ }
1602
+ try {
1603
+ await handler(message);
1604
+ } catch (error) {
1605
+ this.reportError("invalidation handler failed", error);
1606
+ }
1607
+ }
1608
+ isInvalidationMessage(value) {
1609
+ if (!value || typeof value !== "object") {
1610
+ return false;
1611
+ }
1612
+ const candidate = value;
1613
+ const validScope = candidate.scope === "key" || candidate.scope === "keys" || candidate.scope === "clear";
1614
+ const validOperation = candidate.operation === void 0 || candidate.operation === "write" || candidate.operation === "delete" || candidate.operation === "invalidate" || candidate.operation === "clear";
1615
+ const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
1616
+ return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
1617
+ }
1618
+ reportError(message, error) {
1619
+ console.error(`[layercache] ${message}`, error);
1620
+ }
702
1621
  };
703
1622
 
704
1623
  // src/invalidation/RedisTagIndex.ts
@@ -791,17 +1710,97 @@ var RedisTagIndex = class {
791
1710
  }
792
1711
  };
793
1712
 
1713
+ // src/http/createCacheStatsHandler.ts
1714
+ function createCacheStatsHandler(cache) {
1715
+ return async (_request, response) => {
1716
+ response.statusCode = 200;
1717
+ response.setHeader?.("content-type", "application/json; charset=utf-8");
1718
+ response.end(JSON.stringify(cache.getStats(), null, 2));
1719
+ };
1720
+ }
1721
+
1722
+ // src/decorators/createCachedMethodDecorator.ts
1723
+ function createCachedMethodDecorator(options) {
1724
+ const wrappedByInstance = /* @__PURE__ */ new WeakMap();
1725
+ return ((_, propertyKey, descriptor) => {
1726
+ const original = descriptor.value;
1727
+ if (typeof original !== "function") {
1728
+ throw new Error("createCachedMethodDecorator can only be applied to methods.");
1729
+ }
1730
+ descriptor.value = async function(...args) {
1731
+ const instance = this;
1732
+ let wrapped = wrappedByInstance.get(instance);
1733
+ if (!wrapped) {
1734
+ const cache = options.cache(instance);
1735
+ wrapped = cache.wrap(
1736
+ options.prefix ?? String(propertyKey),
1737
+ (...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
1738
+ options
1739
+ );
1740
+ wrappedByInstance.set(instance, wrapped);
1741
+ }
1742
+ return wrapped(...args);
1743
+ };
1744
+ });
1745
+ }
1746
+
1747
+ // src/integrations/fastify.ts
1748
+ function createFastifyLayercachePlugin(cache, options = {}) {
1749
+ return async (fastify) => {
1750
+ fastify.decorate("cache", cache);
1751
+ if (options.exposeStatsRoute !== false && fastify.get) {
1752
+ fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
1753
+ }
1754
+ };
1755
+ }
1756
+
1757
+ // src/integrations/graphql.ts
1758
+ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1759
+ const wrapped = cache.wrap(prefix, resolver, {
1760
+ ...options,
1761
+ keyResolver: options.keyResolver
1762
+ });
1763
+ return (...args) => wrapped(...args);
1764
+ }
1765
+
1766
+ // src/integrations/trpc.ts
1767
+ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
1768
+ return async (context) => {
1769
+ const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
1770
+ let didFetch = false;
1771
+ let fetchedResult = null;
1772
+ const cached = await cache.get(
1773
+ key,
1774
+ async () => {
1775
+ didFetch = true;
1776
+ fetchedResult = await context.next();
1777
+ return fetchedResult;
1778
+ },
1779
+ options
1780
+ );
1781
+ if (cached !== null) {
1782
+ return cached;
1783
+ }
1784
+ if (didFetch) {
1785
+ return fetchedResult;
1786
+ }
1787
+ return context.next();
1788
+ };
1789
+ }
1790
+
794
1791
  // src/layers/MemoryLayer.ts
795
1792
  var MemoryLayer = class {
796
1793
  name;
797
1794
  defaultTtl;
798
1795
  isLocal = true;
799
1796
  maxSize;
1797
+ evictionPolicy;
800
1798
  entries = /* @__PURE__ */ new Map();
801
1799
  constructor(options = {}) {
802
1800
  this.name = options.name ?? "memory";
803
1801
  this.defaultTtl = options.ttl;
804
1802
  this.maxSize = options.maxSize ?? 1e3;
1803
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
805
1804
  }
806
1805
  async get(key) {
807
1806
  const value = await this.getEntry(key);
@@ -816,8 +1815,13 @@ var MemoryLayer = class {
816
1815
  this.entries.delete(key);
817
1816
  return null;
818
1817
  }
819
- this.entries.delete(key);
820
- this.entries.set(key, entry);
1818
+ if (this.evictionPolicy === "lru") {
1819
+ this.entries.delete(key);
1820
+ entry.frequency += 1;
1821
+ this.entries.set(key, entry);
1822
+ } else {
1823
+ entry.frequency += 1;
1824
+ }
821
1825
  return entry.value;
822
1826
  }
823
1827
  async getMany(keys) {
@@ -831,15 +1835,42 @@ var MemoryLayer = class {
831
1835
  this.entries.delete(key);
832
1836
  this.entries.set(key, {
833
1837
  value,
834
- expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
1838
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1839
+ frequency: 0,
1840
+ insertedAt: Date.now()
835
1841
  });
836
1842
  while (this.entries.size > this.maxSize) {
837
- const oldestKey = this.entries.keys().next().value;
838
- if (!oldestKey) {
839
- break;
840
- }
841
- this.entries.delete(oldestKey);
1843
+ this.evict();
1844
+ }
1845
+ }
1846
+ async has(key) {
1847
+ const entry = this.entries.get(key);
1848
+ if (!entry) {
1849
+ return false;
1850
+ }
1851
+ if (this.isExpired(entry)) {
1852
+ this.entries.delete(key);
1853
+ return false;
1854
+ }
1855
+ return true;
1856
+ }
1857
+ async ttl(key) {
1858
+ const entry = this.entries.get(key);
1859
+ if (!entry) {
1860
+ return null;
842
1861
  }
1862
+ if (this.isExpired(entry)) {
1863
+ this.entries.delete(key);
1864
+ return null;
1865
+ }
1866
+ if (entry.expiresAt === null) {
1867
+ return null;
1868
+ }
1869
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
1870
+ }
1871
+ async size() {
1872
+ this.pruneExpired();
1873
+ return this.entries.size;
843
1874
  }
844
1875
  async delete(key) {
845
1876
  this.entries.delete(key);
@@ -856,6 +1887,52 @@ var MemoryLayer = class {
856
1887
  this.pruneExpired();
857
1888
  return [...this.entries.keys()];
858
1889
  }
1890
+ exportState() {
1891
+ this.pruneExpired();
1892
+ return [...this.entries.entries()].map(([key, entry]) => ({
1893
+ key,
1894
+ value: entry.value,
1895
+ expiresAt: entry.expiresAt
1896
+ }));
1897
+ }
1898
+ importState(entries) {
1899
+ for (const entry of entries) {
1900
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
1901
+ continue;
1902
+ }
1903
+ this.entries.set(entry.key, {
1904
+ value: entry.value,
1905
+ expiresAt: entry.expiresAt,
1906
+ frequency: 0,
1907
+ insertedAt: Date.now()
1908
+ });
1909
+ }
1910
+ while (this.entries.size > this.maxSize) {
1911
+ this.evict();
1912
+ }
1913
+ }
1914
+ evict() {
1915
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
1916
+ const oldestKey = this.entries.keys().next().value;
1917
+ if (oldestKey !== void 0) {
1918
+ this.entries.delete(oldestKey);
1919
+ }
1920
+ return;
1921
+ }
1922
+ let victimKey;
1923
+ let minFreq = Number.POSITIVE_INFINITY;
1924
+ let minInsertedAt = Number.POSITIVE_INFINITY;
1925
+ for (const [key, entry] of this.entries.entries()) {
1926
+ if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1927
+ minFreq = entry.frequency;
1928
+ minInsertedAt = entry.insertedAt;
1929
+ victimKey = key;
1930
+ }
1931
+ }
1932
+ if (victimKey !== void 0) {
1933
+ this.entries.delete(victimKey);
1934
+ }
1935
+ }
859
1936
  pruneExpired() {
860
1937
  for (const [key, entry] of this.entries.entries()) {
861
1938
  if (this.isExpired(entry)) {
@@ -868,6 +1945,9 @@ var MemoryLayer = class {
868
1945
  }
869
1946
  };
870
1947
 
1948
+ // src/layers/RedisLayer.ts
1949
+ var import_node_zlib = require("zlib");
1950
+
871
1951
  // src/serialization/JsonSerializer.ts
872
1952
  var JsonSerializer = class {
873
1953
  serialize(value) {
@@ -880,6 +1960,7 @@ var JsonSerializer = class {
880
1960
  };
881
1961
 
882
1962
  // src/layers/RedisLayer.ts
1963
+ var BATCH_DELETE_SIZE = 500;
883
1964
  var RedisLayer = class {
884
1965
  name;
885
1966
  defaultTtl;
@@ -889,6 +1970,8 @@ var RedisLayer = class {
889
1970
  prefix;
890
1971
  allowUnprefixedClear;
891
1972
  scanCount;
1973
+ compression;
1974
+ compressionThreshold;
892
1975
  constructor(options) {
893
1976
  this.client = options.client;
894
1977
  this.defaultTtl = options.ttl;
@@ -897,6 +1980,8 @@ var RedisLayer = class {
897
1980
  this.prefix = options.prefix ?? "";
898
1981
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
899
1982
  this.scanCount = options.scanCount ?? 100;
1983
+ this.compression = options.compression;
1984
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
900
1985
  }
901
1986
  async get(key) {
902
1987
  const payload = await this.getEntry(key);
@@ -907,7 +1992,7 @@ var RedisLayer = class {
907
1992
  if (payload === null) {
908
1993
  return null;
909
1994
  }
910
- return this.serializer.deserialize(payload);
1995
+ return this.deserializeOrDelete(key, payload);
911
1996
  }
912
1997
  async getMany(keys) {
913
1998
  if (keys.length === 0) {
@@ -921,16 +2006,18 @@ var RedisLayer = class {
921
2006
  if (results === null) {
922
2007
  return keys.map(() => null);
923
2008
  }
924
- return results.map((result) => {
925
- const [, payload] = result;
926
- if (payload === null) {
927
- return null;
928
- }
929
- return this.serializer.deserialize(payload);
930
- });
2009
+ return Promise.all(
2010
+ results.map(async (result, index) => {
2011
+ const [error, payload] = result;
2012
+ if (error || payload === null || !this.isSerializablePayload(payload)) {
2013
+ return null;
2014
+ }
2015
+ return this.deserializeOrDelete(keys[index] ?? "", payload);
2016
+ })
2017
+ );
931
2018
  }
932
2019
  async set(key, value, ttl = this.defaultTtl) {
933
- const payload = this.serializer.serialize(value);
2020
+ const payload = this.encodePayload(this.serializer.serialize(value));
934
2021
  const normalizedKey = this.withPrefix(key);
935
2022
  if (ttl && ttl > 0) {
936
2023
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -947,14 +2034,44 @@ var RedisLayer = class {
947
2034
  }
948
2035
  await this.client.del(...keys.map((key) => this.withPrefix(key)));
949
2036
  }
950
- async clear() {
951
- if (!this.prefix && !this.allowUnprefixedClear) {
952
- throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
2037
+ async has(key) {
2038
+ const exists = await this.client.exists(this.withPrefix(key));
2039
+ return exists > 0;
2040
+ }
2041
+ async ttl(key) {
2042
+ const remaining = await this.client.ttl(this.withPrefix(key));
2043
+ if (remaining < 0) {
2044
+ return null;
953
2045
  }
2046
+ return remaining;
2047
+ }
2048
+ async size() {
954
2049
  const keys = await this.keys();
955
- if (keys.length > 0) {
956
- await this.deleteMany(keys);
2050
+ return keys.length;
2051
+ }
2052
+ /**
2053
+ * Deletes all keys matching the layer's prefix in batches to avoid
2054
+ * loading millions of keys into memory at once.
2055
+ */
2056
+ async clear() {
2057
+ if (!this.prefix && !this.allowUnprefixedClear) {
2058
+ throw new Error(
2059
+ "RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
2060
+ );
957
2061
  }
2062
+ const pattern = `${this.prefix}*`;
2063
+ let cursor = "0";
2064
+ do {
2065
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
2066
+ cursor = nextCursor;
2067
+ if (keys.length === 0) {
2068
+ continue;
2069
+ }
2070
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
2071
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
2072
+ await this.client.del(...batch);
2073
+ }
2074
+ } while (cursor !== "0");
958
2075
  }
959
2076
  async keys() {
960
2077
  const pattern = `${this.prefix}*`;
@@ -977,6 +2094,205 @@ var RedisLayer = class {
977
2094
  withPrefix(key) {
978
2095
  return `${this.prefix}${key}`;
979
2096
  }
2097
+ async deserializeOrDelete(key, payload) {
2098
+ try {
2099
+ return this.serializer.deserialize(this.decodePayload(payload));
2100
+ } catch {
2101
+ await this.client.del(this.withPrefix(key)).catch(() => void 0);
2102
+ return null;
2103
+ }
2104
+ }
2105
+ isSerializablePayload(payload) {
2106
+ return typeof payload === "string" || Buffer.isBuffer(payload);
2107
+ }
2108
+ encodePayload(payload) {
2109
+ if (!this.compression) {
2110
+ return payload;
2111
+ }
2112
+ const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
2113
+ if (source.byteLength < this.compressionThreshold) {
2114
+ return payload;
2115
+ }
2116
+ const header = Buffer.from(`LCZ1:${this.compression}:`);
2117
+ const compressed = this.compression === "gzip" ? (0, import_node_zlib.gzipSync)(source) : (0, import_node_zlib.brotliCompressSync)(source);
2118
+ return Buffer.concat([header, compressed]);
2119
+ }
2120
+ decodePayload(payload) {
2121
+ if (!Buffer.isBuffer(payload)) {
2122
+ return payload;
2123
+ }
2124
+ if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
2125
+ return (0, import_node_zlib.gunzipSync)(payload.subarray(10));
2126
+ }
2127
+ if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
2128
+ return (0, import_node_zlib.brotliDecompressSync)(payload.subarray(12));
2129
+ }
2130
+ return payload;
2131
+ }
2132
+ };
2133
+
2134
+ // src/layers/DiskLayer.ts
2135
+ var import_node_crypto2 = require("crypto");
2136
+ var import_node_fs2 = require("fs");
2137
+ var import_node_path = require("path");
2138
+ var DiskLayer = class {
2139
+ name;
2140
+ defaultTtl;
2141
+ isLocal = true;
2142
+ directory;
2143
+ serializer;
2144
+ constructor(options) {
2145
+ this.directory = options.directory;
2146
+ this.defaultTtl = options.ttl;
2147
+ this.name = options.name ?? "disk";
2148
+ this.serializer = options.serializer ?? new JsonSerializer();
2149
+ }
2150
+ async get(key) {
2151
+ return unwrapStoredValue(await this.getEntry(key));
2152
+ }
2153
+ async getEntry(key) {
2154
+ const filePath = this.keyToPath(key);
2155
+ let raw;
2156
+ try {
2157
+ raw = await import_node_fs2.promises.readFile(filePath);
2158
+ } catch {
2159
+ return null;
2160
+ }
2161
+ let entry;
2162
+ try {
2163
+ entry = this.serializer.deserialize(raw);
2164
+ } catch {
2165
+ await this.safeDelete(filePath);
2166
+ return null;
2167
+ }
2168
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2169
+ await this.safeDelete(filePath);
2170
+ return null;
2171
+ }
2172
+ return entry.value;
2173
+ }
2174
+ async set(key, value, ttl = this.defaultTtl) {
2175
+ await import_node_fs2.promises.mkdir(this.directory, { recursive: true });
2176
+ const entry = {
2177
+ value,
2178
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2179
+ };
2180
+ const payload = this.serializer.serialize(entry);
2181
+ await import_node_fs2.promises.writeFile(this.keyToPath(key), payload);
2182
+ }
2183
+ async has(key) {
2184
+ const value = await this.getEntry(key);
2185
+ return value !== null;
2186
+ }
2187
+ async ttl(key) {
2188
+ const filePath = this.keyToPath(key);
2189
+ let raw;
2190
+ try {
2191
+ raw = await import_node_fs2.promises.readFile(filePath);
2192
+ } catch {
2193
+ return null;
2194
+ }
2195
+ let entry;
2196
+ try {
2197
+ entry = this.serializer.deserialize(raw);
2198
+ } catch {
2199
+ return null;
2200
+ }
2201
+ if (entry.expiresAt === null) {
2202
+ return null;
2203
+ }
2204
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
2205
+ if (remaining <= 0) {
2206
+ return null;
2207
+ }
2208
+ return remaining;
2209
+ }
2210
+ async delete(key) {
2211
+ await this.safeDelete(this.keyToPath(key));
2212
+ }
2213
+ async deleteMany(keys) {
2214
+ await Promise.all(keys.map((key) => this.delete(key)));
2215
+ }
2216
+ async clear() {
2217
+ let entries;
2218
+ try {
2219
+ entries = await import_node_fs2.promises.readdir(this.directory);
2220
+ } catch {
2221
+ return;
2222
+ }
2223
+ await Promise.all(
2224
+ entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete((0, import_node_path.join)(this.directory, name)))
2225
+ );
2226
+ }
2227
+ async keys() {
2228
+ let entries;
2229
+ try {
2230
+ entries = await import_node_fs2.promises.readdir(this.directory);
2231
+ } catch {
2232
+ return [];
2233
+ }
2234
+ return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2235
+ }
2236
+ async size() {
2237
+ const keys = await this.keys();
2238
+ return keys.length;
2239
+ }
2240
+ keyToPath(key) {
2241
+ const hash = (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex");
2242
+ return (0, import_node_path.join)(this.directory, `${hash}.lc`);
2243
+ }
2244
+ async safeDelete(filePath) {
2245
+ try {
2246
+ await import_node_fs2.promises.unlink(filePath);
2247
+ } catch {
2248
+ }
2249
+ }
2250
+ };
2251
+
2252
+ // src/layers/MemcachedLayer.ts
2253
+ var MemcachedLayer = class {
2254
+ name;
2255
+ defaultTtl;
2256
+ isLocal = false;
2257
+ client;
2258
+ keyPrefix;
2259
+ constructor(options) {
2260
+ this.client = options.client;
2261
+ this.defaultTtl = options.ttl;
2262
+ this.name = options.name ?? "memcached";
2263
+ this.keyPrefix = options.keyPrefix ?? "";
2264
+ }
2265
+ async get(key) {
2266
+ const result = await this.client.get(this.withPrefix(key));
2267
+ if (!result || result.value === null) {
2268
+ return null;
2269
+ }
2270
+ try {
2271
+ return JSON.parse(result.value.toString("utf8"));
2272
+ } catch {
2273
+ return null;
2274
+ }
2275
+ }
2276
+ async set(key, value, ttl = this.defaultTtl) {
2277
+ const payload = JSON.stringify(value);
2278
+ await this.client.set(this.withPrefix(key), payload, {
2279
+ expires: ttl && ttl > 0 ? ttl : void 0
2280
+ });
2281
+ }
2282
+ async delete(key) {
2283
+ await this.client.delete(this.withPrefix(key));
2284
+ }
2285
+ async deleteMany(keys) {
2286
+ await Promise.all(keys.map((key) => this.delete(key)));
2287
+ }
2288
+ async clear() {
2289
+ throw new Error(
2290
+ "MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
2291
+ );
2292
+ }
2293
+ withPrefix(key) {
2294
+ return `${this.keyPrefix}${key}`;
2295
+ }
980
2296
  };
981
2297
 
982
2298
  // src/serialization/MsgpackSerializer.ts
@@ -992,7 +2308,7 @@ var MsgpackSerializer = class {
992
2308
  };
993
2309
 
994
2310
  // src/singleflight/RedisSingleFlightCoordinator.ts
995
- var import_node_crypto2 = require("crypto");
2311
+ var import_node_crypto3 = require("crypto");
996
2312
  var RELEASE_SCRIPT = `
997
2313
  if redis.call("get", KEYS[1]) == ARGV[1] then
998
2314
  return redis.call("del", KEYS[1])
@@ -1008,7 +2324,7 @@ var RedisSingleFlightCoordinator = class {
1008
2324
  }
1009
2325
  async execute(key, options, worker, waiter) {
1010
2326
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
1011
- const token = (0, import_node_crypto2.randomUUID)();
2327
+ const token = (0, import_node_crypto3.randomUUID)();
1012
2328
  const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
1013
2329
  if (acquired === "OK") {
1014
2330
  try {
@@ -1020,10 +2336,80 @@ var RedisSingleFlightCoordinator = class {
1020
2336
  return waiter();
1021
2337
  }
1022
2338
  };
2339
+
2340
+ // src/metrics/PrometheusExporter.ts
2341
+ function createPrometheusMetricsExporter(stacks) {
2342
+ return () => {
2343
+ const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
2344
+ const lines = [];
2345
+ lines.push("# HELP layercache_hits_total Total number of cache hits");
2346
+ lines.push("# TYPE layercache_hits_total counter");
2347
+ lines.push("# HELP layercache_misses_total Total number of cache misses");
2348
+ lines.push("# TYPE layercache_misses_total counter");
2349
+ lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
2350
+ lines.push("# TYPE layercache_fetches_total counter");
2351
+ lines.push("# HELP layercache_sets_total Total number of cache sets");
2352
+ lines.push("# TYPE layercache_sets_total counter");
2353
+ lines.push("# HELP layercache_deletes_total Total number of cache deletes");
2354
+ lines.push("# TYPE layercache_deletes_total counter");
2355
+ lines.push("# HELP layercache_backfills_total Total number of backfill operations");
2356
+ lines.push("# TYPE layercache_backfills_total counter");
2357
+ lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
2358
+ lines.push("# TYPE layercache_stale_hits_total counter");
2359
+ lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
2360
+ lines.push("# TYPE layercache_refreshes_total counter");
2361
+ lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
2362
+ lines.push("# TYPE layercache_refresh_errors_total counter");
2363
+ lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
2364
+ lines.push("# TYPE layercache_negative_cache_hits_total counter");
2365
+ lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
2366
+ lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
2367
+ lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
2368
+ lines.push("# TYPE layercache_degraded_operations_total counter");
2369
+ lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
2370
+ lines.push("# TYPE layercache_hit_rate gauge");
2371
+ lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
2372
+ lines.push("# TYPE layercache_hits_by_layer_total counter");
2373
+ lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2374
+ lines.push("# TYPE layercache_misses_by_layer_total counter");
2375
+ for (const { stack, name } of entries) {
2376
+ const m = stack.getMetrics();
2377
+ const hr = stack.getHitRate();
2378
+ const label = `cache="${sanitizeLabel(name)}"`;
2379
+ lines.push(`layercache_hits_total{${label}} ${m.hits}`);
2380
+ lines.push(`layercache_misses_total{${label}} ${m.misses}`);
2381
+ lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
2382
+ lines.push(`layercache_sets_total{${label}} ${m.sets}`);
2383
+ lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
2384
+ lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
2385
+ lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
2386
+ lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
2387
+ lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
2388
+ lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
2389
+ lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
2390
+ lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
2391
+ lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
2392
+ for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
2393
+ lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2394
+ }
2395
+ for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2396
+ lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2397
+ }
2398
+ }
2399
+ lines.push("");
2400
+ return lines.join("\n");
2401
+ };
2402
+ }
2403
+ function sanitizeLabel(value) {
2404
+ return value.replace(/["\\\n]/g, "_");
2405
+ }
1023
2406
  // Annotate the CommonJS export names for ESM import in node:
1024
2407
  0 && (module.exports = {
2408
+ CacheNamespace,
1025
2409
  CacheStack,
2410
+ DiskLayer,
1026
2411
  JsonSerializer,
2412
+ MemcachedLayer,
1027
2413
  MemoryLayer,
1028
2414
  MsgpackSerializer,
1029
2415
  PatternMatcher,
@@ -1032,5 +2418,11 @@ var RedisSingleFlightCoordinator = class {
1032
2418
  RedisSingleFlightCoordinator,
1033
2419
  RedisTagIndex,
1034
2420
  StampedeGuard,
1035
- TagIndex
2421
+ TagIndex,
2422
+ cacheGraphqlResolver,
2423
+ createCacheStatsHandler,
2424
+ createCachedMethodDecorator,
2425
+ createFastifyLayercachePlugin,
2426
+ createPrometheusMetricsExporter,
2427
+ createTrpcCacheMiddleware
1036
2428
  });