@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/src/backups.ts ADDED
@@ -0,0 +1,342 @@
1
+ import { InstantAPIError, version as coreVersion } from '@instantdb/core';
2
+ import type { WithAuth } from '@instantdb/webhooks';
3
+ import version from './version.ts';
4
+ import {
5
+ downloadBackupArchive,
6
+ type BackupDownloadResult,
7
+ type DownloadBackupArchiveOpts,
8
+ } from './backupDownload.ts';
9
+
10
+ /** A point-in-time snapshot of an app. */
11
+ export type AppBackup = {
12
+ /** Unique identifier for the backup. */
13
+ id: string;
14
+ /** Instant sequence number the snapshot was taken at. */
15
+ isn: string;
16
+ /** When the snapshot was taken. */
17
+ backupAt: Date;
18
+ /** Total size in bytes of the app's storage files at backup time, if known. */
19
+ filesSize: number | null;
20
+ /** Size in bytes of the app's database at backup time, if known. */
21
+ dbSize: number | null;
22
+ /**
23
+ * Total uncompressed size in bytes of the backup's entity files (what they
24
+ * take up unpacked on disk), if known.
25
+ */
26
+ uncompressedSize: number | null;
27
+ /** Human-readable label, e.g. "Automated Daily Snapshot". */
28
+ description: string | null;
29
+ /** When the backup stops being available for download. */
30
+ expiresAt: Date | null;
31
+ };
32
+
33
+ /**
34
+ * A file that makes up the backup payload: `config.json` or an
35
+ * `entities/<etype>.jsonl` shard.
36
+ */
37
+ export type AppBackupFile = {
38
+ name: string;
39
+ /** Size in bytes as stored (the entity shards are stored compressed). */
40
+ size: number;
41
+ };
42
+
43
+ /** A storage file captured in a backup, with a presigned download URL. */
44
+ export type AppBackupStorageFile = {
45
+ /**
46
+ * Stable id of the file's blob. A backup archive stores the blob at
47
+ * `files/<locationId>`.
48
+ */
49
+ locationId: string;
50
+ /** The path the user uploaded the file to. */
51
+ path: string | null;
52
+ /** Presigned URL for the file's contents. Expires after 12 hours. */
53
+ url: string;
54
+ };
55
+
56
+ type AppBackupResponse = {
57
+ id: string;
58
+ isn: string;
59
+ backup_at: string;
60
+ files_size: number | null;
61
+ db_size: number | null;
62
+ uncompressed_size: number | null;
63
+ description: string | null;
64
+ expires_at: string | null;
65
+ };
66
+
67
+ /** Converts a backup row as the server sends it into an {@link AppBackup}. */
68
+ export function toAppBackup(row: AppBackupResponse): AppBackup {
69
+ return {
70
+ id: row.id,
71
+ isn: row.isn,
72
+ backupAt: new Date(row.backup_at),
73
+ filesSize: row.files_size,
74
+ dbSize: row.db_size,
75
+ uncompressedSize: row.uncompressed_size,
76
+ description: row.description,
77
+ expiresAt: row.expires_at ? new Date(row.expires_at) : null,
78
+ };
79
+ }
80
+
81
+ /** Suggested filename for a backup archive. */
82
+ export function backupZipName(backup: AppBackup): string {
83
+ const safe = backup.backupAt.toISOString().replace(/[:.]/g, '-');
84
+ return `instant-backup-${safe}.zip`;
85
+ }
86
+
87
+ /**
88
+ * Formats a byte count the way macOS/Finder reports file sizes: decimal
89
+ * (1000-based) units with SI labels, so the number lines up with what lands
90
+ * on disk.
91
+ */
92
+ export function formatFileSize(n: number): string {
93
+ if (n < 1000) return `${n} B`;
94
+ const units = ['KB', 'MB', 'GB', 'TB'];
95
+ let i = -1;
96
+ let v = n;
97
+ do {
98
+ v /= 1000;
99
+ i++;
100
+ } while (v >= 1000 && i < units.length - 1);
101
+ let digits = v < 10 ? 2 : v < 100 ? 1 : 0;
102
+ // Rounding can cross a unit boundary (999,500 would read "1000 KB");
103
+ // promote to the next unit instead.
104
+ if (Number(v.toFixed(digits)) >= 1000 && i < units.length - 1) {
105
+ v /= 1000;
106
+ i++;
107
+ digits = 2;
108
+ }
109
+ return `${v.toFixed(digits)} ${units[i]}`;
110
+ }
111
+
112
+ /**
113
+ * Estimated size range for a backup's zip archive, or null when the backup
114
+ * row carries no sizes. Upper bound: everything stored uncompressed (STORE
115
+ * mode and/or files that don't compress). Lower bound: everything compressed
116
+ * at a ~4x DEFLATE ratio, best case for text/JSON, but storage files vary
117
+ * wildly (raw text compresses well, already-compressed images/videos don't).
118
+ * The same divisor applies to both since the file types aren't visible from
119
+ * here; the actual zip lands somewhere inside the range.
120
+ */
121
+ export function estimateZipSize(
122
+ backup: AppBackup,
123
+ ): { min: number; max: number } | null {
124
+ const backupBytes = backup.uncompressedSize ?? backup.dbSize;
125
+ if (backupBytes == null || backup.filesSize == null) return null;
126
+ const max = backupBytes + backup.filesSize;
127
+ return { min: Math.round(max / 4), max };
128
+ }
129
+
130
+ function authHeaders(token: string): Record<string, string> {
131
+ return {
132
+ authorization: `Bearer ${token}`,
133
+ 'Instant-Platform-Version': version,
134
+ 'Instant-Core-Version': coreVersion,
135
+ 'X-Instant-Source': 'platform-sdk',
136
+ 'X-Instant-Version': version,
137
+ };
138
+ }
139
+
140
+ async function apiError(res: Response): Promise<InstantAPIError> {
141
+ const body = await res.text();
142
+ try {
143
+ return new InstantAPIError({ status: res.status, body: JSON.parse(body) });
144
+ } catch (_e) {
145
+ return new InstantAPIError({
146
+ status: res.status,
147
+ body: { type: undefined, message: body },
148
+ });
149
+ }
150
+ }
151
+
152
+ async function* ndjsonLines(
153
+ body: ReadableStream<Uint8Array>,
154
+ ): AsyncGenerator<any, void, void> {
155
+ const reader = body.getReader();
156
+ try {
157
+ const decoder = new TextDecoder();
158
+ let buf = '';
159
+ while (true) {
160
+ const { done, value } = await reader.read();
161
+ if (done) break;
162
+ buf += decoder.decode(value, { stream: true });
163
+ let nl = buf.indexOf('\n');
164
+ while (nl !== -1) {
165
+ const line = buf.slice(0, nl).trim();
166
+ buf = buf.slice(nl + 1);
167
+ if (line.length > 0) {
168
+ yield JSON.parse(line);
169
+ }
170
+ nl = buf.indexOf('\n');
171
+ }
172
+ }
173
+ const line = buf.trim();
174
+ if (line.length > 0) {
175
+ yield JSON.parse(line);
176
+ }
177
+ } finally {
178
+ // Also releases the connection when the consumer stops iterating early.
179
+ await reader.cancel().catch(() => {});
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Read-only API for an app's backups.
185
+ *
186
+ * A backup archive has a canonical entry order that restore relies on:
187
+ * `config.json` first, then every `entities/<etype>.jsonl` shard, then the
188
+ * `files/<locationId>` storage blobs. In particular ALL entity files must
189
+ * come before ANY storage file, so a restore can process the archive in a
190
+ * single streaming pass: `config.json` sets up the schema, the `$files`
191
+ * entities register file metadata, and only then can each blob be matched
192
+ * to its entity. {@link downloadArchive} implements that order end to end;
193
+ * {@link listFiles} (which returns the entity files already in write order)
194
+ * and {@link streamStorageFiles} are the pieces for building a custom
195
+ * pipeline.
196
+ */
197
+ export class BackupsManager {
198
+ #appId: string;
199
+ #apiURI: string;
200
+ #withAuth: WithAuth;
201
+
202
+ constructor(opts: { appId: string; apiURI: string; withAuth: WithAuth }) {
203
+ this.#appId = opts.appId;
204
+ this.#apiURI = opts.apiURI;
205
+ this.#withAuth = opts.withAuth;
206
+ }
207
+
208
+ #getJson(path: string, signal?: AbortSignal): Promise<any> {
209
+ return this.#withAuth(async (token) => {
210
+ const res = await fetch(`${this.#apiURI}${path}`, {
211
+ headers: authHeaders(token),
212
+ signal,
213
+ });
214
+ if (res.status !== 200) {
215
+ throw await apiError(res);
216
+ }
217
+ return res.json();
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Returns the app's downloadable (non-expired) backups, newest first.
223
+ */
224
+ async list(opts?: { signal?: AbortSignal }): Promise<AppBackup[]> {
225
+ const res = await this.#getJson(
226
+ `/dash/apps/${this.#appId}/backups`,
227
+ opts?.signal,
228
+ );
229
+ return ((res.backups || []) as AppBackupResponse[]).map(toAppBackup);
230
+ }
231
+
232
+ /**
233
+ * Returns the backup's entity files (`config.json` and the
234
+ * `entities/<etype>.jsonl` shards) in archive write order. Storage blobs
235
+ * are not included; discover those with {@link streamStorageFiles}.
236
+ */
237
+ async listFiles(
238
+ backupId: string,
239
+ opts?: { signal?: AbortSignal },
240
+ ): Promise<AppBackupFile[]> {
241
+ const res = await this.#getJson(
242
+ `/dash/apps/${this.#appId}/backups/${backupId}/files`,
243
+ opts?.signal,
244
+ );
245
+ return (res.files || []) as AppBackupFile[];
246
+ }
247
+
248
+ /**
249
+ * Returns a presigned download URL for one of the backup's entity files.
250
+ * The URL expires after 1 hour, so fetch it right before downloading.
251
+ *
252
+ * The entity files are stored zstd-compressed (the response carries
253
+ * `Content-Encoding: zstd`); decompress to get the raw JSON/JSONL.
254
+ */
255
+ async getFileUrl(
256
+ backupId: string,
257
+ name: string,
258
+ opts?: { signal?: AbortSignal },
259
+ ): Promise<string> {
260
+ const res = await this.#getJson(
261
+ `/dash/apps/${this.#appId}/backups/${backupId}/file-url?name=${encodeURIComponent(name)}`,
262
+ opts?.signal,
263
+ );
264
+ return res.url as string;
265
+ }
266
+
267
+ /**
268
+ * Streams every storage file captured in the backup, each with a presigned
269
+ * download URL. Completes without yielding anything when the app has no
270
+ * storage files.
271
+ *
272
+ * The server ends a healthy stream with a terminal sentinel; if the stream
273
+ * closes without it (a server-side failure truncated the listing), this
274
+ * throws instead of silently under-reporting files.
275
+ */
276
+ async *streamStorageFiles(
277
+ backupId: string,
278
+ opts?: { signal?: AbortSignal },
279
+ ): AsyncGenerator<AppBackupStorageFile, void, void> {
280
+ const res = await this.#withAuth(async (token) => {
281
+ const res = await fetch(
282
+ `${this.#apiURI}/dash/apps/${this.#appId}/backups/${backupId}/storage-files`,
283
+ { headers: authHeaders(token), signal: opts?.signal },
284
+ );
285
+ if (res.status !== 200) {
286
+ throw await apiError(res);
287
+ }
288
+ return res;
289
+ });
290
+ if (!res.body) {
291
+ throw new Error('Storage file listing returned no body.');
292
+ }
293
+ let complete = false;
294
+ for await (const line of ndjsonLines(res.body)) {
295
+ if (line.done === true) {
296
+ complete = true;
297
+ break;
298
+ }
299
+ // A file record always carries a locationId and url; anything else is
300
+ // corruption, and skipping it would silently omit a file from the
301
+ // archive.
302
+ if (
303
+ typeof line.locationId !== 'string' ||
304
+ line.locationId.length === 0 ||
305
+ // The locationId becomes the archive entry path `files/<locationId>`;
306
+ // reject separators and control characters that could escape it.
307
+ /[/\\\u0000-\u001f\u007f]/.test(line.locationId) ||
308
+ line.locationId === '.' ||
309
+ line.locationId === '..' ||
310
+ typeof line.url !== 'string' ||
311
+ line.url.length === 0
312
+ ) {
313
+ throw new Error(
314
+ 'Storage file listing returned a malformed record. Please retry the download.',
315
+ );
316
+ }
317
+ yield {
318
+ locationId: line.locationId,
319
+ path: line.path ?? null,
320
+ url: line.url,
321
+ };
322
+ }
323
+ if (!complete) {
324
+ throw new Error(
325
+ 'Storage file listing ended before it finished. Please retry the download.',
326
+ );
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Downloads the backup into a single zip archive written to `opts.sink`,
332
+ * entries in the canonical restore order described above. The caller
333
+ * supplies the runtime-specific pieces — how to fetch a presigned URL,
334
+ * where the bytes go, and the archive encoder (e.g. zip.js's `ZipWriter`);
335
+ * see {@link DownloadBackupArchiveOpts}.
336
+ */
337
+ downloadArchive(
338
+ opts: DownloadBackupArchiveOpts,
339
+ ): Promise<BackupDownloadResult> {
340
+ return downloadBackupArchive({ manager: this, ...opts });
341
+ }
342
+ }
package/src/index.ts CHANGED
@@ -115,6 +115,24 @@ export {
115
115
  type Identifier,
116
116
  } from './migrations.ts';
117
117
 
118
+ export {
119
+ BackupsManager,
120
+ backupZipName,
121
+ estimateZipSize,
122
+ formatFileSize,
123
+ toAppBackup,
124
+ type AppBackup,
125
+ type AppBackupFile,
126
+ type AppBackupStorageFile,
127
+ } from './backups.ts';
128
+
129
+ export {
130
+ type DownloadBackupArchiveOpts,
131
+ type BackupArchiveWriter,
132
+ type BackupDownloadProgress,
133
+ type BackupDownloadResult,
134
+ } from './backupDownload.ts';
135
+
118
136
  export {
119
137
  DEFAULT_OAUTH_CALLBACK_URL,
120
138
  oauthCallbackURL,