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.js CHANGED
@@ -1,12 +1,166 @@
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";
10
+
11
+ // src/internal/StoredValue.ts
12
+ function isStoredValueEnvelope(value) {
13
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
14
+ }
15
+ function createStoredValueEnvelope(options) {
16
+ const now = options.now ?? Date.now();
17
+ const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
18
+ const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
19
+ const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
20
+ const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
21
+ const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
22
+ const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
23
+ return {
24
+ __layercache: 1,
25
+ kind: options.kind,
26
+ value: options.value,
27
+ freshUntil,
28
+ staleUntil,
29
+ errorUntil,
30
+ freshTtlSeconds: freshTtlSeconds ?? null,
31
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
32
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
33
+ };
34
+ }
35
+ function resolveStoredValue(stored, now = Date.now()) {
36
+ if (!isStoredValueEnvelope(stored)) {
37
+ return { state: "fresh", value: stored, stored };
38
+ }
39
+ if (stored.freshUntil === null || stored.freshUntil > now) {
40
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
41
+ }
42
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
43
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
44
+ }
45
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
46
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
47
+ }
48
+ return { state: "expired", value: null, stored, envelope: stored };
49
+ }
50
+ function unwrapStoredValue(stored) {
51
+ if (!isStoredValueEnvelope(stored)) {
52
+ return stored;
53
+ }
54
+ if (stored.kind === "empty") {
55
+ return null;
56
+ }
57
+ return stored.value ?? null;
58
+ }
59
+ function remainingStoredTtlSeconds(stored, now = Date.now()) {
60
+ if (!isStoredValueEnvelope(stored)) {
61
+ return void 0;
62
+ }
63
+ const expiry = maxExpiry(stored);
64
+ if (expiry === null) {
65
+ return void 0;
66
+ }
67
+ const remainingMs = expiry - now;
68
+ if (remainingMs <= 0) {
69
+ return 1;
70
+ }
71
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
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
+ }
96
+ function maxExpiry(stored) {
97
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
98
+ (value) => value !== null
99
+ );
100
+ if (values.length === 0) {
101
+ return null;
102
+ }
103
+ return Math.max(...values);
104
+ }
105
+ function normalizePositiveSeconds(value) {
106
+ if (!value || value <= 0) {
107
+ return void 0;
108
+ }
109
+ return value;
110
+ }
3
111
 
