layercache 1.0.0 → 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,
@@ -27,14 +28,178 @@ __export(index_exports, {
27
28
  PatternMatcher: () => PatternMatcher,
28
29
  RedisInvalidationBus: () => RedisInvalidationBus,
29
30
  RedisLayer: () => RedisLayer,
31
+ RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
30
32
  RedisTagIndex: () => RedisTagIndex,
31
33
  StampedeGuard: () => StampedeGuard,
32
- TagIndex: () => TagIndex
34
+ TagIndex: () => TagIndex,
35
+ cacheGraphqlResolver: () => cacheGraphqlResolver,
36
+ createCacheStatsHandler: () => createCacheStatsHandler,
37
+ createCachedMethodDecorator: () => createCachedMethodDecorator,
38
+ createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
39
+ createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
33
40
  });
34
41
  module.exports = __toCommonJS(index_exports);
35
42
 
36
43
  // src/CacheStack.ts
37
44
  var import_node_crypto = require("crypto");
45
+ var import_node_fs = require("fs");
46
+ var import_node_events = require("events");
47
+
48
+ // src/internal/StoredValue.ts
49
+ function isStoredValueEnvelope(value) {
50
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
51
+ }
52
+ function createStoredValueEnvelope(options) {
53
+ const now = options.now ?? Date.now();
54
+ const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
55
+ const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
56
+ const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
57
+ const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
58
+ const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
59
+ const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
60
+ return {
61
+ __layercache: 1,
62
+ kind: options.kind,
63
+ value: options.value,
64
+ freshUntil,
65
+ staleUntil,
66
+ errorUntil,
67
+ freshTtlSeconds: freshTtlSeconds ?? null,
68
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
69
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
70
+ };
71
+ }
72
+ function resolveStoredValue(stored, now = Date.now()) {
73
+ if (!isStoredValueEnvelope(stored)) {
74
+ return { state: "fresh", value: stored, stored };
75
+ }
76
+ if (stored.freshUntil === null || stored.freshUntil > now) {
77
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
78
+ }
79
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
80
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
81
+ }
82
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
83
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
84
+ }
85
+ return { state: "expired", value: null, stored, envelope: stored };
86
+ }
87
+ function unwrapStoredValue(stored) {
88
+ if (!isStoredValueEnvelope(stored)) {
89
+ return stored;
90
+ }
91
+ if (stored.kind === "empty") {
92
+ return null;
93
+ }
94
+ return stored.value ?? null;
95
+ }
96
+ function remainingStoredTtlSeconds(stored, now = Date.now()) {
97
+ if (!isStoredValueEnvelope(stored)) {
98
+ return void 0;
99
+ }
100
+ const expiry = maxExpiry(stored);
101
+ if (expiry === null) {
102
+ return void 0;
103
+ }
104
+ const remainingMs = expiry - now;
105
+ if (remainingMs <= 0) {
106
+ return 1;
107
+ }
108
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
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
+ }
133
+ function maxExpiry(stored) {
134
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
135
+ (value) => value !== null
136
+ );
137
+ if (values.length === 0) {
138
+ return null;
139
+ }
140
+ return Math.max(...values);
141
+ }
142
+ function normalizePositiveSeconds(value) {
143
+ if (!value || value <= 0) {
144
+ return void 0;
145
+ }
146
+ return value;
147
+ }
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
+ };
38
203
 
39
204
  // src/invalidation/PatternMatcher.ts
40
205
  var PatternMatcher = class {
@@ -108,26 +273,33 @@ var import_async_mutex = require("async-mutex");
108
273
  var StampedeGuard = class {
109
274
  mutexes = /* @__PURE__ */ new Map();
110
275
  async execute(key, task) {
111
- const mutex = this.getMutex(key);
276
+ const entry = this.getMutexEntry(key);
112
277
  try {
113
- return await mutex.runExclusive(task);
278
+ return await entry.mutex.runExclusive(task);
114
279
  } finally {
115
- if (!mutex.isLocked()) {
280
+ entry.references -= 1;
281
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
116
282
  this.mutexes.delete(key);
117
283
  }
118
284
  }
119
285
  }
120
- getMutex(key) {
121
- let mutex = this.mutexes.get(key);
122
- if (!mutex) {
123
- mutex = new import_async_mutex.Mutex();
124
- 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);
125
291
  }
126
- return mutex;
292
+ entry.references += 1;
293
+ return entry;
127
294
  }
128
295
  };
