layercache 2.1.0 → 3.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.
package/dist/index.js CHANGED
@@ -12,12 +12,13 @@ import {
12
12
  validateTag,
13
13
  validateTags,
14
14
  validateTtlPolicy
15
- } from "./chunk-6X7NV5BG.js";
15
+ } from "./chunk-L6L7QXYF.js";
16
16
  import {
17
17
  MemoryLayer,
18
18
  TagIndex,
19
- createHonoCacheMiddleware
20
- } from "./chunk-IVX6ABFX.js";
19
+ createHonoCacheMiddleware,
20
+ normalizeHttpCacheUrl
21
+ } from "./chunk-XMUT66SH.js";
21
22
  import {
22
23
  PatternMatcher,
23
24
  createStoredValueEnvelope,
@@ -70,39 +71,6 @@ function cloneNamespaceMetrics(metrics) {
70
71
  )
71
72
  };
72
73
  }
73
- function diffNamespaceMetrics(before, after) {
74
- const latencyByLayer = Object.fromEntries(
75
- Object.entries(after.latencyByLayer).map(([layer, value]) => [
76
- layer,
77
- {
78
- avgMs: value.avgMs,
79
- maxMs: value.maxMs,
80
- count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
81
- }
82
- ])
83
- );
84
- return {
85
- hits: after.hits - before.hits,
86
- misses: after.misses - before.misses,
87
- fetches: after.fetches - before.fetches,
88
- sets: after.sets - before.sets,
89
- deletes: after.deletes - before.deletes,
90
- backfills: after.backfills - before.backfills,
91
- invalidations: after.invalidations - before.invalidations,
92
- staleHits: after.staleHits - before.staleHits,
93
- refreshes: after.refreshes - before.refreshes,
94
- refreshErrors: after.refreshErrors - before.refreshErrors,
95
- writeFailures: after.writeFailures - before.writeFailures,
96
- singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
97
- negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
98
- circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
99
- degradedOperations: after.degradedOperations - before.degradedOperations,
100
- hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
101
- missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
102
- latencyByLayer,
103
- resetAt: after.resetAt
104
- };
105
- }
106
74
  function addNamespaceMetrics(base, delta) {
107
75
  return {
108
76
  hits: base.hits + delta.hits,
@@ -138,14 +106,6 @@ function computeNamespaceHitRate(metrics) {
138
106
  }
139
107
  return { overall, byLayer };
140
108
  }
141
- function diffMetricMap(before, after) {
142
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
143
- const result = {};
144
- for (const key of keys) {
145
- result[key] = (after[key] ?? 0) - (before[key] ?? 0);
146
- }
147
- return result;
148
- }
149
109
  function addMetricMap(base, delta) {
150
110
  const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
151
111
  const result = {};
@@ -182,6 +142,20 @@ var CacheNamespace = class _CacheNamespace {
182
142
  async getOrSet(key, fetcher, options) {
183
143
  return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
184
144
  }
145
+ /**
146
+ * Returns a namespaced cache entry, or `null` on miss.
147
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
148
+ */
149
+ async getEntry(key) {
150
+ const entry = await this.trackMetrics(() => this.cache.getEntry(this.qualify(key)));
151
+ if (entry === null) {
152
+ return null;
153
+ }
154
+ return {
155
+ ...entry,
156
+ key
157
+ };
158
+ }
185
159
  /**
186
160
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
187
161
  */
@@ -416,13 +390,24 @@ var CacheNamespace = class _CacheNamespace {
416
390
  };
417
391
  }
418
392
  async trackMetrics(operation) {
419
- return this.getMetricsMutex().runExclusive(async () => {
420
- const before = this.cache.getMetrics();
421
- const result = await operation();
422
- const after = this.cache.getMetrics();
423
- this.metrics = addNamespaceMetrics(this.metrics, diffNamespaceMetrics(before, after));
424
- return result;
393
+ let result;
394
+ let metrics;
395
+ try {
396
+ ;
397
+ ({ result, metrics } = await this.cache.captureMetrics(operation));
398
+ } catch (error) {
399
+ const capturedMetrics = error.metrics;
400
+ if (capturedMetrics) {
401
+ await this.getMetricsMutex().runExclusive(() => {
402
+ this.metrics = addNamespaceMetrics(this.metrics, capturedMetrics);
403
+ });
404
+ }
405
+ throw error;
406
+ }
407
+ await this.getMetricsMutex().runExclusive(() => {
408
+ this.metrics = addNamespaceMetrics(this.metrics, metrics);
425
409
  });
410
+ return result;
426
411
  }
427
412
  getMetricsMutex() {
428
413
  const existing = _CacheNamespace.metricsMutexes.get(this.cache);
@@ -913,7 +898,9 @@ var CacheStackMaintenance = class {
913
898
  }
914
899
  bumpKeyEpochs(keys) {
915
900
  for (const key of keys) {
916
- this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
901
+ const nextEpoch = this.currentKeyEpoch(key) + 1;
902
+ this.keyEpochs.delete(key);
903
+ this.keyEpochs.set(key, nextEpoch);
917
904
  }
918
905
  this.pruneKeyEpochsIfNeeded();
919
906
  }
@@ -972,10 +959,13 @@ var CacheStackMaintenance = class {
972
959
  if (this.keyEpochs.size <= MAX_KEY_EPOCHS) {
973
960
  return;
974
961
  }
975
- const sorted = [...this.keyEpochs.entries()].sort((a, b) => a[1] - b[1]);
976
- const toDelete = Math.ceil(sorted.length * 0.1);
962
+ const toDelete = Math.ceil(this.keyEpochs.size * 0.1);
977
963
  for (let i = 0; i < toDelete; i++) {
978
- this.keyEpochs.delete(sorted[i][0]);
964
+ const oldestKey = this.keyEpochs.keys().next().value;
965
+ if (oldestKey === void 0) {
966
+ break;
967
+ }
968
+ this.keyEpochs.delete(oldestKey);
979
969
  }
980
970
  }
981
971
  };
@@ -1021,6 +1011,9 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1021
1011
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1022
1012
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1023
1013
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1014
+ var SINGLE_FLIGHT_BACKOFF_FACTOR = 2;
1015
+ var SINGLE_FLIGHT_BACKOFF_JITTER = 0.2;
1016
+ var SINGLE_FLIGHT_MAX_POLL_MS = 1e3;
1024
1017
  var CacheStackReader = class {
1025
1018
  constructor(options) {
1026
1019
  this.options = options;
@@ -1109,22 +1102,28 @@ var CacheStackReader = class {
1109
1102
  if (upToIndex < 0) {
1110
1103
  return;
1111
1104
  }
1105
+ const operations = [];
1112
1106
  for (let index = 0; index <= upToIndex; index += 1) {
1113
1107
  const layer = this.options.layers[index];
1114
1108
  if (!layer || this.options.shouldSkipLayer(layer)) {
1115
1109
  continue;
1116
1110
  }
1117
1111
  const ttl = remainingStoredTtlMs(stored) ?? this.options.resolveLayerMs(layer.name, options?.ttl, void 0, layer.defaultTtl);
1118
- try {
1119
- await layer.set(key, stored, ttl);
1120
- } catch (error) {
1121
- await this.options.handleLayerFailure(layer, "backfill", error);
1122
- continue;
1123
- }
1124
- this.options.metricsCollector.increment("backfills");
1125
- this.options.logger.debug?.("backfill", { key, layer: layer.name });
1126
- this.options.emit("backfill", { key, layer: layer.name });
1112
+ operations.push(
1113
+ (async () => {
1114
+ try {
1115
+ await layer.set(key, stored, ttl);
1116
+ } catch (error) {
1117
+ await this.options.handleLayerFailure(layer, "backfill", error);
1118
+ return;
1119
+ }
1120
+ this.options.metricsCollector.increment("backfills");
1121
+ this.options.logger.debug?.("backfill", { key, layer: layer.name });
1122
+ this.options.emit("backfill", { key, layer: layer.name });
1123
+ })()
1124
+ );
1127
1125
  }
1126
+ await Promise.all(operations);
1128
1127
  }
1129
1128
  abortAllRefreshes() {
1130
1129
  for (const key of this.backgroundRefreshAbort.keys()) {
@@ -1238,6 +1237,7 @@ var CacheStackReader = class {
1238
1237
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1239
1238
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1240
1239
  const deadline = Date.now() + timeoutMs;
1240
+ let nextPollMs = pollIntervalMs;
1241
1241
  this.options.metricsCollector.increment("singleFlightWaits");
1242
1242
  this.options.emit("stampede-dedupe", { key });
1243
1243
  while (Date.now() < deadline) {
@@ -1246,16 +1246,36 @@ var CacheStackReader = class {
1246
1246
  this.options.metricsCollector.increment("hits");
1247
1247
  return hit.value;
1248
1248
  }
1249
- await this.options.sleep(pollIntervalMs);
1249
+ const remainingMs = deadline - Date.now();
1250
+ if (remainingMs <= 0) {
1251
+ break;
1252
+ }
1253
+ const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs);
1254
+ await this.options.sleep(delayMs);
1255
+ nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs);
1256
+ }
1257
+ if (!this.options.singleFlightCoordinator) {
1258
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
1250
1259
  }
1251
- return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
1260
+ return this.options.singleFlightCoordinator.execute(
1261
+ key,
1262
+ this.resolveSingleFlightOptions(),
1263
+ () => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
1264
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
1265
+ );
1266
+ }
1267
+ jitterSingleFlightPoll(delayMs) {
1268
+ const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER;
1269
+ return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2));
1252
1270
  }
1253
1271
  async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext = {
1254
1272
  key,
1255
1273
  currentValue: void 0,
1256
1274
  state: "miss"
1257
1275
  }) {
1258
- this.options.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1276
+ const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker;
1277
+ const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions);
1278
+ this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions);
1259
1279
  this.options.metricsCollector.increment("fetches");
1260
1280
  const fetchStart = Date.now();
1261
1281
  let fetched;
@@ -1265,13 +1285,13 @@ var CacheStackReader = class {
1265
1285
  { key, fetcher },
1266
1286
  () => fetcher(fetcherContext)
1267
1287
  );
1268
- this.options.circuitBreakerManager.recordSuccess(key);
1288
+ this.options.circuitBreakerManager.recordSuccess(breakerKey);
1269
1289
  this.options.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1270
1290
  } catch (error) {
1271
- this.options.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1291
+ this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
1272
1292
  throw error;
1273
1293
  }
1274
- if (fetched === null || fetched === void 0) {
1294
+ if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
1275
1295
  if (!this.shouldNegativeCache(options)) {
1276
1296
  return null;
1277
1297
  }
@@ -1313,6 +1333,18 @@ var CacheStackReader = class {
1313
1333
  await this.options.storeEntry(key, "value", fetched, options);
1314
1334
  return fetched;
1315
1335
  }
1336
+ resolveCircuitBreakerKey(key, options) {
1337
+ if (!options) {
1338
+ return `key:${key}`;
1339
+ }
1340
+ if (options.breakerKey) {
1341
+ return `custom:${options.breakerKey}`;
1342
+ }
1343
+ if (options.scope === "shared") {
1344
+ return "scope:shared";
1345
+ }
1346
+ return `key:${key}`;
1347
+ }
1316
1348
  runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
1317
1349
  this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
1318
1350
  }
@@ -1413,6 +1445,9 @@ var CacheStackReader = class {
1413
1445
  shouldNegativeCache(options) {
1414
1446
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
1415
1447
  }
1448
+ shouldCacheNullValues(options) {
1449
+ return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
1450
+ }
1416
1451
  isNegativeStoredValue(stored) {
1417
1452
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
1418
1453
  }
@@ -1814,6 +1849,7 @@ var CircuitBreakerManager = class {
1814
1849
  // src/internal/FetchRateLimiter.ts
1815
1850
  var MAX_BUCKETS = 1e4;
1816
1851
  var MAX_QUEUE_PER_BUCKET = 1e4;
1852
+ var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
1817
1853
  var FetchRateLimiter = class {
1818
1854
  buckets = /* @__PURE__ */ new Map();
1819
1855
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -1838,8 +1874,12 @@ var FetchRateLimiter = class {
1838
1874
  const bucketKey = this.resolveBucketKey(normalized, context);
1839
1875
  const queue = this.queuesByBucket.get(bucketKey) ?? [];
1840
1876
  if (queue.length >= MAX_QUEUE_PER_BUCKET) {
1841
- this.rateLimitBypasses += 1;
1842
- task().then(resolve2, reject);
1877
+ if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
1878
+ this.rateLimitBypasses += 1;
1879
+ task().then(resolve2, reject);
1880
+ return;
1881
+ }
1882
+ reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
1843
1883
  return;
1844
1884
  }
1845
1885
  queue.push({
@@ -1887,7 +1927,8 @@ var FetchRateLimiter = class {
1887
1927
  intervalMs,
1888
1928
  maxPerInterval,
1889
1929
  scope: options.scope ?? "global",
1890
- bucketKey: options.bucketKey
1930
+ bucketKey: options.bucketKey,
1931
+ queueOverflow: options.queueOverflow
1891
1932
  };
1892
1933
  }
1893
1934
  resolveBucketKey(options, context) {
@@ -2072,7 +2113,9 @@ var FetchRateLimiter = class {
2072
2113
  };
2073
2114
 
2074
2115
  // src/internal/MetricsCollector.ts
2116
+ import { AsyncLocalStorage } from "async_hooks";
2075
2117
  var MetricsCollector = class {
2118
+ captures = new AsyncLocalStorage();
2076
2119
  data = this.empty();
2077
2120
  get snapshot() {
2078
2121
  return {
@@ -2085,18 +2128,46 @@ var MetricsCollector = class {
2085
2128
  increment(field, amount = 1) {
2086
2129
  ;
2087
2130
  this.data[field] += amount;
2131
+ for (const capture of this.captures.getStore() ?? []) {
2132
+ ;
2133
+ capture[field] += amount;
2134
+ }
2088
2135
  }
2089
2136
  incrementLayer(map, layerName) {
2090
2137
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2138
+ for (const capture of this.captures.getStore() ?? []) {
2139
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2140
+ }
2091
2141
  }
2092
2142
  /**
2093
2143
  * Records a read latency sample for the given layer.
2094
2144
  * Maintains a rolling average and max using Welford's online algorithm.
2095
2145
  */
2096
2146
  recordLatency(layerName, durationMs) {
2097
- const existing = this.data.latencyByLayer[layerName];
2147
+ this.recordLatencySample(this.data, layerName, durationMs);
2148
+ for (const capture of this.captures.getStore() ?? []) {
2149
+ this.recordLatencySample(capture, layerName, durationMs);
2150
+ }
2151
+ }
2152
+ async capture(operation) {
2153
+ const metrics = this.empty();
2154
+ const activeCaptures = this.captures.getStore();
2155
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2156
+ try {
2157
+ const result = await this.captures.run(captures, operation);
2158
+ return { result, metrics };
2159
+ } catch (error) {
2160
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2161
+ ;
2162
+ error.metrics = metrics;
2163
+ }
2164
+ throw error;
2165
+ }
2166
+ }
2167
+ recordLatencySample(metrics, layerName, durationMs) {
2168
+ const existing = metrics.latencyByLayer[layerName];
2098
2169
  if (!existing) {
2099
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2170
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2100
2171
  return;
2101
2172
  }
2102
2173
  existing.count += 1;
@@ -2163,6 +2234,7 @@ var TtlResolver = class {
2163
2234
  const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
2164
2235
  profile.hits += 1;
2165
2236
  profile.lastAccessAt = Date.now();
2237
+ this.accessProfiles.delete(key);
2166
2238
  this.accessProfiles.set(key, profile);
2167
2239
  this.pruneIfNeeded();
2168
2240
  }
@@ -2252,12 +2324,12 @@ var TtlResolver = class {
2252
2324
  return;
2253
2325
  }
2254
2326
  const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
2255
- const sorted = [...this.accessProfiles.entries()].sort((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
2256
- for (let i = 0; i < toRemove && i < sorted.length; i++) {
2257
- const entry = sorted[i];
2258
- if (entry) {
2259
- this.accessProfiles.delete(entry[0]);
2327
+ for (let i = 0; i < toRemove; i++) {
2328
+ const oldestKey = this.accessProfiles.keys().next().value;
2329
+ if (oldestKey === void 0) {
2330
+ break;
2260
2331
  }
2332
+ this.accessProfiles.delete(oldestKey);
2261
2333
  }
2262
2334
  }
2263
2335
  };
@@ -2508,7 +2580,7 @@ var CacheStack = class extends EventEmitter {
2508
2580
  emitError: (operation, context) => this.emitError(operation, context),
2509
2581
  formatError: (error) => this.formatError(error),
2510
2582
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
2511
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
2583
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
2512
2584
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
2513
2585
  sleep: (ms) => this.sleep(ms),
2514
2586
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -2523,6 +2595,7 @@ var CacheStack = class extends EventEmitter {
2523
2595
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
2524
2596
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
2525
2597
  negativeCaching: options.negativeCaching,
2598
+ cacheNullValues: options.cacheNullValues,
2526
2599
  refreshAhead: options.refreshAhead,
2527
2600
  circuitBreaker: options.circuitBreaker,
2528
2601
  fetcherRateLimit: options.fetcherRateLimit
@@ -2575,6 +2648,64 @@ var CacheStack = class extends EventEmitter {
2575
2648
  async getOrSet(key, fetcher, options) {
2576
2649
  return this.get(key, fetcher, options);
2577
2650
  }
2651
+ /**
2652
+ * Returns a discriminated cache entry, or `null` on miss.
2653
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
2654
+ */
2655
+ async getEntry(key) {
2656
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
2657
+ const userKey = validateCacheKey(key);
2658
+ const normalizedKey = this.qualifyKey(userKey);
2659
+ await this.awaitStartup("getEntry");
2660
+ let sawRetainableValue = false;
2661
+ for (let index = 0; index < this.layers.length; index += 1) {
2662
+ const layer = this.layers[index];
2663
+ if (!layer || this.shouldSkipLayer(layer)) {
2664
+ continue;
2665
+ }
2666
+ const readStart = performance.now();
2667
+ const stored = await this.readLayerEntry(layer, normalizedKey);
2668
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
2669
+ if (stored === null) {
2670
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
2671
+ continue;
2672
+ }
2673
+ const resolved = resolveStoredValue(stored);
2674
+ if (resolved.state === "expired") {
2675
+ await layer.delete(normalizedKey);
2676
+ continue;
2677
+ }
2678
+ sawRetainableValue = true;
2679
+ await this.tagIndex.touch(normalizedKey);
2680
+ await this.reader.backfill(normalizedKey, stored, index - 1);
2681
+ this.metricsCollector.increment("hits");
2682
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2683
+ this.metricsCollector.increment("staleHits");
2684
+ }
2685
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
2686
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
2687
+ this.emit("hit", {
2688
+ key: normalizedKey,
2689
+ layer: layer.name,
2690
+ state: resolved.state
2691
+ });
2692
+ return {
2693
+ key: userKey,
2694
+ value: resolved.value,
2695
+ kind: resolved.envelope?.kind ?? "value",
2696
+ state: resolved.state,
2697
+ layer: layer.name
2698
+ };
2699
+ }
2700
+ if (!sawRetainableValue) {
2701
+ await this.tagIndex.remove(normalizedKey);
2702
+ }
2703
+ this.metricsCollector.increment("misses");
2704
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
2705
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
2706
+ return null;
2707
+ });
2708
+ }
2578
2709
  /**
2579
2710
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
2580
2711
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3039,6 +3170,13 @@ var CacheStack = class extends EventEmitter {
3039
3170
  getMetrics() {
3040
3171
  return this.metricsCollector.snapshot;
3041
3172
  }
3173
+ /**
3174
+ * Runs an operation while collecting only the metrics emitted by its async context.
3175
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3176
+ */
3177
+ async captureMetrics(operation) {
3178
+ return this.metricsCollector.capture(operation);
3179
+ }
3042
3180
  /**
3043
3181
  * Returns metrics plus layer degradation state and active background refresh count.
3044
3182
  */
@@ -3112,6 +3250,12 @@ var CacheStack = class extends EventEmitter {
3112
3250
  }
3113
3251
  return this.currentGeneration;
3114
3252
  }
3253
+ /**
3254
+ * Returns the active generation prefix number used for future cache keys.
3255
+ */
3256
+ getGeneration() {
3257
+ return this.currentGeneration;
3258
+ }
3115
3259
  /**
3116
3260
  * Returns detailed metadata about a single cache key: which layers contain it,
3117
3261
  * remaining fresh/stale/error TTLs, and associated tags.
@@ -3336,7 +3480,7 @@ var CacheStack = class extends EventEmitter {
3336
3480
  for (const key of keys) {
3337
3481
  await this.tagIndex.remove(key);
3338
3482
  this.ttlResolver.deleteProfile(key);
3339
- this.circuitBreakerManager.delete(key);
3483
+ this.circuitBreakerManager.delete(`key:${key}`);
3340
3484
  }
3341
3485
  this.metricsCollector.increment("deletes", keys.length);
3342
3486
  this.metricsCollector.increment("invalidations");
@@ -3355,7 +3499,7 @@ var CacheStack = class extends EventEmitter {
3355
3499
  }
3356
3500
  await this.tagIndex.remove(key);
3357
3501
  this.ttlResolver.deleteProfile(key);
3358
- this.circuitBreakerManager.delete(key);
3502
+ this.circuitBreakerManager.delete(`key:${key}`);
3359
3503
  }
3360
3504
  this.metricsCollector.increment("invalidations");
3361
3505
  this.logger.debug?.("expire", { keys });
@@ -3397,7 +3541,7 @@ var CacheStack = class extends EventEmitter {
3397
3541
  for (const key of keys) {
3398
3542
  await this.tagIndex.remove(key);
3399
3543
  this.ttlResolver.deleteProfile(key);
3400
- this.circuitBreakerManager.delete(key);
3544
+ this.circuitBreakerManager.delete(`key:${key}`);
3401
3545
  }
3402
3546
  }
3403
3547
  }
@@ -3642,15 +3786,15 @@ var CacheStack = class extends EventEmitter {
3642
3786
  isGracefulDegradationEnabled() {
3643
3787
  return Boolean(this.options.gracefulDegradation);
3644
3788
  }
3645
- recordCircuitFailure(key, options, error) {
3789
+ recordCircuitFailure(key, breakerKey, options, error) {
3646
3790
  if (!options) {
3647
3791
  return;
3648
3792
  }
3649
- this.circuitBreakerManager.recordFailure(key, options);
3650
- if (this.circuitBreakerManager.isOpen(key)) {
3793
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
3794
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
3651
3795
  this.metricsCollector.increment("circuitBreakerTrips");
3652
3796
  }
3653
- this.emitError("fetch", { key, error: this.formatError(error) });
3797
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
3654
3798
  }
3655
3799
  emitError(operation, context) {
3656
3800
  this.logger.error?.(operation, context);
@@ -3669,12 +3813,65 @@ var CacheStack = class extends EventEmitter {
3669
3813
  }
3670
3814
  };
3671
3815
 
3816
+ // src/generation/RedisGenerationStore.ts
3817
+ var DEFAULT_GENERATION_KEY = "layercache:generation";
3818
+ var RedisGenerationStore = class {
3819
+ client;
3820
+ key;
3821
+ constructor(options) {
3822
+ this.client = options.client;
3823
+ this.key = options.key ?? DEFAULT_GENERATION_KEY;
3824
+ }
3825
+ async get() {
3826
+ const stored = await this.client.get(this.key);
3827
+ if (stored === null) {
3828
+ return void 0;
3829
+ }
3830
+ return this.parseGeneration(stored);
3831
+ }
3832
+ async getOrInitialize(initialGeneration = 0) {
3833
+ this.assertGeneration(initialGeneration);
3834
+ await this.client.set(this.key, String(initialGeneration), "NX");
3835
+ const generation = await this.get();
3836
+ if (generation === void 0) {
3837
+ throw new Error(`RedisGenerationStore failed to initialize generation key "${this.key}".`);
3838
+ }
3839
+ return generation;
3840
+ }
3841
+ async set(generation) {
3842
+ this.assertGeneration(generation);
3843
+ await this.client.set(this.key, String(generation));
3844
+ }
3845
+ async bump() {
3846
+ const generation = await this.client.incr(this.key);
3847
+ this.assertGeneration(generation);
3848
+ return generation;
3849
+ }
3850
+ parseGeneration(value) {
3851
+ const generation = Number.parseInt(value, 10);
3852
+ if (String(generation) !== value || !this.isGeneration(generation)) {
3853
+ throw new Error(`RedisGenerationStore found invalid persisted generation value for key "${this.key}".`);
3854
+ }
3855
+ return generation;
3856
+ }
3857
+ assertGeneration(value) {
3858
+ if (!this.isGeneration(value)) {
3859
+ throw new Error("RedisGenerationStore generation must be a non-negative safe integer.");
3860
+ }
3861
+ }
3862
+ isGeneration(value) {
3863
+ return Number.isSafeInteger(value) && value >= 0;
3864
+ }
3865
+ };
3866
+
3672
3867
  // src/invalidation/RedisInvalidationBus.ts
3868
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
3673
3869
  var RedisInvalidationBus = class {
3674
3870
  channel;
3675
3871
  publisher;
3676
3872
  subscriber;
3677
3873
  logger;
3874
+ signingKey;
3678
3875
  handlers = /* @__PURE__ */ new Set();
3679
3876
  sharedListener;
3680
3877
  subscribePromise;
@@ -3683,6 +3880,7 @@ var RedisInvalidationBus = class {
3683
3880
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
3684
3881
  this.channel = options.channel ?? "layercache:invalidation";
3685
3882
  this.logger = options.logger;
3883
+ this.signingKey = options.signingSecret ? normalizeSigningSecret(options.signingSecret) : void 0;
3686
3884
  }
3687
3885
  /**
3688
3886
  * Subscribes to invalidation messages and returns an unsubscribe function.
@@ -3722,7 +3920,7 @@ var RedisInvalidationBus = class {
3722
3920
  * Publishes an invalidation message to other subscribers.
3723
3921
  */
3724
3922
  async publish(message) {
3725
- await this.publisher.publish(this.channel, JSON.stringify(message));
3923
+ await this.publisher.publish(this.channel, JSON.stringify(this.signingKey ? this.signMessage(message) : message));
3726
3924
  }
3727
3925
  async dispatchToHandlers(payload) {
3728
3926
  let message;
@@ -3733,10 +3931,11 @@ var RedisInvalidationBus = class {
3733
3931
  maxNodes: 1e4,
3734
3932
  createObject: () => /* @__PURE__ */ Object.create(null)
3735
3933
  });
3736
- if (!this.isInvalidationMessage(parsed)) {
3934
+ const candidate = this.signingKey ? this.verifySignedEnvelope(parsed) : parsed;
3935
+ if (!this.isInvalidationMessage(candidate)) {
3737
3936
  throw new Error("Invalid invalidation payload shape.");
3738
3937
  }
3739
- message = parsed;
3938
+ message = candidate;
3740
3939
  } catch (error) {
3741
3940
  this.reportError("invalid invalidation payload", error);
3742
3941
  return;
@@ -3761,6 +3960,34 @@ var RedisInvalidationBus = class {
3761
3960
  const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
3762
3961
  return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
3763
3962
  }
3963
+ signMessage(message) {
3964
+ const payload = JSON.stringify(message);
3965
+ return {
3966
+ payload: message,
3967
+ signature: this.createSignature(payload)
3968
+ };
3969
+ }
3970
+ verifySignedEnvelope(value) {
3971
+ if (!value || typeof value !== "object") {
3972
+ throw new Error("Signed invalidation envelope must be an object.");
3973
+ }
3974
+ const envelope = value;
3975
+ if (!envelope.payload || typeof envelope.payload !== "object" || typeof envelope.signature !== "string") {
3976
+ throw new Error("Signed invalidation envelope is missing payload or signature.");
3977
+ }
3978
+ const payload = JSON.stringify(envelope.payload);
3979
+ const expected = this.createSignature(payload);
3980
+ if (!isEqualSignature(envelope.signature, expected)) {
3981
+ throw new Error("Invalid invalidation message signature.");
3982
+ }
3983
+ return envelope.payload;
3984
+ }
3985
+ createSignature(payload) {
3986
+ if (!this.signingKey) {
3987
+ throw new Error("RedisInvalidationBus signing key is not configured.");
3988
+ }
3989
+ return createHmac("sha256", this.signingKey).update(payload).digest("hex");
3990
+ }
3764
3991
  reportError(message, error) {
3765
3992
  if (this.logger?.error) {
3766
3993
  this.logger.error(message, { error });
@@ -3769,6 +3996,15 @@ var RedisInvalidationBus = class {
3769
3996
  console.error(`[layercache] ${message}`, error);
3770
3997
  }
3771
3998
  };
3999
+ function normalizeSigningSecret(secret) {
4000
+ const raw = Buffer.isBuffer(secret) ? secret : Buffer.from(secret, "utf8");
4001
+ return createHash("sha256").update(raw).digest();
4002
+ }
4003
+ function isEqualSignature(actual, expected) {
4004
+ const actualBuffer = Buffer.from(actual, "hex");
4005
+ const expectedBuffer = Buffer.from(expected, "hex");
4006
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
4007
+ }
3772
4008
 
3773
4009
  // src/http/createCacheStatsHandler.ts
3774
4010
  function createCacheStatsHandler(cache, options = {}) {
@@ -3866,7 +4102,7 @@ function createExpressCacheMiddleware(cache, options = {}) {
3866
4102
  return;
3867
4103
  }
3868
4104
  const rawUrl = req.originalUrl ?? req.url ?? "/";
3869
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
4105
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeHttpCacheUrl(rawUrl)}`;
3870
4106
  const cached = await cache.get(key, void 0, options);
3871
4107
  if (cached !== null) {
3872
4108
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -3882,12 +4118,14 @@ function createExpressCacheMiddleware(cache, options = {}) {
3882
4118
  if (originalJson) {
3883
4119
  res.json = (body) => {
3884
4120
  res.setHeader?.("x-cache", "MISS");
3885
- cache.set(key, body, options).catch((err) => {
3886
- cache.emit("error", {
3887
- operation: "set",
3888
- error: err instanceof Error ? err.message : String(err)
4121
+ if (isSuccessfulStatus(res.statusCode)) {
4122
+ cache.set(key, body, options).catch((err) => {
4123
+ cache.emit("error", {
4124
+ operation: "set",
4125
+ error: err instanceof Error ? err.message : String(err)
4126
+ });
3889
4127
  });
3890
- });
4128
+ }
3891
4129
  return originalJson(body);
3892
4130
  };
3893
4131
  }
@@ -3897,14 +4135,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
3897
4135
  }
3898
4136
  };
3899
4137
  }
3900
- function normalizeUrl(url) {
3901
- try {
3902
- const parsed = new URL(url, "http://localhost");
3903
- parsed.searchParams.sort();
3904
- return parsed.pathname + parsed.search;
3905
- } catch {
3906
- return url;
3907
- }
4138
+ function isSuccessfulStatus(statusCode) {
4139
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
3908
4140
  }
3909
4141
 
3910
4142
  // src/integrations/graphql.ts
@@ -4471,12 +4703,12 @@ var RedisLayer = class {
4471
4703
  };
4472
4704
 
4473
4705
  // src/layers/DiskLayer.ts
4474
- import { createHash as createHash2, randomBytes as randomBytes4 } from "crypto";
4706
+ import { createHash as createHash3, randomBytes as randomBytes4 } from "crypto";
4475
4707
  import { promises as fs2 } from "fs";
4476
4708
  import { join, resolve } from "path";
4477
4709
 
4478
4710
  // src/internal/PayloadProtection.ts
4479
- import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes3, timingSafeEqual } from "crypto";
4711
+ import { createCipheriv, createDecipheriv, createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual2 } from "crypto";
4480
4712
  var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
4481
4713
  var MAGIC_SIGNED = Buffer.from("LCS1:");
4482
4714
  var ALGORITHM = "aes-256-gcm";
@@ -4489,11 +4721,11 @@ var PayloadProtection = class {
4489
4721
  constructor(options) {
4490
4722
  if (options.encryptionKey) {
4491
4723
  const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
4492
- this.encryptionKey = createHash("sha256").update(raw).digest();
4724
+ this.encryptionKey = createHash2("sha256").update(raw).digest();
4493
4725
  }
4494
4726
  if (options.signingKey && !options.encryptionKey) {
4495
4727
  const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
4496
- this.signingKey = createHash("sha256").update(raw).digest();
4728
+ this.signingKey = createHash2("sha256").update(raw).digest();
4497
4729
  }
4498
4730
  }
4499
4731
  /** Returns `true` when any protection (encryption or signing) is configured. */
@@ -4565,15 +4797,15 @@ var PayloadProtection = class {
4565
4797
  }
4566
4798
  // ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
4567
4799
  sign(payload, key) {
4568
- const hmac = createHmac("sha256", key).update(payload).digest();
4800
+ const hmac = createHmac2("sha256", key).update(payload).digest();
4569
4801
  return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
4570
4802
  }
4571
4803
  verify(payload, key) {
4572
4804
  const headerEnd = MAGIC_SIGNED.length;
4573
4805
  const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
4574
4806
  const data = payload.subarray(headerEnd + HMAC_LENGTH);
4575
- const expectedHmac = createHmac("sha256", key).update(data).digest();
4576
- if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual(receivedHmac, expectedHmac)) {
4807
+ const expectedHmac = createHmac2("sha256", key).update(data).digest();
4808
+ if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual2(receivedHmac, expectedHmac)) {
4577
4809
  throw new PayloadProtectionError(
4578
4810
  "HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
4579
4811
  );
@@ -4597,6 +4829,7 @@ var PayloadProtectionError = class extends Error {
4597
4829
 
4598
4830
  // src/layers/DiskLayer.ts
4599
4831
  var FILE_SCAN_CONCURRENCY = 32;
4832
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
4600
4833
  var DiskLayer = class {
4601
4834
  name;
4602
4835
  defaultTtl;
@@ -4605,8 +4838,10 @@ var DiskLayer = class {
4605
4838
  serializer;
4606
4839
  maxFiles;
4607
4840
  maxEntryBytes;
4841
+ maxWriteQueueDepth;
4608
4842
  protection;
4609
4843
  writeQueue = Promise.resolve();
4844
+ writeQueueDepth = 0;
4610
4845
  /**
4611
4846
  * Creates a disk-backed cache layer.
4612
4847
  */
@@ -4617,6 +4852,7 @@ var DiskLayer = class {
4617
4852
  this.serializer = options.serializer ?? new JsonSerializer();
4618
4853
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
4619
4854
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
4855
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
4620
4856
  this.protection = new PayloadProtection({
4621
4857
  encryptionKey: options.encryptionKey,
4622
4858
  signingKey: options.signingKey
@@ -4798,7 +5034,7 @@ var DiskLayer = class {
4798
5034
  async dispose() {
4799
5035
  }
4800
5036
  keyToPath(key) {
4801
- const hash = createHash2("sha256").update(key).digest("hex");
5037
+ const hash = createHash3("sha256").update(key).digest("hex");
4802
5038
  return join(this.directory, `${hash}.lc`);
4803
5039
  }
4804
5040
  resolveDirectory(directory) {
@@ -4832,6 +5068,16 @@ var DiskLayer = class {
4832
5068
  }
4833
5069
  return normalized;
4834
5070
  }
5071
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
5072
+ if (maxWriteQueueDepth === false) {
5073
+ return false;
5074
+ }
5075
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
5076
+ if (!Number.isInteger(normalized) || normalized <= 0) {
5077
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
5078
+ }
5079
+ return normalized;
5080
+ }
4835
5081
  async readEntryFile(filePath) {
4836
5082
  let handle;
4837
5083
  try {
@@ -4944,7 +5190,13 @@ var DiskLayer = class {
4944
5190
  }
4945
5191
  }
4946
5192
  enqueueWrite(operation) {
4947
- const next = this.writeQueue.then(operation, operation);
5193
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
5194
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
5195
+ }
5196
+ this.writeQueueDepth += 1;
5197
+ const next = this.writeQueue.then(operation, operation).finally(() => {
5198
+ this.writeQueueDepth -= 1;
5199
+ });
4948
5200
  this.writeQueue = next.catch(() => void 0);
4949
5201
  return next;
4950
5202
  }
@@ -5297,6 +5549,7 @@ export {
5297
5549
  MemoryLayer,
5298
5550
  MsgpackSerializer,
5299
5551
  PatternMatcher,
5552
+ RedisGenerationStore,
5300
5553
  RedisInvalidationBus,
5301
5554
  RedisLayer,
5302
5555
  RedisSingleFlightCoordinator,