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