layercache 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,7 @@ var index_exports = {};
30
30
  __export(index_exports, {
31
31
  CACHE_STACK: () => CACHE_STACK,
32
32
  CacheStackModule: () => CacheStackModule,
33
+ Cacheable: () => Cacheable,
33
34
  InjectCacheStack: () => InjectCacheStack
34
35
  });
35
36
  module.exports = __toCommonJS(index_exports);
@@ -37,11 +38,264 @@ module.exports = __toCommonJS(index_exports);
37
38
  // src/constants.ts
38
39
  var CACHE_STACK = /* @__PURE__ */ Symbol("CACHE_STACK");
39
40
 
41
+ // ../../src/decorators/createCachedMethodDecorator.ts
42
+ function createCachedMethodDecorator(options) {
43
+ const wrappedByInstance = /* @__PURE__ */ new WeakMap();
44
+ return ((_, propertyKey, descriptor) => {
45
+ const original = descriptor.value;
46
+ if (typeof original !== "function") {
47
+ throw new Error("createCachedMethodDecorator can only be applied to methods.");
48
+ }
49
+ descriptor.value = async function(...args) {
50
+ const instance = this;
51
+ let wrapped = wrappedByInstance.get(instance);
52
+ if (!wrapped) {
53
+ const cache = options.cache(instance);
54
+ wrapped = cache.wrap(
55
+ options.prefix ?? String(propertyKey),
56
+ (...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
57
+ options
58
+ );
59
+ wrappedByInstance.set(instance, wrapped);
60
+ }
61
+ return wrapped(...args);
62
+ };
63
+ });
64
+ }
65
+
66
+ // src/decorators.ts
67
+ function Cacheable(options) {
68
+ return createCachedMethodDecorator(options);
69
+ }
70
+
40
71
  // src/module.ts
41
72
  var import_common = require("@nestjs/common");
42
73
 
43
74
  // ../../src/CacheStack.ts
44
75
  var import_node_crypto = require("crypto");
76
+ var import_node_events = require("events");
77
+ var import_node_fs = require("fs");
78
+
79
+ // ../../src/CacheNamespace.ts
80
+ var CacheNamespace = class {
81
+ constructor(cache, prefix) {
82
+ this.cache = cache;
83
+ this.prefix = prefix;
84
+ }
85
+ cache;
86
+ prefix;
87
+ async get(key, fetcher, options) {
88
+ return this.cache.get(this.qualify(key), fetcher, options);
89
+ }
90
+ async getOrSet(key, fetcher, options) {
91
+ return this.cache.getOrSet(this.qualify(key), fetcher, options);
92
+ }
93
+ async has(key) {
94
+ return this.cache.has(this.qualify(key));
95
+ }
96
+ async ttl(key) {
97
+ return this.cache.ttl(this.qualify(key));
98
+ }
99
+ async set(key, value, options) {
100
+ await this.cache.set(this.qualify(key), value, options);
101
+ }
102
+ async delete(key) {
103
+ await this.cache.delete(this.qualify(key));
104
+ }
105
+ async mdelete(keys) {
106
+ await this.cache.mdelete(keys.map((k) => this.qualify(k)));
107
+ }
108
+ async clear() {
109
+ await this.cache.invalidateByPattern(`${this.prefix}:*`);
110
+ }
111
+ async mget(entries) {
112
+ return this.cache.mget(
113
+ entries.map((entry) => ({
114
+ ...entry,
115
+ key: this.qualify(entry.key)
116
+ }))
117
+ );
118
+ }
119
+ async mset(entries) {
120
+ await this.cache.mset(
121
+ entries.map((entry) => ({
122
+ ...entry,
123
+ key: this.qualify(entry.key)
124
+ }))
125
+ );
126
+ }
127
+ async invalidateByTag(tag) {
128
+ await this.cache.invalidateByTag(tag);
129
+ }
130
+ async invalidateByPattern(pattern) {
131
+ await this.cache.invalidateByPattern(this.qualify(pattern));
132
+ }
133
+ wrap(keyPrefix, fetcher, options) {
134
+ return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
135
+ }
136
+ warm(entries, options) {
137
+ return this.cache.warm(
138
+ entries.map((entry) => ({
139
+ ...entry,
140
+ key: this.qualify(entry.key)
141
+ })),
142
+ options
143
+ );
144
+ }
145
+ getMetrics() {
146
+ return this.cache.getMetrics();
147
+ }
148
+ getHitRate() {
149
+ return this.cache.getHitRate();
150
+ }
151
+ qualify(key) {
152
+ return `${this.prefix}:${key}`;
153
+ }
154
+ };
155
+
156
+ // ../../src/internal/CircuitBreakerManager.ts
157
+ var CircuitBreakerManager = class {
158
+ breakers = /* @__PURE__ */ new Map();
159
+ maxEntries;
160
+ constructor(options) {
161
+ this.maxEntries = options.maxEntries;
162
+ }
163
+ /**
164
+ * Throws if the circuit is open for the given key.
165
+ * Automatically resets if the cooldown has elapsed.
166
+ */
167
+ assertClosed(key, options) {
168
+ const state = this.breakers.get(key);
169
+ if (!state?.openUntil) {
170
+ return;
171
+ }
172
+ const now = Date.now();
173
+ if (state.openUntil <= now) {
174
+ state.openUntil = null;
175
+ state.failures = 0;
176
+ this.breakers.set(key, state);
177
+ return;
178
+ }
179
+ const remainingMs = state.openUntil - now;
180
+ const remainingSecs = Math.ceil(remainingMs / 1e3);
181
+ throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
182
+ }
183
+ recordFailure(key, options) {
184
+ if (!options) {
185
+ return;
186
+ }
187
+ const failureThreshold = options.failureThreshold ?? 3;
188
+ const cooldownMs = options.cooldownMs ?? 3e4;
189
+ const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
190
+ state.failures += 1;
191
+ if (state.failures >= failureThreshold) {
192
+ state.openUntil = Date.now() + cooldownMs;
193
+ }
194
+ this.breakers.set(key, state);
195
+ this.pruneIfNeeded();
196
+ }
197
+ recordSuccess(key) {
198
+ this.breakers.delete(key);
199
+ }
200
+ isOpen(key) {
201
+ const state = this.breakers.get(key);
202
+ if (!state?.openUntil) {
203
+ return false;
204
+ }
205
+ if (state.openUntil <= Date.now()) {
206
+ state.openUntil = null;
207
+ state.failures = 0;
208
+ return false;
209
+ }
210
+ return true;
211
+ }
212
+ delete(key) {
213
+ this.breakers.delete(key);
214
+ }
215
+ clear() {
216
+ this.breakers.clear();
217
+ }
218
+ tripCount() {
219
+ let count = 0;
220
+ for (const state of this.breakers.values()) {
221
+ if (state.openUntil !== null) {
222
+ count += 1;
223
+ }
224
+ }
225
+ return count;
226
+ }
227
+ pruneIfNeeded() {
228
+ if (this.breakers.size <= this.maxEntries) {
229
+ return;
230
+ }
231
+ for (const [key, state] of this.breakers.entries()) {
232
+ if (this.breakers.size <= this.maxEntries) {
233
+ break;
234
+ }
235
+ if (!state.openUntil || state.openUntil <= Date.now()) {
236
+ this.breakers.delete(key);
237
+ }
238
+ }
239
+ for (const key of this.breakers.keys()) {
240
+ if (this.breakers.size <= this.maxEntries) {
241
+ break;
242
+ }
243
+ this.breakers.delete(key);
244
+ }
245
+ }
246
+ };
247
+
248
+ // ../../src/internal/MetricsCollector.ts
249
+ var MetricsCollector = class {
250
+ data = this.empty();
251
+ get snapshot() {
252
+ return { ...this.data };
253
+ }
254
+ increment(field, amount = 1) {
255
+ ;
256
+ this.data[field] += amount;
257
+ }
258
+ incrementLayer(map, layerName) {
259
+ this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
260
+ }
261
+ reset() {
262
+ this.data = this.empty();
263
+ }
264
+ hitRate() {
265
+ const total = this.data.hits + this.data.misses;
266
+ const overall = total === 0 ? 0 : this.data.hits / total;
267
+ const byLayer = {};
268
+ const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
269
+ for (const layer of allLayers) {
270
+ const h = this.data.hitsByLayer[layer] ?? 0;
271
+ const m = this.data.missesByLayer[layer] ?? 0;
272
+ byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
273
+ }
274
+ return { overall, byLayer };
275
+ }
276
+ empty() {
277
+ return {
278
+ hits: 0,
279
+ misses: 0,
280
+ fetches: 0,
281
+ sets: 0,
282
+ deletes: 0,
283
+ backfills: 0,
284
+ invalidations: 0,
285
+ staleHits: 0,
286
+ refreshes: 0,
287
+ refreshErrors: 0,
288
+ writeFailures: 0,
289
+ singleFlightWaits: 0,
290
+ negativeCacheHits: 0,
291
+ circuitBreakerTrips: 0,
292
+ degradedOperations: 0,
293
+ hitsByLayer: {},
294
+ missesByLayer: {},
295
+ resetAt: Date.now()
296
+ };
297
+ }
298
+ };
45
299
 
46
300
  // ../../src/internal/StoredValue.ts
47
301
  function isStoredValueEnvelope(value) {
@@ -61,7 +315,10 @@ function createStoredValueEnvelope(options) {
61
315
  value: options.value,
62
316
  freshUntil,
63
317
  staleUntil,
64
- errorUntil
318
+ errorUntil,
319
+ freshTtlSeconds: freshTtlSeconds ?? null,
320
+ staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
321
+ staleIfErrorSeconds: staleIfErrorSeconds ?? null
65
322
  };
66
323
  }
67
324
  function resolveStoredValue(stored, now = Date.now()) {
@@ -102,6 +359,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
102
359
  }
103
360
  return Math.max(1, Math.ceil(remainingMs / 1e3));
104
361
  }
