layercache 1.2.1 → 1.2.2

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
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  RedisTagIndex
3
- } from "./chunk-GF47Y3XR.js";
3
+ } from "./chunk-IXCMHVHP.js";
4
4
  import {
5
5
  MemoryLayer,
6
6
  TagIndex,
@@ -360,11 +360,12 @@ var CircuitBreakerManager = class {
360
360
 
361
361
  // src/internal/FetchRateLimiter.ts
362
362
  var FetchRateLimiter = class {
363
- active = 0;
364
363
  queue = [];
365
- startedAt = [];
364
+ buckets = /* @__PURE__ */ new Map();
365
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
366
+ nextFetcherBucketId = 0;
366
367
  drainTimer;
367
- async schedule(options, task) {
368
+ async schedule(options, context, task) {
368
369
  if (!options) {
369
370
  return task();
370
371
  }
@@ -372,8 +373,14 @@ var FetchRateLimiter = class {
372
373
  if (!normalized) {
373
374
  return task();
374
375
  }
375
- return new Promise((resolve, reject) => {
376
- this.queue.push({ options: normalized, task, resolve, reject });
376
+ return new Promise((resolve2, reject) => {
377
+ this.queue.push({
378
+ bucketKey: this.resolveBucketKey(normalized, context),
379
+ options: normalized,
380
+ task,
381
+ resolve: resolve2,
382
+ reject
383
+ });
377
384
  this.drain();
378
385
  });
379
386
  }
@@ -387,63 +394,109 @@ var FetchRateLimiter = class {
387
394
  return {
388
395
  maxConcurrent,
389
396
  intervalMs,
390
- maxPerInterval
397
+ maxPerInterval,
398
+ scope: options.scope ?? "global",
399
+ bucketKey: options.bucketKey
391
400
  };
392
401
  }
402
+ resolveBucketKey(options, context) {
403
+ if (options.bucketKey) {
404
+ return `custom:${options.bucketKey}`;
405
+ }
406
+ if (options.scope === "key") {
407
+ return `key:${context.key}`;
408
+ }
409
+ if (options.scope === "fetcher") {
410
+ const existing = this.fetcherBuckets.get(context.fetcher);
411
+ if (existing) {
412
+ return existing;
413
+ }
414
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
415
+ this.nextFetcherBucketId += 1;
416
+ this.fetcherBuckets.set(context.fetcher, bucket);
417
+ return bucket;
418
+ }
419
+ return "global";
420
+ }
393
421
  drain() {
394
422
  if (this.drainTimer) {
395
423
  clearTimeout(this.drainTimer);
396
424
  this.drainTimer = void 0;
397
425
  }
398
426
  while (this.queue.length > 0) {
399
- const next = this.queue[0];
400
- if (!next) {
427
+ let nextIndex = -1;
428
+ let nextWaitMs = Number.POSITIVE_INFINITY;
429
+ for (let index = 0; index < this.queue.length; index += 1) {
430
+ const next2 = this.queue[index];
431
+ if (!next2) {
432
+ continue;
433
+ }
434
+ const waitMs = this.waitTime(next2.bucketKey, next2.options);
435
+ if (waitMs <= 0) {
436
+ nextIndex = index;
437
+ break;
438
+ }
439
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
440
+ }
441
+ if (nextIndex < 0) {
442
+ if (Number.isFinite(nextWaitMs)) {
443
+ this.drainTimer = setTimeout(() => {
444
+ this.drainTimer = void 0;
445
+ this.drain();
446
+ }, nextWaitMs);
447
+ this.drainTimer.unref?.();
448
+ }
401
449
  return;
402
450
  }
403
- const waitMs = this.waitTime(next.options);
404
- if (waitMs > 0) {
405
- this.drainTimer = setTimeout(() => {
406
- this.drainTimer = void 0;
407
- this.drain();
408
- }, waitMs);
409
- this.drainTimer.unref?.();
451
+ const next = this.queue.splice(nextIndex, 1)[0];
452
+ if (!next) {
410
453
  return;
411
454
  }
412
- this.queue.shift();
413
- this.active += 1;
414
- this.startedAt.push(Date.now());
455
+ const bucket = this.bucketState(next.bucketKey);
456
+ bucket.active += 1;
457
+ bucket.startedAt.push(Date.now());
415
458
  void next.task().then(next.resolve, next.reject).finally(() => {
416
- this.active -= 1;
459
+ bucket.active -= 1;
417
460
  this.drain();
418
461
  });
419
462
  }
420
463
  }
421
- waitTime(options) {
464
+ waitTime(bucketKey, options) {
465
+ const bucket = this.bucketState(bucketKey);
422
466
  const now = Date.now();
423
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
467
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
424
468
  return 1;
425
469
  }
426
470
  if (!options.intervalMs || !options.maxPerInterval) {
427
471
  return 0;
428
472
  }
429
- this.prune(now, options.intervalMs);
430
- if (this.startedAt.length < options.maxPerInterval) {
473
+ this.prune(bucket, now, options.intervalMs);
474
+ if (bucket.startedAt.length < options.maxPerInterval) {
431
475
  return 0;
432
476
  }
433
- const oldest = this.startedAt[0];
477
+ const oldest = bucket.startedAt[0];
434
478
  if (!oldest) {
435
479
  return 0;
436
480
  }
437
481
  return Math.max(1, options.intervalMs - (now - oldest));
438
482
  }
439
- prune(now, intervalMs) {
440
- while (this.startedAt.length > 0) {
441
- const startedAt = this.startedAt[0];
483
+ prune(bucket, now, intervalMs) {
484
+ while (bucket.startedAt.length > 0) {
485
+ const startedAt = bucket.startedAt[0];
442
486
  if (startedAt === void 0 || now - startedAt < intervalMs) {
443
487
  break;
444
488
  }
445
- this.startedAt.shift();
489
+ bucket.startedAt.shift();
490
+ }
491
+ }
492
+ bucketState(bucketKey) {
493
+ const existing = this.buckets.get(bucketKey);
494
+ if (existing) {
495
+ return existing;
446
496
  }
497
+ const bucket = { active: 0, startedAt: [] };
498
+ this.buckets.set(bucketKey, bucket);
499
+ return bucket;
447
500
  }
448
501
  };
449
502
 
@@ -1314,6 +1367,7 @@ var CacheStack = class extends EventEmitter {
1314
1367
  try {
1315
1368
  fetched = await this.fetchRateLimiter.schedule(
1316
1369
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
1370
+ { key, fetcher },
1317
1371
  fetcher
1318
1372
  );
1319
1373
  this.circuitBreakerManager.recordSuccess(key);
@@ -1571,7 +1625,8 @@ var CacheStack = class extends EventEmitter {
1571
1625
  return {
1572
1626
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
1573
1627
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
1574
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
1628
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
1629
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
1575
1630
  };
1576
1631
  }
1577
1632
  async deleteKeys(keys) {
@@ -1631,7 +1686,7 @@ var CacheStack = class extends EventEmitter {
1631
1686
  return String(error);
1632
1687
  }
1633
1688
  sleep(ms) {
1634
- return new Promise((resolve) => setTimeout(resolve, ms));
1689
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1635
1690
  }
1636
1691
  shouldBroadcastL1Invalidation() {
1637
1692
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
@@ -1776,6 +1831,8 @@ var CacheStack = class extends EventEmitter {
1776
1831
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
1777
1832
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
1778
1833
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
1834
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
1835
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
1779
1836
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
1780
1837
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
1781
1838
  if (this.options.generation !== void 0) {
@@ -1795,6 +1852,7 @@ var CacheStack = class extends EventEmitter {
1795
1852
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
1796
1853
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
1797
1854
  this.validateCircuitBreakerOptions(options.circuitBreaker);
1855
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
1798
1856
  }
1799
1857
  validateLayerNumberOption(name, value) {
1800
1858
  if (value === void 0) {
@@ -1819,6 +1877,20 @@ var CacheStack = class extends EventEmitter {
1819
1877
  throw new Error(`${name} must be a positive finite number.`);
1820
1878
  }
1821
1879
  }
1880
+ validateRateLimitOptions(name, options) {
1881
+ if (!options) {
1882
+ return;
1883
+ }
1884
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
1885
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
1886
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
1887
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
1888
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
1889
+ }
1890
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
1891
+ throw new Error(`${name}.bucketKey must not be empty.`);
1892
+ }
1893
+ }
1822
1894
  validateNonNegativeNumber(name, value) {
1823
1895
  if (!Number.isFinite(value) || value < 0) {
1824
1896
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -2224,15 +2296,35 @@ import { promisify } from "util";
2224
2296
  import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
2225
2297
 
2226
2298
  // src/serialization/JsonSerializer.ts
2299
+ var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2227
2300
  var JsonSerializer = class {
2228
2301
  serialize(value) {
2229
2302
  return JSON.stringify(value);
2230
2303
  }
2231
2304
  deserialize(payload) {
2232
2305
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2233
- return JSON.parse(normalized);
2306
+ return sanitizeJsonValue(JSON.parse(normalized));
2234
2307
  }
2235
2308
  };
2309
+ function sanitizeJsonValue(value) {
2310
+ if (Array.isArray(value)) {
2311
+ return value.map((entry) => sanitizeJsonValue(entry));
2312
+ }
2313
+ if (!isPlainObject(value)) {
2314
+ return value;
2315
+ }
2316
+ const sanitized = {};
2317
+ for (const [key, entry] of Object.entries(value)) {
2318
+ if (DANGEROUS_JSON_KEYS.has(key)) {
2319
+ continue;
2320
+ }
2321
+ sanitized[key] = sanitizeJsonValue(entry);
2322
+ }
2323
+ return sanitized;
2324
+ }
2325
+ function isPlainObject(value) {
2326
+ return Object.prototype.toString.call(value) === "[object Object]";
2327
+ }
2236
2328
 
2237
2329
  // src/layers/RedisLayer.ts
2238
2330
  var BATCH_DELETE_SIZE = 500;
@@ -2479,7 +2571,7 @@ var RedisLayer = class {
2479
2571
  // src/layers/DiskLayer.ts
2480
2572
  import { createHash } from "crypto";
2481
2573
  import { promises as fs } from "fs";
2482
- import { join } from "path";
2574
+ import { join, resolve } from "path";
2483
2575
  var DiskLayer = class {
2484
2576
  name;
2485
2577
  defaultTtl;
@@ -2489,11 +2581,11 @@ var DiskLayer = class {
2489
2581
  maxFiles;
2490
2582
  writeQueue = Promise.resolve();
2491
2583
  constructor(options) {
2492
- this.directory = options.directory;
2584
+ this.directory = this.resolveDirectory(options.directory);
2493
2585
  this.defaultTtl = options.ttl;
2494
2586
  this.name = options.name ?? "disk";
2495
2587
  this.serializer = options.serializer ?? new JsonSerializer();
2496
- this.maxFiles = options.maxFiles;
2588
+ this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
2497
2589
  }
2498
2590
  async get(key) {
2499
2591
  return unwrapStoredValue(await this.getEntry(key));
@@ -2508,7 +2600,7 @@ var DiskLayer = class {
2508
2600
  }
2509
2601
  let entry;
2510
2602
  try {
2511
- entry = this.serializer.deserialize(raw);
2603
+ entry = this.deserializeEntry(raw);
2512
2604
  } catch {
2513
2605
  await this.safeDelete(filePath);
2514
2606
  return null;
@@ -2559,8 +2651,9 @@ var DiskLayer = class {
2559
2651
  }
2560
2652
  let entry;
2561
2653
  try {
2562
- entry = this.serializer.deserialize(raw);
2654
+ entry = this.deserializeEntry(raw);
2563
2655
  } catch {
2656
+ await this.safeDelete(filePath);
2564
2657
  return null;
2565
2658
  }
2566
2659
  if (entry.expiresAt === null) {
@@ -2617,7 +2710,7 @@ var DiskLayer = class {
2617
2710
  }
2618
2711
  let entry;
2619
2712
  try {
2620
- entry = this.serializer.deserialize(raw);
2713
+ entry = this.deserializeEntry(raw);
2621
2714
  } catch {
2622
2715
  await this.safeDelete(filePath);
2623
2716
  return;
@@ -2649,6 +2742,31 @@ var DiskLayer = class {
2649
2742
  const hash = createHash("sha256").update(key).digest("hex");
2650
2743
  return join(this.directory, `${hash}.lc`);
2651
2744
  }
2745
+ resolveDirectory(directory) {
2746
+ if (typeof directory !== "string" || directory.trim().length === 0) {
2747
+ throw new Error("DiskLayer.directory must be a non-empty path.");
2748
+ }
2749
+ if (directory.includes("\0")) {
2750
+ throw new Error("DiskLayer.directory must not contain null bytes.");
2751
+ }
2752
+ return resolve(directory);
2753
+ }
2754
+ normalizeMaxFiles(maxFiles) {
2755
+ if (maxFiles === void 0) {
2756
+ return void 0;
2757
+ }
2758
+ if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
2759
+ throw new Error("DiskLayer.maxFiles must be a positive integer.");
2760
+ }
2761
+ return maxFiles;
2762
+ }
2763
+ deserializeEntry(raw) {
2764
+ const entry = this.serializer.deserialize(raw);
2765
+ if (!isDiskEntry(entry)) {
2766
+ throw new Error("Invalid disk cache entry.");
2767
+ }
2768
+ return entry;
2769
+ }
2652
2770
  async safeDelete(filePath) {
2653
2771
  try {
2654
2772
  await fs.unlink(filePath);
@@ -2693,6 +2811,14 @@ var DiskLayer = class {
2693
2811
  await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
2694
2812
  }
2695
2813
  };
2814
+ function isDiskEntry(value) {
2815
+ if (!value || typeof value !== "object") {
2816
+ return false;
2817
+ }
2818
+ const candidate = value;
2819
+ const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
2820
+ return typeof candidate.key === "string" && validExpiry && "value" in candidate;
2821
+ }
2696
2822
 
2697
2823
  // src/layers/MemcachedLayer.ts
2698
2824
  var MemcachedLayer = class {
@@ -2772,6 +2898,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
2772
2898
  end
2773
2899
  return 0
2774
2900
  `;
2901
+ var RENEW_SCRIPT = `
2902
+ if redis.call("get", KEYS[1]) == ARGV[1] then
2903
+ return redis.call("pexpire", KEYS[1], ARGV[2])
2904
+ end
2905
+ return 0
2906
+ `;
2775
2907
  var RedisSingleFlightCoordinator = class {
2776
2908
  client;
2777
2909
  prefix;
@@ -2784,14 +2916,29 @@ var RedisSingleFlightCoordinator = class {
2784
2916
  const token = randomUUID();
2785
2917
  const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
2786
2918
  if (acquired === "OK") {
2919
+ const renewTimer = this.startLeaseRenewal(lockKey, token, options);
2787
2920
  try {
2788
2921
  return await worker();
2789
2922
  } finally {
2923
+ if (renewTimer) {
2924
+ clearInterval(renewTimer);
2925
+ }
2790
2926
  await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
2791
2927
  }
2792
2928
  }
2793
2929
  return waiter();
2794
2930
  }
2931
+ startLeaseRenewal(lockKey, token, options) {
2932
+ const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
2933
+ if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
2934
+ return void 0;
2935
+ }
2936
+ const timer = setInterval(() => {
2937
+ void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
2938
+ }, renewIntervalMs);
2939
+ timer.unref?.();
2940
+ return timer;
2941
+ }
2795
2942
  };
2796
2943
 
2797
2944
  // src/metrics/PrometheusExporter.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.1",
4
- "description": "Unified multi-layer caching for Node.js with memory, Redis, stampede prevention, and invalidation helpers.",
3
+ "version": "1.2.2",
4
+ "description": "Hardened multi-layer caching for Node.js with memory, Redis, stampede prevention, and operational invalidation helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -598,11 +598,12 @@ var CircuitBreakerManager = class {
598
598
 
599
599
  // ../../src/internal/FetchRateLimiter.ts
600
600
  var FetchRateLimiter = class {
601
- active = 0;
602
601
  queue = [];
603
- startedAt = [];
602
+ buckets = /* @__PURE__ */ new Map();
603
+ fetcherBuckets = /* @__PURE__ */ new WeakMap();
604
+ nextFetcherBucketId = 0;
604
605
  drainTimer;
605
- async schedule(options, task) {
606
+ async schedule(options, context, task) {
606
607
  if (!options) {
607
608
  return task();
608
609
  }
@@ -611,7 +612,13 @@ var FetchRateLimiter = class {
611
612
  return task();
612
613
  }
613
614
  return new Promise((resolve, reject) => {
614
- this.queue.push({ options: normalized, task, resolve, reject });
615
+ this.queue.push({
616
+ bucketKey: this.resolveBucketKey(normalized, context),
617
+ options: normalized,
618
+ task,
619
+ resolve,
620
+ reject
621
+ });
615
622
  this.drain();
616
623
  });
617
624
  }
@@ -625,63 +632,109 @@ var FetchRateLimiter = class {
625
632
  return {
626
633
  maxConcurrent,
627
634
  intervalMs,
628
- maxPerInterval
635
+ maxPerInterval,
636
+ scope: options.scope ?? "global",
637
+ bucketKey: options.bucketKey
629
638
  };
630
639
  }
640
+ resolveBucketKey(options, context) {
641
+ if (options.bucketKey) {
642
+ return `custom:${options.bucketKey}`;
643
+ }
644
+ if (options.scope === "key") {
645
+ return `key:${context.key}`;
646
+ }
647
+ if (options.scope === "fetcher") {
648
+ const existing = this.fetcherBuckets.get(context.fetcher);
649
+ if (existing) {
650
+ return existing;
651
+ }
652
+ const bucket = `fetcher:${this.nextFetcherBucketId}`;
653
+ this.nextFetcherBucketId += 1;
654
+ this.fetcherBuckets.set(context.fetcher, bucket);
655
+ return bucket;
656
+ }
657
+ return "global";
658
+ }
631
659
  drain() {
632
660
  if (this.drainTimer) {
633
661
  clearTimeout(this.drainTimer);
634
662
  this.drainTimer = void 0;
635
663
  }
636
664
  while (this.queue.length > 0) {
637
- const next = this.queue[0];
638
- if (!next) {
665
+ let nextIndex = -1;
666
+ let nextWaitMs = Number.POSITIVE_INFINITY;
667
+ for (let index = 0; index < this.queue.length; index += 1) {
668
+ const next2 = this.queue[index];
669
+ if (!next2) {
670
+ continue;
671
+ }
672
+ const waitMs = this.waitTime(next2.bucketKey, next2.options);
673
+ if (waitMs <= 0) {
674
+ nextIndex = index;
675
+ break;
676
+ }
677
+ nextWaitMs = Math.min(nextWaitMs, waitMs);
678
+ }
679
+ if (nextIndex < 0) {
680
+ if (Number.isFinite(nextWaitMs)) {
681
+ this.drainTimer = setTimeout(() => {
682
+ this.drainTimer = void 0;
683
+ this.drain();
684
+ }, nextWaitMs);
685
+ this.drainTimer.unref?.();
686
+ }
639
687
  return;
640
688
  }
641
- const waitMs = this.waitTime(next.options);
642
- if (waitMs > 0) {
643
- this.drainTimer = setTimeout(() => {
644
- this.drainTimer = void 0;
645
- this.drain();
646
- }, waitMs);
647
- this.drainTimer.unref?.();
689
+ const next = this.queue.splice(nextIndex, 1)[0];
690
+ if (!next) {
648
691
  return;
649
692
  }
650
- this.queue.shift();
651
- this.active += 1;
652
- this.startedAt.push(Date.now());
693
+ const bucket = this.bucketState(next.bucketKey);
694
+ bucket.active += 1;
695
+ bucket.startedAt.push(Date.now());
653
696
  void next.task().then(next.resolve, next.reject).finally(() => {
654
- this.active -= 1;
697
+ bucket.active -= 1;
655
698
  this.drain();
656
699
  });
657
700
  }
658
701
  }
659
- waitTime(options) {
702
+ waitTime(bucketKey, options) {
703
+ const bucket = this.bucketState(bucketKey);
660
704
  const now = Date.now();
661
- if (options.maxConcurrent && this.active >= options.maxConcurrent) {
705
+ if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
662
706
  return 1;
663
707
  }
664
708
  if (!options.intervalMs || !options.maxPerInterval) {
665
709
  return 0;
666
710
  }
667
- this.prune(now, options.intervalMs);
668
- if (this.startedAt.length < options.maxPerInterval) {
711
+ this.prune(bucket, now, options.intervalMs);
712
+ if (bucket.startedAt.length < options.maxPerInterval) {
669
713
  return 0;
670
714
  }
671
- const oldest = this.startedAt[0];
715
+ const oldest = bucket.startedAt[0];
672
716
  if (!oldest) {
673
717
  return 0;
674
718
  }
675
719
  return Math.max(1, options.intervalMs - (now - oldest));
676
720
  }
677
- prune(now, intervalMs) {
678
- while (this.startedAt.length > 0) {
679
- const startedAt = this.startedAt[0];
721
+ prune(bucket, now, intervalMs) {
722
+ while (bucket.startedAt.length > 0) {
723
+ const startedAt = bucket.startedAt[0];
680
724
  if (startedAt === void 0 || now - startedAt < intervalMs) {
681
725
  break;
682
726
  }
683
- this.startedAt.shift();
727
+ bucket.startedAt.shift();
728
+ }
729
+ }
730
+ bucketState(bucketKey) {
731
+ const existing = this.buckets.get(bucketKey);
732
+ if (existing) {
733
+ return existing;
684
734
  }
735
+ const bucket = { active: 0, startedAt: [] };
736
+ this.buckets.set(bucketKey, bucket);
737
+ return bucket;
685
738
  }
686
739
  };
687
740
 
@@ -1787,6 +1840,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
1787
1840
  try {
1788
1841
  fetched = await this.fetchRateLimiter.schedule(
1789
1842
  options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
1843
+ { key, fetcher },
1790
1844
  fetcher
1791
1845
  );
1792
1846
  this.circuitBreakerManager.recordSuccess(key);
@@ -2044,7 +2098,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2044
2098
  return {
2045
2099
  leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
2046
2100
  waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
2047
- pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
2101
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
2102
+ renewIntervalMs: this.options.singleFlightRenewIntervalMs
2048
2103
  };
2049
2104
  }
2050
2105
  async deleteKeys(keys) {
@@ -2249,6 +2304,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2249
2304
  this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
2250
2305
  this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
2251
2306
  this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
2307
+ this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
2308
+ this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
2252
2309
  this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
2253
2310
  this.validateCircuitBreakerOptions(this.options.circuitBreaker);
2254
2311
  if (this.options.generation !== void 0) {
@@ -2268,6 +2325,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2268
2325
  this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
2269
2326
  this.validateAdaptiveTtlOptions(options.adaptiveTtl);
2270
2327
  this.validateCircuitBreakerOptions(options.circuitBreaker);
2328
+ this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
2271
2329
  }
2272
2330
  validateLayerNumberOption(name, value) {
2273
2331
  if (value === void 0) {
@@ -2292,6 +2350,20 @@ var CacheStack = class extends import_node_events.EventEmitter {
2292
2350
  throw new Error(`${name} must be a positive finite number.`);
2293
2351
  }
2294
2352
  }
2353
+ validateRateLimitOptions(name, options) {
2354
+ if (!options) {
2355
+ return;
2356
+ }
2357
+ this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2358
+ this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2359
+ this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2360
+ if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2361
+ throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2362
+ }
2363
+ if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2364
+ throw new Error(`${name}.bucketKey must not be empty.`);
2365
+ }
2366
+ }
2295
2367
  validateNonNegativeNumber(name, value) {
2296
2368
  if (!Number.isFinite(value) || value < 0) {
2297
2369
  throw new Error(`${name} must be a non-negative finite number.`);
@@ -156,6 +156,7 @@ interface CacheSingleFlightExecutionOptions {
156
156
  leaseMs: number;
157
157
  waitTimeoutMs: number;
158
158
  pollIntervalMs: number;
159
+ renewIntervalMs?: number;
159
160
  }
160
161
  interface CacheSingleFlightCoordinator {
161
162
  execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
@@ -189,6 +190,7 @@ interface CacheStackOptions {
189
190
  singleFlightLeaseMs?: number;
190
191
  singleFlightTimeoutMs?: number;
191
192
  singleFlightPollMs?: number;
193
+ singleFlightRenewIntervalMs?: number;
192
194
  /**
193
195
  * Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
194
196
  * before the oldest entries are pruned. Prevents unbounded memory growth.
@@ -219,6 +221,8 @@ interface CacheRateLimitOptions {
219
221
  maxConcurrent?: number;
220
222
  intervalMs?: number;
221
223
  maxPerInterval?: number;
224
+ scope?: 'global' | 'key' | 'fetcher';
225
+ bucketKey?: string;
222
226
  }
223
227
  interface CacheWriteBehindOptions {
224
228
  flushIntervalMs?: number;
@@ -527,6 +531,7 @@ declare class CacheStack extends EventEmitter {
527
531
  private validateWriteOptions;
528
532
  private validateLayerNumberOption;
529
533
  private validatePositiveNumber;
534
+ private validateRateLimitOptions;
530
535
  private validateNonNegativeNumber;
531
536
  private validateCacheKey;
532
537
  private validateTtlPolicy;