sliftutils 1.7.48 → 1.7.50

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.
Files changed (35) hide show
  1. package/index.d.ts +99 -38
  2. package/package.json +1 -1
  3. package/storage/ArchivesDisk.d.ts +5 -5
  4. package/storage/ArchivesDisk.ts +19 -5
  5. package/storage/IArchives.d.ts +36 -7
  6. package/storage/IArchives.ts +76 -8
  7. package/storage/backblaze.d.ts +4 -4
  8. package/storage/backblaze.ts +89 -36
  9. package/storage/dist/ArchivesDisk.ts.cache +8 -2
  10. package/storage/dist/IArchives.ts.cache +2 -2
  11. package/storage/dist/backblaze.ts.cache +86 -36
  12. package/storage/remoteStorage/ArchivesRemote.d.ts +4 -5
  13. package/storage/remoteStorage/ArchivesRemote.ts +7 -7
  14. package/storage/remoteStorage/ArchivesUrl.d.ts +2 -1
  15. package/storage/remoteStorage/ArchivesUrl.ts +13 -3
  16. package/storage/remoteStorage/blobStore.d.ts +15 -6
  17. package/storage/remoteStorage/blobStore.ts +198 -115
  18. package/storage/remoteStorage/createArchives.d.ts +4 -5
  19. package/storage/remoteStorage/createArchives.ts +6 -11
  20. package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
  21. package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +15 -4
  22. package/storage/remoteStorage/dist/blobStore.ts.cache +194 -108
  23. package/storage/remoteStorage/dist/createArchives.ts.cache +4 -9
  24. package/storage/remoteStorage/dist/remoteConfig.ts.cache +15 -3
  25. package/storage/remoteStorage/dist/sourcesList.ts.cache +79 -0
  26. package/storage/remoteStorage/dist/storageController.ts.cache +7 -10
  27. package/storage/remoteStorage/dist/storageServerState.ts.cache +50 -18
  28. package/storage/remoteStorage/remoteConfig.d.ts +3 -1
  29. package/storage/remoteStorage/remoteConfig.ts +11 -1
  30. package/storage/remoteStorage/sourcesList.d.ts +15 -0
  31. package/storage/remoteStorage/sourcesList.ts +69 -0
  32. package/storage/remoteStorage/storageController.d.ts +4 -4
  33. package/storage/remoteStorage/storageController.ts +11 -14
  34. package/storage/remoteStorage/storageServerState.d.ts +3 -0
  35. package/storage/remoteStorage/storageServerState.ts +40 -15
