@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instantdb/platform",
3
- "version": "1.0.64",
3
+ "version": "1.0.65",
4
4
  "description": "Instant's platform package for managing Instant apps.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/instantdb/instant/tree/main/client/packages/platform",
@@ -55,9 +55,9 @@
55
55
  "dependencies": {
56
56
  "@babel/parser": "^8.0.0-beta.0",
57
57
  "@babel/types": "^8.0.0-beta.0",
58
- "@instantdb/core": "1.0.64",
59
- "@instantdb/version": "1.0.64",
60
- "@instantdb/webhooks": "1.0.64"
58
+ "@instantdb/core": "1.0.65",
59
+ "@instantdb/version": "1.0.65",
60
+ "@instantdb/webhooks": "1.0.65"
61
61
  },
62
62
  "scripts": {
63
63
  "test": "vitest",
@@ -76,14 +76,242 @@ export type DownloadBackupArchiveOpts = {
76
76
  ) => Promise<BackupArchiveWriter>;
77
77
  signal?: AbortSignal;
78
78
  onProgress?: (progress: BackupDownloadProgress) => void;
79
+ /**
80
+ * How many entries to fetch ahead of the one currently being written into
81
+ * the archive.
82
+ *
83
+ * Only fetch *initiation* is parallelised, the bodies are still written in
84
+ * order and consumed one at a time, so an in-flight prefetched body buffers
85
+ * only to its stream's high-water mark and memory stays bounded. Defaults to
86
+ * {@link DEFAULT_PREFETCH}.
87
+ */
88
+ prefetch?: number;
89
+ /**
90
+ * Retry policy for *opening* an entry's body (fetching its presigned URL and
91
+ * getting a live response). Prefetching keeps body connections open, paused,
92
+ * while earlier entries write, so a queued connection can be reset before we
93
+ * read it.
94
+ * `attempts` is the total number of tries (default {@link DEFAULT_FETCH_ATTEMPTS});
95
+ * `delayMs` is the base for exponential backoff (default {@link DEFAULT_RETRY_DELAY_MS}).
96
+ *
97
+ * This only covers failures before the writer starts consuming the body.
98
+ * Once bytes have been written into the archive entry there's no way to
99
+ * restart it without HTTP range/resume support, so a mid-stream failure
100
+ * still fails the download.
101
+ */
102
+ retry?: { attempts?: number; delayMs?: number };
79
103
  };
80
104
 
105
+ const DEFAULT_PREFETCH = 4;
106
+ const DEFAULT_FETCH_ATTEMPTS = 3;
107
+ const DEFAULT_RETRY_DELAY_MS = 500;
108
+ const MAX_RETRY_DELAY_MS = 5000;
109
+
110
+ // Normalize caller-supplied numeric options so NaN/Infinity/non-integers can't
111
+ // alter retry counts or pipeline bounds — fall back to the default instead.
112
+ const finitePositiveInt = (v: number | undefined, fallback: number): number =>
113
+ v != null && Number.isInteger(v) && v > 0 ? v : fallback;
114
+ const finiteNonNegative = (v: number | undefined, fallback: number): number =>
115
+ v != null && Number.isFinite(v) && v >= 0 ? v : fallback;
116
+
81
117
  const isAbortError = (e: unknown): boolean =>
82
118
  (e as { name?: string })?.name === 'AbortError';
83
119
 
84
120
  const errorMessage = (e: unknown): string =>
85
121
  e instanceof Error ? e.message : String(e);
86
122
 
