sliftutils 1.8.4 → 1.8.5
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/package.json
CHANGED
package/storage/backblaze.ts
CHANGED
|
@@ -31,14 +31,22 @@ const MIN_BUCKET_CACHE_TIME = 60 * 1000;
|
|
|
31
31
|
const LARGE_FILE_MIN_CHUNK_SIZE = 32 * 1024 * 1024;
|
|
32
32
|
|
|
33
33
|
// A B2 download/HEAD response's headers carry the file's metadata. content-range's total wins over content-length for ranged responses (which only report the slice's length).
|
|
34
|
-
function parseFileMetadataHeaders(response: HttpsResponseInfo): { size: number;
|
|
34
|
+
function parseFileMetadataHeaders(response: HttpsResponseInfo): { size: number; writeTime: number } {
|
|
35
35
|
let size = Number(response.headers["content-length"] || 0);
|
|
36
36
|
let contentRange = response.headers["content-range"];
|
|
37
37
|
let total = contentRange && Number(contentRange.split("/")[1]);
|
|
38
38
|
if (total && Number.isFinite(total)) {
|
|
39
39
|
size = total;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
let uploadTimestamp = Number(response.headers["x-bz-upload-timestamp"] || 0);
|
|
42
|
+
return { size, writeTime: preservedWriteTime(response.headers["x-bz-info-src_last_modified_millis"], uploadTimestamp) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Files written before src_last_modified_millis was sent (and copies, which deliberately omit it) fall back to b2's own uploadTimestamp - the exact value every reader used before, so old files keep behaving identically.
|
|
46
|
+
function preservedWriteTime(srcLastModifiedMillis: string | number | undefined, uploadTimestamp: number): number {
|
|
47
|
+
let value = Number(srcLastModifiedMillis);
|
|
48
|
+
if (value > 0) return value;
|
|
49
|
+
return uploadTimestamp;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
const getAPI = lazy(async () => {
|
|
@@ -185,7 +193,7 @@ const getAPI = lazy(async () => {
|
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
/** A file's metadata by name, without the body: HEAD on the download URL, which is a class B (download-priced) transaction - b2_list_file_names is class C at 10x the price, and b2_get_file_info needs a fileId we don't have. Returns undefined for missing (or hidden) files. */
|
|
188
|
-
async function headFileByName(config: { bucketName: string; fileName: string }): Promise<{ size: number;
|
|
196
|
+
async function headFileByName(config: { bucketName: string; fileName: string }): Promise<{ size: number; writeTime: number } | undefined> {
|
|
189
197
|
let fileName = encodePath(config.fileName);
|
|
190
198
|
let response: HttpsResponseInfo = { headers: {} };
|
|
191
199
|
try {
|
|
@@ -224,17 +232,19 @@ const getAPI = lazy(async () => {
|
|
|
224
232
|
bucketId: string;
|
|
225
233
|
fileName: string;
|
|
226
234
|
data: Buffer;
|
|
235
|
+
lastModified?: number;
|
|
227
236
|
}) {
|
|
228
237
|
let getUploadUrl = await getUploadURL(config.bucketId);
|
|
229
238
|
|
|
230
239
|
await httpsRequest(getUploadUrl.uploadUrl, config.data, "POST", undefined, {
|
|
231
|
-
headers: {
|
|
240
|
+
headers: Object.fromEntries(Object.entries({
|
|
232
241
|
Authorization: getUploadUrl.authorizationToken,
|
|
233
242
|
"X-Bz-File-Name": encodePath(config.fileName),
|
|
234
243
|
"Content-Type": "b2/x-auto",
|
|
235
244
|
"X-Bz-Content-Sha1": "do_not_verify",
|
|
236
245
|
"Content-Length": config.data.length + "",
|
|
237
|
-
|
|
246
|
+
"X-Bz-Info-src_last_modified_millis": config.lastModified && String(config.lastModified) || undefined,
|
|
247
|
+
}).filter(x => x[1] !== undefined)) as { [key: string]: string },
|
|
238
248
|
});
|
|
239
249
|
}
|
|
240
250
|
|
|
@@ -255,7 +265,7 @@ const getAPI = lazy(async () => {
|
|
|
255
265
|
contentSha1: string;
|
|
256
266
|
contentType: string;
|
|
257
267
|
fileInfo: {
|
|
258
|
-
src_last_modified_millis
|
|
268
|
+
src_last_modified_millis?: string | number;
|
|
259
269
|
};
|
|
260
270
|
action: string;
|
|
261
271
|
uploadTimestamp: number;
|
|
@@ -277,7 +287,7 @@ const getAPI = lazy(async () => {
|
|
|
277
287
|
contentSha1: string;
|
|
278
288
|
contentType: string;
|
|
279
289
|
fileInfo: {
|
|
280
|
-
src_last_modified_millis
|
|
290
|
+
src_last_modified_millis?: string | number;
|
|
281
291
|
};
|
|
282
292
|
action: string;
|
|
283
293
|
uploadTimestamp: number;
|
|
@@ -289,6 +299,9 @@ const getAPI = lazy(async () => {
|
|
|
289
299
|
sourceFileId: string;
|
|
290
300
|
fileName: string;
|
|
291
301
|
destinationBucketId: string;
|
|
302
|
+
metadataDirective?: "COPY" | "REPLACE";
|
|
303
|
+
contentType?: string;
|
|
304
|
+
fileInfo?: { [key: string]: string };
|
|
292
305
|
}, {}>("b2_copy_file", "POST", "noAccountId");
|
|
293
306
|
|
|
294
307
|
const startLargeFile = createB2Function<{
|
|
@@ -695,7 +708,7 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
695
708
|
return undefined;
|
|
696
709
|
}
|
|
697
710
|
let meta = parseFileMetadataHeaders(response);
|
|
698
|
-
return { data, writeTime: meta.
|
|
711
|
+
return { data, writeTime: meta.writeTime, size: data.length };
|
|
699
712
|
});
|
|
700
713
|
if (!result) return undefined;
|
|
701
714
|
let timeStr = formatTime(Date.now() - time);
|
|
@@ -740,7 +753,7 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
740
753
|
// This comparison deliberately IGNORES noChecks: backblaze has no server of ours to enforce only-take-the-latest (unlike hosted targets, where the receiving store re-checks), so this comparison IS the ordering guard. Skipping it lets a stale push land over a newer value or tombstone - and because b2 stamps its own upload times, the stale data then becomes the newest-timestamped copy in the whole system, resurrecting globally through everyone's scans. includeTombstones: a deletion on b2 is a real size-0 file and must win this comparison too.
|
|
741
754
|
let existing = await this.getInfo(fileName, { includeTombstones: true });
|
|
742
755
|
if (!existing) return false;
|
|
743
|
-
// An older write never overwrites a newer one (see IArchives.set).
|
|
756
|
+
// An older write never overwrites a newer one (see IArchives.set). lastModified is preserved on the stored file as src_last_modified_millis, so this compares real write times.
|
|
744
757
|
if (config.lastModified < existing.writeTime) return true;
|
|
745
758
|
// Immutability wins: a synchronization push never overwrites an existing path on an immutable bucket (see SetConfig.forceSetImmutable)
|
|
746
759
|
if (config.forceSetImmutable && this.config.immutable) return true;
|
|
@@ -763,7 +776,7 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
763
776
|
this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
|
|
764
777
|
let f = fileName;
|
|
765
778
|
await this.apiRetryLogic(`uploadFile ${fileName}`, async (api) => {
|
|
766
|
-
await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
|
|
779
|
+
await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, lastModified: config?.lastModified });
|
|
767
780
|
});
|
|
768
781
|
if (!config?.noChecks) {
|
|
769
782
|
let existsChecks = 30;
|
|
@@ -783,13 +796,13 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
783
796
|
public async del(fileName: string, config?: DelConfig): Promise<void> {
|
|
784
797
|
validateFileName(fileName, "del");
|
|
785
798
|
if (config?.lastModified) {
|
|
786
|
-
// A synchronized deletion: b2's hide removes the file from listings entirely, so peers scanning the bucket could never learn of it. Instead the tombstone is stored as a REAL empty file (an empty file IS a missing file), which listings show and scans ingest as a deletion
|
|
799
|
+
// A synchronized deletion: b2's hide removes the file from listings entirely, so peers scanning the bucket could never learn of it. Instead the tombstone is stored as a REAL empty file (an empty file IS a missing file), which listings show and scans ingest as a deletion, carrying the original deletion time as src_last_modified_millis so ordering survives.
|
|
787
800
|
// The comparison ignores noChecks for the same reason as in set: on b2 it IS the ordering guard
|
|
788
801
|
let existing = await this.getInfo(fileName, { includeTombstones: true });
|
|
789
802
|
if (existing && config.lastModified < existing.writeTime) return;
|
|
790
803
|
this.log(`backblaze tombstone upload ${fileName}`);
|
|
791
804
|
await this.apiRetryLogic(`del ${fileName}`, async (api) => {
|
|
792
|
-
await api.uploadFile({ bucketId: this.bucketId, fileName, data: Buffer.alloc(0) });
|
|
805
|
+
await api.uploadFile({ bucketId: this.bucketId, fileName, data: Buffer.alloc(0), lastModified: config.lastModified });
|
|
793
806
|
});
|
|
794
807
|
return;
|
|
795
808
|
}
|
|
@@ -805,7 +818,7 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
805
818
|
// NOTE: Deletion SEEMS to work. This DOES break if we delete a file which keeps being recreated, ex, the heartbeat. let existsChecks = 10; while (existsChecks > 0) { let exists = await this.getInfo(fileName); if (!exists) break; await delay(1000); existsChecks--; } if (existsChecks === 0) { let exists = await this.getInfo(fileName); devDebugbreak(); console.warn(`File ${fileName} was deleted, but was still found afterwards`); exists = await this.getInfo(fileName); }
|
|
806
819
|
}
|
|
807
820
|
|
|
808
|
-
// lastModified is
|
|
821
|
+
// lastModified is preserved as src_last_modified_millis (b2 still stamps its own uploadTimestamp, which readers only fall back to when the info field is absent). fallbacks means nothing here: a single bucket has nowhere to fall back to.
|
|
809
822
|
public async setLargeFile(config: SetLargeFileConfig): Promise<void> {
|
|
810
823
|
validateFileName(config.path, "setLargeFile");
|
|
811
824
|
// Checked before a single byte moves: an upload that is already superseded must not be started at all (a cancelled large upload still costs the transfer)
|
|
@@ -857,11 +870,15 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
857
870
|
|
|
858
871
|
|
|
859
872
|
let uploadInfo = await this.apiRetryLogic(`startLargeFile ${fileName}`, async (api) => {
|
|
873
|
+
let fileInfo: { [key: string]: string } = {};
|
|
874
|
+
if (config.lastModified) {
|
|
875
|
+
fileInfo.src_last_modified_millis = String(config.lastModified);
|
|
876
|
+
}
|
|
860
877
|
return await api.startLargeFile({
|
|
861
878
|
bucketId: this.bucketId,
|
|
862
879
|
fileName: fileName,
|
|
863
880
|
contentType: "b2/x-auto",
|
|
864
|
-
fileInfo
|
|
881
|
+
fileInfo,
|
|
865
882
|
});
|
|
866
883
|
});
|
|
867
884
|
onError.push(async () => {
|
|
@@ -952,7 +969,7 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
952
969
|
}
|
|
953
970
|
this.log(`Backblaze file exists ${fileName}`);
|
|
954
971
|
return {
|
|
955
|
-
writeTime: file.
|
|
972
|
+
writeTime: file.writeTime,
|
|
956
973
|
size: file.size,
|
|
957
974
|
};
|
|
958
975
|
} catch (e: any) {
|
|
@@ -998,14 +1015,15 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
998
1015
|
delimiter: config?.shallow ? "/" : undefined,
|
|
999
1016
|
});
|
|
1000
1017
|
for (let file of result.files) {
|
|
1018
|
+
let createTime = preservedWriteTime(file.fileInfo?.src_last_modified_millis, file.uploadTimestamp);
|
|
1001
1019
|
if (file.action === "upload" && config?.type !== "folders") {
|
|
1002
|
-
files.set(file.fileName, { path: file.fileName, createTime
|
|
1020
|
+
files.set(file.fileName, { path: file.fileName, createTime, size: file.contentLength });
|
|
1003
1021
|
} else if (file.action === "folder" && config?.type === "folders") {
|
|
1004
1022
|
let folder = file.fileName;
|
|
1005
1023
|
if (folder.endsWith("/")) {
|
|
1006
1024
|
folder = folder.slice(0, -1);
|
|
1007
1025
|
}
|
|
1008
|
-
files.set(folder, { path: folder, createTime
|
|
1026
|
+
files.set(folder, { path: folder, createTime, size: file.contentLength });
|
|
1009
1027
|
}
|
|
1010
1028
|
|
|
1011
1029
|
}
|
|
@@ -1050,10 +1068,14 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
1050
1068
|
let info = await api.listFileNames({ bucketId: this.bucketId, prefix: path, maxFileCount: 10 });
|
|
1051
1069
|
let file = info.files.find(x => x.fileName === path);
|
|
1052
1070
|
if (!file) throw new Error(`File not found to copy: ${path}`);
|
|
1071
|
+
// REPLACE drops the source's src_last_modified_millis, so readers fall back to the copy's uploadTimestamp - the fresh write time move requires (a COPY directive would carry the old time forward now that we honor it)
|
|
1053
1072
|
await api.copyFile({
|
|
1054
1073
|
sourceFileId: file.fileId,
|
|
1055
1074
|
fileName: targetPath,
|
|
1056
1075
|
destinationBucketId: targetBucketId,
|
|
1076
|
+
metadataDirective: "REPLACE",
|
|
1077
|
+
contentType: "b2/x-auto",
|
|
1078
|
+
fileInfo: {},
|
|
1057
1079
|
});
|
|
1058
1080
|
});
|
|
1059
1081
|
return;
|