362
+ function remainingFreshTtlSeconds(stored, now = Date.now()) {
363
+ if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
364
+ return void 0;
365
+ }
366
+ const remainingMs = stored.freshUntil - now;
367
+ if (remainingMs <= 0) {
368
+ return 0;
369
+ }
370
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
371
+ }
372
+ function refreshStoredEnvelope(stored, now = Date.now()) {
373
+ if (!isStoredValueEnvelope(stored)) {
374
+ return stored;
375
+ }
376
+ return createStoredValueEnvelope({
377
+ kind: stored.kind,
378
+ value: stored.value,
379
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
380
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
381
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
382
+ now
383
+ });
384
+ }
105
385
  function maxExpiry(stored) {
106
386
  const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
107
387
  (value) => value !== null
@@ -118,12 +398,129 @@ function normalizePositiveSeconds(value) {
118
398
  return value;
119
399
  }
120
400
 
401
+ // ../../src/internal/TtlResolver.ts
402
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
403
+ var TtlResolver = class {
404
+ accessProfiles = /* @__PURE__ */ new Map();
405
+ maxProfileEntries;
406
+ constructor(options) {
407
+ this.maxProfileEntries = options.maxProfileEntries;
408
+ }
409
+ recordAccess(key) {
410
+ const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
411
+ profile.hits += 1;
412
+ profile.lastAccessAt = Date.now();
413
+ this.accessProfiles.set(key, profile);
414
+ this.pruneIfNeeded();
415
+ }
416
+ deleteProfile(key) {
417
+ this.accessProfiles.delete(key);
418
+ }
419
+ clearProfiles() {
420
+ this.accessProfiles.clear();
421
+ }
422
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
423
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
424
+ layerName,
425
+ options?.negativeTtl,
426
+ globalNegativeTtl,
427
+ this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
428
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
429
+ const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
430
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
431
+ return this.applyJitter(adaptiveTtl, jitter);
432
+ }
433
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
434
+ if (override !== void 0) {
435
+ return this.readLayerNumber(layerName, override) ?? fallback;
436
+ }
437
+ if (globalDefault !== void 0) {
438
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
439
+ }
440
+ return fallback;
441
+ }
442
+ applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
443
+ if (!ttl || !adaptiveTtl) {
444
+ return ttl;
445
+ }
446
+ const profile = this.accessProfiles.get(key);
447
+ if (!profile) {
448
+ return ttl;
449
+ }
450
+ const config = adaptiveTtl === true ? {} : adaptiveTtl;
451
+ const hotAfter = config.hotAfter ?? 3;
452
+ if (profile.hits < hotAfter) {
453
+ return ttl;
454
+ }
455
+ const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
456
+ const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
457
+ const multiplier = Math.floor(profile.hits / hotAfter);
458
+ return Math.min(maxTtl, ttl + step * multiplier);
459
+ }
460
+ applyJitter(ttl, jitter) {
461
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
462
+ return ttl;
463
+ }
464
+ const delta = (Math.random() * 2 - 1) * jitter;
465
+ return Math.max(1, Math.round(ttl + delta));
466
+ }
467
+ readLayerNumber(layerName, value) {
468
+ if (typeof value === "number") {
469
+ return value;
470
+ }
471
+ return value[layerName];
472
+ }
473
+ pruneIfNeeded() {
474
+ if (this.accessProfiles.size <= this.maxProfileEntries) {
475
+ return;
476
+ }
477
+ const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
478
+ let removed = 0;
479
+ for (const key of this.accessProfiles.keys()) {
480
+ if (removed >= toRemove) {
481
+ break;
482
+ }
483
+ this.accessProfiles.delete(key);
484
+ removed += 1;
485
+ }
486
+ }
487
+ };
488
+
121
489
  // ../../src/invalidation/PatternMatcher.ts
