layercache 1.2.7 → 1.2.8

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
@@ -468,102 +468,6 @@ function createInstanceId() {
468
468
  return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
469
469
  }
470
470
 
471
- // src/internal/CacheSnapshotFile.ts
472
- function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
473
- const relative = path.relative(realBaseDir, candidatePath);
474
- return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
475
- }
476
- async function findExistingAncestor(directory, fs2, path) {
477
- let current = directory;
478
- while (true) {
479
- try {
480
- await fs2.lstat(current);
481
- return current;
482
- } catch (error) {
483
- if (error.code !== "ENOENT") {
484
- throw error;
485
- }
486
- }
487
- const parent = path.dirname(current);
488
- if (parent === current) {
489
- return current;
490
- }
491
- current = parent;
492
- }
493
- }
494
- async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
495
- if (filePath.length === 0) {
496
- throw new Error("filePath must not be empty.");
497
- }
498
- if (filePath.includes("\0")) {
499
- throw new Error("filePath must not contain null bytes.");
500
- }
501
- const { promises: fs2 } = await import("fs");
502
- const path = await import("path");
503
- const resolved = path.resolve(filePath);
504
- const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
505
- if (baseDir === false) {
506
- return resolved;
507
- }
508
- await fs2.mkdir(baseDir, { recursive: true });
509
- const realBaseDir = await fs2.realpath(baseDir);
510
- if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
511
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
512
- }
513
- if (mode === "read") {
514
- const realTarget = await fs2.realpath(resolved);
515
- if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
516
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
517
- }
518
- return realTarget;
519
- }
520
- const parentDir = path.dirname(resolved);
521
- const existingAncestor = await findExistingAncestor(parentDir, fs2, path);
522
- const realExistingAncestor = await fs2.realpath(existingAncestor);
523
- if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
524
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
525
- }
526
- await fs2.mkdir(parentDir, { recursive: true });
527
- const realParentDir = await fs2.realpath(parentDir);
528
- if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
529
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
530
- }
531
- const targetPath = path.join(realParentDir, path.basename(resolved));
532
- try {
533
- const existing = await fs2.lstat(targetPath);
534
- if (existing.isSymbolicLink()) {
535
- throw new Error("filePath must not point to a symbolic link.");
536
- }
537
- } catch (error) {
538
- if (error.code !== "ENOENT") {
539
- throw error;
540
- }
541
- }
542
- return targetPath;
543
- }
544
- async function readUtf8HandleWithLimit(handle, byteLimit) {
545
- if (byteLimit === false) {
546
- return handle.readFile({ encoding: "utf8" });
547
- }
548
- const chunks = [];
549
- let totalBytes = 0;
550
- let position = 0;
551
- while (true) {
552
- const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
553
- const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
554
- if (bytesRead === 0) {
555
- break;
556
- }
557
- totalBytes += bytesRead;
558
- if (totalBytes > byteLimit) {
559
- throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
560
- }
561
- chunks.push(buffer.subarray(0, bytesRead));
562
- position += bytesRead;
563
- }
564
- return Buffer.concat(chunks).toString("utf8");
565
- }
566
-
567
471
  // src/internal/CacheStackGeneration.ts
568
472
  var DEFAULT_GENERATION_CLEANUP_BATCH_SIZE = 500;
569
473
  function generationPrefix(generation) {
@@ -611,6 +515,205 @@ function planGenerationCleanupBatches(keys, generationCleanup) {
611
515
  return batches;
612
516
  }
613
517
 
518
+ // src/internal/CacheStackInvalidationSupport.ts
519
+ var CacheStackInvalidationSupport = class {
520
+ constructor(options) {
521
+ this.options = options;
522
+ }
523
+ options;
524
+ async collectKeysForTag(tag, maxKeys) {
525
+ const keys = /* @__PURE__ */ new Set();
526
+ if (this.options.tagIndex.forEachKeyForTag) {
527
+ await this.options.tagIndex.forEachKeyForTag(tag, async (key) => {
528
+ keys.add(key);
529
+ this.assertWithinInvalidationKeyLimit(keys.size, maxKeys);
530
+ });
531
+ return [...keys];
532
+ }
533
+ for (const key of await this.options.tagIndex.keysForTag(tag)) {
534
+ keys.add(key);
535
+ this.assertWithinInvalidationKeyLimit(keys.size, maxKeys);
536
+ }
537
+ return [...keys];
538
+ }
539
+ intersectKeys(groups) {
540
+ if (groups.length === 0) {
541
+ return [];
542
+ }
543
+ const [firstGroup, ...rest] = groups;
544
+ const restSets = rest.map((group) => new Set(group));
545
+ return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)));
546
+ }
547
+ async deleteKeysFromLayers(layers, keys) {
548
+ await Promise.all(
549
+ layers.map(async (layer) => {
550
+ if (this.options.shouldSkipLayer(layer)) {
551
+ return;
552
+ }
553
+ if (layer.deleteMany) {
554
+ try {
555
+ await layer.deleteMany(keys);
556
+ } catch (error) {
557
+ await this.options.handleLayerFailure(layer, "delete", error);
558
+ }
559
+ return;
560
+ }
561
+ await Promise.all(
562
+ keys.map(async (key) => {
563
+ try {
564
+ await layer.delete(key);
565
+ } catch (error) {
566
+ await this.options.handleLayerFailure(layer, "delete", error);
567
+ }
568
+ })
569
+ );
570
+ })
571
+ );
572
+ }
573
+ assertWithinInvalidationKeyLimit(size, maxKeys) {
574
+ if (maxKeys !== false && size > maxKeys) {
575
+ throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
576
+ }
577
+ }
578
+ };
579
+
580
+ // src/internal/CacheStackLayerWriter.ts
581
+ var CacheStackLayerWriter = class {
582
+ constructor(options) {
583
+ this.options = options;
584
+ }
585
+ options;
586
+ async writeAcrossLayers(key, kind, value, writeOptions) {
587
+ const now = Date.now();
588
+ const clearEpoch = this.options.maintenance.currentClearEpoch();
589
+ const keyEpoch = this.options.maintenance.currentKeyEpoch(key);
590
+ const immediateOperations = [];
591
+ const deferredOperations = [];
592
+ for (const layer of this.options.layers) {
593
+ const operation = async () => {
594
+ if (this.options.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
595
+ return;
596
+ }
597
+ if (this.options.shouldSkipLayer(layer)) {
598
+ return;
599
+ }
600
+ const entry = this.buildLayerSetEntry(layer, key, kind, value, writeOptions, now);
601
+ try {
602
+ await layer.set(entry.key, entry.value, entry.ttl);
603
+ } catch (error) {
604
+ await this.options.handleLayerFailure(layer, "write", error);
605
+ }
606
+ };
607
+ if (this.options.shouldWriteBehind(layer)) {
608
+ deferredOperations.push(operation);
609
+ } else {
610
+ immediateOperations.push(operation);
611
+ }
612
+ }
613
+ await this.executeLayerOperations(immediateOperations, { key, action: kind === "empty" ? "negative-set" : "set" });
614
+ await Promise.all(deferredOperations.map((operation) => this.options.enqueueWriteBehind(operation)));
615
+ }
616
+ async writeBatch(entries) {
617
+ const now = Date.now();
618
+ const clearEpoch = this.options.maintenance.currentClearEpoch();
619
+ const entryEpochs = new Map(
620
+ entries.map((entry) => [entry.key, this.options.maintenance.currentKeyEpoch(entry.key)])
621
+ );
622
+ const entriesByLayer = /* @__PURE__ */ new Map();
623
+ const immediateOperations = [];
624
+ const deferredOperations = [];
625
+ for (const entry of entries) {
626
+ for (const layer of this.options.layers) {
627
+ if (this.options.shouldSkipLayer(layer)) {
628
+ continue;
629
+ }
630
+ const layerEntry = this.buildLayerSetEntry(layer, entry.key, "value", entry.value, entry.options, now);
631
+ const bucket = entriesByLayer.get(layer) ?? [];
632
+ bucket.push(layerEntry);
633
+ entriesByLayer.set(layer, bucket);
634
+ }
635
+ }
636
+ for (const [layer, layerEntries] of entriesByLayer.entries()) {
637
+ const operation = async () => {
638
+ if (clearEpoch !== this.options.maintenance.currentClearEpoch()) {
639
+ return;
640
+ }
641
+ const activeEntries = layerEntries.filter(
642
+ (entry) => (entryEpochs.get(entry.key) ?? 0) === this.options.maintenance.currentKeyEpoch(entry.key)
643
+ );
644
+ if (activeEntries.length === 0) {
645
+ return;
646
+ }
647
+ try {
648
+ if (layer.setMany) {
649
+ await layer.setMany(activeEntries);
650
+ return;
651
+ }
652
+ await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
653
+ } catch (error) {
654
+ await this.options.handleLayerFailure(layer, "write", error);
655
+ }
656
+ };
657
+ if (this.options.shouldWriteBehind(layer)) {
658
+ deferredOperations.push(operation);
659
+ } else {
660
+ immediateOperations.push(operation);
661
+ }
662
+ }
663
+ await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
664
+ await Promise.all(deferredOperations.map((operation) => this.options.enqueueWriteBehind(operation)));
665
+ return { clearEpoch, entryEpochs };
666
+ }
667
+ async executeLayerOperations(operations, context) {
668
+ if (this.options.writePolicy !== "best-effort") {
669
+ await Promise.all(operations.map((operation) => operation()));
670
+ return;
671
+ }
672
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
673
+ const failures = results.filter((result) => result.status === "rejected");
674
+ if (failures.length === 0) {
675
+ return;
676
+ }
677
+ this.options.onWriteFailures(
678
+ context,
679
+ failures.map((failure) => failure.reason)
680
+ );
681
+ if (failures.length === operations.length) {
682
+ throw new AggregateError(
683
+ failures.map((failure) => failure.reason),
684
+ `${context.action} failed for every cache layer`
685
+ );
686
+ }
687
+ }
688
+ buildLayerSetEntry(layer, key, kind, value, writeOptions, now) {
689
+ const freshTtl = this.options.resolveFreshTtl(key, layer.name, kind, writeOptions, layer.defaultTtl, value);
690
+ const staleWhileRevalidate = this.options.resolveLayerSeconds(
691
+ layer.name,
692
+ writeOptions?.staleWhileRevalidate,
693
+ this.options.globalStaleWhileRevalidate
694
+ );
695
+ const staleIfError = this.options.resolveLayerSeconds(
696
+ layer.name,
697
+ writeOptions?.staleIfError,
698
+ this.options.globalStaleIfError
699
+ );
700
+ const payload = createStoredValueEnvelope({
701
+ kind,
702
+ value,
703
+ freshTtlSeconds: freshTtl,
704
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
705
+ staleIfErrorSeconds: staleIfError,
706
+ now
707
+ });
708
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
709
+ return {
710
+ key,
711
+ value: payload,
712
+ ttl
713
+ };
714
+ }
715
+ };
716
+
614
717
  // src/internal/CacheStackMaintenance.ts
