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.cjs CHANGED
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  MemoryLayer: () => MemoryLayer,
40
40
  MsgpackSerializer: () => MsgpackSerializer,
41
41
  PatternMatcher: () => PatternMatcher,
42
+ RedisGenerationStore: () => RedisGenerationStore,
42
43
  RedisInvalidationBus: () => RedisInvalidationBus,
43
44
  RedisLayer: () => RedisLayer,
44
45
  RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
@@ -97,39 +98,6 @@ function cloneNamespaceMetrics(metrics) {
97
98
  )
98
99
  };
99
100
  }
100
- function diffNamespaceMetrics(before, after) {
101
- const latencyByLayer = Object.fromEntries(
102
- Object.entries(after.latencyByLayer).map(([layer, value]) => [
103
- layer,
104
- {
105
- avgMs: value.avgMs,
106
- maxMs: value.maxMs,
107
- count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
108
- }
109
- ])
110
- );
111
- return {
112
- hits: after.hits - before.hits,
113
- misses: after.misses - before.misses,
114
- fetches: after.fetches - before.fetches,
115
- sets: after.sets - before.sets,
116
- deletes: after.deletes - before.deletes,
117
- backfills: after.backfills - before.backfills,
118
- invalidations: after.invalidations - before.invalidations,
119
- staleHits: after.staleHits - before.staleHits,
120
- refreshes: after.refreshes - before.refreshes,
121
- refreshErrors: after.refreshErrors - before.refreshErrors,
122
- writeFailures: after.writeFailures - before.writeFailures,
123
- singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
124
- negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
125
- circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
126
- degradedOperations: after.degradedOperations - before.degradedOperations,
127
- hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
128
- missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
129
- latencyByLayer,
130
- resetAt: after.resetAt
131
- };
132
- }
133
101
  function addNamespaceMetrics(base, delta) {
134
102
  return {
135
103
  hits: base.hits + delta.hits,
@@ -165,14 +133,6 @@ function computeNamespaceHitRate(metrics) {
165
133
  }
166
134
  return { overall, byLayer };
167
135
  }
168
- function diffMetricMap(before, after) {
169
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
170
- const result = {};
171
- for (const key of keys) {
172
- result[key] = (after[key] ?? 0) - (before[key] ?? 0);
173
- }
174
- return result;
175
- }
176
136
  function addMetricMap(base, delta) {
177
137
  const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
178
138
  const result = {};
@@ -209,6 +169,20 @@ var CacheNamespace = class _CacheNamespace {
209
169
  async getOrSet(key, fetcher, options) {
210
170
  return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
211
171
  }
172
+ /**
173
+ * Returns a namespaced cache entry, or `null` on miss.
174
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
175
+ */
176
+ async getEntry(key) {
177
+ const entry = await this.trackMetrics(() => this.cache.getEntry(this.qualify(key)));
178
+ if (entry === null) {
179
+ return null;
180
+ }
181
+ return {
182
+ ...entry,
183
+ key
184
+ };
185
+ }
212
186
  /**
213
187
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
214
188
  */
@@ -443,13 +417,24 @@ var CacheNamespace = class _CacheNamespace {
443
417
  };
444
418
  }
445
419
  async trackMetrics(operation) {
446
- return this.getMetricsMutex().runExclusive(async () => {
447
- const before = this.cache.getMetrics();
448
- const result = await operation();
449
- const after = this.cache.getMetrics();
450
- this.metrics = addNamespaceMetrics(this.metrics, diffNamespaceMetrics(before, after));
451
- return result;
420
+ let result;
421
+ let metrics;
422
+ try {
423
+ ;
424
+ ({ result, metrics } = await this.cache.captureMetrics(operation));
425
+ } catch (error) {
426
+ const capturedMetrics = error.metrics;
427
+ if (capturedMetrics) {
428
+ await this.getMetricsMutex().runExclusive(() => {
429
+ this.metrics = addNamespaceMetrics(this.metrics, capturedMetrics);
430
+ });
431
+ }
432
+ throw error;
433
+ }
434
+ await this.getMetricsMutex().runExclusive(() => {
435
+ this.metrics = addNamespaceMetrics(this.metrics, metrics);
452
436
  });
437
+ return result;
453
438
  }
454
439
  getMetricsMutex() {
455
440
  const existing = _CacheNamespace.metricsMutexes.get(this.cache);
@@ -1157,7 +1142,9 @@ var CacheStackMaintenance = class {
1157
1142
  }
1158
1143
  bumpKeyEpochs(keys) {
1159
1144
  for (const key of keys) {
1160
- this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
1145
+ const nextEpoch = this.currentKeyEpoch(key) + 1;
1146
+ this.keyEpochs.delete(key);
1147
+ this.keyEpochs.set(key, nextEpoch);
1161
1148
  }
1162
1149
  this.pruneKeyEpochsIfNeeded();
1163
1150
  }
@@ -1216,10 +1203,13 @@ var CacheStackMaintenance = class {
1216
1203
  if (this.keyEpochs.size <= MAX_KEY_EPOCHS) {
1217
1204
  return;
1218
1205
  }
1219
- const sorted = [...this.keyEpochs.entries()].sort((a, b) => a[1] - b[1]);
1220
- const toDelete = Math.ceil(sorted.length * 0.1);
1206
+ const toDelete = Math.ceil(this.keyEpochs.size * 0.1);
1221
1207
  for (let i = 0; i < toDelete; i++) {
1222
- this.keyEpochs.delete(sorted[i][0]);
1208
+ const oldestKey = this.keyEpochs.keys().next().value;
1209
+ if (oldestKey === void 0) {
1210
+ break;
1211
+ }
1212
+ this.keyEpochs.delete(oldestKey);
1223
1213
  }
1224
1214
  }
1225
1215
  };
@@ -1265,6 +1255,9 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1265
1255
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1266
1256
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1267
1257
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1258
+ var SINGLE_FLIGHT_BACKOFF_FACTOR = 2;
1259
+ var SINGLE_FLIGHT_BACKOFF_JITTER = 0.2;
1260
+ var SINGLE_FLIGHT_MAX_POLL_MS = 1e3;
1268
1261
  var CacheStackReader = class {
1269
1262
  constructor(options) {
1270
1263
  this.options = options;
@@ -1353,22 +1346,28 @@ var CacheStackReader = class {
1353
1346
  if (upToIndex < 0) {
1354
1347
  return;
1355
1348
  }
1349
+ const operations = [];
1356
1350
  for (let index = 0; index <= upToIndex; index += 1) {
1357
1351
  const layer = this.options.layers[index];
1358
1352
  if (!layer || this.options.shouldSkipLayer(layer)) {
1359
1353
  continue;
1360
1354
  }
1361
1355
  const ttl = remainingStoredTtlMs(stored) ?? this.options.resolveLayerMs(layer.name, options?.ttl, void 0, layer.defaultTtl);
1362
- try {
1363
- await layer.set(key, stored, ttl);
1364
- } catch (error) {
1365
- await this.options.handleLayerFailure(layer, "backfill", error);
1366
- continue;
1367
- }
1368
- this.options.metricsCollector.increment("backfills");
1369
- this.options.logger.debug?.("backfill", { key, layer: layer.name });
1370
- this.options.emit("backfill", { key, layer: layer.name });
1356
+ operations.push(
1357
+ (async () => {
1358
+ try {
1359
+ await layer.set(key, stored, ttl);
1360
+ } catch (error) {
1361
+ await this.options.handleLayerFailure(layer, "backfill", error);
1362
+ return;
1363
+ }
1364
+ this.options.metricsCollector.increment("backfills");
1365
+ this.options.logger.debug?.("backfill", { key, layer: layer.name });
1366
+ this.options.emit("backfill", { key, layer: layer.name });
1367
+ })()
1368
+ );
1371
1369
  }
1370
+ await Promise.all(operations);
1372
1371
  }
1373
1372
  abortAllRefreshes() {
1374
1373
  for (const key of this.backgroundRefreshAbort.keys()) {
@@ -1482,6 +1481,7 @@ var CacheStackReader = class {
1482
1481
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1483
1482
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1484
1483
  const deadline = Date.now() + timeoutMs;
1484
+ let nextPollMs = pollIntervalMs;
1485
1485
  this.options.metricsCollector.increment("singleFlightWaits");
1486
1486
  this.options.emit("stampede-dedupe", { key });
1487
1487
  while (Date.now() < deadline) {
@@ -1490,16 +1490,36 @@ var CacheStackReader = class {
1490
1490
  this.options.metricsCollector.increment("hits");
1491
1491
  return hit.value;
1492
1492
  }
1493
- await this.options.sleep(pollIntervalMs);
1493
+ const remainingMs = deadline - Date.now();
1494
+ if (remainingMs <= 0) {
1495
+ break;
1496
+ }
1497
+ const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs);
1498
+ await this.options.sleep(delayMs);
1499
+ nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs);
1500
+ }
1501
+ if (!this.options.singleFlightCoordinator) {
1502
+ return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
1494
1503
  }
1495
- return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
1504
+ return this.options.singleFlightCoordinator.execute(
1505
+ key,
1506
+ this.resolveSingleFlightOptions(),
1507
+ () => this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext),
1508
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
1509
+ );
1510
+ }
1511
+ jitterSingleFlightPoll(delayMs) {
1512
+ const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER;
1513
+ return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2));
1496
1514
  }
1497
1515
  async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext = {
1498
1516
  key,
1499
1517
  currentValue: void 0,
1500
1518
  state: "miss"
1501
1519
  }) {
1502
- this.options.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1520
+ const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker;
1521
+ const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions);
1522
+ this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions);
1503
1523
  this.options.metricsCollector.increment("fetches");
1504
1524
  const fetchStart = Date.now();
1505
1525
  let fetched;
@@ -1509,13 +1529,13 @@ var CacheStackReader = class {
1509
1529
  { key, fetcher },
1510
1530
  () => fetcher(fetcherContext)
1511
1531
  );
1512
- this.options.circuitBreakerManager.recordSuccess(key);
1532
+ this.options.circuitBreakerManager.recordSuccess(breakerKey);
1513
1533
  this.options.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1514
1534
  } catch (error) {
1515
- this.options.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1535
+ this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
1516
1536
  throw error;
1517
1537
  }
