layercache 1.0.1 → 1.0.2

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,6 +20,7 @@ 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,
24
25
  JsonSerializer: () => JsonSerializer,
25
26
  MemoryLayer: () => MemoryLayer,
@@ -30,12 +31,19 @@ __export(index_exports, {
30
31
  RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
31
32
  RedisTagIndex: () => RedisTagIndex,
32
33
  StampedeGuard: () => StampedeGuard,
33
- TagIndex: () => TagIndex
34
+ TagIndex: () => TagIndex,
35
+ cacheGraphqlResolver: () => cacheGraphqlResolver,
36
+ createCacheStatsHandler: () => createCacheStatsHandler,
37
+ createCachedMethodDecorator: () => createCachedMethodDecorator,
38
+ createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
39
+ createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
34
40
  });
35
41
  module.exports = __toCommonJS(index_exports);
36
42
 
37
43
  // src/CacheStack.ts
38
44
  var import_node_crypto = require("crypto");
45
+ var import_node_fs = require("fs");
46
+ var import_node_events = require("events");
39
47
 
40
48
  // src/internal/StoredValue.ts
41
49
  function isStoredValueEnvelope(value) {
@@ -55,7 +63,10 @@ function createStoredValueEnvelope(options) {
55
63
  value: options.value,
56
64
  freshUntil,
57
65
  staleUntil,
58
- errorUntil
66
+ errorUntil,
67
+ freshTtlSeconds: freshTtlSeconds ?? null,
68
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
69
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
59
70
  };
60
71
  }
61
72
  function resolveStoredValue(stored, now = Date.now()) {
@@ -96,6 +107,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
96
107
  }
97
108
  return Math.max(1, Math.ceil(remainingMs / 1e3));
98
109
  }