129
296
 
130
297
  // src/CacheStack.ts
298
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
299
+ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
300
+ var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
301
+ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
302
+ var MAX_CACHE_KEY_LENGTH = 1024;
131
303
  var EMPTY_METRICS = () => ({
132
304
  hits: 0,
133
305
  misses: 0,
@@ -135,7 +307,17 @@ var EMPTY_METRICS = () => ({
135
307
  sets: 0,
136
308
  deletes: 0,
137
309
  backfills: 0,
138
- invalidations: 0
310
+ invalidations: 0,
311
+ staleHits: 0,
312
+ refreshes: 0,
313
+ refreshErrors: 0,
314
+ writeFailures: 0,
315
+ singleFlightWaits: 0,
316
+ negativeCacheHits: 0,
317
+ circuitBreakerTrips: 0,
318
+ degradedOperations: 0,
319
+ hitsByLayer: {},
320
+ missesByLayer: {}
139
321
  });
140
322
  var DebugLogger = class {
141
323
  enabled;
@@ -143,21 +325,35 @@ var DebugLogger = class {
143
325
  this.enabled = enabled;
144
326
  }
145
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) {
146
340
  if (!this.enabled) {
147
341
  return;
148
342
  }
149
343
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
150
- console.debug(`[cachestack] ${message}${suffix}`);
344
+ console[level](`[layercache] ${message}${suffix}`);
151
345
  }
152
346
  };
153
- var CacheStack = class {
347
+ var CacheStack = class extends import_node_events.EventEmitter {
154
348
  constructor(layers, options = {}) {
349
+ super();
155
350
  this.layers = layers;
156
351
  this.options = options;
157
352
  if (layers.length === 0) {
158
353
  throw new Error("CacheStack requires at least one cache layer.");
159
354
  }
160
- const debugEnv = process.env.DEBUG?.split(",").includes("cachestack:debug") ?? false;
355
+ this.validateConfiguration();
356
+ const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
161
357
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
162
358
  this.tagIndex = options.tagIndex ?? new TagIndex();
163
359
  this.startup = this.initialize();
@@ -171,68 +367,193 @@ var CacheStack = class {
171
367
  unsubscribeInvalidation;
172
368
  logger;
173
369
  tagIndex;
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;
174
376
  async get(key, fetcher, options) {
377
+ const normalizedKey = this.validateCacheKey(key);
378
+ this.validateWriteOptions(options);
175
379
  await this.startup;
176
- const hit = await this.getFromLayers(key, options);
380
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
177
381
  if (hit.found) {
178
- this.metrics.hits += 1;
179
- return hit.value;
382
+ this.recordAccess(normalizedKey);
383
+ if (this.isNegativeStoredValue(hit.stored)) {
384
+ this.metrics.negativeCacheHits += 1;
385
+ }
386
+ if (hit.state === "fresh") {
387
+ this.metrics.hits += 1;
388
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
389
+ return hit.value;
390
+ }
391
+ if (hit.state === "stale-while-revalidate") {
392
+ this.metrics.hits += 1;
393
+ this.metrics.staleHits += 1;
394
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
395
+ if (fetcher) {
396
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
397
+ }
398
+ return hit.value;
399
+ }
400
+ if (!fetcher) {
401
+ this.metrics.hits += 1;
402
+ this.metrics.staleHits += 1;
403
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
404
+ return hit.value;
405
+ }
406
+ try {
407
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
408
+ } catch (error) {
409
+ this.metrics.staleHits += 1;
410
+ this.metrics.refreshErrors += 1;
411
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
412
+ return hit.value;
413
+ }
180
414
  }
181
415
  this.metrics.misses += 1;
182
416
  if (!fetcher) {
183
417
  return null;
184
418
  }
185
- const runFetch = async () => {
186
- const secondHit = await this.getFromLayers(key, options);
187
- if (secondHit.found) {
188
- this.metrics.hits += 1;
189
- return secondHit.value;
190
- }
191
- this.metrics.fetches += 1;
192
- const fetched = await fetcher();
193
- if (fetched === null || fetched === void 0) {
194
- return null;
195
- }
196
- await this.set(key, fetched, options);
197
- return fetched;
198
- };
199
- if (this.options.stampedePrevention === false) {
200
- return runFetch();
201
- }
202
- return this.stampedeGuard.execute(key, runFetch);
419
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
203
420
  }
204
421
  async set(key, value, options) {
422
+ const normalizedKey = this.validateCacheKey(key);
423
+ this.validateWriteOptions(options);
205
424
  await this.startup;
206
- await this.setAcrossLayers(key, value, options);
207
- if (options?.tags) {
208
- await this.tagIndex.track(key, options.tags);
209
- } else {
210
- await this.tagIndex.touch(key);
211
- }
212
- this.metrics.sets += 1;
213
- this.logger.debug("set", { key, tags: options?.tags });
214
- if (this.options.publishSetInvalidation !== false) {
215
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
216
- }
425
+ await this.storeEntry(normalizedKey, "value", value, options);
217
426
  }
218
427
  async delete(key) {
428
+ const normalizedKey = this.validateCacheKey(key);
219
429
  await this.startup;
220
- await this.deleteKeys([key]);
221
- 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" });
222
432
  }
223
433
  async clear() {
224
434
  await this.startup;
225
435
  await Promise.all(this.layers.map((layer) => layer.clear()));
226
436
  await this.tagIndex.clear();
437
+ this.accessProfiles.clear();
227
438
  this.metrics.invalidations += 1;
228
- this.logger.debug("clear");
439
+ this.logger.debug?.("clear");
229
440
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
230
441
  }
231
442
  async mget(entries) {
232
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
443
+ if (entries.length === 0) {
444
+ return [];
445
+ }
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);
452
+ if (!canFastPath) {
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
+ );
473
+ }
474
+ await this.startup;
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) {
489
+ break;
490
+ }
491
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
492
+ for (let offset = 0; offset < values.length; offset += 1) {
493
+ const key = keys[offset];
494
+ const stored = values[offset];
495
+ if (stored === null) {
496
+ continue;
497
+ }
498
+ const resolved = resolveStoredValue(stored);
499
+ if (resolved.state === "expired") {
500
+ await layer.delete(key);
501
+ continue;
502
+ }
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;
508
+ }
509
+ }
510
+ if (pending.size > 0) {
511
+ for (const key of pending) {
512
+ await this.tagIndex.remove(key);
513
+ this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
514
+ }
515
+ }
516
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
233
517
  }
234
518
  async mset(entries) {
235
- 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);
236
557
  }
