@instantdb/platform 1.0.64 → 1.0.65
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/.turbo/turbo-build.log +8 -8
- package/__tests__/src/backupDownload.test.ts +218 -0
- package/dist/commonjs/backupDownload.d.ts +27 -0
- package/dist/commonjs/backupDownload.d.ts.map +1 -1
- package/dist/commonjs/backupDownload.js +222 -44
- package/dist/commonjs/backupDownload.js.map +1 -1
- package/dist/esm/backupDownload.d.ts +27 -0
- package/dist/esm/backupDownload.d.ts.map +1 -1
- package/dist/esm/backupDownload.js +222 -44
- package/dist/esm/backupDownload.js.map +1 -1
- package/dist/standalone/index.js +1414 -1301
- package/dist/standalone/index.umd.cjs +23 -23
- package/package.json +4 -4
- package/src/backupDownload.ts +300 -49
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
|
|
2
|
-
> @instantdb/platform@1.0.
|
|
2
|
+
> @instantdb/platform@1.0.65 build /home/runner/work/instant/instant/client/packages/platform
|
|
3
3
|
> rm -rf dist; npm run build:tshy && npm run build:standalone && npm run check-exports
|
|
4
4
|
|
|
5
5
|
npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
6
6
|
npm warn Unknown user config "always-auth". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
7
7
|
|
|
8
|
-
> @instantdb/platform@1.0.
|
|
8
|
+
> @instantdb/platform@1.0.65 build:tshy
|
|
9
9
|
> tshy
|
|
10
10
|
|
|
11
11
|
npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
12
12
|
npm warn Unknown user config "always-auth". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
13
13
|
|
|
14
|
-
> @instantdb/platform@1.0.
|
|
14
|
+
> @instantdb/platform@1.0.65 build:standalone
|
|
15
15
|
> vite build
|
|
16
16
|
|
|
17
17
|
[36mvite v5.4.14 [32mbuilding for production...[36m[39m
|
|
@@ -19,17 +19,17 @@ transforming...
|
|
|
19
19
|
[32m✓[39m 88 modules transformed.
|
|
20
20
|
rendering chunks...
|
|
21
21
|
computing gzip size...
|
|
22
|
-
[2mdist/standalone/[22m[36mindex.umd.cjs [39m[1m[
|
|
23
|
-
[2mdist/standalone/[22m[36mindex.js [39m[1m[
|
|
24
|
-
[32m✓ built in
|
|
22
|
+
[2mdist/standalone/[22m[36mindex.umd.cjs [39m[1m[2m372.28 kB[22m[1m[22m[2m │ gzip: 103.21 kB[22m
|
|
23
|
+
[2mdist/standalone/[22m[36mindex.js [39m[1m[2m499.57 kB[22m[1m[22m[2m │ gzip: 120.82 kB[22m
|
|
24
|
+
[32m✓ built in 3.63s[39m
|
|
25
25
|
npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
26
26
|
npm warn Unknown user config "always-auth". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
|
|
27
27
|
|
|
28
|
-
> @instantdb/platform@1.0.
|
|
28
|
+
> @instantdb/platform@1.0.65 check-exports
|
|
29
29
|
> attw --pack .
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
@instantdb/platform v1.0.
|
|
32
|
+
@instantdb/platform v1.0.65
|
|
33
33
|
|
|
34
34
|
Build tools:
|
|
35
35
|
- @arethetypeswrong/cli@^0.18.5
|
|
@@ -168,6 +168,223 @@ describe('downloadBackupArchive', () => {
|
|
|
168
168
|
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
test('fetches later entries while an earlier one is still being written', async () => {
|
|
172
|
+
const files = [
|
|
173
|
+
{ name: 'config.json', size: 1 },
|
|
174
|
+
{ name: 'entities/a.jsonl', size: 1 },
|
|
175
|
+
{ name: 'entities/b.jsonl', size: 1 },
|
|
176
|
+
];
|
|
177
|
+
const started: string[] = [];
|
|
178
|
+
let resolveAllStarted!: () => void;
|
|
179
|
+
const allStarted = new Promise<void>((r) => {
|
|
180
|
+
resolveAllStarted = r;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const manager = {
|
|
184
|
+
listFiles: async () => files,
|
|
185
|
+
getFileUrl,
|
|
186
|
+
// A fetch records that it started and, once every entry's fetch has
|
|
187
|
+
// begun, releases the writer below.
|
|
188
|
+
streamStorageFiles: async function* () {},
|
|
189
|
+
} as any;
|
|
190
|
+
const trackingFetch = async (url: string) => {
|
|
191
|
+
started.push(url);
|
|
192
|
+
if (started.length === files.length) resolveAllStarted();
|
|
193
|
+
return bodyOf(`body:${url}`);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// The first entry's write can't finish until every fetch has started. A
|
|
197
|
+
// strictly sequential downloader would deadlock — the second file's fetch
|
|
198
|
+
// would wait on the first file's write, which waits on all fetches — so
|
|
199
|
+
// this test only completes because later fetches run ahead of the writer.
|
|
200
|
+
let firstAdd = true;
|
|
201
|
+
const createWriter = async (sink: WritableStream<Uint8Array>) => {
|
|
202
|
+
const w = sink.getWriter();
|
|
203
|
+
return {
|
|
204
|
+
add: async (_name: string, input: ReadableStream<Uint8Array>) => {
|
|
205
|
+
if (firstAdd) {
|
|
206
|
+
firstAdd = false;
|
|
207
|
+
await allStarted;
|
|
208
|
+
}
|
|
209
|
+
for await (const _chunk of input) {
|
|
210
|
+
// drain
|
|
211
|
+
}
|
|
212
|
+
await w.write(new Uint8Array([0]));
|
|
213
|
+
},
|
|
214
|
+
close: () => w.close(),
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
await downloadBackupArchive({
|
|
219
|
+
manager,
|
|
220
|
+
backup,
|
|
221
|
+
fetchBody: trackingFetch,
|
|
222
|
+
sink: nullSink(),
|
|
223
|
+
createWriter,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(started).toEqual([
|
|
227
|
+
'config.json',
|
|
228
|
+
'entities/a.jsonl',
|
|
229
|
+
'entities/b.jsonl',
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('retries a transient failure opening an entry body', async () => {
|
|
234
|
+
const names: string[] = [];
|
|
235
|
+
const manager = {
|
|
236
|
+
listFiles,
|
|
237
|
+
getFileUrl,
|
|
238
|
+
streamStorageFiles: async function* () {
|
|
239
|
+
yield { locationId: 'loc-1', path: 'a.png', url: 'loc-1-url' };
|
|
240
|
+
},
|
|
241
|
+
} as any;
|
|
242
|
+
|
|
243
|
+
// The storage blob's first open fails once, then succeeds.
|
|
244
|
+
let storageAttempts = 0;
|
|
245
|
+
const flakyFetch = async (url: string) => {
|
|
246
|
+
if (url === 'loc-1-url') {
|
|
247
|
+
storageAttempts++;
|
|
248
|
+
if (storageAttempts === 1) throw new Error('ECONNRESET');
|
|
249
|
+
}
|
|
250
|
+
return bodyOf(`body:${url}`);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const result = await downloadBackupArchive({
|
|
254
|
+
manager,
|
|
255
|
+
backup,
|
|
256
|
+
fetchBody: flakyFetch,
|
|
257
|
+
sink: nullSink(),
|
|
258
|
+
createWriter: makeWriter(names),
|
|
259
|
+
retry: { attempts: 3, delayMs: 0 },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(storageAttempts).toBe(2);
|
|
263
|
+
expect(names).toEqual([
|
|
264
|
+
'config.json',
|
|
265
|
+
'entities/todos.jsonl',
|
|
266
|
+
'files/loc-1',
|
|
267
|
+
]);
|
|
268
|
+
expect(result.files).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('retries a body that connects but errors on its first read', async () => {
|
|
272
|
+
const names: string[] = [];
|
|
273
|
+
const manager = {
|
|
274
|
+
listFiles,
|
|
275
|
+
getFileUrl,
|
|
276
|
+
streamStorageFiles: async function* () {
|
|
277
|
+
yield { locationId: 'loc-1', path: 'a.png', url: 'loc-1-url' };
|
|
278
|
+
},
|
|
279
|
+
} as any;
|
|
280
|
+
|
|
281
|
+
// fetchBody resolves (connection established) but the body errors on its
|
|
282
|
+
// first read the first time — the shape of a reset idle connection.
|
|
283
|
+
let storageAttempts = 0;
|
|
284
|
+
const flakyFetch = async (url: string) => {
|
|
285
|
+
if (url === 'loc-1-url') {
|
|
286
|
+
storageAttempts++;
|
|
287
|
+
if (storageAttempts === 1) {
|
|
288
|
+
return new ReadableStream<Uint8Array>({
|
|
289
|
+
pull(controller) {
|
|
290
|
+
controller.error(new Error('ECONNRESET'));
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return bodyOf(`body:${url}`);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const result = await downloadBackupArchive({
|
|
299
|
+
manager,
|
|
300
|
+
backup,
|
|
301
|
+
fetchBody: flakyFetch,
|
|
302
|
+
sink: nullSink(),
|
|
303
|
+
createWriter: makeWriter(names),
|
|
304
|
+
retry: { attempts: 3, delayMs: 0 },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(storageAttempts).toBe(2);
|
|
308
|
+
expect(names).toEqual([
|
|
309
|
+
'config.json',
|
|
310
|
+
'entities/todos.jsonl',
|
|
311
|
+
'files/loc-1',
|
|
312
|
+
]);
|
|
313
|
+
expect(result.files).toBe(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('does not retry once the body has yielded a chunk', async () => {
|
|
317
|
+
const manager = {
|
|
318
|
+
listFiles,
|
|
319
|
+
getFileUrl,
|
|
320
|
+
streamStorageFiles: async function* () {
|
|
321
|
+
yield { locationId: 'loc-1', path: 'a.png', url: 'loc-1-url' };
|
|
322
|
+
},
|
|
323
|
+
} as any;
|
|
324
|
+
|
|
325
|
+
// The body delivers one chunk on the first read, then errors on the next —
|
|
326
|
+
// a mid-stream failure. Once bytes are flowing the entry can't be
|
|
327
|
+
// restarted, so this must not retry.
|
|
328
|
+
let storageAttempts = 0;
|
|
329
|
+
const fetchBody = async (url: string) => {
|
|
330
|
+
if (url === 'loc-1-url') {
|
|
331
|
+
storageAttempts++;
|
|
332
|
+
let phase = 0;
|
|
333
|
+
return new ReadableStream<Uint8Array>({
|
|
334
|
+
pull(controller) {
|
|
335
|
+
if (phase++ === 0) {
|
|
336
|
+
controller.enqueue(new TextEncoder().encode('partial'));
|
|
337
|
+
} else {
|
|
338
|
+
controller.error(new Error('mid-stream reset'));
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return bodyOf(`body:${url}`);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
await expect(
|
|
347
|
+
downloadBackupArchive({
|
|
348
|
+
manager,
|
|
349
|
+
backup,
|
|
350
|
+
fetchBody,
|
|
351
|
+
sink: nullSink(),
|
|
352
|
+
createWriter: makeWriter([]),
|
|
353
|
+
retry: { attempts: 3, delayMs: 0 },
|
|
354
|
+
}),
|
|
355
|
+
).rejects.toThrow();
|
|
356
|
+
expect(storageAttempts).toBe(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('gives up after exhausting retries and names the failing entry', async () => {
|
|
360
|
+
const manager = {
|
|
361
|
+
listFiles,
|
|
362
|
+
getFileUrl,
|
|
363
|
+
streamStorageFiles: async function* () {
|
|
364
|
+
yield { locationId: 'loc-9', path: null, url: 'bad-url' };
|
|
365
|
+
},
|
|
366
|
+
} as any;
|
|
367
|
+
|
|
368
|
+
let attempts = 0;
|
|
369
|
+
await expect(
|
|
370
|
+
downloadBackupArchive({
|
|
371
|
+
manager,
|
|
372
|
+
backup,
|
|
373
|
+
fetchBody: async (url: string) => {
|
|
374
|
+
if (url === 'bad-url') {
|
|
375
|
+
attempts++;
|
|
376
|
+
throw new Error('HTTP 500');
|
|
377
|
+
}
|
|
378
|
+
return bodyOf('x');
|
|
379
|
+
},
|
|
380
|
+
sink: nullSink(),
|
|
381
|
+
createWriter: makeWriter([]),
|
|
382
|
+
retry: { attempts: 3, delayMs: 0 },
|
|
383
|
+
}),
|
|
384
|
+
).rejects.toThrow('Couldn\'t download storage file "loc-9" (HTTP 500).');
|
|
385
|
+
expect(attempts).toBe(3);
|
|
386
|
+
});
|
|
387
|
+
|
|
171
388
|
test('names a pathless storage file by locationId when its download fails', async () => {
|
|
172
389
|
const manager = {
|
|
173
390
|
listFiles,
|
|
@@ -187,6 +404,7 @@ describe('downloadBackupArchive', () => {
|
|
|
187
404
|
},
|
|
188
405
|
sink: nullSink(),
|
|
189
406
|
createWriter: makeWriter([]),
|
|
407
|
+
retry: { attempts: 1 },
|
|
190
408
|
}),
|
|
191
409
|
).rejects.toThrow('Couldn\'t download storage file "loc-9" (HTTP 500).');
|
|
192
410
|
});
|
|
@@ -54,6 +54,33 @@ export type DownloadBackupArchiveOpts = {
|
|
|
54
54
|
createWriter: (sink: WritableStream<Uint8Array>, signal: AbortSignal) => Promise<BackupArchiveWriter>;
|
|
55
55
|
signal?: AbortSignal;
|
|
56
56
|
onProgress?: (progress: BackupDownloadProgress) => void;
|
|
57
|
+
/**
|
|
58
|
+
* How many entries to fetch ahead of the one currently being written into
|
|
59
|
+
* the archive.
|
|
60
|
+
*
|
|
61
|
+
* Only fetch *initiation* is parallelised, the bodies are still written in
|
|
62
|
+
* order and consumed one at a time, so an in-flight prefetched body buffers
|
|
63
|
+
* only to its stream's high-water mark and memory stays bounded. Defaults to
|
|
64
|
+
* {@link DEFAULT_PREFETCH}.
|
|
65
|
+
*/
|
|
66
|
+
prefetch?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Retry policy for *opening* an entry's body (fetching its presigned URL and
|
|
69
|
+
* getting a live response). Prefetching keeps body connections open, paused,
|
|
70
|
+
* while earlier entries write, so a queued connection can be reset before we
|
|
71
|
+
* read it.
|
|
72
|
+
* `attempts` is the total number of tries (default {@link DEFAULT_FETCH_ATTEMPTS});
|
|
73
|
+
* `delayMs` is the base for exponential backoff (default {@link DEFAULT_RETRY_DELAY_MS}).
|
|
74
|
+
*
|
|
75
|
+
* This only covers failures before the writer starts consuming the body.
|
|
76
|
+
* Once bytes have been written into the archive entry there's no way to
|
|
77
|
+
* restart it without HTTP range/resume support, so a mid-stream failure
|
|
78
|
+
* still fails the download.
|
|
79
|
+
*/
|
|
80
|
+
retry?: {
|
|
81
|
+
attempts?: number;
|
|
82
|
+
delayMs?: number;
|
|
83
|
+
};
|
|
57
84
|
};
|
|
58
85
|
/**
|
|
59
86
|
* Downloads a backup into a single archive written to `opts.sink`: entries
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"backupDownload.d.ts","sourceRoot":"","sources":["../../src/backupDownload.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,SAAS,EAET,cAAc,EACf,MAAM,cAAc,CAAC;AAEtB,MAAM,MAAM,sBAAsB,GAAG;IACnC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAE1B,QAAQ,EAAE,MAAM,CAAC;IAIjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAG1B,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CACD,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,cAAc,CAAC,UAAU,CAAC,EACjC,IAAI,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAC1B,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,KAAK,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,MAAM,EAAE,SAAS,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,CACT,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,KAChB,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAC;IACzC;;;;OAIG;IACH,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACjC;;;OAGG;IACH,YAAY,EAAE,CACZ,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,MAAM,EAAE,WAAW,KAChB,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAClC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,sBAAsB,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"backupDownload.d.ts","sourceRoot":"","sources":["../../src/backupDownload.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,SAAS,EAET,cAAc,EACf,MAAM,cAAc,CAAC;AAEtB,MAAM,MAAM,sBAAsB,GAAG;IACnC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAE1B,QAAQ,EAAE,MAAM,CAAC;IAIjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAG1B,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;;;;;GASG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CACD,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,cAAc,CAAC,UAAU,CAAC,EACjC,IAAI,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAC1B,OAAO,CAAC,OAAO,CAAC,CAAC;IACpB,KAAK,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,MAAM,EAAE,SAAS,CAAC;IAClB;;;;;;OAMG;IACH,SAAS,EAAE,CACT,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,KAChB,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAAC;IACzC;;;;OAIG;IACH,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACjC;;;OAGG;IACH,YAAY,EAAE,CACZ,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,MAAM,EAAE,WAAW,KAChB,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAClC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACxD;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACjD,CAAC;AAoNF;;;;;;;;;;;;GAYG;AACH,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,yBAAyB,GAAG;IAChC,OAAO,EAAE,IAAI,CACX,cAAc,EACd,WAAW,GAAG,YAAY,GAAG,oBAAoB,CAClD,CAAC;CACH,GACA,OAAO,CAAC,oBAAoB,CAAC,CAwR/B"}
|
|
@@ -1,8 +1,168 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.downloadBackupArchive = downloadBackupArchive;
|
|
4
|
+
const DEFAULT_PREFETCH = 4;
|
|
5
|
+
const DEFAULT_FETCH_ATTEMPTS = 3;
|
|
6
|
+
const DEFAULT_RETRY_DELAY_MS = 500;
|
|
7
|
+
const MAX_RETRY_DELAY_MS = 5000;
|
|
8
|
+
// Normalize caller-supplied numeric options so NaN/Infinity/non-integers can't
|
|
9
|
+
// alter retry counts or pipeline bounds — fall back to the default instead.
|
|
10
|
+
const finitePositiveInt = (v, fallback) => v != null && Number.isInteger(v) && v > 0 ? v : fallback;
|
|
11
|
+
const finiteNonNegative = (v, fallback) => v != null && Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
4
12
|
const isAbortError = (e) => e?.name === 'AbortError';
|
|
5
13
|
const errorMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
14
|
+
// A cancellable sleep: resolves after `ms`, or rejects if the signal aborts
|
|
15
|
+
// first so backoff between retries doesn't outlive a cancelled download.
|
|
16
|
+
const delay = (ms, signal) => new Promise((resolve, reject) => {
|
|
17
|
+
const onAbort = () => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
20
|
+
};
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
signal.removeEventListener('abort', onAbort);
|
|
23
|
+
resolve();
|
|
24
|
+
}, ms);
|
|
25
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
26
|
+
});
|
|
27
|
+
// Re-emits an already-read first chunk, then streams the rest from `reader`.
|
|
28
|
+
// Past that first chunk read errors propagate to the consumer unchanged — by
|
|
29
|
+
// then bytes are in the archive entry and it can't be restarted.
|
|
30
|
+
function replayFrom(first, reader) {
|
|
31
|
+
let replayed = false;
|
|
32
|
+
return new ReadableStream({
|
|
33
|
+
async pull(controller) {
|
|
34
|
+
if (!replayed) {
|
|
35
|
+
replayed = true;
|
|
36
|
+
if (first.done) {
|
|
37
|
+
controller.close();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
controller.enqueue(first.value);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done)
|
|
45
|
+
controller.close();
|
|
46
|
+
else
|
|
47
|
+
controller.enqueue(value);
|
|
48
|
+
},
|
|
49
|
+
async cancel(reason) {
|
|
50
|
+
await reader.cancel(reason);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Opens a body via `open`, retrying on transient failure with abortable
|
|
56
|
+
* exponential backoff. `open` is re-invoked from scratch each attempt, so an
|
|
57
|
+
* entity file re-mints its presigned URL. An abort propagates immediately; any
|
|
58
|
+
* other final failure is wrapped by `describe` into a user-facing message
|
|
59
|
+
* naming the entry.
|
|
60
|
+
*
|
|
61
|
+
* The first chunk is read inside the retry scope, so a body that connects but
|
|
62
|
+
* fails on its first read — the shape of a prefetched connection reset while it
|
|
63
|
+
* sat idle — is re-fetched too, since nothing has been written to the archive
|
|
64
|
+
* yet. Only failures once bytes are flowing are treated as unrecoverable.
|
|
65
|
+
*/
|
|
66
|
+
async function openWithRetry(open, opts) {
|
|
67
|
+
let lastError;
|
|
68
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt++) {
|
|
69
|
+
opts.signal.throwIfAborted();
|
|
70
|
+
let reader;
|
|
71
|
+
try {
|
|
72
|
+
const body = await open();
|
|
73
|
+
reader = body.getReader();
|
|
74
|
+
const first = await reader.read();
|
|
75
|
+
return replayFrom(first, reader);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
// Release the failed connection before retrying (or giving up).
|
|
79
|
+
if (reader)
|
|
80
|
+
reader.cancel().catch(() => { });
|
|
81
|
+
if (isAbortError(e))
|
|
82
|
+
throw e;
|
|
83
|
+
lastError = e;
|
|
84
|
+
if (attempt < opts.attempts) {
|
|
85
|
+
const backoff = Math.min(opts.delayMs * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
|
|
86
|
+
await delay(backoff, opts.signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
throw new Error(opts.describe(lastError));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Wraps an ordered stream of entry thunks, keeping up to `lookahead` fetches
|
|
94
|
+
* in flight while yielding the prepared entries in their original order. A
|
|
95
|
+
* background producer pulls thunks and starts their fetches as space frees up;
|
|
96
|
+
* the consumer awaits each in turn. Preserves order and backpressure: at most
|
|
97
|
+
* `lookahead` bodies are ever in flight, and the producer parks when the
|
|
98
|
+
* pipeline is full or the source is waiting for more work.
|
|
99
|
+
*/
|
|
100
|
+
async function* prefetchEntries(thunks, lookahead) {
|
|
101
|
+
const pipeline = [];
|
|
102
|
+
const state = { done: false, producerError: null, onItem: null, onSpace: null };
|
|
103
|
+
const wakeItem = () => {
|
|
104
|
+
const w = state.onItem;
|
|
105
|
+
state.onItem = null;
|
|
106
|
+
if (w)
|
|
107
|
+
w();
|
|
108
|
+
};
|
|
109
|
+
const wakeSpace = () => {
|
|
110
|
+
const w = state.onSpace;
|
|
111
|
+
state.onSpace = null;
|
|
112
|
+
if (w)
|
|
113
|
+
w();
|
|
114
|
+
};
|
|
115
|
+
// Never rejects: a failure to produce the next thunk (or start its fetch)
|
|
116
|
+
// lands in state.producerError for the consumer to throw in order.
|
|
117
|
+
const producer = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
for await (const thunk of thunks) {
|
|
120
|
+
while (pipeline.length >= lookahead) {
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
state.onSpace = resolve;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const started = thunk();
|
|
126
|
+
// The consumer awaits `started` in order; attach a no-op catch so a
|
|
127
|
+
// fetch that rejects before then isn't reported as unhandled.
|
|
128
|
+
started.catch(() => { });
|
|
129
|
+
pipeline.push(started);
|
|
130
|
+
wakeItem();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
state.producerError = e;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
state.done = true;
|
|
138
|
+
wakeItem();
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
if (pipeline.length === 0) {
|
|
144
|
+
if (state.done) {
|
|
145
|
+
if (state.producerError)
|
|
146
|
+
throw state.producerError;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
await new Promise((resolve) => {
|
|
150
|
+
state.onItem = resolve;
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const entry = await pipeline.shift();
|
|
155
|
+
wakeSpace();
|
|
156
|
+
yield entry;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
// On early exit (abort/error), let the producer unwind — its own signal
|
|
161
|
+
// teardown resolves the source's waits — without blocking here.
|
|
162
|
+
wakeSpace();
|
|
163
|
+
producer.catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
6
166
|
/**
|
|
7
167
|
* Downloads a backup into a single archive written to `opts.sink`: entries
|
|
8
168
|
* in the canonical restore order (`config.json`, then the
|
|
@@ -41,6 +201,8 @@ async function downloadBackupArchive(opts) {
|
|
|
41
201
|
const bytesTotal = backup.uncompressedSize != null
|
|
42
202
|
? backup.uncompressedSize + (backup.filesSize ?? 0)
|
|
43
203
|
: null;
|
|
204
|
+
const retryAttempts = finitePositiveInt(opts.retry?.attempts, DEFAULT_FETCH_ATTEMPTS);
|
|
205
|
+
const retryDelayMs = finiteNonNegative(opts.retry?.delayMs, DEFAULT_RETRY_DELAY_MS);
|
|
44
206
|
const tick = () => onProgress?.({
|
|
45
207
|
entitiesCompleted,
|
|
46
208
|
entitiesTotal,
|
|
@@ -119,7 +281,14 @@ async function downloadBackupArchive(opts) {
|
|
|
119
281
|
notify();
|
|
120
282
|
}
|
|
121
283
|
})();
|
|
122
|
-
|
|
284
|
+
// Entry write order is significant for restore: config.json first, then the
|
|
285
|
+
// entities/*.jsonl shards, then files/<locationId>. In particular ALL entity
|
|
286
|
+
// files must be written before ANY storage file. listFiles returns the
|
|
287
|
+
// entity files in write order; this generator yields thunks for them in
|
|
288
|
+
// order, then drains the storage queue. The thunks are consumed through
|
|
289
|
+
// prefetchEntries, which starts a bounded number of the fetches ahead of the
|
|
290
|
+
// encoder while keeping this order and one-at-a-time writing.
|
|
291
|
+
const thunks = (async function* () {
|
|
123
292
|
const files = await manager.listFiles(backup.id, { signal });
|
|
124
293
|
if (files.length === 0) {
|
|
125
294
|
throw new Error('No files found for this backup.');
|
|
@@ -134,30 +303,32 @@ async function downloadBackupArchive(opts) {
|
|
|
134
303
|
entitiesTotal = files.filter((f) => f.name !== 'config.json').length;
|
|
135
304
|
tick();
|
|
136
305
|
for (const f of files) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
306
|
+
yield async () => {
|
|
307
|
+
const body = await openWithRetry(async () => {
|
|
308
|
+
const url = await manager.getFileUrl(backup.id, f.name, { signal });
|
|
309
|
+
return fetchBody(url, signal);
|
|
310
|
+
}, {
|
|
311
|
+
signal,
|
|
312
|
+
attempts: retryAttempts,
|
|
313
|
+
delayMs: retryDelayMs,
|
|
314
|
+
describe: (e) => `Failed to fetch ${f.name}: ${errorMessage(e)}.`,
|
|
315
|
+
});
|
|
316
|
+
return {
|
|
317
|
+
name: f.name,
|
|
318
|
+
input: countBytes(body),
|
|
319
|
+
onWriting: () => {
|
|
320
|
+
currentEntity = f.name;
|
|
321
|
+
currentFile = '';
|
|
322
|
+
tick();
|
|
323
|
+
},
|
|
324
|
+
onAdded: () => {
|
|
325
|
+
if (f.name !== 'config.json')
|
|
326
|
+
entitiesCompleted++;
|
|
327
|
+
tick();
|
|
328
|
+
},
|
|
329
|
+
};
|
|
157
330
|
};
|
|
158
331
|
}
|
|
159
|
-
currentEntity = '';
|
|
160
|
-
tick();
|
|
161
332
|
while (true) {
|
|
162
333
|
if (storageError)
|
|
163
334
|
throw storageError;
|
|
@@ -173,25 +344,28 @@ async function downloadBackupArchive(opts) {
|
|
|
173
344
|
queueHead++;
|
|
174
345
|
}
|
|
175
346
|
if (file) {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
347
|
+
const storageFile = file;
|
|
348
|
+
const label = storageFile.path || storageFile.locationId;
|
|
349
|
+
yield async () => {
|
|
350
|
+
const body = await openWithRetry(() => fetchBody(storageFile.url, signal), {
|
|
351
|
+
signal,
|
|
352
|
+
attempts: retryAttempts,
|
|
353
|
+
delayMs: retryDelayMs,
|
|
354
|
+
describe: (e) => `Couldn't download storage file "${label}" (${errorMessage(e)}).`,
|
|
355
|
+
});
|
|
356
|
+
return {
|
|
357
|
+
name: `files/${storageFile.locationId}`,
|
|
358
|
+
input: countBytes(body),
|
|
359
|
+
onWriting: () => {
|
|
360
|
+
currentEntity = '';
|
|
361
|
+
currentFile = label;
|
|
362
|
+
tick();
|
|
363
|
+
},
|
|
364
|
+
onAdded: () => {
|
|
365
|
+
filesCompleted++;
|
|
366
|
+
tick();
|
|
367
|
+
},
|
|
368
|
+
};
|
|
195
369
|
};
|
|
196
370
|
}
|
|
197
371
|
else if (storageDone) {
|
|
@@ -203,11 +377,11 @@ async function downloadBackupArchive(opts) {
|
|
|
203
377
|
});
|
|
204
378
|
}
|
|
205
379
|
}
|
|
206
|
-
currentFile = '';
|
|
207
|
-
tick();
|
|
208
380
|
if (storageError)
|
|
209
381
|
throw storageError;
|
|
210
382
|
})();
|
|
383
|
+
const prefetch = finitePositiveInt(opts.prefetch, DEFAULT_PREFETCH);
|
|
384
|
+
const entries = prefetchEntries(thunks, prefetch);
|
|
211
385
|
const sinkWriter = opts.sink.getWriter();
|
|
212
386
|
try {
|
|
213
387
|
// Sink the archive encoder writes into: it tallies the encoded size for
|
|
@@ -230,11 +404,15 @@ async function downloadBackupArchive(opts) {
|
|
|
230
404
|
});
|
|
231
405
|
const writer = await createWriter(countingSink, signal);
|
|
232
406
|
for await (const entry of entries) {
|
|
407
|
+
entry.onWriting();
|
|
233
408
|
await writer.add(entry.name, entry.input, {
|
|
234
409
|
lastModDate: backup.backupAt,
|
|
235
410
|
});
|
|
236
411
|
entry.onAdded();
|
|
237
412
|
}
|
|
413
|
+
currentEntity = '';
|
|
414
|
+
currentFile = '';
|
|
415
|
+
tick();
|
|
238
416
|
// A caller abort that lands after the last entry lets the generator
|
|
239
417
|
// finish cleanly; don't close and return a complete-looking archive.
|
|
240
418
|
signal.throwIfAborted();
|