layercache 1.2.8 → 1.2.9

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
@@ -440,7 +440,7 @@ function normalizeForSerialization(value) {
440
440
  }
441
441
  function serializeKeyPart(value) {
442
442
  if (typeof value === "string") {
443
- return `s:${value}`;
443
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
444
444
  }
445
445
  if (typeof value === "number") {
446
446
  return `n:${value}`;
@@ -671,6 +671,7 @@ var CacheStackLayerWriter = class {
671
671
  }
672
672
  const results = await Promise.allSettled(operations.map((operation) => operation()));
673
673
  const failures = results.filter((result) => result.status === "rejected");
674
+ const degraded = results.filter((result) => result.status === "fulfilled");
674
675
  if (failures.length === 0) {
675
676
  return;
676
677
  }
@@ -849,6 +850,7 @@ function planFreshReadPolicies({
849
850
  }
850
851
 
851
852
  // src/internal/CacheStackSnapshotManager.ts
853
+ import { randomBytes } from "crypto";
852
854
  import { constants, promises as fs } from "fs";
853
855
  import path from "path";
854
856
 
@@ -948,6 +950,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
948
950
  return Buffer.concat(chunks).toString("utf8");
949
951
  }
950
952
 
953
+ // src/internal/StructuredDataSanitizer.ts
954
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
955
+ function sanitizeStructuredData(value, options) {
956
+ return sanitizeValue(value, 0, { count: 0 }, options);
957
+ }
958
+ function sanitizeValue(value, depth, state, options) {
959
+ state.count += 1;
960
+ if (state.count > options.maxNodes) {
961
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
962
+ }
963
+ if (depth > options.maxDepth) {
964
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
965
+ }
966
+ if (Array.isArray(value)) {
967
+ const sanitized2 = [];
968
+ for (const entry of value) {
969
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
970
+ }
971
+ return sanitized2;
972
+ }
973
+ if (!isPlainObject(value)) {
974
+ return value;
975
+ }
976
+ const sanitized = options.createObject?.() ?? {};
977
+ for (const [key, entry] of Object.entries(value)) {
978
+ if (DANGEROUS_KEYS.has(key)) {
979
+ continue;
980
+ }
981
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
982
+ }
983
+ return sanitized;
984
+ }
985
+ function isPlainObject(value) {
986
+ return Object.prototype.toString.call(value) === "[object Object]";
987
+ }
988
+
951
989
  // src/internal/CacheStackSnapshotManager.ts
952
990
  var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
953
991
  var CacheStackSnapshotManager = class {
@@ -972,7 +1010,16 @@ var CacheStackSnapshotManager = class {
972
1010
  const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
973
1011
  await Promise.all(
974
1012
  batch.map(async (entry) => {
975
- await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1013
+ await Promise.all(
1014
+ this.options.layers.map(async (layer) => {
1015
+ if (this.options.shouldSkipLayer(layer)) return;
1016
+ try {
1017
+ await layer.set(entry.key, entry.value, entry.ttl);
1018
+ } catch (error) {
1019
+ await this.options.handleLayerFailure(layer, "write", error);
1020
+ }
1021
+ })
1022
+ );
976
1023
  await this.options.tagIndex.touch(entry.key);
977
1024
  })
978
1025
  );
@@ -982,7 +1029,7 @@ var CacheStackSnapshotManager = class {
982
1029
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
983
1030
  const tempPath = path.join(
984
1031
  path.dirname(targetPath),
985
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1032
+ `.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
986
1033
  );
987
1034
  let handle;
988
1035
  try {
@@ -1082,7 +1129,13 @@ var CacheStackSnapshotManager = class {
1082
1129
  });
1083
1130
  }
1084
1131
  sanitizeSnapshotValue(value) {
1085
- return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1132
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1133
+ return sanitizeStructuredData(roundTripped, {
1134
+ label: "Snapshot value",
1135
+ maxDepth: 64,
1136
+ maxNodes: 1e4,
1137
+ createObject: () => /* @__PURE__ */ Object.create(null)
1138
+ });
1086
1139
  }
1087
1140
  };
1088
1141
 
@@ -1462,7 +1515,13 @@ var FetchRateLimiter = class {
1462
1515
  this.pendingBuckets.add(next.bucketKey);
1463
1516
  }
1464
1517
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1465
- this.drain();
1518
+ if (!this.drainTimer) {
1519
+ this.drainTimer = setTimeout(() => {
1520
+ this.drainTimer = void 0;
1521
+ this.drain();
1522
+ }, 0);
1523
+ this.drainTimer.unref?.();
1524
+ }
1466
1525
  });
1467
1526
  }
1468
1527
  }
@@ -1504,6 +1563,9 @@ var FetchRateLimiter = class {
1504
1563
  }
1505
1564
  if (this.buckets.size >= MAX_BUCKETS) {
1506
1565
  this.evictIdleBuckets();
1566
+ if (this.buckets.size >= MAX_BUCKETS) {
1567
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
1568
+ }
1507
1569
  }
1508
1570
  const bucket = { active: 0, startedAt: [] };
1509
1571
  this.buckets.set(bucketKey, bucket);
@@ -1733,38 +1795,6 @@ var TtlResolver = class {
1733
1795
  }
1734
1796
  };
1735
1797
 
1736
- // src/internal/StructuredDataSanitizer.ts
1737
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1738
- function sanitizeStructuredData(value, options) {
1739
- return sanitizeValue(value, 0, { count: 0 }, options);
1740
- }
1741
- function sanitizeValue(value, depth, state, options) {
1742
- state.count += 1;
1743
- if (state.count > options.maxNodes) {
1744
- throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1745
- }
1746
- if (depth > options.maxDepth) {
1747
- throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1748
- }
1749
- if (Array.isArray(value)) {
1750
- return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
1751
- }
1752
- if (!isPlainObject(value)) {
1753
- return value;
1754
- }
1755
- const sanitized = options.createObject?.() ?? {};
1756
- for (const [key, entry] of Object.entries(value)) {
1757
- if (DANGEROUS_KEYS.has(key)) {
1758
- continue;
1759
- }
1760
- sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1761
- }
1762
- return sanitized;
1763
- }
1764
- function isPlainObject(value) {
1765
- return Object.prototype.toString.call(value) === "[object Object]";
1766
- }
1767
-
1768
1798
  // src/serialization/JsonSerializer.ts
1769
1799
  var JsonSerializer = class {
1770
1800
  serialize(value) {
@@ -1929,6 +1959,8 @@ var CacheStack = class extends EventEmitter {
1929
1959
  tagIndex: this.tagIndex,
1930
1960
  snapshotSerializer: this.snapshotSerializer,
1931
1961
  readLayerEntry: this.readLayerEntry.bind(this),
1962
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1963
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
1932
1964
  qualifyKey: this.qualifyKey.bind(this),
1933
1965
  stripQualifiedKey: this.stripQualifiedKey.bind(this),
1934
1966
  validateCacheKey,
@@ -1953,6 +1985,7 @@ var CacheStack = class extends EventEmitter {
1953
1985
  layerWriter;
1954
1986
  snapshots;
1955
1987
  backgroundRefreshes = /* @__PURE__ */ new Map();
1988
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
1956
1989
  layerDegradedUntil = /* @__PURE__ */ new Map();
1957
1990
  maintenance = new CacheStackMaintenance();
1958
1991
  ttlResolver;
@@ -2197,7 +2230,7 @@ var CacheStack = class extends EventEmitter {
2197
2230
  }
2198
2231
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2199
2232
  const layer = this.layers[layerIndex];
2200
- if (!layer) continue;
2233
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2201
2234
  const keys = [...pending];
2202
2235
  if (keys.length === 0) {
2203
2236
  break;
@@ -2214,6 +2247,9 @@ var CacheStack = class extends EventEmitter {
2214
2247
  await layer.delete(key);
2215
2248
  continue;
2216
2249
  }
2250
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2251
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2252
+ }
2217
2253
  await this.tagIndex.touch(key);
2218
2254
  await this.backfill(key, stored, layerIndex - 1);
2219
2255
  resultsByKey.set(key, resolved.value);
@@ -2469,7 +2505,25 @@ var CacheStack = class extends EventEmitter {
2469
2505
  await this.unsubscribeInvalidation?.();
2470
2506
  await this.flushWriteBehindQueue();
2471
2507
  await this.maintenance.waitForGenerationCleanup();
2472
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
2508
+ for (const key of this.backgroundRefreshAbort.keys()) {
2509
+ this.backgroundRefreshAbort.set(key, true);
2510
+ }
2511
+ await Promise.allSettled(
2512
+ [...this.backgroundRefreshes.values()].map((promise) => {
2513
+ let timer;
2514
+ return Promise.race([
2515
+ promise,
2516
+ new Promise((resolve2) => {
2517
+ timer = setTimeout(resolve2, 5e3);
2518
+ timer.unref?.();
2519
+ })
2520
+ ]).finally(() => {
2521
+ if (timer) clearTimeout(timer);
2522
+ });
2523
+ })
2524
+ );
2525
+ this.backgroundRefreshes.clear();
2526
+ this.backgroundRefreshAbort.clear();
2473
2527
  this.maintenance.disposeWriteBehindTimer();
2474
2528
  this.fetchRateLimiter.dispose();
2475
2529
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
@@ -2736,15 +2790,19 @@ var CacheStack = class extends EventEmitter {
2736
2790
  }
2737
2791
  const clearEpoch = this.maintenance.currentClearEpoch();
2738
2792
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
2793
+ this.backgroundRefreshAbort.set(key, false);
2739
2794
  const refresh = (async () => {
2740
2795
  this.metricsCollector.increment("refreshes");
2741
2796
  try {
2797
+ if (this.backgroundRefreshAbort.get(key)) return;
2742
2798
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
2743
2799
  } catch (error) {
2800
+ if (this.backgroundRefreshAbort.get(key)) return;
2744
2801
  this.metricsCollector.increment("refreshErrors");
2745
2802
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
2746
2803
  } finally {
2747
2804
  this.backgroundRefreshes.delete(key);
2805
+ this.backgroundRefreshAbort.delete(key);
2748
2806
  }
2749
2807
  })();
2750
2808
  this.backgroundRefreshes.set(key, refresh);
@@ -2847,7 +2905,7 @@ var CacheStack = class extends EventEmitter {
2847
2905
  timer.unref?.();
2848
2906
  })
2849
2907
  ]);
2850
- if (result && typeof result === "object" && "kind" in result) {
2908
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
2851
2909
  if (result.kind === "error") {
2852
2910
  throw result.error;
2853
2911
  }
@@ -2865,7 +2923,7 @@ var CacheStack = class extends EventEmitter {
2865
2923
  }
2866
2924
  async observeOperation(name, attributes, execute) {
2867
2925
  const id = this.nextOperationId;
2868
- this.nextOperationId += 1;
2926
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
2869
2927
  this.emit("operation-start", { id, name, attributes });
2870
2928
  try {
2871
2929
  const result = await execute();
@@ -3101,6 +3159,7 @@ var RedisInvalidationBus = class {
3101
3159
  logger;
3102
3160
  handlers = /* @__PURE__ */ new Set();
3103
3161
  sharedListener;
3162
+ subscribePromise;
3104
3163
  constructor(options) {
3105
3164
  this.publisher = options.publisher;
3106
3165
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
@@ -3108,15 +3167,27 @@ var RedisInvalidationBus = class {
3108
3167
  this.logger = options.logger;
3109
3168
  }
3110
3169
  async subscribe(handler) {
3111
- if (this.handlers.size === 0) {
3112
- const listener = (_channel, payload) => {
3113
- void this.dispatchToHandlers(payload);
3114
- };
3115
- this.sharedListener = listener;
3116
- this.subscriber.on("message", listener);
3117
- await this.subscriber.subscribe(this.channel);
3170
+ const previousPromise = this.subscribePromise;
3171
+ let resolveThis;
3172
+ this.subscribePromise = new Promise((resolve2) => {
3173
+ resolveThis = resolve2;
3174
+ });
3175
+ if (previousPromise) {
3176
+ await previousPromise;
3177
+ }
3178
+ try {
3179
+ if (this.handlers.size === 0) {
3180
+ const listener = (_channel, payload) => {
3181
+ void this.dispatchToHandlers(payload);
3182
+ };
3183
+ this.sharedListener = listener;
3184
+ this.subscriber.on("message", listener);
3185
+ await this.subscriber.subscribe(this.channel);
3186
+ }
3187
+ this.handlers.add(handler);
3188
+ } finally {
3189
+ resolveThis();
3118
3190
  }
3119
- this.handlers.add(handler);
3120
3191
  return async () => {
3121
3192
  this.handlers.delete(handler);
3122
3193
  if (this.handlers.size === 0 && this.sharedListener) {
@@ -3317,10 +3388,21 @@ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
3317
3388
  }
3318
3389
 
3319
3390
  // src/integrations/opentelemetry.ts
3391
+ var MAX_SPANS = 1e4;
3320
3392
  function createOpenTelemetryPlugin(cache, tracer) {
3321
3393
  const spans = /* @__PURE__ */ new Map();
3322
3394
  const onStart = (event) => {
3323
- spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
3395
+ try {
3396
+ if (spans.size >= MAX_SPANS) {
3397
+ const oldest = spans.keys().next().value;
3398
+ if (oldest !== void 0) {
3399
+ spans.get(oldest)?.end();
3400
+ spans.delete(oldest);
3401
+ }
3402
+ }
3403
+ spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
3404
+ } catch {
3405
+ }
3324
3406
  };
3325
3407
  const onEnd = (event) => {
3326
3408
  const span = spans.get(event.id);
@@ -3328,12 +3410,15 @@ function createOpenTelemetryPlugin(cache, tracer) {
3328
3410
  return;
3329
3411
  }
3330
3412
  spans.delete(event.id);
3331
- span.setAttribute?.("layercache.success", event.success);
3332
- if (event.result) {
3333
- span.setAttribute?.("layercache.result", event.result);
3334
- }
3335
- if (event.error !== void 0) {
3336
- span.recordException?.(event.error);
3413
+ try {
3414
+ span.setAttribute?.("layercache.success", event.success);
3415
+ if (event.result) {
3416
+ span.setAttribute?.("layercache.result", event.result);
3417
+ }
3418
+ if (event.error !== void 0) {
3419
+ span.recordException?.(event.error);
3420
+ }
3421
+ } catch {
3337
3422
  }
3338
3423
  span.end();
3339
3424
  };
@@ -3711,7 +3796,7 @@ var RedisLayer = class {
3711
3796
  };
3712
3797
 
3713
3798
  // src/layers/DiskLayer.ts
3714
- import { createHash } from "crypto";
3799
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
3715
3800
  import { promises as fs2 } from "fs";
3716
3801
  import { join, resolve } from "path";
3717
3802
  var FILE_SCAN_CONCURRENCY = 32;
@@ -3764,7 +3849,7 @@ var DiskLayer = class {
3764
3849
  };
3765
3850
  const payload = this.serializer.serialize(entry);
3766
3851
  const targetPath = this.keyToPath(key);
3767
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3852
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes2(8).toString("hex")}.tmp`;
3768
3853
  try {
3769
3854
  await fs2.writeFile(tempPath, payload);
3770
3855
  await fs2.rename(tempPath, targetPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
4
4
  "description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
5
5
  "keywords": [
6
6
  "cache",
@@ -724,7 +724,7 @@ function normalizeForSerialization(value) {
724
724
  }
725
725
  function serializeKeyPart(value) {
726
726
  if (typeof value === "string") {
727
- return `s:${value}`;
727
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
728
728
  }
729
729
  if (typeof value === "number") {
730
730
  return `n:${value}`;
@@ -1113,6 +1113,7 @@ var CacheStackLayerWriter = class {
1113
1113
  }
1114
1114
  const results = await Promise.allSettled(operations.map((operation) => operation()));
1115
1115
  const failures = results.filter((result) => result.status === "rejected");
1116
+ const degraded = results.filter((result) => result.status === "fulfilled");
1116
1117
  if (failures.length === 0) {
1117
1118
  return;
1118
1119
  }
@@ -1291,6 +1292,7 @@ function planFreshReadPolicies({
1291
1292
  }
1292
1293
 
1293
1294
  // ../../src/internal/CacheStackSnapshotManager.ts
1295
+ var import_node_crypto = require("crypto");
1294
1296
  var import_node_fs = require("fs");
1295
1297
  var import_node_path = __toESM(require("path"), 1);
1296
1298
 
@@ -1390,6 +1392,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
1390
1392
  return Buffer.concat(chunks).toString("utf8");
1391
1393
  }
1392
1394
 
1395
+ // ../../src/internal/StructuredDataSanitizer.ts
1396
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1397
+ function sanitizeStructuredData(value, options) {
1398
+ return sanitizeValue(value, 0, { count: 0 }, options);
1399
+ }
1400
+ function sanitizeValue(value, depth, state, options) {
1401
+ state.count += 1;
1402
+ if (state.count > options.maxNodes) {
1403
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1404
+ }
1405
+ if (depth > options.maxDepth) {
1406
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1407
+ }
1408
+ if (Array.isArray(value)) {
1409
+ const sanitized2 = [];
1410
+ for (const entry of value) {
1411
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
1412
+ }
1413
+ return sanitized2;
1414
+ }
1415
+ if (!isPlainObject(value)) {
1416
+ return value;
1417
+ }
1418
+ const sanitized = options.createObject?.() ?? {};
1419
+ for (const [key, entry] of Object.entries(value)) {
1420
+ if (DANGEROUS_KEYS.has(key)) {
1421
+ continue;
1422
+ }
1423
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1424
+ }
1425
+ return sanitized;
1426
+ }
1427
+ function isPlainObject(value) {
1428
+ return Object.prototype.toString.call(value) === "[object Object]";
1429
+ }
1430
+
1393
1431
  // ../../src/internal/CacheStackSnapshotManager.ts
1394
1432
  var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1395
1433
  var CacheStackSnapshotManager = class {
@@ -1414,7 +1452,16 @@ var CacheStackSnapshotManager = class {
1414
1452
  const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
1415
1453
  await Promise.all(
1416
1454
  batch.map(async (entry) => {
1417
- await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1455
+ await Promise.all(
1456
+ this.options.layers.map(async (layer) => {
1457
+ if (this.options.shouldSkipLayer(layer)) return;
1458
+ try {
1459
+ await layer.set(entry.key, entry.value, entry.ttl);
1460
+ } catch (error) {
1461
+ await this.options.handleLayerFailure(layer, "write", error);
1462
+ }
1463
+ })
1464
+ );
1418
1465
  await this.options.tagIndex.touch(entry.key);
1419
1466
  })
1420
1467
  );
@@ -1424,7 +1471,7 @@ var CacheStackSnapshotManager = class {
1424
1471
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
1425
1472
  const tempPath = import_node_path.default.join(
1426
1473
  import_node_path.default.dirname(targetPath),
1427
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1474
+ `.layercache-${process.pid}-${Date.now()}-${(0, import_node_crypto.randomBytes)(8).toString("hex")}.tmp`
1428
1475
  );
1429
1476
  let handle;
1430
1477
  try {
@@ -1524,7 +1571,13 @@ var CacheStackSnapshotManager = class {
1524
1571
  });
1525
1572
  }
1526
1573
  sanitizeSnapshotValue(value) {
1527
- return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1574
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1575
+ return sanitizeStructuredData(roundTripped, {
1576
+ label: "Snapshot value",
1577
+ maxDepth: 64,
1578
+ maxNodes: 1e4,
1579
+ createObject: () => /* @__PURE__ */ Object.create(null)
1580
+ });
1528
1581
  }
1529
1582
  };
1530
1583
 
@@ -1904,7 +1957,13 @@ var FetchRateLimiter = class {
1904
1957
  this.pendingBuckets.add(next.bucketKey);
1905
1958
  }
1906
1959
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1907
- this.drain();
1960
+ if (!this.drainTimer) {
1961
+ this.drainTimer = setTimeout(() => {
1962
+ this.drainTimer = void 0;
1963
+ this.drain();
1964
+ }, 0);
1965
+ this.drainTimer.unref?.();
1966
+ }
1908
1967
  });
1909
1968
  }
1910
1969
  }
@@ -1946,6 +2005,9 @@ var FetchRateLimiter = class {
1946
2005
  }
1947
2006
  if (this.buckets.size >= MAX_BUCKETS) {
1948
2007
  this.evictIdleBuckets();
2008
+ if (this.buckets.size >= MAX_BUCKETS) {
2009
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
2010
+ }
1949
2011
  }
1950
2012
  const bucket = { active: 0, startedAt: [] };
1951
2013
  this.buckets.set(bucketKey, bucket);
@@ -2424,38 +2486,6 @@ var TagIndex = class {
2424
2486
  }
2425
2487
  };
2426
2488
 
2427
- // ../../src/internal/StructuredDataSanitizer.ts
2428
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2429
- function sanitizeStructuredData(value, options) {
2430
- return sanitizeValue(value, 0, { count: 0 }, options);
2431
- }
2432
- function sanitizeValue(value, depth, state, options) {
2433
- state.count += 1;
2434
- if (state.count > options.maxNodes) {
2435
- throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
2436
- }
2437
- if (depth > options.maxDepth) {
2438
- throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
2439
- }
2440
- if (Array.isArray(value)) {
2441
- return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
2442
- }
2443
- if (!isPlainObject(value)) {
2444
- return value;
2445
- }
2446
- const sanitized = options.createObject?.() ?? {};
2447
- for (const [key, entry] of Object.entries(value)) {
2448
- if (DANGEROUS_KEYS.has(key)) {
2449
- continue;
2450
- }
2451
- sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
2452
- }
2453
- return sanitized;
2454
- }
2455
- function isPlainObject(value) {
2456
- return Object.prototype.toString.call(value) === "[object Object]";
2457
- }
2458
-
2459
2489
  // ../../src/serialization/JsonSerializer.ts
2460
2490
  var JsonSerializer = class {
2461
2491
  serialize(value) {
@@ -2619,6 +2649,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2619
2649
  tagIndex: this.tagIndex,
2620
2650
  snapshotSerializer: this.snapshotSerializer,
2621
2651
  readLayerEntry: this.readLayerEntry.bind(this),
2652
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2653
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
2622
2654
  qualifyKey: this.qualifyKey.bind(this),
2623
2655
  stripQualifiedKey: this.stripQualifiedKey.bind(this),
2624
2656
  validateCacheKey,
@@ -2643,6 +2675,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2643
2675
  layerWriter;
2644
2676
  snapshots;
2645
2677
  backgroundRefreshes = /* @__PURE__ */ new Map();
2678
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
2646
2679
  layerDegradedUntil = /* @__PURE__ */ new Map();
2647
2680
  maintenance = new CacheStackMaintenance();
2648
2681
  ttlResolver;
@@ -2887,7 +2920,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2887
2920
  }
2888
2921
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2889
2922
  const layer = this.layers[layerIndex];
2890
- if (!layer) continue;
2923
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2891
2924
  const keys = [...pending];
2892
2925
  if (keys.length === 0) {
2893
2926
  break;
@@ -2904,6 +2937,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2904
2937
  await layer.delete(key);
2905
2938
  continue;
2906
2939
  }
2940
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2941
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2942
+ }
2907
2943
  await this.tagIndex.touch(key);
2908
2944
  await this.backfill(key, stored, layerIndex - 1);
2909
2945
  resultsByKey.set(key, resolved.value);
@@ -3159,7 +3195,25 @@ var CacheStack = class extends import_node_events.EventEmitter {
3159
3195
  await this.unsubscribeInvalidation?.();
3160
3196
  await this.flushWriteBehindQueue();
3161
3197
  await this.maintenance.waitForGenerationCleanup();
3162
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
3198
+ for (const key of this.backgroundRefreshAbort.keys()) {
3199
+ this.backgroundRefreshAbort.set(key, true);
3200
+ }
3201
+ await Promise.allSettled(
3202
+ [...this.backgroundRefreshes.values()].map((promise) => {
3203
+ let timer;
3204
+ return Promise.race([
3205
+ promise,
3206
+ new Promise((resolve) => {
3207
+ timer = setTimeout(resolve, 5e3);
3208
+ timer.unref?.();
3209
+ })
3210
+ ]).finally(() => {
3211
+ if (timer) clearTimeout(timer);
3212
+ });
3213
+ })
3214
+ );
3215
+ this.backgroundRefreshes.clear();
3216
+ this.backgroundRefreshAbort.clear();
3163
3217
  this.maintenance.disposeWriteBehindTimer();
3164
3218
  this.fetchRateLimiter.dispose();
3165
3219
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
@@ -3426,15 +3480,19 @@ var CacheStack = class extends import_node_events.EventEmitter {
3426
3480
  }
3427
3481
  const clearEpoch = this.maintenance.currentClearEpoch();
3428
3482
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
3483
+ this.backgroundRefreshAbort.set(key, false);
3429
3484
  const refresh = (async () => {
3430
3485
  this.metricsCollector.increment("refreshes");
3431
3486
  try {
3487
+ if (this.backgroundRefreshAbort.get(key)) return;
3432
3488
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
3433
3489
  } catch (error) {
3490
+ if (this.backgroundRefreshAbort.get(key)) return;
3434
3491
  this.metricsCollector.increment("refreshErrors");
3435
3492
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
3436
3493
  } finally {
3437
3494
  this.backgroundRefreshes.delete(key);
3495
+ this.backgroundRefreshAbort.delete(key);
3438
3496
  }
3439
3497
  })();
3440
3498
  this.backgroundRefreshes.set(key, refresh);
@@ -3537,7 +3595,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3537
3595
  timer.unref?.();
3538
3596
  })
3539
3597
  ]);
3540
- if (result && typeof result === "object" && "kind" in result) {
3598
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
3541
3599
  if (result.kind === "error") {
3542
3600
  throw result.error;
3543
3601
  }
@@ -3555,7 +3613,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3555
3613
  }
3556
3614
  async observeOperation(name, attributes, execute) {
3557
3615
  const id = this.nextOperationId;
3558
- this.nextOperationId += 1;
3616
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
3559
3617
  this.emit("operation-start", { id, name, attributes });
3560
3618
  try {
3561
3619
  const result = await execute();
@@ -443,6 +443,7 @@ declare class CacheStack extends EventEmitter {
443
443
  private readonly layerWriter;
444
444
  private readonly snapshots;
445
445
  private readonly backgroundRefreshes;
446
+ private readonly backgroundRefreshAbort;
446
447
  private readonly layerDegradedUntil;
447
448
  private readonly maintenance;
448
449
  private readonly ttlResolver;
@@ -443,6 +443,7 @@ declare class CacheStack extends EventEmitter {
443
443
  private readonly layerWriter;
444
444
  private readonly snapshots;
445
445
  private readonly backgroundRefreshes;
446
+ private readonly backgroundRefreshAbort;
446
447
  private readonly layerDegradedUntil;
447
448
  private readonly maintenance;
448
449
  private readonly ttlResolver;