@instantdb/platform 1.0.60 → 1.0.61

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/__tests__/src/backupDownload.test.ts +193 -0
  3. package/__tests__/src/backups.test.ts +168 -0
  4. package/dist/commonjs/api.d.ts +7 -0
  5. package/dist/commonjs/api.d.ts.map +1 -1
  6. package/dist/commonjs/api.js +10 -0
  7. package/dist/commonjs/api.js.map +1 -1
  8. package/dist/commonjs/backupDownload.d.ts +74 -0
  9. package/dist/commonjs/backupDownload.d.ts.map +1 -0
  10. package/dist/commonjs/backupDownload.js +255 -0
  11. package/dist/commonjs/backupDownload.js.map +1 -0
  12. package/dist/commonjs/backups.d.ts +146 -0
  13. package/dist/commonjs/backups.d.ts.map +1 -0
  14. package/dist/commonjs/backups.js +248 -0
  15. package/dist/commonjs/backups.js.map +1 -0
  16. package/dist/commonjs/index.d.ts +2 -0
  17. package/dist/commonjs/index.d.ts.map +1 -1
  18. package/dist/commonjs/index.js +7 -1
  19. package/dist/commonjs/index.js.map +1 -1
  20. package/dist/esm/api.d.ts +7 -0
  21. package/dist/esm/api.d.ts.map +1 -1
  22. package/dist/esm/api.js +10 -0
  23. package/dist/esm/api.js.map +1 -1
  24. package/dist/esm/backupDownload.d.ts +74 -0
  25. package/dist/esm/backupDownload.d.ts.map +1 -0
  26. package/dist/esm/backupDownload.js +252 -0
  27. package/dist/esm/backupDownload.js.map +1 -0
  28. package/dist/esm/backups.d.ts +146 -0
  29. package/dist/esm/backups.d.ts.map +1 -0
  30. package/dist/esm/backups.js +237 -0
  31. package/dist/esm/backups.js.map +1 -0
  32. package/dist/esm/index.d.ts +2 -0
  33. package/dist/esm/index.d.ts.map +1 -1
  34. package/dist/esm/index.js +1 -0
  35. package/dist/esm/index.js.map +1 -1
  36. package/dist/standalone/index.js +1929 -1610
  37. package/dist/standalone/index.umd.cjs +24 -22
  38. package/package.json +4 -4
  39. package/src/api.ts +15 -0
  40. package/src/backupDownload.ts +364 -0
  41. package/src/backups.ts +342 -0
  42. package/src/index.ts +18 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instantdb/platform",
3
- "version": "1.0.60",
3
+ "version": "1.0.61",
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.60",
59
- "@instantdb/version": "1.0.60",
60
- "@instantdb/webhooks": "1.0.60"
58
+ "@instantdb/core": "1.0.61",
59
+ "@instantdb/version": "1.0.61",
60
+ "@instantdb/webhooks": "1.0.61"
61
61
  },
62
62
  "scripts": {
63
63
  "test": "vitest",
package/src/api.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  DataAttrDef,
16
16
  } from '@instantdb/core';
17
17
  import { Webhooks, type WithAuth } from '@instantdb/webhooks';
18
+ import { BackupsManager } from './backups.ts';
18
19
  import version from './version.ts';
19
20
  import {
20
21
  attrFwdLabel,
@@ -1878,4 +1879,18 @@ export class PlatformApi {
1878
1879
  withAuth,
1879
1880
  });
1880
1881
  }