123
+ // A cancellable sleep: resolves after `ms`, or rejects if the signal aborts
124
+ // first so backoff between retries doesn't outlive a cancelled download.
125
+ const delay = (ms: number, signal: AbortSignal): Promise<void> =>
126
+ new Promise<void>((resolve, reject) => {
127
+ const onAbort = () => {
128
+ clearTimeout(timer);
129
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
130
+ };
131
+ const timer = setTimeout(() => {
132
+ signal.removeEventListener('abort', onAbort);
133
+ resolve();
134
+ }, ms);
135
+ signal.addEventListener('abort', onAbort, { once: true });
136
+ });
137
+
138
+ // Re-emits an already-read first chunk, then streams the rest from `reader`.
139
+ // Past that first chunk read errors propagate to the consumer unchanged — by
140
+ // then bytes are in the archive entry and it can't be restarted.
141
+ function replayFrom(
142
+ first: ReadableStreamReadResult<Uint8Array>,
143
+ reader: ReadableStreamDefaultReader<Uint8Array>,
144
+ ): ReadableStream<Uint8Array> {
145
+ let replayed = false;
146
+ return new ReadableStream<Uint8Array>({
147
+ async pull(controller) {
148
+ if (!replayed) {
149
+ replayed = true;
150
+ if (first.done) {
151
+ controller.close();
152
+ return;
153
+ }
154
+ controller.enqueue(first.value);
155
+ return;
156
+ }
157
+ const { done, value } = await reader.read();
158
+ if (done) controller.close();
159
+ else controller.enqueue(value);
160
+ },
161
+ async cancel(reason) {
162
+ await reader.cancel(reason);
163
+ },
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Opens a body via `open`, retrying on transient failure with abortable
169
+ * exponential backoff. `open` is re-invoked from scratch each attempt, so an
170
+ * entity file re-mints its presigned URL. An abort propagates immediately; any
171
+ * other final failure is wrapped by `describe` into a user-facing message
172
+ * naming the entry.
173
+ *
174
+ * The first chunk is read inside the retry scope, so a body that connects but
175
+ * fails on its first read — the shape of a prefetched connection reset while it
176
+ * sat idle — is re-fetched too, since nothing has been written to the archive
177
+ * yet. Only failures once bytes are flowing are treated as unrecoverable.
178
+ */
179
+ async function openWithRetry(
180
+ open: () => Promise<ReadableStream<Uint8Array>>,
181
+ opts: {
182
+ signal: AbortSignal;
183
+ attempts: number;
184
+ delayMs: number;
185
+ describe: (e: unknown) => string;
186
+ },
187
+ ): Promise<ReadableStream<Uint8Array>> {
188
+ let lastError: unknown;
189
+ for (let attempt = 1; attempt <= opts.attempts; attempt++) {
190
+ opts.signal.throwIfAborted();
191
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
192
+ try {
193
+ const body = await open();
194
+ reader = body.getReader();
195
+ const first = await reader.read();
196
+ return replayFrom(first, reader);
197
+ } catch (e) {
198
+ // Release the failed connection before retrying (or giving up).
199
+ if (reader) reader.cancel().catch(() => {});
200
+ if (isAbortError(e)) throw e;
201
+ lastError = e;
202
+ if (attempt < opts.attempts) {
203
+ const backoff = Math.min(
204
+ opts.delayMs * 2 ** (attempt - 1),
205
+ MAX_RETRY_DELAY_MS,
206
+ );
207
+ await delay(backoff, opts.signal);
208
+ }
209
+ }
210
+ }
211
+ throw new Error(opts.describe(lastError));
212
+ }
213
+
214
+ /**
215
+ * One archive entry whose body fetch has already been started. `onWriting`
216
+ * runs when the encoder begins consuming it (so progress reflects the entry
217
+ * actually streaming, not one prefetched ahead); `onAdded` runs once it's
218
+ * fully written.
219
+ */
220
+ type PreparedEntry = {
221
+ name: string;
222
+ input: ReadableStream<Uint8Array>;
223
+ onWriting: () => void;
224
+ onAdded: () => void;
225
+ };
226
+
227
+ /**
228
+ * A thunk that starts fetching one entry (presigned URL + body) and resolves
229
+ * once the body stream is available — not once it's fully downloaded.
230
+ */
231
+ type EntryThunk = () => Promise<PreparedEntry>;
232
+
233
+ /**
234
+ * Wraps an ordered stream of entry thunks, keeping up to `lookahead` fetches
235
+ * in flight while yielding the prepared entries in their original order. A
236
+ * background producer pulls thunks and starts their fetches as space frees up;
237
+ * the consumer awaits each in turn. Preserves order and backpressure: at most
238
+ * `lookahead` bodies are ever in flight, and the producer parks when the
239
+ * pipeline is full or the source is waiting for more work.
240
+ */
241
+ async function* prefetchEntries(
242
+ thunks: AsyncIterable<EntryThunk>,
243
+ lookahead: number,
244
+ ): AsyncGenerator<PreparedEntry> {
245
+ const pipeline: Promise<PreparedEntry>[] = [];
246
+ const state: {
247
+ done: boolean;
248
+ producerError: unknown;
249
+ // Woken when the producer pushes an entry (or finishes).
250
+ onItem: (() => void) | null;
251
+ // Woken when the consumer frees a pipeline slot.
252
+ onSpace: (() => void) | null;
253
+ } = { done: false, producerError: null, onItem: null, onSpace: null };
254
+
255
+ const wakeItem = () => {
256
+ const w = state.onItem;
257
+ state.onItem = null;
258
+ if (w) w();
259
+ };
260
+ const wakeSpace = () => {
261
+ const w = state.onSpace;
262
+ state.onSpace = null;
263
+ if (w) w();
264
+ };
265
+
266
+ // Never rejects: a failure to produce the next thunk (or start its fetch)
267
+ // lands in state.producerError for the consumer to throw in order.
268
+ const producer = (async () => {
269
+ try {
270
+ for await (const thunk of thunks) {
271
+ while (pipeline.length >= lookahead) {
272
+ await new Promise<void>((resolve) => {
273
+ state.onSpace = resolve;
274
+ });
275
+ }
276
+ const started = thunk();
277
+ // The consumer awaits `started` in order; attach a no-op catch so a
278
+ // fetch that rejects before then isn't reported as unhandled.
279
+ started.catch(() => {});
280
+ pipeline.push(started);
281
+ wakeItem();
282
+ }
283
+ } catch (e) {
284
+ state.producerError = e;
285
+ } finally {
286
+ state.done = true;
287
+ wakeItem();
288
+ }
289
+ })();
290
+
291
+ try {
292
+ while (true) {
293
+ if (pipeline.length === 0) {
294
+ if (state.done) {
295
+ if (state.producerError) throw state.producerError;
296
+ break;
297
+ }
298
+ await new Promise<void>((resolve) => {
299
+ state.onItem = resolve;
300
+ });
301
+ continue;
302
+ }
303
+ const entry = await pipeline.shift()!;
304
+ wakeSpace();
305
+ yield entry;
306
+ }
307
+ } finally {
308
+ // On early exit (abort/error), let the producer unwind — its own signal
309
+ // teardown resolves the source's waits — without blocking here.
310
+ wakeSpace();
311
+ producer.catch(() => {});
312
+ }
313
+ }
314
+
87
315
  /**
88
316
  * Downloads a backup into a single archive written to `opts.sink`: entries
89
317
  * in the canonical restore order (`config.json`, then the
@@ -132,6 +360,15 @@ export async function downloadBackupArchive(
132
360
  ? backup.uncompressedSize + (backup.filesSize ?? 0)
133
361
  : null;
134
362
 
363
+ const retryAttempts = finitePositiveInt(
364
+ opts.retry?.attempts,
365
+ DEFAULT_FETCH_ATTEMPTS,
366
+ );
367
+ const retryDelayMs = finiteNonNegative(
368
+ opts.retry?.delayMs,
369
+ DEFAULT_RETRY_DELAY_MS,
370
+ );
371
+
135
372
  const tick = () =>
136
373
  onProgress?.({
137
374
  entitiesCompleted,
@@ -221,16 +458,11 @@ export async function downloadBackupArchive(
221
458
  // Entry write order is significant for restore: config.json first, then the
222
459
  // entities/*.jsonl shards, then files/<locationId>. In particular ALL entity
223
460
  // files must be written before ANY storage file. listFiles returns the
224
- // entity files in write order; this generator yields them to completion,
225
- // then drains the storage queue.
226
- type ArchiveEntry = {
227
- name: string;
228
- input: ReadableStream<Uint8Array>;
229
- // Fired after the writer finishes consuming the entry, so completion
230
- // counters reflect fully-written files rather than started fetches.
231
- onAdded: () => void;
232
- };
233
- const entries = (async function* (): AsyncGenerator<ArchiveEntry> {
461
+ // entity files in write order; this generator yields thunks for them in
462
+ // order, then drains the storage queue. The thunks are consumed through
463
+ // prefetchEntries, which starts a bounded number of the fetches ahead of the
464
+ // encoder while keeping this order and one-at-a-time writing.
465
+ const thunks = (async function* (): AsyncGenerator<EntryThunk> {
234
466
  const files = await manager.listFiles(backup.id, { signal });
235
467
  if (files.length === 0) {
236
468
  throw new Error('No files found for this backup.');
@@ -248,27 +480,34 @@ export async function downloadBackupArchive(
248
480
  tick();
249
481
 
250
482
  for (const f of files) {
251
- currentEntity = f.name;
252
- tick();
253
- const url = await manager.getFileUrl(backup.id, f.name, { signal });
254
- let body: ReadableStream<Uint8Array>;
255
- try {
256
- body = await fetchBody(url, signal);
257
- } catch (e) {
258
- if (isAbortError(e)) throw e;
259
- throw new Error(`Failed to fetch ${f.name}: ${errorMessage(e)}.`);
260
- }
261
- yield {
262
- name: f.name,
263
- input: countBytes(body),
264
- onAdded: () => {
265
- if (f.name !== 'config.json') entitiesCompleted++;
266
- tick();
267
- },
483
+ yield async () => {
484
+ const body = await openWithRetry(
485
+ async () => {
486
+ const url = await manager.getFileUrl(backup.id, f.name, { signal });
487
+ return fetchBody(url, signal);
488
+ },
489
+ {
490
+ signal,
491
+ attempts: retryAttempts,
492
+ delayMs: retryDelayMs,
493
+ describe: (e) => `Failed to fetch ${f.name}: ${errorMessage(e)}.`,
494
+ },
495
+ );
496
+ return {
497
+ name: f.name,
498
+ input: countBytes(body),
499
+ onWriting: () => {
500
+ currentEntity = f.name;
501
+ currentFile = '';
502
+ tick();
503
+ },
504
+ onAdded: () => {
505
+ if (f.name !== 'config.json') entitiesCompleted++;
506
+ tick();
507
+ },
508
+ };
268
509
  };
269
510
  }
270
- currentEntity = '';
271
- tick();
272
511
 
273
512
  while (true) {
274
513
  if (storageError) throw storageError;
@@ -284,25 +523,32 @@ export async function downloadBackupArchive(
284
523
  queueHead++;
285
524
  }
286
525
  if (file) {
287
- const label = file.path || file.locationId;
288
- currentFile = label;
289
- tick();
290
- let body: ReadableStream<Uint8Array>;
291
- try {
292
- body = await fetchBody(file.url, signal);
293
- } catch (e) {
294
- if (isAbortError(e)) throw e;
295
- throw new Error(
296
- `Couldn't download storage file "${label}" (${errorMessage(e)}).`,
526
+ const storageFile = file;
527
+ const label = storageFile.path || storageFile.locationId;
528
+ yield async () => {
529
+ const body = await openWithRetry(
530
+ () => fetchBody(storageFile.url, signal),
531
+ {
532
+ signal,
533
+ attempts: retryAttempts,
534
+ delayMs: retryDelayMs,
535
+ describe: (e) =>
536
+ `Couldn't download storage file "${label}" (${errorMessage(e)}).`,
537
+ },
297
538
  );
298
- }
299
- yield {
300
- name: `files/${file.locationId}`,
301
- input: countBytes(body),
302
- onAdded: () => {
303
- filesCompleted++;
304
- tick();
305
- },
539
+ return {
540
+ name: `files/${storageFile.locationId}`,
541
+ input: countBytes(body),
542
+ onWriting: () => {
543
+ currentEntity = '';
544
+ currentFile = label;
545
+ tick();
546
+ },
547
+ onAdded: () => {
548
+ filesCompleted++;
549
+ tick();
550
+ },
551
+ };
306
552
  };
307
553
  } else if (storageDone) {
308
554
  break;
@@ -312,12 +558,13 @@ export async function downloadBackupArchive(
312
558
  });
313
559
  }
314
560
  }
315
- currentFile = '';
316
- tick();
317
561
 
318
562
  if (storageError) throw storageError;
319
563
  })();
320
564
 
565
+ const prefetch = finitePositiveInt(opts.prefetch, DEFAULT_PREFETCH);
566
+ const entries = prefetchEntries(thunks, prefetch);
567
+
321
568
  const sinkWriter = opts.sink.getWriter();
322
569
  try {
323
570
  // Sink the archive encoder writes into: it tallies the encoded size for
@@ -341,11 +588,15 @@ export async function downloadBackupArchive(
341
588
 
342
589
  const writer = await createWriter(countingSink, signal);
343
590
  for await (const entry of entries) {
591
+ entry.onWriting();
344
592
  await writer.add(entry.name, entry.input, {
345
593
  lastModDate: backup.backupAt,
346
594
  });
347
595
  entry.onAdded();
348
596
  }
597
+ currentEntity = '';
598
+ currentFile = '';
599
+ tick();
349
600
  // A caller abort that lands after the last entry lets the generator
350
601
  // finish cleanly; don't close and return a complete-looking archive.
351
602
  signal.throwIfAborted();