615
718
  var CacheStackMaintenance = class {
616
719
  keyEpochs = /* @__PURE__ */ new Map();
@@ -694,57 +797,295 @@ var CacheStackMaintenance = class {
694
797
  await this.flushWriteBehindQueue(options, flushBatch);
695
798
  }
696
799
  }
697
- scheduleGenerationCleanup(generation, task, onError) {
698
- const scheduledTask = (this.generationCleanupPromise ?? Promise.resolve()).then(() => task(generation)).catch((error) => {
699
- onError(generation, error);
700
- });
701
- this.generationCleanupPromise = scheduledTask.finally(() => {
702
- if (this.generationCleanupPromise === scheduledTask) {
703
- this.generationCleanupPromise = void 0;
800
+ scheduleGenerationCleanup(generation, task, onError) {
801
+ const scheduledTask = (this.generationCleanupPromise ?? Promise.resolve()).then(() => task(generation)).catch((error) => {
802
+ onError(generation, error);
803
+ });
804
+ this.generationCleanupPromise = scheduledTask.finally(() => {
805
+ if (this.generationCleanupPromise === scheduledTask) {
806
+ this.generationCleanupPromise = void 0;
807
+ }
808
+ });
809
+ }
810
+ async waitForGenerationCleanup() {
811
+ await this.generationCleanupPromise;
812
+ }
813
+ };
814
+
815
+ // src/internal/CacheStackRuntimePolicy.ts
816
+ function shouldSkipLayer(degradedUntil, now = Date.now()) {
817
+ return degradedUntil !== void 0 && degradedUntil > now;
818
+ }
819
+ function shouldStartBackgroundRefresh({
820
+ isDisconnecting,
821
+ hasRefreshInFlight
822
+ }) {
823
+ return !isDisconnecting && !hasRefreshInFlight;
824
+ }
825
+ function resolveRecoverableLayerFailure(gracefulDegradation, now = Date.now()) {
826
+ if (!gracefulDegradation) {
827
+ return { degrade: false };
828
+ }
829
+ const retryAfterMs = typeof gracefulDegradation === "object" ? gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
830
+ return {
831
+ degrade: true,
832
+ degradedUntil: now + retryAfterMs
833
+ };
834
+ }
835
+ function planFreshReadPolicies({
836
+ stored,
837
+ hasFetcher,
838
+ slidingTtl,
839
+ refreshAheadSeconds
840
+ }) {
841
+ const refreshedStored = slidingTtl && isStoredValueEnvelope(stored) ? refreshStoredEnvelope(stored) : void 0;
842
+ const refreshedStoredTtl = refreshedStored ? remainingStoredTtlSeconds(refreshedStored) ?? void 0 : void 0;
843
+ const remainingFreshTtl = remainingFreshTtlSeconds(stored) ?? 0;
844
+ return {
845
+ refreshedStored,
846
+ refreshedStoredTtl,
847
+ shouldScheduleBackgroundRefresh: hasFetcher && refreshAheadSeconds > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAheadSeconds
848
+ };
849
+ }
850
+
851
+ // src/internal/CacheStackSnapshotManager.ts
852
+ import { constants, promises as fs } from "fs";
853
+ import path from "path";
854
+
855
+ // src/internal/CacheSnapshotFile.ts
856
+ function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path2) {
857
+ const relative = path2.relative(realBaseDir, candidatePath);
858
+ return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path2.isAbsolute(relative));
859
+ }
860
+ async function findExistingAncestor(directory, fs3, path2) {
861
+ let current = directory;
862
+ while (true) {
863
+ try {
864
+ await fs3.lstat(current);
865
+ return current;
866
+ } catch (error) {
867
+ if (error.code !== "ENOENT") {
868
+ throw error;
869
+ }
870
+ }
871
+ const parent = path2.dirname(current);
872
+ if (parent === current) {
873
+ return current;
874
+ }
875
+ current = parent;
876
+ }
877
+ }
878
+ async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
879
+ if (filePath.length === 0) {
880
+ throw new Error("filePath must not be empty.");
881
+ }
882
+ if (filePath.includes("\0")) {
883
+ throw new Error("filePath must not contain null bytes.");
884
+ }
885
+ const { promises: fs3 } = await import("fs");
886
+ const path2 = await import("path");
887
+ const resolved = path2.resolve(filePath);
888
+ const baseDir = snapshotBaseDir === false ? false : path2.resolve(snapshotBaseDir ?? cwd);
889
+ if (baseDir === false) {
890
+ return resolved;
891
+ }
892
+ await fs3.mkdir(baseDir, { recursive: true });
893
+ const realBaseDir = await fs3.realpath(baseDir);
894
+ if (!isWithinSnapshotBase(realBaseDir, resolved, path2.sep, path2)) {
895
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
896
+ }
897
+ if (mode === "read") {
898
+ const realTarget = await fs3.realpath(resolved);
899
+ if (!isWithinSnapshotBase(realBaseDir, realTarget, path2.sep, path2)) {
900
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
901
+ }
902
+ return realTarget;
903
+ }
904
+ const parentDir = path2.dirname(resolved);
905
+ const existingAncestor = await findExistingAncestor(parentDir, fs3, path2);
906
+ const realExistingAncestor = await fs3.realpath(existingAncestor);
907
+ if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path2.sep, path2)) {
908
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
909
+ }
910
+ await fs3.mkdir(parentDir, { recursive: true });
911
+ const realParentDir = await fs3.realpath(parentDir);
912
+ if (!isWithinSnapshotBase(realBaseDir, realParentDir, path2.sep, path2)) {
913
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
914
+ }
915
+ const targetPath = path2.join(realParentDir, path2.basename(resolved));
916
+ try {
917
+ const existing = await fs3.lstat(targetPath);
918
+ if (existing.isSymbolicLink()) {
919
+ throw new Error("filePath must not point to a symbolic link.");
920
+ }
921
+ } catch (error) {
922
+ if (error.code !== "ENOENT") {
923
+ throw error;
924
+ }
925
+ }
926
+ return targetPath;
927
+ }
928
+ async function readUtf8HandleWithLimit(handle, byteLimit) {
929
+ if (byteLimit === false) {
930
+ return handle.readFile({ encoding: "utf8" });
931
+ }
932
+ const chunks = [];
933
+ let totalBytes = 0;
934
+ let position = 0;
935
+ while (true) {
936
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
937
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
938
+ if (bytesRead === 0) {
939
+ break;
940
+ }
941
+ totalBytes += bytesRead;
942
+ if (totalBytes > byteLimit) {
943
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
944
+ }
945
+ chunks.push(buffer.subarray(0, bytesRead));
946
+ position += bytesRead;
947
+ }
948
+ return Buffer.concat(chunks).toString("utf8");
949
+ }
950
+
951
+ // src/internal/CacheStackSnapshotManager.ts
952
+ var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
953
+ var CacheStackSnapshotManager = class {
954
+ constructor(options) {
955
+ this.options = options;
956
+ }
957
+ options;
958
+ async exportState(maxEntries) {
959
+ const entries = [];
960
+ await this.visitExportEntries(maxEntries, async (entry) => {
961
+ entries.push(entry);
962
+ });
963
+ return entries;
964
+ }
965
+ async importState(entries) {
966
+ const normalizedEntries = entries.map((entry) => ({
967
+ key: this.options.qualifyKey(this.options.validateCacheKey(entry.key)),
968
+ value: entry.value,
969
+ ttl: entry.ttl
970
+ }));
971
+ for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
972
+ const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
973
+ await Promise.all(
974
+ batch.map(async (entry) => {
975
+ await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
976
+ await this.options.tagIndex.touch(entry.key);
977
+ })
978
+ );
979
+ }
980
+ }
981
+ async persistToFile(filePath, snapshotBaseDir, maxEntries) {
982
+ const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
983
+ const tempPath = path.join(
984
+ path.dirname(targetPath),
985
+ `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
986
+ );
987
+ let handle;
988
+ try {
989
+ handle = await fs.open(tempPath, "wx");
990
+ const openedHandle = handle;
991
+ await openedHandle.writeFile("[", "utf8");
992
+ let wroteAny = false;
993
+ await this.visitExportEntries(maxEntries, async (entry) => {
994
+ await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
995
+ await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
996
+ wroteAny = true;
997
+ });
998
+ await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
999
+ await openedHandle.close();
1000
+ handle = void 0;
1001
+ await fs.rename(tempPath, targetPath);
1002
+ } catch (error) {
1003
+ await handle?.close().catch(() => void 0);
1004
+ await fs.unlink(tempPath).catch(() => void 0);
1005
+ throw error;
1006
+ }
1007
+ }
1008
+ async restoreFromFile(filePath, snapshotBaseDir, maxBytes) {
1009
+ const validatedPath = await validateSnapshotFilePath(filePath, "read", snapshotBaseDir);
1010
+ const handle = await fs.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1011
+ let raw;
1012
+ try {
1013
+ if (maxBytes !== false) {
1014
+ const stat = await handle.stat();
1015
+ if (stat.size > maxBytes) {
1016
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${maxBytes} bytes).`);
1017
+ }
1018
+ }
1019
+ raw = await readUtf8HandleWithLimit(handle, maxBytes);
1020
+ } finally {
1021
+ await handle.close();
1022
+ }
1023
+ let parsed;
1024
+ try {
1025
+ parsed = JSON.parse(raw);
1026
+ } catch (cause) {
1027
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.options.formatError(cause)})`);
1028
+ }
1029
+ if (!this.isCacheSnapshotEntries(parsed)) {
1030
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1031
+ }
1032
+ await this.importState(
1033
+ parsed.map((entry) => ({
1034
+ key: entry.key,
1035
+ value: this.sanitizeSnapshotValue(entry.value),
1036
+ ttl: entry.ttl
1037
+ }))
1038
+ );
1039
+ }
1040
+ async visitExportEntries(maxEntries, visitor) {
1041
+ const exported = /* @__PURE__ */ new Set();
1042
+ for (const layer of this.options.layers) {
1043
+ if (!layer.keys && !layer.forEachKey) {
1044
+ continue;
1045
+ }
1046
+ const visitKey = async (key) => {
1047
+ const exportedKey = this.options.stripQualifiedKey(key);
1048
+ if (exported.has(exportedKey)) {
1049
+ return;
1050
+ }
1051
+ const stored = await this.options.readLayerEntry(layer, key);
1052
+ if (stored === null) {
1053
+ return;
1054
+ }
1055
+ exported.add(exportedKey);
1056
+ if (maxEntries !== false && exported.size > maxEntries) {
1057
+ throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
1058
+ }
1059
+ await visitor({
1060
+ key: exportedKey,
1061
+ value: stored,
1062
+ ttl: remainingStoredTtlSeconds(stored)
1063
+ });
1064
+ };
1065
+ if (layer.forEachKey) {
1066
+ await layer.forEachKey(visitKey);
1067
+ continue;
1068
+ }
1069
+ const keys = await layer.keys?.();
1070
+ for (const key of keys ?? []) {
1071
+ await visitKey(key);
1072
+ }
1073
+ }
1074
+ }
1075
+ isCacheSnapshotEntries(value) {
1076
+ return Array.isArray(value) && value.every((entry) => {
1077
+ if (!entry || typeof entry !== "object") {
1078
+ return false;
704
1079
  }
1080
+ const candidate = entry;
1081
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
705
1082
  });
706
1083
  }
707
- async waitForGenerationCleanup() {
708
- await this.generationCleanupPromise;
1084
+ sanitizeSnapshotValue(value) {
1085
+ return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
709
1086
  }
710
1087
  };
711
1088
 
712
- // src/internal/CacheStackRuntimePolicy.ts
713
- function shouldSkipLayer(degradedUntil, now = Date.now()) {
714
- return degradedUntil !== void 0 && degradedUntil > now;
715
- }
716
- function shouldStartBackgroundRefresh({
717
- isDisconnecting,
718
- hasRefreshInFlight
719
- }) {
720
- return !isDisconnecting && !hasRefreshInFlight;
721
- }
722
- function resolveRecoverableLayerFailure(gracefulDegradation, now = Date.now()) {
723
- if (!gracefulDegradation) {
724
- return { degrade: false };
725
- }
726
- const retryAfterMs = typeof gracefulDegradation === "object" ? gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
727
- return {
728
- degrade: true,
729
- degradedUntil: now + retryAfterMs
730
- };
731
- }
732
- function planFreshReadPolicies({
733
- stored,
734
- hasFetcher,
735
- slidingTtl,
736
- refreshAheadSeconds
737
- }) {
738
- const refreshedStored = slidingTtl && isStoredValueEnvelope(stored) ? refreshStoredEnvelope(stored) : void 0;
739
- const refreshedStoredTtl = refreshedStored ? remainingStoredTtlSeconds(refreshedStored) ?? void 0 : void 0;
740
- const remainingFreshTtl = remainingFreshTtlSeconds(stored) ?? 0;
741
- return {
742
- refreshedStored,
743
- refreshedStoredTtl,
744
- shouldScheduleBackgroundRefresh: hasFetcher && refreshAheadSeconds > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAheadSeconds
745
- };
746
- }
747
-
748
1089
  // src/internal/CacheStackValidation.ts
749
1090
  var MAX_CACHE_KEY_LENGTH = 1024;
750
1091
  var MAX_PATTERN_LENGTH = 1024;
@@ -972,7 +1313,11 @@ var FetchRateLimiter = class {
972
1313
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
973
1314
  nextFetcherBucketId = 0;
974
1315
  drainTimer;
1316
+ isDisposed = false;
975
1317
  async schedule(options, context, task) {
1318
+ if (this.isDisposed) {
1319
+ throw new Error("FetchRateLimiter has been disposed.");
1320
+ }
976
1321
  if (!options) {
977
1322
  return task();
978
1323
  }
@@ -995,6 +1340,27 @@ var FetchRateLimiter = class {
995
1340
  this.drain();
996
1341
  });
997
1342
  }
1343
+ dispose() {
1344
+ this.isDisposed = true;
1345
+ if (this.drainTimer) {
1346
+ clearTimeout(this.drainTimer);
1347
+ this.drainTimer = void 0;
1348
+ }
1349
+ for (const bucket of this.buckets.values()) {
1350
+ if (bucket.cleanupTimer) {
1351
+ clearTimeout(bucket.cleanupTimer);
1352
+ bucket.cleanupTimer = void 0;
1353
+ }
1354
+ }
1355
+ for (const queue of this.queuesByBucket.values()) {
1356
+ for (const item of queue) {
1357
+ item.reject(new Error("FetchRateLimiter has been disposed."));
1358
+ }
1359
+ }
1360
+ this.queuesByBucket.clear();
1361
+ this.pendingBuckets.clear();
1362
+ this.buckets.clear();
1363
+ }
998
1364
  normalize(options) {
999
1365
  const maxConcurrent = options.maxConcurrent;
1000
1366
  const intervalMs = options.intervalMs;
@@ -1030,6 +1396,9 @@ var FetchRateLimiter = class {
1030
1396
  return "global";
1031
1397
  }
1032
1398
  drain() {
1399
+ if (this.isDisposed) {
1400
+ return;
1401
+ }
1033
1402
  if (this.drainTimer) {
1034
1403
  clearTimeout(this.drainTimer);
1035
1404
  this.drainTimer = void 0;
@@ -1126,6 +1495,9 @@ var FetchRateLimiter = class {
1126
1495
  }
1127
1496
  }
1128
1497
  bucketState(bucketKey) {
1498
+ if (this.isDisposed) {
1499
+ throw new Error("FetchRateLimiter has been disposed.");
1500
+ }
1129
1501
  const existing = this.buckets.get(bucketKey);
1130
1502
  if (existing) {
1131
1503
  return existing;
@@ -1361,39 +1733,31 @@ var TtlResolver = class {
1361
1733
  }
1362
1734
  };
1363
1735
 
1364
- // src/serialization/JsonSerializer.ts
1365
- var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1366
- var MAX_SANITIZE_NODES = 1e4;
1367
- var JsonSerializer = class {
1368
- serialize(value) {
1369
- return JSON.stringify(value);
1370
- }
1371
- deserialize(payload) {
1372
- const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1373
- return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
1374
- }
1375
- };
1376
- var MAX_SANITIZE_DEPTH = 200;
1377
- function sanitizeJsonValue(value, depth, state) {
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) {
1378
1742
  state.count += 1;
1379
- if (state.count > MAX_SANITIZE_NODES) {
1380
- throw new Error(`JSON payload exceeds max node count of ${MAX_SANITIZE_NODES}.`);
1743
+ if (state.count > options.maxNodes) {
1744
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1381
1745
  }
1382
- if (depth > MAX_SANITIZE_DEPTH) {
1383
- throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
1746
+ if (depth > options.maxDepth) {
1747
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1384
1748
  }
1385
1749
  if (Array.isArray(value)) {
1386
- return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
1750
+ return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
1387
1751
  }
1388
1752
  if (!isPlainObject(value)) {
1389
1753
  return value;
1390
1754
  }
1391
- const sanitized = {};
1755
+ const sanitized = options.createObject?.() ?? {};
1392
1756
  for (const [key, entry] of Object.entries(value)) {
1393
- if (DANGEROUS_JSON_KEYS.has(key)) {
1757
+ if (DANGEROUS_KEYS.has(key)) {
1394
1758
  continue;
1395
1759
  }
1396
- sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
1760
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1397
1761
  }
1398
1762
  return sanitized;
1399
1763
  }
@@ -1401,6 +1765,21 @@ function isPlainObject(value) {
1401
1765
  return Object.prototype.toString.call(value) === "[object Object]";
1402
1766
  }
1403
1767
 
1768
+ // src/serialization/JsonSerializer.ts
1769
+ var JsonSerializer = class {
1770
+ serialize(value) {
1771
+ return JSON.stringify(value);
1772
+ }
1773
+ deserialize(payload) {
1774
+ const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
1775
+ return sanitizeStructuredData(JSON.parse(normalized), {
1776
+ label: "JSON payload",
1777
+ maxDepth: 200,
1778
+ maxNodes: 1e4
1779
+ });
1780
+ }
1781
+ };
1782
+
1404
1783
  // src/stampede/StampedeGuard.ts
1405
1784
  import { Mutex as Mutex2 } from "async-mutex";
1406
1785
  var StampedeGuard = class {
@@ -1445,7 +1824,6 @@ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1445
1824
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1446
1825
  var DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
1447
1826
  var DEFAULT_SNAPSHOT_MAX_ENTRIES = 1e4;
1448
- var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1449
1827
  var DEFAULT_INVALIDATION_MAX_KEYS = 1e4;
1450
1828
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
1451
1829
  var DebugLogger = class {
@@ -1502,6 +1880,35 @@ var CacheStack = class extends EventEmitter {
1502
1880
  await this.handleLayerFailure(layer, operation, error);
1503
1881
  }
1504
1882
  });
1883
+ this.invalidation = new CacheStackInvalidationSupport({
1884
+ tagIndex: this.tagIndex,
1885
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1886
+ handleLayerFailure: async (layer, operation, error) => {
1887
+ await this.handleLayerFailure(layer, operation, error);
1888
+ }
1889
+ });
1890
+ this.layerWriter = new CacheStackLayerWriter({
1891
+ layers: this.layers,
1892
+ maintenance: this.maintenance,
1893
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1894
+ shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
1895
+ handleLayerFailure: async (layer, operation, error) => {
1896
+ await this.handleLayerFailure(layer, operation, error);
1897
+ },
1898
+ enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
1899
+ resolveFreshTtl: this.resolveFreshTtl.bind(this),
1900
+ resolveLayerSeconds: this.resolveLayerSeconds.bind(this),
1901
+ globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
1902
+ globalStaleIfError: this.options.staleIfError,
1903
+ writePolicy: this.options.writePolicy,
1904
+ onWriteFailures: (context, failures) => {
1905
+ this.metricsCollector.increment("writeFailures", failures.length);
1906
+ this.logger.debug?.("write-failure", {
1907
+ ...context,
1908
+ failures: failures.map((failure) => this.formatError(failure))
1909
+ });
1910
+ }
1911
+ });
1505
1912
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
1506
1913
  this.logger.warn?.(
1507
1914
  "Using the default in-memory TagIndex with a shared cache layer only tracks keys seen by this process. Use RedisTagIndex for cross-instance tag invalidation."
@@ -1517,6 +1924,16 @@ var CacheStack = class extends EventEmitter {
1517
1924
  "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
1518
1925
  );
1519
1926
  }
1927
+ this.snapshots = new CacheStackSnapshotManager({
1928
+ layers: this.layers,
1929
+ tagIndex: this.tagIndex,
1930
+ snapshotSerializer: this.snapshotSerializer,
1931
+ readLayerEntry: this.readLayerEntry.bind(this),
1932
+ qualifyKey: this.qualifyKey.bind(this),
1933
+ stripQualifiedKey: this.stripQualifiedKey.bind(this),
1934
+ validateCacheKey,
1935
+ formatError: this.formatError.bind(this)
1936
+ });
1520
1937
  this.initializeWriteBehind(options.writeBehind);
1521
1938
  this.startup = this.initialize();
1522
1939
  }
@@ -1532,11 +1949,15 @@ var CacheStack = class extends EventEmitter {
1532
1949
  keyDiscovery;
1533
1950
  fetchRateLimiter = new FetchRateLimiter();
1534
1951
  snapshotSerializer = new JsonSerializer();
1952
+ invalidation;
1953
+ layerWriter;
1954
+ snapshots;
1535
1955
  backgroundRefreshes = /* @__PURE__ */ new Map();
1536
1956
  layerDegradedUntil = /* @__PURE__ */ new Map();
1537
1957
  maintenance = new CacheStackMaintenance();
1538
1958
  ttlResolver;
1539
1959
  circuitBreakerManager;
1960
+ nextOperationId = 0;
1540
1961
  currentGeneration;
1541
1962
  isDisconnecting = false;
1542
1963
  disconnectPromise;
@@ -1547,10 +1968,12 @@ var CacheStack = class extends EventEmitter {
1547
1968
  * and no `fetcher` is provided.
1548
1969
  */
1549
1970
  async get(key, fetcher, options) {
1550
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
1551
- this.validateWriteOptions(options);
1552
- await this.awaitStartup("get");
1553
- return this.getPrepared(normalizedKey, fetcher, options);
1971
+ return this.observeOperation("layercache.get", { "layercache.key": String(key ?? "") }, async () => {
1972
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
1973
+ this.validateWriteOptions(options);
1974
+ await this.awaitStartup("get");
1975
+ return this.getPrepared(normalizedKey, fetcher, options);
1976
+ });
1554
1977
  }
1555
1978
  async getPrepared(normalizedKey, fetcher, options) {
1556
1979
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
@@ -1672,23 +2095,27 @@ var CacheStack = class extends EventEmitter {
1672
2095
  * Stores a value in all cache layers. Overwrites any existing value.
1673
2096
  */
1674
2097
  async set(key, value, options) {
1675
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
1676
- this.validateWriteOptions(options);
1677
- await this.awaitStartup("set");
1678
- await this.storeEntry(normalizedKey, "value", value, options);
2098
+ await this.observeOperation("layercache.set", { "layercache.key": String(key ?? "") }, async () => {
2099
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
2100
+ this.validateWriteOptions(options);
2101
+ await this.awaitStartup("set");
2102
+ await this.storeEntry(normalizedKey, "value", value, options);
2103
+ });
1679
2104
  }
1680
2105
  /**
1681
2106
  * Deletes the key from all layers and publishes an invalidation message.
1682
2107
  */
1683
2108
  async delete(key) {
1684
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
1685
- await this.awaitStartup("delete");
1686
- await this.deleteKeys([normalizedKey]);
1687
- await this.publishInvalidation({
1688
- scope: "key",
1689
- keys: [normalizedKey],
1690
- sourceId: this.instanceId,
1691
- operation: "delete"
2109
+ await this.observeOperation("layercache.delete", { "layercache.key": String(key ?? "") }, async () => {
2110
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
2111
+ await this.awaitStartup("delete");
2112
+ await this.deleteKeys([normalizedKey]);
2113
+ await this.publishInvalidation({
2114
+ scope: "key",
2115
+ keys: [normalizedKey],
2116
+ sourceId: this.instanceId,
2117
+ operation: "delete"
2118
+ });
1692
2119
  });
1693
2120
  }
1694
2121
  async clear() {
@@ -1721,95 +2148,99 @@ var CacheStack = class extends EventEmitter {
1721
2148
  });
1722
2149
  }
1723
2150
  async mget(entries) {
1724
- this.assertActive("mget");
1725
- if (entries.length === 0) {
1726
- return [];
1727
- }
1728
- const normalizedEntries = entries.map((entry) => ({
1729
- ...entry,
1730
- key: this.qualifyKey(validateCacheKey(entry.key))
1731
- }));
1732
- normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1733
- const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
1734
- if (!canFastPath) {
2151
+ return this.observeOperation("layercache.mget", void 0, async () => {
2152
+ this.assertActive("mget");
2153
+ if (entries.length === 0) {
2154
+ return [];
2155
+ }
2156
+ const normalizedEntries = entries.map((entry) => ({
2157
+ ...entry,
2158
+ key: this.qualifyKey(validateCacheKey(entry.key))
2159
+ }));
2160
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2161
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
2162
+ if (!canFastPath) {
2163
+ await this.awaitStartup("mget");
2164
+ const pendingReads = /* @__PURE__ */ new Map();
2165
+ return Promise.all(
2166
+ normalizedEntries.map((entry) => {
2167
+ const optionsSignature = serializeOptions(entry.options);
2168
+ const existing = pendingReads.get(entry.key);
2169
+ if (!existing) {
2170
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
2171
+ pendingReads.set(entry.key, {
2172
+ promise,
2173
+ fetch: entry.fetch,
2174
+ optionsSignature
2175
+ });
2176
+ return promise;
2177
+ }
2178
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2179
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2180
+ }
2181
+ return existing.promise;
2182
+ })
2183
+ );
2184
+ }
1735
2185
  await this.awaitStartup("mget");
1736
- const pendingReads = /* @__PURE__ */ new Map();
1737
- return Promise.all(
1738
- normalizedEntries.map((entry) => {
1739
- const optionsSignature = serializeOptions(entry.options);
1740
- const existing = pendingReads.get(entry.key);
1741
- if (!existing) {
1742
- const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
1743
- pendingReads.set(entry.key, {
1744
- promise,
1745
- fetch: entry.fetch,
1746
- optionsSignature
1747
- });
1748
- return promise;
1749
- }
1750
- if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
1751
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
1752
- }
1753
- return existing.promise;
1754
- })
1755
- );
1756
- }
1757
- await this.awaitStartup("mget");
1758
- const pending = /* @__PURE__ */ new Set();
1759
- const indexesByKey = /* @__PURE__ */ new Map();
1760
- const resultsByKey = /* @__PURE__ */ new Map();
1761
- for (let index = 0; index < normalizedEntries.length; index += 1) {
1762
- const entry = normalizedEntries[index];
1763
- if (!entry) continue;
1764
- const key = entry.key;
1765
- const indexes = indexesByKey.get(key) ?? [];
1766
- indexes.push(index);
1767
- indexesByKey.set(key, indexes);
1768
- pending.add(key);
1769
- }
1770
- for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
1771
- const layer = this.layers[layerIndex];
1772
- if (!layer) continue;
1773
- const keys = [...pending];
1774
- if (keys.length === 0) {
1775
- break;
2186
+ const pending = /* @__PURE__ */ new Set();
2187
+ const indexesByKey = /* @__PURE__ */ new Map();
2188
+ const resultsByKey = /* @__PURE__ */ new Map();
2189
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
2190
+ const entry = normalizedEntries[index];
2191
+ if (!entry) continue;
2192
+ const key = entry.key;
2193
+ const indexes = indexesByKey.get(key) ?? [];
2194
+ indexes.push(index);
2195
+ indexesByKey.set(key, indexes);
2196
+ pending.add(key);
1776
2197
  }
1777
- const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
1778
- for (let offset = 0; offset < values.length; offset += 1) {
1779
- const key = keys[offset];
1780
- const stored = values[offset];
1781
- if (!key || stored === null) {
1782
- continue;
2198
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2199
+ const layer = this.layers[layerIndex];
2200
+ if (!layer) continue;
2201
+ const keys = [...pending];
2202
+ if (keys.length === 0) {
2203
+ break;
1783
2204
  }
1784
- const resolved = resolveStoredValue(stored);
1785
- if (resolved.state === "expired") {
1786
- await layer.delete(key);
1787
- continue;
2205
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
2206
+ for (let offset = 0; offset < values.length; offset += 1) {
2207
+ const key = keys[offset];
2208
+ const stored = values[offset];
2209
+ if (!key || stored === null) {
2210
+ continue;
2211
+ }
2212
+ const resolved = resolveStoredValue(stored);
2213
+ if (resolved.state === "expired") {
2214
+ await layer.delete(key);
2215
+ continue;
2216
+ }
2217
+ await this.tagIndex.touch(key);
2218
+ await this.backfill(key, stored, layerIndex - 1);
2219
+ resultsByKey.set(key, resolved.value);
2220
+ pending.delete(key);
2221
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
1788
2222
  }
1789
- await this.tagIndex.touch(key);
1790
- await this.backfill(key, stored, layerIndex - 1);
1791
- resultsByKey.set(key, resolved.value);
1792
- pending.delete(key);
1793
- this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
1794
2223
  }
1795
- }
1796
- if (pending.size > 0) {
1797
- for (const key of pending) {
1798
- await this.tagIndex.remove(key);
1799
- this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
2224
+ if (pending.size > 0) {
2225
+ for (const key of pending) {
2226
+ await this.tagIndex.remove(key);
2227
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
2228
+ }
1800
2229
  }
1801
- }
1802
- return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
2230
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
2231
+ });
1803
2232
  }
1804
2233
  async mset(entries) {
1805
- this.assertActive("mset");
1806
- const normalizedEntries = entries.map((entry) => ({
1807
- ...entry,
1808
- key: this.qualifyKey(validateCacheKey(entry.key))
1809
- }));
1810
- normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
1811
- await this.awaitStartup("mset");
1812
- await this.writeBatch(normalizedEntries);
2234
+ await this.observeOperation("layercache.mset", void 0, async () => {
2235
+ this.assertActive("mset");
2236
+ const normalizedEntries = entries.map((entry) => ({
2237
+ ...entry,
2238
+ key: this.qualifyKey(validateCacheKey(entry.key))
2239
+ }));
2240
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2241
+ await this.awaitStartup("mset");
2242
+ await this.writeBatch(normalizedEntries);
2243
+ });
1813
2244
  }
1814
2245
  async warm(entries, options = {}) {
1815
2246
  this.assertActive("warm");
@@ -1862,40 +2293,50 @@ var CacheStack = class extends EventEmitter {
1862
2293
  return new CacheNamespace(this, prefix);
1863
2294
  }
1864
2295
  async invalidateByTag(tag) {
1865
- validateTag(tag);
1866
- await this.awaitStartup("invalidateByTag");
1867
- const keys = await this.collectKeysForTag(tag);
1868
- await this.deleteKeys(keys);
1869
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2296
+ await this.observeOperation("layercache.invalidate_by_tag", void 0, async () => {
2297
+ validateTag(tag);
2298
+ await this.awaitStartup("invalidateByTag");
2299
+ const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys());
2300
+ await this.deleteKeys(keys);
2301
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2302
+ });
1870
2303
  }
1871
2304
  async invalidateByTags(tags, mode = "any") {
1872
- if (tags.length === 0) {
1873
- return;
1874
- }
1875
- validateTags(tags);
1876
- await this.awaitStartup("invalidateByTags");
1877
- const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
1878
- const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
1879
- this.assertWithinInvalidationKeyLimit(keys.length);
1880
- await this.deleteKeys(keys);
1881
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2305
+ await this.observeOperation("layercache.invalidate_by_tags", void 0, async () => {
2306
+ if (tags.length === 0) {
2307
+ return;
2308
+ }
2309
+ validateTags(tags);
2310
+ await this.awaitStartup("invalidateByTags");
2311
+ const keysByTag = await Promise.all(
2312
+ tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
2313
+ );
2314
+ const keys = mode === "all" ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
2315
+ this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys());
2316
+ await this.deleteKeys(keys);
2317
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2318
+ });
1882
2319
  }
1883
2320
  async invalidateByPattern(pattern) {
1884
- validatePattern(pattern);
1885
- await this.awaitStartup("invalidateByPattern");
1886
- const keys = await this.keyDiscovery.collectKeysMatchingPattern(
1887
- this.qualifyPattern(pattern),
1888
- this.invalidationMaxKeys()
1889
- );
1890
- await this.deleteKeys(keys);
1891
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2321
+ await this.observeOperation("layercache.invalidate_by_pattern", void 0, async () => {
2322
+ validatePattern(pattern);
2323
+ await this.awaitStartup("invalidateByPattern");
2324
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(
2325
+ this.qualifyPattern(pattern),
2326
+ this.invalidationMaxKeys()
2327
+ );
2328
+ await this.deleteKeys(keys);
2329
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2330
+ });
1892
2331
  }
1893
2332
  async invalidateByPrefix(prefix) {
1894
- await this.awaitStartup("invalidateByPrefix");
1895
- const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
1896
- const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
1897
- await this.deleteKeys(keys);
1898
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2333
+ await this.observeOperation("layercache.invalidate_by_prefix", void 0, async () => {
2334
+ await this.awaitStartup("invalidateByPrefix");
2335
+ const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
2336
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
2337
+ await this.deleteKeys(keys);
2338
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2339
+ });
1899
2340
  }
1900
2341
  getMetrics() {
1901
2342
  return this.metricsCollector.snapshot;
@@ -1996,105 +2437,29 @@ var CacheStack = class extends EventEmitter {
1996
2437
  staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
1997
2438
  errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
1998
2439
  isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
1999
- }
2000
- }
2001
- if (foundInLayers.length === 0) {
2002
- return null;
2003
- }
2004
- const tags = await this.getTagsForKey(normalizedKey);
2005
- return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
2006
- }
2007
- async exportState() {
2008
- await this.awaitStartup("exportState");
2009
- const entries = [];
2010
- await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2011
- entries.push(entry);
2012
- });
2013
- return entries;
2014
- }
2015
- async importState(entries) {
2016
- await this.awaitStartup("importState");
2017
- const normalizedEntries = entries.map((entry) => ({
2018
- key: this.qualifyKey(validateCacheKey(entry.key)),
2019
- value: entry.value,
2020
- ttl: entry.ttl
2021
- }));
2022
- for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
2023
- const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
2024
- await Promise.all(
2025
- batch.map(async (entry) => {
2026
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
2027
- await this.tagIndex.touch(entry.key);
2028
- })
2029
- );
2030
- }
2031
- }
2032
- async persistToFile(filePath) {
2033
- this.assertActive("persistToFile");
2034
- const { promises: fs2 } = await import("fs");
2035
- const path = await import("path");
2036
- const targetPath = await validateSnapshotFilePath(filePath, "write", this.options.snapshotBaseDir);
2037
- const tempPath = path.join(
2038
- path.dirname(targetPath),
2039
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
2040
- );
2041
- let handle;
2042
- try {
2043
- handle = await fs2.open(tempPath, "wx");
2044
- const openedHandle = handle;
2045
- await openedHandle.writeFile("[", "utf8");
2046
- let wroteAny = false;
2047
- await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2048
- await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
2049
- await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
2050
- wroteAny = true;
2051
- });
2052
- await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
2053
- await openedHandle.close();
2054
- handle = void 0;
2055
- await fs2.rename(tempPath, targetPath);
2056
- } catch (error) {
2057
- await handle?.close().catch(() => void 0);
2058
- await fs2.unlink(tempPath).catch(() => void 0);
2059
- throw error;
2060
- }
2061
- }
2062
- async restoreFromFile(filePath) {
2063
- this.assertActive("restoreFromFile");
2064
- const { promises: fs2, constants } = await import("fs");
2065
- const validatedPath = await validateSnapshotFilePath(filePath, "read", this.options.snapshotBaseDir);
2066
- const handle = await fs2.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
2067
- const snapshotMaxBytes = this.snapshotMaxBytes();
2068
- let raw;
2069
- try {
2070
- if (snapshotMaxBytes !== false) {
2071
- const stat = await handle.stat();
2072
- if (stat.size > snapshotMaxBytes) {
2073
- throw new Error(
2074
- `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
2075
- );
2076
- }
2077
- }
2078
- raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes);
2079
- } finally {
2080
- await handle.close();
2081
- }
2082
- let parsed;
2083
- try {
2084
- parsed = JSON.parse(raw);
2085
- } catch (cause) {
2086
- throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
2087
- }
2088
- if (!this.isCacheSnapshotEntries(parsed)) {
2089
- throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
2090
- }
2091
- await this.importState(
2092
- parsed.map((entry) => ({
2093
- key: entry.key,
2094
- value: this.sanitizeSnapshotValue(entry.value),
2095
- ttl: entry.ttl
2096
- }))
2097
- );
2440
+ }
2441
+ }
2442
+ if (foundInLayers.length === 0) {
2443
+ return null;
2444
+ }
2445
+ const tags = await this.getTagsForKey(normalizedKey);
2446
+ return { key: userKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
2447
+ }
2448
+ async exportState() {
2449
+ await this.awaitStartup("exportState");
2450
+ return this.snapshots.exportState(this.snapshotMaxEntries());
2451
+ }
2452
+ async importState(entries) {
2453
+ await this.awaitStartup("importState");
2454
+ await this.snapshots.importState(entries);
2455
+ }
2456
+ async persistToFile(filePath) {
2457
+ this.assertActive("persistToFile");
2458
+ await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries());
2459
+ }
2460
+ async restoreFromFile(filePath) {
2461
+ this.assertActive("restoreFromFile");
2462
+ await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes());
2098
2463
  }
