layercache 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -688,7 +688,7 @@ function normalizeForSerialization(value) {
688
688
  }
689
689
  function serializeKeyPart(value) {
690
690
  if (typeof value === "string") {
691
- return `s:${value}`;
691
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
692
692
  }
693
693
  if (typeof value === "number") {
694
694
  return `n:${value}`;
@@ -716,102 +716,6 @@ function createInstanceId() {
716
716
  return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
717
717
  }
718
718
 
719
- // ../../src/internal/CacheSnapshotFile.ts
720
- function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path) {
721
- const relative = path.relative(realBaseDir, candidatePath);
722
- return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path.isAbsolute(relative));
723
- }
724
- async function findExistingAncestor(directory, fs, path) {
725
- let current = directory;
726
- while (true) {
727
- try {
728
- await fs.lstat(current);
729
- return current;
730
- } catch (error) {
731
- if (error.code !== "ENOENT") {
732
- throw error;
733
- }
734
- }
735
- const parent = path.dirname(current);
736
- if (parent === current) {
737
- return current;
738
- }
739
- current = parent;
740
- }
741
- }
742
- async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
743
- if (filePath.length === 0) {
744
- throw new Error("filePath must not be empty.");
745
- }
746
- if (filePath.includes("\0")) {
747
- throw new Error("filePath must not contain null bytes.");
748
- }
749
- const { promises: fs } = await import("fs");
750
- const path = await import("path");
751
- const resolved = path.resolve(filePath);
752
- const baseDir = snapshotBaseDir === false ? false : path.resolve(snapshotBaseDir ?? cwd);
753
- if (baseDir === false) {
754
- return resolved;
755
- }
756
- await fs.mkdir(baseDir, { recursive: true });
757
- const realBaseDir = await fs.realpath(baseDir);
758
- if (!isWithinSnapshotBase(realBaseDir, resolved, path.sep, path)) {
759
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
760
- }
761
- if (mode === "read") {
762
- const realTarget = await fs.realpath(resolved);
763
- if (!isWithinSnapshotBase(realBaseDir, realTarget, path.sep, path)) {
764
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
765
- }
766
- return realTarget;
767
- }
768
- const parentDir = path.dirname(resolved);
769
- const existingAncestor = await findExistingAncestor(parentDir, fs, path);
770
- const realExistingAncestor = await fs.realpath(existingAncestor);
771
- if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path.sep, path)) {
772
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
773
- }
774
- await fs.mkdir(parentDir, { recursive: true });
775
- const realParentDir = await fs.realpath(parentDir);
776
- if (!isWithinSnapshotBase(realBaseDir, realParentDir, path.sep, path)) {
777
- throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
778
- }
779
- const targetPath = path.join(realParentDir, path.basename(resolved));
780
- try {
781
- const existing = await fs.lstat(targetPath);
782
- if (existing.isSymbolicLink()) {
783
- throw new Error("filePath must not point to a symbolic link.");
784
- }
785
- } catch (error) {
786
- if (error.code !== "ENOENT") {
787
- throw error;
788
- }
789
- }
790
- return targetPath;
791
- }
792
- async function readUtf8HandleWithLimit(handle, byteLimit) {
793
- if (byteLimit === false) {
794
- return handle.readFile({ encoding: "utf8" });
795
- }
796
- const chunks = [];
797
- let totalBytes = 0;
798
- let position = 0;
799
- while (true) {
800
- const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
801
- const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
802
- if (bytesRead === 0) {
803
- break;
804
- }
805
- totalBytes += bytesRead;
806
- if (totalBytes > byteLimit) {
807
- throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
808
- }
809
- chunks.push(buffer.subarray(0, bytesRead));
810
- position += bytesRead;
811
- }
812
- return Buffer.concat(chunks).toString("utf8");
813
- }
814
-
815
719
  // ../../src/internal/CacheStackGeneration.ts
816
720
  var DEFAULT_GENERATION_CLEANUP_BATCH_SIZE = 500;