237
558
  async invalidateByTag(tag) {
238
559
  await this.startup;
@@ -249,12 +570,74 @@ var CacheStack = class {
249
570
  getMetrics() {
250
571
  return { ...this.metrics };
251
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
+ }
252
584
  resetMetrics() {
253
585
  Object.assign(this.metrics, EMPTY_METRICS());
254
586
  }
255
- 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) {
256
613
  await this.startup;
257
- await this.unsubscribeInvalidation?.();
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;
258
641
  }
259
642
  async initialize() {
260
643
  if (!this.options.invalidationBus) {
@@ -264,66 +647,286 @@ var CacheStack = class {
264
647
  await this.handleInvalidationMessage(message);
265
648
  });
266
649
  }
267
- async getFromLayers(key, options) {
650
+ async fetchWithGuards(key, fetcher, options) {
651
+ const fetchTask = async () => {
652
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
653
+ if (secondHit.found) {
654
+ this.metrics.hits += 1;
655
+ return secondHit.value;
656
+ }
657
+ return this.fetchAndPopulate(key, fetcher, options);
658
+ };
659
+ const singleFlightTask = async () => {
660
+ if (!this.options.singleFlightCoordinator) {
661
+ return fetchTask();
662
+ }
663
+ return this.options.singleFlightCoordinator.execute(
664
+ key,
665
+ this.resolveSingleFlightOptions(),
666
+ fetchTask,
667
+ () => this.waitForFreshValue(key, fetcher, options)
668
+ );
669
+ };
670
+ if (this.options.stampedePrevention === false) {
671
+ return singleFlightTask();
672
+ }
673
+ return this.stampedeGuard.execute(key, singleFlightTask);
674
+ }
675
+ async waitForFreshValue(key, fetcher, options) {
676
+ const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
677
+ const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
678
+ const deadline = Date.now() + timeoutMs;
679
+ this.metrics.singleFlightWaits += 1;
680
+ this.emit("stampede-dedupe", { key });
681
+ while (Date.now() < deadline) {
682
+ const hit = await this.readFromLayers(key, options, "fresh-only");
683
+ if (hit.found) {
684
+ this.metrics.hits += 1;
685
+ return hit.value;
686
+ }
687
+ await this.sleep(pollIntervalMs);
688
+ }
689
+ return this.fetchAndPopulate(key, fetcher, options);
690
+ }
691
+ async fetchAndPopulate(key, fetcher, options) {
692
+ this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
693
+ this.metrics.fetches += 1;
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
+ }
702
+ if (fetched === null || fetched === void 0) {
703
+ if (!this.shouldNegativeCache(options)) {
704
+ return null;
705
+ }
706
+ await this.storeEntry(key, "empty", null, options);
707
+ return null;
708
+ }
709
+ await this.storeEntry(key, "value", fetched, options);
710
+ return fetched;
711
+ }
712
+ async storeEntry(key, kind, value, options) {
713
+ await this.writeAcrossLayers(key, kind, value, options);
714
+ if (options?.tags) {
715
+ await this.tagIndex.track(key, options.tags);
716
+ } else {
717
+ await this.tagIndex.touch(key);
718
+ }
719
+ this.metrics.sets += 1;
720
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
721
+ this.emit("set", { key, kind, tags: options?.tags });
722
+ if (this.shouldBroadcastL1Invalidation()) {
723
+ await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
724
+ }
725
+ }
726
+ async readFromLayers(key, options, mode) {
727
+ let sawRetainableValue = false;
268
728
  for (let index = 0; index < this.layers.length; index += 1) {
269
729
  const layer = this.layers[index];
270
- const value = await layer.get(key);
271
- if (value === null) {
730
+ const stored = await this.readLayerEntry(layer, key);
731
+ if (stored === null) {
732
+ this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
733
+ continue;
734
+ }
735
+ const resolved = resolveStoredValue(stored);
736
+ if (resolved.state === "expired") {
737
+ await layer.delete(key);
738
+ continue;
739
+ }
740
+ sawRetainableValue = true;
741
+ if (mode === "fresh-only" && resolved.state !== "fresh") {
272
742
  continue;
273
743
  }
274
744
  await this.tagIndex.touch(key);
275
- await this.backfill(key, value, index - 1, options);
276
- this.logger.debug("hit", { key, layer: layer.name });
277
- return { found: true, value };
745
+ await this.backfill(key, stored, index - 1, options);
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 };
750
+ }
751
+ if (!sawRetainableValue) {
752
+ await this.tagIndex.remove(key);
278
753
  }
279
- await this.tagIndex.remove(key);
280
- this.logger.debug("miss", { key });
281
- return { found: false, value: null };
754
+ this.logger.debug?.("miss", { key, mode });
755
+ this.emit("miss", { key, mode });
756
+ return { found: false, value: null, stored: null, state: "miss" };
282
757
  }
283
- async backfill(key, value, upToIndex, options) {
758
+ async readLayerEntry(layer, key) {
759
+ if (this.shouldSkipLayer(layer)) {
760
+ return null;
761
+ }
762
+ if (layer.getEntry) {
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);
773
+ }
774
+ }
775
+ async backfill(key, stored, upToIndex, options) {
284
776
  if (upToIndex < 0) {
285
777
  return;
286
778
  }
287
779
  for (let index = 0; index <= upToIndex; index += 1) {
288
780
  const layer = this.layers[index];
289
- await layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl));
781
+ if (this.shouldSkipLayer(layer)) {
782
+ continue;
783
+ }
784
+ const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
785
+ try {
786
+ await layer.set(key, stored, ttl);
787
+ } catch (error) {
788
+ await this.handleLayerFailure(layer, "backfill", error);
789
+ continue;
790
+ }
290
791
  this.metrics.backfills += 1;
291
- 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 });
292
794
  }
