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.js CHANGED
@@ -1,5 +1,233 @@
1
+ import {
2
+ PatternMatcher,
3
+ RedisTagIndex
4
+ } from "./chunk-QUB5VZFZ.js";
5
+
1
6
  // src/CacheStack.ts
2
7
  import { randomUUID } from "crypto";
8
+ import { EventEmitter } from "events";
9
+ import { promises as fs } from "fs";
10
+
11
+ // src/CacheNamespace.ts
12
+ var CacheNamespace = class {
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
+ async has(key) {
26
+ return this.cache.has(this.qualify(key));
27
+ }
28
+ async ttl(key) {
29
+ return this.cache.ttl(this.qualify(key));
30
+ }
31
+ async set(key, value, options) {
32
+ await this.cache.set(this.qualify(key), value, options);
33
+ }
34
+ async delete(key) {
35
+ await this.cache.delete(this.qualify(key));
36
+ }
37
+ async mdelete(keys) {
38
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
39
+ }
40
+ async clear() {
41
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
42
+ }
43
+ async mget(entries) {
44
+ return this.cache.mget(
45
+ entries.map((entry) => ({
46
+ ...entry,
47
+ key: this.qualify(entry.key)
48
+ }))
49
+ );
50
+ }
51
+ async mset(entries) {
52
+ await this.cache.mset(
53
+ entries.map((entry) => ({
54
+ ...entry,
55
+ key: this.qualify(entry.key)
56
+ }))
57
+ );
58
+ }
59
+ async invalidateByTag(tag) {
60
+ await this.cache.invalidateByTag(tag);
61
+ }
62
+ async invalidateByPattern(pattern) {
63
+ await this.cache.invalidateByPattern(this.qualify(pattern));
64
+ }
65
+ wrap(keyPrefix, fetcher, options) {
66
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
67
+ }
68
+ warm(entries, options) {
69
+ return this.cache.warm(
70
+ entries.map((entry) => ({
71
+ ...entry,
72
+ key: this.qualify(entry.key)
73
+ })),
74
+ options
75
+ );
76
+ }
77
+ getMetrics() {
78
+ return this.cache.getMetrics();
79
+ }
80
+ getHitRate() {
81
+ return this.cache.getHitRate();
82
+ }
83
+ qualify(key) {
84
+ return `${this.prefix}:${key}`;
85
+ }
86
+ };
87
+
88
+ // src/internal/CircuitBreakerManager.ts
89
+ var CircuitBreakerManager = class {
90
+ breakers = /* @__PURE__ */ new Map();
91
+ maxEntries;
92
+ constructor(options) {
93
+ this.maxEntries = options.maxEntries;
94
+ }
95
+ /**
96
+ * Throws if the circuit is open for the given key.
97
+ * Automatically resets if the cooldown has elapsed.
98
+ */
99
+ assertClosed(key, options) {
100
+ const state = this.breakers.get(key);
101
+ if (!state?.openUntil) {
102
+ return;
103
+ }
104
+ const now = Date.now();
105
+ if (state.openUntil <= now) {
106
+ state.openUntil = null;
107
+ state.failures = 0;
108
+ this.breakers.set(key, state);
109
+ return;
110
+ }
111
+ const remainingMs = state.openUntil - now;
112
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
113
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
114
+ }
115
+ recordFailure(key, options) {
116
+ if (!options) {
117
+ return;
118
+ }
119
+ const failureThreshold = options.failureThreshold ?? 3;
120
+ const cooldownMs = options.cooldownMs ?? 3e4;
121
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
122
+ state.failures += 1;
123
+ if (state.failures >= failureThreshold) {
124
+ state.openUntil = Date.now() + cooldownMs;
125
+ }
126
+ this.breakers.set(key, state);
127
+ this.pruneIfNeeded();
128
+ }
129
+ recordSuccess(key) {
130
+ this.breakers.delete(key);
131
+ }
132
+ isOpen(key) {
133
+ const state = this.breakers.get(key);
134
+ if (!state?.openUntil) {
135
+ return false;
136
+ }
137
+ if (state.openUntil <= Date.now()) {
138
+ state.openUntil = null;
139
+ state.failures = 0;
140
+ return false;
141
+ }
142
+ return true;
143
+ }
144
+ delete(key) {
145
+ this.breakers.delete(key);
146
+ }
147
+ clear() {
148
+ this.breakers.clear();
149
+ }
150
+ tripCount() {
151
+ let count = 0;
152
+ for (const state of this.breakers.values()) {
153
+ if (state.openUntil !== null) {
154
+ count += 1;
155
+ }
156
+ }
157
+ return count;
158
+ }
159
+ pruneIfNeeded() {
160
+ if (this.breakers.size <= this.maxEntries) {
161
+ return;
162
+ }
163
+ for (const [key, state] of this.breakers.entries()) {
164
+ if (this.breakers.size <= this.maxEntries) {
165
+ break;
166
+ }
167
+ if (!state.openUntil || state.openUntil <= Date.now()) {
168
+ this.breakers.delete(key);
169
+ }
170
+ }
171
+ for (const key of this.breakers.keys()) {
172
+ if (this.breakers.size <= this.maxEntries) {
173
+ break;
174
+ }
175
+ this.breakers.delete(key);
176
+ }
177
+ }
178
+ };
179
+
180
+ // src/internal/MetricsCollector.ts
181
+ var MetricsCollector = class {
182
+ data = this.empty();
183
+ get snapshot() {
184
+ return { ...this.data };
185
+ }
186
+ increment(field, amount = 1) {
187
+ ;
188
+ this.data[field] += amount;
189
+ }
190
+ incrementLayer(map, layerName) {
191
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
192
+ }
193
+ reset() {
194
+ this.data = this.empty();
195
+ }
196
+ hitRate() {
197
+ const total = this.data.hits + this.data.misses;
198
+ const overall = total === 0 ? 0 : this.data.hits / total;
199
+ const byLayer = {};
200
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
201
+ for (const layer of allLayers) {
202
+ const h = this.data.hitsByLayer[layer] ?? 0;
203
+ const m = this.data.missesByLayer[layer] ?? 0;
204
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
205
+ }
206
+ return { overall, byLayer };
207
+ }
208
+ empty() {
209
+ return {
210
+ hits: 0,
211
+ misses: 0,
212
+ fetches: 0,
213
+ sets: 0,
214
+ deletes: 0,
215
+ backfills: 0,
216
+ invalidations: 0,
217
+ staleHits: 0,
218
+ refreshes: 0,
219
+ refreshErrors: 0,
220
+ writeFailures: 0,
221
+ singleFlightWaits: 0,
222
+ negativeCacheHits: 0,
223
+ circuitBreakerTrips: 0,
224
+ degradedOperations: 0,
225
+ hitsByLayer: {},
226
+ missesByLayer: {},
227
+ resetAt: Date.now()
228
+ };
229
+ }
230
+ };
3
231
 
4
232
  // src/internal/StoredValue.ts
5
233
  function isStoredValueEnvelope(value) {
@@ -19,7 +247,10 @@ function createStoredValueEnvelope(options) {
19
247
  value: options.value,
20
248
  freshUntil,
21
249
  staleUntil,
22
- errorUntil
250
+ errorUntil,
251
+ freshTtlSeconds: freshTtlSeconds ?? null,
252
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
253
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
23
254
  };
24
255
  }
25
256
  function resolveStoredValue(stored, now = Date.now()) {
@@ -60,6 +291,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
60
291
  }
61
292
  return Math.max(1, Math.ceil(remainingMs / 1e3));
62
293
  }
294
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
295
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
296
+ return void 0;
297
+ }
298
+ const remainingMs = stored.freshUntil - now;
299
+ if (remainingMs <= 0) {
300
+ return 0;
301
+ }
302
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
303
+ }
304
+ function refreshStoredEnvelope(stored, now = Date.now()) {
305
+ if (!isStoredValueEnvelope(stored)) {
306
+ return stored;
307
+ }
308
+ return createStoredValueEnvelope({
309
+ kind: stored.kind,
310
+ value: stored.value,
311
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
312
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
313
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
314
+ now
315
+ });
316
+ }
63
317
  function maxExpiry(stored) {
64
318
  const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
65
319
  (value) => value !== null
@@ -76,12 +330,91 @@ function normalizePositiveSeconds(value) {
76
330
  return value;
77
331
  }
78
332
 
79
- // src/invalidation/PatternMatcher.ts
80
- var PatternMatcher = class {
81
- static matches(pattern, value) {
82
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
83
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
84
- return regex.test(value);
333
+ // src/internal/TtlResolver.ts
334
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
335
+ var TtlResolver = class {
336
+ accessProfiles = /* @__PURE__ */ new Map();
337
+ maxProfileEntries;
338
+ constructor(options) {
339
+ this.maxProfileEntries = options.maxProfileEntries;
340
+ }
341
+ recordAccess(key) {
342
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
343
+ profile.hits += 1;
344
+ profile.lastAccessAt = Date.now();
345
+ this.accessProfiles.set(key, profile);
346
+ this.pruneIfNeeded();
347
+ }
348
+ deleteProfile(key) {
349
+ this.accessProfiles.delete(key);
350
+ }
351
+ clearProfiles() {
352
+ this.accessProfiles.clear();
353
+ }
354
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
355
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
356
+ layerName,
357
+ options?.negativeTtl,
358
+ globalNegativeTtl,
359
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
360
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
361
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
362
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
363
+ return this.applyJitter(adaptiveTtl, jitter);
364
+ }
365
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
366
+ if (override !== void 0) {
367
+ return this.readLayerNumber(layerName, override) ?? fallback;
368
+ }
369
+ if (globalDefault !== void 0) {
370
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
371
+ }
372
+ return fallback;
373
+ }
374
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
375
+ if (!ttl || !adaptiveTtl) {
376
+ return ttl;
377
+ }
378
+ const profile = this.accessProfiles.get(key);
379
+ if (!profile) {
380
+ return ttl;
381
+ }
382
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
383
+ const hotAfter = config.hotAfter ?? 3;
384
+ if (profile.hits < hotAfter) {
385
+ return ttl;
386
+ }
387
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
388
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
389
+ const multiplier = Math.floor(profile.hits / hotAfter);
390
+ return Math.min(maxTtl, ttl + step * multiplier);
391
+ }
392
+ applyJitter(ttl, jitter) {
393
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
394
+ return ttl;
395
+ }
396
+ const delta = (Math.random() * 2 - 1) * jitter;
397
+ return Math.max(1, Math.round(ttl + delta));
398
+ }
399
+ readLayerNumber(layerName, value) {
400
+ if (typeof value === "number") {
401
+ return value;
402
+ }
403
+ return value[layerName];
404
+ }
405
+ pruneIfNeeded() {
406
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
407
+ return;
408
+ }
409
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
410
+ let removed = 0;
411
+ for (const key of this.accessProfiles.keys()) {
412
+ if (removed >= toRemove) {
413
+ break;
414
+ }
415
+ this.accessProfiles.delete(key);
416
+ removed += 1;
417
+ }
85
418
  }