@@ -58,7 +58,7 @@ export type HostedConfig = CommonConfig & {
58
58
  // Fast mode: the server acknowledges writes once they are in memory, flushing to disk after writeDelay (default 5 minutes) and coalescing writes to the same file. A server crash loses writes that haven't flushed yet.
59
59
  fast?: boolean;
60
60
  writeDelay?: number;
61
- // The bucket is served straight from the server's disk, with no index — so no fast writes and no getChangesAfter/getSyncStatus.
61
+ // The bucket is served straight from the server's disk, with no index — so no fast writes, no getSyncStatus, and getChangesAfter2 falls back to a full listing.
62
62
  rawDisk?: boolean;
63
63
  // Writes to paths that already exist are disallowed (deletes still work).
64
64
  immutable?: boolean;
@@ -86,6 +86,20 @@ export const FULL_VALID_WINDOW: [number, number] = [0, Number.MAX_SAFE_INTEGER];
86
86
 
87
87
 
88
88
 
89
+ export type ChangesAfterConfig = {
90
+ time: number;
91
+ /** Only keys routing into one of these [start, end) ranges. Only scanning passes this - it lets a store syncing a partial shard ask for just its slice. */
92
+ routes?: [number, number][];
93
+ };
94
+
95
+ export type SetConfig = {
96
+ lastModified?: number;
97
+ /** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
98
+ forceSetImmutable?: boolean;
99
+ /** Skips the target-side safety reads around the write (backblaze: the pre-write getInfo comparison and the post-upload existence poll). For writers whose own bookkeeping already decides what to write and orders it by write time (BlobStore's index-driven writes and synchronization), those reads are pure extra API calls - but the default stays checked, because other users of the raw backends rely on the checks. */
100
+ noChecks?: boolean;
101
+ };
102
+
89
103
  // createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date: each write stamps a fresh timestamp on the current version, so both fields are just "when the bytes served by get() were most recently written".
90
104
  export type ArchiveFileInfo = { path: string; createTime: number; size: number };
91
105
 
@@ -102,7 +116,7 @@ export type SyncActivity = {
102
116
  };
103
117
 
104
118
  export type ArchivesConfig = {
105
- // Whether getChangesAfter is implemented (fast change polling, instead of full rescans)
119
+ // Whether getChangesAfter2 is natively index-backed (fast change polling; every backend still serves it, but the others emulate it with a full listing)
106
120
  supportsChangesAfter?: boolean;
107
121
  // The bucket's full routing config (ROUTING_FILE). Absent for sources that don't have one (a bare disk source, or a bucket that doesn't exist yet).
108
122
  remoteConfig?: RemoteConfig;
@@ -119,6 +133,8 @@ export type ArchivesConfig = {
119
133
  // A synchronization source of a BlobStore (which synchronizes an index + local cache from them)
120
134
  export type ArchivesSource = {
121
135
  source: IArchives;
136
+ /** The persistent identity of the endpoint: its routing URL (hosted/backblaze), or the disk folder path for the base disk source. The store persists this (via its append-only sources list) as IndexEntry.sourcesListIndex, so it must mean the same endpoint forever. */
137
+ url: string;
122
138
  // From the source's CommonConfig. Values with write times outside the window are ignored when scanning, and once its end is past (see windowAcceptsWrites) the source stops receiving writes entirely - still scanned (it holds the authoritative data for its window), it just stops growing.
123
139
  validWindow: [number, number];
124
140
  // From the source's CommonConfig (intersected with the owning store's own route): only keys routing into [start, end) are accepted from this source's scans and sent to it in writes/reconciliation. The routing file is exempt - config flows everywhere. Absent = all keys.
@@ -145,6 +161,55 @@ export function windowAcceptsWrites(validWindow: [number, number] | undefined):
145
161
  return validWindow[1] > Date.now();
146
162
  }
147
163
 
164
+ const LARGE_COPY_THRESHOLD = 64 * 1024 * 1024;
165
+ const LARGE_COPY_CHUNK = 32 * 1024 * 1024;
166
+
167
+ /** Copies one file between two archives. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. size/writeTime usually come from the caller's metadata scan; when either is omitted, getInfo fills them in. Returns the copied file's info, or undefined when the source doesn't have the file. */
168
+ export async function copyArchiveFile(config: {
169
+ from: IArchives;
170
+ to: IArchives;
171
+ path: string;
172
+ size?: number;
173
+ writeTime?: number;
174
+ forceSetImmutable?: boolean;
175
+ noChecks?: boolean;
176
+ }): Promise<{ writeTime: number; size: number } | undefined> {
177
+ let { from, to, path } = config;
178
+ let size = config.size;
179
+ let writeTime = config.writeTime;
180
+ if (size === undefined || writeTime === undefined) {
181
+ let info = await from.getInfo(path);
182
+ if (!info) return undefined;
183
+ size = info.size;
184
+ writeTime = info.writeTime;
185
+ }
186
+ if (size <= LARGE_COPY_THRESHOLD) {
187
+ let result = await from.get2(path);
188
+ if (!result) return undefined;
189
+ await to.set(path, result.data, { lastModified: result.writeTime, forceSetImmutable: config.forceSetImmutable, noChecks: config.noChecks });
190
+ return { writeTime: result.writeTime, size: result.data.length };
191
+ }
192
+ // Consts so the closure keeps the narrowed types
193
+ const totalSize = size;
194
+ const finalWriteTime = writeTime;
195
+ let offset = 0;
196
+ await to.setLargeFile({
197
+ path,
198
+ lastModified: finalWriteTime,
199
+ getNextData: async () => {
200
+ if (offset >= totalSize) return undefined;
201
+ let end = Math.min(offset + LARGE_COPY_CHUNK, totalSize);
202
+ let data = await from.get(path, { range: { start: offset, end } });
203
+ if (!data || !data.length) {
204
+ throw new Error(`Ranged read of ${JSON.stringify(path)} from ${from.getDebugName()} returned ${data && data.length || "nothing"} at ${offset}-${end} (expected ${end - offset} bytes of a ${totalSize} byte file - it changed or vanished mid-copy)`);
205
+ }
206
+ offset += data.length;
207
+ return data;
208
+ },
209
+ });
210
+ return { writeTime: finalWriteTime, size: totalSize };
211
+ }
212
+
148
213
  export type ArchivesSyncSourceStatus = {
149
214
  debugName: string;
150
215
  validWindow: [number, number];
@@ -177,10 +242,10 @@ export interface IArchives {
177
242
  * VARIABLE_SHARD, where the shard value is materialized into the key (picked by shard latency,
178
243
  * see ArchivesChain) and the caller needs the returned key to ever read the value back.
179
244
  */
180
- set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<string>;
245
+ set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
181
246
  del(fileName: string): Promise<void>;
182
- /** Streams a file too large to hold in memory. getNextData returns undefined when done. */
183
- setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined> }): Promise<void>;
247
+ /** Streams a file too large to hold in memory. getNextData returns undefined when done. lastModified stamps the finished file like set's (synchronized copies need it to keep write ordering); backends that stamp their own times (backblaze) accept and ignore it. */
248
+ setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined> }): Promise<void>;
184
249
  /** writeTime is the last-write time — see ArchiveFileInfo.createTime, which is the same value. */
185
250
  getInfo(fileName: string): Promise<{ writeTime: number; size: number } | undefined>;
186
251
  find(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<string[]>;
@@ -190,10 +255,13 @@ export interface IArchives {
190
255
  /** The bucket's configuration, which tells whether the optional functions are supported. */
191
256
  getConfig(): Promise<ArchivesConfig>;
192
257
  /**
193
- * All files changed after the given time. Only exists when getConfig().supportsChangesAfter;
194
- * backed by an index, so it is fast (unlike a full findInfo scan). Deletions are not reported.
258
+ * All files changed after config.time, optionally restricted to keys routing into one of
259
+ * config.routes (used by scanning, so partially-overlapping shards only receive their slice).
260
+ * When getConfig().supportsChangesAfter, this is backed by an index (fast, and deletions ARE
261
+ * reported, as size-0 tombstone entries). Every other backend emulates it: a full findInfo
262
+ * listing filtered in memory - correct, but no cheaper than the listing itself.
195
263
  */
196
- getChangesAfter?(time: number): Promise<ArchiveFileInfo[]>;
264
+ getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
197
265
  /** Synchronization introspection, for backends that synchronize from sources (see BlobStore). */
198
266
  getSyncStatus?(): Promise<ArchivesSyncStatus>;
199
267
  }
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
- import { IArchives, ArchivesConfig } from "./IArchives";
3
+ import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, SetConfig } from "./IArchives";
4
4
  export declare class ArchivesBackblaze implements IArchives {
5
5
  private config;
6
6
  constructor(config: {
@@ -39,12 +39,11 @@ export declare class ArchivesBackblaze implements IArchives {
39
39
  } | undefined>;
40
40
  getConfig(): Promise<ArchivesConfig>;
41
41
  hasWriteAccess(): Promise<boolean>;
42
- set(fileName: string, data: Buffer, config?: {
43
- lastModified?: number;
44
- }): Promise<string>;
42
+ set(fileName: string, data: Buffer, config?: SetConfig): Promise<string>;
45
43
  del(fileName: string): Promise<void>;
46
44
  setLargeFile(config: {
47
45
  path: string;
46
+ lastModified?: number;
48
47
  getNextData(): Promise<Buffer | undefined>;
49
48
  }): Promise<void>;
50
49
  getInfo(fileName: string): Promise<{
@@ -55,6 +54,7 @@ export declare class ArchivesBackblaze implements IArchives {
55
54
  shallow?: boolean;
56
55
  type: "files" | "folders";
57
56
  }): Promise<string[]>;
57
+ getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]>;
58
58
  findInfo(prefix: string, config?: {
59
59
  shallow?: boolean;
60
60
  type: "files" | "folders";
@@ -8,8 +8,9 @@ import { blue, green, magenta } from "socket-function/src/formatting/logColors";
8
8
  import debugbreak from "debugbreak";
9
9
  import dns from "dns";
10
10
  import { getSecret } from "../misc/getSecret";
11
- import { httpsRequest } from "socket-function/src/https";
12
- import { IArchives, ArchivesConfig, assertValidLastModified, IMMUTABLE_CACHE_TIME } from "./IArchives";
11
+ import { httpsRequest, HttpsResponseInfo } from "socket-function/src/https";
12
+ import { IArchives, ArchivesConfig, ChangesAfterConfig, ArchiveFileInfo, SetConfig, assertValidLastModified, IMMUTABLE_CACHE_TIME } from "./IArchives";
13
+ import { filterChanges } from "./remoteStorage/remoteConfig";
13
14
 
14
15
  type BackblazeCreds = {
15
16
  applicationKeyId: string;
@@ -24,6 +25,17 @@ let backblazeCreds = lazy(async (): Promise<BackblazeCreds> => {
24
25
  applicationKey: key,
25
26
  };
26
27
  });
28
+ // 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).
29
+ function parseFileMetadataHeaders(response: HttpsResponseInfo): { size: number; uploadTimestamp: number } {
30
+ let size = Number(response.headers["content-length"] || 0);
31
+ let contentRange = response.headers["content-range"];
32
+ let total = contentRange && Number(contentRange.split("/")[1]);
33
+ if (total && Number.isFinite(total)) {
34
+ size = total;
35
+ }
36
+ return { size, uploadTimestamp: Number(response.headers["x-bz-upload-timestamp"] || 0) };
37
+ }
38
+
27
39
  const getAPI = lazy(async () => {
28
40
  let creds = await backblazeCreds();
29
41
 
@@ -148,6 +160,8 @@ const getAPI = lazy(async () => {
148
160
  bucketName: string;
149
161
  fileName: string;
150
162
  range?: { start: number; end: number; };
163
+ // Captures the response headers, which carry the file's metadata (x-bz-upload-timestamp, content-length / content-range) - so callers that need it don't pay a second API call
164
+ outResponse?: HttpsResponseInfo;
151
165
  }) {
152
166
  let fileName = encodePath(config.fileName);
153
167
 
@@ -160,10 +174,31 @@ const getAPI = lazy(async () => {
160
174
  "Content-Type": "application/json",
161
175
  Range: config.range ? `bytes=${config.range.start}-${config.range.end - 1}` : undefined,
162
176
  }).filter(x => x[1] !== undefined)),
177
+ outResponse: config.outResponse,
163
178
  });
164
179
  return result;
165
180
  }
166
181
 
182
+ /** 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. */
183
+ async function headFileByName(config: { bucketName: string; fileName: string }): Promise<{ size: number; uploadTimestamp: number } | undefined> {
184
+ let fileName = encodePath(config.fileName);
185
+ let response: HttpsResponseInfo = { headers: {} };
186
+ try {
187
+ await httpsRequest(auth.apiUrl + "/file/" + config.bucketName + "/" + fileName, undefined, "HEAD", undefined, {
188
+ headers: { Authorization: auth.authorizationToken },
189
+ outResponse: response,
190
+ });
191
+ } catch (e) {
192
+ if (response.statusCode === 404) return undefined;
193
+ // HEAD responses have no body, so the expired_auth_token marker apiRetryLogic matches on never appears - re-add it so an expired token still refreshes and retries
194
+ if (response.statusCode === 401) {
195
+ throw new Error(`"expired_auth_token" HEAD ${config.bucketName}/${config.fileName} got a 401: ${(e as Error).stack ?? e}`);
196
+ }
197
+ throw e;
198
+ }
199
+ return parseFileMetadataHeaders(response);
200
+ }
201
+
167
202
  // Oh... apparently, we can't reuse these? Huh...
168
203
  const getUploadURL = (async (bucketId: string) => {
169
204
  // setTimeout(() => getUploadURL.clear(bucketId), timeInHour * 1);
@@ -353,6 +388,7 @@ const getAPI = lazy(async () => {
353
388
  updateBucket,
354
389
  listBuckets,
355
390
  downloadFileByName,
391
+ headFileByName,
356
392
  uploadFile,
357
393
  hideFile,
358
394
  getFileInfo,
@@ -587,6 +623,10 @@ export class ArchivesBackblaze implements IArchives {
587
623
  }
588
624
 
589
625
  public async get(fileName: string, config?: { range?: { start: number; end: number; }; retryCount?: number }): Promise<Buffer | undefined> {
626
+ let result = await this.get2(fileName, config);
627
+ return result && result.data || undefined;
628
+ }
629
+ public async get2(fileName: string, config?: { range?: { start: number; end: number; } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
590
630
  let downloading = true;
591
631
  try {
592
632
  let time = Date.now();
@@ -596,33 +636,39 @@ export class ArchivesBackblaze implements IArchives {
596
636
  setTimeout(downloadPoll, 5000);
597
637
  };
598
638
  setTimeout(downloadPoll, 5000);
599
- let result = await this.apiRetryLogic(`get ${fileName}`, async (api) => {
639
+ let result = await this.apiRetryLogic(`get ${fileName}`, async (api): Promise<{ data: Buffer; writeTime: number; size: number }> => {
600
640
  let range = config?.range;
601
641
  if (range) {
642
+ // The clamp needs the file's size (and gives us its metadata for free)
602
643
  let fileInfo = await this.getInfo(fileName);
603
644
  if (!fileInfo) throw new Error(`File ${fileName} not found`);
604
645
  let rangeStart = range.start;
605
646
  let rangeEnd = Math.min(range.end, fileInfo.size);
606
647
  // NOTE: I think if we request nothing, it confuses Backblaze and ends up giving us the entire file.
607
- if (rangeEnd <= rangeStart) return Buffer.alloc(0);
608
- let result = await api.downloadFileByName({
648
+ if (rangeEnd <= rangeStart) return { data: Buffer.alloc(0), writeTime: fileInfo.writeTime, size: fileInfo.size };
649
+ let data = await api.downloadFileByName({
609
650
  bucketName: this.bucketName,
610
651
  fileName,
611
652
  range: { start: rangeStart, end: rangeEnd },
612
653
  });
613
- if (result.length !== rangeEnd - rangeStart) {
614
- console.error(`Backblaze range download returned the wrong number of bytes. Tried to get ${rangeStart}-${rangeEnd}, but received ${rangeStart}-${rangeStart + result.length}. For file: ${fileName}`);
654
+ if (data.length !== rangeEnd - rangeStart) {
655
+ console.error(`Backblaze range download returned the wrong number of bytes. Tried to get ${rangeStart}-${rangeEnd}, but received ${rangeStart}-${rangeStart + data.length}. For file: ${fileName}`);
615
656
  }
616
- return result;
657
+ return { data, writeTime: fileInfo.writeTime, size: fileInfo.size };
617
658
  }
618
- return await api.downloadFileByName({
659
+ // The download's own response headers carry the metadata, so a full read is a single API call
660
+ let response: HttpsResponseInfo = { headers: {} };
661
+ let data = await api.downloadFileByName({
619
662
  bucketName: this.bucketName,
620
663
  fileName,
664
+ outResponse: response,
621
665
  });
666
+ let meta = parseFileMetadataHeaders(response);
667
+ return { data, writeTime: meta.uploadTimestamp, size: data.length };
622
668
  });
623
669
  let timeStr = formatTime(Date.now() - time);
624
- let rateStr = formatNumber(result.length / (Date.now() - time) * 1000) + "B/s";
625
- this.log(`backblaze download (${formatNumber(result.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
670
+ let rateStr = formatNumber(result.data.length / (Date.now() - time) * 1000) + "B/s";
671
+ this.log(`backblaze download (${formatNumber(result.data.length)}B${config?.range && `, ${formatNumber(config.range.start)} - ${formatNumber(config.range.end)}` || ""}) in ${timeStr} (${rateStr}, ${fileName})`);
626
672
  return result;
627
673
  } catch (e) {
628
674
  this.log(`backblaze file does not exist ${fileName}`);
@@ -631,12 +677,6 @@ export class ArchivesBackblaze implements IArchives {
631
677
  downloading = false;
632
678
  }
633
679
  }
634
- public async get2(fileName: string, config?: { range?: { start: number; end: number; } }): Promise<{ data: Buffer; writeTime: number; size: number } | undefined> {
635
- // B2 downloads don't return the upload time, so this takes a second API call
636
- let [data, info] = await Promise.all([this.get(fileName, config), this.getInfo(fileName)]);
637
- if (!data || !info) return undefined;
638
- return { data, writeTime: info.writeTime, size: info.size };
639
- }
640
680
  public async getConfig(): Promise<ArchivesConfig> {
641
681
  return {};
642
682
  }
@@ -656,28 +696,37 @@ export class ArchivesBackblaze implements IArchives {
656
696
  return false;
657
697
  }
658
698
  }
659
- public async set(fileName: string, data: Buffer, config?: { lastModified?: number }): Promise<string> {
699
+ public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
700
+ if (config?.forceSetImmutable && !config.lastModified) {
701
+ throw new Error(`forceSetImmutable requires lastModified (synchronization writes are ordered by their write time), writing ${fileName} to ${this.getDebugName()}`);
702
+ }
660
703
  if (config?.lastModified) {
661
704
  assertValidLastModified(config.lastModified);
662
- let existing = await this.getInfo(fileName);
663
- // 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.
664
- if (existing && config.lastModified < existing.writeTime) return fileName;
705
+ if (!config.noChecks) {
706
+ let existing = await this.getInfo(fileName);
707
+ // 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.
708
+ if (existing && config.lastModified < existing.writeTime) return fileName;
709
+ // Immutability wins: a synchronization push never overwrites an existing path on an immutable bucket (see SetConfig.forceSetImmutable)
710
+ if (existing && config.forceSetImmutable && this.config.immutable) return fileName;
711
+ }
665
712
  }
666
713
  this.log(`backblaze upload (${formatNumber(data.length)}B) ${fileName}`);
667
714
  let f = fileName;
668
715
  await this.apiRetryLogic(`uploadFile ${fileName}`, async (api) => {
669
716
  await api.uploadFile({ bucketId: this.bucketId, fileName, data: data, });
670
717
  });
671
- let existsChecks = 30;
672
- while (existsChecks > 0) {
673
- let exists = await this.getInfo(fileName);
674
- if (exists) break;
675
- await delay(1000);
676
- existsChecks--;
677
- }
678
- if (existsChecks === 0) {
679
- let exists = await this.getInfo(fileName);
680
- console.warn(`File ${fileName}/${f} was uploaded, but could not be found afterwards. Hopefully it was just deleted, very quickly? If backblaze is taking too long for files to propagate, then we might run into issues with the database atomicity.`);
718
+ if (!config?.noChecks) {
719
+ let existsChecks = 30;
720
+ while (existsChecks > 0) {
721
+ let exists = await this.getInfo(fileName);
722
+ if (exists) break;
723
+ await delay(1000);
724
+ existsChecks--;
725
+ }
726
+ if (existsChecks === 0) {
727
+ let exists = await this.getInfo(fileName);
728
+ console.warn(`File ${fileName}/${f} was uploaded, but could not be found afterwards. Hopefully it was just deleted, very quickly? If backblaze is taking too long for files to propagate, then we might run into issues with the database atomicity.`);
729
+ }
681
730
  }
682
731
  return fileName;
683
732
  }
@@ -694,7 +743,8 @@ export class ArchivesBackblaze implements IArchives {
694
743
  // 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); }
695
744
  }
696
745
 
697
- public async setLargeFile(config: { path: string; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
746
+ // lastModified is accepted but cannot be honored - b2 stamps its own uploadTimestamp, which is what our getInfo/findInfo report as the write time
747
+ public async setLargeFile(config: { path: string; lastModified?: number; getNextData(): Promise<Buffer | undefined>; }): Promise<void> {
698
748
 
699
749
  let onError: (() => Promise<void>)[] = [];
700
750
  let time = Date.now();
@@ -829,9 +879,7 @@ export class ArchivesBackblaze implements IArchives {
829
879
  public async getInfo(fileName: string): Promise<{ writeTime: number; size: number; } | undefined> {
830
880
  return await this.apiRetryLogic(`getInfo ${fileName}`, async (api) => {
831
881
  try {
832
- // NOTE: Apparently, there's no other way to do this, as the file name does not equal the file ID, and get file info requires the file ID.
833
- let info = await api.listFileNames({ bucketId: this.bucketId, prefix: fileName, maxFileCount: 10 });
834
- let file = info.files.find(x => x.fileName === fileName && x.action === "upload");
882
+ let file = await api.headFileByName({ bucketName: this.bucketName, fileName });
835
883
  if (!file) {
836
884
  this.log(`Backblaze file not exists ${fileName}`);
837
885
  return undefined;
@@ -839,7 +887,7 @@ export class ArchivesBackblaze implements IArchives {
839
887
  this.log(`Backblaze file exists ${fileName}`);
840
888
  return {
841
889
  writeTime: file.uploadTimestamp,
842
- size: file.contentLength,
890
+ size: file.size,
843
891
  };
844
892
  } catch (e: any) {
845
893
  if (e.stack.includes(`file_not_found`)) {
@@ -856,6 +904,11 @@ export class ArchivesBackblaze implements IArchives {
856
904
  let result = await this.findInfo(prefix, config);
857
905
  return result.map(x => x.path);
858
906
  }
907
+ public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
908
+ // No native change feed (b2 supports neither time nor route filtering) - a full listing filtered in memory
909
+ return filterChanges(await this.findInfo(""), config);
910
+ }
911
+
859
912
  public async findInfo(prefix: string, config?: { shallow?: boolean; type: "files" | "folders" }): Promise<{ path: string; createTime: number; size: number; }[]> {
860
913
  return await this.apiRetryLogic(`findInfo ${prefix}`, async (api) => {
861
914
  if (!config?.shallow && config?.type === "folders") {