293
795
  }
294
- async setAcrossLayers(key, value, options) {
295
- await Promise.all(
296
- this.layers.map((layer) => layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl)))
796
+ async writeAcrossLayers(key, kind, value, options) {
797
+ const now = Date.now();
798
+ const operations = this.layers.map((layer) => async () => {
799
+ if (this.shouldSkipLayer(layer)) {
800
+ return;
801
+ }
802
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
803
+ const staleWhileRevalidate = this.resolveLayerSeconds(
804
+ layer.name,
805
+ options?.staleWhileRevalidate,
806
+ this.options.staleWhileRevalidate
807
+ );
808
+ const staleIfError = this.resolveLayerSeconds(
809
+ layer.name,
810
+ options?.staleIfError,
811
+ this.options.staleIfError
812
+ );
813
+ const payload = createStoredValueEnvelope({
814
+ kind,
815
+ value,
816
+ freshTtlSeconds: freshTtl,
817
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
818
+ staleIfErrorSeconds: staleIfError,
819
+ now
820
+ });
821
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
822
+ try {
823
+ await layer.set(key, payload, ttl);
824
+ } catch (error) {
825
+ await this.handleLayerFailure(layer, "write", error);
826
+ }
827
+ });
828
+ await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
829
+ }
830
+ async executeLayerOperations(operations, context) {
831
+ if (this.options.writePolicy !== "best-effort") {
832
+ await Promise.all(operations.map((operation) => operation()));
833
+ return;
834
+ }
835
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
836
+ const failures = results.filter((result) => result.status === "rejected");
837
+ if (failures.length === 0) {
838
+ return;
839
+ }
840
+ this.metrics.writeFailures += failures.length;
841
+ this.logger.debug?.("write-failure", {
842
+ ...context,
843
+ failures: failures.map((failure) => this.formatError(failure.reason))
844
+ });
845
+ if (failures.length === operations.length) {
846
+ throw new AggregateError(
847
+ failures.map((failure) => failure.reason),
848
+ `${context.action} failed for every cache layer`
849
+ );
850
+ }
851
+ }
852
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
853
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
854
+ layerName,
855
+ options?.negativeTtl,
856
+ this.options.negativeTtl,
857
+ this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
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
297
864
  );