2099
2464
  async disconnect() {
2100
2465
  if (!this.disconnectPromise) {
@@ -2106,6 +2471,7 @@ var CacheStack = class extends EventEmitter {
2106
2471
  await this.maintenance.waitForGenerationCleanup();
2107
2472
  await Promise.allSettled([...this.backgroundRefreshes.values()]);
2108
2473
  this.maintenance.disposeWriteBehindTimer();
2474
+ this.fetchRateLimiter.dispose();
2109
2475
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
2110
2476
  })();
2111
2477
  }
@@ -2219,7 +2585,7 @@ var CacheStack = class extends EventEmitter {
2219
2585
  async storeEntry(key, kind, value, options) {
2220
2586
  const clearEpoch = this.maintenance.currentClearEpoch();
2221
2587
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
2222
- await this.writeAcrossLayers(key, kind, value, options);
2588
+ await this.layerWriter.writeAcrossLayers(key, kind, value, options);
2223
2589
  if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2224
2590
  return;
2225
2591
  }
@@ -2236,52 +2602,7 @@ var CacheStack = class extends EventEmitter {
2236
2602
  }
2237
2603
  }
2238
2604
  async writeBatch(entries) {
2239
- const now = Date.now();
2240
- const clearEpoch = this.maintenance.currentClearEpoch();
2241
- const entryEpochs = new Map(entries.map((entry) => [entry.key, this.maintenance.currentKeyEpoch(entry.key)]));
2242
- const entriesByLayer = /* @__PURE__ */ new Map();
2243
- const immediateOperations = [];
2244
- const deferredOperations = [];
2245
- for (const entry of entries) {
2246
- for (const layer of this.layers) {
2247
- if (this.shouldSkipLayer(layer)) {
2248
- continue;
2249
- }
2250
- const layerEntry = this.buildLayerSetEntry(layer, entry.key, "value", entry.value, entry.options, now);
2251
- const bucket = entriesByLayer.get(layer) ?? [];
2252
- bucket.push(layerEntry);
2253
- entriesByLayer.set(layer, bucket);
2254
- }
2255
- }
2256
- for (const [layer, layerEntries] of entriesByLayer.entries()) {
2257
- const operation = async () => {
2258
- if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2259
- return;
2260
- }
2261
- const activeEntries = layerEntries.filter(
2262
- (entry) => (entryEpochs.get(entry.key) ?? 0) === this.maintenance.currentKeyEpoch(entry.key)
2263
- );
2264
- if (activeEntries.length === 0) {
2265
- return;
2266
- }
2267
- try {
2268
- if (layer.setMany) {
2269
- await layer.setMany(activeEntries);
2270
- return;
2271
- }
2272
- await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
2273
- } catch (error) {
2274
- await this.handleLayerFailure(layer, "write", error);
2275
- }
2276
- };
2277
- if (this.shouldWriteBehind(layer)) {
2278
- deferredOperations.push(operation);
2279
- } else {
2280
- immediateOperations.push(operation);
2281
- }
2282
- }
2283
- await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
2284
- await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
2605
+ const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(entries);
2285
2606
  if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2286