4
- // src/invalidation/PatternMatcher.ts
5
- var PatternMatcher = class {
6
- static matches(pattern, value) {
7
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
8
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
9
- 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}`;
10
164
  }
11
165
  };
12
166
 
@@ -73,26 +227,33 @@ import { Mutex } from "async-mutex";
73
227
  var StampedeGuard = class {
74
228
  mutexes = /* @__PURE__ */ new Map();
75
229
  async execute(key, task) {
76
- const mutex = this.getMutex(key);
230
+ const entry = this.getMutexEntry(key);
77
231
  try {
78
- return await mutex.runExclusive(task);
232
+ return await entry.mutex.runExclusive(task);
79
233
  } finally {
80
- if (!mutex.isLocked()) {
234
+ entry.references -= 1;
235
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
81
236
  this.mutexes.delete(key);
82
237
  }
83
238
  }
84
239
  }
85
- getMutex(key) {
86
- let mutex = this.mutexes.get(key);
87
- if (!mutex) {
88
- mutex = new Mutex();
89
- 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);
90
245
  }
91
- return mutex;
246
+ entry.references += 1;
247
+ return entry;
92
248
  }
93
249
  };
94
250
 
95
251
  // src/CacheStack.ts
252
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
253
+ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
254
+ var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
255
+ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
256
+ var MAX_CACHE_KEY_LENGTH = 1024;
96
257
  var EMPTY_METRICS = () => ({
97
258
  hits: 0,
98
259
  misses: 0,
@@ -100,7 +261,17 @@ var EMPTY_METRICS = () => ({
100
261
  sets: 0,
101
262
  deletes: 0,
102
263
  backfills: 0,
103
- invalidations: 0
264
+ invalidations: 0,
265
+ staleHits: 0,
266
+ refreshes: 0,
267
+ refreshErrors: 0,
268
+ writeFailures: 0,
269
+ singleFlightWaits: 0,
270
+ negativeCacheHits: 0,
271
+ circuitBreakerTrips: 0,
272
+ degradedOperations: 0,
273
+ hitsByLayer: {},
274
+ missesByLayer: {}
104
275
  });
105
276
  var DebugLogger = class {
106
277
  enabled;
@@ -108,21 +279,35 @@ var DebugLogger = class {
108
279
  this.enabled = enabled;
109
280
  }
110
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) {
111
294
  if (!this.enabled) {
112
295
  return;
113
296
  }
114
297
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
115
- console.debug(`[cachestack] ${message}${suffix}`);
298
+ console[level](`[layercache] ${message}${suffix}`);
116
299
  }
117
300
  };
118
- var CacheStack = class {
301
+ var CacheStack = class extends EventEmitter {
119
302
  constructor(layers, options = {}) {
303
+ super();
120
304
  this.layers = layers;
121
305
  this.options = options;
122
306
  if (layers.length === 0) {
123
307
  throw new Error("CacheStack requires at least one cache layer.");
124
308
  }
125
- const debugEnv = process.env.DEBUG?.split(",").includes("cachestack:debug") ?? false;
309
+ this.validateConfiguration();
310
+ const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
126
311
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
127
312
  this.tagIndex = options.tagIndex ?? new TagIndex();
128
313
  this.startup = this.initialize();
@@ -136,68 +321,193 @@ var CacheStack = class {
136
321
  unsubscribeInvalidation;
137
322
  logger;
138
323
  tagIndex;
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;
139
330
  async get(key, fetcher, options) {
331
+ const normalizedKey = this.validateCacheKey(key);
332
+ this.validateWriteOptions(options);
140
333
  await this.startup;
141
- const hit = await this.getFromLayers(key, options);
334
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
142
335
  if (hit.found) {
143
- this.metrics.hits += 1;
144
- return hit.value;
336
+ this.recordAccess(normalizedKey);
337
+ if (this.isNegativeStoredValue(hit.stored)) {
338
+ this.metrics.negativeCacheHits += 1;
339
+ }
340
+ if (hit.state === "fresh") {
341
+ this.metrics.hits += 1;
342
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
343
+ return hit.value;
344
+ }
345
+ if (hit.state === "stale-while-revalidate") {
346
+ this.metrics.hits += 1;
347
+ this.metrics.staleHits += 1;
348
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
349
+ if (fetcher) {
350
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
351
+ }
352
+ return hit.value;
353
+ }
354
+ if (!fetcher) {
355
+ this.metrics.hits += 1;
356
+ this.metrics.staleHits += 1;
357
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
358
+ return hit.value;
359
+ }
360
+ try {
361
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
362
+ } catch (error) {
363
+ this.metrics.staleHits += 1;
364
+ this.metrics.refreshErrors += 1;
365
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
366
+ return hit.value;
367
+ }
145
368
  }
146
369
  this.metrics.misses += 1;
147
370
  if (!fetcher) {
148
371
  return null;
149
372
  }
150
- const runFetch = async () => {
151
- const secondHit = await this.getFromLayers(key, options);
152
- if (secondHit.found) {
153
- this.metrics.hits += 1;
154
- return secondHit.value;
155
- }
156
- this.metrics.fetches += 1;
157
- const fetched = await fetcher();
158
- if (fetched === null || fetched === void 0) {
159
- return null;
160
- }
161
- await this.set(key, fetched, options);
162
- return fetched;
163
- };
164
- if (this.options.stampedePrevention === false) {
165
- return runFetch();
166
- }
167
- return this.stampedeGuard.execute(key, runFetch);
373
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
168
374
  }
169
375
  async set(key, value, options) {
376
+ const normalizedKey = this.validateCacheKey(key);
377
+ this.validateWriteOptions(options);
170
378
  await this.startup;
171
- await this.setAcrossLayers(key, value, options);
172
- if (options?.tags) {
173
- await this.tagIndex.track(key, options.tags);
174
- } else {
175
- await this.tagIndex.touch(key);
176
- }
177
- this.metrics.sets += 1;
178
- this.logger.debug("set", { key, tags: options?.tags });
179
- if (this.options.publishSetInvalidation !== false) {
180
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
181
- }
379
+ await this.storeEntry(normalizedKey, "value", value, options);
182
380
  }
183
381
  async delete(key) {
382
+ const normalizedKey = this.validateCacheKey(key);
184
383
  await this.startup;
185
- await this.deleteKeys([key]);
186
- 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" });
187
386
  }
188
387
  async clear() {
189
388
  await this.startup;
190
389
  await Promise.all(this.layers.map((layer) => layer.clear()));
191
390
  await this.tagIndex.clear();
391
+ this.accessProfiles.clear();
192
392
  this.metrics.invalidations += 1;
193
- this.logger.debug("clear");
393
+ this.logger.debug?.("clear");
194
394
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
195
395
  }
196
396
  async mget(entries) {
197
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
397
+ if (entries.length === 0) {
398
+ return [];
399
+ }
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);
406
+ if (!canFastPath) {
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
+ );
427
+ }
428
+ await this.startup;
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) {
443
+ break;
444
+ }
445
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
446
+ for (let offset = 0; offset < values.length; offset += 1) {
447
+ const key = keys[offset];
448
+ const stored = values[offset];
449
+ if (stored === null) {
450
+ continue;
451
+ }
452
+ const resolved = resolveStoredValue(stored);
453
+ if (resolved.state === "expired") {
454
+ await layer.delete(key);
455
+ continue;
456
+ }
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;
462
+ }
463
+ }
464
+ if (pending.size > 0) {
465
+ for (const key of pending) {
466
+ await this.tagIndex.remove(key);
467
+ this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
468
+ }
469
+ }
470
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
198
471
  }
199
472
  async mset(entries) {
200
- 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);
201
511
  }
202
512
  async invalidateByTag(tag) {
203
513
  await this.startup;
@@ -214,12 +524,74 @@ var CacheStack = class {
214
524
  getMetrics() {
215
525
  return { ...this.metrics };
216
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
+ }
217
538
  resetMetrics() {
218
539
  Object.assign(this.metrics, EMPTY_METRICS());
219
540
  }
220
- async disconnect() {
541
+ async exportState() {
221
542
  await this.startup;
222
- await this.unsubscribeInvalidation?.();
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) {
567
+ await this.startup;
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;
223
595
  }
224
596
  async initialize() {
225
597
  if (!this.options.invalidationBus) {
@@ -229,66 +601,286 @@ var CacheStack = class {
229
601
  await this.handleInvalidationMessage(message);
230
602
  });
231
603
  }
232
- async getFromLayers(key, options) {
604
+ async fetchWithGuards(key, fetcher, options) {
605
+ const fetchTask = async () => {
606
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
607
+ if (secondHit.found) {
608
+ this.metrics.hits += 1;
609
+ return secondHit.value;
610
+ }
611
+ return this.fetchAndPopulate(key, fetcher, options);
612
+ };
613
+ const singleFlightTask = async () => {
614
+ if (!this.options.singleFlightCoordinator) {
615
+ return fetchTask();
616
+ }
617
+ return this.options.singleFlightCoordinator.execute(
618
+ key,
619
+ this.resolveSingleFlightOptions(),
620
+ fetchTask,
621
+ () => this.waitForFreshValue(key, fetcher, options)
622
+ );
623
+ };
624
+ if (this.options.stampedePrevention === false) {
625
+ return singleFlightTask();
626
+ }
627
+ return this.stampedeGuard.execute(key, singleFlightTask);
628
+ }
629
+ async waitForFreshValue(key, fetcher, options) {
630
+ const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
631
+ const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
632
+ const deadline = Date.now() + timeoutMs;
633
+ this.metrics.singleFlightWaits += 1;
634
+ this.emit("stampede-dedupe", { key });
635
+ while (Date.now() < deadline) {
636
+ const hit = await this.readFromLayers(key, options, "fresh-only");
637
+ if (hit.found) {
638
+ this.metrics.hits += 1;
639
+ return hit.value;
640
+ }
641
+ await this.sleep(pollIntervalMs);
642
+ }
643
+ return this.fetchAndPopulate(key, fetcher, options);
644
+ }
645
+ async fetchAndPopulate(key, fetcher, options) {
646
+ this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
647
+ this.metrics.fetches += 1;
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
+ }
656
+ if (fetched === null || fetched === void 0) {
657
+ if (!this.shouldNegativeCache(options)) {
658
+ return null;
659
+ }
660
+ await this.storeEntry(key, "empty", null, options);
661
+ return null;
662
+ }
663
+ await this.storeEntry(key, "value", fetched, options);
664
+ return fetched;
665
+ }
666
+ async storeEntry(key, kind, value, options) {
667
+ await this.writeAcrossLayers(key, kind, value, options);
668
+ if (options?.tags) {
669
+ await this.tagIndex.track(key, options.tags);
670
+ } else {
671
+ await this.tagIndex.touch(key);
672
+ }
673
+ this.metrics.sets += 1;
674
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
675
+ this.emit("set", { key, kind, tags: options?.tags });
676
+ if (this.shouldBroadcastL1Invalidation()) {
677
+ await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
678
+ }
679
+ }
680
+ async readFromLayers(key, options, mode) {
681
+ let sawRetainableValue = false;
233
682
  for (let index = 0; index < this.layers.length; index += 1) {
234
683
  const layer = this.layers[index];
235
- const value = await layer.get(key);
236
- if (value === null) {
684
+ const stored = await this.readLayerEntry(layer, key);
685
+ if (stored === null) {
686
+ this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
687
+ continue;
688
+ }
689
+ const resolved = resolveStoredValue(stored);
690
+ if (resolved.state === "expired") {
691
+ await layer.delete(key);
692
+ continue;
693
+ }
694
+ sawRetainableValue = true;
695
+ if (mode === "fresh-only" && resolved.state !== "fresh") {
237
696
  continue;
238
697
  }
239
698
  await this.tagIndex.touch(key);
240
- await this.backfill(key, value, index - 1, options);
241
- this.logger.debug("hit", { key, layer: layer.name });
242
- return { found: true, value };
699
+ await this.backfill(key, stored, index - 1, options);
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 };
704
+ }
705
+ if (!sawRetainableValue) {
706
+ await this.tagIndex.remove(key);
707
+ }
708
+ this.logger.debug?.("miss", { key, mode });
709
+ this.emit("miss", { key, mode });
710
+ return { found: false, value: null, stored: null, state: "miss" };
711
+ }
712
+ async readLayerEntry(layer, key) {
713
+ if (this.shouldSkipLayer(layer)) {
714
+ return null;
715
+ }
716
+ if (layer.getEntry) {
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);
243
727
  }
244
- await this.tagIndex.remove(key);
245
- this.logger.debug("miss", { key });
246
- return { found: false, value: null };
247
728
  }
248
- async backfill(key, value, upToIndex, options) {
729
+ async backfill(key, stored, upToIndex, options) {
249
730
  if (upToIndex < 0) {
250
731
  return;
251
732
  }
252
733
  for (let index = 0; index <= upToIndex; index += 1) {
253
734
  const layer = this.layers[index];
254
- await layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl));
735
+ if (this.shouldSkipLayer(layer)) {
736
+ continue;
737
+ }
738
+ const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
739
+ try {
740
+ await layer.set(key, stored, ttl);
741
+ } catch (error) {
742
+ await this.handleLayerFailure(layer, "backfill", error);
743
+ continue;
744
+ }
255
745
  this.metrics.backfills += 1;
256
- 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 });
257
748
  }
258
749
  }
259
- async setAcrossLayers(key, value, options) {
260
- await Promise.all(
261
- this.layers.map((layer) => layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl)))
750
+ async writeAcrossLayers(key, kind, value, options) {
751
+ const now = Date.now();
752
+ const operations = this.layers.map((layer) => async () => {
753
+ if (this.shouldSkipLayer(layer)) {
754
+ return;
755
+ }
756
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
757
+ const staleWhileRevalidate = this.resolveLayerSeconds(
758
+ layer.name,
759
+ options?.staleWhileRevalidate,
760
+ this.options.staleWhileRevalidate
761
+ );
762
+ const staleIfError = this.resolveLayerSeconds(
763
+ layer.name,
764
+ options?.staleIfError,
765
+ this.options.staleIfError
766
+ );
767
+ const payload = createStoredValueEnvelope({
768
+ kind,
769
+ value,
770
+ freshTtlSeconds: freshTtl,
771
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
772
+ staleIfErrorSeconds: staleIfError,
773
+ now
774
+ });
775
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
776
+ try {
777
+ await layer.set(key, payload, ttl);
778
+ } catch (error) {
779
+ await this.handleLayerFailure(layer, "write", error);
780
+ }
781
+ });
782
+ await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
783
+ }
784
+ async executeLayerOperations(operations, context) {
785
+ if (this.options.writePolicy !== "best-effort") {
786
+ await Promise.all(operations.map((operation) => operation()));
787
+ return;
788
+ }
789
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
790
+ const failures = results.filter((result) => result.status === "rejected");
791
+ if (failures.length === 0) {
792
+ return;
793
+ }
794
+ this.metrics.writeFailures += failures.length;
795
+ this.logger.debug?.("write-failure", {
796
+ ...context,
797
+ failures: failures.map((failure) => this.formatError(failure.reason))
798
+ });
799
+ if (failures.length === operations.length) {
800
+ throw new AggregateError(
801
+ failures.map((failure) => failure.reason),
802
+ `${context.action} failed for every cache layer`
803
+ );
804
+ }
805
+ }
806
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
807
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
808
+ layerName,
809
+ options?.negativeTtl,
810
+ this.options.negativeTtl,
811
+ this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
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
262
818
  );
819
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
820
+ return this.applyJitter(adaptiveTtl, jitter);
821
+ }
822
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
823
+ if (override !== void 0) {
824
+ return this.readLayerNumber(layerName, override) ?? fallback;
825
+ }
826
+ if (globalDefault !== void 0) {
827
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
828
+ }
829
+ return fallback;
263
830
  }
264
- resolveTtl(layerName, fallbackTtl, ttlOverride) {
265
- if (ttlOverride === void 0) {
266
- return fallbackTtl;
831
+ readLayerNumber(layerName, value) {
832
+ if (typeof value === "number") {
833
+ return value;
267
834
  }
268
- if (typeof ttlOverride === "number") {
269
- return ttlOverride;
835
+ return value[layerName];
836
+ }
837
+ applyJitter(ttl, jitter) {
838
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
839
+ return ttl;
270
840
  }
271
- return ttlOverride[layerName] ?? fallbackTtl;
841
+ const delta = (Math.random() * 2 - 1) * jitter;
842
+ return Math.max(1, Math.round(ttl + delta));
843
+ }
844
+ shouldNegativeCache(options) {
845
+ return options?.negativeCache ?? this.options.negativeCaching ?? false;
846
+ }
847
+ scheduleBackgroundRefresh(key, fetcher, options) {
848
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
849
+ return;
850
+ }
851
+ const refresh = (async () => {
852
+ this.metrics.refreshes += 1;
853
+ try {
854
+ await this.fetchWithGuards(key, fetcher, options);
855
+ } catch (error) {
856
+ this.metrics.refreshErrors += 1;
857
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
858
+ } finally {
859
+ this.backgroundRefreshes.delete(key);
860
+ }
861
+ })();
862
+ this.backgroundRefreshes.set(key, refresh);
863
+ }
864
+ resolveSingleFlightOptions() {
865
+ return {
866
+ leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
867
+ waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
868
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
869
+ };
272
870
  }
273
871
  async deleteKeys(keys) {
274
872
  if (keys.length === 0) {
275
873
  return;
276
874
  }
277
- await Promise.all(
278
- this.layers.map(async (layer) => {
279
- if (layer.deleteMany) {
280
- await layer.deleteMany(keys);
281
- return;
282
- }
283
- await Promise.all(keys.map((key) => layer.delete(key)));
284
- })
285
- );
875
+ await this.deleteKeysFromLayers(this.layers, keys);
286
876
  for (const key of keys) {
287
877
  await this.tagIndex.remove(key);
878
+ this.accessProfiles.delete(key);
288
879
  }
289
880
  this.metrics.deletes += keys.length;
290
881
  this.metrics.invalidations += 1;
291
- this.logger.debug("delete", { keys });
882
+ this.logger.debug?.("delete", { keys });
883
+ this.emit("delete", { keys });
292
884
  }
293
885
  async publishInvalidation(message) {
294
886
  if (!this.options.invalidationBus) {
@@ -307,24 +899,278 @@ var CacheStack = class {
307
899
  if (message.scope === "clear") {
308
900
  await Promise.all(localLayers.map((layer) => layer.clear()));
309
901
  await this.tagIndex.clear();
902
+ this.accessProfiles.clear();
310
903
  return;
311
904
  }
312
905
  const keys = message.keys ?? [];
906
+ await this.deleteKeysFromLayers(localLayers, keys);
907
+ if (message.operation !== "write") {
908
+ for (const key of keys) {
909
+ await this.tagIndex.remove(key);
910
+ this.accessProfiles.delete(key);
911
+ }
912
+ }
913
+ }
914
+ formatError(error) {
915
+ if (error instanceof Error) {
916
+ return error.message;
917
+ }
918
+ return String(error);
919
+ }
920
+ sleep(ms) {
921
+ return new Promise((resolve) => setTimeout(resolve, ms));
922
+ }
923
+ shouldBroadcastL1Invalidation() {
924
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
925
+ }
926
+ async deleteKeysFromLayers(layers, keys) {
313
927
  await Promise.all(
314
- localLayers.map(async (layer) => {
928
+ layers.map(async (layer) => {
929
+ if (this.shouldSkipLayer(layer)) {
930
+ return;
931
+ }
315
932
  if (layer.deleteMany) {
316
- await layer.deleteMany(keys);
933
+ try {
934
+ await layer.deleteMany(keys);
935
+ } catch (error) {
936
+ await this.handleLayerFailure(layer, "delete", error);
937
+ }
317
938
  return;
318
939
  }
319
- await Promise.all(keys.map((key) => layer.delete(key)));
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
+ }));
320
947
  })
321
948
  );
322
- if (message.operation !== "write") {
323
- for (const key of keys) {
324
- await this.tagIndex.remove(key);
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;
325
992
  }
993
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
326
994
  }
327
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
+ }
328
1174
  };
329
1175
 
330
1176
  // src/invalidation/RedisInvalidationBus.ts
@@ -332,19 +1178,27 @@ var RedisInvalidationBus = class {
332
1178
  channel;
333
1179
  publisher;
334
1180
  subscriber;
1181
+ activeListener;
335
1182
  constructor(options) {
336
1183
  this.publisher = options.publisher;
337
1184
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
338
- this.channel = options.channel ?? "cachestack:invalidation";
1185
+ this.channel = options.channel ?? "layercache:invalidation";
339
1186
  }
340
1187
  async subscribe(handler) {
341
- const listener = async (_channel, payload) => {
342
- const message = JSON.parse(payload);
343
- 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);
344
1193
  };
1194
+ this.activeListener = listener;
345
1195
  this.subscriber.on("message", listener);
346
1196
  await this.subscriber.subscribe(this.channel);
347
1197
  return async () => {
1198
+ if (this.activeListener !== listener) {
1199
+ return;
1200
+ }
1201
+ this.activeListener = void 0;
348
1202
  this.subscriber.off("message", listener);
349
1203
  await this.subscriber.unsubscribe(this.channel);
350
1204
  };
@@ -352,98 +1206,117 @@ var RedisInvalidationBus = class {
352
1206
  async publish(message) {
353
1207
  await this.publisher.publish(this.channel, JSON.stringify(message));
354
1208
  }
355
- };
356
-
357
- // src/invalidation/RedisTagIndex.ts
358
- var RedisTagIndex = class {
359
- client;
360
- prefix;
361
- scanCount;
362
- constructor(options) {
363
- this.client = options.client;
364
- this.prefix = options.prefix ?? "cachestack:tag-index";
365
- this.scanCount = options.scanCount ?? 100;
366
- }
367
- async touch(key) {
368
- await this.client.sadd(this.knownKeysKey(), key);
369
- }
370
- async track(key, tags) {
371
- const keyTagsKey = this.keyTagsKey(key);
372
- const existingTags = await this.client.smembers(keyTagsKey);
373
- const pipeline = this.client.pipeline();
374
- pipeline.sadd(this.knownKeysKey(), key);
375
- for (const tag of existingTags) {
376
- pipeline.srem(this.tagKeysKey(tag), key);
377
- }
378
- pipeline.del(keyTagsKey);
379
- if (tags.length > 0) {
380
- pipeline.sadd(keyTagsKey, ...tags);
381
- for (const tag of new Set(tags)) {
382
- 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.");
383
1215
  }
1216
+ message = parsed;
1217
+ } catch (error) {
1218
+ this.reportError("invalid invalidation payload", error);
1219
+ return;
384
1220
  }
385
- await pipeline.exec();
386
- }
387
- async remove(key) {
388
- const keyTagsKey = this.keyTagsKey(key);
389
- const existingTags = await this.client.smembers(keyTagsKey);
390
- const pipeline = this.client.pipeline();
391
- pipeline.srem(this.knownKeysKey(), key);
392
- pipeline.del(keyTagsKey);
393
- for (const tag of existingTags) {
394
- pipeline.srem(this.tagKeysKey(tag), key);
1221
+ try {
1222
+ await handler(message);
1223
+ } catch (error) {
1224
+ this.reportError("invalidation handler failed", error);
395
1225
  }
396
- await pipeline.exec();
397
- }
398
- async keysForTag(tag) {
399
- return this.client.smembers(this.tagKeysKey(tag));
400
- }
401
- async matchPattern(pattern) {
402
- const matches = [];
403
- let cursor = "0";
404
- do {
405
- const [nextCursor, keys] = await this.client.sscan(
406
- this.knownKeysKey(),
407
- cursor,
408
- "MATCH",
409
- pattern,
410
- "COUNT",
411
- this.scanCount
412
- );
413
- cursor = nextCursor;
414
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
415
- } while (cursor !== "0");
416
- return matches;
417
1226
  }
418
- async clear() {
419
- const indexKeys = await this.scanIndexKeys();
420
- if (indexKeys.length === 0) {
421
- return;
1227
+ isInvalidationMessage(value) {
1228
+ if (!value || typeof value !== "object") {
1229
+ return false;
422
1230
  }
423
- await this.client.del(...indexKeys);
424
- }
425
- async scanIndexKeys() {
426
- const matches = [];
427
- let cursor = "0";
428
- const pattern = `${this.prefix}:*`;
429
- do {
430
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
431
- cursor = nextCursor;
432
- matches.push(...keys);
433
- } while (cursor !== "0");
434
- return matches;
435
- }
436
- knownKeysKey() {
437
- return `${this.prefix}:keys`;
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;
438
1236
  }
439
- keyTagsKey(key) {
440
- return `${this.prefix}:key:${encodeURIComponent(key)}`;
441
- }
442
- tagKeysKey(tag) {
443
- return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
1237
+ reportError(message, error) {
1238
+ console.error(`[layercache] ${message}`, error);
444
1239
  }
445
1240
  };
446
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
+
447
1320
  // src/layers/MemoryLayer.ts
448
1321
  var MemoryLayer = class {
449
1322
  name;
@@ -457,6 +1330,10 @@ var MemoryLayer = class {
457
1330
  this.maxSize = options.maxSize ?? 1e3;
458
1331
  }
459
1332
  async get(key) {
1333
+ const value = await this.getEntry(key);
1334
+ return unwrapStoredValue(value);
1335
+ }
1336
+ async getEntry(key) {
460
1337
  const entry = this.entries.get(key);
461
1338
  if (!entry) {
462
1339
  return null;
@@ -469,6 +1346,13 @@ var MemoryLayer = class {
469
1346
  this.entries.set(key, entry);
470
1347
  return entry.value;
471
1348
  }
1349
+ async getMany(keys) {
1350
+ const values = [];
1351
+ for (const key of keys) {
1352
+ values.push(await this.getEntry(key));
1353
+ }
1354
+ return values;
1355
+ }
472
1356
  async set(key, value, ttl = this.defaultTtl) {
473
1357
  this.entries.delete(key);
474
1358
  this.entries.set(key, {
@@ -498,6 +1382,32 @@ var MemoryLayer = class {
498
1382
  this.pruneExpired();
499
1383
  return [...this.entries.keys()];
500
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
+ }
501
1411
  pruneExpired() {
502
1412
  for (const [key, entry] of this.entries.entries()) {
503
1413
  if (this.isExpired(entry)) {
@@ -510,6 +1420,9 @@ var MemoryLayer = class {
510
1420
  }
511
1421
  };
512
1422
 
1423
+ // src/layers/RedisLayer.ts
1424
+ import { brotliCompressSync, brotliDecompressSync, gzipSync, gunzipSync } from "zlib";
1425
+
513
1426
  // src/serialization/JsonSerializer.ts
514
1427
  var JsonSerializer = class {
515
1428
  serialize(value) {
@@ -531,6 +1444,8 @@ var RedisLayer = class {
531
1444
  prefix;
532
1445
  allowUnprefixedClear;
533
1446
  scanCount;
1447
+ compression;
1448
+ compressionThreshold;
534
1449
  constructor(options) {
535
1450
  this.client = options.client;
536
1451
  this.defaultTtl = options.ttl;
@@ -539,16 +1454,44 @@ var RedisLayer = class {
539
1454
  this.prefix = options.prefix ?? "";
540
1455
  this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
541
1456
  this.scanCount = options.scanCount ?? 100;
1457
+ this.compression = options.compression;
1458
+ this.compressionThreshold = options.compressionThreshold ?? 1024;
542
1459
  }
543
1460
  async get(key) {
1461
+ const payload = await this.getEntry(key);
1462
+ return unwrapStoredValue(payload);
1463
+ }
1464
+ async getEntry(key) {
544
1465
  const payload = await this.client.getBuffer(this.withPrefix(key));
545
1466
  if (payload === null) {
546
1467
  return null;
547
1468
  }
548
- return this.serializer.deserialize(payload);
1469
+ return this.deserializeOrDelete(key, payload);
1470
+ }
1471
+ async getMany(keys) {
1472
+ if (keys.length === 0) {
1473
+ return [];
1474
+ }
1475
+ const pipeline = this.client.pipeline();
1476
+ for (const key of keys) {
1477
+ pipeline.getBuffer(this.withPrefix(key));
1478
+ }
1479
+ const results = await pipeline.exec();
1480
+ if (results === null) {
1481
+ return keys.map(() => null);
1482
+ }
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
+ );
549
1492
  }
550
1493
  async set(key, value, ttl = this.defaultTtl) {
551
- const payload = this.serializer.serialize(value);
1494
+ const payload = this.encodePayload(this.serializer.serialize(value));
552
1495
  const normalizedKey = this.withPrefix(key);
553
1496
  if (ttl && ttl > 0) {
554
1497
  await this.client.set(normalizedKey, payload, "EX", ttl);
@@ -595,6 +1538,41 @@ var RedisLayer = class {
595
1538
  withPrefix(key) {
596
1539
  return `${this.prefix}${key}`;
597
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
+ }
598
1576
  };
599
1577
 
600
1578
  // src/serialization/MsgpackSerializer.ts
@@ -608,7 +1586,38 @@ var MsgpackSerializer = class {
608
1586
  return decode(normalized);
609
1587
  }
610
1588
  };
1589
+
1590
+ // src/singleflight/RedisSingleFlightCoordinator.ts
1591
+ import { randomUUID as randomUUID2 } from "crypto";
1592
+ var RELEASE_SCRIPT = `
1593
+ if redis.call("get", KEYS[1]) == ARGV[1] then
1594
+ return redis.call("del", KEYS[1])
1595
+ end
1596
+ return 0
1597
+ `;
1598
+ var RedisSingleFlightCoordinator = class {
1599
+ client;
1600
+ prefix;
1601
+ constructor(options) {
1602
+ this.client = options.client;
1603
+ this.prefix = options.prefix ?? "layercache:singleflight";
1604
+ }
1605
+ async execute(key, options, worker, waiter) {
1606
+ const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
1607
+ const token = randomUUID2();
1608
+ const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
1609
+ if (acquired === "OK") {
1610
+ try {
1611
+ return await worker();
1612
+ } finally {
1613
+ await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
1614
+ }
1615
+ }
1616
+ return waiter();
1617
+ }
1618
+ };
611
1619
  export {
1620
+ CacheNamespace,
612
1621
  CacheStack,
613
1622
  JsonSerializer,
614
1623
  MemoryLayer,
@@ -616,7 +1625,13 @@ export {
616
1625
  PatternMatcher,
617
1626
  RedisInvalidationBus,
618
1627
  RedisLayer,
1628
+ RedisSingleFlightCoordinator,
619
1629
  RedisTagIndex,
620
1630
  StampedeGuard,
621
- TagIndex
1631
+ TagIndex,
1632
+ cacheGraphqlResolver,
1633
+ createCacheStatsHandler,
1634
+ createCachedMethodDecorator,
1635
+ createFastifyLayercachePlugin,
1636
+ createTrpcCacheMiddleware
622
1637
  };