110
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
111
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
112
+ return void 0;
113
+ }
114
+ const remainingMs = stored.freshUntil - now;
115
+ if (remainingMs <= 0) {
116
+ return 0;
117
+ }
118
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
119
+ }
120
+ function refreshStoredEnvelope(stored, now = Date.now()) {
121
+ if (!isStoredValueEnvelope(stored)) {
122
+ return stored;
123
+ }
124
+ return createStoredValueEnvelope({
125
+ kind: stored.kind,
126
+ value: stored.value,
127
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
128
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
129
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
130
+ now
131
+ });
132
+ }
99
133
  function maxExpiry(stored) {
100
134
  const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
101
135
  (value) => value !== null
@@ -112,6 +146,61 @@ function normalizePositiveSeconds(value) {
112
146
  return value;
113
147
  }
114
148
 
149
+ // src/CacheNamespace.ts
150
+ var CacheNamespace = class {
151
+ constructor(cache, prefix) {
152
+ this.cache = cache;
153
+ this.prefix = prefix;
154
+ }
155
+ cache;
156
+ prefix;
157
+ async get(key, fetcher, options) {
158
+ return this.cache.get(this.qualify(key), fetcher, options);
159
+ }
160
+ async set(key, value, options) {
161
+ await this.cache.set(this.qualify(key), value, options);
162
+ }
163
+ async delete(key) {
164
+ await this.cache.delete(this.qualify(key));
165
+ }
166
+ async clear() {
167
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
168
+ }
169
+ async mget(entries) {
170
+ return this.cache.mget(entries.map((entry) => ({
171
+ ...entry,
172
+ key: this.qualify(entry.key)
173
+ })));
174
+ }
175
+ async mset(entries) {
176
+ await this.cache.mset(entries.map((entry) => ({
177
+ ...entry,
178
+ key: this.qualify(entry.key)
179
+ })));
180
+ }
181
+ async invalidateByTag(tag) {
182
+ await this.cache.invalidateByTag(tag);
183
+ }
184
+ async invalidateByPattern(pattern) {
185
+ await this.cache.invalidateByPattern(this.qualify(pattern));
186
+ }
187
+ wrap(keyPrefix, fetcher, options) {
188
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
189
+ }
190
+ warm(entries, options) {
191
+ return this.cache.warm(entries.map((entry) => ({
192
+ ...entry,
193
+ key: this.qualify(entry.key)
194
+ })), options);
195
+ }
196
+ getMetrics() {
197
+ return this.cache.getMetrics();
198
+ }
199
+ qualify(key) {
200
+ return `${this.prefix}:${key}`;
201
+ }
202
+ };
203
+
115
204
  // src/invalidation/PatternMatcher.ts
116
205
  var PatternMatcher = class {
117
206
  static matches(pattern, value) {
@@ -184,22 +273,24 @@ var import_async_mutex = require("async-mutex");
184
273
  var StampedeGuard = class {
185
274
  mutexes = /* @__PURE__ */ new Map();
186
275
  async execute(key, task) {
187
- const mutex = this.getMutex(key);
276
+ const entry = this.getMutexEntry(key);
188
277
  try {
189
- return await mutex.runExclusive(task);
278
+ return await entry.mutex.runExclusive(task);
190
279
  } finally {
191
- if (!mutex.isLocked()) {
280
+ entry.references -= 1;
281
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
192
282
  this.mutexes.delete(key);
193
283
  }
194
284
  }
195
285
  }
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);
286
+ getMutexEntry(key) {
287
+ let entry = this.mutexes.get(key);
288
+ if (!entry) {
289
+ entry = { mutex: new import_async_mutex.Mutex(), references: 0 };
290
+ this.mutexes.set(key, entry);
201
291
  }
202
- return mutex;
292
+ entry.references += 1;
293
+ return entry;
203
294
  }
204
295
  };
205
296
 
@@ -208,6 +299,7 @@ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
208
299
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
209
300
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
210
301
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
302
+ var MAX_CACHE_KEY_LENGTH = 1024;
211
303
  var EMPTY_METRICS = () => ({
212
304
  hits: 0,
213
305
  misses: 0,
@@ -220,7 +312,12 @@ var EMPTY_METRICS = () => ({
220
312
  refreshes: 0,
221
313
  refreshErrors: 0,
222
314
  writeFailures: 0,
223
- singleFlightWaits: 0
315
+ singleFlightWaits: 0,
316
+ negativeCacheHits: 0,
317
+ circuitBreakerTrips: 0,
318
+ degradedOperations: 0,
319
+ hitsByLayer: {},
320
+ missesByLayer: {}
224
321
  });
225
322
  var DebugLogger = class {
226
323
  enabled;
@@ -228,20 +325,34 @@ var DebugLogger = class {
228
325
  this.enabled = enabled;
229
326
  }
230
327
  debug(message, context) {
328
+ this.write("debug", message, context);
329
+ }
330
+ info(message, context) {
331
+ this.write("info", message, context);
332
+ }
333
+ warn(message, context) {
334
+ this.write("warn", message, context);
335
+ }
336
+ error(message, context) {
337
+ this.write("error", message, context);
338
+ }
339
+ write(level, message, context) {
231
340
  if (!this.enabled) {
232
341
  return;
233
342
  }
234
343
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
235
- console.debug(`[layercache] ${message}${suffix}`);
344
+ console[level](`[layercache] ${message}${suffix}`);
236
345
  }
237
346
  };
238
- var CacheStack = class {
347
+ var CacheStack = class extends import_node_events.EventEmitter {
239
348
  constructor(layers, options = {}) {
349
+ super();
240
350
  this.layers = layers;
241
351
  this.options = options;
242
352
  if (layers.length === 0) {
243
353
  throw new Error("CacheStack requires at least one cache layer.");
244
354
  }
355
+ this.validateConfiguration();
245
356
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
246
357
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
247
358
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -257,33 +368,47 @@ var CacheStack = class {
257
368
  logger;
258
369
  tagIndex;
259
370
  backgroundRefreshes = /* @__PURE__ */ new Map();
371
+ accessProfiles = /* @__PURE__ */ new Map();
372
+ layerDegradedUntil = /* @__PURE__ */ new Map();
373
+ circuitBreakers = /* @__PURE__ */ new Map();
374
+ isDisconnecting = false;
375
+ disconnectPromise;
260
376
  async get(key, fetcher, options) {
377
+ const normalizedKey = this.validateCacheKey(key);
378
+ this.validateWriteOptions(options);
261
379
  await this.startup;
262
- const hit = await this.readFromLayers(key, options, "allow-stale");
380
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
263
381
  if (hit.found) {
382
+ this.recordAccess(normalizedKey);
383
+ if (this.isNegativeStoredValue(hit.stored)) {
384
+ this.metrics.negativeCacheHits += 1;
385
+ }
264
386
  if (hit.state === "fresh") {
265
387
  this.metrics.hits += 1;
388
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
266
389
  return hit.value;
267
390
  }
268
391
  if (hit.state === "stale-while-revalidate") {
269
392
  this.metrics.hits += 1;
270
393
  this.metrics.staleHits += 1;
394
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
271
395
  if (fetcher) {
272
- this.scheduleBackgroundRefresh(key, fetcher, options);
396
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
273
397
  }
274
398
  return hit.value;
275
399
  }
276
400
  if (!fetcher) {
277
401
  this.metrics.hits += 1;
278
402
  this.metrics.staleHits += 1;
403
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
279
404
  return hit.value;
280
405
  }
281
406
  try {
282
- return await this.fetchWithGuards(key, fetcher, options);
407
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
283
408
  } catch (error) {
284
409
  this.metrics.staleHits += 1;
285
410
  this.metrics.refreshErrors += 1;
286
- this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
411
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
287
412
  return hit.value;
288
413
  }
289
414
  }
@@ -291,71 +416,144 @@ var CacheStack = class {
291
416
  if (!fetcher) {
292
417
  return null;
293
418
  }
294
- return this.fetchWithGuards(key, fetcher, options);
419
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
295
420
  }
296
421
  async set(key, value, options) {
422
+ const normalizedKey = this.validateCacheKey(key);
423
+ this.validateWriteOptions(options);
297
424
  await this.startup;
298
- await this.storeEntry(key, "value", value, options);
425
+ await this.storeEntry(normalizedKey, "value", value, options);
299
426
  }
300
427
  async delete(key) {
428
+ const normalizedKey = this.validateCacheKey(key);
301
429
  await this.startup;
302
- await this.deleteKeys([key]);
303
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
430
+ await this.deleteKeys([normalizedKey]);
431
+ await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
304
432
  }
305
433
  async clear() {
306
434
  await this.startup;
307
435
  await Promise.all(this.layers.map((layer) => layer.clear()));
308
436
  await this.tagIndex.clear();
437
+ this.accessProfiles.clear();
309
438
  this.metrics.invalidations += 1;
310
- this.logger.debug("clear");
439
+ this.logger.debug?.("clear");
311
440
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
312
441
  }
313
442
  async mget(entries) {
314
443
  if (entries.length === 0) {
315
444
  return [];
316
445
  }
317
- const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
446
+ const normalizedEntries = entries.map((entry) => ({
447
+ ...entry,
448
+ key: this.validateCacheKey(entry.key)
449
+ }));
450
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
451
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
318
452
  if (!canFastPath) {
319
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
453
+ const pendingReads = /* @__PURE__ */ new Map();
454
+ return Promise.all(
455
+ normalizedEntries.map((entry) => {
456
+ const optionsSignature = this.serializeOptions(entry.options);
457
+ const existing = pendingReads.get(entry.key);
458
+ if (!existing) {
459
+ const promise = this.get(entry.key, entry.fetch, entry.options);
460
+ pendingReads.set(entry.key, {
461
+ promise,
462
+ fetch: entry.fetch,
463
+ optionsSignature
464
+ });
465
+ return promise;
466
+ }
467
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
468
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
469
+ }
470
+ return existing.promise;
471
+ })
472
+ );
320
473
  }
321
474
  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) {
475
+ const pending = /* @__PURE__ */ new Set();
476
+ const indexesByKey = /* @__PURE__ */ new Map();
477
+ const resultsByKey = /* @__PURE__ */ new Map();
478
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
479
+ const key = normalizedEntries[index].key;
480
+ const indexes = indexesByKey.get(key) ?? [];
481
+ indexes.push(index);
482
+ indexesByKey.set(key, indexes);
483
+ pending.add(key);
484
+ }
485
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
486
+ const layer = this.layers[layerIndex];
487
+ const keys = [...pending];
488
+ if (keys.length === 0) {
327
489
  break;
328
490
  }
329
- const keys = indexes.map((index) => entries[index].key);
330
491
  const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
331
492
  for (let offset = 0; offset < values.length; offset += 1) {
332
- const index = indexes[offset];
493
+ const key = keys[offset];
333
494
  const stored = values[offset];
334
495
  if (stored === null) {
335
496
  continue;
336
497
  }
337
498
  const resolved = resolveStoredValue(stored);
338
499
  if (resolved.state === "expired") {
339
- await layer.delete(entries[index].key);
500
+ await layer.delete(key);
340
501
  continue;
341
502
  }
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;
503
+ await this.tagIndex.touch(key);
504
+ await this.backfill(key, stored, layerIndex - 1);
505
+ resultsByKey.set(key, resolved.value);
506
+ pending.delete(key);
507
+ this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
347
508
  }
348
509
  }
349
510
  if (pending.size > 0) {
350
- for (const index of pending) {
351
- await this.tagIndex.remove(entries[index].key);
352
- this.metrics.misses += 1;
511
+ for (const key of pending) {
512
+ await this.tagIndex.remove(key);
513
+ this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
353
514
  }
354
515
  }
355
- return results;
516
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
356
517
  }
357
518
  async mset(entries) {
358
- await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
519
+ const normalizedEntries = entries.map((entry) => ({
520
+ ...entry,
521
+ key: this.validateCacheKey(entry.key)
522
+ }));
523
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
524
+ await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
525
+ }
526
+ async warm(entries, options = {}) {
527
+ const concurrency = Math.max(1, options.concurrency ?? 4);
528
+ const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
529
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
530
+ while (queue.length > 0) {
531
+ const entry = queue.shift();
532
+ if (!entry) {
533
+ return;
534
+ }
535
+ try {
536
+ await this.get(entry.key, entry.fetcher, entry.options);
537
+ this.emit("warm", { key: entry.key });
538
+ } catch (error) {
539
+ this.emitError("warm", { key: entry.key, error: this.formatError(error) });
540
+ if (!options.continueOnError) {
541
+ throw error;
542
+ }
543
+ }
544
+ }
545
+ });
546
+ await Promise.all(workers);
547
+ }
548
+ wrap(prefix, fetcher, options = {}) {
549
+ return (...args) => {
550
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
551
+ const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
552
+ return this.get(key, () => fetcher(...args), options);
553
+ };
554
+ }
555
+ namespace(prefix) {
556
+ return new CacheNamespace(this, prefix);
359
557
  }
360
558
  async invalidateByTag(tag) {
361
559
  await this.startup;
@@ -372,13 +570,74 @@ var CacheStack = class {
372
570
  getMetrics() {
373
571
  return { ...this.metrics };
374
572
  }
573
+ getStats() {
574
+ return {
575
+ metrics: this.getMetrics(),
576
+ layers: this.layers.map((layer) => ({
577
+ name: layer.name,
578
+ isLocal: Boolean(layer.isLocal),
579
+ degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
580
+ })),
581
+ backgroundRefreshes: this.backgroundRefreshes.size
582
+ };
583
+ }
375
584
  resetMetrics() {
376
585
  Object.assign(this.metrics, EMPTY_METRICS());
377
586
  }
378
- async disconnect() {
587
+ async exportState() {
588
+ await this.startup;
589
+ const exported = /* @__PURE__ */ new Map();
590
+ for (const layer of this.layers) {
591
+ if (!layer.keys) {
592
+ continue;
593
+ }
594
+ const keys = await layer.keys();
595
+ for (const key of keys) {
596
+ if (exported.has(key)) {
597
+ continue;
598
+ }
599
+ const stored = await this.readLayerEntry(layer, key);
600
+ if (stored === null) {
601
+ continue;
602
+ }
603
+ exported.set(key, {
604
+ key,
605
+ value: stored,
606
+ ttl: remainingStoredTtlSeconds(stored)
607
+ });
608
+ }
609
+ }
610
+ return [...exported.values()];
611
+ }
612
+ async importState(entries) {
379
613
  await this.startup;
380
- await this.unsubscribeInvalidation?.();
381
- await Promise.allSettled(this.backgroundRefreshes.values());
614
+ await Promise.all(entries.map(async (entry) => {
615
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
616
+ await this.tagIndex.touch(entry.key);
617
+ }));
618
+ }
619
+ async persistToFile(filePath) {
620
+ const snapshot = await this.exportState();
621
+ await import_node_fs.promises.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
622
+ }
623
+ async restoreFromFile(filePath) {
624
+ const raw = await import_node_fs.promises.readFile(filePath, "utf8");
625
+ const snapshot = JSON.parse(raw);
626
+ if (!this.isCacheSnapshotEntries(snapshot)) {
627
+ throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
628
+ }
629
+ await this.importState(snapshot);
630
+ }
631
+ async disconnect() {
632
+ if (!this.disconnectPromise) {
633
+ this.isDisconnecting = true;
634
+ this.disconnectPromise = (async () => {
635
+ await this.startup;
636
+ await this.unsubscribeInvalidation?.();
637
+ await Promise.allSettled([...this.backgroundRefreshes.values()]);
638
+ })();
639
+ }
640
+ await this.disconnectPromise;
382
641
  }
383
642
  async initialize() {
384
643
  if (!this.options.invalidationBus) {
@@ -418,6 +677,7 @@ var CacheStack = class {
418
677
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
419
678
  const deadline = Date.now() + timeoutMs;
420
679
  this.metrics.singleFlightWaits += 1;
680
+ this.emit("stampede-dedupe", { key });
421
681
  while (Date.now() < deadline) {
422
682
  const hit = await this.readFromLayers(key, options, "fresh-only");
423
683
  if (hit.found) {
@@ -429,8 +689,16 @@ var CacheStack = class {
429
689
  return this.fetchAndPopulate(key, fetcher, options);
430
690
  }
431
691
  async fetchAndPopulate(key, fetcher, options) {
692
+ this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
432
693
  this.metrics.fetches += 1;
433
- const fetched = await fetcher();
694
+ let fetched;
695
+ try {
696
+ fetched = await fetcher();
697
+ this.resetCircuitBreaker(key);
698
+ } catch (error) {
699
+ this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
700
+ throw error;
701
+ }
434
702
  if (fetched === null || fetched === void 0) {
435
703
  if (!this.shouldNegativeCache(options)) {
436
704
  return null;
@@ -449,8 +717,9 @@ var CacheStack = class {
449
717
  await this.tagIndex.touch(key);
450
718
  }
451
719
  this.metrics.sets += 1;
452
- this.logger.debug("set", { key, kind, tags: options?.tags });
453
- if (this.options.publishSetInvalidation !== false) {
720
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
721
+ this.emit("set", { key, kind, tags: options?.tags });
722
+ if (this.shouldBroadcastL1Invalidation()) {
454
723
  await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
455
724
  }
456
725
  }
@@ -460,6 +729,7 @@ var CacheStack = class {
460
729
  const layer = this.layers[index];
461
730
  const stored = await this.readLayerEntry(layer, key);
462
731
  if (stored === null) {
732
+ this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
463
733
  continue;
464
734
  }
465
735
  const resolved = resolveStoredValue(stored);
@@ -473,20 +743,34 @@ var CacheStack = class {
473
743
  }
474
744
  await this.tagIndex.touch(key);
475
745
  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 };
746
+ this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
747
+ this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
748
+ this.emit("hit", { key, layer: layer.name, state: resolved.state });
749
+ return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
478
750
  }
479
751
  if (!sawRetainableValue) {
480
752
  await this.tagIndex.remove(key);
481
753
  }
482
- this.logger.debug("miss", { key, mode });
754
+ this.logger.debug?.("miss", { key, mode });
755
+ this.emit("miss", { key, mode });
483
756
  return { found: false, value: null, stored: null, state: "miss" };
484
757
  }
485
758
  async readLayerEntry(layer, key) {
759
+ if (this.shouldSkipLayer(layer)) {
760
+ return null;
761
+ }
486
762
  if (layer.getEntry) {
487
- return layer.getEntry(key);
763
+ try {
764
+ return await layer.getEntry(key);
765
+ } catch (error) {
766
+ return this.handleLayerFailure(layer, "read", error);
767
+ }
768
+ }
769
+ try {
770
+ return await layer.get(key);
771
+ } catch (error) {
772
+ return this.handleLayerFailure(layer, "read", error);
488
773
  }
489
- return layer.get(key);
490
774
  }
491
775
  async backfill(key, stored, upToIndex, options) {
492
776
  if (upToIndex < 0) {
@@ -494,16 +778,28 @@ var CacheStack = class {
494
778
  }
495
779
  for (let index = 0; index <= upToIndex; index += 1) {
496
780
  const layer = this.layers[index];
781
+ if (this.shouldSkipLayer(layer)) {
782
+ continue;
783
+ }
497
784
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
498
- await layer.set(key, stored, ttl);
785
+ try {
786
+ await layer.set(key, stored, ttl);
787
+ } catch (error) {
788
+ await this.handleLayerFailure(layer, "backfill", error);
789
+ continue;
790
+ }
499
791
  this.metrics.backfills += 1;
500
- this.logger.debug("backfill", { key, layer: layer.name });
792
+ this.logger.debug?.("backfill", { key, layer: layer.name });
793
+ this.emit("backfill", { key, layer: layer.name });
501
794
  }
502
795
  }
503
796
  async writeAcrossLayers(key, kind, value, options) {
504
797
  const now = Date.now();
505
798
  const operations = this.layers.map((layer) => async () => {
506
- const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
799
+ if (this.shouldSkipLayer(layer)) {
800
+ return;
801
+ }
802
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
507
803
  const staleWhileRevalidate = this.resolveLayerSeconds(
508
804
  layer.name,
509
805
  options?.staleWhileRevalidate,
@@ -523,7 +819,11 @@ var CacheStack = class {
523
819
  now
524
820
  });
525
821
  const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
526
- await layer.set(key, payload, ttl);
822
+ try {
823
+ await layer.set(key, payload, ttl);
824
+ } catch (error) {
825
+ await this.handleLayerFailure(layer, "write", error);
826
+ }
527
827
  });
528
828
  await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
529
829
  }
@@ -538,7 +838,7 @@ var CacheStack = class {
538
838
  return;
539
839
  }
540
840
  this.metrics.writeFailures += failures.length;
541
- this.logger.debug("write-failure", {
841
+ this.logger.debug?.("write-failure", {
542
842
  ...context,
543
843
  failures: failures.map((failure) => this.formatError(failure.reason))
544
844
  });
@@ -549,15 +849,21 @@ var CacheStack = class {
549
849
  );
550
850
  }
551
851
  }
552
- resolveFreshTtl(layerName, kind, options, fallbackTtl) {
852
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
553
853
  const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
554
854
  layerName,
555
855
  options?.negativeTtl,
556
856
  this.options.negativeTtl,
557
857
  this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
558
858
  ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
859
+ const adaptiveTtl = this.applyAdaptiveTtl(
860
+ key,
861
+ layerName,
862
+ baseTtl,
863
+ options?.adaptiveTtl ?? this.options.adaptiveTtl
864
+ );
559
865
  const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
560
- return this.applyJitter(baseTtl, jitter);
866
+ return this.applyJitter(adaptiveTtl, jitter);
561
867
  }
562
868
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
563
869
  if (override !== void 0) {
@@ -585,7 +891,7 @@ var CacheStack = class {
585
891
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
586
892
  }
587
893
  scheduleBackgroundRefresh(key, fetcher, options) {
588
- if (this.backgroundRefreshes.has(key)) {
894
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
589
895
  return;
590
896
  }
591
897
  const refresh = (async () => {
@@ -594,7 +900,7 @@ var CacheStack = class {
594
900
  await this.fetchWithGuards(key, fetcher, options);
595
901
  } catch (error) {
596
902
  this.metrics.refreshErrors += 1;
597
- this.logger.debug("refresh-error", { key, error: this.formatError(error) });
903
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
598
904
  } finally {
599
905
  this.backgroundRefreshes.delete(key);
600
906
  }
@@ -612,21 +918,15 @@ var CacheStack = class {
612
918
  if (keys.length === 0) {
613
919
  return;
614
920
  }
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
- );
921
+ await this.deleteKeysFromLayers(this.layers, keys);
624
922
  for (const key of keys) {
625
923
  await this.tagIndex.remove(key);
924
+ this.accessProfiles.delete(key);
626
925
  }
627
926
  this.metrics.deletes += keys.length;
628
927
  this.metrics.invalidations += 1;
629
- this.logger.debug("delete", { keys });
928
+ this.logger.debug?.("delete", { keys });
929
+ this.emit("delete", { keys });
630
930
  }
631
931
  async publishInvalidation(message) {
632
932
  if (!this.options.invalidationBus) {
@@ -645,21 +945,15 @@ var CacheStack = class {
645
945
  if (message.scope === "clear") {
646
946
  await Promise.all(localLayers.map((layer) => layer.clear()));
647
947
  await this.tagIndex.clear();
948
+ this.accessProfiles.clear();
648
949
  return;
649
950
  }
650
951
  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
- );
952
+ await this.deleteKeysFromLayers(localLayers, keys);
660
953
  if (message.operation !== "write") {
661
954
  for (const key of keys) {
662
955
  await this.tagIndex.remove(key);
956
+ this.accessProfiles.delete(key);
663
957
  }
664
958
  }
665
959
  }
@@ -672,6 +966,257 @@ var CacheStack = class {
672
966
  sleep(ms) {
673
967
  return new Promise((resolve) => setTimeout(resolve, ms));
674
968
  }
969
+ shouldBroadcastL1Invalidation() {
970
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
971
+ }
972
+ async deleteKeysFromLayers(layers, keys) {
973
+ await Promise.all(
974
+ layers.map(async (layer) => {
975
+ if (this.shouldSkipLayer(layer)) {
976
+ return;
977
+ }
978
+ if (layer.deleteMany) {
979
+ try {
980
+ await layer.deleteMany(keys);
981
+ } catch (error) {
982
+ await this.handleLayerFailure(layer, "delete", error);
983
+ }
984
+ return;
985
+ }
986
+ await Promise.all(keys.map(async (key) => {
987
+ try {
988
+ await layer.delete(key);
989
+ } catch (error) {
990
+ await this.handleLayerFailure(layer, "delete", error);
991
+ }
992
+ }));
993
+ })
994
+ );
995
+ }
996
+ validateConfiguration() {
997
+ if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
998
+ throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
999
+ }
1000
+ if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
1001
+ throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
1002
+ }
1003
+ this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
1004
+ this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
1005
+ this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
1006
+ this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
1007
+ this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
1008
+ this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1009
+ this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1010
+ this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1011
+ this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1012
+ this.validateCircuitBreakerOptions(this.options.circuitBreaker);
1013
+ }
1014
+ validateWriteOptions(options) {
1015
+ if (!options) {
1016
+ return;
1017
+ }
1018
+ this.validateLayerNumberOption("options.ttl", options.ttl);
1019
+ this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
1020
+ this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
1021
+ this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
1022
+ this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
1023
+ this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
1024
+ this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1025
+ this.validateCircuitBreakerOptions(options.circuitBreaker);
1026
+ }
1027
+ validateLayerNumberOption(name, value) {
1028
+ if (value === void 0) {
1029
+ return;
1030
+ }
1031
+ if (typeof value === "number") {
1032
+ this.validateNonNegativeNumber(name, value);
1033
+ return;
1034
+ }
1035
+ for (const [layerName, layerValue] of Object.entries(value)) {
1036
+ if (layerValue === void 0) {
1037
+ continue;
1038
+ }
1039
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
1040
+ }
1041
+ }
1042
+ validatePositiveNumber(name, value) {
1043
+ if (value === void 0) {
1044
+ return;
1045
+ }
1046
+ if (!Number.isFinite(value) || value <= 0) {
1047
+ throw new Error(`${name} must be a positive finite number.`);
1048
+ }
1049
+ }
1050
+ validateNonNegativeNumber(name, value) {
1051
+ if (!Number.isFinite(value) || value < 0) {
1052
+ throw new Error(`${name} must be a non-negative finite number.`);
1053
+ }
1054
+ }
1055
+ validateCacheKey(key) {
1056
+ if (key.length === 0) {
1057
+ throw new Error("Cache key must not be empty.");
1058
+ }
1059
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
1060
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1061
+ }
1062
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
1063
+ throw new Error("Cache key contains unsupported control characters.");
1064
+ }
1065
+ return key;
1066
+ }
1067
+ serializeOptions(options) {
1068
+ return JSON.stringify(this.normalizeForSerialization(options) ?? null);
1069
+ }
1070
+ validateAdaptiveTtlOptions(options) {
1071
+ if (!options || options === true) {
1072
+ return;
1073
+ }
1074
+ this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1075
+ this.validateLayerNumberOption("adaptiveTtl.step", options.step);
1076
+ this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1077
+ }
1078
+ validateCircuitBreakerOptions(options) {
1079
+ if (!options) {
1080
+ return;
1081
+ }
1082
+ this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1083
+ this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1084
+ }
1085
+ async applyFreshReadPolicies(key, hit, options, fetcher) {
1086
+ const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
1087
+ const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
1088
+ if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
1089
+ const refreshed = refreshStoredEnvelope(hit.stored);
1090
+ const ttl = remainingStoredTtlSeconds(refreshed);
1091
+ for (let index = 0; index <= hit.layerIndex; index += 1) {
1092
+ const layer = this.layers[index];
1093
+ if (this.shouldSkipLayer(layer)) {
1094
+ continue;
1095
+ }
1096
+ try {
1097
+ await layer.set(key, refreshed, ttl);
1098
+ } catch (error) {
1099
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1100
+ }
1101
+ }
1102
+ }
1103
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1104
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1105
+ }
1106
+ }
1107
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1108
+ if (!ttl || !adaptiveTtl) {
1109
+ return ttl;
1110
+ }
1111
+ const profile = this.accessProfiles.get(key);
1112
+ if (!profile) {
1113
+ return ttl;
1114
+ }
1115
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
1116
+ const hotAfter = config.hotAfter ?? 3;
1117
+ if (profile.hits < hotAfter) {
1118
+ return ttl;
1119
+ }
1120
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1121
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1122
+ const multiplier = Math.floor(profile.hits / hotAfter);
1123
+ return Math.min(maxTtl, ttl + step * multiplier);
1124
+ }
1125
+ recordAccess(key) {
1126
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1127
+ profile.hits += 1;
1128
+ profile.lastAccessAt = Date.now();
1129
+ this.accessProfiles.set(key, profile);
1130
+ }
1131
+ incrementMetricMap(target, key) {
1132
+ target[key] = (target[key] ?? 0) + 1;
1133
+ }
1134
+ shouldSkipLayer(layer) {
1135
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
1136
+ return degradedUntil !== void 0 && degradedUntil > Date.now();
1137
+ }
1138
+ async handleLayerFailure(layer, operation, error) {
1139
+ if (!this.isGracefulDegradationEnabled()) {
1140
+ throw error;
1141
+ }
1142
+ const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1143
+ this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1144
+ this.metrics.degradedOperations += 1;
1145
+ this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1146
+ this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1147
+ return null;
1148
+ }
1149
+ isGracefulDegradationEnabled() {
1150
+ return Boolean(this.options.gracefulDegradation);
1151
+ }
1152
+ assertCircuitClosed(key, options) {
1153
+ const state = this.circuitBreakers.get(key);
1154
+ if (!state?.openUntil) {
1155
+ return;
1156
+ }
1157
+ if (state.openUntil <= Date.now()) {
1158
+ state.openUntil = null;
1159
+ state.failures = 0;
1160
+ this.circuitBreakers.set(key, state);
1161
+ return;
1162
+ }
1163
+ this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1164
+ throw new Error(`Circuit breaker is open for key "${key}".`);
1165
+ }
1166
+ recordCircuitFailure(key, options, error) {
1167
+ if (!options) {
1168
+ return;
1169
+ }
1170
+ const failureThreshold = options.failureThreshold ?? 3;
1171
+ const cooldownMs = options.cooldownMs ?? 3e4;
1172
+ const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1173
+ state.failures += 1;
1174
+ if (state.failures >= failureThreshold) {
1175
+ state.openUntil = Date.now() + cooldownMs;
1176
+ this.metrics.circuitBreakerTrips += 1;
1177
+ }
1178
+ this.circuitBreakers.set(key, state);
1179
+ this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1180
+ }
1181
+ resetCircuitBreaker(key) {
1182
+ this.circuitBreakers.delete(key);
1183
+ }
1184
+ isNegativeStoredValue(stored) {
1185
+ return isStoredValueEnvelope(stored) && stored.kind === "empty";
1186
+ }
1187
+ emitError(operation, context) {
1188
+ this.logger.error?.(operation, context);
1189
+ if (this.listenerCount("error") > 0) {
1190
+ this.emit("error", { operation, ...context });
1191
+ }
1192
+ }
1193
+ serializeKeyPart(value) {
1194
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1195
+ return String(value);
1196
+ }
1197
+ return JSON.stringify(this.normalizeForSerialization(value));
1198
+ }
1199
+ isCacheSnapshotEntries(value) {
1200
+ return Array.isArray(value) && value.every((entry) => {
1201
+ if (!entry || typeof entry !== "object") {
1202
+ return false;
1203
+ }
1204
+ const candidate = entry;
1205
+ return typeof candidate.key === "string";
1206
+ });
1207
+ }
1208
+ normalizeForSerialization(value) {
1209
+ if (Array.isArray(value)) {
1210
+ return value.map((entry) => this.normalizeForSerialization(entry));
1211
+ }
1212
+ if (value && typeof value === "object") {
1213
+ return Object.keys(value).sort().reduce((normalized, key) => {
1214
+ normalized[key] = this.normalizeForSerialization(value[key]);
1215
+ return normalized;
1216
+ }, {});
1217
+ }
1218
+ return value;
1219
+ }
675
1220
  };
676
1221
 
677
1222
  // src/invalidation/RedisInvalidationBus.ts
@@ -679,19 +1224,27 @@ var RedisInvalidationBus = class {
679
1224
  channel;
680
1225
  publisher;
681
1226
  subscriber;
1227
+ activeListener;
682
1228
  constructor(options) {
683
1229
  this.publisher = options.publisher;
684
1230
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
685
1231
  this.channel = options.channel ?? "layercache:invalidation";
686
1232
  }
687
1233
  async subscribe(handler) {
688
- const listener = async (_channel, payload) => {
689
- const message = JSON.parse(payload);
690
- await handler(message);
1234
+ if (this.activeListener) {
1235
+ throw new Error("RedisInvalidationBus already has an active subscription.");
1236
+ }
1237
+ const listener = (_channel, payload) => {
1238
+ void this.handleMessage(payload, handler);
691
1239
  };
1240
+ this.activeListener = listener;
692
1241
  this.subscriber.on("message", listener);
693
1242
  await this.subscriber.subscribe(this.channel);
694
1243
  return async () => {
1244
+ if (this.activeListener !== listener) {
1245
+ return;
1246
+ }
1247
+ this.activeListener = void 0;
695
1248
  this.subscriber.off("message", listener);
696
1249
  await this.subscriber.unsubscribe(this.channel);
697
1250
  };
@@ -699,6 +1252,37 @@ var RedisInvalidationBus = class {
699
1252
  async publish(message) {
700
1253
  await this.publisher.publish(this.channel, JSON.stringify(message));
701
1254
  }
1255
+ async handleMessage(payload, handler) {
1256
+ let message;
1257
+ try {
1258
+ const parsed = JSON.parse(payload);
1259
+ if (!this.isInvalidationMessage(parsed)) {
1260
+ throw new Error("Invalid invalidation payload shape.");
1261
+ }
1262
+ message = parsed;
1263
+ } catch (error) {
1264
+ this.reportError("invalid invalidation payload", error);
1265
+ return;
1266
+ }
1267
+ try {
1268
+ await handler(message);
1269
+ } catch (error) {
1270
+ this.reportError("invalidation handler failed", error);
1271
+ }
1272
+ }
1273
+ isInvalidationMessage(value) {
1274
+ if (!value || typeof value !== "object") {
1275
+ return false;
1276
+ }
1277
+ const candidate = value;
1278
+ const validScope = candidate.scope === "key" || candidate.scope === "keys" || candidate.scope === "clear";
1279
+ const validOperation = candidate.operation === void 0 || candidate.operation === "write" || candidate.operation === "delete" || candidate.operation === "invalidate" || candidate.operation === "clear";
1280
+ const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
1281
+ return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
1282
+ }
1283
+ reportError(message, error) {
1284
+ console.error(`[layercache] ${message}`, error);
1285
+ }
702
1286
  };
703
1287
 
704
1288
  // src/invalidation/RedisTagIndex.ts
@@ -791,6 +1375,84 @@ var RedisTagIndex = class {
791
1375
  }
792
1376
  };
793
1377
 
1378
+ // src/http/createCacheStatsHandler.ts
1379
+ function createCacheStatsHandler(cache) {
1380
+ return async (_request, response) => {
1381
+ response.statusCode = 200;
1382
+ response.setHeader?.("content-type", "application/json; charset=utf-8");
1383
+ response.end(JSON.stringify(cache.getStats(), null, 2));
1384
+ };
1385
+ }
1386
+
1387
+ // src/decorators/createCachedMethodDecorator.ts
1388
+ function createCachedMethodDecorator(options) {
1389
+ const wrappedByInstance = /* @__PURE__ */ new WeakMap();
1390
+ return ((_, propertyKey, descriptor) => {
1391
+ const original = descriptor.value;
1392
+ if (typeof original !== "function") {
1393
+ throw new Error("createCachedMethodDecorator can only be applied to methods.");
1394
+ }
1395
+ descriptor.value = async function(...args) {
1396
+ const instance = this;
1397
+ let wrapped = wrappedByInstance.get(instance);
1398
+ if (!wrapped) {
1399
+ const cache = options.cache(instance);
1400
+ wrapped = cache.wrap(
1401
+ options.prefix ?? String(propertyKey),
1402
+ (...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
1403
+ options
1404
+ );
1405
+ wrappedByInstance.set(instance, wrapped);
1406
+ }
1407
+ return wrapped(...args);
1408
+ };
1409
+ });
1410
+ }
1411
+
1412
+ // src/integrations/fastify.ts
1413
+ function createFastifyLayercachePlugin(cache, options = {}) {
1414
+ return async (fastify) => {
1415
+ fastify.decorate("cache", cache);
1416
+ if (options.exposeStatsRoute !== false && fastify.get) {
1417
+ fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
1418
+ }
1419
+ };
1420
+ }
1421
+
1422
+ // src/integrations/graphql.ts
1423
+ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1424
+ const wrapped = cache.wrap(prefix, resolver, {
1425
+ ...options,
1426
+ keyResolver: options.keyResolver
1427
+ });
1428
+ return (...args) => wrapped(...args);
1429
+ }
1430
+
1431
+ // src/integrations/trpc.ts
1432
+ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
1433
+ return async (context) => {
1434
+ const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
1435
+ let didFetch = false;
1436
+ let fetchedResult = null;
1437
+ const cached = await cache.get(
1438
+ key,
1439
+ async () => {
1440
+ didFetch = true;
1441
+ fetchedResult = await context.next();
1442
+ return fetchedResult;
1443
+ },
1444
+ options
1445
+ );
1446
+ if (cached !== null) {
1447
+ return cached;
1448
+ }
1449
+ if (didFetch) {
1450
+ return fetchedResult;
1451
+ }
1452
+ return context.next();
1453
+ };
1454
+ }
1455
+
794
1456
  // src/layers/MemoryLayer.ts
795
1457
  var MemoryLayer = class {
796
1458
  name;
@@ -856,6 +1518,32 @@ var MemoryLayer = class {
856
1518
  this.pruneExpired();
857
1519
  return [...this.entries.keys()];
858
1520
  }
1521
+ exportState() {
1522
+ this.pruneExpired();
1523
+ return [...this.entries.entries()].map(([key, entry]) => ({
1524
+ key,
1525
+ value: entry.value,
1526
+ expiresAt: entry.expiresAt
1527
+ }));
1528
+ }
1529
+ importState(entries) {
1530
+ for (const entry of entries) {
1531
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
1532
+ continue;
1533
+ }
1534
+ this.entries.set(entry.key, {
1535
+ value: entry.value,
1536
+ expiresAt: entry.expiresAt
1537
+ });
1538
+ }
1539
+ while (this.entries.size > this.maxSize) {
1540
+ const oldestKey = this.entries.keys().next().value;
1541
+ if (!oldestKey) {
1542
+ break;
1543
+ }
1544
+ this.entries.delete(oldestKey);
1545
+ }
1546
+ }
859
1547
  pruneExpired() {
860
1548
  for (const [key, entry] of this.entries.entries()) {
861
1549
  if (this.isExpired(entry)) {
@@ -868,6 +1556,9 @@ var MemoryLayer = class {
868
1556
  }
869
1557
  };
870
1558
 
1559
+ // src/layers/RedisLayer.ts
1560
+ var import_node_zlib = require("zlib");
1561
+
871
1562
  // src/serialization/JsonSerializer.ts
872
1563
  var JsonSerializer = class {
873
1564
  serialize(value) {
@@ -889,6 +1580,8 @@ var RedisLayer = class {
889
1580
  prefix;
890
1581
  allowUnprefixedClear;
891
1582
  scanCount;
1583
+ compression;
1584
+ compressionThreshold;
892
1585
  constructor(options) {
893
1586
  this.client = options.client;
894
1587
  this.defaultTtl = options.ttl;
@@ -897,6 +1590,8 @@ var RedisLayer = class {
897
1590
  this.prefix = options.prefix ?? "";
898
1591
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
899
1592
  this.scanCount = options.scanCount ?? 100;
1593
+ this.compression = options.compression;
1594
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
900
1595
  }
901
1596
  async get(key) {
902
1597
  const payload = await this.getEntry(key);
@@ -907,7 +1602,7 @@ var RedisLayer = class {
907
1602
  if (payload === null) {
908
1603
  return null;
909
1604
  }
910
- return this.serializer.deserialize(payload);
1605
+ return this.deserializeOrDelete(key, payload);
911
1606
  }
912
1607
  async getMany(keys) {
913
1608
  if (keys.length === 0) {
@@ -921,16 +1616,18 @@ var RedisLayer = class {
921
1616
  if (results === null) {
922
1617
  return keys.map(() => null);
923
1618
  }
924
- return results.map((result) => {
925
- const [, payload] = result;
926
- if (payload === null) {
927
- return null;
928
- }
929
- return this.serializer.deserialize(payload);
930
- });
1619
+ return Promise.all(
1620
+ results.map(async (result, index) => {
1621
+ const [error, payload] = result;
1622
+ if (error || payload === null || !this.isSerializablePayload(payload)) {
1623
+ return null;
1624
+ }
1625
+ return this.deserializeOrDelete(keys[index], payload);
1626
+ })
1627
+ );
931
1628
  }
932
1629
  async set(key, value, ttl = this.defaultTtl) {
933
- const payload = this.serializer.serialize(value);
1630
+ const payload = this.encodePayload(this.serializer.serialize(value));
934
1631
  const normalizedKey = this.withPrefix(key);
935
1632
  if (ttl && ttl > 0) {
936
1633
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -977,6 +1674,41 @@ var RedisLayer = class {
977
1674
  withPrefix(key) {
978
1675
  return `${this.prefix}${key}`;
979
1676
  }
1677
+ async deserializeOrDelete(key, payload) {
1678
+ try {
1679
+ return this.serializer.deserialize(this.decodePayload(payload));
1680
+ } catch {
1681
+ await this.client.del(this.withPrefix(key)).catch(() => void 0);
1682
+ return null;
1683
+ }
1684
+ }
1685
+ isSerializablePayload(payload) {
1686
+ return typeof payload === "string" || Buffer.isBuffer(payload);
1687
+ }
1688
+ encodePayload(payload) {
1689
+ if (!this.compression) {
1690
+ return payload;
1691
+ }
1692
+ const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
1693
+ if (source.byteLength < this.compressionThreshold) {
1694
+ return payload;
1695
+ }
1696
+ const header = Buffer.from(`LCZ1:${this.compression}:`);
1697
+ const compressed = this.compression === "gzip" ? (0, import_node_zlib.gzipSync)(source) : (0, import_node_zlib.brotliCompressSync)(source);
1698
+ return Buffer.concat([header, compressed]);
1699
+ }
1700
+ decodePayload(payload) {
1701
+ if (!Buffer.isBuffer(payload)) {
1702
+ return payload;
1703
+ }
1704
+ if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
1705
+ return (0, import_node_zlib.gunzipSync)(payload.subarray(10));
1706
+ }
1707
+ if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
1708
+ return (0, import_node_zlib.brotliDecompressSync)(payload.subarray(12));
1709
+ }
1710
+ return payload;
1711
+ }
980
1712
  };
981
1713
 
982
1714
  // src/serialization/MsgpackSerializer.ts
@@ -1022,6 +1754,7 @@ var RedisSingleFlightCoordinator = class {
1022
1754
  };
1023
1755
  // Annotate the CommonJS export names for ESM import in node:
1024
1756
  0 && (module.exports = {
1757
+ CacheNamespace,
1025
1758
  CacheStack,
1026
1759
  JsonSerializer,
1027
1760
  MemoryLayer,
@@ -1032,5 +1765,10 @@ var RedisSingleFlightCoordinator = class {
1032
1765
  RedisSingleFlightCoordinator,
1033
1766
  RedisTagIndex,
1034
1767
  StampedeGuard,
1035
- TagIndex
1768
+ TagIndex,
1769
+ cacheGraphqlResolver,
1770
+ createCacheStatsHandler,
1771
+ createCachedMethodDecorator,
1772
+ createFastifyLayercachePlugin,
1773
+ createTrpcCacheMiddleware
1036
1774
  });