865
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
866
+ return this.applyJitter(adaptiveTtl, jitter);
298
867
  }
299
- resolveTtl(layerName, fallbackTtl, ttlOverride) {
300
- if (ttlOverride === void 0) {
301
- return fallbackTtl;
868
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
869
+ if (override !== void 0) {
870
+ return this.readLayerNumber(layerName, override) ?? fallback;
871
+ }
872
+ if (globalDefault !== void 0) {
873
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
302
874
  }
303
- if (typeof ttlOverride === "number") {
304
- return ttlOverride;
875
+ return fallback;
876
+ }
877
+ readLayerNumber(layerName, value) {
878
+ if (typeof value === "number") {
879
+ return value;
880
+ }
881
+ return value[layerName];
882
+ }
883
+ applyJitter(ttl, jitter) {
884
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
885
+ return ttl;
305
886
  }
306
- return ttlOverride[layerName] ?? fallbackTtl;
887
+ const delta = (Math.random() * 2 - 1) * jitter;
888
+ return Math.max(1, Math.round(ttl + delta));
889
+ }
890
+ shouldNegativeCache(options) {
891
+ return options?.negativeCache ?? this.options.negativeCaching ?? false;
892
+ }
893
+ scheduleBackgroundRefresh(key, fetcher, options) {
894
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
895
+ return;
896
+ }
897
+ const refresh = (async () => {
898
+ this.metrics.refreshes += 1;
899
+ try {
900
+ await this.fetchWithGuards(key, fetcher, options);
901
+ } catch (error) {
902
+ this.metrics.refreshErrors += 1;
903
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
904
+ } finally {
905
+ this.backgroundRefreshes.delete(key);
906
+ }
907
+ })();
908
+ this.backgroundRefreshes.set(key, refresh);
909
+ }
910
+ resolveSingleFlightOptions() {
911
+ return {
912
+ leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
913
+ waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
914
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
915
+ };
307
916
  }