1882
+
1883
+ /**
1884
+ * Returns a {@link BackupsManager} scoped to `appId` for listing an app's
1885
+ * backups and downloading their contents. Calls are routed through
1886
+ * {@link withRetry}, so an expired access token is transparently refreshed.
1887
+ */
1888
+ backups(appId: string): BackupsManager {
1889
+ const withAuth: WithAuth = (operation) =>
1890
+ this.withRetry(
1891
+ (_apiURI: string, token: string) => operation(token),
1892
+ [this.#apiURI, this.token()],
1893
+ );
1894
+ return new BackupsManager({ appId, apiURI: this.#apiURI, withAuth });
1895
+ }
1881
1896
  }
@@ -0,0 +1,364 @@
1
+ import type {
2
+ AppBackup,
3
+ AppBackupStorageFile,
4
+ BackupsManager,
5
+ } from './backups.ts';
6
+
7
+ export type BackupDownloadProgress = {
8
+ entitiesCompleted: number;
9
+ entitiesTotal: number | null;
10
+ filesCompleted: number;
11
+ filesTotal: number | null;
12
+ // Compressed bytes written to the sink so far (the zip's on-disk size).
13
+ zipBytes: number;
14
+ // Uncompressed bytes read from source bodies, and the backup's known
15
+ // uncompressed total — the numerator/denominator for a progress bar.
16
+ // bytesTotal is null when the backup row carries no sizes.
17
+ bytesRead: number;
18
+ bytesTotal: number | null;
19
+ // The entry currently being fetched in each phase; empty while that phase
20
+ // isn't actively fetching, so a finished phase stops claiming a file.
21
+ currentEntity: string;
22
+ currentFile: string;
23
+ };
24
+
25
+ export type BackupDownloadResult = {
26
+ entities: number;
27
+ files: number;
28
+ zipBytes: number;
29
+ };
30
+
31
+ /**
32
+ * The archive encoder {@link downloadBackupArchive} writes entries through,
33
+ * supplied by the caller so this package doesn't depend on a zip
34
+ * implementation. zip.js's `ZipWriter` satisfies it structurally, so
35
+ * `new ZipWriter(sink, { zip64: true, signal })` works without an adapter.
36
+ *
37
+ * Implementations must handle archives past 4GB — for zip that means zip64,
38
+ * without which the central-directory offsets wrap and the archive is
39
+ * silently unreadable.
40
+ */
41
+ export type BackupArchiveWriter = {
42
+ add(
43
+ name: string,
44
+ input: ReadableStream<Uint8Array>,
45
+ opts: { lastModDate: Date },
46
+ ): Promise<unknown>;
47
+ close(): Promise<unknown>;
48
+ };
49
+
50
+ export type DownloadBackupArchiveOpts = {
51
+ backup: AppBackup;
52
+ /**
53
+ * Fetches a presigned URL, resolving with the response body and rejecting
54
+ * on a non-200 status. Put the status in the message (e.g. `HTTP 403`) —
55
+ * it's surfaced to the user alongside the failing entry's name. The entity
56
+ * files are served with `Content-Encoding: zstd`; browser fetch decodes
57
+ * that transparently, other runtimes must decompress explicitly.
58
+ */
59
+ fetchBody: (
60
+ url: string,
61
+ signal: AbortSignal,
62
+ ) => Promise<ReadableStream<Uint8Array>>;
63
+ /**
64
+ * Where the archive's bytes go. Closed after the last entry is written;
65
+ * aborted when the download fails or is cancelled, so the caller can
66
+ * discard partial output.
67
+ */
68
+ sink: WritableStream<Uint8Array>;
69
+ /**
70
+ * Builds the archive encoder over a sink that already counts progress and
71
+ * carries the caller's sink's backpressure.
72
+ */
73
+ createWriter: (
74
+ sink: WritableStream<Uint8Array>,
75
+ signal: AbortSignal,
76
+ ) => Promise<BackupArchiveWriter>;
77
+ signal?: AbortSignal;
78
+ onProgress?: (progress: BackupDownloadProgress) => void;
79
+ };
80
+
81
+ const isAbortError = (e: unknown): boolean =>
82
+ (e as { name?: string })?.name === 'AbortError';
83
+
84
+ const errorMessage = (e: unknown): string =>
85
+ e instanceof Error ? e.message : String(e);
86
+
87
+ /**
88
+ * Downloads a backup into a single archive written to `opts.sink`: entries
89
+ * in the canonical restore order (`config.json`, then the
90
+ * `entities/<etype>.jsonl` shards, then `files/<locationId>` storage blobs —
91
+ * all entity files before any storage file), with the encoder writing
92
+ * through a counting sink that awaits the caller's sink, so a fast source
93
+ * can't outrun it and balloon memory.
94
+ *
95
+ * The runtime-specific pieces are injected: how to fetch a presigned URL
96
+ * (`fetchBody`), where the bytes go (`sink`), and the archive encoder
97
+ * (`createWriter`). Most callers reach this via
98
+ * {@link BackupsManager.downloadArchive}.
99
+ */
100
+ export async function downloadBackupArchive(
101
+ opts: DownloadBackupArchiveOpts & {
102
+ manager: Pick<
103
+ BackupsManager,
104
+ 'listFiles' | 'getFileUrl' | 'streamStorageFiles'
105
+ >;
106
+ },
107
+ ): Promise<BackupDownloadResult> {
108
+ const { manager, backup, fetchBody, createWriter, onProgress } = opts;
109
+
110
+ // Internal controller so a pipeline failure also tears down the
111
+ // storage-files discovery stream and any in-flight body fetches.
112
+ const abortController = new AbortController();
113
+ if (opts.signal?.aborted) {
114
+ abortController.abort();
115
+ } else {
116
+ opts.signal?.addEventListener('abort', () => abortController.abort(), {
117
+ once: true,
118
+ });
119
+ }
120
+ const signal = abortController.signal;
121
+
122
+ let entitiesCompleted = 0;
123
+ let entitiesTotal: number | null = null;
124
+ let filesCompleted = 0;
125
+ let filesTotal: number | null = null;
126
+ let zipBytes = 0;
127
+ let bytesRead = 0;
128
+ let currentEntity = '';
129
+ let currentFile = '';
130
+ const bytesTotal =
131
+ backup.uncompressedSize != null
132
+ ? backup.uncompressedSize + (backup.filesSize ?? 0)
133
+ : null;
134
+
135
+ const tick = () =>
136
+ onProgress?.({
137
+ entitiesCompleted,
138
+ entitiesTotal,
139
+ filesCompleted,
140
+ filesTotal,
141
+ zipBytes,
142
+ bytesRead,
143
+ bytesTotal,
144
+ currentEntity,
145
+ currentFile,
146
+ });
147
+
148
+ // Throttle by time: a large backup pushes many small chunks and ticking on
149
+ // every one is wasted work. Phase changes tick() directly so they're still
150
+ // immediate.
151
+ const TICK_INTERVAL_MS = 100;
152
+ let lastTickAt = 0;
153
+ const throttledTick = () => {
154
+ const now = Date.now();
155
+ if (now - lastTickAt >= TICK_INTERVAL_MS) {
156
+ lastTickAt = now;
157
+ tick();
158
+ }
159
+ };
160
+
161
+ // Count the uncompressed bytes of a source body for progress as it streams
162
+ // into the archive.
163
+ const countBytes = (
164
+ body: ReadableStream<Uint8Array>,
165
+ ): ReadableStream<Uint8Array> =>
166
+ body.pipeThrough(
167
+ new TransformStream<Uint8Array, Uint8Array>({
168
+ transform(chunk, controller) {
169
+ bytesRead += chunk.byteLength;
170
+ throttledTick();
171
+ controller.enqueue(chunk);
172
+ },
173
+ }),
174
+ );
175
+
176
+ // Storage-files discovery runs concurrently with the entity phase and is
177
+ // drained eagerly into a queue. That isn't just overlap: it closes the
178
+ // NDJSON connection quickly instead of holding it open (and at the mercy of
179
+ // idle timeouts) while multi-GB blobs download. `queueHead` walks the array
180
+ // in place, freeing each slot as it's consumed.
181
+ const queue: (AppBackupStorageFile | undefined)[] = [];
182
+ let queueHead = 0;
183
+ let storageDone = false;
184
+ let storageError: Error | null = null;
185
+ let waitResolve: (() => void) | null = null;
186
+ const notify = () => {
187
+ const w = waitResolve;
188
+ waitResolve = null;
189
+ w?.();
190
+ };
191
+
192
+ // Never rejects: failures land in storageError for the drain loop to throw.
193
+ const discovery = (async () => {
194
+ let discoveryComplete = false;
195
+ try {
196
+ for await (const file of manager.streamStorageFiles(backup.id, {
197
+ signal,
198
+ })) {
199
+ queue.push(file);
200
+ filesTotal = (filesTotal ?? 0) + 1;
201
+ throttledTick();
202
+ notify();
203
+ }
204
+ discoveryComplete = true;
205
+ } catch (e) {
206
+ // The abort path is expected when the pipeline failed and we tore the
207
+ // discovery down.
208
+ if (!isAbortError(e)) {
209
+ storageError = e as Error;
210
+ }
211
+ } finally {
212
+ // A failed listing keeps the total unknown rather than reading as an
213
+ // empty-but-complete storage phase.
214
+ if (discoveryComplete && filesTotal == null) filesTotal = 0;
215
+ storageDone = true;
216
+ tick();
217
+ notify();
218
+ }
219
+ })();
220
+
221
+ // Entry write order is significant for restore: config.json first, then the
222
+ // entities/*.jsonl shards, then files/<locationId>. In particular ALL entity
223
+ // 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> {
234
+ const files = await manager.listFiles(backup.id, { signal });
235
+ if (files.length === 0) {
236
+ throw new Error('No files found for this backup.');
237
+ }
238
+ // We write entries in the order the server returns them, and restore
239
+ // requires config.json to be the first entry. Fail loudly rather than
240
+ // build a zip that can't be restored.
241
+ if (files[0].name !== 'config.json') {
242
+ throw new Error(
243
+ `Backup files came back in an unexpected order (expected config.json first, got "${files[0].name}").`,
244
+ );
245
+ }
246
+ // config.json isn't a namespace — count only the entities/*.jsonl shards.
247
+ entitiesTotal = files.filter((f) => f.name !== 'config.json').length;
248
+ tick();
249
+
250
+ 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
+ },
268
+ };
269
+ }
270
+ currentEntity = '';
271
+ tick();
272
+
273
+ while (true) {
274
+ if (storageError) throw storageError;
275
+ // A caller abort while no fetch is in flight surfaces only in the
276
+ // discovery stream, which swallows it as expected teardown — check
277
+ // explicitly so a cancellation can't read as a complete storage phase
278
+ // with files still undiscovered.
279
+ signal.throwIfAborted();
280
+ let file: AppBackupStorageFile | undefined;
281
+ if (queueHead < queue.length) {
282
+ file = queue[queueHead];
283
+ queue[queueHead] = undefined;
284
+ queueHead++;
285
+ }
286
+ 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)}).`,
297
+ );
298
+ }
299
+ yield {
300
+ name: `files/${file.locationId}`,
301
+ input: countBytes(body),
302
+ onAdded: () => {
303
+ filesCompleted++;
304
+ tick();
305
+ },
306
+ };
307
+ } else if (storageDone) {
308
+ break;
309
+ } else {
310
+ await new Promise<void>((resolve) => {
311
+ waitResolve = resolve;
312
+ });
313
+ }
314
+ }
315
+ currentFile = '';
316
+ tick();
317
+
318
+ if (storageError) throw storageError;
319
+ })();
320
+
321
+ const sinkWriter = opts.sink.getWriter();
322
+ try {
323
+ // Sink the archive encoder writes into: it tallies the encoded size for
324
+ // progress, then forwards to the caller's sink. Awaiting the downstream
325
+ // write propagates backpressure up into the encoder, so a fast source
326
+ // can't outrun the sink and balloon memory.
327
+ const countingSink = new WritableStream<Uint8Array>({
328
+ async write(chunk) {
329
+ zipBytes += chunk.byteLength;
330
+ throttledTick();
331
+ await sinkWriter.write(chunk);
332
+ },
333
+ async close() {
334
+ await sinkWriter.close();
335
+ tick();
336
+ },
337
+ async abort(reason) {
338
+ await sinkWriter.abort(reason);
339
+ },
340
+ });
341
+
342
+ const writer = await createWriter(countingSink, signal);
343
+ for await (const entry of entries) {
344
+ await writer.add(entry.name, entry.input, {
345
+ lastModDate: backup.backupAt,
346
+ });
347
+ entry.onAdded();
348
+ }
349
+ // A caller abort that lands after the last entry lets the generator
350
+ // finish cleanly; don't close and return a complete-looking archive.
351
+ signal.throwIfAborted();
352
+ await writer.close();
353
+ await discovery;
354
+ tick();
355
+ return { entities: entitiesCompleted, files: filesCompleted, zipBytes };
356
+ } catch (e) {
357
+ // Tear down the discovery stream and any in-flight body fetches so we
358
+ // don't keep pulling from S3, and abort the caller's sink so it can
359
+ // discard whatever it wrote.
360
+ abortController.abort();
361
+ await sinkWriter.abort(e).catch(() => {});
362
+ throw e;
363
+ }
364
+ }