@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@instantdb/platform",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
59
|
-
"@instantdb/version": "1.0.
|
|
60
|
-
"@instantdb/webhooks": "1.0.
|
|
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",
|
package/src/backupDownload.ts
CHANGED
|
@@ -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
|
|
225
|
-
// then drains the storage queue.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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();
|