1518
- if (fetched === null || fetched === void 0) {
1538
+ if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
1519
1539
  if (!this.shouldNegativeCache(options)) {
1520
1540
  return null;
1521
1541
  }
@@ -1557,6 +1577,18 @@ var CacheStackReader = class {
1557
1577
  await this.options.storeEntry(key, "value", fetched, options);
1558
1578
  return fetched;
1559
1579
  }
1580
+ resolveCircuitBreakerKey(key, options) {
1581
+ if (!options) {
1582
+ return `key:${key}`;
1583
+ }
1584
+ if (options.breakerKey) {
1585
+ return `custom:${options.breakerKey}`;
1586
+ }
1587
+ if (options.scope === "shared") {
1588
+ return "scope:shared";
1589
+ }
1590
+ return `key:${key}`;
1591
+ }
1560
1592
  runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
1561
1593
  this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
1562
1594
  }
@@ -1657,6 +1689,9 @@ var CacheStackReader = class {
1657
1689
  shouldNegativeCache(options) {
1658
1690
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
1659
1691
  }
1692
+ shouldCacheNullValues(options) {
1693
+ return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
1694
+ }
1660
1695
  isNegativeStoredValue(stored) {
1661
1696
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
1662
1697
  }
@@ -1999,9 +2034,12 @@ function validateRateLimitOptions(name, options) {
1999
2034
  validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2000
2035
  validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2001
2036
  validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2002
- if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2037
+ if (options.scope !== void 0 && !["global", "key", "fetcher"].includes(options.scope)) {
2003
2038
  throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2004
2039
  }
2040
+ if (options.queueOverflow !== void 0 && !["reject", "bypass"].includes(options.queueOverflow)) {
2041
+ throw new Error(`${name}.queueOverflow must be one of "reject" or "bypass".`);
2042
+ }
2005
2043
  if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2006
2044
  throw new Error(`${name}.bucketKey must not be empty.`);
2007
2045
  }
@@ -2082,6 +2120,12 @@ function validateCircuitBreakerOptions(options) {
2082
2120
  }
2083
2121
  validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
2084
2122
  validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
2123
+ if (options.scope !== void 0 && !["key", "shared"].includes(options.scope)) {
2124
+ throw new Error('circuitBreaker.scope must be one of "key" or "shared".');
2125
+ }
2126
+ if (options.breakerKey !== void 0 && options.breakerKey.length === 0) {
2127
+ throw new Error("circuitBreaker.breakerKey must not be empty.");
2128
+ }
2085
2129
  }
