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.
- package/index.d.ts +99 -38
- package/package.json +1 -1
- package/storage/ArchivesDisk.d.ts +5 -5
- package/storage/ArchivesDisk.ts +19 -5
- package/storage/IArchives.d.ts +36 -7
- package/storage/IArchives.ts +76 -8
- package/storage/backblaze.d.ts +4 -4
- package/storage/backblaze.ts +89 -36
- package/storage/dist/ArchivesDisk.ts.cache +8 -2
- package/storage/dist/IArchives.ts.cache +2 -2
- package/storage/dist/backblaze.ts.cache +86 -36
- package/storage/remoteStorage/ArchivesRemote.d.ts +4 -5
- package/storage/remoteStorage/ArchivesRemote.ts +7 -7
- package/storage/remoteStorage/ArchivesUrl.d.ts +2 -1
- package/storage/remoteStorage/ArchivesUrl.ts +13 -3
- package/storage/remoteStorage/blobStore.d.ts +15 -6
- package/storage/remoteStorage/blobStore.ts +198 -115
- package/storage/remoteStorage/createArchives.d.ts +4 -5
- package/storage/remoteStorage/createArchives.ts +6 -11
- package/storage/remoteStorage/dist/ArchivesRemote.ts.cache +5 -5
- package/storage/remoteStorage/dist/ArchivesUrl.ts.cache +15 -4
- package/storage/remoteStorage/dist/blobStore.ts.cache +194 -108
- package/storage/remoteStorage/dist/createArchives.ts.cache +4 -9
- package/storage/remoteStorage/dist/remoteConfig.ts.cache +15 -3
- package/storage/remoteStorage/dist/sourcesList.ts.cache +79 -0
- package/storage/remoteStorage/dist/storageController.ts.cache +7 -10
- package/storage/remoteStorage/dist/storageServerState.ts.cache +50 -18
- package/storage/remoteStorage/remoteConfig.d.ts +3 -1
- package/storage/remoteStorage/remoteConfig.ts +11 -1
- package/storage/remoteStorage/sourcesList.d.ts +15 -0
- package/storage/remoteStorage/sourcesList.ts +69 -0
- package/storage/remoteStorage/storageController.d.ts +4 -4
- package/storage/remoteStorage/storageController.ts +11 -14
- package/storage/remoteStorage/storageServerState.d.ts +3 -0
- package/storage/remoteStorage/storageServerState.ts +40 -15
package/storage/IArchives.ts
CHANGED
|
@@ -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
|
|
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
|
|
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?:
|
|
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
|
|
194
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/storage/backblaze.d.ts
CHANGED
|
@@ -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";
|
package/storage/backblaze.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
614
|
-
console.error(`Backblaze range download returned the wrong number of bytes. Tried to get ${rangeStart}-${rangeEnd}, but received ${rangeStart}-${rangeStart +
|
|
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
|
|
657
|
+
return { data, writeTime: fileInfo.writeTime, size: fileInfo.size };
|
|
617
658
|
}
|
|
618
|
-
|
|
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?:
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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") {
|