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.js CHANGED
@@ -1,5 +1,12 @@
1
+ import {
2
+ PatternMatcher,
3
+ RedisTagIndex
4
+ } from "./chunk-IILH5XTS.js";
5
+
1
6
  // src/CacheStack.ts
2
7
  import { randomUUID } from "crypto";
8
+ import { promises as fs } from "fs";
9
+ import { EventEmitter } from "events";
3
10
 
4
11
  // src/internal/StoredValue.ts
5
12
  function isStoredValueEnvelope(value) {
@@ -19,7 +26,10 @@ function createStoredValueEnvelope(options) {
19
26
  value: options.value,
20
27
  freshUntil,
21
28
  staleUntil,
22
- errorUntil
29
+ errorUntil,
30
+ freshTtlSeconds: freshTtlSeconds ?? null,
31
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
32
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
23
33
  };
24
34
  }
25
35
  function resolveStoredValue(stored, now = Date.now()) {
@@ -60,6 +70,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
60
70
  }
61
71
  return Math.max(1, Math.ceil(remainingMs / 1e3));
62
72
  }
73
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
74
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
75
+ return void 0;
76
+ }
77
+ const remainingMs = stored.freshUntil - now;
78
+ if (remainingMs <= 0) {
79
+ return 0;
80
+ }
81
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
82
+ }
83
+ function refreshStoredEnvelope(stored, now = Date.now()) {
84
+ if (!isStoredValueEnvelope(stored)) {
85
+ return stored;
86
+ }
87
+ return createStoredValueEnvelope({
88
+ kind: stored.kind,
89
+ value: stored.value,
90
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
91
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
92
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
93
+ now
94
+ });
95
+ }
63
96
  function maxExpiry(stored) {
64
97
  const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
65
98
  (value) => value !== null
@@ -76,12 +109,58 @@ function normalizePositiveSeconds(value) {
76
109
  return value;
77
110
  }
78
111
 
79
- // src/invalidation/PatternMatcher.ts
80
- var PatternMatcher = class {
81
- static matches(pattern, value) {
82
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
83
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
84
- return regex.test(value);
112
+ // src/CacheNamespace.ts
113
+ var CacheNamespace = class {
114
+ constructor(cache, prefix) {
115
+ this.cache = cache;
116
+ this.prefix = prefix;
117
+ }
118
+ cache;
119
+ prefix;
120
+ async get(key, fetcher, options) {
121
+ return this.cache.get(this.qualify(key), fetcher, options);
122
+ }
123
+ async set(key, value, options) {
124
+ await this.cache.set(this.qualify(key), value, options);
125
+ }
126
+ async delete(key) {
127
+ await this.cache.delete(this.qualify(key));
128
+ }
129
+ async clear() {
130
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
131
+ }
132
+ async mget(entries) {
133
+ return this.cache.mget(entries.map((entry) => ({
134
+ ...entry,
135
+ key: this.qualify(entry.key)
136
+ })));
137
+ }
138
+ async mset(entries) {
139
+ await this.cache.mset(entries.map((entry) => ({
140
+ ...entry,
141
+ key: this.qualify(entry.key)
142
+ })));
143
+ }
144
+ async invalidateByTag(tag) {
145
+ await this.cache.invalidateByTag(tag);
146
+ }
147
+ async invalidateByPattern(pattern) {
148
+ await this.cache.invalidateByPattern(this.qualify(pattern));
149
+ }
150
+ wrap(keyPrefix, fetcher, options) {
151
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
152
+ }
153
+ warm(entries, options) {
154
+ return this.cache.warm(entries.map((entry) => ({
155
+ ...entry,
156
+ key: this.qualify(entry.key)
157
+ })), options);
158
+ }
159
+ getMetrics() {
160
+ return this.cache.getMetrics();
161
+ }
162
+ qualify(key) {
163
+ return `${this.prefix}:${key}`;
85
164
  }
86
165
  };
87
166
 
@@ -148,22 +227,24 @@ import { Mutex } from "async-mutex";
148
227
  var StampedeGuard = class {
149
228
  mutexes = /* @__PURE__ */ new Map();
150
229
  async execute(key, task) {
151
- const mutex = this.getMutex(key);
230
+ const entry = this.getMutexEntry(key);
152
231
  try {
153
- return await mutex.runExclusive(task);
232
+ return await entry.mutex.runExclusive(task);
154
233
  } finally {
155
- if (!mutex.isLocked()) {
234
+ entry.references -= 1;
235
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
156
236
  this.mutexes.delete(key);
157
237
  }
158
238
  }
159
239
  }
160
- getMutex(key) {
161
- let mutex = this.mutexes.get(key);
162
- if (!mutex) {
163
- mutex = new Mutex();
164
- this.mutexes.set(key, mutex);
240
+ getMutexEntry(key) {
241
+ let entry = this.mutexes.get(key);
242
+ if (!entry) {
243
+ entry = { mutex: new Mutex(), references: 0 };
244
+ this.mutexes.set(key, entry);
165
245
  }
166
- return mutex;
246
+ entry.references += 1;
247
+ return entry;
167
248
  }
168
249
  };
169
250
 
@@ -172,6 +253,7 @@ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
172
253
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
173
254
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
174
255
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
256
+ var MAX_CACHE_KEY_LENGTH = 1024;
175
257
  var EMPTY_METRICS = () => ({
176
258
  hits: 0,
177
259
  misses: 0,
@@ -184,7 +266,12 @@ var EMPTY_METRICS = () => ({
184
266
  refreshes: 0,
185
267
  refreshErrors: 0,
186
268
  writeFailures: 0,
187
- singleFlightWaits: 0
269
+ singleFlightWaits: 0,
270
+ negativeCacheHits: 0,
271
+ circuitBreakerTrips: 0,
272
+ degradedOperations: 0,
273
+ hitsByLayer: {},
274
+ missesByLayer: {}
188
275
  });
189
276
  var DebugLogger = class {
190
277
  enabled;
@@ -192,20 +279,34 @@ var DebugLogger = class {
192
279
  this.enabled = enabled;
193
280
  }
194
281
  debug(message, context) {
282
+ this.write("debug", message, context);
283
+ }
284
+ info(message, context) {
285
+ this.write("info", message, context);
286
+ }
287
+ warn(message, context) {
288
+ this.write("warn", message, context);
289
+ }
290
+ error(message, context) {
291
+ this.write("error", message, context);
292
+ }
293
+ write(level, message, context) {
195
294
  if (!this.enabled) {
196
295
  return;
197
296
  }
198
297
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
199
- console.debug(`[layercache] ${message}${suffix}`);
298
+ console[level](`[layercache] ${message}${suffix}`);
200
299
  }
201
300
  };
202
- var CacheStack = class {
301
+ var CacheStack = class extends EventEmitter {
203
302
  constructor(layers, options = {}) {
303
+ super();
204
304
  this.layers = layers;
205
305
  this.options = options;
206
306
  if (layers.length === 0) {
207
307
  throw new Error("CacheStack requires at least one cache layer.");
208
308
  }
309
+ this.validateConfiguration();
209
310
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
210
311
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
211
312
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -221,33 +322,47 @@ var CacheStack = class {
221
322
  logger;
222
323
  tagIndex;
223
324
  backgroundRefreshes = /* @__PURE__ */ new Map();
325
+ accessProfiles = /* @__PURE__ */ new Map();
326
+ layerDegradedUntil = /* @__PURE__ */ new Map();
327
+ circuitBreakers = /* @__PURE__ */ new Map();
328
+ isDisconnecting = false;
329
+ disconnectPromise;
224
330
  async get(key, fetcher, options) {
331
+ const normalizedKey = this.validateCacheKey(key);
332
+ this.validateWriteOptions(options);
225
333
  await this.startup;
226
- const hit = await this.readFromLayers(key, options, "allow-stale");
334
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
227
335
  if (hit.found) {
336
+ this.recordAccess(normalizedKey);
337
+ if (this.isNegativeStoredValue(hit.stored)) {
338
+ this.metrics.negativeCacheHits += 1;
339
+ }
228
340
  if (hit.state === "fresh") {
229
341
  this.metrics.hits += 1;
342
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
230
343
  return hit.value;
231
344
  }
232
345
  if (hit.state === "stale-while-revalidate") {
233
346
  this.metrics.hits += 1;
234
347
  this.metrics.staleHits += 1;
348
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
235
349
  if (fetcher) {
236
- this.scheduleBackgroundRefresh(key, fetcher, options);
350
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
237
351
  }
238
352
  return hit.value;
239
353
  }
240
354
  if (!fetcher) {
241
355
  this.metrics.hits += 1;
242
356
  this.metrics.staleHits += 1;
357
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
243
358
  return hit.value;
244
359
  }
245
360
  try {
246
- return await this.fetchWithGuards(key, fetcher, options);
361
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
247
362
  } catch (error) {
248
363
  this.metrics.staleHits += 1;
249
364
  this.metrics.refreshErrors += 1;
250
- this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
365
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
251
366
  return hit.value;
252
367
  }
253
368
  }
@@ -255,71 +370,144 @@ var CacheStack = class {
255
370
  if (!fetcher) {
256
371
  return null;
257
372
  }
258
- return this.fetchWithGuards(key, fetcher, options);
373
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
259
374
  }
260
375
  async set(key, value, options) {
376
+ const normalizedKey = this.validateCacheKey(key);
377
+ this.validateWriteOptions(options);
261
378
  await this.startup;
262
- await this.storeEntry(key, "value", value, options);
379
+ await this.storeEntry(normalizedKey, "value", value, options);
263
380
  }
264
381
  async delete(key) {
382
+ const normalizedKey = this.validateCacheKey(key);
265
383
  await this.startup;
266
- await this.deleteKeys([key]);
267
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
384
+ await this.deleteKeys([normalizedKey]);
385
+ await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
268
386
  }
269
387
  async clear() {
270
388
  await this.startup;
271
389
  await Promise.all(this.layers.map((layer) => layer.clear()));
272
390
  await this.tagIndex.clear();
391
+ this.accessProfiles.clear();
273
392
  this.metrics.invalidations += 1;
274
- this.logger.debug("clear");
393
+ this.logger.debug?.("clear");
275
394
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
276
395
  }
277
396
  async mget(entries) {
278
397
  if (entries.length === 0) {
279
398
  return [];
280
399
  }
281
- const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
400
+ const normalizedEntries = entries.map((entry) => ({
401
+ ...entry,
402
+ key: this.validateCacheKey(entry.key)
403
+ }));
404
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
405
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
282
406
  if (!canFastPath) {
283
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
407
+ const pendingReads = /* @__PURE__ */ new Map();
408
+ return Promise.all(
409
+ normalizedEntries.map((entry) => {
410
+ const optionsSignature = this.serializeOptions(entry.options);
411
+ const existing = pendingReads.get(entry.key);
412
+ if (!existing) {
413
+ const promise = this.get(entry.key, entry.fetch, entry.options);
414
+ pendingReads.set(entry.key, {
415
+ promise,
416
+ fetch: entry.fetch,
417
+ optionsSignature
418
+ });
419
+ return promise;
420
+ }
421
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
422
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
423
+ }
424
+ return existing.promise;
425
+ })
426
+ );
284
427
  }
285
428
  await this.startup;
286
- const pending = new Set(entries.map((_, index) => index));
287
- const results = Array(entries.length).fill(null);
288
- for (const layer of this.layers) {
289
- const indexes = [...pending];
290
- if (indexes.length === 0) {
429
+ const pending = /* @__PURE__ */ new Set();
430
+ const indexesByKey = /* @__PURE__ */ new Map();
431
+ const resultsByKey = /* @__PURE__ */ new Map();
432
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
433
+ const key = normalizedEntries[index].key;
434
+ const indexes = indexesByKey.get(key) ?? [];
435
+ indexes.push(index);
436
+ indexesByKey.set(key, indexes);
437
+ pending.add(key);
438
+ }
439
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
440
+ const layer = this.layers[layerIndex];
441
+ const keys = [...pending];
442
+ if (keys.length === 0) {
291
443
  break;
292
444
  }
293
- const keys = indexes.map((index) => entries[index].key);
294
445
  const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
295
446
  for (let offset = 0; offset < values.length; offset += 1) {
296
- const index = indexes[offset];
447
+ const key = keys[offset];
297
448
  const stored = values[offset];
298
449
  if (stored === null) {
299
450
  continue;
300
451
  }
301
452
  const resolved = resolveStoredValue(stored);
302
453
  if (resolved.state === "expired") {
303
- await layer.delete(entries[index].key);
454
+ await layer.delete(key);
304
455
  continue;
305
456
  }
306
- await this.tagIndex.touch(entries[index].key);
307
- await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
308
- results[index] = resolved.value;
309
- pending.delete(index);
310
- this.metrics.hits += 1;
457
+ await this.tagIndex.touch(key);
458
+ await this.backfill(key, stored, layerIndex - 1);
459
+ resultsByKey.set(key, resolved.value);
460
+ pending.delete(key);
461
+ this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
311
462
  }
312
463
  }
313
464
  if (pending.size > 0) {
314
- for (const index of pending) {
315
- await this.tagIndex.remove(entries[index].key);
316
- this.metrics.misses += 1;
465
+ for (const key of pending) {
466
+ await this.tagIndex.remove(key);
467
+ this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
317
468
  }
318
469
  }
319
- return results;
470
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
320
471
  }
321
472
  async mset(entries) {
322
- await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
473
+ const normalizedEntries = entries.map((entry) => ({
474
+ ...entry,
475
+ key: this.validateCacheKey(entry.key)
476
+ }));
477
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
478
+ await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
479
+ }
480
+ async warm(entries, options = {}) {
481
+ const concurrency = Math.max(1, options.concurrency ?? 4);
482
+ const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
483
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
484
+ while (queue.length > 0) {
485
+ const entry = queue.shift();
486
+ if (!entry) {
487
+ return;
488
+ }
489
+ try {
490
+ await this.get(entry.key, entry.fetcher, entry.options);
491
+ this.emit("warm", { key: entry.key });
492
+ } catch (error) {
493
+ this.emitError("warm", { key: entry.key, error: this.formatError(error) });
494
+ if (!options.continueOnError) {
495
+ throw error;
496
+ }
497
+ }
498
+ }
499
+ });
500
+ await Promise.all(workers);
501
+ }
502
+ wrap(prefix, fetcher, options = {}) {
503
+ return (...args) => {
504
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
505
+ const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
506
+ return this.get(key, () => fetcher(...args), options);
507
+ };
508
+ }
509
+ namespace(prefix) {
510
+ return new CacheNamespace(this, prefix);
323
511
  }
324
512
  async invalidateByTag(tag) {
325
513
  await this.startup;
@@ -336,13 +524,74 @@ var CacheStack = class {
336
524
  getMetrics() {
337
525
  return { ...this.metrics };
338
526
  }
527
+ getStats() {
528
+ return {
529
+ metrics: this.getMetrics(),
530
+ layers: this.layers.map((layer) => ({
531
+ name: layer.name,
532
+ isLocal: Boolean(layer.isLocal),
533
+ degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
534
+ })),
535
+ backgroundRefreshes: this.backgroundRefreshes.size
536
+ };
537
+ }
339
538
  resetMetrics() {
340
539
  Object.assign(this.metrics, EMPTY_METRICS());
341
540
  }
342
- async disconnect() {
541
+ async exportState() {
542
+ await this.startup;
543
+ const exported = /* @__PURE__ */ new Map();
544
+ for (const layer of this.layers) {
545
+ if (!layer.keys) {
546
+ continue;
547
+ }
548
+ const keys = await layer.keys();
549
+ for (const key of keys) {
550
+ if (exported.has(key)) {
551
+ continue;
552
+ }
553
+ const stored = await this.readLayerEntry(layer, key);
554
+ if (stored === null) {
555
+ continue;
556
+ }
557
+ exported.set(key, {
558
+ key,
559
+ value: stored,
560
+ ttl: remainingStoredTtlSeconds(stored)
561
+ });
562
+ }
563
+ }
564
+ return [...exported.values()];
565
+ }
566
+ async importState(entries) {
343
567
  await this.startup;
344
- await this.unsubscribeInvalidation?.();
345
- await Promise.allSettled(this.backgroundRefreshes.values());
568
+ await Promise.all(entries.map(async (entry) => {
569
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
570
+ await this.tagIndex.touch(entry.key);
571
+ }));
572
+ }
573
+ async persistToFile(filePath) {
574
+ const snapshot = await this.exportState();
575
+ await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
576
+ }
577
+ async restoreFromFile(filePath) {
578
+ const raw = await fs.readFile(filePath, "utf8");
579
+ const snapshot = JSON.parse(raw);
580
+ if (!this.isCacheSnapshotEntries(snapshot)) {
581
+ throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
582
+ }
583
+ await this.importState(snapshot);
584
+ }
585
+ async disconnect() {
586
+ if (!this.disconnectPromise) {
587
+ this.isDisconnecting = true;
588
+ this.disconnectPromise = (async () => {
589
+ await this.startup;
590
+ await this.unsubscribeInvalidation?.();
591
+ await Promise.allSettled([...this.backgroundRefreshes.values()]);
592
+ })();
593
+ }
594
+ await this.disconnectPromise;
346
595
  }
347
596
  async initialize() {
348
597
  if (!this.options.invalidationBus) {
@@ -382,6 +631,7 @@ var CacheStack = class {
382
631
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
383
632
  const deadline = Date.now() + timeoutMs;
384
633
  this.metrics.singleFlightWaits += 1;
634
+ this.emit("stampede-dedupe", { key });
385
635
  while (Date.now() < deadline) {
386
636
  const hit = await this.readFromLayers(key, options, "fresh-only");
387
637
  if (hit.found) {
@@ -393,8 +643,16 @@ var CacheStack = class {
393
643
  return this.fetchAndPopulate(key, fetcher, options);
394
644
  }
395
645
  async fetchAndPopulate(key, fetcher, options) {
646
+ this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
396
647
  this.metrics.fetches += 1;
397
- const fetched = await fetcher();
648
+ let fetched;
649
+ try {
650
+ fetched = await fetcher();
651
+ this.resetCircuitBreaker(key);
652
+ } catch (error) {
653
+ this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
654
+ throw error;
655
+ }
398
656
  if (fetched === null || fetched === void 0) {
399
657
  if (!this.shouldNegativeCache(options)) {
400
658
  return null;
@@ -413,8 +671,9 @@ var CacheStack = class {
413
671
  await this.tagIndex.touch(key);
414
672
  }
415
673
  this.metrics.sets += 1;
416
- this.logger.debug("set", { key, kind, tags: options?.tags });
417
- if (this.options.publishSetInvalidation !== false) {
674
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
675
+ this.emit("set", { key, kind, tags: options?.tags });
676
+ if (this.shouldBroadcastL1Invalidation()) {
418
677
  await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
419
678
  }
420
679
  }
@@ -424,6 +683,7 @@ var CacheStack = class {
424
683
  const layer = this.layers[index];
425
684
  const stored = await this.readLayerEntry(layer, key);
426
685
  if (stored === null) {
686
+ this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
427
687
  continue;
428
688
  }
429
689
  const resolved = resolveStoredValue(stored);
@@ -437,20 +697,34 @@ var CacheStack = class {
437
697
  }
438
698
  await this.tagIndex.touch(key);
439
699
  await this.backfill(key, stored, index - 1, options);
440
- this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
441
- return { found: true, value: resolved.value, stored, state: resolved.state };
700
+ this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
701
+ this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
702
+ this.emit("hit", { key, layer: layer.name, state: resolved.state });
703
+ return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
442
704
  }
443
705
  if (!sawRetainableValue) {
444
706
  await this.tagIndex.remove(key);
445
707
  }
446
- this.logger.debug("miss", { key, mode });
708
+ this.logger.debug?.("miss", { key, mode });
709
+ this.emit("miss", { key, mode });
447
710
  return { found: false, value: null, stored: null, state: "miss" };
448
711
  }
449
712
  async readLayerEntry(layer, key) {
713
+ if (this.shouldSkipLayer(layer)) {
714
+ return null;
715
+ }
450
716
  if (layer.getEntry) {
451
- return layer.getEntry(key);
717
+ try {
718
+ return await layer.getEntry(key);
719
+ } catch (error) {
720
+ return this.handleLayerFailure(layer, "read", error);
721
+ }
722
+ }
723
+ try {
724
+ return await layer.get(key);
725
+ } catch (error) {
726
+ return this.handleLayerFailure(layer, "read", error);
452
727
  }
453
- return layer.get(key);
454
728
  }
455
729
  async backfill(key, stored, upToIndex, options) {
456
730
  if (upToIndex < 0) {
@@ -458,16 +732,28 @@ var CacheStack = class {
458
732
  }
459
733
  for (let index = 0; index <= upToIndex; index += 1) {
460
734
  const layer = this.layers[index];
735
+ if (this.shouldSkipLayer(layer)) {
736
+ continue;
737
+ }
461
738
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
462
- await layer.set(key, stored, ttl);
739
+ try {
740
+ await layer.set(key, stored, ttl);
741
+ } catch (error) {
742
+ await this.handleLayerFailure(layer, "backfill", error);
743
+ continue;
744
+ }
463
745
  this.metrics.backfills += 1;
464
- this.logger.debug("backfill", { key, layer: layer.name });
746
+ this.logger.debug?.("backfill", { key, layer: layer.name });
747
+ this.emit("backfill", { key, layer: layer.name });
465
748
  }
466
749
  }
467
750
  async writeAcrossLayers(key, kind, value, options) {
468
751
  const now = Date.now();
469
752
  const operations = this.layers.map((layer) => async () => {
470
- const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
753
+ if (this.shouldSkipLayer(layer)) {
754
+ return;
755
+ }
756
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
471
757
  const staleWhileRevalidate = this.resolveLayerSeconds(
472
758
  layer.name,
473
759
  options?.staleWhileRevalidate,
@@ -487,7 +773,11 @@ var CacheStack = class {
487
773
  now
488
774
  });
489
775
  const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
490
- await layer.set(key, payload, ttl);
776
+ try {
777
+ await layer.set(key, payload, ttl);
778
+ } catch (error) {
779
+ await this.handleLayerFailure(layer, "write", error);
780
+ }
491
781
  });
492
782
  await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
493
783
  }
@@ -502,7 +792,7 @@ var CacheStack = class {
502
792
  return;
503
793
  }
504
794
  this.metrics.writeFailures += failures.length;
505
- this.logger.debug("write-failure", {
795
+ this.logger.debug?.("write-failure", {
506
796
  ...context,
507
797
  failures: failures.map((failure) => this.formatError(failure.reason))
508
798
  });
@@ -513,15 +803,21 @@ var CacheStack = class {
513
803
  );
514
804
  }
515
805
  }
516
- resolveFreshTtl(layerName, kind, options, fallbackTtl) {
806
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
517
807
  const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
518
808
  layerName,
519
809
  options?.negativeTtl,
520
810
  this.options.negativeTtl,
521
811
  this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
522
812
  ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
813
+ const adaptiveTtl = this.applyAdaptiveTtl(
814
+ key,
815
+ layerName,
816
+ baseTtl,
817
+ options?.adaptiveTtl ?? this.options.adaptiveTtl
818
+ );
523
819
  const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
524
- return this.applyJitter(baseTtl, jitter);
820
+ return this.applyJitter(adaptiveTtl, jitter);
525
821
  }
526
822
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
527
823
  if (override !== void 0) {
@@ -549,7 +845,7 @@ var CacheStack = class {
549
845
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
550
846
  }
551
847
  scheduleBackgroundRefresh(key, fetcher, options) {
552
- if (this.backgroundRefreshes.has(key)) {
848
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
553
849
  return;
554
850
  }
555
851
  const refresh = (async () => {
@@ -558,7 +854,7 @@ var CacheStack = class {
558
854
  await this.fetchWithGuards(key, fetcher, options);
559
855
  } catch (error) {
560
856
  this.metrics.refreshErrors += 1;
561
- this.logger.debug("refresh-error", { key, error: this.formatError(error) });
857
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
562
858
  } finally {
563
859
  this.backgroundRefreshes.delete(key);
564
860
  }
@@ -576,21 +872,15 @@ var CacheStack = class {
576
872
  if (keys.length === 0) {
577
873
  return;
578
874
  }
579
- await Promise.all(
580
- this.layers.map(async (layer) => {
581
- if (layer.deleteMany) {
582
- await layer.deleteMany(keys);
583
- return;
584
- }
585
- await Promise.all(keys.map((key) => layer.delete(key)));
586
- })
587
- );
875
+ await this.deleteKeysFromLayers(this.layers, keys);
588
876
  for (const key of keys) {
589
877
  await this.tagIndex.remove(key);
878
+ this.accessProfiles.delete(key);
590
879
  }
591
880
  this.metrics.deletes += keys.length;
592
881
  this.metrics.invalidations += 1;
593
- this.logger.debug("delete", { keys });
882
+ this.logger.debug?.("delete", { keys });
883
+ this.emit("delete", { keys });
594
884
  }
595
885
  async publishInvalidation(message) {
596
886
  if (!this.options.invalidationBus) {
@@ -609,21 +899,15 @@ var CacheStack = class {
609
899
  if (message.scope === "clear") {
610
900
  await Promise.all(localLayers.map((layer) => layer.clear()));
611
901
  await this.tagIndex.clear();
902
+ this.accessProfiles.clear();
612
903
  return;
613
904
  }
614
905
  const keys = message.keys ?? [];
615
- await Promise.all(
616
- localLayers.map(async (layer) => {
617
- if (layer.deleteMany) {
618
- await layer.deleteMany(keys);
619
- return;
620
- }
621
- await Promise.all(keys.map((key) => layer.delete(key)));
622
- })
623
- );
906
+ await this.deleteKeysFromLayers(localLayers, keys);
624
907
  if (message.operation !== "write") {
625
908
  for (const key of keys) {
626
909
  await this.tagIndex.remove(key);
910
+ this.accessProfiles.delete(key);
627
911
  }
628
912
  }
629
913
  }
@@ -636,6 +920,257 @@ var CacheStack = class {
636
920
  sleep(ms) {
637
921
  return new Promise((resolve) => setTimeout(resolve, ms));
638
922
  }
923
+ shouldBroadcastL1Invalidation() {
924
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
925
+ }
926
+ async deleteKeysFromLayers(layers, keys) {
927
+ await Promise.all(
928
+ layers.map(async (layer) => {
929
+ if (this.shouldSkipLayer(layer)) {
930
+ return;
931
+ }
932
+ if (layer.deleteMany) {
933
+ try {
934
+ await layer.deleteMany(keys);
935
+ } catch (error) {
936
+ await this.handleLayerFailure(layer, "delete", error);
937
+ }
938
+ return;
939
+ }
940
+ await Promise.all(keys.map(async (key) => {
941
+ try {
942
+ await layer.delete(key);
943
+ } catch (error) {
944
+ await this.handleLayerFailure(layer, "delete", error);
945
+ }
946
+ }));
947
+ })
948
+ );
949
+ }
950
+ validateConfiguration() {
951
+ if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
952
+ throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
953
+ }
954
+ if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
955
+ throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
956
+ }
957
+ this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
958
+ this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
959
+ this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
960
+ this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
961
+ this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
962
+ this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
963
+ this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
964
+ this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
965
+ this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
966
+ this.validateCircuitBreakerOptions(this.options.circuitBreaker);
967
+ }
968
+ validateWriteOptions(options) {
969
+ if (!options) {
970
+ return;
971
+ }
972
+ this.validateLayerNumberOption("options.ttl", options.ttl);
973
+ this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
974
+ this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
975
+ this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
976
+ this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
977
+ this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
978
+ this.validateAdaptiveTtlOptions(options.adaptiveTtl);
979
+ this.validateCircuitBreakerOptions(options.circuitBreaker);
980
+ }
981
+ validateLayerNumberOption(name, value) {
982
+ if (value === void 0) {
983
+ return;
984
+ }
985
+ if (typeof value === "number") {
986
+ this.validateNonNegativeNumber(name, value);
987
+ return;
988
+ }
989
+ for (const [layerName, layerValue] of Object.entries(value)) {
990
+ if (layerValue === void 0) {
991
+ continue;
992
+ }
993
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
994
+ }
995
+ }
996
+ validatePositiveNumber(name, value) {
997
+ if (value === void 0) {
998
+ return;
999
+ }
1000
+ if (!Number.isFinite(value) || value <= 0) {
1001
+ throw new Error(`${name} must be a positive finite number.`);
1002
+ }
1003
+ }
1004
+ validateNonNegativeNumber(name, value) {
1005
+ if (!Number.isFinite(value) || value < 0) {
1006
+ throw new Error(`${name} must be a non-negative finite number.`);
1007
+ }
1008
+ }
1009
+ validateCacheKey(key) {
1010
+ if (key.length === 0) {
1011
+ throw new Error("Cache key must not be empty.");
1012
+ }
1013
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
1014
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1015
+ }
1016
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
1017
+ throw new Error("Cache key contains unsupported control characters.");
1018
+ }
1019
+ return key;
1020
+ }
1021
+ serializeOptions(options) {
1022
+ return JSON.stringify(this.normalizeForSerialization(options) ?? null);
1023
+ }
1024
+ validateAdaptiveTtlOptions(options) {
1025
+ if (!options || options === true) {
1026
+ return;
1027
+ }
1028
+ this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1029
+ this.validateLayerNumberOption("adaptiveTtl.step", options.step);
1030
+ this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1031
+ }
1032
+ validateCircuitBreakerOptions(options) {
1033
+ if (!options) {
1034
+ return;
1035
+ }
1036
+ this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1037
+ this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1038
+ }
1039
+ async applyFreshReadPolicies(key, hit, options, fetcher) {
1040
+ const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
1041
+ const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
1042
+ if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
1043
+ const refreshed = refreshStoredEnvelope(hit.stored);
1044
+ const ttl = remainingStoredTtlSeconds(refreshed);
1045
+ for (let index = 0; index <= hit.layerIndex; index += 1) {
1046
+ const layer = this.layers[index];
1047
+ if (this.shouldSkipLayer(layer)) {
1048
+ continue;
1049
+ }
1050
+ try {
1051
+ await layer.set(key, refreshed, ttl);
1052
+ } catch (error) {
1053
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1054
+ }
1055
+ }
1056
+ }
1057
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1058
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1059
+ }
1060
+ }
1061
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
1062
+ if (!ttl || !adaptiveTtl) {
1063
+ return ttl;
1064
+ }
1065
+ const profile = this.accessProfiles.get(key);
1066
+ if (!profile) {
1067
+ return ttl;
1068
+ }
1069
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
1070
+ const hotAfter = config.hotAfter ?? 3;
1071
+ if (profile.hits < hotAfter) {
1072
+ return ttl;
1073
+ }
1074
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
1075
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
1076
+ const multiplier = Math.floor(profile.hits / hotAfter);
1077
+ return Math.min(maxTtl, ttl + step * multiplier);
1078
+ }
1079
+ recordAccess(key) {
1080
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
1081
+ profile.hits += 1;
1082
+ profile.lastAccessAt = Date.now();
1083
+ this.accessProfiles.set(key, profile);
1084
+ }
1085
+ incrementMetricMap(target, key) {
1086
+ target[key] = (target[key] ?? 0) + 1;
1087
+ }
1088
+ shouldSkipLayer(layer) {
1089
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
1090
+ return degradedUntil !== void 0 && degradedUntil > Date.now();
1091
+ }
1092
+ async handleLayerFailure(layer, operation, error) {
1093
+ if (!this.isGracefulDegradationEnabled()) {
1094
+ throw error;
1095
+ }
1096
+ const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1097
+ this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1098
+ this.metrics.degradedOperations += 1;
1099
+ this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1100
+ this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1101
+ return null;
1102
+ }
1103
+ isGracefulDegradationEnabled() {
1104
+ return Boolean(this.options.gracefulDegradation);
1105
+ }
1106
+ assertCircuitClosed(key, options) {
1107
+ const state = this.circuitBreakers.get(key);
1108
+ if (!state?.openUntil) {
1109
+ return;
1110
+ }
1111
+ if (state.openUntil <= Date.now()) {
1112
+ state.openUntil = null;
1113
+ state.failures = 0;
1114
+ this.circuitBreakers.set(key, state);
1115
+ return;
1116
+ }
1117
+ this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
1118
+ throw new Error(`Circuit breaker is open for key "${key}".`);
1119
+ }
1120
+ recordCircuitFailure(key, options, error) {
1121
+ if (!options) {
1122
+ return;
1123
+ }
1124
+ const failureThreshold = options.failureThreshold ?? 3;
1125
+ const cooldownMs = options.cooldownMs ?? 3e4;
1126
+ const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
1127
+ state.failures += 1;
1128
+ if (state.failures >= failureThreshold) {
1129
+ state.openUntil = Date.now() + cooldownMs;
1130
+ this.metrics.circuitBreakerTrips += 1;
1131
+ }
1132
+ this.circuitBreakers.set(key, state);
1133
+ this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
1134
+ }
1135
+ resetCircuitBreaker(key) {
1136
+ this.circuitBreakers.delete(key);
1137
+ }
1138
+ isNegativeStoredValue(stored) {
1139
+ return isStoredValueEnvelope(stored) && stored.kind === "empty";
1140
+ }
1141
+ emitError(operation, context) {
1142
+ this.logger.error?.(operation, context);
1143
+ if (this.listenerCount("error") > 0) {
1144
+ this.emit("error", { operation, ...context });
1145
+ }
1146
+ }
1147
+ serializeKeyPart(value) {
1148
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1149
+ return String(value);
1150
+ }
1151
+ return JSON.stringify(this.normalizeForSerialization(value));
1152
+ }
1153
+ isCacheSnapshotEntries(value) {
1154
+ return Array.isArray(value) && value.every((entry) => {
1155
+ if (!entry || typeof entry !== "object") {
1156
+ return false;
1157
+ }
1158
+ const candidate = entry;
1159
+ return typeof candidate.key === "string";
1160
+ });
1161
+ }
1162
+ normalizeForSerialization(value) {
1163
+ if (Array.isArray(value)) {
1164
+ return value.map((entry) => this.normalizeForSerialization(entry));
1165
+ }
1166
+ if (value && typeof value === "object") {
1167
+ return Object.keys(value).sort().reduce((normalized, key) => {
1168
+ normalized[key] = this.normalizeForSerialization(value[key]);
1169
+ return normalized;
1170
+ }, {});
1171
+ }
1172
+ return value;
1173
+ }
639
1174
  };
640
1175
 
641
1176
  // src/invalidation/RedisInvalidationBus.ts
@@ -643,19 +1178,27 @@ var RedisInvalidationBus = class {
643
1178
  channel;
644
1179
  publisher;
645
1180
  subscriber;
1181
+ activeListener;
646
1182
  constructor(options) {
647
1183
  this.publisher = options.publisher;
648
1184
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
649
1185
  this.channel = options.channel ?? "layercache:invalidation";
650
1186
  }
651
1187
  async subscribe(handler) {
652
- const listener = async (_channel, payload) => {
653
- const message = JSON.parse(payload);
654
- await handler(message);
1188
+ if (this.activeListener) {
1189
+ throw new Error("RedisInvalidationBus already has an active subscription.");
1190
+ }
1191
+ const listener = (_channel, payload) => {
1192
+ void this.handleMessage(payload, handler);
655
1193
  };
1194
+ this.activeListener = listener;
656
1195
  this.subscriber.on("message", listener);
657
1196
  await this.subscriber.subscribe(this.channel);
658
1197
  return async () => {
1198
+ if (this.activeListener !== listener) {
1199
+ return;
1200
+ }
1201
+ this.activeListener = void 0;
659
1202
  this.subscriber.off("message", listener);
660
1203
  await this.subscriber.unsubscribe(this.channel);
661
1204
  };
@@ -663,98 +1206,117 @@ var RedisInvalidationBus = class {
663
1206
  async publish(message) {
664
1207
  await this.publisher.publish(this.channel, JSON.stringify(message));
665
1208
  }
666
- };
667
-
668
- // src/invalidation/RedisTagIndex.ts
669
- var RedisTagIndex = class {
670
- client;
671
- prefix;
672
- scanCount;
673
- constructor(options) {
674
- this.client = options.client;
675
- this.prefix = options.prefix ?? "layercache:tag-index";
676
- this.scanCount = options.scanCount ?? 100;
677
- }
678
- async touch(key) {
679
- await this.client.sadd(this.knownKeysKey(), key);
680
- }
681
- async track(key, tags) {
682
- const keyTagsKey = this.keyTagsKey(key);
683
- const existingTags = await this.client.smembers(keyTagsKey);
684
- const pipeline = this.client.pipeline();
685
- pipeline.sadd(this.knownKeysKey(), key);
686
- for (const tag of existingTags) {
687
- pipeline.srem(this.tagKeysKey(tag), key);
688
- }
689
- pipeline.del(keyTagsKey);
690
- if (tags.length > 0) {
691
- pipeline.sadd(keyTagsKey, ...tags);
692
- for (const tag of new Set(tags)) {
693
- pipeline.sadd(this.tagKeysKey(tag), key);
1209
+ async handleMessage(payload, handler) {
1210
+ let message;
1211
+ try {
1212
+ const parsed = JSON.parse(payload);
1213
+ if (!this.isInvalidationMessage(parsed)) {
1214
+ throw new Error("Invalid invalidation payload shape.");
694
1215
  }
1216
+ message = parsed;
1217
+ } catch (error) {
1218
+ this.reportError("invalid invalidation payload", error);
1219
+ return;
695
1220
  }
696
- await pipeline.exec();
697
- }
698
- async remove(key) {
699
- const keyTagsKey = this.keyTagsKey(key);
700
- const existingTags = await this.client.smembers(keyTagsKey);
701
- const pipeline = this.client.pipeline();
702
- pipeline.srem(this.knownKeysKey(), key);
703
- pipeline.del(keyTagsKey);
704
- for (const tag of existingTags) {
705
- pipeline.srem(this.tagKeysKey(tag), key);
1221
+ try {
1222
+ await handler(message);
1223
+ } catch (error) {
1224
+ this.reportError("invalidation handler failed", error);
706
1225
  }
707
- await pipeline.exec();
708
- }
709
- async keysForTag(tag) {
710
- return this.client.smembers(this.tagKeysKey(tag));
711
- }
712
- async matchPattern(pattern) {
713
- const matches = [];
714
- let cursor = "0";
715
- do {
716
- const [nextCursor, keys] = await this.client.sscan(
717
- this.knownKeysKey(),
718
- cursor,
719
- "MATCH",
720
- pattern,
721
- "COUNT",
722
- this.scanCount
723
- );
724
- cursor = nextCursor;
725
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
726
- } while (cursor !== "0");
727
- return matches;
728
1226
  }
729
- async clear() {
730
- const indexKeys = await this.scanIndexKeys();
731
- if (indexKeys.length === 0) {
732
- return;
1227
+ isInvalidationMessage(value) {
1228
+ if (!value || typeof value !== "object") {
1229
+ return false;
733
1230
  }
734
- await this.client.del(...indexKeys);
735
- }
736
- async scanIndexKeys() {
737
- const matches = [];
738
- let cursor = "0";
739
- const pattern = `${this.prefix}:*`;
740
- do {
741
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
742
- cursor = nextCursor;
743
- matches.push(...keys);
744
- } while (cursor !== "0");
745
- return matches;
746
- }
747
- knownKeysKey() {
748
- return `${this.prefix}:keys`;
749
- }
750
- keyTagsKey(key) {
751
- return `${this.prefix}:key:${encodeURIComponent(key)}`;
1231
+ const candidate = value;
1232
+ const validScope = candidate.scope === "key" || candidate.scope === "keys" || candidate.scope === "clear";
1233
+ const validOperation = candidate.operation === void 0 || candidate.operation === "write" || candidate.operation === "delete" || candidate.operation === "invalidate" || candidate.operation === "clear";
1234
+ const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
1235
+ return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
752
1236
  }
753
- tagKeysKey(tag) {
754
- return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
1237
+ reportError(message, error) {
1238
+ console.error(`[layercache] ${message}`, error);
755
1239
  }
756
1240
  };
757
1241
 
1242
+ // src/http/createCacheStatsHandler.ts
1243
+ function createCacheStatsHandler(cache) {
1244
+ return async (_request, response) => {
1245
+ response.statusCode = 200;
1246
+ response.setHeader?.("content-type", "application/json; charset=utf-8");
1247
+ response.end(JSON.stringify(cache.getStats(), null, 2));
1248
+ };
1249
+ }
1250
+
1251
+ // src/decorators/createCachedMethodDecorator.ts
1252
+ function createCachedMethodDecorator(options) {
1253
+ const wrappedByInstance = /* @__PURE__ */ new WeakMap();
1254
+ return ((_, propertyKey, descriptor) => {
1255
+ const original = descriptor.value;
1256
+ if (typeof original !== "function") {
1257
+ throw new Error("createCachedMethodDecorator can only be applied to methods.");
1258
+ }
1259
+ descriptor.value = async function(...args) {
1260
+ const instance = this;
1261
+ let wrapped = wrappedByInstance.get(instance);
1262
+ if (!wrapped) {
1263
+ const cache = options.cache(instance);
1264
+ wrapped = cache.wrap(
1265
+ options.prefix ?? String(propertyKey),
1266
+ (...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
1267
+ options
1268
+ );
1269
+ wrappedByInstance.set(instance, wrapped);
1270
+ }
1271
+ return wrapped(...args);
1272
+ };
1273
+ });
1274
+ }
1275
+
1276
+ // src/integrations/fastify.ts
1277
+ function createFastifyLayercachePlugin(cache, options = {}) {
1278
+ return async (fastify) => {
1279
+ fastify.decorate("cache", cache);
1280
+ if (options.exposeStatsRoute !== false && fastify.get) {
1281
+ fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
1282
+ }
1283
+ };
1284
+ }
1285
+
1286
+ // src/integrations/graphql.ts
1287
+ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
1288
+ const wrapped = cache.wrap(prefix, resolver, {
1289
+ ...options,
1290
+ keyResolver: options.keyResolver
1291
+ });
1292
+ return (...args) => wrapped(...args);
1293
+ }
1294
+
1295
+ // src/integrations/trpc.ts
1296
+ function createTrpcCacheMiddleware(cache, prefix, options = {}) {
1297
+ return async (context) => {
1298
+ const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
1299
+ let didFetch = false;
1300
+ let fetchedResult = null;
1301
+ const cached = await cache.get(
1302
+ key,
1303
+ async () => {
1304
+ didFetch = true;
1305
+ fetchedResult = await context.next();
1306
+ return fetchedResult;
1307
+ },
1308
+ options
1309
+ );
1310
+ if (cached !== null) {
1311
+ return cached;
1312
+ }
1313
+ if (didFetch) {
1314
+ return fetchedResult;
1315
+ }
1316
+ return context.next();
1317
+ };
1318
+ }
1319
+
758
1320
  // src/layers/MemoryLayer.ts
759
1321
  var MemoryLayer = class {
760
1322
  name;
@@ -820,6 +1382,32 @@ var MemoryLayer = class {
820
1382
  this.pruneExpired();
821
1383
  return [...this.entries.keys()];
822
1384
  }
1385
+ exportState() {
1386
+ this.pruneExpired();
1387
+ return [...this.entries.entries()].map(([key, entry]) => ({
1388
+ key,
1389
+ value: entry.value,
1390
+ expiresAt: entry.expiresAt
1391
+ }));
1392
+ }
1393
+ importState(entries) {
1394
+ for (const entry of entries) {
1395
+ if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
1396
+ continue;
1397
+ }
1398
+ this.entries.set(entry.key, {
1399
+ value: entry.value,
1400
+ expiresAt: entry.expiresAt
1401
+ });
1402
+ }
1403
+ while (this.entries.size > this.maxSize) {
1404
+ const oldestKey = this.entries.keys().next().value;
1405
+ if (!oldestKey) {
1406
+ break;
1407
+ }
1408
+ this.entries.delete(oldestKey);
1409
+ }
1410
+ }
823
1411
  pruneExpired() {
824
1412
  for (const [key, entry] of this.entries.entries()) {
825
1413
  if (this.isExpired(entry)) {
@@ -832,6 +1420,9 @@ var MemoryLayer = class {
832
1420
  }
833
1421
  };
834
1422
 
1423
+ // src/layers/RedisLayer.ts
1424
+ import { brotliCompressSync, brotliDecompressSync, gzipSync, gunzipSync } from "zlib";
1425
+
835
1426
  // src/serialization/JsonSerializer.ts
836
1427
  var JsonSerializer = class {
837
1428
  serialize(value) {
@@ -853,6 +1444,8 @@ var RedisLayer = class {
853
1444
  prefix;
854
1445
  allowUnprefixedClear;
855
1446
  scanCount;
1447
+ compression;
1448
+ compressionThreshold;
856
1449
  constructor(options) {
857
1450
  this.client = options.client;
858
1451
  this.defaultTtl = options.ttl;
@@ -861,6 +1454,8 @@ var RedisLayer = class {
861
1454
  this.prefix = options.prefix ?? "";
862
1455
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
863
1456
  this.scanCount = options.scanCount ?? 100;
1457
+ this.compression = options.compression;
1458
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
864
1459
  }
865
1460
  async get(key) {
866
1461
  const payload = await this.getEntry(key);
@@ -871,7 +1466,7 @@ var RedisLayer = class {
871
1466
  if (payload === null) {
872
1467
  return null;
873
1468
  }
874
- return this.serializer.deserialize(payload);
1469
+ return this.deserializeOrDelete(key, payload);
875
1470
  }
876
1471
  async getMany(keys) {
877
1472
  if (keys.length === 0) {
@@ -885,16 +1480,18 @@ var RedisLayer = class {
885
1480
  if (results === null) {
886
1481
  return keys.map(() => null);
887
1482
  }
888
- return results.map((result) => {
889
- const [, payload] = result;
890
- if (payload === null) {
891
- return null;
892
- }
893
- return this.serializer.deserialize(payload);
894
- });
1483
+ return Promise.all(
1484
+ results.map(async (result, index) => {
1485
+ const [error, payload] = result;
1486
+ if (error || payload === null || !this.isSerializablePayload(payload)) {
1487
+ return null;
1488
+ }
1489
+ return this.deserializeOrDelete(keys[index], payload);
1490
+ })
1491
+ );
895
1492
  }
896
1493
  async set(key, value, ttl = this.defaultTtl) {
897
- const payload = this.serializer.serialize(value);
1494
+ const payload = this.encodePayload(this.serializer.serialize(value));
898
1495
  const normalizedKey = this.withPrefix(key);
899
1496
  if (ttl && ttl > 0) {
900
1497
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -941,6 +1538,41 @@ var RedisLayer = class {
941
1538
  withPrefix(key) {
942
1539
  return `${this.prefix}${key}`;
943
1540
  }
1541
+ async deserializeOrDelete(key, payload) {
1542
+ try {
1543
+ return this.serializer.deserialize(this.decodePayload(payload));
1544
+ } catch {
1545
+ await this.client.del(this.withPrefix(key)).catch(() => void 0);
1546
+ return null;
1547
+ }
1548
+ }
1549
+ isSerializablePayload(payload) {
1550
+ return typeof payload === "string" || Buffer.isBuffer(payload);
1551
+ }
1552
+ encodePayload(payload) {
1553
+ if (!this.compression) {
1554
+ return payload;
1555
+ }
1556
+ const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
1557
+ if (source.byteLength < this.compressionThreshold) {
1558
+ return payload;
1559
+ }
1560
+ const header = Buffer.from(`LCZ1:${this.compression}:`);
1561
+ const compressed = this.compression === "gzip" ? gzipSync(source) : brotliCompressSync(source);
1562
+ return Buffer.concat([header, compressed]);
1563
+ }
1564
+ decodePayload(payload) {
1565
+ if (!Buffer.isBuffer(payload)) {
1566
+ return payload;
1567
+ }
1568
+ if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
1569
+ return gunzipSync(payload.subarray(10));
1570
+ }
1571
+ if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
1572
+ return brotliDecompressSync(payload.subarray(12));
1573
+ }
1574
+ return payload;
1575
+ }
944
1576
  };
945
1577
 
946
1578
  // src/serialization/MsgpackSerializer.ts
@@ -985,6 +1617,7 @@ var RedisSingleFlightCoordinator = class {
985
1617
  }
986
1618
  };
987
1619
  export {
1620
+ CacheNamespace,
988
1621
  CacheStack,
989
1622
  JsonSerializer,
990
1623
  MemoryLayer,
@@ -995,5 +1628,10 @@ export {
995
1628
  RedisSingleFlightCoordinator,
996
1629
  RedisTagIndex,
997
1630
  StampedeGuard,
998
- TagIndex
1631
+ TagIndex,
1632
+ cacheGraphqlResolver,
1633
+ createCacheStatsHandler,
1634
+ createCachedMethodDecorator,
1635
+ createFastifyLayercachePlugin,
1636
+ createTrpcCacheMiddleware
999
1637
  };