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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.8.4",
3
+ "version": "1.8.5",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -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; uploadTimestamp: 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
- return { size, uploadTimestamp: Number(response.headers["x-bz-upload-timestamp"] || 0) };
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; uploadTimestamp: number } | undefined> {
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: number;
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: number;
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.uploadTimestamp, size: data.length };
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). B2 stamps its own upload time, so the exact lastModified is not preserved on the stored file.
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. (b2 stamps its own upload time, so the exact deletion time is not preserved here - same as every b2 write.)
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 accepted but cannot be honored - b2 stamps its own uploadTimestamp, which is what our getInfo/findInfo report as the write time. fallbacks means nothing here: a single bucket has nowhere to fall back to.
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.uploadTimestamp,
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: file.uploadTimestamp, size: file.contentLength });
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: file.uploadTimestamp, size: file.contentLength });
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;