817
721
  function generationPrefix(generation) {
@@ -859,102 +763,66 @@ function planGenerationCleanupBatches(keys, generationCleanup) {
859
763
  return batches;
860
764
  }
861
765
 
862
- // ../../src/internal/CacheStackMaintenance.ts
863
- var CacheStackMaintenance = class {
864
- keyEpochs = /* @__PURE__ */ new Map();
865
- writeBehindQueue = [];
866
- writeBehindTimer;
867
- writeBehindFlushPromise;
868
- generationCleanupPromise;
869
- clearEpoch = 0;
870
- initializeWriteBehindTimer(writeStrategy, options, flush) {
871
- if (writeStrategy !== "write-behind") {
872
- return;
873
- }
874
- const flushIntervalMs = options?.flushIntervalMs;
875
- if (!flushIntervalMs || flushIntervalMs <= 0) {
876
- return;
877
- }
878
- this.disposeWriteBehindTimer();
879
- this.writeBehindTimer = setInterval(() => {
880
- void flush();
881
- }, flushIntervalMs);
882
- this.writeBehindTimer.unref?.();
766
+ // ../../src/internal/CacheStackInvalidationSupport.ts
767
+ var CacheStackInvalidationSupport = class {
768
+ constructor(options) {
769
+ this.options = options;
883
770
  }
884
- disposeWriteBehindTimer() {
885
- if (!this.writeBehindTimer) {
886
- return;
771
+ options;
772
+ async collectKeysForTag(tag, maxKeys) {
773
+ const keys = /* @__PURE__ */ new Set();
774
+ if (this.options.tagIndex.forEachKeyForTag) {
775
+ await this.options.tagIndex.forEachKeyForTag(tag, async (key) => {
776
+ keys.add(key);
777
+ this.assertWithinInvalidationKeyLimit(keys.size, maxKeys);
778
+ });
779
+ return [...keys];
887
780
  }
888
- clearInterval(this.writeBehindTimer);
889
- this.writeBehindTimer = void 0;
890
- }
891
- beginClearEpoch() {
892
- this.clearEpoch += 1;
893
- this.keyEpochs.clear();
894
- this.writeBehindQueue.length = 0;
895
- }
896
- currentClearEpoch() {
897
- return this.clearEpoch;
898
- }
899
- currentKeyEpoch(key) {
900
- return this.keyEpochs.get(key) ?? 0;
901
- }
902
- bumpKeyEpochs(keys) {
903
- for (const key of keys) {
904
- this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
781
+ for (const key of await this.options.tagIndex.keysForTag(tag)) {
782
+ keys.add(key);
783
+ this.assertWithinInvalidationKeyLimit(keys.size, maxKeys);
905
784
  }
785
+ return [...keys];
906
786
  }
907
- isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
908
- if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
909
- return true;
910
- }
911
- if (expectedKeyEpoch !== void 0 && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
912
- return true;
787
+ intersectKeys(groups) {
788
+ if (groups.length === 0) {
789
+ return [];
913
790
  }
914
- return false;
791
+ const [firstGroup, ...rest] = groups;
792
+ const restSets = rest.map((group) => new Set(group));
793
+ return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)));
915
794
  }
916
- async enqueueWriteBehind(operation, options, flushBatch) {
917
- this.writeBehindQueue.push(operation);
918
- const batchSize = options?.batchSize ?? 100;
919
- const maxQueueSize = options?.maxQueueSize ?? batchSize * 10;
920
- if (this.writeBehindQueue.length >= batchSize) {
921
- await this.flushWriteBehindQueue(options, flushBatch);
922
- return;
923
- }
924
- if (this.writeBehindQueue.length >= maxQueueSize) {
925
- await this.flushWriteBehindQueue(options, flushBatch);
926
- }
795
+ async deleteKeysFromLayers(layers, keys) {
796
+ await Promise.all(
797
+ layers.map(async (layer) => {
798
+ if (this.options.shouldSkipLayer(layer)) {
799
+ return;
800
+ }
801
+ if (layer.deleteMany) {
802
+ try {
803
+ await layer.deleteMany(keys);
804
+ } catch (error) {
805
+ await this.options.handleLayerFailure(layer, "delete", error);
806
+ }
807
+ return;
808
+ }
809
+ await Promise.all(
810
+ keys.map(async (key) => {
811
+ try {
812
+ await layer.delete(key);
813
+ } catch (error) {
814
+ await this.options.handleLayerFailure(layer, "delete", error);
815
+ }
816
+ })
817
+ );
818
+ })
819
+ );
927
820
  }
928
- async flushWriteBehindQueue(options, flushBatch) {
929
- if (this.writeBehindFlushPromise || this.writeBehindQueue.length === 0) {
930
- await this.writeBehindFlushPromise;
931
- return;
932
- }
933
- const batchSize = options?.batchSize ?? 100;
934
- const batch = this.writeBehindQueue.splice(0, batchSize);
935
- this.writeBehindFlushPromise = flushBatch(batch);
936
- try {
937
- await this.writeBehindFlushPromise;
938
- } finally {
939
- this.writeBehindFlushPromise = void 0;
940
- }
941
- if (this.writeBehindQueue.length > 0) {
942
- await this.flushWriteBehindQueue(options, flushBatch);
821
+ assertWithinInvalidationKeyLimit(size, maxKeys) {
822
+ if (maxKeys !== false && size > maxKeys) {
823
+ throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
943
824
  }
944
825
  }
945
- scheduleGenerationCleanup(generation, task, onError) {
946
- const scheduledTask = (this.generationCleanupPromise ?? Promise.resolve()).then(() => task(generation)).catch((error) => {
947
- onError(generation, error);
948
- });
949
- this.generationCleanupPromise = scheduledTask.finally(() => {
950
- if (this.generationCleanupPromise === scheduledTask) {
951
- this.generationCleanupPromise = void 0;
952
- }
953
- });
954
- }
955
- async waitForGenerationCleanup() {
956
- await this.generationCleanupPromise;
957
- }
958
826
  };
959
827
 
960
828
  // ../../src/internal/StoredValue.ts
@@ -1084,72 +952,598 @@ function refreshStoredEnvelope(stored, now = Date.now()) {
1084
952
  if (!isStoredValueEnvelope(stored)) {
1085
953
  return stored;
1086
954
  }
1087
- return createStoredValueEnvelope({
1088
- kind: stored.kind,
1089
- value: stored.value,
1090
- freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
1091
- staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
1092
- staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
1093
- now
1094
- });
1095
- }
1096
- function maxExpiry(stored) {
1097
- const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
1098
- (value) => value !== null
1099
- );
1100
- if (values.length === 0) {
1101
- return null;
955
+ return createStoredValueEnvelope({
956
+ kind: stored.kind,
957
+ value: stored.value,
958
+ freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
959
+ staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
960
+ staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
961
+ now
962
+ });
963
+ }
964
+ function maxExpiry(stored) {
965
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
966
+ (value) => value !== null
967
+ );
968
+ if (values.length === 0) {
969
+ return null;
970
+ }
971
+ return Math.max(...values);
972
+ }
973
+ function normalizePositiveSeconds(value) {
974
+ if (!value || value <= 0) {
975
+ return void 0;
976
+ }
977
+ return value;
978
+ }
979
+ function isValidEnvelopeTtlSeconds(value, maxTtlSeconds) {
980
+ if (value == null) {
981
+ return true;
982
+ }
983
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= maxTtlSeconds;
984
+ }
985
+
986
+ // ../../src/internal/CacheStackLayerWriter.ts
987
+ var CacheStackLayerWriter = class {
988
+ constructor(options) {
989
+ this.options = options;
990
+ }
991
+ options;
992
+ async writeAcrossLayers(key, kind, value, writeOptions) {
993
+ const now = Date.now();
994
+ const clearEpoch = this.options.maintenance.currentClearEpoch();
995
+ const keyEpoch = this.options.maintenance.currentKeyEpoch(key);
996
+ const immediateOperations = [];
997
+ const deferredOperations = [];
998
+ for (const layer of this.options.layers) {
999
+ const operation = async () => {
1000
+ if (this.options.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
1001
+ return;
1002
+ }
1003
+ if (this.options.shouldSkipLayer(layer)) {
1004
+ return;
1005
+ }
1006
+ const entry = this.buildLayerSetEntry(layer, key, kind, value, writeOptions, now);
1007
+ try {
1008
+ await layer.set(entry.key, entry.value, entry.ttl);
1009
+ } catch (error) {
1010
+ await this.options.handleLayerFailure(layer, "write", error);
1011
+ }
1012
+ };
1013
+ if (this.options.shouldWriteBehind(layer)) {
1014
+ deferredOperations.push(operation);
1015
+ } else {
1016
+ immediateOperations.push(operation);
1017
+ }
1018
+ }
1019
+ await this.executeLayerOperations(immediateOperations, { key, action: kind === "empty" ? "negative-set" : "set" });
1020
+ await Promise.all(deferredOperations.map((operation) => this.options.enqueueWriteBehind(operation)));
1021
+ }
1022
+ async writeBatch(entries) {
1023
+ const now = Date.now();
1024
+ const clearEpoch = this.options.maintenance.currentClearEpoch();
1025
+ const entryEpochs = new Map(
1026
+ entries.map((entry) => [entry.key, this.options.maintenance.currentKeyEpoch(entry.key)])
1027
+ );
1028
+ const entriesByLayer = /* @__PURE__ */ new Map();
1029
+ const immediateOperations = [];
1030
+ const deferredOperations = [];
1031
+ for (const entry of entries) {
1032
+ for (const layer of this.options.layers) {
1033
+ if (this.options.shouldSkipLayer(layer)) {
1034
+ continue;
1035
+ }
1036
+ const layerEntry = this.buildLayerSetEntry(layer, entry.key, "value", entry.value, entry.options, now);
1037
+ const bucket = entriesByLayer.get(layer) ?? [];
1038
+ bucket.push(layerEntry);
1039
+ entriesByLayer.set(layer, bucket);
1040
+ }
1041
+ }
1042
+ for (const [layer, layerEntries] of entriesByLayer.entries()) {
1043
+ const operation = async () => {
1044
+ if (clearEpoch !== this.options.maintenance.currentClearEpoch()) {
1045
+ return;
1046
+ }
1047
+ const activeEntries = layerEntries.filter(
1048
+ (entry) => (entryEpochs.get(entry.key) ?? 0) === this.options.maintenance.currentKeyEpoch(entry.key)
1049
+ );
1050
+ if (activeEntries.length === 0) {
1051
+ return;
1052
+ }
1053
+ try {
1054
+ if (layer.setMany) {
1055
+ await layer.setMany(activeEntries);
1056
+ return;
1057
+ }
1058
+ await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
1059
+ } catch (error) {
1060
+ await this.options.handleLayerFailure(layer, "write", error);
1061
+ }
1062
+ };
1063
+ if (this.options.shouldWriteBehind(layer)) {
1064
+ deferredOperations.push(operation);
1065
+ } else {
1066
+ immediateOperations.push(operation);
1067
+ }
1068
+ }
1069
+ await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
1070
+ await Promise.all(deferredOperations.map((operation) => this.options.enqueueWriteBehind(operation)));
1071
+ return { clearEpoch, entryEpochs };
1072
+ }
1073
+ async executeLayerOperations(operations, context) {
1074
+ if (this.options.writePolicy !== "best-effort") {
1075
+ await Promise.all(operations.map((operation) => operation()));
1076
+ return;
1077
+ }
1078
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
1079
+ const failures = results.filter((result) => result.status === "rejected");
1080
+ const degraded = results.filter((result) => result.status === "fulfilled");
1081
+ if (failures.length === 0) {
1082
+ return;
1083
+ }
1084
+ this.options.onWriteFailures(
1085
+ context,
1086
+ failures.map((failure) => failure.reason)
1087
+ );
1088
+ if (failures.length === operations.length) {
1089
+ throw new AggregateError(
1090
+ failures.map((failure) => failure.reason),
1091
+ `${context.action} failed for every cache layer`
1092
+ );
1093
+ }
1094
+ }
1095
+ buildLayerSetEntry(layer, key, kind, value, writeOptions, now) {
1096
+ const freshTtl = this.options.resolveFreshTtl(key, layer.name, kind, writeOptions, layer.defaultTtl, value);
1097
+ const staleWhileRevalidate = this.options.resolveLayerSeconds(
1098
+ layer.name,
1099
+ writeOptions?.staleWhileRevalidate,
1100
+ this.options.globalStaleWhileRevalidate
1101
+ );
1102
+ const staleIfError = this.options.resolveLayerSeconds(
1103
+ layer.name,
1104
+ writeOptions?.staleIfError,
1105
+ this.options.globalStaleIfError
1106
+ );
1107
+ const payload = createStoredValueEnvelope({
1108
+ kind,
1109
+ value,
1110
+ freshTtlSeconds: freshTtl,
1111
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
1112
+ staleIfErrorSeconds: staleIfError,
1113
+ now
1114
+ });
1115
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
1116
+ return {
1117
+ key,
1118
+ value: payload,
1119
+ ttl
1120
+ };
1121
+ }
1122
+ };
1123
+
1124
+ // ../../src/internal/CacheStackMaintenance.ts
1125
+ var CacheStackMaintenance = class {
1126
+ keyEpochs = /* @__PURE__ */ new Map();
1127
+ writeBehindQueue = [];
1128
+ writeBehindTimer;
1129
+ writeBehindFlushPromise;
1130
+ generationCleanupPromise;
1131
+ clearEpoch = 0;
1132
+ initializeWriteBehindTimer(writeStrategy, options, flush) {
1133
+ if (writeStrategy !== "write-behind") {
1134
+ return;
1135
+ }
1136
+ const flushIntervalMs = options?.flushIntervalMs;
1137
+ if (!flushIntervalMs || flushIntervalMs <= 0) {
1138
+ return;
1139
+ }
1140
+ this.disposeWriteBehindTimer();
1141
+ this.writeBehindTimer = setInterval(() => {
1142
+ void flush();
1143
+ }, flushIntervalMs);
1144
+ this.writeBehindTimer.unref?.();
1145
+ }
1146
+ disposeWriteBehindTimer() {
1147
+ if (!this.writeBehindTimer) {
1148
+ return;
1149
+ }
1150
+ clearInterval(this.writeBehindTimer);
1151
+ this.writeBehindTimer = void 0;
1152
+ }
1153
+ beginClearEpoch() {
1154
+ this.clearEpoch += 1;
1155
+ this.keyEpochs.clear();
1156
+ this.writeBehindQueue.length = 0;
1157
+ }
1158
+ currentClearEpoch() {
1159
+ return this.clearEpoch;
1160
+ }
1161
+ currentKeyEpoch(key) {
1162
+ return this.keyEpochs.get(key) ?? 0;
1163
+ }
1164
+ bumpKeyEpochs(keys) {
1165
+ for (const key of keys) {
1166
+ this.keyEpochs.set(key, this.currentKeyEpoch(key) + 1);
1167
+ }
1168
+ }
1169
+ isWriteOutdated(key, expectedClearEpoch, expectedKeyEpoch) {
1170
+ if (expectedClearEpoch !== void 0 && expectedClearEpoch !== this.clearEpoch) {
1171
+ return true;
1172
+ }
1173
+ if (expectedKeyEpoch !== void 0 && expectedKeyEpoch !== this.currentKeyEpoch(key)) {
1174
+ return true;
1175
+ }
1176
+ return false;
1177
+ }
1178
+ async enqueueWriteBehind(operation, options, flushBatch) {
1179
+ this.writeBehindQueue.push(operation);
1180
+ const batchSize = options?.batchSize ?? 100;
1181
+ const maxQueueSize = options?.maxQueueSize ?? batchSize * 10;
1182
+ if (this.writeBehindQueue.length >= batchSize) {
1183
+ await this.flushWriteBehindQueue(options, flushBatch);
1184
+ return;
1185
+ }
1186
+ if (this.writeBehindQueue.length >= maxQueueSize) {
1187
+ await this.flushWriteBehindQueue(options, flushBatch);
1188
+ }
1189
+ }
1190
+ async flushWriteBehindQueue(options, flushBatch) {
1191
+ if (this.writeBehindFlushPromise || this.writeBehindQueue.length === 0) {
1192
+ await this.writeBehindFlushPromise;
1193
+ return;
1194
+ }
1195
+ const batchSize = options?.batchSize ?? 100;
1196
+ const batch = this.writeBehindQueue.splice(0, batchSize);
1197
+ this.writeBehindFlushPromise = flushBatch(batch);
1198
+ try {
1199
+ await this.writeBehindFlushPromise;
1200
+ } finally {
1201
+ this.writeBehindFlushPromise = void 0;
1202
+ }
1203
+ if (this.writeBehindQueue.length > 0) {
1204
+ await this.flushWriteBehindQueue(options, flushBatch);
1205
+ }
1206
+ }
1207
+ scheduleGenerationCleanup(generation, task, onError) {
1208
+ const scheduledTask = (this.generationCleanupPromise ?? Promise.resolve()).then(() => task(generation)).catch((error) => {
1209
+ onError(generation, error);
1210
+ });
1211
+ this.generationCleanupPromise = scheduledTask.finally(() => {
1212
+ if (this.generationCleanupPromise === scheduledTask) {
1213
+ this.generationCleanupPromise = void 0;
1214
+ }
1215
+ });
1216
+ }
1217
+ async waitForGenerationCleanup() {
1218
+ await this.generationCleanupPromise;
1219
+ }
1220
+ };
1221
+
1222
+ // ../../src/internal/CacheStackRuntimePolicy.ts
1223
+ function shouldSkipLayer(degradedUntil, now = Date.now()) {
1224
+ return degradedUntil !== void 0 && degradedUntil > now;
1225
+ }
1226
+ function shouldStartBackgroundRefresh({
1227
+ isDisconnecting,
1228
+ hasRefreshInFlight
1229
+ }) {
1230
+ return !isDisconnecting && !hasRefreshInFlight;
1231
+ }
1232
+ function resolveRecoverableLayerFailure(gracefulDegradation, now = Date.now()) {
1233
+ if (!gracefulDegradation) {
1234
+ return { degrade: false };
1235
+ }
1236
+ const retryAfterMs = typeof gracefulDegradation === "object" ? gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1237
+ return {
1238
+ degrade: true,
1239
+ degradedUntil: now + retryAfterMs
1240
+ };
1241
+ }
1242
+ function planFreshReadPolicies({
1243
+ stored,
1244
+ hasFetcher,
1245
+ slidingTtl,
1246
+ refreshAheadSeconds
1247
+ }) {
1248
+ const refreshedStored = slidingTtl && isStoredValueEnvelope(stored) ? refreshStoredEnvelope(stored) : void 0;
1249
+ const refreshedStoredTtl = refreshedStored ? remainingStoredTtlSeconds(refreshedStored) ?? void 0 : void 0;
1250
+ const remainingFreshTtl = remainingFreshTtlSeconds(stored) ?? 0;
1251
+ return {
1252
+ refreshedStored,
1253
+ refreshedStoredTtl,
1254
+ shouldScheduleBackgroundRefresh: hasFetcher && refreshAheadSeconds > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAheadSeconds
1255
+ };
1256
+ }
1257
+
1258
+ // ../../src/internal/CacheStackSnapshotManager.ts
1259
+ import { randomBytes } from "crypto";
1260
+ import { constants, promises as fs } from "fs";
1261
+ import path from "path";
1262
+
1263
+ // ../../src/internal/CacheSnapshotFile.ts
1264
+ function isWithinSnapshotBase(realBaseDir, candidatePath, pathSeparator, path2) {
1265
+ const relative = path2.relative(realBaseDir, candidatePath);
1266
+ return !(relative === ".." || relative.startsWith(`..${pathSeparator}`) || path2.isAbsolute(relative));
1267
+ }
1268
+ async function findExistingAncestor(directory, fs2, path2) {
1269
+ let current = directory;
1270
+ while (true) {
1271
+ try {
1272
+ await fs2.lstat(current);
1273
+ return current;
1274
+ } catch (error) {
1275
+ if (error.code !== "ENOENT") {
1276
+ throw error;
1277
+ }
1278
+ }
1279
+ const parent = path2.dirname(current);
1280
+ if (parent === current) {
1281
+ return current;
1282
+ }
1283
+ current = parent;
1284
+ }
1285
+ }
1286
+ async function validateSnapshotFilePath(filePath, mode, snapshotBaseDir, cwd = process.cwd()) {
1287
+ if (filePath.length === 0) {
1288
+ throw new Error("filePath must not be empty.");
1289
+ }
1290
+ if (filePath.includes("\0")) {
1291
+ throw new Error("filePath must not contain null bytes.");
1292
+ }
1293
+ const { promises: fs2 } = await import("fs");
1294
+ const path2 = await import("path");
1295
+ const resolved = path2.resolve(filePath);
1296
+ const baseDir = snapshotBaseDir === false ? false : path2.resolve(snapshotBaseDir ?? cwd);
1297
+ if (baseDir === false) {
1298
+ return resolved;
1299
+ }
1300
+ await fs2.mkdir(baseDir, { recursive: true });
1301
+ const realBaseDir = await fs2.realpath(baseDir);
1302
+ if (!isWithinSnapshotBase(realBaseDir, resolved, path2.sep, path2)) {
1303
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
1304
+ }
1305
+ if (mode === "read") {
1306
+ const realTarget = await fs2.realpath(resolved);
1307
+ if (!isWithinSnapshotBase(realBaseDir, realTarget, path2.sep, path2)) {
1308
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
1309
+ }
1310
+ return realTarget;
1311
+ }
1312
+ const parentDir = path2.dirname(resolved);
1313
+ const existingAncestor = await findExistingAncestor(parentDir, fs2, path2);
1314
+ const realExistingAncestor = await fs2.realpath(existingAncestor);
1315
+ if (!isWithinSnapshotBase(realBaseDir, realExistingAncestor, path2.sep, path2)) {
1316
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
1317
+ }
1318
+ await fs2.mkdir(parentDir, { recursive: true });
1319
+ const realParentDir = await fs2.realpath(parentDir);
1320
+ if (!isWithinSnapshotBase(realBaseDir, realParentDir, path2.sep, path2)) {
1321
+ throw new Error(`filePath is outside the allowed snapshot directory: ${realBaseDir}`);
1322
+ }
1323
+ const targetPath = path2.join(realParentDir, path2.basename(resolved));
1324
+ try {
1325
+ const existing = await fs2.lstat(targetPath);
1326
+ if (existing.isSymbolicLink()) {
1327
+ throw new Error("filePath must not point to a symbolic link.");
1328
+ }
1329
+ } catch (error) {
1330
+ if (error.code !== "ENOENT") {
1331
+ throw error;
1332
+ }
1333
+ }
1334
+ return targetPath;
1335
+ }
1336
+ async function readUtf8HandleWithLimit(handle, byteLimit) {
1337
+ if (byteLimit === false) {
1338
+ return handle.readFile({ encoding: "utf8" });
1339
+ }
1340
+ const chunks = [];
1341
+ let totalBytes = 0;
1342
+ let position = 0;
1343
+ while (true) {
1344
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, byteLimit - totalBytes + 1));
1345
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, position);
1346
+ if (bytesRead === 0) {
1347
+ break;
1348
+ }
1349
+ totalBytes += bytesRead;
1350
+ if (totalBytes > byteLimit) {
1351
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${totalBytes} bytes > ${byteLimit} bytes).`);
1352
+ }
1353
+ chunks.push(buffer.subarray(0, bytesRead));
1354
+ position += bytesRead;
1355
+ }
1356
+ return Buffer.concat(chunks).toString("utf8");
1357
+ }
1358
+
1359
+ // ../../src/internal/StructuredDataSanitizer.ts
1360
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1361
+ function sanitizeStructuredData(value, options) {
1362
+ return sanitizeValue(value, 0, { count: 0 }, options);
1363
+ }
1364
+ function sanitizeValue(value, depth, state, options) {
1365
+ state.count += 1;
1366
+ if (state.count > options.maxNodes) {
1367
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1368
+ }
1369
+ if (depth > options.maxDepth) {
1370
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1371
+ }
1372
+ if (Array.isArray(value)) {
1373
+ const sanitized2 = [];
1374
+ for (const entry of value) {
1375
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
1376
+ }
1377
+ return sanitized2;
1378
+ }
1379
+ if (!isPlainObject(value)) {
1380
+ return value;
1381
+ }
1382
+ const sanitized = options.createObject?.() ?? {};
1383
+ for (const [key, entry] of Object.entries(value)) {
1384
+ if (DANGEROUS_KEYS.has(key)) {
1385
+ continue;
1386
+ }
1387
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1388
+ }
1389
+ return sanitized;
1390
+ }
1391
+ function isPlainObject(value) {
1392
+ return Object.prototype.toString.call(value) === "[object Object]";
1393
+ }
1394
+
1395
+ // ../../src/internal/CacheStackSnapshotManager.ts
1396
+ var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1397
+ var CacheStackSnapshotManager = class {
1398
+ constructor(options) {
1399
+ this.options = options;
1400
+ }
1401
+ options;
1402
+ async exportState(maxEntries) {
1403
+ const entries = [];
1404
+ await this.visitExportEntries(maxEntries, async (entry) => {
1405
+ entries.push(entry);
1406
+ });
1407
+ return entries;
1408
+ }
1409
+ async importState(entries) {
1410
+ const normalizedEntries = entries.map((entry) => ({
1411
+ key: this.options.qualifyKey(this.options.validateCacheKey(entry.key)),
1412
+ value: entry.value,
1413
+ ttl: entry.ttl
1414
+ }));
1415
+ for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
1416
+ const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
1417
+ await Promise.all(
1418
+ batch.map(async (entry) => {
1419
+ await Promise.all(
1420
+ this.options.layers.map(async (layer) => {
1421
+ if (this.options.shouldSkipLayer(layer)) return;
1422
+ try {
1423
+ await layer.set(entry.key, entry.value, entry.ttl);
1424
+ } catch (error) {
1425
+ await this.options.handleLayerFailure(layer, "write", error);
1426
+ }
1427
+ })
1428
+ );
1429
+ await this.options.tagIndex.touch(entry.key);
1430
+ })
1431
+ );
1432
+ }
1433
+ }
1434
+ async persistToFile(filePath, snapshotBaseDir, maxEntries) {
1435
+ const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
1436
+ const tempPath = path.join(
1437
+ path.dirname(targetPath),
1438
+ `.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
1439
+ );
1440
+ let handle;
1441
+ try {
1442
+ handle = await fs.open(tempPath, "wx");
1443
+ const openedHandle = handle;
1444
+ await openedHandle.writeFile("[", "utf8");
1445
+ let wroteAny = false;
1446
+ await this.visitExportEntries(maxEntries, async (entry) => {
1447
+ await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
1448
+ await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
1449
+ wroteAny = true;
1450
+ });
1451
+ await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
1452
+ await openedHandle.close();
1453
+ handle = void 0;
1454
+ await fs.rename(tempPath, targetPath);
1455
+ } catch (error) {
1456
+ await handle?.close().catch(() => void 0);
1457
+ await fs.unlink(tempPath).catch(() => void 0);
1458
+ throw error;
1459
+ }
1460
+ }
1461
+ async restoreFromFile(filePath, snapshotBaseDir, maxBytes) {
1462
+ const validatedPath = await validateSnapshotFilePath(filePath, "read", snapshotBaseDir);
1463
+ const handle = await fs.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
1464
+ let raw;
1465
+ try {
1466
+ if (maxBytes !== false) {
1467
+ const stat = await handle.stat();
1468
+ if (stat.size > maxBytes) {
1469
+ throw new Error(`Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${maxBytes} bytes).`);
1470
+ }
1471
+ }
1472
+ raw = await readUtf8HandleWithLimit(handle, maxBytes);
1473
+ } finally {
1474
+ await handle.close();
1475
+ }
1476
+ let parsed;
1477
+ try {
1478
+ parsed = JSON.parse(raw);
1479
+ } catch (cause) {
1480
+ throw new Error(`Invalid snapshot file: could not parse JSON (${this.options.formatError(cause)})`);
1481
+ }
1482
+ if (!this.isCacheSnapshotEntries(parsed)) {
1483
+ throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
1484
+ }
1485
+ await this.importState(
1486
+ parsed.map((entry) => ({
1487
+ key: entry.key,
1488
+ value: this.sanitizeSnapshotValue(entry.value),
1489
+ ttl: entry.ttl
1490
+ }))
1491
+ );
1102
1492
  }
1103
- return Math.max(...values);
1104
- }
1105
- function normalizePositiveSeconds(value) {
1106
- if (!value || value <= 0) {
1107
- return void 0;
1493
+ async visitExportEntries(maxEntries, visitor) {
1494
+ const exported = /* @__PURE__ */ new Set();
1495
+ for (const layer of this.options.layers) {
1496
+ if (!layer.keys && !layer.forEachKey) {
1497
+ continue;
1498
+ }
1499
+ const visitKey = async (key) => {
1500
+ const exportedKey = this.options.stripQualifiedKey(key);
1501
+ if (exported.has(exportedKey)) {
1502
+ return;
1503
+ }
1504
+ const stored = await this.options.readLayerEntry(layer, key);
1505
+ if (stored === null) {
1506
+ return;
1507
+ }
1508
+ exported.add(exportedKey);
1509
+ if (maxEntries !== false && exported.size > maxEntries) {
1510
+ throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
1511
+ }
1512
+ await visitor({
1513
+ key: exportedKey,
1514
+ value: stored,
1515
+ ttl: remainingStoredTtlSeconds(stored)
1516
+ });
1517
+ };
1518
+ if (layer.forEachKey) {
1519
+ await layer.forEachKey(visitKey);
1520
+ continue;
1521
+ }
1522
+ const keys = await layer.keys?.();
1523
+ for (const key of keys ?? []) {
1524
+ await visitKey(key);
1525
+ }
1526
+ }
1108
1527
  }
1109
- return value;
1110
- }
1111
- function isValidEnvelopeTtlSeconds(value, maxTtlSeconds) {
1112
- if (value == null) {
1113
- return true;
1528
+ isCacheSnapshotEntries(value) {
1529
+ return Array.isArray(value) && value.every((entry) => {
1530
+ if (!entry || typeof entry !== "object") {
1531
+ return false;
1532
+ }
1533
+ const candidate = entry;
1534
+ return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
1535
+ });
1114
1536
  }
1115
- return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= maxTtlSeconds;
1116
- }
1117
-
1118
- // ../../src/internal/CacheStackRuntimePolicy.ts
1119
- function shouldSkipLayer(degradedUntil, now = Date.now()) {
1120
- return degradedUntil !== void 0 && degradedUntil > now;
1121
- }
1122
- function shouldStartBackgroundRefresh({
1123
- isDisconnecting,
1124
- hasRefreshInFlight
1125
- }) {
1126
- return !isDisconnecting && !hasRefreshInFlight;
1127
- }
1128
- function resolveRecoverableLayerFailure(gracefulDegradation, now = Date.now()) {
1129
- if (!gracefulDegradation) {
1130
- return { degrade: false };
1537
+ sanitizeSnapshotValue(value) {
1538
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1539
+ return sanitizeStructuredData(roundTripped, {
1540
+ label: "Snapshot value",
1541
+ maxDepth: 64,
1542
+ maxNodes: 1e4,
1543
+ createObject: () => /* @__PURE__ */ Object.create(null)
1544
+ });
1131
1545
  }
1132
- const retryAfterMs = typeof gracefulDegradation === "object" ? gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
1133
- return {
1134
- degrade: true,
1135
- degradedUntil: now + retryAfterMs
1136
- };
1137
- }
1138
- function planFreshReadPolicies({
1139
- stored,
1140
- hasFetcher,
1141
- slidingTtl,
1142
- refreshAheadSeconds
1143
- }) {
1144
- const refreshedStored = slidingTtl && isStoredValueEnvelope(stored) ? refreshStoredEnvelope(stored) : void 0;
1145
- const refreshedStoredTtl = refreshedStored ? remainingStoredTtlSeconds(refreshedStored) ?? void 0 : void 0;
1146
- const remainingFreshTtl = remainingFreshTtlSeconds(stored) ?? 0;
1147
- return {
1148
- refreshedStored,
1149
- refreshedStoredTtl,
1150
- shouldScheduleBackgroundRefresh: hasFetcher && refreshAheadSeconds > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAheadSeconds
1151
- };
1152
- }
1546
+ };
1153
1547
 
1154
1548
  // ../../src/internal/CacheStackValidation.ts
1155
1549
  var MAX_CACHE_KEY_LENGTH = 1024;
@@ -1378,7 +1772,11 @@ var FetchRateLimiter = class {
1378
1772
  fetcherBuckets = /* @__PURE__ */ new WeakMap();
1379
1773
  nextFetcherBucketId = 0;
1380
1774
  drainTimer;
1775
+ isDisposed = false;
1381
1776
  async schedule(options, context, task) {
1777
+ if (this.isDisposed) {
1778
+ throw new Error("FetchRateLimiter has been disposed.");
1779
+ }
1382
1780
  if (!options) {
1383
1781
  return task();
1384
1782
  }
@@ -1401,6 +1799,27 @@ var FetchRateLimiter = class {
1401
1799
  this.drain();
1402
1800
  });
1403
1801
  }
1802
+ dispose() {
1803
+ this.isDisposed = true;
1804
+ if (this.drainTimer) {
1805
+ clearTimeout(this.drainTimer);
1806
+ this.drainTimer = void 0;
1807
+ }
1808
+ for (const bucket of this.buckets.values()) {
1809
+ if (bucket.cleanupTimer) {
1810
+ clearTimeout(bucket.cleanupTimer);
1811
+ bucket.cleanupTimer = void 0;
1812
+ }
1813
+ }
1814
+ for (const queue of this.queuesByBucket.values()) {
1815
+ for (const item of queue) {
1816
+ item.reject(new Error("FetchRateLimiter has been disposed."));
1817
+ }
1818
+ }
1819
+ this.queuesByBucket.clear();
1820
+ this.pendingBuckets.clear();
1821
+ this.buckets.clear();
1822
+ }
1404
1823
  normalize(options) {
1405
1824
  const maxConcurrent = options.maxConcurrent;
1406
1825
  const intervalMs = options.intervalMs;
@@ -1436,6 +1855,9 @@ var FetchRateLimiter = class {
1436
1855
  return "global";
1437
1856
  }
1438
1857
  drain() {
1858
+ if (this.isDisposed) {
1859
+ return;
1860
+ }
1439
1861
  if (this.drainTimer) {
1440
1862
  clearTimeout(this.drainTimer);
1441
1863
  this.drainTimer = void 0;
@@ -1499,7 +1921,13 @@ var FetchRateLimiter = class {
1499
1921
  this.pendingBuckets.add(next.bucketKey);
1500
1922
  }
1501
1923
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1502
- this.drain();
1924
+ if (!this.drainTimer) {
1925
+ this.drainTimer = setTimeout(() => {
1926
+ this.drainTimer = void 0;
1927
+ this.drain();
1928
+ }, 0);
1929
+ this.drainTimer.unref?.();
1930
+ }
1503
1931
  });
1504
1932
  }
1505
1933
  }
@@ -1532,12 +1960,18 @@ var FetchRateLimiter = class {
1532
1960
  }
1533
1961
  }
1534
1962
  bucketState(bucketKey) {
1963
+ if (this.isDisposed) {
1964
+ throw new Error("FetchRateLimiter has been disposed.");
1965
+ }
1535
1966
  const existing = this.buckets.get(bucketKey);
1536
1967
  if (existing) {
1537
1968
  return existing;
1538
1969
  }
1539
1970
  if (this.buckets.size >= MAX_BUCKETS) {
1540
1971
  this.evictIdleBuckets();
1972
+ if (this.buckets.size >= MAX_BUCKETS) {
1973
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
1974
+ }
1541
1975
  }
1542
1976
  const bucket = { active: 0, startedAt: [] };
1543
1977
  this.buckets.set(bucketKey, bucket);
@@ -1990,19 +2424,19 @@ var TagIndex = class {
1990
2424
  if (!this.knownKeys.delete(key)) {
1991
2425
  return;
1992
2426
  }
1993
- const path = [];
2427
+ const path2 = [];
1994
2428
  let node = this.root;
1995
2429
  for (const character of key) {
1996
2430
  const child = node.children.get(character);
1997
2431
  if (!child) {
1998
2432
  return;
1999
2433
  }
2000
- path.push([node, character]);
2434
+ path2.push([node, character]);
2001
2435
  node = child;
2002
2436
  }
2003
2437
  node.terminal = false;
2004
- for (let index = path.length - 1; index >= 0; index -= 1) {
2005
- const entry = path[index];
2438
+ for (let index = path2.length - 1; index >= 0; index -= 1) {
2439
+ const entry = path2[index];
2006
2440
  if (!entry) {
2007
2441
  continue;
2008
2442
  }
@@ -2017,44 +2451,19 @@ var TagIndex = class {
2017
2451
  };
2018
2452
 
2019
2453
  // ../../src/serialization/JsonSerializer.ts
2020
- var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2021
- var MAX_SANITIZE_NODES = 1e4;
2022
2454
  var JsonSerializer = class {
2023
2455
  serialize(value) {
2024
2456
  return JSON.stringify(value);
2025
2457
  }
2026
2458
  deserialize(payload) {
2027
2459
  const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
2028
- return sanitizeJsonValue(JSON.parse(normalized), 0, { count: 0 });
2460
+ return sanitizeStructuredData(JSON.parse(normalized), {
2461
+ label: "JSON payload",
2462
+ maxDepth: 200,
2463
+ maxNodes: 1e4
2464
+ });
2029
2465
  }
2030
2466
  };
2031
- var MAX_SANITIZE_DEPTH = 200;
2032
- function sanitizeJsonValue(value, depth, state) {
2033
- state.count += 1;
2034
- if (state.count > MAX_SANITIZE_NODES) {
2035
- throw new Error(`JSON payload exceeds max node count of ${MAX_SANITIZE_NODES}.`);
2036
- }
2037
- if (depth > MAX_SANITIZE_DEPTH) {
2038
- throw new Error(`JSON payload exceeds max depth of ${MAX_SANITIZE_DEPTH}.`);
2039
- }
2040
- if (Array.isArray(value)) {
2041
- return value.map((entry) => sanitizeJsonValue(entry, depth + 1, state));
2042
- }
2043
- if (!isPlainObject(value)) {
2044
- return value;
2045
- }
2046
- const sanitized = {};
2047
- for (const [key, entry] of Object.entries(value)) {
2048
- if (DANGEROUS_JSON_KEYS.has(key)) {
2049
- continue;
2050
- }
2051
- sanitized[key] = sanitizeJsonValue(entry, depth + 1, state);
2052
- }
2053
- return sanitized;
2054
- }
2055
- function isPlainObject(value) {
2056
- return Object.prototype.toString.call(value) === "[object Object]";
2057
- }
2058
2467
 
2059
2468
  // ../../src/stampede/StampedeGuard.ts
2060
2469
  var StampedeGuard = class {
@@ -2099,7 +2508,6 @@ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
2099
2508
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
2100
2509
  var DEFAULT_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024;
2101
2510
  var DEFAULT_SNAPSHOT_MAX_ENTRIES = 1e4;
2102
- var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
2103
2511
  var DEFAULT_INVALIDATION_MAX_KEYS = 1e4;
2104
2512
  var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
2105
2513
  var DebugLogger = class {
@@ -2156,6 +2564,35 @@ var CacheStack = class extends EventEmitter {
2156
2564
  await this.handleLayerFailure(layer, operation, error);
2157
2565
  }
2158
2566
  });
2567
+ this.invalidation = new CacheStackInvalidationSupport({
2568
+ tagIndex: this.tagIndex,
2569
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2570
+ handleLayerFailure: async (layer, operation, error) => {
2571
+ await this.handleLayerFailure(layer, operation, error);
2572
+ }
2573
+ });
2574
+ this.layerWriter = new CacheStackLayerWriter({
2575
+ layers: this.layers,
2576
+ maintenance: this.maintenance,
2577
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2578
+ shouldWriteBehind: (layer) => this.shouldWriteBehind(layer),
2579
+ handleLayerFailure: async (layer, operation, error) => {
2580
+ await this.handleLayerFailure(layer, operation, error);
2581
+ },
2582
+ enqueueWriteBehind: this.enqueueWriteBehind.bind(this),
2583
+ resolveFreshTtl: this.resolveFreshTtl.bind(this),
2584
+ resolveLayerSeconds: this.resolveLayerSeconds.bind(this),
2585
+ globalStaleWhileRevalidate: this.options.staleWhileRevalidate,
2586
+ globalStaleIfError: this.options.staleIfError,
2587
+ writePolicy: this.options.writePolicy,
2588
+ onWriteFailures: (context, failures) => {
2589
+ this.metricsCollector.increment("writeFailures", failures.length);
2590
+ this.logger.debug?.("write-failure", {
2591
+ ...context,
2592
+ failures: failures.map((failure) => this.formatError(failure))
2593
+ });
2594
+ }
2595
+ });
2159
2596
  if (!options.tagIndex && layers.some((layer) => layer.isLocal === false)) {
2160
2597
  this.logger.warn?.(
2161
2598
  "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."
@@ -2171,6 +2608,18 @@ var CacheStack = class extends EventEmitter {
2171
2608
  "broadcastL1Invalidation defaults to false when an invalidation bus is configured; opt in explicitly if write-triggered L1 invalidation is desired."
2172
2609
  );
2173
2610
  }
2611
+ this.snapshots = new CacheStackSnapshotManager({
2612
+ layers: this.layers,
2613
+ tagIndex: this.tagIndex,
2614
+ snapshotSerializer: this.snapshotSerializer,
2615
+ readLayerEntry: this.readLayerEntry.bind(this),
2616
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2617
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
2618
+ qualifyKey: this.qualifyKey.bind(this),
2619
+ stripQualifiedKey: this.stripQualifiedKey.bind(this),
2620
+ validateCacheKey,
2621
+ formatError: this.formatError.bind(this)
2622
+ });
2174
2623
  this.initializeWriteBehind(options.writeBehind);
2175
2624
  this.startup = this.initialize();
2176
2625
  }
@@ -2186,11 +2635,16 @@ var CacheStack = class extends EventEmitter {
2186
2635
  keyDiscovery;
2187
2636
  fetchRateLimiter = new FetchRateLimiter();
2188
2637
  snapshotSerializer = new JsonSerializer();
2638
+ invalidation;
2639
+ layerWriter;
2640
+ snapshots;
2189
2641
  backgroundRefreshes = /* @__PURE__ */ new Map();
2642
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
2190
2643
  layerDegradedUntil = /* @__PURE__ */ new Map();
2191
2644
  maintenance = new CacheStackMaintenance();
2192
2645
  ttlResolver;
2193
2646
  circuitBreakerManager;
2647
+ nextOperationId = 0;
2194
2648
  currentGeneration;
2195
2649
  isDisconnecting = false;
2196
2650
  disconnectPromise;
@@ -2201,10 +2655,12 @@ var CacheStack = class extends EventEmitter {
2201
2655
  * and no `fetcher` is provided.
2202
2656
  */
2203
2657
  async get(key, fetcher, options) {
2204
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
2205
- this.validateWriteOptions(options);
2206
- await this.awaitStartup("get");
2207
- return this.getPrepared(normalizedKey, fetcher, options);
2658
+ return this.observeOperation("layercache.get", { "layercache.key": String(key ?? "") }, async () => {
2659
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
2660
+ this.validateWriteOptions(options);
2661
+ await this.awaitStartup("get");
2662
+ return this.getPrepared(normalizedKey, fetcher, options);
2663
+ });
2208
2664
  }
2209
2665
  async getPrepared(normalizedKey, fetcher, options) {
2210
2666
  const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
@@ -2326,23 +2782,27 @@ var CacheStack = class extends EventEmitter {
2326
2782
  * Stores a value in all cache layers. Overwrites any existing value.
2327
2783
  */
2328
2784
  async set(key, value, options) {
2329
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
2330
- this.validateWriteOptions(options);
2331
- await this.awaitStartup("set");
2332
- await this.storeEntry(normalizedKey, "value", value, options);
2785
+ await this.observeOperation("layercache.set", { "layercache.key": String(key ?? "") }, async () => {
2786
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
2787
+ this.validateWriteOptions(options);
2788
+ await this.awaitStartup("set");
2789
+ await this.storeEntry(normalizedKey, "value", value, options);
2790
+ });
2333
2791
  }
2334
2792
  /**
2335
2793
  * Deletes the key from all layers and publishes an invalidation message.
2336
2794
  */
2337
2795
  async delete(key) {
2338
- const normalizedKey = this.qualifyKey(validateCacheKey(key));
2339
- await this.awaitStartup("delete");
2340
- await this.deleteKeys([normalizedKey]);
2341
- await this.publishInvalidation({
2342
- scope: "key",
2343
- keys: [normalizedKey],
2344
- sourceId: this.instanceId,
2345
- operation: "delete"
2796
+ await this.observeOperation("layercache.delete", { "layercache.key": String(key ?? "") }, async () => {
2797
+ const normalizedKey = this.qualifyKey(validateCacheKey(key));
2798
+ await this.awaitStartup("delete");
2799
+ await this.deleteKeys([normalizedKey]);
2800
+ await this.publishInvalidation({
2801
+ scope: "key",
2802
+ keys: [normalizedKey],
2803
+ sourceId: this.instanceId,
2804
+ operation: "delete"
2805
+ });
2346
2806
  });
2347
2807
  }
2348
2808
  async clear() {
@@ -2375,95 +2835,102 @@ var CacheStack = class extends EventEmitter {
2375
2835
  });
2376
2836
  }
2377
2837
  async mget(entries) {
2378
- this.assertActive("mget");
2379
- if (entries.length === 0) {
2380
- return [];
2381
- }
2382
- const normalizedEntries = entries.map((entry) => ({
2383
- ...entry,
2384
- key: this.qualifyKey(validateCacheKey(entry.key))
2385
- }));
2386
- normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2387
- const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
2388
- if (!canFastPath) {
2838
+ return this.observeOperation("layercache.mget", void 0, async () => {
2839
+ this.assertActive("mget");
2840
+ if (entries.length === 0) {
2841
+ return [];
2842
+ }
2843
+ const normalizedEntries = entries.map((entry) => ({
2844
+ ...entry,
2845
+ key: this.qualifyKey(validateCacheKey(entry.key))
2846
+ }));
2847
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2848
+ const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
2849
+ if (!canFastPath) {
2850
+ await this.awaitStartup("mget");
2851
+ const pendingReads = /* @__PURE__ */ new Map();
2852
+ return Promise.all(
2853
+ normalizedEntries.map((entry) => {
2854
+ const optionsSignature = serializeOptions(entry.options);
2855
+ const existing = pendingReads.get(entry.key);
2856
+ if (!existing) {
2857
+ const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
2858
+ pendingReads.set(entry.key, {
2859
+ promise,
2860
+ fetch: entry.fetch,
2861
+ optionsSignature
2862
+ });
2863
+ return promise;
2864
+ }
2865
+ if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2866
+ throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2867
+ }
2868
+ return existing.promise;
2869
+ })
2870
+ );
2871
+ }
2389
2872
  await this.awaitStartup("mget");
2390
- const pendingReads = /* @__PURE__ */ new Map();
2391
- return Promise.all(
2392
- normalizedEntries.map((entry) => {
2393
- const optionsSignature = serializeOptions(entry.options);
2394
- const existing = pendingReads.get(entry.key);
2395
- if (!existing) {
2396
- const promise = this.getPrepared(entry.key, entry.fetch, entry.options);
2397
- pendingReads.set(entry.key, {
2398
- promise,
2399
- fetch: entry.fetch,
2400
- optionsSignature
2401
- });
2402
- return promise;
2873
+ const pending = /* @__PURE__ */ new Set();
2874
+ const indexesByKey = /* @__PURE__ */ new Map();
2875
+ const resultsByKey = /* @__PURE__ */ new Map();
2876
+ for (let index = 0; index < normalizedEntries.length; index += 1) {
2877
+ const entry = normalizedEntries[index];
2878
+ if (!entry) continue;
2879
+ const key = entry.key;
2880
+ const indexes = indexesByKey.get(key) ?? [];
2881
+ indexes.push(index);
2882
+ indexesByKey.set(key, indexes);
2883
+ pending.add(key);
2884
+ }
2885
+ for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2886
+ const layer = this.layers[layerIndex];
2887
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2888
+ const keys = [...pending];
2889
+ if (keys.length === 0) {
2890
+ break;
2891
+ }
2892
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
2893
+ for (let offset = 0; offset < values.length; offset += 1) {
2894
+ const key = keys[offset];
2895
+ const stored = values[offset];
2896
+ if (!key || stored === null) {
2897
+ continue;
2403
2898
  }
2404
- if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
2405
- throw new Error(`mget received conflicting entries for key "${entry.key}".`);
2899
+ const resolved = resolveStoredValue(stored);
2900
+ if (resolved.state === "expired") {
2901
+ await layer.delete(key);
2902
+ continue;
2406
2903
  }
2407
- return existing.promise;
2408
- })
2409
- );
2410
- }
2411
- await this.awaitStartup("mget");
2412
- const pending = /* @__PURE__ */ new Set();
2413
- const indexesByKey = /* @__PURE__ */ new Map();
2414
- const resultsByKey = /* @__PURE__ */ new Map();
2415
- for (let index = 0; index < normalizedEntries.length; index += 1) {
2416
- const entry = normalizedEntries[index];
2417
- if (!entry) continue;
2418
- const key = entry.key;
2419
- const indexes = indexesByKey.get(key) ?? [];
2420
- indexes.push(index);
2421
- indexesByKey.set(key, indexes);
2422
- pending.add(key);
2423
- }
2424
- for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2425
- const layer = this.layers[layerIndex];
2426
- if (!layer) continue;
2427
- const keys = [...pending];
2428
- if (keys.length === 0) {
2429
- break;
2430
- }
2431
- const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
2432
- for (let offset = 0; offset < values.length; offset += 1) {
2433
- const key = keys[offset];
2434
- const stored = values[offset];
2435
- if (!key || stored === null) {
2436
- continue;
2437
- }
2438
- const resolved = resolveStoredValue(stored);
2439
- if (resolved.state === "expired") {
2440
- await layer.delete(key);
2441
- continue;
2904
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2905
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2906
+ }
2907
+ await this.tagIndex.touch(key);
2908
+ await this.backfill(key, stored, layerIndex - 1);
2909
+ resultsByKey.set(key, resolved.value);
2910
+ pending.delete(key);
2911
+ this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
2442
2912
  }
2443
- await this.tagIndex.touch(key);
2444
- await this.backfill(key, stored, layerIndex - 1);
2445
- resultsByKey.set(key, resolved.value);
2446
- pending.delete(key);
2447
- this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
2448
2913
  }
2449
- }
2450
- if (pending.size > 0) {
2451
- for (const key of pending) {
2452
- await this.tagIndex.remove(key);
2453
- this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
2914
+ if (pending.size > 0) {
2915
+ for (const key of pending) {
2916
+ await this.tagIndex.remove(key);
2917
+ this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
2918
+ }
2454
2919
  }
2455
- }
2456
- return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
2920
+ return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
2921
+ });
2457
2922
  }
2458
2923
  async mset(entries) {
2459
- this.assertActive("mset");
2460
- const normalizedEntries = entries.map((entry) => ({
2461
- ...entry,
2462
- key: this.qualifyKey(validateCacheKey(entry.key))
2463
- }));
2464
- normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2465
- await this.awaitStartup("mset");
2466
- await this.writeBatch(normalizedEntries);
2924
+ await this.observeOperation("layercache.mset", void 0, async () => {
2925
+ this.assertActive("mset");
2926
+ const normalizedEntries = entries.map((entry) => ({
2927
+ ...entry,
2928
+ key: this.qualifyKey(validateCacheKey(entry.key))
2929
+ }));
2930
+ normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
2931
+ await this.awaitStartup("mset");
2932
+ await this.writeBatch(normalizedEntries);
2933
+ });
2467
2934
  }
2468
2935
  async warm(entries, options = {}) {
2469
2936
  this.assertActive("warm");
@@ -2516,40 +2983,50 @@ var CacheStack = class extends EventEmitter {
2516
2983
  return new CacheNamespace(this, prefix);
2517
2984
  }
2518
2985
  async invalidateByTag(tag) {
2519
- validateTag(tag);
2520
- await this.awaitStartup("invalidateByTag");
2521
- const keys = await this.collectKeysForTag(tag);
2522
- await this.deleteKeys(keys);
2523
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2986
+ await this.observeOperation("layercache.invalidate_by_tag", void 0, async () => {
2987
+ validateTag(tag);
2988
+ await this.awaitStartup("invalidateByTag");
2989
+ const keys = await this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys());
2990
+ await this.deleteKeys(keys);
2991
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2992
+ });
2524
2993
  }
2525
2994
  async invalidateByTags(tags, mode = "any") {
2526
- if (tags.length === 0) {
2527
- return;
2528
- }
2529
- validateTags(tags);
2530
- await this.awaitStartup("invalidateByTags");
2531
- const keysByTag = await Promise.all(tags.map((tag) => this.collectKeysForTag(tag)));
2532
- const keys = mode === "all" ? this.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
2533
- this.assertWithinInvalidationKeyLimit(keys.length);
2534
- await this.deleteKeys(keys);
2535
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
2995
+ await this.observeOperation("layercache.invalidate_by_tags", void 0, async () => {
2996
+ if (tags.length === 0) {
2997
+ return;
2998
+ }
2999
+ validateTags(tags);
3000
+ await this.awaitStartup("invalidateByTags");
3001
+ const keysByTag = await Promise.all(
3002
+ tags.map((tag) => this.invalidation.collectKeysForTag(tag, this.invalidationMaxKeys()))
3003
+ );
3004
+ const keys = mode === "all" ? this.invalidation.intersectKeys(keysByTag) : [...new Set(keysByTag.flat())];
3005
+ this.invalidation.assertWithinInvalidationKeyLimit(keys.length, this.invalidationMaxKeys());
3006
+ await this.deleteKeys(keys);
3007
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
3008
+ });
2536
3009
  }
2537
3010
  async invalidateByPattern(pattern) {
2538
- validatePattern(pattern);
2539
- await this.awaitStartup("invalidateByPattern");
2540
- const keys = await this.keyDiscovery.collectKeysMatchingPattern(
2541
- this.qualifyPattern(pattern),
2542
- this.invalidationMaxKeys()
2543
- );
2544
- await this.deleteKeys(keys);
2545
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
3011
+ await this.observeOperation("layercache.invalidate_by_pattern", void 0, async () => {
3012
+ validatePattern(pattern);
3013
+ await this.awaitStartup("invalidateByPattern");
3014
+ const keys = await this.keyDiscovery.collectKeysMatchingPattern(
3015
+ this.qualifyPattern(pattern),
3016
+ this.invalidationMaxKeys()
3017
+ );
3018
+ await this.deleteKeys(keys);
3019
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
3020
+ });
2546
3021
  }
2547
3022
  async invalidateByPrefix(prefix) {
2548
- await this.awaitStartup("invalidateByPrefix");
2549
- const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
2550
- const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
2551
- await this.deleteKeys(keys);
2552
- await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
3023
+ await this.observeOperation("layercache.invalidate_by_prefix", void 0, async () => {
3024
+ await this.awaitStartup("invalidateByPrefix");
3025
+ const qualifiedPrefix = this.qualifyKey(validateCacheKey(prefix));
3026
+ const keys = await this.keyDiscovery.collectKeysWithPrefix(qualifiedPrefix, this.invalidationMaxKeys());
3027
+ await this.deleteKeys(keys);
3028
+ await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
3029
+ });
2553
3030
  }
2554
3031
  getMetrics() {
2555
3032
  return this.metricsCollector.snapshot;
@@ -2660,95 +3137,19 @@ var CacheStack = class extends EventEmitter {
2660
3137
  }
2661
3138
  async exportState() {
2662
3139
  await this.awaitStartup("exportState");
2663
- const entries = [];
2664
- await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2665
- entries.push(entry);
2666
- });
2667
- return entries;
3140
+ return this.snapshots.exportState(this.snapshotMaxEntries());
2668
3141
  }
2669
3142
  async importState(entries) {
2670
3143
  await this.awaitStartup("importState");
2671
- const normalizedEntries = entries.map((entry) => ({
2672
- key: this.qualifyKey(validateCacheKey(entry.key)),
2673
- value: entry.value,
2674
- ttl: entry.ttl
2675
- }));
2676
- for (let index = 0; index < normalizedEntries.length; index += DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE) {
2677
- const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
2678
- await Promise.all(
2679
- batch.map(async (entry) => {
2680
- await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
2681
- await this.tagIndex.touch(entry.key);
2682
- })
2683
- );
2684
- }
3144
+ await this.snapshots.importState(entries);
2685
3145
  }
2686
3146
  async persistToFile(filePath) {
2687
3147
  this.assertActive("persistToFile");
2688
- const { promises: fs } = await import("fs");
2689
- const path = await import("path");
2690
- const targetPath = await validateSnapshotFilePath(filePath, "write", this.options.snapshotBaseDir);
2691
- const tempPath = path.join(
2692
- path.dirname(targetPath),
2693
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
2694
- );
2695
- let handle;
2696
- try {
2697
- handle = await fs.open(tempPath, "wx");
2698
- const openedHandle = handle;
2699
- await openedHandle.writeFile("[", "utf8");
2700
- let wroteAny = false;
2701
- await this.visitExportEntries(this.snapshotMaxEntries(), async (entry) => {
2702
- await openedHandle.writeFile(wroteAny ? ",\n" : "\n", "utf8");
2703
- await openedHandle.writeFile(JSON.stringify(entry, null, 2), "utf8");
2704
- wroteAny = true;
2705
- });
2706
- await openedHandle.writeFile(wroteAny ? "\n]" : "]", "utf8");
2707
- await openedHandle.close();
2708
- handle = void 0;
2709
- await fs.rename(tempPath, targetPath);
2710
- } catch (error) {
2711
- await handle?.close().catch(() => void 0);
2712
- await fs.unlink(tempPath).catch(() => void 0);
2713
- throw error;
2714
- }
3148
+ await this.snapshots.persistToFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxEntries());
2715
3149
  }
2716
3150
  async restoreFromFile(filePath) {
2717
3151
  this.assertActive("restoreFromFile");
2718
- const { promises: fs, constants } = await import("fs");
2719
- const validatedPath = await validateSnapshotFilePath(filePath, "read", this.options.snapshotBaseDir);
2720
- const handle = await fs.open(validatedPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
2721
- const snapshotMaxBytes = this.snapshotMaxBytes();
2722
- let raw;
2723
- try {
2724
- if (snapshotMaxBytes !== false) {
2725
- const stat = await handle.stat();
2726
- if (stat.size > snapshotMaxBytes) {
2727
- throw new Error(
2728
- `Snapshot file exceeds snapshotMaxBytes limit (${stat.size} bytes > ${snapshotMaxBytes} bytes).`
2729
- );
2730
- }
2731
- }
2732
- raw = await readUtf8HandleWithLimit(handle, snapshotMaxBytes);
2733
- } finally {
2734
- await handle.close();
2735
- }
2736
- let parsed;
2737
- try {
2738
- parsed = JSON.parse(raw);
2739
- } catch (cause) {
2740
- throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
2741
- }
2742
- if (!this.isCacheSnapshotEntries(parsed)) {
2743
- throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
2744
- }
2745
- await this.importState(
2746
- parsed.map((entry) => ({
2747
- key: entry.key,
2748
- value: this.sanitizeSnapshotValue(entry.value),
2749
- ttl: entry.ttl
2750
- }))
2751
- );
3152
+ await this.snapshots.restoreFromFile(filePath, this.options.snapshotBaseDir, this.snapshotMaxBytes());
2752
3153
  }
2753
3154
  async disconnect() {
2754
3155
  if (!this.disconnectPromise) {
@@ -2758,8 +3159,27 @@ var CacheStack = class extends EventEmitter {
2758
3159
  await this.unsubscribeInvalidation?.();
2759
3160
  await this.flushWriteBehindQueue();
2760
3161
  await this.maintenance.waitForGenerationCleanup();
2761
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
3162
+ for (const key of this.backgroundRefreshAbort.keys()) {
3163
+ this.backgroundRefreshAbort.set(key, true);
3164
+ }
3165
+ await Promise.allSettled(
3166
+ [...this.backgroundRefreshes.values()].map((promise) => {
3167
+ let timer;
3168
+ return Promise.race([
3169
+ promise,
3170
+ new Promise((resolve) => {
3171
+ timer = setTimeout(resolve, 5e3);
3172
+ timer.unref?.();
3173
+ })
3174
+ ]).finally(() => {
3175
+ if (timer) clearTimeout(timer);
3176
+ });
3177
+ })
3178
+ );
3179
+ this.backgroundRefreshes.clear();
3180
+ this.backgroundRefreshAbort.clear();
2762
3181
  this.maintenance.disposeWriteBehindTimer();
3182
+ this.fetchRateLimiter.dispose();
2763
3183
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
2764
3184
  })();
2765
3185
  }
@@ -2873,7 +3293,7 @@ var CacheStack = class extends EventEmitter {
2873
3293
  async storeEntry(key, kind, value, options) {
2874
3294
  const clearEpoch = this.maintenance.currentClearEpoch();
2875
3295
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
2876
- await this.writeAcrossLayers(key, kind, value, options);
3296
+ await this.layerWriter.writeAcrossLayers(key, kind, value, options);
2877
3297
  if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
2878
3298
  return;
2879
3299
  }
@@ -2890,52 +3310,7 @@ var CacheStack = class extends EventEmitter {
2890
3310
  }
2891
3311
  }
2892
3312
  async writeBatch(entries) {
2893
- const now = Date.now();
2894
- const clearEpoch = this.maintenance.currentClearEpoch();
2895
- const entryEpochs = new Map(entries.map((entry) => [entry.key, this.maintenance.currentKeyEpoch(entry.key)]));
2896
- const entriesByLayer = /* @__PURE__ */ new Map();
2897
- const immediateOperations = [];
2898
- const deferredOperations = [];
2899
- for (const entry of entries) {
2900
- for (const layer of this.layers) {
2901
- if (this.shouldSkipLayer(layer)) {
2902
- continue;
2903
- }
2904
- const layerEntry = this.buildLayerSetEntry(layer, entry.key, "value", entry.value, entry.options, now);
2905
- const bucket = entriesByLayer.get(layer) ?? [];
2906
- bucket.push(layerEntry);
2907
- entriesByLayer.set(layer, bucket);
2908
- }
2909
- }
2910
- for (const [layer, layerEntries] of entriesByLayer.entries()) {
2911
- const operation = async () => {
2912
- if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2913
- return;
2914
- }
2915
- const activeEntries = layerEntries.filter(
2916
- (entry) => (entryEpochs.get(entry.key) ?? 0) === this.maintenance.currentKeyEpoch(entry.key)
2917
- );
2918
- if (activeEntries.length === 0) {
2919
- return;
2920
- }
2921
- try {
2922
- if (layer.setMany) {
2923
- await layer.setMany(activeEntries);
2924
- return;
2925
- }
2926
- await Promise.all(activeEntries.map((entry) => layer.set(entry.key, entry.value, entry.ttl)));
2927
- } catch (error) {
2928
- await this.handleLayerFailure(layer, "write", error);
2929
- }
2930
- };
2931
- if (this.shouldWriteBehind(layer)) {
2932
- deferredOperations.push(operation);
2933
- } else {
2934
- immediateOperations.push(operation);
2935
- }
2936
- }
2937
- await this.executeLayerOperations(immediateOperations, { key: "batch", action: "mset" });
2938
- await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
3313
+ const { clearEpoch, entryEpochs } = await this.layerWriter.writeBatch(entries);
2939
3314
  if (clearEpoch !== this.maintenance.currentClearEpoch()) {
2940
3315
  return;
2941
3316
  }
@@ -3042,58 +3417,6 @@ var CacheStack = class extends EventEmitter {
3042
3417
  this.emit("backfill", { key, layer: layer.name });
3043
3418
  }
3044
3419
  }
3045
- async writeAcrossLayers(key, kind, value, options) {
3046
- const now = Date.now();
3047
- const clearEpoch = this.maintenance.currentClearEpoch();
3048
- const keyEpoch = this.maintenance.currentKeyEpoch(key);
3049
- const immediateOperations = [];
3050
- const deferredOperations = [];
3051
- for (const layer of this.layers) {
3052
- const operation = async () => {
3053
- if (this.maintenance.isWriteOutdated(key, clearEpoch, keyEpoch)) {
3054
- return;
3055
- }
3056
- if (this.shouldSkipLayer(layer)) {
3057
- return;
3058
- }
3059
- const entry = this.buildLayerSetEntry(layer, key, kind, value, options, now);
3060
- try {
3061
- await layer.set(entry.key, entry.value, entry.ttl);
3062
- } catch (error) {
3063
- await this.handleLayerFailure(layer, "write", error);
3064
- }
3065
- };
3066
- if (this.shouldWriteBehind(layer)) {
3067
- deferredOperations.push(operation);
3068
- } else {
3069
- immediateOperations.push(operation);
3070
- }
3071
- }
3072
- await this.executeLayerOperations(immediateOperations, { key, action: kind === "empty" ? "negative-set" : "set" });
3073
- await Promise.all(deferredOperations.map((operation) => this.enqueueWriteBehind(operation)));
3074
- }
3075
- async executeLayerOperations(operations, context) {
3076
- if (this.options.writePolicy !== "best-effort") {
3077
- await Promise.all(operations.map((operation) => operation()));
3078
- return;
3079
- }
3080
- const results = await Promise.allSettled(operations.map((operation) => operation()));
3081
- const failures = results.filter((result) => result.status === "rejected");
3082
- if (failures.length === 0) {
3083
- return;
3084
- }
3085
- this.metricsCollector.increment("writeFailures", failures.length);
3086
- this.logger.debug?.("write-failure", {
3087
- ...context,
3088
- failures: failures.map((failure) => this.formatError(failure.reason))
3089
- });
3090
- if (failures.length === operations.length) {
3091
- throw new AggregateError(
3092
- failures.map((failure) => failure.reason),
3093
- `${context.action} failed for every cache layer`
3094
- );
3095
- }
3096
- }
3097
3420
  resolveFreshTtl(key, layerName, kind, options, fallbackTtl, value) {
3098
3421
  return this.ttlResolver.resolveFreshTtl(
3099
3422
  key,
@@ -3121,15 +3444,19 @@ var CacheStack = class extends EventEmitter {
3121
3444
  }
3122
3445
  const clearEpoch = this.maintenance.currentClearEpoch();
3123
3446
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
3447
+ this.backgroundRefreshAbort.set(key, false);
3124
3448
  const refresh = (async () => {
3125
3449
  this.metricsCollector.increment("refreshes");
3126
3450
  try {
3451
+ if (this.backgroundRefreshAbort.get(key)) return;
3127
3452
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
3128
3453
  } catch (error) {
3454
+ if (this.backgroundRefreshAbort.get(key)) return;
3129
3455
  this.metricsCollector.increment("refreshErrors");
3130
3456
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
3131
3457
  } finally {
3132
3458
  this.backgroundRefreshes.delete(key);
3459
+ this.backgroundRefreshAbort.delete(key);
3133
3460
  }
3134
3461
  })();
3135
3462
  this.backgroundRefreshes.set(key, refresh);
@@ -3159,7 +3486,7 @@ var CacheStack = class extends EventEmitter {
3159
3486
  return;
3160
3487
  }
3161
3488
  this.maintenance.bumpKeyEpochs(keys);
3162
- await this.deleteKeysFromLayers(this.layers, keys);
3489
+ await this.invalidation.deleteKeysFromLayers(this.layers, keys);
3163
3490
  for (const key of keys) {
3164
3491
  await this.tagIndex.remove(key);
3165
3492
  this.ttlResolver.deleteProfile(key);
@@ -3191,7 +3518,7 @@ var CacheStack = class extends EventEmitter {
3191
3518
  }
3192
3519
  const keys = message.keys ?? [];
3193
3520
  this.maintenance.bumpKeyEpochs(keys);
3194
- await this.deleteKeysFromLayers(localLayers, keys);
3521
+ await this.invalidation.deleteKeysFromLayers(localLayers, keys);
3195
3522
  if (message.operation !== "write") {
3196
3523
  for (const key of keys) {
3197
3524
  await this.tagIndex.remove(key);
@@ -3232,7 +3559,7 @@ var CacheStack = class extends EventEmitter {
3232
3559
  timer.unref?.();
3233
3560
  })
3234
3561
  ]);
3235
- if (result && typeof result === "object" && "kind" in result) {
3562
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
3236
3563
  if (result.kind === "error") {
3237
3564
  throw result.error;
3238
3565
  }
@@ -3248,6 +3575,31 @@ var CacheStack = class extends EventEmitter {
3248
3575
  shouldBroadcastL1Invalidation() {
3249
3576
  return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? false;
3250
3577
  }
3578
+ async observeOperation(name, attributes, execute) {
3579
+ const id = this.nextOperationId;
3580
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
3581
+ this.emit("operation-start", { id, name, attributes });
3582
+ try {
3583
+ const result = await execute();
3584
+ this.emit("operation-end", {
3585
+ id,
3586
+ name,
3587
+ attributes,
3588
+ success: true,
3589
+ result: result === null ? "null" : void 0
3590
+ });
3591
+ return result;
3592
+ } catch (error) {
3593
+ this.emit("operation-end", {
3594
+ id,
3595
+ name,
3596
+ attributes,
3597
+ success: false,
3598
+ error
3599
+ });
3600
+ throw error;
3601
+ }
3602
+ }
3251
3603
  scheduleGenerationCleanup(generation) {
3252
3604
  this.maintenance.scheduleGenerationCleanup(
3253
3605
  generation,
@@ -3303,37 +3655,6 @@ var CacheStack = class extends EventEmitter {
3303
3655
  });
3304
3656
  this.emitError("write-behind", { failed: failures.length, total: batch.length });
3305
3657
  }
3306
- buildLayerSetEntry(layer, key, kind, value, options, now) {
3307
- const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl, value);
3308
- const staleWhileRevalidate = this.resolveLayerSeconds(
3309
- layer.name,
3310
- options?.staleWhileRevalidate,
3311
- this.options.staleWhileRevalidate
3312
- );
3313
- const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
3314
- const payload = createStoredValueEnvelope({
3315
- kind,
3316
- value,
3317
- freshTtlSeconds: freshTtl,
3318
- staleWhileRevalidateSeconds: staleWhileRevalidate,
3319
- staleIfErrorSeconds: staleIfError,
3320
- now
3321
- });
3322
- const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
3323
- return {
3324
- key,
3325
- value: payload,
3326
- ttl
3327
- };
3328
- }
3329
- intersectKeys(groups) {
3330
- if (groups.length === 0) {
3331
- return [];
3332
- }
3333
- const [firstGroup, ...rest] = groups;
3334
- const restSets = rest.map((group) => new Set(group));
3335
- return [...new Set(firstGroup)].filter((key) => restSets.every((group) => group.has(key)));
3336
- }
3337
3658
  qualifyKey(key) {
3338
3659
  return qualifyGenerationKey(key, this.currentGeneration);
3339
3660
  }
@@ -3343,32 +3664,6 @@ var CacheStack = class extends EventEmitter {
3343
3664
  stripQualifiedKey(key) {
3344
3665
  return stripGenerationPrefix(key, this.currentGeneration);
3345
3666
  }
3346
- async deleteKeysFromLayers(layers, keys) {
3347
- await Promise.all(
3348
- layers.map(async (layer) => {
3349
- if (this.shouldSkipLayer(layer)) {
3350
- return;
3351
- }
3352
- if (layer.deleteMany) {
3353
- try {
3354
- await layer.deleteMany(keys);
3355
- } catch (error) {
3356
- await this.handleLayerFailure(layer, "delete", error);
3357
- }
3358
- return;
3359
- }
3360
- await Promise.all(
3361
- keys.map(async (key) => {
3362
- try {
3363
- await layer.delete(key);
3364
- } catch (error) {
3365
- await this.handleLayerFailure(layer, "delete", error);
3366
- }
3367
- })
3368
- );
3369
- })
3370
- );
3371
- }
3372
3667
  validateConfiguration() {
3373
3668
  if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
3374
3669
  throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
@@ -3499,18 +3794,6 @@ var CacheStack = class extends EventEmitter {
3499
3794
  this.emit("error", { operation, ...context });
3500
3795
  }
3501
3796
  }
3502
- isCacheSnapshotEntries(value) {
3503
- return Array.isArray(value) && value.every((entry) => {
3504
- if (!entry || typeof entry !== "object") {
3505
- return false;
3506
- }
3507
- const candidate = entry;
3508
- return typeof candidate.key === "string" && (candidate.ttl === void 0 || typeof candidate.ttl === "number" && Number.isFinite(candidate.ttl) && candidate.ttl >= 0);
3509
- });
3510
- }
3511
- sanitizeSnapshotValue(value) {
3512
- return this.snapshotSerializer.deserialize(this.snapshotSerializer.serialize(value));
3513
- }
3514
3797
  snapshotMaxBytes() {
3515
3798
  return this.options.snapshotMaxBytes === false ? false : this.options.snapshotMaxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES;
3516
3799
  }
@@ -3520,62 +3803,6 @@ var CacheStack = class extends EventEmitter {
3520
3803
  invalidationMaxKeys() {
3521
3804
  return this.options.invalidationMaxKeys === false ? false : this.options.invalidationMaxKeys ?? DEFAULT_INVALIDATION_MAX_KEYS;
3522
3805
  }
3523
- async collectKeysForTag(tag) {
3524
- const keys = /* @__PURE__ */ new Set();
3525
- if (this.tagIndex.forEachKeyForTag) {
3526
- await this.tagIndex.forEachKeyForTag(tag, async (key) => {
3527
- keys.add(key);
3528
- this.assertWithinInvalidationKeyLimit(keys.size);
3529
- });
3530
- return [...keys];
3531
- }
3532
- for (const key of await this.tagIndex.keysForTag(tag)) {
3533
- keys.add(key);
3534
- this.assertWithinInvalidationKeyLimit(keys.size);
3535
- }
3536
- return [...keys];
3537
- }
3538
- assertWithinInvalidationKeyLimit(size) {
3539
- const maxKeys = this.invalidationMaxKeys();
3540
- if (maxKeys !== false && size > maxKeys) {
3541
- throw new Error(`Invalidation matched too many keys (${size} > ${maxKeys}).`);
3542
- }
3543
- }
3544
- async visitExportEntries(maxEntries, visitor) {
3545
- const exported = /* @__PURE__ */ new Set();
3546
- for (const layer of this.layers) {
3547
- if (!layer.keys && !layer.forEachKey) {
3548
- continue;
3549
- }
3550
- const visitKey = async (key) => {
3551
- const exportedKey = this.stripQualifiedKey(key);
3552
- if (exported.has(exportedKey)) {
3553
- return;
3554
- }
3555
- const stored = await this.readLayerEntry(layer, key);
3556
- if (stored === null) {
3557
- return;
3558
- }
3559
- exported.add(exportedKey);
3560
- if (maxEntries !== false && exported.size > maxEntries) {
3561
- throw new Error(`Snapshot export exceeds snapshotMaxEntries limit (${exported.size} > ${maxEntries}).`);
3562
- }
3563
- await visitor({
3564
- key: exportedKey,
3565
- value: stored,
3566
- ttl: remainingStoredTtlSeconds(stored)
3567
- });
3568
- };
3569
- if (layer.forEachKey) {
3570
- await layer.forEachKey(visitKey);
3571
- continue;
3572
- }
3573
- const keys = await layer.keys?.();
3574
- for (const key of keys ?? []) {
3575
- await visitKey(key);
3576
- }
3577
- }
3578
- }
3579
3806
  };
3580
3807
 
3581
3808
  // src/module.ts