2607
  return;
2287
2608
  }
@@ -2388,58 +2709,6 @@ var CacheStack = class extends EventEmitter {
2388
2709
  this.emit("backfill", { key, layer: layer.name });
2389
2710
  }
2390
2711
  }
2391
- async writeAcrossLayers(key, kind, value, options) {
2392
- const now = Date.now();
2393
- const clearEpoch = this.maintenance.currentClearEpoch();
2394
- const keyEpoch = this.maintenance.currentKeyEpoch(key);
2395
- const immediateOperations = [];
2396
- const deferredOperations = [];
2397
- for (const layer of this.layers) {
2398
- const operation = async () => {
2399
- if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2400
- return;
2401
- }
2402
- if (this.shouldSkipLayer(layer)) {
2403
- return;
2404
- }
2405
- const entry = this.buildLayerSetEntry(layer, key, kind, value, options, now);
2406
- try {
2407
- await layer.set(entry.key, entry.value, entry.ttl);
2408
- } catch (error) {
2409
- await this.handleLayerFailure(layer, "write", error);
2410
- }
2411
- };
2412
- if (this.shouldWriteBehind(layer)) {
2413
- deferredOperations.push(operation);
2414
- } else {
2415
- immediateOperations.push(operation);
2416
- }
2417
- }
2418
- await this.executeLayerOperations(immediateOperations, { key, action: kind === "empty" ? "negative-set" : "set" });
2419
- await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
2420
- }
2421
- async executeLayerOperations(operations, context) {
2422
- if (this.options.writePolicy !== "best-effort") {
2423
- await Promise.all(operations.map((operation) => operation()));
2424
- return;
2425
- }
2426
- const results = await Promise.allSettled(operations.map((operation) => operation()));
2427
- const failures = results.filter((result) => result.status === "rejected");
2428
- if (failures.length === 0) {
2429
- return;
2430
- }
2431
- this.metricsCollector.increment("writeFailures", failures.length);
2432
- this.logger.debug?.("write-failure", {
2433
- ...context,
2434
- failures: failures.map((failure) => this.formatError(failure.reason))
2435
- });
2436
- if (failures.length === operations.length) {
2437
- throw new AggregateError(
2438
- failures.map((failure) => failure.reason),
2439
- `${context.action} failed for every cache layer`
2440
- );
2441
- }
2442
- }
2443
2712
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl, value) {
2444
2713
  return this.ttlResolver.resolveFreshTtl(
2445
2714
  key,
@@ -2505,7 +2774,7 @@ var CacheStack = class extends EventEmitter {
2505
2774
  return;
2506
2775
  }
2507
2776
  this.maintenance.bumpKeyEpochs(keys);
2508
- await this.deleteKeysFromLayers(this.layers, keys);
2777
+ await this.invalidation.deleteKeysFromLayers(this.layers, keys);
2509
2778
  for (const key of keys) {
2510
2779
  await this.tagIndex.remove(key);
2511
2780
  this.ttlResolver.deleteProfile(key);
@@ -2537,7 +2806,7 @@ var CacheStack = class extends EventEmitter {
2537
2806
  }
2538
2807
  const keys = message.keys ?? [];
2539
2808
  this.maintenance.bumpKeyEpochs(keys);
2540
- await this.deleteKeysFromLayers(localLayers, keys);
2809
+ await this.invalidation.deleteKeysFromLayers(localLayers, keys);
2541
2810
  if (message.operation !== "write") {
2542
2811
  for (const key of keys) {
2543
2812
  await this.tagIndex.remove(key);
@@ -2594,6 +2863,31 @@ var CacheStack = class extends EventEmitter {
2594
2863
  shouldBroadcastL1Invalidation() {
2595
2864
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
2596
2865
  }
2866
+ async observeOperation(name, attributes, execute) {
2867
+ const id = this.nextOperationId;
2868
+ this.nextOperationId += 1;
2869
+ this.emit("operation-start", { id, name, attributes });
2870
+ try {
2871
+ const result = await execute();
2872
+ this.emit("operation-end", {
2873
+ id,
2874
+ name,
2875
+ attributes,
2876
+ success: true,
2877
+ result: result === null ? "null" : void 0
2878
+ });
2879
+ return result;
2880
+ } catch (error) {
2881
+ this.emit("operation-end", {
2882
+ id,
2883
+ name,
2884
+ attributes,
2885
+ success: false,
2886
+ error
2887
+ });
2888
+ throw error;
2889
+ }
2890
+ }
2597
2891
  scheduleGenerationCleanup(generation) {
2598
2892
  this.maintenance.scheduleGenerationCleanup(
2599
2893
  generation,
@@ -2649,37 +2943,6 @@ var CacheStack = class extends EventEmitter {
2649
2943
  });
2650
2944
  this.emitError("write-behind", { failed: failures.length, total: batch.length });
2651
2945
  }
2652
- buildLayerSetEntry(layer, key, kind, value, options, now) {
2653
- const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl, value);
2654
- const staleWhileRevalidate = this.resolveLayerSeconds(
2655
- layer.name,
2656
- options?.staleWhileRevalidate,
2657
- this.options.staleWhileRevalidate
2658
- );
2659
- const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
2660
- const payload = createStoredValueEnvelope({
2661
- kind,
2662
- value,
2663
- freshTtlSeconds: freshTtl,
2664
- staleWhileRevalidateSeconds: staleWhileRevalidate,
2665
- staleIfErrorSeconds: staleIfError,
2666
- now
2667
- });
2668
- const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
2669
- return {
2670
- key,
2671
- value: payload,
2672
- ttl
2673
- };
2674
- }
2675
- intersectKeys(groups) {
2676
- if (groups.length === 0) {
2677
- return [];
2678
- }
2679
- const [firstGroup, ...rest] = groups;
2680
- const restSets = rest.map((group) => new Set(group));
2681
- return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)));
2682
- }
2683
2946
  qualifyKey(key) {
2684
2947
  return qualifyGenerationKey(key, this.currentGeneration);
2685
2948
  }
@@ -2689,32 +2952,6 @@ var CacheStack = class extends EventEmitter {
2689
2952
  stripQualifiedKey(key) {
2690
2953
  return stripGenerationPrefix(key, this.currentGeneration);
2691
2954
  }
2692
- async deleteKeysFromLayers(layers, keys) {
2693
- await Promise.all(
2694
- layers.map(async (layer) => {
2695
- if (this.shouldSkipLayer(layer)) {
2696
- return;
2697
- }
2698
- if (layer.deleteMany) {
2699
- try {
2700
- await layer.deleteMany(keys);
2701
- } catch (error) {
2702
- await this.handleLayerFailure(layer, "delete", error);
2703
- }
2704
- return;
2705
- }
2706
- await Promise.all(
2707
- keys.map(async (key) => {
2708
- try {
2709
- await layer.delete(key);
2710
- } catch (error) {
2711
- await this.handleLayerFailure(layer, "delete", error);
2712
- }
2713
- })
2714
- );
2715
- })
2716
- );
2717
- }
2718
2955
  validateConfiguration() {
2719
2956
  if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
2720
2957
  throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
@@ -2845,18 +3082,6 @@ var CacheStack = class extends EventEmitter {
2845
3082
  this.emit("error", { operation, ...context });
2846
3083
  }
2847
3084
  }
2848
- isCacheSnapshotEntries(value) {
2849
- return Array.isArray(value) && value.every((entry) => {
2850
- if (!entry || typeof entry !== "object") {
2851
- return false;
2852
- }
2853
- const candidate = entry;
2854
- return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
2855
- });
2856
- }
2857
- sanitizeSnapshotValue(value) {
2858
- return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
2859
- }
2860
3085
  snapshotMaxBytes() {
2861
3086
  return this.options.snapshotMaxBytes === false ? false : this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES;
2862
3087
  }
@@ -2866,62 +3091,6 @@ var CacheStack = class extends EventEmitter {
2866
3091
  invalidationMaxKeys() {
2867
3092
  return this.options.invalidationMaxKeys === false ? false : this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS;
2868
3093
  }
2869
- async collectKeysForTag(tag) {
2870
- const keys = /* @__PURE__ */ new Set();
2871
- if (this.tagIndex.forEachKeyForTag) {
2872
- await this.tagIndex.forEachKeyForTag(tag, async (key) => {
2873
- keys.add(key);
2874
- this.assertWithinInvalidationKeyLimit(keys.size);
2875
- });
2876
- return [...keys];
2877
- }
2878
- for (const key of await this.tagIndex.keysForTag(tag)) {
2879
- keys.add(key);
2880
- this.assertWithinInvalidationKeyLimit(keys.size);
2881
- }
2882
- return [...keys];
2883
- }
2884
- assertWithinInvalidationKeyLimit(size) {
2885
- const maxKeys = this.invalidationMaxKeys();
2886
- if (maxKeys !== false && size > maxKeys) {
2887
- throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
2888
- }
2889
- }
2890
- async visitExportEntries(maxEntries, visitor) {
2891
- const exported = /* @__PURE__ */ new Set();
2892
- for (const layer of this.layers) {
2893
- if (!layer.keys && !layer.forEachKey) {
2894
- continue;
2895
- }
2896
- const visitKey = async (key) => {
2897
- const exportedKey = this.stripQualifiedKey(key);
2898
- if (exported.has(exportedKey)) {
2899
- return;
2900
- }
2901
- const stored = await this.readLayerEntry(layer, key);
2902
- if (stored === null) {
2903
- return;
2904
- }
2905
- exported.add(exportedKey);
2906
- if (maxEntries !== false && exported.size > maxEntries) {
2907
- throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
2908
- }
2909
- await visitor({
2910
- key: exportedKey,
2911
- value: stored,
2912
- ttl: remainingStoredTtlSeconds(stored)
2913
- });
2914
- };
2915
- if (layer.forEachKey) {
2916
- await layer.forEachKey(visitKey);
2917
- continue;
2918
- }
2919
- const keys = await layer.keys?.();
2920
- for (const key of keys ?? []) {
2921
- await visitKey(key);
2922
- }
2923
- }
2924
- }
2925
3094
  };
2926
3095
 
2927
3096
  // src/invalidation/RedisInvalidationBus.ts
@@ -2963,7 +3132,12 @@ var RedisInvalidationBus = class {
2963
3132
  async dispatchToHandlers(payload) {
2964
3133
  let message;
2965
3134
  try {
2966
- const parsed = sanitizeJsonValue2(JSON.parse(payload));
3135
+ const parsed = sanitizeStructuredData(JSON.parse(payload), {
3136
+ label: "Invalidation payload",
3137
+ maxDepth: 64,
3138
+ maxNodes: 1e4,
3139
+ createObject: () => /* @__PURE__ */ Object.create(null)
3140
+ });
2967
3141
  if (!this.isInvalidationMessage(parsed)) {
2968
3142
  throw new Error("Invalid invalidation payload shape.");
2969
3143
  }
@@ -3000,31 +3174,6 @@ var RedisInvalidationBus = class {
3000
3174
  console.error(`[layercache] ${message}`, error);
3001
3175
  }
3002
3176
  };
3003
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
3004
- var MAX_SANITIZE_DEPTH2 = 64;
3005
- var MAX_SANITIZE_NODES2 = 1e4;
3006
- function sanitizeJsonValue2(value, depth = 0, state = { count: 0 }) {
3007
- state.count += 1;
3008
- if (state.count > MAX_SANITIZE_NODES2) {
3009
- throw new Error(`Invalidation payload exceeds max node count of ${MAX_SANITIZE_NODES2}.`);
3010
- }
3011
- if (depth > MAX_SANITIZE_DEPTH2) {
3012
- throw new Error(`Invalidation payload exceeds max depth of ${MAX_SANITIZE_DEPTH2}.`);
3013
- }
3014
- if (Array.isArray(value)) {
3015
- return value.map((entry) => sanitizeJsonValue2(entry, depth + 1, state));
3016
- }
3017
- if (value && typeof value === "object") {
3018
- const result = /* @__PURE__ */ Object.create(null);
3019
- for (const key of Object.keys(value)) {
3020
- if (!DANGEROUS_KEYS.has(key)) {
3021
- result[key] = sanitizeJsonValue2(value[key], depth + 1, state);
3022
- }
3023
- }
3024
- return result;
3025
- }
3026
- return value;
3027
- }
3028
3177
 
3029
3178
  // src/http/createCacheStatsHandler.ts
3030
3179
  function createCacheStatsHandler(cache, options = {}) {
@@ -3169,64 +3318,37 @@ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
3169
3318
 
3170
3319
  // src/integrations/opentelemetry.ts
3171
3320
  function createOpenTelemetryPlugin(cache, tracer) {
3172
- const originals = {
3173
- get: cache.get.bind(cache),
3174
- set: cache.set.bind(cache),
3175
- delete: cache.delete.bind(cache),
3176
- mget: cache.mget.bind(cache),
3177
- mset: cache.mset.bind(cache),
3178
- invalidateByTag: cache.invalidateByTag.bind(cache),
3179
- invalidateByTags: cache.invalidateByTags.bind(cache),
3180
- invalidateByPattern: cache.invalidateByPattern.bind(cache),
3181
- invalidateByPrefix: cache.invalidateByPrefix.bind(cache)
3321
+ const spans = /* @__PURE__ */ new Map();
3322
+ const onStart = (event) => {
3323
+ spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
3182
3324
  };
3183
- cache.get = instrument("layercache.get", tracer, originals.get, (args) => ({
3184
- "layercache.key": String(args[0] ?? "")
3185
- }));
3186
- cache.set = instrument("layercache.set", tracer, originals.set, (args) => ({
3187
- "layercache.key": String(args[0] ?? "")
3188
- }));
3189
- cache.delete = instrument("layercache.delete", tracer, originals.delete, (args) => ({
3190
- "layercache.key": String(args[0] ?? "")
3191
- }));
3192
- cache.mget = instrument("layercache.mget", tracer, originals.mget);
3193
- cache.mset = instrument("layercache.mset", tracer, originals.mset);
3194
- cache.invalidateByTag = instrument("layercache.invalidate_by_tag", tracer, originals.invalidateByTag);
3195
- cache.invalidateByTags = instrument("layercache.invalidate_by_tags", tracer, originals.invalidateByTags);
3196
- cache.invalidateByPattern = instrument("layercache.invalidate_by_pattern", tracer, originals.invalidateByPattern);
3197
- cache.invalidateByPrefix = instrument("layercache.invalidate_by_prefix", tracer, originals.invalidateByPrefix);
3198
- return {
3199
- uninstall() {
3200
- cache.get = originals.get;
3201
- cache.set = originals.set;
3202
- cache.delete = originals.delete;
3203
- cache.mget = originals.mget;
3204
- cache.mset = originals.mset;
3205
- cache.invalidateByTag = originals.invalidateByTag;
3206
- cache.invalidateByTags = originals.invalidateByTags;
3207
- cache.invalidateByPattern = originals.invalidateByPattern;
3208
- cache.invalidateByPrefix = originals.invalidateByPrefix;
3325
+ const onEnd = (event) => {
3326
+ const span = spans.get(event.id);
3327
+ if (!span) {
3328
+ return;
3209
3329
  }
3330
+ 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);
3337
+ }
3338
+ span.end();
3210
3339
  };
3211
- }
3212
- function instrument(name, tracer, method, attributes) {
3213
- return (async (...args) => {
3214
- const span = tracer.startSpan(name, { attributes: attributes?.(args) });
3215
- try {
3216
- const result = await method(...args);
3217
- span.setAttribute?.("layercache.success", true);
3218
- if (result === null) {
3219
- span.setAttribute?.("layercache.result", "null");
3340
+ cache.on("operation-start", onStart);
3341
+ cache.on("operation-end", onEnd);
3342
+ return {
3343
+ uninstall() {
3344
+ cache.off("operation-start", onStart);
3345
+ cache.off("operation-end", onEnd);
3346
+ for (const span of spans.values()) {
3347
+ span.end();
3220
3348
  }
3221
- return result;
3222
- } catch (error) {
3223
- span.setAttribute?.("layercache.success", false);
3224
- span.recordException?.(error);
3225
- throw error;
3226
- } finally {
3227
- span.end();
3349
+ spans.clear();
3228
3350
  }
3229
- });
3351
+ };
3230
3352
  }
3231
3353
 
3232
3354
  // src/integrations/trpc.ts
@@ -3590,7 +3712,7 @@ var RedisLayer = class {
3590
3712
 
3591
3713
  // src/layers/DiskLayer.ts
3592
3714
  import { createHash } from "crypto";
3593
- import { promises as fs } from "fs";
3715
+ import { promises as fs2 } from "fs";
3594
3716
  import { join, resolve } from "path";
3595
3717
  var FILE_SCAN_CONCURRENCY = 32;
3596
3718
  var DiskLayer = class {
@@ -3634,7 +3756,7 @@ var DiskLayer = class {
3634
3756
  }
3635
3757
  async set(key, value, ttl = this.defaultTtl) {
3636
3758
  await this.enqueueWrite(async () => {
3637
- await fs.mkdir(this.directory, { recursive: true });
3759
+ await fs2.mkdir(this.directory, { recursive: true });
3638
3760
  const entry = {
3639
3761
  key,
3640
3762
  value,
@@ -3644,8 +3766,8 @@ var DiskLayer = class {
3644
3766
  const targetPath = this.keyToPath(key);
3645
3767
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3646
3768
  try {
3647
- await fs.writeFile(tempPath, payload);
3648
- await fs.rename(tempPath, targetPath);
3769
+ await fs2.writeFile(tempPath, payload);
3770
+ await fs2.rename(tempPath, targetPath);
3649
3771
  } catch (error) {
3650
3772
  await this.safeDelete(tempPath);
3651
3773
  throw error;
@@ -3699,7 +3821,7 @@ var DiskLayer = class {
3699
3821
  await this.enqueueWrite(async () => {
3700
3822
  let entries;
3701
3823
  try {
3702
- entries = await fs.readdir(this.directory);
3824
+ entries = await fs2.readdir(this.directory);
3703
3825
  } catch {
3704
3826
  return;
3705
3827
  }
@@ -3733,7 +3855,7 @@ var DiskLayer = class {
3733
3855
  }
3734
3856
  async ping() {
3735
3857
  try {
3736
- await fs.mkdir(this.directory, { recursive: true });
3858
+ await fs2.mkdir(this.directory, { recursive: true });
3737
3859
  return true;
3738
3860
  } catch {
3739
3861
  return false;
@@ -3776,7 +3898,7 @@ var DiskLayer = class {
3776
3898
  async readEntryFile(filePath) {
3777
3899
  let handle;
3778
3900
  try {
3779
- handle = await fs.open(filePath, "r");
3901
+ handle = await fs2.open(filePath, "r");
3780
3902
  return await this.readHandleWithLimit(handle);
3781
3903
  } catch {
3782
3904
  await this.safeDelete(filePath);
@@ -3816,7 +3938,7 @@ var DiskLayer = class {
3816
3938
  async scanEntries(visitor) {
3817
3939
  let entries;
3818
3940
  try {
3819
- entries = await fs.readdir(this.directory);
3941
+ entries = await fs2.readdir(this.directory);
3820
3942
  } catch {
3821
3943
  return;
3822
3944
  }
@@ -3879,7 +4001,7 @@ var DiskLayer = class {
3879
4001
  }
3880
4002
  async safeDelete(filePath) {
3881
4003
  try {
3882
- await fs.unlink(filePath);
4004
+ await fs2.unlink(filePath);
3883
4005
  } catch {
3884
4006
  }
3885
4007
  }
@@ -3897,7 +4019,7 @@ var DiskLayer = class {
3897
4019
  }
3898
4020
  let entries;
3899
4021
  try {
3900
- entries = await fs.readdir(this.directory);
4022
+ entries = await fs2.readdir(this.directory);
3901
4023
  } catch {
3902
4024
  return;
3903
4025
  }
@@ -3909,7 +4031,7 @@ var DiskLayer = class {
3909
4031
  lcFiles.map(async (name) => {
3910
4032
  const filePath = join(this.directory, name);
3911
4033
  try {
3912
- const stat = await fs.stat(filePath);
4034
+ const stat = await fs2.stat(filePath);
3913
4035
  return { filePath, mtimeMs: stat.mtimeMs };
3914
4036
  } catch {
3915
4037
  return { filePath, mtimeMs: 0 };
@@ -4005,44 +4127,19 @@ var MemcachedLayer = class {
4005
4127
 
4006
4128
  // src/serialization/MsgpackSerializer.ts
4007
4129
  import { decode, encode } from "@msgpack/msgpack";
4008
- var DANGEROUS_KEYS2 = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
4009
- var MAX_SANITIZE_DEPTH3 = 64;
4010
- var MAX_SANITIZE_NODES3 = 1e4;
4011
4130
  var MsgpackSerializer = class {
4012
4131
  serialize(value) {
4013
4132
  return Buffer.from(encode(value));
4014
4133
  }
4015
4134
  deserialize(payload) {
4016
4135
  const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "latin1");
4017
- return sanitizeMsgpackValue(decode(normalized), 0, { count: 0 });
4136
+ return sanitizeStructuredData(decode(normalized), {
4137
+ label: "MessagePack payload",
4138
+ maxDepth: 64,
4139
+ maxNodes: 1e4
4140
+ });
4018
4141
  }
4019
4142
  };
4020
- function sanitizeMsgpackValue(value, depth, state) {
4021
- state.count += 1;
4022
- if (state.count > MAX_SANITIZE_NODES3) {
4023
- throw new Error(`MessagePack payload exceeds max node count of ${MAX_SANITIZE_NODES3}.`);
4024
- }
4025
- if (depth > MAX_SANITIZE_DEPTH3) {
4026
- throw new Error(`MessagePack payload exceeds max depth of ${MAX_SANITIZE_DEPTH3}.`);
4027
- }
4028
- if (Array.isArray(value)) {
4029
- return value.map((entry) => sanitizeMsgpackValue(entry, depth + 1, state));
4030
- }
4031
- if (!isPlainObject2(value)) {
4032
- return value;
4033
- }
4034
- const sanitized = {};
4035
- for (const [key, entry] of Object.entries(value)) {
4036
- if (DANGEROUS_KEYS2.has(key)) {
4037
- continue;
4038
- }
4039
- sanitized[key] = sanitizeMsgpackValue(entry, depth + 1, state);
4040
- }
4041
- return sanitized;
4042
- }
4043
- function isPlainObject2(value) {
4044
- return Object.prototype.toString.call(value) === "[object Object]";
4045
- }
4046
4143
 
4047
4144
  // src/singleflight/RedisSingleFlightCoordinator.ts
4048
4145
  import { randomUUID } from "crypto";