122
- var PatternMatcher = class {
490
+ var PatternMatcher = class _PatternMatcher {
491
+ /**
492
+ * Tests whether a glob-style pattern matches a value.
493
+ * Supports `*` (any sequence of characters) and `?` (any single character).
494
+ * Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
495
+ */
123
496
  static matches(pattern, value) {
124
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
125
- const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
126
- return regex.test(value);
497
+ return _PatternMatcher.matchLinear(pattern, value);
498
+ }
499
+ /**
500
+ * Linear-time glob matching using dynamic programming.
501
+ * Avoids catastrophic backtracking that RegExp-based glob matching can cause.
502
+ */
503
+ static matchLinear(pattern, value) {
504
+ const m = pattern.length;
505
+ const n = value.length;
506
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
507
+ dp[0][0] = true;
508
+ for (let i = 1; i <= m; i++) {
509
+ if (pattern[i - 1] === "*") {
510
+ dp[i][0] = dp[i - 1]?.[0];
511
+ }
512
+ }
513
+ for (let i = 1; i <= m; i++) {
514
+ for (let j = 1; j <= n; j++) {
515
+ const pc = pattern[i - 1];
516
+ if (pc === "*") {
517
+ dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
518
+ } else if (pc === "?" || pc === value[j - 1]) {
519
+ dp[i][j] = dp[i - 1]?.[j - 1];
520
+ }
521
+ }
522
+ }
523
+ return dp[m]?.[n];
127
524
  }
128
525
  };
129
526
 
@@ -364,64 +761,75 @@ var Mutex = class {
364
761
  var StampedeGuard = class {
365
762
  mutexes = /* @__PURE__ */ new Map();
366
763
  async execute(key, task) {
367
- const mutex = this.getMutex(key);
764
+ const entry = this.getMutexEntry(key);
368
765
  try {
369
- return await mutex.runExclusive(task);
766
+ return await entry.mutex.runExclusive(task);
370
767
  } finally {
371
- if (!mutex.isLocked()) {
768
+ entry.references -= 1;
769
+ if (entry.references === 0 && !entry.mutex.isLocked()) {
372
770
  this.mutexes.delete(key);
373
771
  }
374
772
  }
375
773
  }
376
- getMutex(key) {
377
- let mutex = this.mutexes.get(key);
378
- if (!mutex) {
379
- mutex = new Mutex();
380
- this.mutexes.set(key, mutex);
774
+ getMutexEntry(key) {
775
+ let entry = this.mutexes.get(key);
776
+ if (!entry) {
777
+ entry = { mutex: new Mutex(), references: 0 };
778
+ this.mutexes.set(key, entry);
381
779
  }
382
- return mutex;
780
+ entry.references += 1;
781
+ return entry;
383
782
  }
384
783
  };
385
784
 
386
785
  // ../../src/CacheStack.ts
387
- var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
388
786
  var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
389
787
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
390
788
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
391
- var EMPTY_METRICS = () => ({
392
- hits: 0,
393
- misses: 0,
394
- fetches: 0,
395
- sets: 0,
396
- deletes: 0,
397
- backfills: 0,
398
- invalidations: 0,
399
- staleHits: 0,
400
- refreshes: 0,
401
- refreshErrors: 0,
402
- writeFailures: 0,
403
- singleFlightWaits: 0
404
- });
789
+ var MAX_CACHE_KEY_LENGTH = 1024;
790
+ var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
405
791
  var DebugLogger = class {
406
792
  enabled;
407
793
  constructor(enabled) {
408
794
  this.enabled = enabled;
409
795
  }
410
796
  debug(message, context) {
797
+ this.write("debug", message, context);
798
+ }
799
+ info(message, context) {
800
+ this.write("info", message, context);
801
+ }
802
+ warn(message, context) {
803
+ this.write("warn", message, context);
804
+ }
805
+ error(message, context) {
806
+ this.write("error", message, context);
807
+ }
808
+ write(level, message, context) {
411
809
  if (!this.enabled) {
412
810
  return;
413
811
  }
414
812
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
415
- console.debug(`[layercache] ${message}${suffix}`);
813
+ console[level](`[layercache] ${message}${suffix}`);
416
814
  }
417
815
  };
418
- var CacheStack = class {
816
+ var CacheStack = class extends import_node_events.EventEmitter {
419
817
  constructor(layers, options = {}) {
818
+ super();
420
819
  this.layers = layers;
421
820
  this.options = options;
422
821
  if (layers.length === 0) {
423
822
  throw new Error("CacheStack requires at least one cache layer.");
424
823
  }
824
+ this.validateConfiguration();
825
+ const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
826
+ this.ttlResolver = new TtlResolver({ maxProfileEntries });
827
+ this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
828
+ if (options.publishSetInvalidation !== void 0) {
829
+ console.warn(
830
+ "[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
831
+ );
832
+ }
425
833
  const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
426
834
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
427
835
  this.tagIndex = options.tagIndex ?? new TagIndex();
@@ -430,112 +838,313 @@ var CacheStack = class {
430
838
  layers;
431
839
  options;
432
840
  stampedeGuard = new StampedeGuard();
433
- metrics = EMPTY_METRICS();
841
+ metricsCollector = new MetricsCollector();
434
842
  instanceId = (0, import_node_crypto.randomUUID)();
435
843
  startup;
436
844
  unsubscribeInvalidation;
437
845
  logger;
438
846
  tagIndex;
439
847
  backgroundRefreshes = /* @__PURE__ */ new Map();
848
+ layerDegradedUntil = /* @__PURE__ */ new Map();
849
+ ttlResolver;
850
+ circuitBreakerManager;
851
+ isDisconnecting = false;
852
+ disconnectPromise;
853
+ /**
854
+ * Read-through cache get.
855
+ * Returns the cached value if present and fresh, or invokes `fetcher` on a miss
856
+ * and stores the result across all layers. Returns `null` if the key is not found
857
+ * and no `fetcher` is provided.
858
+ */
440
859
  async get(key, fetcher, options) {
860
+ const normalizedKey = this.validateCacheKey(key);
861
+ this.validateWriteOptions(options);
441
862
  await this.startup;
442
- const hit = await this.readFromLayers(key, options, "allow-stale");
863
+ const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
443
864
  if (hit.found) {
865
+ this.ttlResolver.recordAccess(normalizedKey);
866
+ if (this.isNegativeStoredValue(hit.stored)) {
867
+ this.metricsCollector.increment("negativeCacheHits");
868
+ }
444
869
  if (hit.state === "fresh") {
445
- this.metrics.hits += 1;
870
+ this.metricsCollector.increment("hits");
871
+ await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
446
872
  return hit.value;
447
873
  }
448
874
  if (hit.state === "stale-while-revalidate") {
449
- this.metrics.hits += 1;
450
- this.metrics.staleHits += 1;
875
+ this.metricsCollector.increment("hits");
876
+ this.metricsCollector.increment("staleHits");
877
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
451
878
  if (fetcher) {
452
- this.scheduleBackgroundRefresh(key, fetcher, options);
879
+ this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
453
880
  }
454
881
  return hit.value;
455
882
  }
456
883
  if (!fetcher) {
457
- this.metrics.hits += 1;
458
- this.metrics.staleHits += 1;
884
+ this.metricsCollector.increment("hits");
885
+ this.metricsCollector.increment("staleHits");
886
+ this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
459
887
  return hit.value;
460
888
  }
461
889
  try {
462
- return await this.fetchWithGuards(key, fetcher, options);
890
+ return await this.fetchWithGuards(normalizedKey, fetcher, options);
463
891
  } catch (error) {
464
- this.metrics.staleHits += 1;
465
- this.metrics.refreshErrors += 1;
466
- this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
892
+ this.metricsCollector.increment("staleHits");
893
+ this.metricsCollector.increment("refreshErrors");
894
+ this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
467
895
  return hit.value;
468
896
  }
469
897
  }
470
- this.metrics.misses += 1;
898
+ this.metricsCollector.increment("misses");
471
899
  if (!fetcher) {
472
900
  return null;
473
901
  }
474
- return this.fetchWithGuards(key, fetcher, options);
902
+ return this.fetchWithGuards(normalizedKey, fetcher, options);
903
+ }
904
+ /**
905
+ * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
906
+ * Fetches and caches the value if not already present.
907
+ */
908
+ async getOrSet(key, fetcher, options) {
909
+ return this.get(key, fetcher, options);
910
+ }
911
+ /**
912
+ * Returns true if the given key exists and is not expired in any layer.
913
+ */
914
+ async has(key) {
915
+ const normalizedKey = this.validateCacheKey(key);
916
+ await this.startup;
917
+ for (const layer of this.layers) {
918
+ if (this.shouldSkipLayer(layer)) {
919
+ continue;
920
+ }
921
+ if (layer.has) {
922
+ try {
923
+ const exists = await layer.has(normalizedKey);
924
+ if (exists) {
925
+ return true;
926
+ }
927
+ } catch {
928
+ }
929
+ } else {
930
+ try {
931
+ const value = await layer.get(normalizedKey);
932
+ if (value !== null) {
933
+ return true;
934
+ }
935
+ } catch {
936
+ }
937
+ }
938
+ }
939
+ return false;
940
+ }
941
+ /**
942
+ * Returns the remaining TTL in seconds for the key in the fastest layer
943
+ * that has it, or null if the key is not found / has no TTL.
944
+ */
945
+ async ttl(key) {
946
+ const normalizedKey = this.validateCacheKey(key);
947
+ await this.startup;
948
+ for (const layer of this.layers) {
949
+ if (this.shouldSkipLayer(layer)) {
950
+ continue;
951
+ }
952
+ if (layer.ttl) {
953
+ try {
954
+ const remaining = await layer.ttl(normalizedKey);
955
+ if (remaining !== null) {
956
+ return remaining;
957
+ }
958
+ } catch {
959
+ }
960
+ }
961
+ }
962
+ return null;
475
963
  }
964
+ /**
965
+ * Stores a value in all cache layers. Overwrites any existing value.
966
+ */
476
967
  async set(key, value, options) {
968
+ const normalizedKey = this.validateCacheKey(key);
969
+ this.validateWriteOptions(options);
477
970
  await this.startup;
478
- await this.storeEntry(key, "value", value, options);
971
+ await this.storeEntry(normalizedKey, "value", value, options);
479
972
  }
973
+ /**
974
+ * Deletes the key from all layers and publishes an invalidation message.
975
+ */
480
976
  async delete(key) {
977
+ const normalizedKey = this.validateCacheKey(key);
481
978
  await this.startup;
482
- await this.deleteKeys([key]);
483
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
979
+ await this.deleteKeys([normalizedKey]);
980
+ await this.publishInvalidation({
981
+ scope: "key",
982
+ keys: [normalizedKey],
983
+ sourceId: this.instanceId,
984
+ operation: "delete"
985
+ });
484
986
  }
485
987
  async clear() {
486
988
  await this.startup;
487
989
  await Promise.all(this.layers.map((layer) => layer.clear()));
488
990
  await this.tagIndex.clear();
489
- this.metrics.invalidations += 1;
490
- this.logger.debug("clear");
991
+ this.ttlResolver.clearProfiles();
992
+ this.circuitBreakerManager.clear();
993
+ this.metricsCollector.increment("invalidations");
994
+ this.logger.debug?.("clear");
491
995
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
492
996
  }
997
+ /**
998
+ * Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
999
+ */
1000
+ async mdelete(keys) {
1001
+ if (keys.length === 0) {
1002
+ return;
1003
+ }
1004
+ await this.startup;
1005
+ const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
1006
+ await this.deleteKeys(normalizedKeys);
1007
+ await this.publishInvalidation({
1008
+ scope: "keys",
1009
+ keys: normalizedKeys,
1010
+ sourceId: this.instanceId,
1011
+ operation: "delete"
1012
+ });
1013
+ }
493
1014
  async mget(entries) {
494
1015
  if (entries.length === 0) {
495
1016
  return [];
496
1017
  }
497
- const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1018
+ const normalizedEntries = entries.map((entry) => ({
1019
+ ...entry,
1020
+ key: this.validateCacheKey(entry.key)
1021
+ }));
1022
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1023
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
498
1024
  if (!canFastPath) {
499
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
1025
+ const pendingReads = /* @__PURE__ */ new Map();
1026
+ return Promise.all(
1027
+ normalizedEntries.map((entry) => {
1028
+ const optionsSignature = this.serializeOptions(entry.options);
1029
+ const existing = pendingReads.get(entry.key);
1030
+ if (!existing) {
1031
+ const promise = this.get(entry.key, entry.fetch, entry.options);
1032
+ pendingReads.set(entry.key, {
1033
+ promise,
1034
+ fetch: entry.fetch,
1035
+ optionsSignature
1036
+ });
1037
+ return promise;
1038
+ }
1039
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
1040
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
1041
+ }
1042
+ return existing.promise;
1043
+ })
1044
+ );
500
1045
  }
501
1046
  await this.startup;
502
- const pending = new Set(entries.map((_, index) => index));
503
- const results = Array(entries.length).fill(null);
504
- for (const layer of this.layers) {
505
- const indexes = [...pending];
506
- if (indexes.length === 0) {
1047
+ const pending = /* @__PURE__ */ new Set();
1048
+ const indexesByKey = /* @__PURE__ */ new Map();
1049
+ const resultsByKey = /* @__PURE__ */ new Map();
1050
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
1051
+ const entry = normalizedEntries[index];
1052
+ if (!entry) continue;
1053
+ const key = entry.key;
1054
+ const indexes = indexesByKey.get(key) ?? [];
1055
+ indexes.push(index);
1056
+ indexesByKey.set(key, indexes);
1057
+ pending.add(key);
1058
+ }
1059
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
1060
+ const layer = this.layers[layerIndex];
1061
+ if (!layer) continue;
1062
+ const keys = [...pending];
1063
+ if (keys.length === 0) {
507
1064
  break;
508
1065
  }
509
- const keys = indexes.map((index) => entries[index].key);
510
1066
  const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
511
1067
  for (let offset = 0; offset < values.length; offset += 1) {
512
- const index = indexes[offset];
1068
+ const key = keys[offset];
513
1069
  const stored = values[offset];
514
- if (stored === null) {
1070
+ if (!key || stored === null) {
515
1071
  continue;
516
1072
  }
517
1073
  const resolved = resolveStoredValue(stored);
518
1074
  if (resolved.state === "expired") {
519
- await layer.delete(entries[index].key);
1075
+ await layer.delete(key);
520
1076
  continue;
521
1077
  }
522
- await this.tagIndex.touch(entries[index].key);
523
- await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
524
- results[index] = resolved.value;
525
- pending.delete(index);
526
- this.metrics.hits += 1;
1078
+ await this.tagIndex.touch(key);
1079
+ await this.backfill(key, stored, layerIndex - 1);
1080
+ resultsByKey.set(key, resolved.value);
1081
+ pending.delete(key);
1082
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
527
1083
  }
528
1084
  }
529
1085
  if (pending.size > 0) {
530
- for (const index of pending) {
531
- await this.tagIndex.remove(entries[index].key);
532
- this.metrics.misses += 1;
1086
+ for (const key of pending) {
1087
+ await this.tagIndex.remove(key);
1088
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
533
1089
  }
534
1090
  }
535
- return results;
1091
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
536
1092
  }
537
1093
  async mset(entries) {
538
- await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
1094
+ const normalizedEntries = entries.map((entry) => ({
1095
+ ...entry,
1096
+ key: this.validateCacheKey(entry.key)
1097
+ }));
1098
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1099
+ await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
1100
+ }
1101
+ async warm(entries, options = {}) {
1102
+ const concurrency = Math.max(1, options.concurrency ?? 4);
1103
+ const total = entries.length;
1104
+ let completed = 0;
1105
+ const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
1106
+ const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
1107
+ while (queue.length > 0) {
1108
+ const entry = queue.shift();
1109
+ if (!entry) {
1110
+ return;
1111
+ }
1112
+ let success = false;
1113
+ try {
1114
+ await this.get(entry.key, entry.fetcher, entry.options);
1115
+ this.emit("warm", { key: entry.key });
1116
+ success = true;
1117
+ } catch (error) {
1118
+ this.emitError("warm", { key: entry.key, error: this.formatError(error) });
1119
+ if (!options.continueOnError) {
1120
+ throw error;
1121
+ }
1122
+ } finally {
1123
+ completed += 1;
1124
+ const progress = { completed, total, key: entry.key, success };
1125
+ options.onProgress?.(progress);
1126
+ }
1127
+ }
1128
+ });
1129
+ await Promise.all(workers);
1130
+ }
1131
+ /**
1132
+ * Returns a cached version of `fetcher`. The cache key is derived from
1133
+ * `prefix` plus the serialized arguments unless a `keyResolver` is provided.
1134
+ */
1135
+ wrap(prefix, fetcher, options = {}) {
1136
+ return (...args) => {
1137
+ const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
1138
+ const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
1139
+ return this.get(key, () => fetcher(...args), options);
1140
+ };
1141
+ }
1142
+ /**
1143
+ * Creates a `CacheNamespace` that automatically prefixes all keys with
1144
+ * `prefix:`. Useful for multi-tenant or module-level isolation.
1145
+ */
1146
+ namespace(prefix) {
1147
+ return new CacheNamespace(this, prefix);
539
1148
  }
540
1149
  async invalidateByTag(tag) {
541
1150
  await this.startup;
@@ -550,15 +1159,94 @@ var CacheStack = class {
550
1159
  await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
551
1160
  }
552
1161
  getMetrics() {
553
- return { ...this.metrics };
1162
+ return this.metricsCollector.snapshot;
1163
+ }
1164
+ getStats() {
1165
+ return {
1166
+ metrics: this.getMetrics(),
1167
+ layers: this.layers.map((layer) => ({
1168
+ name: layer.name,
1169
+ isLocal: Boolean(layer.isLocal),
1170
+ degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
1171
+ })),
1172
+ backgroundRefreshes: this.backgroundRefreshes.size
1173
+ };
554
1174
  }
555
1175
  resetMetrics() {
556
- Object.assign(this.metrics, EMPTY_METRICS());
1176
+ this.metricsCollector.reset();
557
1177
  }
558
- async disconnect() {
1178
+ /**
1179
+ * Returns computed hit-rate statistics (overall and per-layer).
1180
+ */
1181
+ getHitRate() {
1182
+ return this.metricsCollector.hitRate();
1183
+ }
1184
+ async exportState() {
1185
+ await this.startup;
1186
+ const exported = /* @__PURE__ */ new Map();
1187
+ for (const layer of this.layers) {
1188
+ if (!layer.keys) {
1189
+ continue;
1190
+ }
1191
+ const keys = await layer.keys();
1192
+ for (const key of keys) {
1193
+ if (exported.has(key)) {
1194
+ continue;
1195
+ }
1196
+ const stored = await this.readLayerEntry(layer, key);
1197
+ if (stored === null) {
1198
+ continue;
1199
+ }
1200
+ exported.set(key, {
1201
+ key,
1202
+ value: stored,
1203
+ ttl: remainingStoredTtlSeconds(stored)
1204
+ });
1205
+ }
1206
+ }
1207
+ return [...exported.values()];
1208
+ }
1209
+ async importState(entries) {
559
1210
  await this.startup;
560
- await this.unsubscribeInvalidation?.();
561
- await Promise.allSettled(this.backgroundRefreshes.values());
1211
+ await Promise.all(
1212
+ entries.map(async (entry) => {
1213
+ await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1214
+ await this.tagIndex.touch(entry.key);
1215
+ })
1216
+ );
1217
+ }
1218
+ async persistToFile(filePath) {
1219
+ const snapshot = await this.exportState();
1220
+ await import_node_fs.promises.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
1221
+ }
1222
+ async restoreFromFile(filePath) {
1223
+ const raw = await import_node_fs.promises.readFile(filePath, "utf8");
1224
+ let parsed;
1225
+ try {
1226
+ parsed = JSON.parse(raw, (_key, value) => {
1227
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1228
+ return Object.assign(/* @__PURE__ */ Object.create(null), value);
1229
+ }
1230
+ return value;
1231
+ });
1232
+ } catch (cause) {
1233
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
1234
+ }
1235
+ if (!this.isCacheSnapshotEntries(parsed)) {
1236
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1237
+ }
1238
+ await this.importState(parsed);
1239
+ }
1240
+ async disconnect() {
1241
+ if (!this.disconnectPromise) {
1242
+ this.isDisconnecting = true;
1243
+ this.disconnectPromise = (async () => {
1244
+ await this.startup;
1245
+ await this.unsubscribeInvalidation?.();
1246
+ await Promise.allSettled([...this.backgroundRefreshes.values()]);
1247
+ })();
1248
+ }
1249
+ await this.disconnectPromise;
562
1250
  }
563
1251
  async initialize() {
564
1252
  if (!this.options.invalidationBus) {
@@ -572,7 +1260,7 @@ var CacheStack = class {
572
1260
  const fetchTask = async () => {
573
1261
  const secondHit = await this.readFromLayers(key, options, "fresh-only");
574
1262
  if (secondHit.found) {
575
- this.metrics.hits += 1;
1263
+ this.metricsCollector.increment("hits");
576
1264
  return secondHit.value;
577
1265
  }
578
1266
  return this.fetchAndPopulate(key, fetcher, options);
@@ -597,11 +1285,12 @@ var CacheStack = class {
597
1285
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
598
1286
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
599
1287
  const deadline = Date.now() + timeoutMs;
600
- this.metrics.singleFlightWaits += 1;
1288
+ this.metricsCollector.increment("singleFlightWaits");
1289
+ this.emit("stampede-dedupe", { key });
601
1290
  while (Date.now() < deadline) {
602
1291
  const hit = await this.readFromLayers(key, options, "fresh-only");
603
1292
  if (hit.found) {
604
- this.metrics.hits += 1;
1293
+ this.metricsCollector.increment("hits");
605
1294
  return hit.value;
606
1295
  }
607
1296
  await this.sleep(pollIntervalMs);
@@ -609,8 +1298,18 @@ var CacheStack = class {
609
1298
  return this.fetchAndPopulate(key, fetcher, options);
610
1299
  }
611
1300
  async fetchAndPopulate(key, fetcher, options) {
612
- this.metrics.fetches += 1;
613
- const fetched = await fetcher();
1301
+ this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1302
+ this.metricsCollector.increment("fetches");
1303
+ const fetchStart = Date.now();
1304
+ let fetched;
1305
+ try {
1306
+ fetched = await fetcher();
1307
+ this.circuitBreakerManager.recordSuccess(key);
1308
+ this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1309
+ } catch (error) {
1310
+ this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1311
+ throw error;
1312
+ }
614
1313
  if (fetched === null || fetched === void 0) {
615
1314
  if (!this.shouldNegativeCache(options)) {
616
1315
  return null;
@@ -628,9 +1327,10 @@ var CacheStack = class {
628
1327
  } else {
629
1328
  await this.tagIndex.touch(key);
630
1329
  }
631
- this.metrics.sets += 1;
632
- this.logger.debug("set", { key, kind, tags: options?.tags });
633
- if (this.options.publishSetInvalidation !== false) {
1330
+ this.metricsCollector.increment("sets");
1331
+ this.logger.debug?.("set", { key, kind, tags: options?.tags });
1332
+ this.emit("set", { key, kind, tags: options?.tags });
1333
+ if (this.shouldBroadcastL1Invalidation()) {
634
1334
  await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
635
1335
  }
636
1336
  }
@@ -638,8 +1338,10 @@ var CacheStack = class {
638
1338
  let sawRetainableValue = false;
639
1339
  for (let index = 0; index < this.layers.length; index += 1) {
640
1340
  const layer = this.layers[index];
1341
+ if (!layer) continue;
641
1342
  const stored = await this.readLayerEntry(layer, key);
642
1343
  if (stored === null) {
1344
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
643
1345
  continue;
644
1346
  }
645
1347
  const resolved = resolveStoredValue(stored);
@@ -653,20 +1355,41 @@ var CacheStack = class {
653
1355
  }
654
1356
  await this.tagIndex.touch(key);
655
1357
  await this.backfill(key, stored, index - 1, options);
656
- this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
657
- return { found: true, value: resolved.value, stored, state: resolved.state };
1358
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
1359
+ this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
1360
+ this.emit("hit", { key, layer: layer.name, state: resolved.state });
1361
+ return {
1362
+ found: true,
1363
+ value: resolved.value,
1364
+ stored,
1365
+ state: resolved.state,
1366
+ layerIndex: index,
1367
+ layerName: layer.name
1368
+ };
658
1369
  }
659
1370
  if (!sawRetainableValue) {
660
1371
  await this.tagIndex.remove(key);
661
1372
  }
662
- this.logger.debug("miss", { key, mode });
1373
+ this.logger.debug?.("miss", { key, mode });
1374
+ this.emit("miss", { key, mode });
663
1375
  return { found: false, value: null, stored: null, state: "miss" };
664
1376
  }
665
1377
  async readLayerEntry(layer, key) {
1378
+ if (this.shouldSkipLayer(layer)) {
1379
+ return null;
1380
+ }
666
1381
  if (layer.getEntry) {
667
- return layer.getEntry(key);
1382
+ try {
1383
+ return await layer.getEntry(key);
1384
+ } catch (error) {
1385
+ return this.handleLayerFailure(layer, "read", error);
1386
+ }
1387
+ }
1388
+ try {
1389
+ return await layer.get(key);
1390
+ } catch (error) {
1391
+ return this.handleLayerFailure(layer, "read", error);
668
1392
  }
669
- return layer.get(key);
670
1393
  }
671
1394
  async backfill(key, stored, upToIndex, options) {
672
1395
  if (upToIndex < 0) {
@@ -674,26 +1397,34 @@ var CacheStack = class {
674
1397
  }
675
1398
  for (let index = 0; index <= upToIndex; index += 1) {
676
1399
  const layer = this.layers[index];
1400
+ if (!layer || this.shouldSkipLayer(layer)) {
1401
+ continue;
1402
+ }
677
1403
  const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
678
- await layer.set(key, stored, ttl);
679
- this.metrics.backfills += 1;
680
- this.logger.debug("backfill", { key, layer: layer.name });
1404
+ try {
1405
+ await layer.set(key, stored, ttl);
1406
+ } catch (error) {
1407
+ await this.handleLayerFailure(layer, "backfill", error);
1408
+ continue;
1409
+ }
1410
+ this.metricsCollector.increment("backfills");
1411
+ this.logger.debug?.("backfill", { key, layer: layer.name });
1412
+ this.emit("backfill", { key, layer: layer.name });
681
1413
  }
682
1414
  }
683
1415
  async writeAcrossLayers(key, kind, value, options) {
684
1416
  const now = Date.now();
685
1417
  const operations = this.layers.map((layer) => async () => {
686
- const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
1418
+ if (this.shouldSkipLayer(layer)) {
1419
+ return;
1420
+ }
1421
+ const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
687
1422
  const staleWhileRevalidate = this.resolveLayerSeconds(
688
1423
  layer.name,
689
1424
  options?.staleWhileRevalidate,
690
1425
  this.options.staleWhileRevalidate
691
1426
  );
692
- const staleIfError = this.resolveLayerSeconds(
693
- layer.name,
694
- options?.staleIfError,
695
- this.options.staleIfError
696
- );
1427
+ const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
697
1428
  const payload = createStoredValueEnvelope({
698
1429
  kind,
699
1430
  value,
@@ -703,7 +1434,11 @@ var CacheStack = class {
703
1434
  now
704
1435
  });
705
1436
  const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
706
- await layer.set(key, payload, ttl);
1437
+ try {
1438
+ await layer.set(key, payload, ttl);
1439
+ } catch (error) {
1440
+ await this.handleLayerFailure(layer, "write", error);
1441
+ }
707
1442
  });
708
1443
  await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
709
1444
  }
@@ -717,8 +1452,8 @@ var CacheStack = class {
717
1452
  if (failures.length === 0) {
718
1453
  return;
719
1454
  }
720
- this.metrics.writeFailures += failures.length;
721
- this.logger.debug("write-failure", {
1455
+ this.metricsCollector.increment("writeFailures", failures.length);
1456
+ this.logger.debug?.("write-failure", {
722
1457
  ...context,
723
1458
  failures: failures.map((failure) => this.formatError(failure.reason))
724
1459
  });
@@ -729,52 +1464,26 @@ var CacheStack = class {
729
1464
  );
730
1465
  }
731
1466
  }
732
- resolveFreshTtl(layerName, kind, options, fallbackTtl) {
733
- const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
734
- layerName,
735
- options?.negativeTtl,
736
- this.options.negativeTtl,
737
- this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
738
- ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
739
- const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
740
- return this.applyJitter(baseTtl, jitter);
1467
+ resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
1468
+ return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
741
1469
  }
742
1470
  resolveLayerSeconds(layerName, override, globalDefault, fallback) {
743
- if (override !== void 0) {
744
- return this.readLayerNumber(layerName, override) ?? fallback;
745
- }
746
- if (globalDefault !== void 0) {
747
- return this.readLayerNumber(layerName, globalDefault) ?? fallback;
748
- }
749
- return fallback;
750
- }
751
- readLayerNumber(layerName, value) {
752
- if (typeof value === "number") {
753
- return value;
754
- }
755
- return value[layerName];
756
- }
757
- applyJitter(ttl, jitter) {
758
- if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
759
- return ttl;
760
- }
761
- const delta = (Math.random() * 2 - 1) * jitter;
762
- return Math.max(1, Math.round(ttl + delta));
1471
+ return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
763
1472
  }
764
1473
  shouldNegativeCache(options) {
765
1474
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
766
1475
  }
767
1476
  scheduleBackgroundRefresh(key, fetcher, options) {
768
- if (this.backgroundRefreshes.has(key)) {
1477
+ if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
769
1478
  return;
770
1479
  }
771
1480
  const refresh = (async () => {
772
- this.metrics.refreshes += 1;
1481
+ this.metricsCollector.increment("refreshes");
773
1482
  try {
774
1483
  await this.fetchWithGuards(key, fetcher, options);
775
1484
  } catch (error) {
776
- this.metrics.refreshErrors += 1;
777
- this.logger.debug("refresh-error", { key, error: this.formatError(error) });
1485
+ this.metricsCollector.increment("refreshErrors");
1486
+ this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
778
1487
  } finally {
779
1488
  this.backgroundRefreshes.delete(key);
780
1489
  }
@@ -792,21 +1501,16 @@ var CacheStack = class {
792
1501
  if (keys.length === 0) {
793
1502
  return;
794
1503
  }
795
- await Promise.all(
796
- this.layers.map(async (layer) => {
797
- if (layer.deleteMany) {
798
- await layer.deleteMany(keys);
799
- return;
800
- }
801
- await Promise.all(keys.map((key) => layer.delete(key)));
802
- })
803
- );
1504
+ await this.deleteKeysFromLayers(this.layers, keys);
804
1505
  for (const key of keys) {
805
1506
  await this.tagIndex.remove(key);
1507
+ this.ttlResolver.deleteProfile(key);
1508
+ this.circuitBreakerManager.delete(key);
806
1509
  }
807
- this.metrics.deletes += keys.length;
808
- this.metrics.invalidations += 1;
809
- this.logger.debug("delete", { keys });
1510
+ this.metricsCollector.increment("deletes", keys.length);
1511
+ this.metricsCollector.increment("invalidations");
1512
+ this.logger.debug?.("delete", { keys });
1513
+ this.emit("delete", { keys });
810
1514
  }
811
1515
  async publishInvalidation(message) {
812
1516
  if (!this.options.invalidationBus) {
@@ -825,21 +1529,15 @@ var CacheStack = class {
825
1529
  if (message.scope === "clear") {
826
1530
  await Promise.all(localLayers.map((layer) => layer.clear()));
827
1531
  await this.tagIndex.clear();
1532
+ this.ttlResolver.clearProfiles();
828
1533
  return;
829
1534
  }
830
1535
  const keys = message.keys ?? [];
831
- await Promise.all(
832
- localLayers.map(async (layer) => {
833
- if (layer.deleteMany) {
834
- await layer.deleteMany(keys);
835
- return;
836
- }
837
- await Promise.all(keys.map((key) => layer.delete(key)));
838
- })
839
- );
1536
+ await this.deleteKeysFromLayers(localLayers, keys);
840
1537
  if (message.operation !== "write") {
841
1538
  for (const key of keys) {
842
1539
  await this.tagIndex.remove(key);
1540
+ this.ttlResolver.deleteProfile(key);
843
1541
  }
844
1542
  }
845
1543
  }
@@ -852,6 +1550,210 @@ var CacheStack = class {
852
1550
  sleep(ms) {
853
1551
  return new Promise((resolve) => setTimeout(resolve, ms));
854
1552
  }
1553
+ shouldBroadcastL1Invalidation() {
1554
+ return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
1555
+ }
1556
+ async deleteKeysFromLayers(layers, keys) {
1557
+ await Promise.all(
1558
+ layers.map(async (layer) => {
1559
+ if (this.shouldSkipLayer(layer)) {
1560
+ return;
1561
+ }
1562
+ if (layer.deleteMany) {
1563
+ try {
1564
+ await layer.deleteMany(keys);
1565
+ } catch (error) {
1566
+ await this.handleLayerFailure(layer, "delete", error);
1567
+ }
1568
+ return;
1569
+ }
1570
+ await Promise.all(
1571
+ keys.map(async (key) => {
1572
+ try {
1573
+ await layer.delete(key);
1574
+ } catch (error) {
1575
+ await this.handleLayerFailure(layer, "delete", error);
1576
+ }
1577
+ })
1578
+ );
1579
+ })
1580
+ );
1581
+ }
1582
+ validateConfiguration() {
1583
+ if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
1584
+ throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
1585
+ }
1586
+ if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
1587
+ throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
1588
+ }
1589
+ this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
1590
+ this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
1591
+ this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
1592
+ this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
1593
+ this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
1594
+ this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1595
+ this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1596
+ this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1597
+ this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1598
+ this.validateCircuitBreakerOptions(this.options.circuitBreaker);
1599
+ }
1600
+ validateWriteOptions(options) {
1601
+ if (!options) {
1602
+ return;
1603
+ }
1604
+ this.validateLayerNumberOption("options.ttl", options.ttl);
1605
+ this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
1606
+ this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
1607
+ this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
1608
+ this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
1609
+ this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
1610
+ this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1611
+ this.validateCircuitBreakerOptions(options.circuitBreaker);
1612
+ }
1613
+ validateLayerNumberOption(name, value) {
1614
+ if (value === void 0) {
1615
+ return;
1616
+ }
1617
+ if (typeof value === "number") {
1618
+ this.validateNonNegativeNumber(name, value);
1619
+ return;
1620
+ }
1621
+ for (const [layerName, layerValue] of Object.entries(value)) {
1622
+ if (layerValue === void 0) {
1623
+ continue;
1624
+ }
1625
+ this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
1626
+ }
1627
+ }
1628
+ validatePositiveNumber(name, value) {
1629
+ if (value === void 0) {
1630
+ return;
1631
+ }
1632
+ if (!Number.isFinite(value) || value <= 0) {
1633
+ throw new Error(`${name} must be a positive finite number.`);
1634
+ }
1635
+ }
1636
+ validateNonNegativeNumber(name, value) {
1637
+ if (!Number.isFinite(value) || value < 0) {
1638
+ throw new Error(`${name} must be a non-negative finite number.`);
1639
+ }
1640
+ }
1641
+ validateCacheKey(key) {
1642
+ if (key.length === 0) {
1643
+ throw new Error("Cache key must not be empty.");
1644
+ }
1645
+ if (key.length > MAX_CACHE_KEY_LENGTH) {
1646
+ throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
1647
+ }
1648
+ if (/[\u0000-\u001F\u007F]/.test(key)) {
1649
+ throw new Error("Cache key contains unsupported control characters.");
1650
+ }
1651
+ return key;
1652
+ }
1653
+ serializeOptions(options) {
1654
+ return JSON.stringify(this.normalizeForSerialization(options) ?? null);
1655
+ }
1656
+ validateAdaptiveTtlOptions(options) {
1657
+ if (!options || options === true) {
1658
+ return;
1659
+ }
1660
+ this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
1661
+ this.validateLayerNumberOption("adaptiveTtl.step", options.step);
1662
+ this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
1663
+ }
1664
+ validateCircuitBreakerOptions(options) {
1665
+ if (!options) {
1666
+ return;
1667
+ }
1668
+ this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
1669
+ this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
1670
+ }
1671
+ async applyFreshReadPolicies(key, hit, options, fetcher) {
1672
+ const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
1673
+ const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
1674
+ if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
1675
+ const refreshed = refreshStoredEnvelope(hit.stored);
1676
+ const ttl = remainingStoredTtlSeconds(refreshed);
1677
+ for (let index = 0; index <= hit.layerIndex; index += 1) {
1678
+ const layer = this.layers[index];
1679
+ if (!layer || this.shouldSkipLayer(layer)) {
1680
+ continue;
1681
+ }
1682
+ try {
1683
+ await layer.set(key, refreshed, ttl);
1684
+ } catch (error) {
1685
+ await this.handleLayerFailure(layer, "sliding-ttl", error);
1686
+ }
1687
+ }
1688
+ }
1689
+ if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
1690
+ this.scheduleBackgroundRefresh(key, fetcher, options);
1691
+ }
1692
+ }
1693
+ shouldSkipLayer(layer) {
1694
+ const degradedUntil = this.layerDegradedUntil.get(layer.name);
1695
+ return degradedUntil !== void 0 && degradedUntil > Date.now();
1696
+ }
1697
+ async handleLayerFailure(layer, operation, error) {
1698
+ if (!this.isGracefulDegradationEnabled()) {
1699
+ throw error;
1700
+ }
1701
+ const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1702
+ this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
1703
+ this.metricsCollector.increment("degradedOperations");
1704
+ this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
1705
+ this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
1706
+ return null;
1707
+ }
1708
+ isGracefulDegradationEnabled() {
1709
+ return Boolean(this.options.gracefulDegradation);
1710
+ }
1711
+ recordCircuitFailure(key, options, error) {
1712
+ if (!options) {
1713
+ return;
1714
+ }
1715
+ this.circuitBreakerManager.recordFailure(key, options);
1716
+ if (this.circuitBreakerManager.isOpen(key)) {
1717
+ this.metricsCollector.increment("circuitBreakerTrips");
1718
+ }
1719
+ this.emitError("fetch", { key, error: this.formatError(error) });
1720
+ }
1721
+ isNegativeStoredValue(stored) {
1722
+ return isStoredValueEnvelope(stored) && stored.kind === "empty";
1723
+ }
1724
+ emitError(operation, context) {
1725
+ this.logger.error?.(operation, context);
1726
+ if (this.listenerCount("error") > 0) {
1727
+ this.emit("error", { operation, ...context });
1728
+ }
1729
+ }
1730
+ serializeKeyPart(value) {
1731
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1732
+ return String(value);
1733
+ }
1734
+ return JSON.stringify(this.normalizeForSerialization(value));
1735
+ }
1736
+ isCacheSnapshotEntries(value) {
1737
+ return Array.isArray(value) && value.every((entry) => {
1738
+ if (!entry || typeof entry !== "object") {
1739
+ return false;
1740
+ }
1741
+ const candidate = entry;
1742
+ return typeof candidate.key === "string";
1743
+ });
1744
+ }
1745
+ normalizeForSerialization(value) {
1746
+ if (Array.isArray(value)) {
1747
+ return value.map((entry) => this.normalizeForSerialization(entry));
1748
+ }
1749
+ if (value && typeof value === "object") {
1750
+ return Object.keys(value).sort().reduce((normalized, key) => {
1751
+ normalized[key] = this.normalizeForSerialization(value[key]);
1752
+ return normalized;
1753
+ }, {});
1754
+ }
1755
+ return value;
1756
+ }
855
1757
  };
856
1758
 
857
1759
  // src/module.ts
@@ -878,5 +1780,6 @@ CacheStackModule = __decorateClass([
878
1780
  0 && (module.exports = {
879
1781
  CACHE_STACK,
880
1782
  CacheStackModule,
1783
+ Cacheable,
881
1784
  InjectCacheStack
882
1785
  });