308
917
  async deleteKeys(keys) {
309
918
  if (keys.length === 0) {
310
919
  return;
311
920
  }
312
- await Promise.all(
313
- this.layers.map(async (layer) => {
314
- if (layer.deleteMany) {
315
- await layer.deleteMany(keys);
316
- return;
317
- }
318
- await Promise.all(keys.map((key) => layer.delete(key)));
319
- })
320
- );
921
+ await this.deleteKeysFromLayers(this.layers, keys);
321
922
  for (const key of keys) {
322
923
  await this.tagIndex.remove(key);
924
+ this.accessProfiles.delete(key);
323
925
  }
324
926
  this.metrics.deletes += keys.length;
325
927
  this.metrics.invalidations += 1;
326
- this.logger.debug("delete", { keys });
928
+ this.logger.debug?.("delete", { keys });
929
+ this.emit("delete", { keys });
327
930
  }
328
931
  async publishInvalidation(message) {
329
932
  if (!this.options.invalidationBus) {
@@ -342,23 +945,277 @@ var CacheStack = class {
342
945
  if (message.scope === "clear") {
343
946
  await Promise.all(localLayers.map((layer) => layer.clear()));
344
947
  await this.tagIndex.clear();
948
+ this.accessProfiles.clear();
345
949
  return;
346
950
  }
347
951
  const keys = message.keys ?? [];
952
+ await this.deleteKeysFromLayers(localLayers, keys);
953
+ if (message.operation !== "write") {
954
+ for (const key of keys) {
955
+ await this.tagIndex.remove(key);
956
+ this.accessProfiles.delete(key);
957
+ }
958
+ }
959
+ }
960
+ formatError(error) {
961
+ if (error instanceof Error) {
962
+ return error.message;
963
+ }
964
+ return String(error);
965
+ }
966
+ sleep(ms) {
967
+ return new Promise((resolve) => setTimeout(resolve, ms));
968
+ }
969
+ shouldBroadcastL1Invalidation() {
970
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
971
+ }
972
+ async deleteKeysFromLayers(layers, keys) {
348
973
  await Promise.all(
349
- localLayers.map(async (layer) => {
974
+ layers.map(async (layer) => {
975
+ if (this.shouldSkipLayer(layer)) {
976
+ return;
977
+ }
350
978
  if (layer.deleteMany) {
351
- await layer.deleteMany(keys);
979
+ try {
980
+ await layer.deleteMany(keys);
981
+ } catch (error) {
982
+ await this.handleLayerFailure(layer, "delete", error);
983
+ }
352
984
  return;
353
985
  }
354
- await Promise.all(keys.map((key) => layer.delete(key)));
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
+ }));
355
993
  })
356
994
  );
357
- if (message.operation !== "write") {
358
- for (const key of keys) {
359
- await this.tagIndex.remove(key);
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;
360
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));
361
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;
362
1219
  }
363
1220
  };
364
1221
 
