@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.
@@ -1,17 +1,17 @@
1
1
 
2
- > @instantdb/platform@1.0.64 build /home/runner/work/instant/instant/client/packages/platform
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.64 build:tshy
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.64 build:standalone
14
+ > @instantdb/platform@1.0.65 build:standalone
15
15
  > vite build
16
16
 
17
17
  vite v5.4.14 building for production...
@@ -19,17 +19,17 @@ transforming...
19
19
  ✓ 88 modules transformed.
20
20
  rendering chunks...
21
21
  computing gzip size...
22
- dist/standalone/index.umd.cjs 370.53 kB │ gzip: 102.56 kB
23
- dist/standalone/index.js 496.65 kB │ gzip: 119.99 kB
24
- ✓ built in 1.71s
22
+ dist/standalone/index.umd.cjs 372.28 kB │ gzip: 103.21 kB
23
+ dist/standalone/index.js 499.57 kB │ gzip: 120.82 kB
24
+ ✓ built in 3.63s
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.64 check-exports
28
+ > @instantdb/platform@1.0.65 check-exports
29
29
  > attw --pack .
30
30
 
31
31
 
32
- @instantdb/platform v1.0.64
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;CACzD,CAAC;AAQF;;;;;;;;;;;;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,CAiQ/B"}
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
- const entries = (async function* () {
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
- currentEntity = f.name;
138
- tick();
139
- const url = await manager.getFileUrl(backup.id, f.name, { signal });
140
- let body;
141
- try {
142
- body = await fetchBody(url, signal);
143
- }
144
- catch (e) {
145
- if (isAbortError(e))
146
- throw e;
147
- throw new Error(`Failed to fetch ${f.name}: ${errorMessage(e)}.`);
148
- }
149
- yield {
150
- name: f.name,
151
- input: countBytes(body),
152
- onAdded: () => {
153
- if (f.name !== 'config.json')
154
- entitiesCompleted++;
155
- tick();
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 label = file.path || file.locationId;
177
- currentFile = label;
178
- tick();
179
- let body;
180
- try {
181
- body = await fetchBody(file.url, signal);
182
- }
183
- catch (e) {
184
- if (isAbortError(e))
185
- throw e;
186
- throw new Error(`Couldn't download storage file "${label}" (${errorMessage(e)}).`);
187
- }
188
- yield {
189
- name: `files/${file.locationId}`,
190
- input: countBytes(body),
191
- onAdded: () => {
192
- filesCompleted++;
193
- tick();
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();