86
419
  };
87
420
 
@@ -148,64 +481,75 @@ import { Mutex } from "async-mutex";
148
481
  var StampedeGuard = class {
149
482
  mutexes = /* @__PURE__ */ new Map();
150
483
  async execute(key, task) {
151
- const mutex = this.getMutex(key);
484
+ const entry = this.getMutexEntry(key);
152
485
  try {
153
- return await mutex.runExclusive(task);
486
+ return await entry.mutex.runExclusive(task);
154
487
  } finally {
155
- if (!mutex.isLocked()) {
488
+ entry.references -= 1;
489
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
156
490
  this.mutexes.delete(key);
157
491
  }
158
492
  }
159
493
  }
160
- getMutex(key) {
161
- let mutex = this.mutexes.get(key);
162
- if (!mutex) {
163
- mutex = new Mutex();
164
- this.mutexes.set(key, mutex);
494
+ getMutexEntry(key) {
495
+ let entry = this.mutexes.get(key);
496
+ if (!entry) {
497
+ entry = { mutex: new Mutex(), references: 0 };
498
+ this.mutexes.set(key, entry);
165
499
  }
166
- return mutex;
500
+ entry.references += 1;
501
+ return entry;
167
502
  }
168
503
  };
169
504
 
170
505
  // src/CacheStack.ts
171
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
172
506
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
173
507
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
174
508
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
175
- var EMPTY_METRICS = () => ({
176
- hits: 0,
177
- misses: 0,
178
- fetches: 0,
179
- sets: 0,
180
- deletes: 0,
181
- backfills: 0,
182
- invalidations: 0,
183
- staleHits: 0,
184
- refreshes: 0,
185
- refreshErrors: 0,
186
- writeFailures: 0,
187
- singleFlightWaits: 0
188
- });
509
+ var MAX_CACHE_KEY_LENGTH = 1024;
510
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
189
511
  var DebugLogger = class {
190
512
  enabled;
191
513
  constructor(enabled) {
192
514
  this.enabled = enabled;
193
515
  }
194
516
  debug(message, context) {
517
+ this.write("debug", message, context);
518
+ }
519
+ info(message, context) {
520
+ this.write("info", message, context);
521
+ }
522
+ warn(message, context) {
523
+ this.write("warn", message, context);
524
+ }
525
+ error(message, context) {
526
+ this.write("error", message, context);
527
+ }
528
+ write(level, message, context) {
195
529
  if (!this.enabled) {
196
530
  return;
197
531
  }
198
532
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
199
- console.debug(`[layercache] ${message}${suffix}`);
533
+ console[level](`[layercache] ${message}${suffix}`);
200
534
  }
201
535
  };
202
- var CacheStack = class {
536
+ var CacheStack = class extends EventEmitter {
203
537
  constructor(layers, options = {}) {
538
+ super();
204
539
  this.layers = layers;
205
540
  this.options = options;
206
541
  if (layers.length === 0) {
207
542
  throw new Error("CacheStack requires at least one cache layer.");
208
543
  }
544
+ this.validateConfiguration();
545
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
546
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
547
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
548
+ if (options.publishSetInvalidation !== void 0) {
549
+ console.warn(
550
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
551
+ );
552
+ }
209
553
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
210
554
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
211
555
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -214,112 +558,313 @@ var CacheStack = class {
214
558
  layers;
215
559
  options;
216
560
  stampedeGuard = new StampedeGuard();
217
- metrics = EMPTY_METRICS();
561
+ metricsCollector = new MetricsCollector();
218
562
  instanceId = randomUUID();
219
563
  startup;
220
564
  unsubscribeInvalidation;
221
565
  logger;
222
566
  tagIndex;
223
567
  backgroundRefreshes = /* @__PURE__ */ new Map();
568
+ layerDegradedUntil = /* @__PURE__ */ new Map();
569
+ ttlResolver;
570
+ circuitBreakerManager;
571
+ isDisconnecting = false;
572
+ disconnectPromise;
573
+ /**
574
+ * Read-through cache get.
575
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
576
+ * and stores the result across all layers. Returns `null` if the key is not found
577
+ * and no `fetcher` is provided.
578
+ */
224
579
  async get(key, fetcher, options) {
580
+ const normalizedKey = this.validateCacheKey(key);
581
+ this.validateWriteOptions(options);
225
582
  await this.startup;
226
- const hit = await this.readFromLayers(key, options, "allow-stale");
583
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
227
584
  if (hit.found) {
585
+ this.ttlResolver.recordAccess(normalizedKey);
586
+ if (this.isNegativeStoredValue(hit.stored)) {
587
+ this.metricsCollector.increment("negativeCacheHits");
588
+ }
228
589
  if (hit.state === "fresh") {
229
- this.metrics.hits += 1;
590
+ this.metricsCollector.increment("hits");
591
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
230
592
  return hit.value;
231
593
  }
232
594
  if (hit.state === "stale-while-revalidate") {
233
- this.metrics.hits += 1;
234
- this.metrics.staleHits += 1;
595
+ this.metricsCollector.increment("hits");
596
+ this.metricsCollector.increment("staleHits");
597
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
235
598
  if (fetcher) {
236
- this.scheduleBackgroundRefresh(key, fetcher, options);
599
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
237
600
  }
238
601
  return hit.value;
239
602
  }
240
603
  if (!fetcher) {
241
- this.metrics.hits += 1;
242
- this.metrics.staleHits += 1;
604
+ this.metricsCollector.increment("hits");
605
+ this.metricsCollector.increment("staleHits");
606
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
243
607
  return hit.value;
244
608
  }
245
609
  try {
246
- return await this.fetchWithGuards(key, fetcher, options);
610
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
247
611
  } catch (error) {
248
- this.metrics.staleHits += 1;
249
- this.metrics.refreshErrors += 1;
250
- this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
612
+ this.metricsCollector.increment("staleHits");
613
+ this.metricsCollector.increment("refreshErrors");
614
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
251
615
  return hit.value;
252
616
  }
253
617
  }
254
- this.metrics.misses += 1;
618
+ this.metricsCollector.increment("misses");
255
619
  if (!fetcher) {
256
620
  return null;
257
621
  }
258
- return this.fetchWithGuards(key, fetcher, options);
622
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
623
+ }
624
+ /**
625
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
626
+ * Fetches and caches the value if not already present.
627
+ */
628
+ async getOrSet(key, fetcher, options) {
629
+ return this.get(key, fetcher, options);
630
+ }
631
+ /**
632
+ * Returns true if the given key exists and is not expired in any layer.
633
+ */
634
+ async has(key) {
635
+ const normalizedKey = this.validateCacheKey(key);
636
+ await this.startup;
637
+ for (const layer of this.layers) {
638
+ if (this.shouldSkipLayer(layer)) {
639
+ continue;
640
+ }
641
+ if (layer.has) {
642
+ try {
643
+ const exists = await layer.has(normalizedKey);
644
+ if (exists) {
645
+ return true;
646
+ }
647
+ } catch {
648
+ }
649
+ } else {
650
+ try {
651
+ const value = await layer.get(normalizedKey);
652
+ if (value !== null) {
653
+ return true;
654
+ }
655
+ } catch {
656
+ }
657
+ }
658
+ }
659
+ return false;
660
+ }
661
+ /**
662
+ * Returns the remaining TTL in seconds for the key in the fastest layer
663
+ * that has it, or null if the key is not found / has no TTL.
664
+ */
665
+ async ttl(key) {
666
+ const normalizedKey = this.validateCacheKey(key);
667
+ await this.startup;
668
+ for (const layer of this.layers) {
669
+ if (this.shouldSkipLayer(layer)) {
670
+ continue;
671
+ }
672
+ if (layer.ttl) {
673
+ try {
674
+ const remaining = await layer.ttl(normalizedKey);
675
+ if (remaining !== null) {
676
+ return remaining;
677
+ }
678
+ } catch {
679
+ }
680
+ }
681
+ }
682
+ return null;
259
683
  }
684
+ /**
685
+ * Stores a value in all cache layers. Overwrites any existing value.
686
+ */
260
687
  async set(key, value, options) {
688
+ const normalizedKey = this.validateCacheKey(key);
689
+ this.validateWriteOptions(options);
261
690
  await this.startup;
262
- await this.storeEntry(key, "value", value, options);
691
+ await this.storeEntry(normalizedKey, "value", value, options);
263
692
  }
693
+ /**
694
+ * Deletes the key from all layers and publishes an invalidation message.
695
+ */
264
696
  async delete(key) {
697
+ const normalizedKey = this.validateCacheKey(key);
265
698
  await this.startup;
266
- await this.deleteKeys([key]);
267
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
699
+ await this.deleteKeys([normalizedKey]);
700
+ await this.publishInvalidation({
701
+ scope: "key",
702
+ keys: [normalizedKey],
703
+ sourceId: this.instanceId,
704
+ operation: "delete"
705
+ });
268
706
  }
269
707
  async clear() {
270
708
  await this.startup;
271
709
  await Promise.all(this.layers.map((layer) => layer.clear()));
272
710
  await this.tagIndex.clear();
273
- this.metrics.invalidations += 1;
274
- this.logger.debug("clear");
711
+ this.ttlResolver.clearProfiles();
712
+ this.circuitBreakerManager.clear();
713
+ this.metricsCollector.increment("invalidations");
714
+ this.logger.debug?.("clear");
275
715
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
276
716
  }
717
+ /**
718
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
719
+ */
720
+ async mdelete(keys) {
721
+ if (keys.length === 0) {
722
+ return;
723
+ }
724
+ await this.startup;
725
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
726
+ await this.deleteKeys(normalizedKeys);
727
+ await this.publishInvalidation({
728
+ scope: "keys",
729
+ keys: normalizedKeys,
730
+ sourceId: this.instanceId,
731
+ operation: "delete"
732
+ });
733
+ }
277
734
  async mget(entries) {
278
735
  if (entries.length === 0) {
279
736
  return [];
280
737
  }
281
- const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
738
+ const normalizedEntries = entries.map((entry) => ({
739
+ ...entry,
740
+ key: this.validateCacheKey(entry.key)
741
+ }));
742
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
743
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
282
744
  if (!canFastPath) {
283
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
745
+ const pendingReads = /* @__PURE__ */ new Map();
746
+ return Promise.all(
747
+ normalizedEntries.map((entry) => {
748
+ const optionsSignature = this.serializeOptions(entry.options);
749
+ const existing = pendingReads.get(entry.key);
750
+ if (!existing) {
751
+ const promise = this.get(entry.key, entry.fetch, entry.options);
752
+ pendingReads.set(entry.key, {
753
+ promise,
754
+ fetch: entry.fetch,
755
+ optionsSignature
756
+ });
757
+ return promise;
758
+ }
759
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
760
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
761
+ }
762
+ return existing.promise;
763
+ })
764
+ );
284
765
  }
285
766
  await this.startup;
286
- const pending = new Set(entries.map((_, index) => index));
287
- const results = Array(entries.length).fill(null);
288
- for (const layer of this.layers) {
289
- const indexes = [...pending];
290
- if (indexes.length === 0) {
767
+ const pending = /* @__PURE__ */ new Set();
768
+ const indexesByKey = /* @__PURE__ */ new Map();
769
+ const resultsByKey = /* @__PURE__ */ new Map();
770
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
771
+ const entry = normalizedEntries[index];
772
+ if (!entry) continue;
773
+ const key = entry.key;
774
+ const indexes = indexesByKey.get(key) ?? [];
775
+ indexes.push(index);
776
+ indexesByKey.set(key, indexes);
777
+ pending.add(key);
778
+ }
779
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
780
+ const layer = this.layers[layerIndex];
781
+ if (!layer) continue;
782
+ const keys = [...pending];
783
+ if (keys.length === 0) {
291
784
  break;
292
785
  }
293
- const keys = indexes.map((index) => entries[index].key);
294
786
  const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
295
787
  for (let offset = 0; offset < values.length; offset += 1) {
296
- const index = indexes[offset];
788
+ const key = keys[offset];
297
789
  const stored = values[offset];
298
- if (stored === null) {
790
+ if (!key || stored === null) {
299
791
  continue;
300
792
  }
301
793
  const resolved = resolveStoredValue(stored);
302
794
  if (resolved.state === "expired") {
303
- await layer.delete(entries[index].key);
795
+ await layer.delete(key);
304
796
  continue;
305
797
  }
306
- await this.tagIndex.touch(entries[index].key);
307
- await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
308
- results[index] = resolved.value;
309
- pending.delete(index);
310
- this.metrics.hits += 1;
798
+ await this.tagIndex.touch(key);
799
+ await this.backfill(key, stored, layerIndex - 1);
800
+ resultsByKey.set(key, resolved.value);
801
+ pending.delete(key);
802
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
311
803
  }
312
804
  }
313
805
  if (pending.size > 0) {
314
- for (const index of pending) {
315
- await this.tagIndex.remove(entries[index].key);
316
- this.metrics.misses += 1;
806
+ for (const key of pending) {
807
+ await this.tagIndex.remove(key);
808
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
317
809
  }
318
810
  }
319
- return results;
811
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
320
812
  }
321
813
  async mset(entries) {
322
- await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
814
+ const normalizedEntries = entries.map((entry) => ({
815
+ ...entry,
816
+ key: this.validateCacheKey(entry.key)
817
+ }));
818
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
819
+ await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
820
+ }
821
+ async warm(entries, options = {}) {
822
+ const concurrency = Math.max(1, options.concurrency ?? 4);
823
+ const total = entries.length;
824
+ let completed = 0;
825
+ const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
826
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
827
+ while (queue.length > 0) {
828
+ const entry = queue.shift();
829
+ if (!entry) {
830
+ return;
831
+ }
832
+ let success = false;
833
+ try {
834
+ await this.get(entry.key, entry.fetcher, entry.options);
835
+ this.emit("warm", { key: entry.key });
836
+ success = true;
837
+ } catch (error) {
838
+ this.emitError("warm", { key: entry.key, error: this.formatError(error) });
839
+ if (!options.continueOnError) {
840
+ throw error;
841
+ }
842
+ } finally {
843
+ completed += 1;
844
+ const progress = { completed, total, key: entry.key, success };
845
+ options.onProgress?.(progress);
846
+ }
847
+ }
848
+ });
849
+ await Promise.all(workers);
850
+ }
851
+ /**
852
+ * Returns a cached version of `fetcher`. The cache key is derived from
853
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
854
+ */
855
+ wrap(prefix, fetcher, options = {}) {
856
+ return (...args) => {
857
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
858
+ const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
859
+ return this.get(key, () => fetcher(...args), options);
860
+ };
861
+ }
862
+ /**
863
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
864
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
865
+ */
866
+ namespace(prefix) {
867
+ return new CacheNamespace(this, prefix);
323
868
  }
324
869
  async invalidateByTag(tag) {
325
870
  await this.startup;
@@ -334,15 +879,94 @@ var CacheStack = class {
334
879
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
335
880
  }
336
881
  getMetrics() {
337
- return { ...this.metrics };
882
+ return this.metricsCollector.snapshot;
883
+ }
884
+ getStats() {
885
+ return {
886
+ metrics: this.getMetrics(),
887
+ layers: this.layers.map((layer) => ({
888
+ name: layer.name,
889
+ isLocal: Boolean(layer.isLocal),
890
+ degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
891
+ })),
892
+ backgroundRefreshes: this.backgroundRefreshes.size
893
+ };
338
894
  }
339
895
  resetMetrics() {
340
- Object.assign(this.metrics, EMPTY_METRICS());
896
+ this.metricsCollector.reset();
341
897
  }
342
- async disconnect() {
898
+ /**
899
+ * Returns computed hit-rate statistics (overall and per-layer).
900
+ */
901
+ getHitRate() {
902
+ return this.metricsCollector.hitRate();
903
+ }
904
+ async exportState() {
905
+ await this.startup;
906
+ const exported = /* @__PURE__ */ new Map();
907
+ for (const layer of this.layers) {
908
+ if (!layer.keys) {
909
+ continue;
910
+ }
911
+ const keys = await layer.keys();
912
+ for (const key of keys) {
913
+ if (exported.has(key)) {
914
+ continue;
915
+ }
916
+ const stored = await this.readLayerEntry(layer, key);
917
+ if (stored === null) {
918
+ continue;
919
+ }
920
+ exported.set(key, {
921
+ key,
922
+ value: stored,
923
+ ttl: remainingStoredTtlSeconds(stored)
924
+ });
925
+ }
926
+ }
927
+ return [...exported.values()];
928
+ }
929
+ async importState(entries) {
343
930
  await this.startup;
344
- await this.unsubscribeInvalidation?.();
345
- await Promise.allSettled(this.backgroundRefreshes.values());
931
+ await Promise.all(
932
+ entries.map(async (entry) => {
933
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
934
+ await this.tagIndex.touch(entry.key);
935
+ })
936
+ );
937
+ }
938
+ async persistToFile(filePath) {
939
+ const snapshot = await this.exportState();
940
+ await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
941
+ }
942
+ async restoreFromFile(filePath) {
943
+ const raw = await fs.readFile(filePath, "utf8");
944
+ let parsed;
945
+ try {
946
+ parsed = JSON.parse(raw, (_key, value) => {
947
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
948
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
949
+ }
950
+ return value;
951
+ });
952
+ } catch (cause) {
953
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
954
+ }
955
+ if (!this.isCacheSnapshotEntries(parsed)) {
956
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
957
+ }
958
+ await this.importState(parsed);
959
+ }
960
+ async disconnect() {
961
+ if (!this.disconnectPromise) {
962
+ this.isDisconnecting = true;
963
+ this.disconnectPromise = (async () => {
964
+ await this.startup;
965
+ await this.unsubscribeInvalidation?.();
966
+ await Promise.allSettled([...this.backgroundRefreshes.values()]);
967
+ })();
968
+ }
969
+ await this.disconnectPromise;
346
970
  }
347
971
  async initialize() {
348
972
  if (!this.options.invalidationBus) {
@@ -356,7 +980,7 @@ var CacheStack = class {
356
980
  const fetchTask = async () => {
357
981
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
358
982
  if (secondHit.found) {
359
- this.metrics.hits += 1;
983
+ this.metricsCollector.increment("hits");
360
984
  return secondHit.value;
361
985
  }
362
986
  return this.fetchAndPopulate(key, fetcher, options);
@@ -381,11 +1005,12 @@ var CacheStack = class {
381
1005
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
382
1006
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
383
1007
  const deadline = Date.now() + timeoutMs;
384
- this.metrics.singleFlightWaits += 1;
1008
+ this.metricsCollector.increment("singleFlightWaits");
1009
+ this.emit("stampede-dedupe", { key });
385
1010
  while (Date.now() < deadline) {
386
1011
  const hit = await this.readFromLayers(key, options, "fresh-only");
387
1012
  if (hit.found) {
388
- this.metrics.hits += 1;
1013
+ this.metricsCollector.increment("hits");
389
1014
  return hit.value;
390
1015
  }
391
1016
  await this.sleep(pollIntervalMs);
@@ -393,8 +1018,18 @@ var CacheStack = class {
393
1018
  return this.fetchAndPopulate(key, fetcher, options);
394
1019
  }
395
1020
  async fetchAndPopulate(key, fetcher, options) {
396
- this.metrics.fetches += 1;
397
- const fetched = await fetcher();
1021
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1022
+ this.metricsCollector.increment("fetches");
1023
+ const fetchStart = Date.now();
1024
+ let fetched;
1025
+ try {
1026
+ fetched = await fetcher();
1027
+ this.circuitBreakerManager.recordSuccess(key);
1028
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1029
+ } catch (error) {
1030
+ this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1031
+ throw error;
1032
+ }
398
1033
  if (fetched === null || fetched === void 0) {
399
1034
  if (!this.shouldNegativeCache(options)) {
400
1035
  return null;
@@ -412,9 +1047,10 @@ var CacheStack = class {
412
1047
  } else {
413
1048
  await this.tagIndex.touch(key);
414
1049
  }
415
- this.metrics.sets += 1;
416
- this.logger.debug("set", { key, kind, tags: options?.tags });
417
- if (this.options.publishSetInvalidation !== false) {
1050
+ this.metricsCollector.increment("sets");
1051
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
1052
+ this.emit("set", { key, kind, tags: options?.tags });
1053
+ if (this.shouldBroadcastL1Invalidation()) {
418
1054
  await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
419
1055
  }
420
1056
  }
@@ -422,8 +1058,10 @@ var CacheStack = class {
422
1058
  let sawRetainableValue = false;
423
1059
  for (let index = 0; index < this.layers.length; index += 1) {
424
1060
  const layer = this.layers[index];
1061
+ if (!layer) continue;
425
1062
  const stored = await this.readLayerEntry(layer, key);
426
1063
  if (stored === null) {
1064
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
427
1065
  continue;
428
1066
  }
429
1067
  const resolved = resolveStoredValue(stored);
@@ -437,20 +1075,41 @@ var CacheStack = class {
437
1075
  }
438
1076
  await this.tagIndex.touch(key);
439
1077
  await this.backfill(key, stored, index - 1, options);
440
- this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
441
- return { found: true, value: resolved.value, stored, state: resolved.state };
1078
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
1079
+ this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
1080
+ this.emit("hit", { key, layer: layer.name, state: resolved.state });
1081
+ return {
1082
+ found: true,
1083
+ value: resolved.value,
1084
+ stored,
1085
+ state: resolved.state,
1086
+ layerIndex: index,
1087
+ layerName: layer.name
1088
+ };
442
1089
  }
443
1090
  if (!sawRetainableValue) {
444
1091
  await this.tagIndex.remove(key);
445
1092
  }
446
- this.logger.debug("miss", { key, mode });
1093
+ this.logger.debug?.("miss", { key, mode });
1094
+ this.emit("miss", { key, mode });
447
1095
  return { found: false, value: null, stored: null, state: "miss" };
448
1096
  }
449
1097
  async readLayerEntry(layer, key) {
1098
+ if (this.shouldSkipLayer(layer)) {
1099
+ return null;
1100
+ }
450
1101
  if (layer.getEntry) {
451
- return layer.getEntry(key);
1102
+ try {
1103
+ return await layer.getEntry(key);
1104
+ } catch (error) {
1105
+ return this.handleLayerFailure(layer, "read", error);
1106
+ }
1107
+ }
1108
+ try {
1109
+ return await layer.get(key);
1110
+ } catch (error) {
1111
+ return this.handleLayerFailure(layer, "read", error);
452
1112
  }
453
- return layer.get(key);
454
1113
  }
455
1114
  async backfill(key, stored, upToIndex, options) {
456
1115
  if (upToIndex < 0) {
@@ -458,26 +1117,34 @@ var CacheStack = class {
458
1117
  }
459
1118
  for (let index = 0; index <= upToIndex; index += 1) {
460
1119
  const layer = this.layers[index];
1120
+ if (!layer || this.shouldSkipLayer(layer)) {
1121
+ continue;
1122
+ }
461
1123
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
462
- await layer.set(key, stored, ttl);
463
- this.metrics.backfills += 1;
464
- this.logger.debug("backfill", { key, layer: layer.name });
1124
+ try {
1125
+ await layer.set(key, stored, ttl);
1126
+ } catch (error) {
1127
+ await this.handleLayerFailure(layer, "backfill", error);
1128
+ continue;
1129
+ }
1130
+ this.metricsCollector.increment("backfills");
1131
+ this.logger.debug?.("backfill", { key, layer: layer.name });
1132
+ this.emit("backfill", { key, layer: layer.name });
465
1133
  }
466
1134
  }
467
1135
  async writeAcrossLayers(key, kind, value, options) {
468
1136
  const now = Date.now();
469
1137
  const operations = this.layers.map((layer) => async () => {
470
- const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
1138
+ if (this.shouldSkipLayer(layer)) {
1139
+ return;
1140
+ }
1141
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
471
1142
  const staleWhileRevalidate = this.resolveLayerSeconds(
472
1143
  layer.name,
473
1144
  options?.staleWhileRevalidate,
474
1145
  this.options.staleWhileRevalidate
475
1146
  );
476
- const staleIfError = this.resolveLayerSeconds(
477
- layer.name,
478
- options?.staleIfError,
479
- this.options.staleIfError
480
- );
1147
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
481
1148
  const payload = createStoredValueEnvelope({
482
1149
  kind,
483
1150
  value,
@@ -487,7 +1154,11 @@ var CacheStack = class {
487
1154
  now
488
1155
  });
489
1156
  const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
490
- await layer.set(key, payload, ttl);
1157
+ try {
1158
+ await layer.set(key, payload, ttl);
1159
+ } catch (error) {
1160
+ await this.handleLayerFailure(layer, "write", error);
1161
+ }
491
1162
  });
492
1163
  await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
493
1164
  }
@@ -501,8 +1172,8 @@ var CacheStack = class {
501
1172
  if (failures.length === 0) {
502
1173
  return;
503
1174
  }
504
- this.metrics.writeFailures += failures.length;
505
- this.logger.debug("write-failure", {
1175
+ this.metricsCollector.increment("writeFailures", failures.length);
1176
+ this.logger.debug?.("write-failure", {
506
1177
  ...context,
507
1178
  failures: failures.map((failure) => this.formatError(failure.reason))
508
1179
  });
@@ -513,52 +1184,26 @@ var CacheStack = class {
513
1184
  );
514
1185
  }
515
1186
  }
516
- resolveFreshTtl(layerName, kind, options, fallbackTtl) {
517
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
518
- layerName,
519
- options?.negativeTtl,
520
- this.options.negativeTtl,
521
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
522
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
523
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
524
- return this.applyJitter(baseTtl, jitter);
1187
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1188
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
525
1189
  }
526
1190
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
527
- if (override !== void 0) {
528
- return this.readLayerNumber(layerName, override) ?? fallback;
529
- }
530
- if (globalDefault !== void 0) {
531
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
532
- }
533
- return fallback;
534
- }
535
- readLayerNumber(layerName, value) {
536
- if (typeof value === "number") {
537
- return value;
538
- }
539
- return value[layerName];
540
- }
541
- applyJitter(ttl, jitter) {
542
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
543
- return ttl;
544
- }
545
- const delta = (Math.random() * 2 - 1) * jitter;
546
- return Math.max(1, Math.round(ttl + delta));
1191
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
547
1192
  }
548
1193
  shouldNegativeCache(options) {
549
1194
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
550
1195
  }
551
1196
  scheduleBackgroundRefresh(key, fetcher, options) {
552
- if (this.backgroundRefreshes.has(key)) {
1197
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
553
1198
  return;
554
1199
  }
555
1200
  const refresh = (async () => {
556
- this.metrics.refreshes += 1;
1201
+ this.metricsCollector.increment("refreshes");
557
1202
  try {
558
1203
  await this.fetchWithGuards(key, fetcher, options);
559
1204
  } catch (error) {
560
- this.metrics.refreshErrors += 1;
561
- this.logger.debug("refresh-error", { key, error: this.formatError(error) });
1205
+ this.metricsCollector.increment("refreshErrors");
1206
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
562
1207
  } finally {
563
1208
  this.backgroundRefreshes.delete(key);
564
1209
  }
@@ -576,21 +1221,16 @@ var CacheStack = class {
576
1221
  if (keys.length === 0) {
577
1222
  return;
578
1223
  }
579
- await Promise.all(
580
- this.layers.map(async (layer) => {
581
- if (layer.deleteMany) {
582
- await layer.deleteMany(keys);
583
- return;
584
- }
585
- await Promise.all(keys.map((key) => layer.delete(key)));
586
- })
587
- );
1224
+ await this.deleteKeysFromLayers(this.layers, keys);
588
1225
  for (const key of keys) {
589
1226
  await this.tagIndex.remove(key);
1227
+ this.ttlResolver.deleteProfile(key);
1228
+ this.circuitBreakerManager.delete(key);
590
1229
  }
591
- this.metrics.deletes += keys.length;
592
- this.metrics.invalidations += 1;
593
- this.logger.debug("delete", { keys });
1230
+ this.metricsCollector.increment("deletes", keys.length);
1231
+ this.metricsCollector.increment("invalidations");
1232
+ this.logger.debug?.("delete", { keys });
1233
+ this.emit("delete", { keys });
594
1234
  }
595
1235
  async publishInvalidation(message) {
596
1236
  if (!this.options.invalidationBus) {
@@ -609,21 +1249,15 @@ var CacheStack = class {
609
1249
  if (message.scope === "clear") {
610
1250
  await Promise.all(localLayers.map((layer) => layer.clear()));
611
1251
  await this.tagIndex.clear();
1252
+ this.ttlResolver.clearProfiles();
612
1253
  return;
613
1254
  }
614
1255
  const keys = message.keys ?? [];
615
- await Promise.all(
616
- localLayers.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
- );
1256
+ await this.deleteKeysFromLayers(localLayers, keys);
624
1257
  if (message.operation !== "write") {
625
1258
  for (const key of keys) {
626
1259
  await this.tagIndex.remove(key);
1260
+ this.ttlResolver.deleteProfile(key);
627
1261
  }
628
1262
  }
629
1263
  }
@@ -636,6 +1270,210 @@ var CacheStack = class {
636
1270
  sleep(ms) {
637
1271
  return new Promise((resolve) => setTimeout(resolve, ms));
638
1272
  }
1273
+ shouldBroadcastL1Invalidation() {
1274
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1275
+ }
1276
+ async deleteKeysFromLayers(layers, keys) {
1277
+ await Promise.all(
1278
+ layers.map(async (layer) => {
1279
+ if (this.shouldSkipLayer(layer)) {
1280
+ return;
1281
+ }
1282
+ if (layer.deleteMany) {
1283
+ try {
1284
+ await layer.deleteMany(keys);
1285
+ } catch (error) {
1286
+ await this.handleLayerFailure(layer, "delete", error);
1287
+ }
1288
+ return;
1289
+ }
1290
+ await Promise.all(
1291
+ keys.map(async (key) => {
1292
+ try {
1293
+ await layer.delete(key);
1294
+ } catch (error) {
1295
+ await this.handleLayerFailure(layer, "delete", error);
1296
+ }
1297
+ })
1298
+ );
1299
+ })
1300
+ );
1301
+ }
1302
+ validateConfiguration() {
1303
+ if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
1304
+ throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
1305
+ }
1306
+ if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
1307
+ throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
1308
+ }
1309
+ this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
1310
+ this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
1311
+ this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
1312
+ this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
1313
+ this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
1314
+ this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1315
+ this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1316
+ this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1317
+ this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1318
+ this.validateCircuitBreakerOptions(this.options.circuitBreaker);
1319
+ }
1320
+ validateWriteOptions(options) {
1321
+ if (!options) {
1322
+ return;
1323
+ }
1324
+ this.validateLayerNumberOption("options.ttl", options.ttl);
1325
+ this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
1326
+ this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
1327
+ this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
1328
+ this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
1329
+ this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
1330
+ this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1331
+ this.validateCircuitBreakerOptions(options.circuitBreaker);
1332
+ }
1333
+ validateLayerNumberOption(name, value) {
1334
+ if (value === void 0) {
1335
+ return;
1336
+ }
1337
+ if (typeof value === "number") {
1338
+ this.validateNonNegativeNumber(name, value);
1339
+ return;
1340
+ }
1341
+ for (const [layerName, layerValue] of Object.entries(value)) {
1342
+ if (layerValue === void 0) {
1343
+ continue;
1344
+ }
1345
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
1346
+ }
1347
+ }
1348
+ validatePositiveNumber(name, value) {
1349
+ if (value === void 0) {
1350
+ return;
1351
+ }
1352
+ if (!Number.isFinite(value) || value <= 0) {
1353
+ throw new Error(`${name} must be a positive finite number.`);
1354
+ }
1355
+ }
1356
+ validateNonNegativeNumber(name, value) {
1357
+ if (!Number.isFinite(value) || value < 0) {
1358
+ throw new Error(`${name} must be a non-negative finite number.`);
1359
+ }
1360
+ }
1361
+ validateCacheKey(key) {
1362
+ if (key.length === 0) {
1363
+ throw new Error("Cache key must not be empty.");
1364
+ }
1365
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
1366
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1367
+ }
1368
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
1369
+ throw new Error("Cache key contains unsupported control characters.");
1370
+ }
1371
+ return key;
1372
+ }
1373
+ serializeOptions(options) {
1374
+ return JSON.stringify(this.normalizeForSerialization(options) ?? null);
1375
+ }
1376
+ validateAdaptiveTtlOptions(options) {
1377
+ if (!options || options === true) {
1378
+ return;
1379
+ }
1380
+ this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1381
+ this.validateLayerNumberOption("adaptiveTtl.step", options.step);
1382
+ this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1383
+ }
1384
+ validateCircuitBreakerOptions(options) {
1385
+ if (!options) {
1386
+ return;
1387
+ }
1388
+ this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1389
+ this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1390
+ }
1391
+ async applyFreshReadPolicies(key, hit, options, fetcher) {
1392
+ const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
1393
+ const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
1394
+ if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
1395
+ const refreshed = refreshStoredEnvelope(hit.stored);
1396
+ const ttl = remainingStoredTtlSeconds(refreshed);
1397
+ for (let index = 0; index <= hit.layerIndex; index += 1) {
1398
+ const layer = this.layers[index];
1399
+ if (!layer || this.shouldSkipLayer(layer)) {
1400
+ continue;
1401
+ }
1402
+ try {
1403
+ await layer.set(key, refreshed, ttl);
1404
+ } catch (error) {
1405
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1406
+ }
1407
+ }
1408
+ }
1409
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1410
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1411
+ }
1412
+ }
1413
+ shouldSkipLayer(layer) {
1414
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
1415
+ return degradedUntil !== void 0 && degradedUntil > Date.now();
1416
+ }
1417
+ async handleLayerFailure(layer, operation, error) {
1418
+ if (!this.isGracefulDegradationEnabled()) {
1419
+ throw error;
1420
+ }
1421
+ const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1422
+ this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1423
+ this.metricsCollector.increment("degradedOperations");
1424
+ this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1425
+ this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1426
+ return null;
1427
+ }
1428
+ isGracefulDegradationEnabled() {
1429
+ return Boolean(this.options.gracefulDegradation);
1430
+ }
1431
+ recordCircuitFailure(key, options, error) {
1432
+ if (!options) {
1433
+ return;
1434
+ }
1435
+ this.circuitBreakerManager.recordFailure(key, options);
1436
+ if (this.circuitBreakerManager.isOpen(key)) {
1437
+ this.metricsCollector.increment("circuitBreakerTrips");
1438
+ }
1439
+ this.emitError("fetch", { key, error: this.formatError(error) });
1440
+ }
1441
+ isNegativeStoredValue(stored) {
1442
+ return isStoredValueEnvelope(stored) && stored.kind === "empty";
1443
+ }
1444
+ emitError(operation, context) {
1445
+ this.logger.error?.(operation, context);
1446
+ if (this.listenerCount("error") > 0) {
1447
+ this.emit("error", { operation, ...context });
1448
+ }
1449
+ }
1450
+ serializeKeyPart(value) {
1451
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1452
+ return String(value);
1453
+ }
1454
+ return JSON.stringify(this.normalizeForSerialization(value));
1455
+ }
1456
+ isCacheSnapshotEntries(value) {
1457
+ return Array.isArray(value) && value.every((entry) => {
1458
+ if (!entry || typeof entry !== "object") {
1459
+ return false;
1460
+ }
1461
+ const candidate = entry;
1462
+ return typeof candidate.key === "string";
1463
+ });
1464
+ }
1465
+ normalizeForSerialization(value) {
1466
+ if (Array.isArray(value)) {
1467
+ return value.map((entry) => this.normalizeForSerialization(entry));
1468
+ }
1469
+ if (value && typeof value === "object") {
1470
+ return Object.keys(value).sort().reduce((normalized, key) => {
1471
+ normalized[key] = this.normalizeForSerialization(value[key]);
1472
+ return normalized;
1473
+ }, {});
1474
+ }
1475
+ return value;
1476
+ }
639
1477
  };
640
1478
 
641
1479
  // src/invalidation/RedisInvalidationBus.ts
@@ -643,19 +1481,27 @@ var RedisInvalidationBus = class {
643
1481
  channel;
644
1482
  publisher;
645
1483
  subscriber;
1484
+ activeListener;
646
1485
  constructor(options) {
647
1486
  this.publisher = options.publisher;
648
1487
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
649
1488
  this.channel = options.channel ?? "layercache:invalidation";
650
1489
  }
651
1490
  async subscribe(handler) {
652
- const listener = async (_channel, payload) => {
653
- const message = JSON.parse(payload);
654
- await handler(message);
1491
+ if (this.activeListener) {
1492
+ throw new Error("RedisInvalidationBus already has an active subscription.");
1493
+ }
1494
+ const listener = (_channel, payload) => {
1495
+ void this.handleMessage(payload, handler);
655
1496
  };
1497
+ this.activeListener = listener;
656
1498
  this.subscriber.on("message", listener);
657
1499
  await this.subscriber.subscribe(this.channel);
658
1500
  return async () => {
1501
+ if (this.activeListener !== listener) {
1502
+ return;
1503
+ }
1504
+ this.activeListener = void 0;
659
1505
  this.subscriber.off("message", listener);
660
1506
  await this.subscriber.unsubscribe(this.channel);
661
1507
  };
@@ -663,109 +1509,130 @@ var RedisInvalidationBus = class {
663
1509
  async publish(message) {
664
1510
  await this.publisher.publish(this.channel, JSON.stringify(message));
665
1511
  }
666
- };
667
-
668
- // src/invalidation/RedisTagIndex.ts
669
- var RedisTagIndex = class {
670
- client;
671
- prefix;
672
- scanCount;
673
- constructor(options) {
674
- this.client = options.client;
675
- this.prefix = options.prefix ?? "layercache:tag-index";
676
- this.scanCount = options.scanCount ?? 100;
677
- }
678
- async touch(key) {
679
- await this.client.sadd(this.knownKeysKey(), key);
680
- }
681
- async track(key, tags) {
682
- const keyTagsKey = this.keyTagsKey(key);
683
- const existingTags = await this.client.smembers(keyTagsKey);
684
- const pipeline = this.client.pipeline();
685
- pipeline.sadd(this.knownKeysKey(), key);
686
- for (const tag of existingTags) {
687
- pipeline.srem(this.tagKeysKey(tag), key);
688
- }
689
- pipeline.del(keyTagsKey);
690
- if (tags.length > 0) {
691
- pipeline.sadd(keyTagsKey, ...tags);
692
- for (const tag of new Set(tags)) {
693
- pipeline.sadd(this.tagKeysKey(tag), key);
1512
+ async handleMessage(payload, handler) {
1513
+ let message;
1514
+ try {
1515
+ const parsed = JSON.parse(payload);
1516
+ if (!this.isInvalidationMessage(parsed)) {
1517
+ throw new Error("Invalid invalidation payload shape.");
694
1518
  }
1519
+ message = parsed;
1520
+ } catch (error) {
1521
+ this.reportError("invalid invalidation payload", error);
1522
+ return;
695
1523
  }
696
- await pipeline.exec();
697
- }
698
- async remove(key) {
699
- const keyTagsKey = this.keyTagsKey(key);
700
- const existingTags = await this.client.smembers(keyTagsKey);
701
- const pipeline = this.client.pipeline();
702
- pipeline.srem(this.knownKeysKey(), key);
703
- pipeline.del(keyTagsKey);
704
- for (const tag of existingTags) {
705
- pipeline.srem(this.tagKeysKey(tag), key);
1524
+ try {
1525
+ await handler(message);
1526
+ } catch (error) {
1527
+ this.reportError("invalidation handler failed", error);
706
1528
  }
707
- await pipeline.exec();
708
- }
709
- async keysForTag(tag) {
710
- return this.client.smembers(this.tagKeysKey(tag));
711
- }
712
- async matchPattern(pattern) {
713
- const matches = [];
714
- let cursor = "0";
715
- do {
716
- const [nextCursor, keys] = await this.client.sscan(
717
- this.knownKeysKey(),
718
- cursor,
719
- "MATCH",
720
- pattern,
721
- "COUNT",
722
- this.scanCount
723
- );
724
- cursor = nextCursor;
725
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
726
- } while (cursor !== "0");
727
- return matches;
728
1529
  }
729
- async clear() {
730
- const indexKeys = await this.scanIndexKeys();
731
- if (indexKeys.length === 0) {
732
- return;
1530
+ isInvalidationMessage(value) {
1531
+ if (!value || typeof value !== "object") {
1532
+ return false;
733
1533
  }
734
- await this.client.del(...indexKeys);
735
- }
736
- async scanIndexKeys() {
737
- const matches = [];
738
- let cursor = "0";
739
- const pattern = `${this.prefix}:*`;
740
- do {
741
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
742
- cursor = nextCursor;
743
- matches.push(...keys);
744
- } while (cursor !== "0");
745
- return matches;
746
- }
747
- knownKeysKey() {
748
- return `${this.prefix}:keys`;
749
- }
750
- keyTagsKey(key) {
751
- return `${this.prefix}:key:${encodeURIComponent(key)}`;
1534
+ const candidate = value;
1535
+ const validScope = candidate.scope === "key" || candidate.scope === "keys" || candidate.scope === "clear";
1536
+ const validOperation = candidate.operation === void 0 || candidate.operation === "write" || candidate.operation === "delete" || candidate.operation === "invalidate" || candidate.operation === "clear";
1537
+ const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
1538
+ return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
752
1539
  }
753
- tagKeysKey(tag) {
754
- return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
1540
+ reportError(message, error) {
1541
+ console.error(`[layercache] ${message}`, error);
755
1542
  }
756
1543
  };
757
1544
 
1545
+ // src/http/createCacheStatsHandler.ts
1546
+ function createCacheStatsHandler(cache) {
1547
+ return async (_request, response) => {
1548
+ response.statusCode = 200;
1549
+ response.setHeader?.("content-type", "application/json; charset=utf-8");
1550
+ response.end(JSON.stringify(cache.getStats(), null, 2));
1551
+ };
1552
+ }
1553
+
1554
+ // src/decorators/createCachedMethodDecorator.ts
1555
+ function createCachedMethodDecorator(options) {
1556
+ const wrappedByInstance = /* @__PURE__ */ new WeakMap();
1557
+ return ((_, propertyKey, descriptor) => {
1558
+ const original = descriptor.value;
1559
+ if (typeof original !== "function") {
1560
+ throw new Error("createCachedMethodDecorator can only be applied to methods.");
1561
+ }
1562
+ descriptor.value = async function(...args) {
1563
+ const instance = this;
1564
+ let wrapped = wrappedByInstance.get(instance);
1565
+ if (!wrapped) {
1566
+ const cache = options.cache(instance);
1567
+ wrapped = cache.wrap(
1568
+ options.prefix ?? String(propertyKey),
1569
+ (...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
1570
+ options
1571
+ );
1572
+ wrappedByInstance.set(instance, wrapped);
1573
+ }
1574
+ return wrapped(...args);
1575
+ };
1576
+ });
1577
+ }
1578
+
1579
+ // src/integrations/fastify.ts
1580
+ function createFastifyLayercachePlugin(cache, options = {}) {
1581
+ return async (fastify) => {
1582
+ fastify.decorate("cache", cache);
1583
+ if (options.exposeStatsRoute !== false && fastify.get) {
1584
+ fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
1585
+ }
1586
+ };
1587
+ }
1588
+
1589
+ // src/integrations/graphql.ts
1590
+ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1591
+ const wrapped = cache.wrap(prefix, resolver, {
1592
+ ...options,
1593
+ keyResolver: options.keyResolver
1594
+ });
1595
+ return (...args) => wrapped(...args);
1596
+ }
1597
+
1598
+ // src/integrations/trpc.ts
1599
+ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
1600
+ return async (context) => {
1601
+ const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
1602
+ let didFetch = false;
1603
+ let fetchedResult = null;
1604
+ const cached = await cache.get(
1605
+ key,
1606
+ async () => {
1607
+ didFetch = true;
1608
+ fetchedResult = await context.next();
1609
+ return fetchedResult;
1610
+ },
1611
+ options
1612
+ );
1613
+ if (cached !== null) {
1614
+ return cached;
1615
+ }
1616
+ if (didFetch) {
1617
+ return fetchedResult;
1618
+ }
1619
+ return context.next();
1620
+ };
1621
+ }
1622
+
758
1623
  // src/layers/MemoryLayer.ts
759
1624
  var MemoryLayer = class {
760
1625
  name;
761
1626
  defaultTtl;
762
1627
  isLocal = true;
763
1628
  maxSize;
1629
+ evictionPolicy;
764
1630
  entries = /* @__PURE__ */ new Map();
765
1631
  constructor(options = {}) {
766
1632
  this.name = options.name ?? "memory";
767
1633
  this.defaultTtl = options.ttl;
768
1634
  this.maxSize = options.maxSize ?? 1e3;
1635
+ this.evictionPolicy = options.evictionPolicy ?? "lru";
769
1636
  }
770
1637
  async get(key) {
771
1638
  const value = await this.getEntry(key);
@@ -780,8 +1647,13 @@ var MemoryLayer = class {
780
1647
  this.entries.delete(key);
781
1648
  return null;
782
1649
  }
783
- this.entries.delete(key);
784
- this.entries.set(key, entry);
1650
+ if (this.evictionPolicy === "lru") {
1651
+ this.entries.delete(key);
1652
+ entry.frequency += 1;
1653
+ this.entries.set(key, entry);
1654
+ } else {
1655
+ entry.frequency += 1;
1656
+ }
785
1657
  return entry.value;
786
1658
  }
787
1659
  async getMany(keys) {
@@ -795,15 +1667,42 @@ var MemoryLayer = class {
795
1667
  this.entries.delete(key);
796
1668
  this.entries.set(key, {
797
1669
  value,
798
- expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
1670
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
1671
+ frequency: 0,
1672
+ insertedAt: Date.now()
799
1673
  });
800
1674
  while (this.entries.size > this.maxSize) {
801
- const oldestKey = this.entries.keys().next().value;
802
- if (!oldestKey) {
803
- break;
804
- }
805
- this.entries.delete(oldestKey);
1675
+ this.evict();
1676
+ }
1677
+ }
1678
+ async has(key) {
1679
+ const entry = this.entries.get(key);
1680
+ if (!entry) {
1681
+ return false;
1682
+ }
1683
+ if (this.isExpired(entry)) {
1684
+ this.entries.delete(key);
1685
+ return false;
806
1686
  }
1687
+ return true;
1688
+ }
1689
+ async ttl(key) {
1690
+ const entry = this.entries.get(key);
1691
+ if (!entry) {
1692
+ return null;
1693
+ }
1694
+ if (this.isExpired(entry)) {
1695
+ this.entries.delete(key);
1696
+ return null;
1697
+ }
1698
+ if (entry.expiresAt === null) {
1699
+ return null;
1700
+ }
1701
+ return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
1702
+ }
1703
+ async size() {
1704
+ this.pruneExpired();
1705
+ return this.entries.size;
807
1706
  }
808
1707
  async delete(key) {
809
1708
  this.entries.delete(key);
@@ -820,6 +1719,52 @@ var MemoryLayer = class {
820
1719
  this.pruneExpired();
821
1720
  return [...this.entries.keys()];
822
1721
  }
1722
+ exportState() {
1723
+ this.pruneExpired();
1724
+ return [...this.entries.entries()].map(([key, entry]) => ({
1725
+ key,
1726
+ value: entry.value,
1727
+ expiresAt: entry.expiresAt
1728
+ }));
1729
+ }
1730
+ importState(entries) {
1731
+ for (const entry of entries) {
1732
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
1733
+ continue;
1734
+ }
1735
+ this.entries.set(entry.key, {
1736
+ value: entry.value,
1737
+ expiresAt: entry.expiresAt,
1738
+ frequency: 0,
1739
+ insertedAt: Date.now()
1740
+ });
1741
+ }
1742
+ while (this.entries.size > this.maxSize) {
1743
+ this.evict();
1744
+ }
1745
+ }
1746
+ evict() {
1747
+ if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
1748
+ const oldestKey = this.entries.keys().next().value;
1749
+ if (oldestKey !== void 0) {
1750
+ this.entries.delete(oldestKey);
1751
+ }
1752
+ return;
1753
+ }
1754
+ let victimKey;
1755
+ let minFreq = Number.POSITIVE_INFINITY;
1756
+ let minInsertedAt = Number.POSITIVE_INFINITY;
1757
+ for (const [key, entry] of this.entries.entries()) {
1758
+ if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
1759
+ minFreq = entry.frequency;
1760
+ minInsertedAt = entry.insertedAt;
1761
+ victimKey = key;
1762
+ }
1763
+ }
1764
+ if (victimKey !== void 0) {
1765
+ this.entries.delete(victimKey);
1766
+ }
1767
+ }
823
1768
  pruneExpired() {
824
1769
  for (const [key, entry] of this.entries.entries()) {
825
1770
  if (this.isExpired(entry)) {
@@ -832,6 +1777,9 @@ var MemoryLayer = class {
832
1777
  }
833
1778
  };
834
1779
 
1780
+ // src/layers/RedisLayer.ts
1781
+ import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "zlib";
1782
+
835
1783
  // src/serialization/JsonSerializer.ts
836
1784
  var JsonSerializer = class {
837
1785
  serialize(value) {
@@ -844,6 +1792,7 @@ var JsonSerializer = class {
844
1792
  };
845
1793
 
846
1794
  // src/layers/RedisLayer.ts
1795
+ var BATCH_DELETE_SIZE = 500;
847
1796
  var RedisLayer = class {
848
1797
  name;
849
1798
  defaultTtl;
@@ -853,6 +1802,8 @@ var RedisLayer = class {
853
1802
  prefix;
854
1803
  allowUnprefixedClear;
855
1804
  scanCount;
1805
+ compression;
1806
+ compressionThreshold;
856
1807
  constructor(options) {
857
1808
  this.client = options.client;
858
1809
  this.defaultTtl = options.ttl;
@@ -861,6 +1812,8 @@ var RedisLayer = class {
861
1812
  this.prefix = options.prefix ?? "";
862
1813
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
863
1814
  this.scanCount = options.scanCount ?? 100;
1815
+ this.compression = options.compression;
1816
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
864
1817
  }
865
1818
  async get(key) {
866
1819
  const payload = await this.getEntry(key);
@@ -871,7 +1824,7 @@ var RedisLayer = class {
871
1824
  if (payload === null) {
872
1825
  return null;
873
1826
  }
874
- return this.serializer.deserialize(payload);
1827
+ return this.deserializeOrDelete(key, payload);
875
1828
  }
876
1829
  async getMany(keys) {
877
1830
  if (keys.length === 0) {
@@ -885,16 +1838,18 @@ var RedisLayer = class {
885
1838
  if (results === null) {
886
1839
  return keys.map(() => null);
887
1840
  }
888
- return results.map((result) => {
889
- const [, payload] = result;
890
- if (payload === null) {
891
- return null;
892
- }
893
- return this.serializer.deserialize(payload);
894
- });
1841
+ return Promise.all(
1842
+ results.map(async (result, index) => {
1843
+ const [error, payload] = result;
1844
+ if (error || payload === null || !this.isSerializablePayload(payload)) {
1845
+ return null;
1846
+ }
1847
+ return this.deserializeOrDelete(keys[index] ?? "", payload);
1848
+ })
1849
+ );
895
1850
  }
896
1851
  async set(key, value, ttl = this.defaultTtl) {
897
- const payload = this.serializer.serialize(value);
1852
+ const payload = this.encodePayload(this.serializer.serialize(value));
898
1853
  const normalizedKey = this.withPrefix(key);
899
1854
  if (ttl && ttl > 0) {
900
1855
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -911,14 +1866,44 @@ var RedisLayer = class {
911
1866
  }
912
1867
  await this.client.del(...keys.map((key) => this.withPrefix(key)));
913
1868
  }
914
- async clear() {
915
- if (!this.prefix && !this.allowUnprefixedClear) {
916
- throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
1869
+ async has(key) {
1870
+ const exists = await this.client.exists(this.withPrefix(key));
1871
+ return exists > 0;
1872
+ }
1873
+ async ttl(key) {
1874
+ const remaining = await this.client.ttl(this.withPrefix(key));
1875
+ if (remaining < 0) {
1876
+ return null;
917
1877
  }
1878
+ return remaining;
1879
+ }
1880
+ async size() {
918
1881
  const keys = await this.keys();
919
- if (keys.length > 0) {
920
- await this.deleteMany(keys);
1882
+ return keys.length;
1883
+ }
1884
+ /**
1885
+ * Deletes all keys matching the layer's prefix in batches to avoid
1886
+ * loading millions of keys into memory at once.
1887
+ */
1888
+ async clear() {
1889
+ if (!this.prefix && !this.allowUnprefixedClear) {
1890
+ throw new Error(
1891
+ "RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
1892
+ );
921
1893
  }
1894
+ const pattern = `${this.prefix}*`;
1895
+ let cursor = "0";
1896
+ do {
1897
+ const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
1898
+ cursor = nextCursor;
1899
+ if (keys.length === 0) {
1900
+ continue;
1901
+ }
1902
+ for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
1903
+ const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
1904
+ await this.client.del(...batch);
1905
+ }
1906
+ } while (cursor !== "0");
922
1907
  }
923
1908
  async keys() {
924
1909
  const pattern = `${this.prefix}*`;
@@ -941,6 +1926,205 @@ var RedisLayer = class {
941
1926
  withPrefix(key) {
942
1927
  return `${this.prefix}${key}`;
943
1928
  }
1929
+ async deserializeOrDelete(key, payload) {
1930
+ try {
1931
+ return this.serializer.deserialize(this.decodePayload(payload));
1932
+ } catch {
1933
+ await this.client.del(this.withPrefix(key)).catch(() => void 0);
1934
+ return null;
1935
+ }
1936
+ }
1937
+ isSerializablePayload(payload) {
1938
+ return typeof payload === "string" || Buffer.isBuffer(payload);
1939
+ }
1940
+ encodePayload(payload) {
1941
+ if (!this.compression) {
1942
+ return payload;
1943
+ }
1944
+ const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
1945
+ if (source.byteLength < this.compressionThreshold) {
1946
+ return payload;
1947
+ }
1948
+ const header = Buffer.from(`LCZ1:${this.compression}:`);
1949
+ const compressed = this.compression === "gzip" ? gzipSync(source) : brotliCompressSync(source);
1950
+ return Buffer.concat([header, compressed]);
1951
+ }
1952
+ decodePayload(payload) {
1953
+ if (!Buffer.isBuffer(payload)) {
1954
+ return payload;
1955
+ }
1956
+ if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
1957
+ return gunzipSync(payload.subarray(10));
1958
+ }
1959
+ if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
1960
+ return brotliDecompressSync(payload.subarray(12));
1961
+ }
1962
+ return payload;
1963
+ }
1964
+ };
1965
+
1966
+ // src/layers/DiskLayer.ts
1967
+ import { createHash } from "crypto";
1968
+ import { promises as fs2 } from "fs";
1969
+ import { join } from "path";
1970
+ var DiskLayer = class {
1971
+ name;
1972
+ defaultTtl;
1973
+ isLocal = true;
1974
+ directory;
1975
+ serializer;
1976
+ constructor(options) {
1977
+ this.directory = options.directory;
1978
+ this.defaultTtl = options.ttl;
1979
+ this.name = options.name ?? "disk";
1980
+ this.serializer = options.serializer ?? new JsonSerializer();
1981
+ }
1982
+ async get(key) {
1983
+ return unwrapStoredValue(await this.getEntry(key));
1984
+ }
1985
+ async getEntry(key) {
1986
+ const filePath = this.keyToPath(key);
1987
+ let raw;
1988
+ try {
1989
+ raw = await fs2.readFile(filePath);
1990
+ } catch {
1991
+ return null;
1992
+ }
1993
+ let entry;
1994
+ try {
1995
+ entry = this.serializer.deserialize(raw);
1996
+ } catch {
1997
+ await this.safeDelete(filePath);
1998
+ return null;
1999
+ }
2000
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
2001
+ await this.safeDelete(filePath);
2002
+ return null;
2003
+ }
2004
+ return entry.value;
2005
+ }
2006
+ async set(key, value, ttl = this.defaultTtl) {
2007
+ await fs2.mkdir(this.directory, { recursive: true });
2008
+ const entry = {
2009
+ value,
2010
+ expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
2011
+ };
2012
+ const payload = this.serializer.serialize(entry);
2013
+ await fs2.writeFile(this.keyToPath(key), payload);
2014
+ }
2015
+ async has(key) {
2016
+ const value = await this.getEntry(key);
2017
+ return value !== null;
2018
+ }
2019
+ async ttl(key) {
2020
+ const filePath = this.keyToPath(key);
2021
+ let raw;
2022
+ try {
2023
+ raw = await fs2.readFile(filePath);
2024
+ } catch {
2025
+ return null;
2026
+ }
2027
+ let entry;
2028
+ try {
2029
+ entry = this.serializer.deserialize(raw);
2030
+ } catch {
2031
+ return null;
2032
+ }
2033
+ if (entry.expiresAt === null) {
2034
+ return null;
2035
+ }
2036
+ const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
2037
+ if (remaining <= 0) {
2038
+ return null;
2039
+ }
2040
+ return remaining;
2041
+ }
2042
+ async delete(key) {
2043
+ await this.safeDelete(this.keyToPath(key));
2044
+ }
2045
+ async deleteMany(keys) {
2046
+ await Promise.all(keys.map((key) => this.delete(key)));
2047
+ }
2048
+ async clear() {
2049
+ let entries;
2050
+ try {
2051
+ entries = await fs2.readdir(this.directory);
2052
+ } catch {
2053
+ return;
2054
+ }
2055
+ await Promise.all(
2056
+ entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
2057
+ );
2058
+ }
2059
+ async keys() {
2060
+ let entries;
2061
+ try {
2062
+ entries = await fs2.readdir(this.directory);
2063
+ } catch {
2064
+ return [];
2065
+ }
2066
+ return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
2067
+ }
2068
+ async size() {
2069
+ const keys = await this.keys();
2070
+ return keys.length;
2071
+ }
2072
+ keyToPath(key) {
2073
+ const hash = createHash("sha256").update(key).digest("hex");
2074
+ return join(this.directory, `${hash}.lc`);
2075
+ }
2076
+ async safeDelete(filePath) {
2077
+ try {
2078
+ await fs2.unlink(filePath);
2079
+ } catch {
2080
+ }
2081
+ }
2082
+ };
2083
+
2084
+ // src/layers/MemcachedLayer.ts
2085
+ var MemcachedLayer = class {
2086
+ name;
2087
+ defaultTtl;
2088
+ isLocal = false;
2089
+ client;
2090
+ keyPrefix;
2091
+ constructor(options) {
2092
+ this.client = options.client;
2093
+ this.defaultTtl = options.ttl;
2094
+ this.name = options.name ?? "memcached";
2095
+ this.keyPrefix = options.keyPrefix ?? "";
2096
+ }
2097
+ async get(key) {
2098
+ const result = await this.client.get(this.withPrefix(key));
2099
+ if (!result || result.value === null) {
2100
+ return null;
2101
+ }
2102
+ try {
2103
+ return JSON.parse(result.value.toString("utf8"));
2104
+ } catch {
2105
+ return null;
2106
+ }
2107
+ }
2108
+ async set(key, value, ttl = this.defaultTtl) {
2109
+ const payload = JSON.stringify(value);
2110
+ await this.client.set(this.withPrefix(key), payload, {
2111
+ expires: ttl && ttl > 0 ? ttl : void 0
2112
+ });
2113
+ }
2114
+ async delete(key) {
2115
+ await this.client.delete(this.withPrefix(key));
2116
+ }
2117
+ async deleteMany(keys) {
2118
+ await Promise.all(keys.map((key) => this.delete(key)));
2119
+ }
2120
+ async clear() {
2121
+ throw new Error(
2122
+ "MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
2123
+ );
2124
+ }
2125
+ withPrefix(key) {
2126
+ return `${this.keyPrefix}${key}`;
2127
+ }
944
2128
  };
945
2129
 
946
2130
  // src/serialization/MsgpackSerializer.ts
@@ -984,9 +2168,79 @@ var RedisSingleFlightCoordinator = class {
984
2168
  return waiter();
985
2169
  }
986
2170
  };
2171
+
2172
+ // src/metrics/PrometheusExporter.ts
2173
+ function createPrometheusMetricsExporter(stacks) {
2174
+ return () => {
2175
+ const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
2176
+ const lines = [];
2177
+ lines.push("# HELP layercache_hits_total Total number of cache hits");
2178
+ lines.push("# TYPE layercache_hits_total counter");
2179
+ lines.push("# HELP layercache_misses_total Total number of cache misses");
2180
+ lines.push("# TYPE layercache_misses_total counter");
2181
+ lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
2182
+ lines.push("# TYPE layercache_fetches_total counter");
2183
+ lines.push("# HELP layercache_sets_total Total number of cache sets");
2184
+ lines.push("# TYPE layercache_sets_total counter");
2185
+ lines.push("# HELP layercache_deletes_total Total number of cache deletes");
2186
+ lines.push("# TYPE layercache_deletes_total counter");
2187
+ lines.push("# HELP layercache_backfills_total Total number of backfill operations");
2188
+ lines.push("# TYPE layercache_backfills_total counter");
2189
+ lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
2190
+ lines.push("# TYPE layercache_stale_hits_total counter");
2191
+ lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
2192
+ lines.push("# TYPE layercache_refreshes_total counter");
2193
+ lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
2194
+ lines.push("# TYPE layercache_refresh_errors_total counter");
2195
+ lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
2196
+ lines.push("# TYPE layercache_negative_cache_hits_total counter");
2197
+ lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
2198
+ lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
2199
+ lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
2200
+ lines.push("# TYPE layercache_degraded_operations_total counter");
2201
+ lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
2202
+ lines.push("# TYPE layercache_hit_rate gauge");
2203
+ lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
2204
+ lines.push("# TYPE layercache_hits_by_layer_total counter");
2205
+ lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
2206
+ lines.push("# TYPE layercache_misses_by_layer_total counter");
2207
+ for (const { stack, name } of entries) {
2208
+ const m = stack.getMetrics();
2209
+ const hr = stack.getHitRate();
2210
+ const label = `cache="${sanitizeLabel(name)}"`;
2211
+ lines.push(`layercache_hits_total{${label}} ${m.hits}`);
2212
+ lines.push(`layercache_misses_total{${label}} ${m.misses}`);
2213
+ lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
2214
+ lines.push(`layercache_sets_total{${label}} ${m.sets}`);
2215
+ lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
2216
+ lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
2217
+ lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
2218
+ lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
2219
+ lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
2220
+ lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
2221
+ lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
2222
+ lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
2223
+ lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
2224
+ for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
2225
+ lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2226
+ }
2227
+ for (const [layerName, count] of Object.entries(m.missesByLayer)) {
2228
+ lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
2229
+ }
2230
+ }
2231
+ lines.push("");
2232
+ return lines.join("\n");
2233
+ };
2234
+ }
2235
+ function sanitizeLabel(value) {
2236
+ return value.replace(/["\\\n]/g, "_");
2237
+ }
987
2238
  export {
2239
+ CacheNamespace,
988
2240
  CacheStack,
2241
+ DiskLayer,
989
2242
  JsonSerializer,
2243
+ MemcachedLayer,
990
2244
  MemoryLayer,
991
2245
  MsgpackSerializer,
992
2246
  PatternMatcher,
@@ -995,5 +2249,11 @@ export {
995
2249
  RedisSingleFlightCoordinator,
996
2250
  RedisTagIndex,
997
2251
  StampedeGuard,
998
- TagIndex
2252
+ TagIndex,
2253
+ cacheGraphqlResolver,
2254
+ createCacheStatsHandler,
2255
+ createCachedMethodDecorator,
2256
+ createFastifyLayercachePlugin,
2257
+ createPrometheusMetricsExporter,
2258
+ createTrpcCacheMiddleware
999
2259
  };