@@ -367,19 +1224,27 @@ var RedisInvalidationBus = class {
367
1224
  channel;
368
1225
  publisher;
369
1226
  subscriber;
1227
+ activeListener;
370
1228
  constructor(options) {
371
1229
  this.publisher = options.publisher;
372
1230
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
373
- this.channel = options.channel ?? "cachestack:invalidation";
1231
+ this.channel = options.channel ?? "layercache:invalidation";
374
1232
  }
375
1233
  async subscribe(handler) {
376
- const listener = async (_channel, payload) => {
377
- const message = JSON.parse(payload);
378
- 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);
379
1239
  };
1240
+ this.activeListener = listener;
380
1241
  this.subscriber.on("message", listener);
381
1242
  await this.subscriber.subscribe(this.channel);
382
1243
  return async () => {
1244
+ if (this.activeListener !== listener) {
1245
+ return;
1246
+ }
1247
+ this.activeListener = void 0;
383
1248
  this.subscriber.off("message", listener);
384
1249
  await this.subscriber.unsubscribe(this.channel);
385
1250
  };
@@ -387,6 +1252,37 @@ var RedisInvalidationBus = class {
387
1252
  async publish(message) {
388
1253
  await this.publisher.publish(this.channel, JSON.stringify(message));
389
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
+ }
390
1286
  };
391
1287
 
392
1288
  // src/invalidation/RedisTagIndex.ts
@@ -396,7 +1292,7 @@ var RedisTagIndex = class {
396
1292
  scanCount;
397
1293
  constructor(options) {
398
1294
  this.client = options.client;
399
- this.prefix = options.prefix ?? "cachestack:tag-index";
1295
+ this.prefix = options.prefix ?? "layercache:tag-index";
400
1296
  this.scanCount = options.scanCount ?? 100;
401
1297
  }
402
1298
  async touch(key) {
@@ -479,6 +1375,84 @@ var RedisTagIndex = class {
479
1375
  }
480
1376
  };
481
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
+
482
1456
  // src/layers/MemoryLayer.ts
483
1457
  var MemoryLayer = class {
484
1458
  name;
@@ -492,6 +1466,10 @@ var MemoryLayer = class {
492
1466
  this.maxSize = options.maxSize ?? 1e3;
493
1467
  }
494
1468
  async get(key) {
1469
+ const value = await this.getEntry(key);
1470
+ return unwrapStoredValue(value);
1471
+ }
1472
+ async getEntry(key) {
495
1473
  const entry = this.entries.get(key);
496
1474
  if (!entry) {
497
1475
  return null;
@@ -504,6 +1482,13 @@ var MemoryLayer = class {
504
1482
  this.entries.set(key, entry);
505
1483
  return entry.value;
506
1484
  }
1485
+ async getMany(keys) {
1486
+ const values = [];
1487
+ for (const key of keys) {
1488
+ values.push(await this.getEntry(key));
1489
+ }
1490
+ return values;
1491
+ }
507
1492
  async set(key, value, ttl = this.defaultTtl) {
508
1493
  this.entries.delete(key);
509
1494
  this.entries.set(key, {
@@ -533,6 +1518,32 @@ var MemoryLayer = class {
533
1518
  this.pruneExpired();
534
1519
  return [...this.entries.keys()];
535
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
+ }
536
1547
  pruneExpired() {
537
1548
  for (const [key, entry] of this.entries.entries()) {
538
1549
  if (this.isExpired(entry)) {
@@ -545,6 +1556,9 @@ var MemoryLayer = class {
545
1556
  }
546
1557
  };
547
1558
 
1559
+ // src/layers/RedisLayer.ts
1560
+ var import_node_zlib = require("zlib");
1561
+
548
1562
  // src/serialization/JsonSerializer.ts
549
1563
  var JsonSerializer = class {
550
1564
  serialize(value) {
@@ -566,6 +1580,8 @@ var RedisLayer = class {
566
1580
  prefix;
567
1581
  allowUnprefixedClear;
568
1582
  scanCount;
1583
+ compression;
1584
+ compressionThreshold;
569
1585
  constructor(options) {
570
1586
  this.client = options.client;
571
1587
  this.defaultTtl = options.ttl;
@@ -574,16 +1590,44 @@ var RedisLayer = class {
574
1590
  this.prefix = options.prefix ?? "";
575
1591
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
576
1592
  this.scanCount = options.scanCount ?? 100;
1593
+ this.compression = options.compression;
1594
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
577
1595
  }
578
1596
  async get(key) {
1597
+ const payload = await this.getEntry(key);
1598
+ return unwrapStoredValue(payload);
1599
+ }
1600
+ async getEntry(key) {
579
1601
  const payload = await this.client.getBuffer(this.withPrefix(key));
580
1602
  if (payload === null) {
581
1603
  return null;
582
1604
  }
583
- return this.serializer.deserialize(payload);
1605
+ return this.deserializeOrDelete(key, payload);
1606
+ }
1607
+ async getMany(keys) {
1608
+ if (keys.length === 0) {
1609
+ return [];
1610
+ }
1611
+ const pipeline = this.client.pipeline();
1612
+ for (const key of keys) {
1613
+ pipeline.getBuffer(this.withPrefix(key));
1614
+ }
1615
+ const results = await pipeline.exec();
1616
+ if (results === null) {
1617
+ return keys.map(() => null);
1618
+ }
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
+ );
584
1628
  }
585
1629
  async set(key, value, ttl = this.defaultTtl) {
586
- const payload = this.serializer.serialize(value);
1630
+ const payload = this.encodePayload(this.serializer.serialize(value));
587
1631
  const normalizedKey = this.withPrefix(key);
588
1632
  if (ttl && ttl > 0) {
589
1633
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -630,6 +1674,41 @@ var RedisLayer = class {
630
1674
  withPrefix(key) {
631
1675
  return `${this.prefix}${key}`;
632
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
+ }
633
1712
  };
634
1713
 
635
1714
  // src/serialization/MsgpackSerializer.ts
@@ -643,8 +1722,39 @@ var MsgpackSerializer = class {
643
1722
  return (0, import_msgpack.decode)(normalized);
644
1723
  }
645
1724
  };
1725
+
1726
+ // src/singleflight/RedisSingleFlightCoordinator.ts
1727
+ var import_node_crypto2 = require("crypto");
1728
+ var RELEASE_SCRIPT = `
1729
+ if redis.call("get", KEYS[1]) == ARGV[1] then
1730
+ return redis.call("del", KEYS[1])
1731
+ end
1732
+ return 0
1733
+ `;
1734
+ var RedisSingleFlightCoordinator = class {
1735
+ client;
1736
+ prefix;
1737
+ constructor(options) {
1738
+ this.client = options.client;
1739
+ this.prefix = options.prefix ?? "layercache:singleflight";
1740
+ }
1741
+ async execute(key, options, worker, waiter) {
1742
+ const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
1743
+ const token = (0, import_node_crypto2.randomUUID)();
1744
+ const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
1745
+ if (acquired === "OK") {
1746
+ try {
1747
+ return await worker();
1748
+ } finally {
1749
+ await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
1750
+ }
1751
+ }
1752
+ return waiter();
1753
+ }
1754
+ };
646
1755
  // Annotate the CommonJS export names for ESM import in node:
647
1756
  0 && (module.exports = {
1757
+ CacheNamespace,
648
1758
  CacheStack,
649
1759
  JsonSerializer,
650
1760
  MemoryLayer,
@@ -652,7 +1762,13 @@ var MsgpackSerializer = class {
652
1762
  PatternMatcher,
653
1763
  RedisInvalidationBus,
654
1764
  RedisLayer,
1765
+ RedisSingleFlightCoordinator,
655
1766
  RedisTagIndex,
656
1767
  StampedeGuard,
657
- TagIndex
1768
+ TagIndex,
1769
+ cacheGraphqlResolver,
1770
+ createCacheStatsHandler,
1771
+ createCachedMethodDecorator,
1772
+ createFastifyLayercachePlugin,
1773
+ createTrpcCacheMiddleware
658
1774
  });