2086
2130
  function validateContextEntryOptions(name, options) {
2087
2131
  if (!options) {
@@ -2195,6 +2239,7 @@ var CircuitBreakerManager = class {
2195
2239
  // src/internal/FetchRateLimiter.ts
2196
2240
  var MAX_BUCKETS = 1e4;
2197
2241
  var MAX_QUEUE_PER_BUCKET = 1e4;
2242
+ var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
2198
2243
  var FetchRateLimiter = class {
2199
2244
  buckets = /* @__PURE__ */ new Map();
2200
2245
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -2219,8 +2264,12 @@ var FetchRateLimiter = class {
2219
2264
  const bucketKey = this.resolveBucketKey(normalized, context);
2220
2265
  const queue = this.queuesByBucket.get(bucketKey) ?? [];
2221
2266
  if (queue.length >= MAX_QUEUE_PER_BUCKET) {
2222
- this.rateLimitBypasses += 1;
2223
- task().then(resolve2, reject);
2267
+ if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
2268
+ this.rateLimitBypasses += 1;
2269
+ task().then(resolve2, reject);
2270
+ return;
2271
+ }
2272
+ reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
2224
2273
  return;
2225
2274
  }
2226
2275
  queue.push({
@@ -2268,7 +2317,8 @@ var FetchRateLimiter = class {
2268
2317
  intervalMs,
2269
2318
  maxPerInterval,
2270
2319
  scope: options.scope ?? "global",
2271
- bucketKey: options.bucketKey
2320
+ bucketKey: options.bucketKey,
2321
+ queueOverflow: options.queueOverflow
2272
2322
  };
2273
2323
  }
2274
2324
  resolveBucketKey(options, context) {
@@ -2453,7 +2503,9 @@ var FetchRateLimiter = class {
2453
2503
  };
2454
2504
 
2455
2505
  // src/internal/MetricsCollector.ts
2506
+ var import_node_async_hooks = require("async_hooks");
2456
2507
  var MetricsCollector = class {
2508
+ captures = new import_node_async_hooks.AsyncLocalStorage();
2457
2509
  data = this.empty();
2458
2510
  get snapshot() {
2459
2511
  return {
@@ -2466,18 +2518,46 @@ var MetricsCollector = class {
2466
2518
  increment(field, amount = 1) {
2467
2519
  ;
2468
2520
  this.data[field] += amount;
2521
+ for (const capture of this.captures.getStore() ?? []) {
2522
+ ;
2523
+ capture[field] += amount;
2524
+ }
2469
2525
  }
2470
2526
  incrementLayer(map, layerName) {
2471
2527
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2528
+ for (const capture of this.captures.getStore() ?? []) {
2529
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2530
+ }
2472
2531
  }
2473
2532
  /**
2474
2533
  * Records a read latency sample for the given layer.
2475
2534
  * Maintains a rolling average and max using Welford's online algorithm.
2476
2535
  */
2477
2536
  recordLatency(layerName, durationMs) {
2478
- const existing = this.data.latencyByLayer[layerName];
2537
+ this.recordLatencySample(this.data, layerName, durationMs);
2538
+ for (const capture of this.captures.getStore() ?? []) {
2539
+ this.recordLatencySample(capture, layerName, durationMs);
2540
+ }
2541
+ }
2542
+ async capture(operation) {
2543
+ const metrics = this.empty();
2544
+ const activeCaptures = this.captures.getStore();
2545
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2546
+ try {
2547
+ const result = await this.captures.run(captures, operation);
2548
+ return { result, metrics };
2549
+ } catch (error) {
2550
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2551
+ ;
2552
+ error.metrics = metrics;
2553
+ }
2554
+ throw error;
2555
+ }
2556
+ }
2557
+ recordLatencySample(metrics, layerName, durationMs) {
2558
+ const existing = metrics.latencyByLayer[layerName];
2479
2559
  if (!existing) {
2480
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2560
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2481
2561
  return;
2482
2562
  }
2483
2563
  existing.count += 1;
@@ -2544,6 +2624,7 @@ var TtlResolver = class {
2544
2624
  const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
2545
2625
  profile.hits += 1;
2546
2626
  profile.lastAccessAt = Date.now();
2627
+ this.accessProfiles.delete(key);
2547
2628
  this.accessProfiles.set(key, profile);
2548
2629
  this.pruneIfNeeded();
2549
2630
  }
@@ -2633,41 +2714,45 @@ var TtlResolver = class {
2633
2714
  return;
2634
2715
  }
2635
2716
  const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
2636
- const sorted = [...this.accessProfiles.entries()].sort((a, b) => a[1].lastAccessAt - b[1].lastAccessAt);
2637
- for (let i = 0; i < toRemove && i < sorted.length; i++) {
2638
- const entry = sorted[i];
2639
- if (entry) {
2640
- this.accessProfiles.delete(entry[0]);
2717
+ for (let i = 0; i < toRemove; i++) {
2718
+ const oldestKey = this.accessProfiles.keys().next().value;
2719
+ if (oldestKey === void 0) {
2720
+ break;
2641
2721
  }
2722
+ this.accessProfiles.delete(oldestKey);
2642
2723
  }
2643
2724
  }
2644
2725
  };
2645
2726
 
2646
2727
  // src/invalidation/TagIndex.ts
2647
- var MAX_PATTERN_RECURSION_DEPTH = 500;
2728
+ var DEFAULT_TOUCH_REFRESH_INTERVAL_MS = 1e3;
2648
2729
  var TagIndex = class {
2649
2730
  tagToKeys = /* @__PURE__ */ new Map();
2650
2731
  keyToTags = /* @__PURE__ */ new Map();
2651
2732
  knownKeys = /* @__PURE__ */ new Map();
2652
2733
  maxKnownKeys;
2734
+ touchRefreshIntervalMs;
2653
2735
  nextNodeId = 1;
2654
2736
  root = this.createTrieNode();
2655
2737
  constructor(options = {}) {
2656
2738
  this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
2739
+ this.touchRefreshIntervalMs = options.touchRefreshIntervalMs ?? DEFAULT_TOUCH_REFRESH_INTERVAL_MS;
2657
2740
  }
2658
2741
  /**
2659
2742
  * Records a key as known without changing tag assignments.
2660
2743
  */
2661
2744
  async touch(key) {
2662
- this.insertKnownKey(key);
2663
- this.pruneKnownKeysIfNeeded();
2745
+ if (this.insertKnownKey(key)) {
2746
+ this.pruneKnownKeysIfNeeded();
2747
+ }
2664
2748
  }
2665
2749
  /**
2666
2750
  * Replaces the tags associated with a key and records the key as known.
2667
2751
  */
2668
2752
  async track(key, tags) {
2669
- this.insertKnownKey(key);
2670
- this.pruneKnownKeysIfNeeded();
2753
+ if (this.insertKnownKey(key)) {
2754
+ this.pruneKnownKeysIfNeeded();
2755
+ }
2671
2756
  if (tags.length === 0) {
2672
2757
  return;
2673
2758
  }
@@ -2737,9 +2822,14 @@ var TagIndex = class {
2737
2822
  * Returns known keys matching a wildcard pattern.
2738
2823
  */
2739
2824
  async matchPattern(pattern) {
2740
- const matches = /* @__PURE__ */ new Set();
2741
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
2742
- return [...matches];
2825
+ const literalPrefix = this.literalPrefix(pattern);
2826
+ const node = this.findNode(literalPrefix);
2827
+ if (!node) {
2828
+ return [];
2829
+ }
2830
+ const candidates = [];
2831
+ this.collectFromNode(node, literalPrefix, candidates);
2832
+ return candidates.filter((key) => PatternMatcher.matches(pattern, key));
2743
2833
  }
2744
2834
  /**
2745
2835
  * Visits known keys matching a wildcard pattern.
@@ -2769,10 +2859,18 @@ var TagIndex = class {
2769
2859
  };
2770
2860
  }
2771
2861
  insertKnownKey(key) {
2772
- const isNew = !this.knownKeys.has(key);
2773
- this.knownKeys.set(key, Date.now());
2862
+ const previousTouch = this.knownKeys.get(key);
2863
+ const isNew = previousTouch === void 0;
2864
+ const now = Date.now();
2865
+ if (!isNew && now - previousTouch < this.touchRefreshIntervalMs) {
2866
+ return false;
2867
+ }
2774
2868
  if (!isNew) {
2775
- return;
2869
+ this.knownKeys.delete(key);
2870
+ }
2871
+ this.knownKeys.set(key, now);
2872
+ if (!isNew) {
2873
+ return true;
2776
2874
  }
2777
2875
  let node = this.root;
2778
2876
  for (const character of key) {
@@ -2784,6 +2882,7 @@ var TagIndex = class {
2784
2882
  node = child;
2785
2883
  }
2786
2884
  node.terminal = true;
2885
+ return true;
2787
2886
  }
2788
2887
  findNode(prefix) {
2789
2888
  let node = this.root;
@@ -2796,85 +2895,52 @@ var TagIndex = class {
2796
2895
  return node;
2797
2896
  }
2798
2897
  collectFromNode(node, prefix, matches) {
2799
- if (node.terminal) {
2800
- matches.push(prefix);
2801
- }
2802
- for (const [character, child] of node.children) {
2803
- this.collectFromNode(child, `${prefix}${character}`, matches);
2898
+ const stack = [{ node, prefix }];
2899
+ while (stack.length > 0) {
2900
+ const current = stack.pop();
2901
+ if (!current) {
2902
+ continue;
2903
+ }
2904
+ if (current.node.terminal) {
2905
+ matches.push(current.prefix);
2906
+ }
2907
+ const children = [...current.node.children].reverse();
2908
+ for (const [character, child] of children) {
2909
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2910
+ }
2804
2911
  }
2805
2912
  }
2806
2913
  async visitFromNode(node, prefix, visitor) {
2807
- if (node.terminal) {
2808
- await visitor(prefix);
2809
- }
2810
- for (const [character, child] of node.children) {
2811
- await this.visitFromNode(child, `${prefix}${character}`, visitor);
2812
- }
2813
- }
2814
- collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
2815
- if (depth > MAX_PATTERN_RECURSION_DEPTH) {
2816
- return;
2817
- }
2818
- const stateKey = `${node.id}:${patternIndex}`;
2819
- if (visited.has(stateKey)) {
2820
- return;
2821
- }
2822
- visited.add(stateKey);
2823
- if (patternIndex === pattern.length) {
2824
- if (node.terminal) {
2825
- matches.add(prefix);
2914
+ const stack = [{ node, prefix }];
2915
+ while (stack.length > 0) {
2916
+ const current = stack.pop();
2917
+ if (!current) {
2918
+ continue;
2826
2919
  }
2827
- return;
2828
- }
2829
- const patternChar = pattern[patternIndex];
2830
- if (patternChar === void 0) {
2831
- return;
2832
- }
2833
- if (patternChar === "*") {
2834
- this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
2835
- for (const [character, child2] of node.children) {
2836
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
2920
+ if (current.node.terminal) {
2921
+ await visitor(current.prefix);
2837
2922
  }
2838
- return;
2839
- }
2840
- if (patternChar === "?") {
2841
- for (const [character, child2] of node.children) {
2842
- this.collectPatternMatches(
2843
- child2,
2844
- `${prefix}${character}`,
2845
- pattern,
2846
- patternIndex + 1,
2847
- matches,
2848
- visited,
2849
- depth + 1
2850
- );
2923
+ const children = [...current.node.children].reverse();
2924
+ for (const [character, child] of children) {
2925
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2851
2926
  }
2852
- return;
2853
- }
2854
- const child = node.children.get(patternChar);
2855
- if (child) {
2856
- this.collectPatternMatches(
2857
- child,
2858
- `${prefix}${patternChar}`,
2859
- pattern,
2860
- patternIndex + 1,
2861
- matches,
2862
- visited,
2863
- depth + 1
2864
- );
2865
2927
  }
2866
2928
  }
2929
+ literalPrefix(pattern) {
2930
+ const wildcardIndex = pattern.search(/[*?]/);
2931
+ return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
2932
+ }
2867
2933
  pruneKnownKeysIfNeeded() {
2868
2934
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
2869
2935
  return;
2870
2936
  }
2871
- const sorted = [...this.knownKeys.entries()].sort((a, b) => a[1] - b[1]);
2872
2937
  const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
2873
- for (let i = 0; i < toRemove && i < sorted.length; i += 1) {
2874
- const entry = sorted[i];
2875
- if (entry) {
2876
- this.removeKey(entry[0]);
2938
+ for (let i = 0; i < toRemove; i += 1) {
2939
+ const oldestKey = this.knownKeys.keys().next().value;
2940
+ if (oldestKey === void 0) {
2941
+ break;
2877
2942
  }
2943
+ this.removeKnownKey(oldestKey);
2878
2944
  }
2879
2945
  }
2880
2946
  removeKey(key) {
@@ -3171,7 +3237,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3171
3237
  emitError: (operation, context) => this.emitError(operation, context),
3172
3238
  formatError: (error) => this.formatError(error),
3173
3239
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
3174
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
3240
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
3175
3241
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
3176
3242
  sleep: (ms) => this.sleep(ms),
3177
3243
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -3186,6 +3252,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3186
3252
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
3187
3253
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
3188
3254
  negativeCaching: options.negativeCaching,
3255
+ cacheNullValues: options.cacheNullValues,
3189
3256
  refreshAhead: options.refreshAhead,
3190
3257
  circuitBreaker: options.circuitBreaker,
3191
3258
  fetcherRateLimit: options.fetcherRateLimit
@@ -3238,6 +3305,64 @@ var CacheStack = class extends import_node_events.EventEmitter {
3238
3305
  async getOrSet(key, fetcher, options) {
3239
3306
  return this.get(key, fetcher, options);
3240
3307
  }
3308
+ /**
3309
+ * Returns a discriminated cache entry, or `null` on miss.
3310
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
3311
+ */
3312
+ async getEntry(key) {
3313
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
3314
+ const userKey = validateCacheKey(key);
3315
+ const normalizedKey = this.qualifyKey(userKey);
3316
+ await this.awaitStartup("getEntry");
3317
+ let sawRetainableValue = false;
3318
+ for (let index = 0; index < this.layers.length; index += 1) {
3319
+ const layer = this.layers[index];
3320
+ if (!layer || this.shouldSkipLayer(layer)) {
3321
+ continue;
3322
+ }
3323
+ const readStart = performance.now();
3324
+ const stored = await this.readLayerEntry(layer, normalizedKey);
3325
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
3326
+ if (stored === null) {
3327
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
3328
+ continue;
3329
+ }
3330
+ const resolved = resolveStoredValue(stored);
3331
+ if (resolved.state === "expired") {
3332
+ await layer.delete(normalizedKey);
3333
+ continue;
3334
+ }
3335
+ sawRetainableValue = true;
3336
+ await this.tagIndex.touch(normalizedKey);
3337
+ await this.reader.backfill(normalizedKey, stored, index - 1);
3338
+ this.metricsCollector.increment("hits");
3339
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
3340
+ this.metricsCollector.increment("staleHits");
3341
+ }
3342
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
3343
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
3344
+ this.emit("hit", {
3345
+ key: normalizedKey,
3346
+ layer: layer.name,
3347
+ state: resolved.state
3348
+ });
3349
+ return {
3350
+ key: userKey,
3351
+ value: resolved.value,
3352
+ kind: resolved.envelope?.kind ?? "value",
3353
+ state: resolved.state,
3354
+ layer: layer.name
3355
+ };
3356
+ }
3357
+ if (!sawRetainableValue) {
3358
+ await this.tagIndex.remove(normalizedKey);
3359
+ }
3360
+ this.metricsCollector.increment("misses");
3361
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
3362
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
3363
+ return null;
3364
+ });
3365
+ }
3241
3366
  /**
3242
3367
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
3243
3368
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3702,6 +3827,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
3702
3827
  getMetrics() {
3703
3828
  return this.metricsCollector.snapshot;
3704
3829
  }
3830
+ /**
3831
+ * Runs an operation while collecting only the metrics emitted by its async context.
3832
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3833
+ */
3834
+ async captureMetrics(operation) {
3835
+ return this.metricsCollector.capture(operation);
3836
+ }
3705
3837
  /**
3706
3838
  * Returns metrics plus layer degradation state and active background refresh count.
3707
3839
  */
@@ -3775,6 +3907,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
3775
3907
  }
3776
3908
  return this.currentGeneration;
3777
3909
  }
3910
+ /**
3911
+ * Returns the active generation prefix number used for future cache keys.
3912
+ */
3913
+ getGeneration() {
3914
+ return this.currentGeneration;
3915
+ }
3778
3916
  /**
3779
3917
  * Returns detailed metadata about a single cache key: which layers contain it,
3780
3918
  * remaining fresh/stale/error TTLs, and associated tags.
@@ -3999,7 +4137,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3999
4137
  for (const key of keys) {
4000
4138
  await this.tagIndex.remove(key);
4001
4139
  this.ttlResolver.deleteProfile(key);
4002
- this.circuitBreakerManager.delete(key);
4140
+ this.circuitBreakerManager.delete(`key:${key}`);
4003
4141
  }
4004
4142
  this.metricsCollector.increment("deletes", keys.length);
4005
4143
  this.metricsCollector.increment("invalidations");
@@ -4018,7 +4156,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4018
4156
  }
4019
4157
  await this.tagIndex.remove(key);
4020
4158
  this.ttlResolver.deleteProfile(key);
4021
- this.circuitBreakerManager.delete(key);
4159
+ this.circuitBreakerManager.delete(`key:${key}`);
4022
4160
  }
4023
4161
  this.metricsCollector.increment("invalidations");
4024
4162
  this.logger.debug?.("expire", { keys });
@@ -4060,7 +4198,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4060
4198
  for (const key of keys) {
4061
4199
  await this.tagIndex.remove(key);
4062
4200
  this.ttlResolver.deleteProfile(key);
4063
- this.circuitBreakerManager.delete(key);
4201
+ this.circuitBreakerManager.delete(`key:${key}`);
4064
4202
  }
4065
4203
  }
4066
4204
  }
@@ -4305,15 +4443,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
4305
4443
  isGracefulDegradationEnabled() {
4306
4444
  return Boolean(this.options.gracefulDegradation);
4307
4445
  }
4308
- recordCircuitFailure(key, options, error) {
4446
+ recordCircuitFailure(key, breakerKey, options, error) {
4309
4447
  if (!options) {
4310
4448
  return;
4311
4449
  }
4312
- this.circuitBreakerManager.recordFailure(key, options);
4313
- if (this.circuitBreakerManager.isOpen(key)) {
4450
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
4451
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
4314
4452
  this.metricsCollector.increment("circuitBreakerTrips");
4315
4453
  }
4316
- this.emitError("fetch", { key, error: this.formatError(error) });
4454
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
4317
4455
  }
4318
4456
  emitError(operation, context) {
4319
4457
  this.logger.error?.(operation, context);
@@ -4332,12 +4470,65 @@ var CacheStack = class extends import_node_events.EventEmitter {
4332
4470
  }
4333
4471
  };
4334
4472
 
4473
+ // src/generation/RedisGenerationStore.ts
4474
+ var DEFAULT_GENERATION_KEY = "layercache:generation";
4475
+ var RedisGenerationStore = class {
4476
+ client;
4477
+ key;
4478
+ constructor(options) {
4479
+ this.client = options.client;
4480
+ this.key = options.key ?? DEFAULT_GENERATION_KEY;
4481
+ }
4482
+ async get() {
4483
+ const stored = await this.client.get(this.key);
4484
+ if (stored === null) {
4485
+ return void 0;
4486
+ }
4487
+ return this.parseGeneration(stored);
4488
+ }
4489
+ async getOrInitialize(initialGeneration = 0) {
4490
+ this.assertGeneration(initialGeneration);
4491
+ await this.client.set(this.key, String(initialGeneration), "NX");
4492
+ const generation = await this.get();
4493
+ if (generation === void 0) {
4494
+ throw new Error(`RedisGenerationStore failed to initialize generation key "${this.key}".`);
4495
+ }
4496
+ return generation;
4497
+ }
4498
+ async set(generation) {
4499
+ this.assertGeneration(generation);
4500
+ await this.client.set(this.key, String(generation));
4501
+ }
4502
+ async bump() {
4503
+ const generation = await this.client.incr(this.key);
4504
+ this.assertGeneration(generation);
4505
+ return generation;
4506
+ }
4507
+ parseGeneration(value) {
4508
+ const generation = Number.parseInt(value, 10);
4509
+ if (String(generation) !== value || !this.isGeneration(generation)) {
4510
+ throw new Error(`RedisGenerationStore found invalid persisted generation value for key "${this.key}".`);
4511
+ }
4512
+ return generation;
4513
+ }
4514
+ assertGeneration(value) {
4515
+ if (!this.isGeneration(value)) {
4516
+ throw new Error("RedisGenerationStore generation must be a non-negative safe integer.");
4517
+ }
4518
+ }
4519
+ isGeneration(value) {
4520
+ return Number.isSafeInteger(value) && value >= 0;
4521
+ }
4522
+ };
4523
+
4335
4524
  // src/invalidation/RedisInvalidationBus.ts
4525
+ var import_node_crypto3 = require("crypto");
4336
4526
  var RedisInvalidationBus = class {
4337
4527
  channel;
4338
4528
  publisher;
4339
4529
  subscriber;
4340
4530
  logger;
4531
+ signingKey;
4341
4532
  handlers = /* @__PURE__ */ new Set();
4342
4533
  sharedListener;
4343
4534
  subscribePromise;
@@ -4346,6 +4537,7 @@ var RedisInvalidationBus = class {
4346
4537
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
4347
4538
  this.channel = options.channel ?? "layercache:invalidation";
4348
4539
  this.logger = options.logger;
4540
+ this.signingKey = options.signingSecret ? normalizeSigningSecret(options.signingSecret) : void 0;
4349
4541
  }
4350
4542
  /**
4351
4543
  * Subscribes to invalidation messages and returns an unsubscribe function.
@@ -4385,7 +4577,7 @@ var RedisInvalidationBus = class {
4385
4577
  * Publishes an invalidation message to other subscribers.
4386
4578
  */
4387
4579
  async publish(message) {
4388
- await this.publisher.publish(this.channel, JSON.stringify(message));
4580
+ await this.publisher.publish(this.channel, JSON.stringify(this.signingKey ? this.signMessage(message) : message));
4389
4581
  }
4390
4582
  async dispatchToHandlers(payload) {
4391
4583
  let message;
@@ -4396,10 +4588,11 @@ var RedisInvalidationBus = class {
4396
4588
  maxNodes: 1e4,
4397
4589
  createObject: () => /* @__PURE__ */ Object.create(null)
4398
4590
  });
4399
- if (!this.isInvalidationMessage(parsed)) {
4591
+ const candidate = this.signingKey ? this.verifySignedEnvelope(parsed) : parsed;
4592
+ if (!this.isInvalidationMessage(candidate)) {
4400
4593
  throw new Error("Invalid invalidation payload shape.");
4401
4594
  }
4402
- message = parsed;
4595
+ message = candidate;
4403
4596
  } catch (error) {
4404
4597
  this.reportError("invalid invalidation payload", error);
4405
4598
  return;
@@ -4424,6 +4617,34 @@ var RedisInvalidationBus = class {
4424
4617
  const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
4425
4618
  return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
4426
4619
  }
4620
+ signMessage(message) {
4621
+ const payload = JSON.stringify(message);
4622
+ return {
4623
+ payload: message,
4624
+ signature: this.createSignature(payload)
4625
+ };
4626
+ }
4627
+ verifySignedEnvelope(value) {
4628
+ if (!value || typeof value !== "object") {
4629
+ throw new Error("Signed invalidation envelope must be an object.");
4630
+ }
4631
+ const envelope = value;
4632
+ if (!envelope.payload || typeof envelope.payload !== "object" || typeof envelope.signature !== "string") {
4633
+ throw new Error("Signed invalidation envelope is missing payload or signature.");
4634
+ }
4635
+ const payload = JSON.stringify(envelope.payload);
4636
+ const expected = this.createSignature(payload);
4637
+ if (!isEqualSignature(envelope.signature, expected)) {
4638
+ throw new Error("Invalid invalidation message signature.");
4639
+ }
4640
+ return envelope.payload;
4641
+ }
4642
+ createSignature(payload) {
4643
+ if (!this.signingKey) {
4644
+ throw new Error("RedisInvalidationBus signing key is not configured.");
4645
+ }
4646
+ return (0, import_node_crypto3.createHmac)("sha256", this.signingKey).update(payload).digest("hex");
4647
+ }
4427
4648
  reportError(message, error) {
4428
4649
  if (this.logger?.error) {
4429
4650
  this.logger.error(message, { error });
@@ -4432,18 +4653,31 @@ var RedisInvalidationBus = class {
4432
4653
  console.error(`[layercache] ${message}`, error);
4433
4654
  }
4434
4655
  };
4656
+ function normalizeSigningSecret(secret) {
4657
+ const raw = Buffer.isBuffer(secret) ? secret : Buffer.from(secret, "utf8");
4658
+ return (0, import_node_crypto3.createHash)("sha256").update(raw).digest();
4659
+ }
4660
+ function isEqualSignature(actual, expected) {
4661
+ const actualBuffer = Buffer.from(actual, "hex");
4662
+ const expectedBuffer = Buffer.from(expected, "hex");
4663
+ return actualBuffer.length === expectedBuffer.length && (0, import_node_crypto3.timingSafeEqual)(actualBuffer, expectedBuffer);
4664
+ }
4435
4665
 
4436
4666
  // src/invalidation/RedisTagIndex.ts
4667
+ var DEFAULT_KNOWN_KEYS_SHARDS = 16;
4437
4668
  var RedisTagIndex = class {
4438
4669
  client;
4439
4670
  prefix;
4440
4671
  scanCount;
4441
4672
  knownKeysShards;
4673
+ logger;
4674
+ warnedLegacyKnownKeys = false;
4442
4675
  constructor(options) {
4443
4676
  this.client = options.client;
4444
4677
  this.prefix = options.prefix ?? "layercache:tag-index";
4445
4678
  this.scanCount = options.scanCount ?? 100;
4446
4679
  this.knownKeysShards = normalizeKnownKeysShards(options.knownKeysShards);
4680
+ this.logger = options.logger;
4447
4681
  }
4448
4682
  /**
4449
4683
  * Records a key as known without changing tag assignments.
@@ -4479,6 +4713,9 @@ var RedisTagIndex = class {
4479
4713
  const existingTags = await this.client.smembers(keyTagsKey);
4480
4714
  const pipeline = this.client.pipeline();
4481
4715
  pipeline.srem(this.knownKeysKeyFor(key), key);
4716
+ if (this.knownKeysShards > 1) {
4717
+ pipeline.srem(this.legacyKnownKeysKey(), key);
4718
+ }
4482
4719
  pipeline.del(keyTagsKey);
4483
4720
  for (const tag of existingTags) {
4484
4721
  pipeline.srem(this.tagKeysKey(tag), key);
@@ -4509,28 +4746,34 @@ var RedisTagIndex = class {
4509
4746
  * Returns known keys that start with a prefix.
4510
4747
  */
4511
4748
  async keysForPrefix(prefix) {
4512
- const matches = [];
4513
- for (const knownKeysKey of this.knownKeysKeys()) {
4749
+ const matches = /* @__PURE__ */ new Set();
4750
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
4514
4751
  let cursor = "0";
4515
4752
  do {
4516
4753
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
4517
4754
  cursor = nextCursor;
4518
- matches.push(...keys.filter((key) => key.startsWith(prefix)));
4755
+ for (const key of keys) {
4756
+ if (key.startsWith(prefix)) {
4757
+ matches.add(key);
4758
+ }
4759
+ }
4519
4760
  } while (cursor !== "0");
4520
4761
  }
4521
- return matches;
4762
+ return [...matches];
4522
4763
  }
4523
4764
  /**
4524
4765
  * Visits known keys that start with a prefix.
4525
4766
  */
4526
4767
  async forEachKeyForPrefix(prefix, visitor) {
4527
- for (const knownKeysKey of this.knownKeysKeys()) {
4768
+ const visited = /* @__PURE__ */ new Set();
4769
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
4528
4770
  let cursor = "0";
4529
4771
  do {
4530
4772
  const [nextCursor, keys] = await this.client.sscan(knownKeysKey, cursor, "COUNT", this.scanCount);
4531
4773
  cursor = nextCursor;
4532
4774
  for (const key of keys) {
4533
- if (key.startsWith(prefix)) {
4775
+ if (key.startsWith(prefix) && !visited.has(key)) {
4776
+ visited.add(key);
4534
4777
  await visitor(key);
4535
4778
  }
4536
4779
  }
@@ -4547,8 +4790,8 @@ var RedisTagIndex = class {
4547
4790
  * Returns known keys matching a wildcard pattern.
4548
4791
  */
4549
4792
  async matchPattern(pattern) {
4550
- const matches = [];
4551
- for (const knownKeysKey of this.knownKeysKeys()) {
4793
+ const matches = /* @__PURE__ */ new Set();
4794
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
4552
4795
  let cursor = "0";
4553
4796
  do {
4554
4797
  const [nextCursor, keys] = await this.client.sscan(
@@ -4560,16 +4803,21 @@ var RedisTagIndex = class {
4560
4803
  this.scanCount
4561
4804
  );
4562
4805
  cursor = nextCursor;
4563
- matches.push(...keys.filter((key) => PatternMatcher.matches(pattern, key)));
4806
+ for (const key of keys) {
4807
+ if (PatternMatcher.matches(pattern, key)) {
4808
+ matches.add(key);
4809
+ }
4810
+ }
4564
4811
  } while (cursor !== "0");
4565
4812
  }
4566
- return matches;
4813
+ return [...matches];
4567
4814
  }
4568
4815
  /**
4569
4816
  * Visits known keys matching a wildcard pattern.
4570
4817
  */
4571
4818
  async forEachKeyMatchingPattern(pattern, visitor) {
4572
- for (const knownKeysKey of this.knownKeysKeys()) {
4819
+ const visited = /* @__PURE__ */ new Set();
4820
+ for (const knownKeysKey of await this.knownKeysKeysForRead()) {
4573
4821
  let cursor = "0";
4574
4822
  do {
4575
4823
  const [nextCursor, keys] = await this.client.sscan(
@@ -4582,7 +4830,8 @@ var RedisTagIndex = class {
4582
4830
  );
4583
4831
  cursor = nextCursor;
4584
4832
  for (const key of keys) {
4585
- if (PatternMatcher.matches(pattern, key)) {
4833
+ if (PatternMatcher.matches(pattern, key) && !visited.has(key)) {
4834
+ visited.add(key);
4586
4835
  await visitor(key);
4587
4836
  }
4588
4837
  }
@@ -4599,6 +4848,31 @@ var RedisTagIndex = class {
4599
4848
  }
4600
4849
  await this.client.del(...indexKeys);
4601
4850
  }
4851
+ async migrateLegacyKnownKeys() {
4852
+ if (this.knownKeysShards === 1) {
4853
+ return { migratedKeys: 0 };
4854
+ }
4855
+ const legacyKey = this.legacyKnownKeysKey();
4856
+ let cursor = "0";
4857
+ let migratedKeys = 0;
4858
+ do {
4859
+ const [nextCursor, keys] = await this.client.sscan(legacyKey, cursor, "COUNT", this.scanCount);
4860
+ cursor = nextCursor;
4861
+ if (keys.length === 0) {
4862
+ continue;
4863
+ }
4864
+ const pipeline = this.client.pipeline();
4865
+ for (const key of keys) {
4866
+ pipeline.sadd(this.knownKeysKeyFor(key), key);
4867
+ }
4868
+ await pipeline.exec();
4869
+ migratedKeys += keys.length;
4870
+ } while (cursor !== "0");
4871
+ if (migratedKeys > 0) {
4872
+ await this.client.del(legacyKey);
4873
+ }
4874
+ return { migratedKeys };
4875
+ }
4602
4876
  async scanIndexKeys() {
4603
4877
  const matches = [];
4604
4878
  let cursor = "0";
@@ -4616,12 +4890,40 @@ var RedisTagIndex = class {
4616
4890
  }
4617
4891
  return `${this.prefix}:keys:${simpleHash(key) % this.knownKeysShards}`;
4618
4892
  }
4893
+ async knownKeysKeysForRead() {
4894
+ if (this.knownKeysShards === 1) {
4895
+ return [this.legacyKnownKeysKey()];
4896
+ }
4897
+ const shardedKeys = this.knownKeysKeys();
4898
+ const legacyKey = this.legacyKnownKeysKey();
4899
+ const legacyExists = await this.client.exists(legacyKey) > 0;
4900
+ if (!legacyExists) {
4901
+ return shardedKeys;
4902
+ }
4903
+ this.warnLegacyKnownKeys(legacyKey);
4904
+ return [legacyKey, ...shardedKeys];
4905
+ }
4619
4906
  knownKeysKeys() {
4620
4907
  if (this.knownKeysShards === 1) {
4621
4908
  return [`${this.prefix}:keys`];
4622
4909
  }
4623
4910
  return Array.from({ length: this.knownKeysShards }, (_, index) => `${this.prefix}:keys:${index}`);
4624
4911
  }
4912
+ legacyKnownKeysKey() {
4913
+ return `${this.prefix}:keys`;
4914
+ }
4915
+ warnLegacyKnownKeys(legacyKey) {
4916
+ if (this.warnedLegacyKnownKeys) {
4917
+ return;
4918
+ }
4919
+ this.warnedLegacyKnownKeys = true;
4920
+ const message = "RedisTagIndex detected a legacy RedisTagIndex known-key set. Run `layercache migrate-tag-index` to migrate keys into the sharded layout.";
4921
+ if (this.logger?.warn) {
4922
+ this.logger.warn(message, { legacyKey, knownKeysShards: this.knownKeysShards });
4923
+ return;
4924
+ }
4925
+ console.warn(`[layercache] ${message}`, { legacyKey, knownKeysShards: this.knownKeysShards });
4926
+ }
4625
4927
  keyTagsKey(key) {
4626
4928
  return `${this.prefix}:key:${encodeURIComponent(key)}`;
4627
4929
  }
@@ -4631,7 +4933,7 @@ var RedisTagIndex = class {
4631
4933
  };
4632
4934
  function normalizeKnownKeysShards(value) {
4633
4935
  if (value === void 0) {
4634
- return 1;
4936
+ return DEFAULT_KNOWN_KEYS_SHARDS;
4635
4937
  }
4636
4938
  if (!Number.isInteger(value) || value <= 0) {
4637
4939
  throw new Error("RedisTagIndex.knownKeysShards must be a positive integer.");
@@ -4727,6 +5029,41 @@ function createFastifyLayercachePlugin(cache, options = {}) {
4727
5029
  };
4728
5030
  }
4729
5031
 
5032
+ // src/integrations/httpCacheKeys.ts
5033
+ var SENSITIVE_QUERY_PARAMETERS = /* @__PURE__ */ new Set([
5034
+ "access_token",
5035
+ "api_key",
5036
+ "apikey",
5037
+ "auth",
5038
+ "authorization",
5039
+ "code",
5040
+ "credentials",
5041
+ "id_token",
5042
+ "jwt",
5043
+ "password",
5044
+ "private_key",
5045
+ "refresh_token",
5046
+ "secret",
5047
+ "session",
5048
+ "sessionid",
5049
+ "session_id",
5050
+ "token"
5051
+ ]);
5052
+ function normalizeHttpCacheUrl(url) {
5053
+ try {
5054
+ const parsed = new URL(url, "http://localhost");
5055
+ for (const name of [...parsed.searchParams.keys()]) {
5056
+ if (SENSITIVE_QUERY_PARAMETERS.has(name.toLowerCase())) {
5057
+ parsed.searchParams.delete(name);
5058
+ }
5059
+ }
5060
+ parsed.searchParams.sort();
5061
+ return parsed.pathname + parsed.search;
5062
+ } catch {
5063
+ return url;
5064
+ }
5065
+ }
5066
+
4730
5067
  // src/integrations/express.ts
4731
5068
  function createExpressCacheMiddleware(cache, options = {}) {
4732
5069
  const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
@@ -4742,7 +5079,7 @@ function createExpressCacheMiddleware(cache, options = {}) {
4742
5079
  return;
4743
5080
  }
4744
5081
  const rawUrl = req.originalUrl ?? req.url ?? "/";
4745
- const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeUrl(rawUrl)}`;
5082
+ const key = options.keyResolver ? options.keyResolver(req) : `${method}:${normalizeHttpCacheUrl(rawUrl)}`;
4746
5083
  const cached = await cache.get(key, void 0, options);
4747
5084
  if (cached !== null) {
4748
5085
  res.setHeader?.("content-type", "application/json; charset=utf-8");
@@ -4758,12 +5095,14 @@ function createExpressCacheMiddleware(cache, options = {}) {
4758
5095
  if (originalJson) {
4759
5096
  res.json = (body) => {
4760
5097
  res.setHeader?.("x-cache", "MISS");
4761
- cache.set(key, body, options).catch((err) => {
4762
- cache.emit("error", {
4763
- operation: "set",
4764
- error: err instanceof Error ? err.message : String(err)
5098
+ if (isSuccessfulStatus(res.statusCode)) {
5099
+ cache.set(key, body, options).catch((err) => {
5100
+ cache.emit("error", {
5101
+ operation: "set",
5102
+ error: err instanceof Error ? err.message : String(err)
5103
+ });
4765
5104
  });
4766
- });
5105
+ }
4767
5106
  return originalJson(body);
4768
5107
  };
4769
5108
  }
@@ -4773,14 +5112,8 @@ function createExpressCacheMiddleware(cache, options = {}) {
4773
5112
  }
4774
5113
  };
4775
5114
  }
4776
- function normalizeUrl(url) {
4777
- try {
4778
- const parsed = new URL(url, "http://localhost");
4779
- parsed.searchParams.sort();
4780
- return parsed.pathname + parsed.search;
4781
- } catch {
4782
- return url;
4783
- }
5115
+ function isSuccessfulStatus(statusCode) {
5116
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
4784
5117
  }
4785
5118
 
4786
5119
  // src/integrations/graphql.ts
@@ -4811,35 +5144,39 @@ function createHonoCacheMiddleware(cache, options = {}) {
4811
5144
  return;
4812
5145
  }
4813
5146
  const rawPath = context.req.path ?? context.req.url ?? "/";
4814
- const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeUrl2(rawPath)}`;
5147
+ const key = options.keyResolver ? options.keyResolver(context.req) : `${method}:${normalizeHttpCacheUrl(rawPath)}`;
4815
5148
  const cached = await cache.get(key, void 0, options);
4816
5149
  if (cached !== null) {
4817
5150
  context.header?.("x-cache", "HIT");
4818
5151
  context.header?.("content-type", "application/json; charset=utf-8");
4819
5152
  return context.json(cached);
4820
5153
  }
5154
+ let currentStatus;
5155
+ const originalStatus = context.status?.bind(context);
5156
+ if (originalStatus) {
5157
+ context.status = (status) => {
5158
+ currentStatus = status;
5159
+ return originalStatus(status);
5160
+ };
5161
+ }
4821
5162
  const originalJson = context.json.bind(context);
4822
5163
  context.json = (body, status) => {
4823
5164
  context.header?.("x-cache", "MISS");
4824
- cache.set(key, body, options).catch((err) => {
4825
- cache.emit("error", {
4826
- operation: "set",
4827
- error: err instanceof Error ? err.message : String(err)
5165
+ if (isSuccessfulStatus2(status ?? currentStatus)) {
5166
+ cache.set(key, body, options).catch((err) => {
5167
+ cache.emit("error", {
5168
+ operation: "set",
5169
+ error: err instanceof Error ? err.message : String(err)
5170
+ });
4828
5171
  });
4829
- });
5172
+ }
4830
5173
  return originalJson(body, status);
4831
5174
  };
4832
5175
  await next();
4833
5176
  };
4834
5177
  }
4835
- function normalizeUrl2(url) {
4836
- try {
4837
- const parsed = new URL(url, "http://localhost");
4838
- parsed.searchParams.sort();
4839
- return parsed.pathname + parsed.search;
4840
- } catch {
4841
- return url;
4842
- }
5178
+ function isSuccessfulStatus2(statusCode) {
5179
+ return statusCode === void 0 || statusCode >= 200 && statusCode < 300;
4843
5180
  }
4844
5181
 
4845
5182
  // src/integrations/opentelemetry.ts
@@ -5634,12 +5971,12 @@ var RedisLayer = class {
5634
5971
  };
5635
5972
 
5636
5973
  // src/layers/DiskLayer.ts
5637
- var import_node_crypto4 = require("crypto");
5974
+ var import_node_crypto5 = require("crypto");
5638
5975
  var import_node_fs2 = require("fs");
5639
5976
  var import_node_path = require("path");
5640
5977
 
5641
5978
  // src/internal/PayloadProtection.ts
5642
- var import_node_crypto3 = require("crypto");
5979
+ var import_node_crypto4 = require("crypto");
5643
5980
  var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
5644
5981
  var MAGIC_SIGNED = Buffer.from("LCS1:");
5645
5982
  var ALGORITHM = "aes-256-gcm";
@@ -5652,11 +5989,11 @@ var PayloadProtection = class {
5652
5989
  constructor(options) {
5653
5990
  if (options.encryptionKey) {
5654
5991
  const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
5655
- this.encryptionKey = (0, import_node_crypto3.createHash)("sha256").update(raw).digest();
5992
+ this.encryptionKey = (0, import_node_crypto4.createHash)("sha256").update(raw).digest();
5656
5993
  }
5657
5994
  if (options.signingKey && !options.encryptionKey) {
5658
5995
  const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
5659
- this.signingKey = (0, import_node_crypto3.createHash)("sha256").update(raw).digest();
5996
+ this.signingKey = (0, import_node_crypto4.createHash)("sha256").update(raw).digest();
5660
5997
  }
5661
5998
  }
5662
5999
  /** Returns `true` when any protection (encryption or signing) is configured. */
@@ -5703,8 +6040,8 @@ var PayloadProtection = class {
5703
6040
  }
5704
6041
  // ── Encryption (AES-256-GCM) ──────────────────────────────────────────
5705
6042
  encrypt(plaintext, key) {
5706
- const iv = (0, import_node_crypto3.randomBytes)(IV_LENGTH);
5707
- const cipher = (0, import_node_crypto3.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
6043
+ const iv = (0, import_node_crypto4.randomBytes)(IV_LENGTH);
6044
+ const cipher = (0, import_node_crypto4.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
5708
6045
  const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
5709
6046
  const authTag = cipher.getAuthTag();
5710
6047
  return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
@@ -5715,7 +6052,7 @@ var PayloadProtection = class {
5715
6052
  const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
5716
6053
  const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
5717
6054
  try {
5718
- const decipher = (0, import_node_crypto3.createDecipheriv)(ALGORITHM, key, iv, {
6055
+ const decipher = (0, import_node_crypto4.createDecipheriv)(ALGORITHM, key, iv, {
5719
6056
  authTagLength: AUTH_TAG_LENGTH
5720
6057
  });
5721
6058
  decipher.setAuthTag(authTag);
@@ -5728,15 +6065,15 @@ var PayloadProtection = class {
5728
6065
  }
5729
6066
  // ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
5730
6067
  sign(payload, key) {
5731
- const hmac = (0, import_node_crypto3.createHmac)("sha256", key).update(payload).digest();
6068
+ const hmac = (0, import_node_crypto4.createHmac)("sha256", key).update(payload).digest();
5732
6069
  return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
5733
6070
  }
5734
6071
  verify(payload, key) {
5735
6072
  const headerEnd = MAGIC_SIGNED.length;
5736
6073
  const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
5737
6074
  const data = payload.subarray(headerEnd + HMAC_LENGTH);
5738
- const expectedHmac = (0, import_node_crypto3.createHmac)("sha256", key).update(data).digest();
5739
- if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto3.timingSafeEqual)(receivedHmac, expectedHmac)) {
6075
+ const expectedHmac = (0, import_node_crypto4.createHmac)("sha256", key).update(data).digest();
6076
+ if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto4.timingSafeEqual)(receivedHmac, expectedHmac)) {
5740
6077
  throw new PayloadProtectionError(
5741
6078
  "HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
5742
6079
  );
@@ -5760,6 +6097,7 @@ var PayloadProtectionError = class extends Error {
5760
6097
 
5761
6098
  // src/layers/DiskLayer.ts
5762
6099
  var FILE_SCAN_CONCURRENCY = 32;
6100
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
5763
6101
  var DiskLayer = class {
5764
6102
  name;
5765
6103
  defaultTtl;
@@ -5768,8 +6106,10 @@ var DiskLayer = class {
5768
6106
  serializer;
5769
6107
  maxFiles;
5770
6108
  maxEntryBytes;
6109
+ maxWriteQueueDepth;
5771
6110
  protection;
5772
6111
  writeQueue = Promise.resolve();
6112
+ writeQueueDepth = 0;
5773
6113
  /**
5774
6114
  * Creates a disk-backed cache layer.
5775
6115
  */
@@ -5780,6 +6120,7 @@ var DiskLayer = class {
5780
6120
  this.serializer = options.serializer ?? new JsonSerializer();
5781
6121
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
5782
6122
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
6123
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
5783
6124
  this.protection = new PayloadProtection({
5784
6125
  encryptionKey: options.encryptionKey,
5785
6126
  signingKey: options.signingKey
@@ -5828,7 +6169,7 @@ var DiskLayer = class {
5828
6169
  const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
5829
6170
  const protectedPayload = this.protection.protect(raw);
5830
6171
  const targetPath = this.keyToPath(key);
5831
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto4.randomBytes)(8).toString("hex")}.tmp`;
6172
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto5.randomBytes)(8).toString("hex")}.tmp`;
5832
6173
  try {
5833
6174
  await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
5834
6175
  await import_node_fs2.promises.rename(tempPath, targetPath);
@@ -5961,7 +6302,7 @@ var DiskLayer = class {
5961
6302
  async dispose() {
5962
6303
  }
5963
6304
  keyToPath(key) {
5964
- const hash = (0, import_node_crypto4.createHash)("sha256").update(key).digest("hex");
6305
+ const hash = (0, import_node_crypto5.createHash)("sha256").update(key).digest("hex");
5965
6306
  return (0, import_node_path.join)(this.directory, `${hash}.lc`);
5966
6307
  }
5967
6308
  resolveDirectory(directory) {
@@ -5995,6 +6336,16 @@ var DiskLayer = class {
5995
6336
  }
5996
6337
  return normalized;
5997
6338
  }
6339
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
6340
+ if (maxWriteQueueDepth === false) {
6341
+ return false;
6342
+ }
6343
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
6344
+ if (!Number.isInteger(normalized) || normalized <= 0) {
6345
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
6346
+ }
6347
+ return normalized;
6348
+ }
5998
6349
  async readEntryFile(filePath) {
5999
6350
  let handle;
6000
6351
  try {
@@ -6107,7 +6458,13 @@ var DiskLayer = class {
6107
6458
  }
6108
6459
  }
6109
6460
  enqueueWrite(operation) {
6110
- const next = this.writeQueue.then(operation, operation);
6461
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
6462
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
6463
+ }
6464
+ this.writeQueueDepth += 1;
6465
+ const next = this.writeQueue.then(operation, operation).finally(() => {
6466
+ this.writeQueueDepth -= 1;
6467
+ });
6111
6468
  this.writeQueue = next.catch(() => void 0);
6112
6469
  return next;
6113
6470
  }
@@ -6279,7 +6636,7 @@ var MsgpackSerializer = class {
6279
6636
  };
6280
6637
 
6281
6638
  // src/singleflight/RedisSingleFlightCoordinator.ts
6282
- var import_node_crypto5 = require("crypto");
6639
+ var import_node_crypto6 = require("crypto");
6283
6640
  var RELEASE_SCRIPT = `
6284
6641
  if redis.call("get", KEYS[1]) == ARGV[1] then
6285
6642
  return redis.call("del", KEYS[1])
@@ -6307,7 +6664,7 @@ var RedisSingleFlightCoordinator = class {
6307
6664
  */
6308
6665
  async execute(key, options, worker, waiter) {
6309
6666
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
6310
- const token = (0, import_node_crypto5.randomUUID)();
6667
+ const token = (0, import_node_crypto6.randomUUID)();
6311
6668
  const acquired = await this.runCommand(
6312
6669
  `acquire("${key}")`,
6313
6670
  () => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
@@ -6461,6 +6818,7 @@ function sanitizeLabel(value) {
6461
6818
  MemoryLayer,
6462
6819
  MsgpackSerializer,
6463
6820
  PatternMatcher,
6821
+ RedisGenerationStore,
6464
6822
  RedisInvalidationBus,
6465
6823
  RedisLayer,
6466
6824
  RedisSingleFlightCoordinator,