instant-cli 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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__tests__/backupDownload.test.ts +206 -0
  3. package/__tests__/backups.test.ts +221 -0
  4. package/dist/commands/backup/download.d.ts +10 -0
  5. package/dist/commands/backup/download.d.ts.map +1 -0
  6. package/dist/commands/backup/download.js +174 -0
  7. package/dist/commands/backup/download.js.map +1 -0
  8. package/dist/commands/backup/list.d.ts +11 -0
  9. package/dist/commands/backup/list.d.ts.map +1 -0
  10. package/dist/commands/backup/list.js +74 -0
  11. package/dist/commands/backup/list.js.map +1 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +33 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/backupDownload.d.ts +21 -0
  17. package/dist/lib/backupDownload.d.ts.map +1 -0
  18. package/dist/lib/backupDownload.js +113 -0
  19. package/dist/lib/backupDownload.js.map +1 -0
  20. package/dist/lib/backups.d.ts +12 -0
  21. package/dist/lib/backups.d.ts.map +1 -0
  22. package/dist/lib/backups.js +26 -0
  23. package/dist/lib/backups.js.map +1 -0
  24. package/dist/lib/platformApi.d.ts +5 -0
  25. package/dist/lib/platformApi.d.ts.map +1 -0
  26. package/dist/lib/platformApi.js +11 -0
  27. package/dist/lib/platformApi.js.map +1 -0
  28. package/dist/lib/webhooks.d.ts +4 -5
  29. package/dist/lib/webhooks.d.ts.map +1 -1
  30. package/dist/lib/webhooks.js +2 -9
  31. package/dist/lib/webhooks.js.map +1 -1
  32. package/dist/ui/lib.d.ts +1 -1
  33. package/dist/ui/lib.d.ts.map +1 -1
  34. package/dist/ui/lib.js.map +1 -1
  35. package/package.json +5 -4
  36. package/src/commands/backup/download.ts +231 -0
  37. package/src/commands/backup/list.ts +94 -0
  38. package/src/index.ts +60 -0
  39. package/src/lib/backupDownload.ts +145 -0
  40. package/src/lib/backups.ts +33 -0
  41. package/src/lib/platformApi.ts +11 -0
  42. package/src/lib/webhooks.ts +1 -10
  43. package/src/ui/lib.ts +1 -1
@@ -1,4 +1,4 @@
1
1
 
2
- > instant-cli@1.0.60 build /home/runner/work/instant/instant/client/packages/cli
2
+ > instant-cli@1.0.61 build /home/runner/work/instant/instant/client/packages/cli
3
3
  > rm -rf dist; tsc -p tsconfig.build.json
4
4
 
@@ -0,0 +1,206 @@
1
+ import { test, expect, describe, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { createServer, type Server } from 'node:http';
3
+ import { existsSync, readdirSync } from 'node:fs';
4
+ import { readFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, join } from 'node:path';
7
+ import { once } from 'node:events';
8
+ import zlib from 'node:zlib';
9
+ import { BackupsManager } from '@instantdb/platform';
10
+ import { downloadBackupToFile } from '../src/lib/backupDownload.ts';
11
+
12
+ // Exercises the real pipeline end-to-end against a local HTTP server: zstd
13
+ // decompression of entity shards, canonical entry order (config.json, then
14
+ // entities/*.jsonl, then files/<locationId>), and the partial-file rename.
15
+
16
+ // Not yet in this @types/node version, same as createZstdDecompress in the
17
+ // pipeline itself.
18
+ const zstd = (s: string): Buffer =>
19
+ (zlib as any).zstdCompressSync(Buffer.from(s));
20
+
21
+ const bodies: Record<string, { body: Buffer; encoding?: string }> = {
22
+ '/config.json': { body: zstd('{"schema":{}}'), encoding: 'zstd' },
23
+ '/entities/todos.jsonl': {
24
+ body: zstd('{"entity":{"id":"1"}}\n'),
25
+ encoding: 'zstd',
26
+ },
27
+ '/entities/$files.jsonl': {
28
+ body: zstd('{"entity":{"location-id":"loc-1"}}\n'),
29
+ encoding: 'zstd',
30
+ },
31
+ '/blobs/loc-1': { body: Buffer.from('blob-one') },
32
+ '/blobs/loc-2': { body: Buffer.from('blob-two') },
33
+ };
34
+
35
+ let server: Server;
36
+ let baseUrl: string;
37
+
38
+ beforeAll(async () => {
39
+ server = createServer((req, res) => {
40
+ const found = bodies[req.url ?? ''];
41
+ if (!found) {
42
+ res.writeHead(404).end();
43
+ return;
44
+ }
45
+ const headers: Record<string, string> = {};
46
+ if (found.encoding) headers['content-encoding'] = found.encoding;
47
+ res.writeHead(200, headers).end(found.body);
48
+ });
49
+ server.listen(0);
50
+ await once(server, 'listening');
51
+ const address = server.address();
52
+ if (typeof address === 'string' || address === null) {
53
+ throw new Error('Expected a TCP address');
54
+ }
55
+ baseUrl = `http://127.0.0.1:${address.port}`;
56
+ });
57
+
58
+ afterAll(() => {
59
+ server.close();
60
+ });
61
+
62
+ const backup = {
63
+ id: 'backup-1',
64
+ isn: '1',
65
+ backupAt: new Date('2026-08-01T00:00:00Z'),
66
+ filesSize: 16,
67
+ dbSize: 100,
68
+ uncompressedSize: 40,
69
+ description: null,
70
+ expiresAt: new Date('2026-08-08T00:00:00Z'),
71
+ };
72
+
73
+ const storageFiles = [
74
+ { locationId: 'loc-1', path: 'a.png', url: () => `${baseUrl}/blobs/loc-1` },
75
+ { locationId: 'loc-2', path: 'b.png', url: () => `${baseUrl}/blobs/loc-2` },
76
+ ];
77
+
78
+ const manager = {
79
+ listFiles: async (_backupId: string) => [
80
+ { name: 'config.json', size: 10 },
81
+ { name: 'entities/todos.jsonl', size: 10 },
82
+ { name: 'entities/$files.jsonl', size: 10 },
83
+ ],
84
+ getFileUrl: async (_backupId: string, name: string) => `${baseUrl}/${name}`,
85
+ streamStorageFiles: async function* (
86
+ _backupId: string,
87
+ _opts?: { signal?: AbortSignal },
88
+ ) {
89
+ for (const f of storageFiles) {
90
+ yield { locationId: f.locationId, path: f.path, url: f.url() };
91
+ }
92
+ },
93
+ // Borrow the real method so the test drives the production pipeline
94
+ // against this fake's endpoints.
95
+ downloadArchive(opts: unknown) {
96
+ return (BackupsManager.prototype.downloadArchive as any).call(this, opts);
97
+ },
98
+ } as any;
99
+
100
+ const outPath = join(tmpdir(), `backup-download-test-${process.pid}.zip`);
101
+
102
+ // The partial file carries a random suffix, so scan for leftovers by prefix.
103
+ const partialLeftovers = () =>
104
+ readdirSync(tmpdir()).filter((f) =>
105
+ f.startsWith(`${basename(outPath)}.partial`),
106
+ );
107
+
108
+ afterEach(async () => {
109
+ await rm(outPath, { force: true });
110
+ for (const f of partialLeftovers()) {
111
+ await rm(join(tmpdir(), f), { force: true });
112
+ }
113
+ });
114
+
115
+ describe('downloadBackupToFile', () => {
116
+ test('writes a zip with the canonical entry order', async () => {
117
+ const result = await downloadBackupToFile({
118
+ manager,
119
+ backup,
120
+ outPath,
121
+ signal: new AbortController().signal,
122
+ onProgress: () => {},
123
+ });
124
+
125
+ expect(result.entities).toBe(2);
126
+ expect(result.files).toBe(2);
127
+ expect(partialLeftovers()).toEqual([]);
128
+
129
+ const { ZipReader, Uint8ArrayReader, TextWriter } = await import(
130
+ '@zip.js/zip.js'
131
+ );
132
+ const reader = new ZipReader(
133
+ new Uint8ArrayReader(new Uint8Array(await readFile(outPath))),
134
+ );
135
+ const entries = await reader.getEntries();
136
+
137
+ // Entry order is the restore contract: config first, all entity shards
138
+ // before any storage blob.
139
+ expect(entries.map((e) => e.filename)).toEqual([
140
+ 'config.json',
141
+ 'entities/todos.jsonl',
142
+ 'entities/$files.jsonl',
143
+ 'files/loc-1',
144
+ 'files/loc-2',
145
+ ]);
146
+
147
+ // Entity shards land decompressed; blobs land verbatim.
148
+ const readText = (entry: any) => entry.getData(new TextWriter());
149
+ expect(await readText(entries[0])).toBe('{"schema":{}}');
150
+ expect(await readText(entries[1])).toBe('{"entity":{"id":"1"}}\n');
151
+ expect(await readText(entries[3])).toBe('blob-one');
152
+ expect(await readText(entries[4])).toBe('blob-two');
153
+ await reader.close();
154
+ });
155
+
156
+ test('removes the partial file when a fetch fails', async () => {
157
+ const failingManager = {
158
+ ...manager,
159
+ getFileUrl: async (_backupId: string, name: string) =>
160
+ `${baseUrl}/missing-${name}`,
161
+ };
162
+
163
+ await expect(
164
+ downloadBackupToFile({
165
+ manager: failingManager,
166
+ backup,
167
+ outPath,
168
+ signal: new AbortController().signal,
169
+ onProgress: () => {},
170
+ }),
171
+ ).rejects.toThrow(/Failed to fetch config.json/);
172
+
173
+ expect(existsSync(outPath)).toBe(false);
174
+ expect(partialLeftovers()).toEqual([]);
175
+ });
176
+
177
+ test('aborting removes the partial file', async () => {
178
+ const controller = new AbortController();
179
+ const slowManager = {
180
+ ...manager,
181
+ streamStorageFiles: async function* () {
182
+ yield {
183
+ locationId: 'loc-1',
184
+ path: 'a.png',
185
+ url: `${baseUrl}/blobs/loc-1`,
186
+ };
187
+ controller.abort();
188
+ // Give the pipeline a moment to observe the abort mid-drain.
189
+ await new Promise((resolve) => setTimeout(resolve, 20));
190
+ },
191
+ };
192
+
193
+ await expect(
194
+ downloadBackupToFile({
195
+ manager: slowManager,
196
+ backup,
197
+ outPath,
198
+ signal: controller.signal,
199
+ onProgress: () => {},
200
+ }),
201
+ ).rejects.toThrow();
202
+
203
+ expect(existsSync(outPath)).toBe(false);
204
+ expect(partialLeftovers()).toEqual([]);
205
+ });
206
+ });
@@ -0,0 +1,221 @@
1
+ import { test, expect, describe, vi, beforeEach } from 'vitest';
2
+ import { join } from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import { Effect, Layer, Logger } from 'effect';
5
+ import { GlobalOpts } from '../src/context/globalOpts.ts';
6
+ import { AuthToken } from '../src/context/authToken.ts';
7
+ import { CurrentApp } from '../src/context/currentApp.ts';
8
+
9
+ vi.mock('../src/index.ts', () => ({}));
10
+
11
+ const state = vi.hoisted(() => ({
12
+ manager: undefined as any,
13
+ downloadResult: undefined as any,
14
+ downloadError: undefined as Error | undefined,
15
+ downloadCalls: [] as any[],
16
+ promptResponses: [] as unknown[],
17
+ }));
18
+
19
+ // Mock at the SDK boundary: any `new InstantPlatformApi(...)` returns a stub
20
+ // whose `.backups(appId)` is the per-test fake manager.
21
+ vi.mock('@instantdb/platform', async (importOriginal) => {
22
+ const orig: any = await importOriginal();
23
+ return {
24
+ ...orig,
25
+ PlatformApi: class {
26
+ backups(_appId: string) {
27
+ return state.manager;
28
+ }
29
+ },
30
+ };
31
+ });
32
+
33
+ // The download pipeline is network- and disk-heavy; the command tests only
34
+ // care that it's invoked with the right backup and destination.
35
+ vi.mock('../src/lib/backupDownload.ts', () => ({
36
+ downloadBackupToFile: vi.fn(async (opts: any) => {
37
+ state.downloadCalls.push(opts);
38
+ if (state.downloadError) throw state.downloadError;
39
+ return state.downloadResult;
40
+ }),
41
+ }));
42
+
43
+ vi.mock('../src/ui/lib.ts', async (importOriginal) => {
44
+ const orig: any = await importOriginal();
45
+ return {
46
+ ...orig,
47
+ renderUnwrap: () => {
48
+ if (state.promptResponses.length === 0) {
49
+ return Promise.reject(new Error('No prompt response queued'));
50
+ }
51
+ return Promise.resolve(state.promptResponses.shift());
52
+ },
53
+ };
54
+ });
55
+
56
+ const { backupListCmd } = await import('../src/commands/backup/list.ts');
57
+ const { backupDownloadCmd } = await import(
58
+ '../src/commands/backup/download.ts'
59
+ );
60
+
61
+ let logs: string[] = [];
62
+
63
+ const makeBackup = (overrides: any = {}) => ({
64
+ id: 'backup-1',
65
+ isn: '1',
66
+ backupAt: new Date('2026-08-01T00:00:00Z'),
67
+ filesSize: 1000,
68
+ dbSize: 2000,
69
+ uncompressedSize: 3000,
70
+ description: 'Automated Daily Snapshot',
71
+ expiresAt: new Date('2026-08-08T00:00:00Z'),
72
+ ...overrides,
73
+ });
74
+
75
+ const buildManager = (backups: any[]) => ({
76
+ list: vi.fn(async () => backups),
77
+ });
78
+
79
+ const outPath = () => join(tmpdir(), `backup-test-${Date.now()}.zip`);
80
+
81
+ const run = (effect: any, opts: { yes: boolean }) =>
82
+ Effect.runPromise(
83
+ effect.pipe(
84
+ Effect.provide(
85
+ Layer.mergeAll(
86
+ Layer.succeed(GlobalOpts, { yes: opts.yes }),
87
+ Layer.succeed(AuthToken, {
88
+ getAuthToken: Effect.succeed('test-token'),
89
+ getSource: Effect.succeed('env' as const),
90
+ setAuthToken: () => Effect.succeed(undefined),
91
+ }),
92
+ Layer.succeed(CurrentApp, {
93
+ appId: 'test-app',
94
+ source: 'env' as const,
95
+ }),
96
+ Logger.replace(
97
+ Logger.defaultLogger,
98
+ Logger.make(({ message }) => {
99
+ logs.push(String(message));
100
+ }),
101
+ ),
102
+ ),
103
+ ),
104
+ ),
105
+ );
106
+
107
+ beforeEach(() => {
108
+ logs = [];
109
+ state.manager = buildManager([]);
110
+ state.downloadResult = { entities: 3, files: 2, zipBytes: 1234 };
111
+ state.downloadError = undefined;
112
+ state.downloadCalls = [];
113
+ state.promptResponses = [];
114
+ });
115
+
116
+ describe('backup list', () => {
117
+ test('renders backups as a table', async () => {
118
+ state.manager = buildManager([makeBackup()]);
119
+ await run(backupListCmd({}), { yes: true });
120
+ const output = logs.join('\n');
121
+ expect(output).toContain('CREATED AT');
122
+ expect(output).toContain('EXPIRES AT');
123
+ expect(output).toContain('backup-1');
124
+ expect(output).toContain('Automated Daily Snapshot');
125
+ expect(output).toMatch(/\d+ (minutes?|hours?|days?) ago/);
126
+ });
127
+
128
+ test('outputs JSON with --json', async () => {
129
+ state.manager = buildManager([makeBackup()]);
130
+ await run(backupListCmd({ json: true }), { yes: true });
131
+ const parsed = JSON.parse(logs.join('\n'));
132
+ expect(parsed).toHaveLength(1);
133
+ expect(parsed[0].id).toBe('backup-1');
134
+ });
135
+
136
+ test('handles no backups', async () => {
137
+ await run(backupListCmd({}), { yes: true });
138
+ expect(logs.join('\n')).toContain('No backups yet.');
139
+ });
140
+ });
141
+
142
+ describe('backup download', () => {
143
+ test('downloads by id', async () => {
144
+ const backup = makeBackup();
145
+ state.manager = buildManager([backup]);
146
+ const out = outPath();
147
+ await run(backupDownloadCmd('backup-1', { out }), { yes: true });
148
+ expect(state.downloadCalls).toHaveLength(1);
149
+ expect(state.downloadCalls[0].backup).toEqual(backup);
150
+ expect(state.downloadCalls[0].outPath).toBe(out);
151
+ expect(logs.join('\n')).toContain('Saved 3 namespaces and 2 storage files');
152
+ });
153
+
154
+ test('rejects a backup id combined with --latest', async () => {
155
+ state.manager = buildManager([makeBackup()]);
156
+ await expect(
157
+ run(backupDownloadCmd('backup-1', { latest: true, out: outPath() }), {
158
+ yes: true,
159
+ }),
160
+ ).rejects.toThrow(/not both/);
161
+ expect(state.downloadCalls).toHaveLength(0);
162
+ });
163
+
164
+ test('rejects an output path that is a directory', async () => {
165
+ state.manager = buildManager([makeBackup()]);
166
+ await expect(
167
+ run(backupDownloadCmd('backup-1', { out: tmpdir() }), { yes: true }),
168
+ ).rejects.toThrow(/is a directory/);
169
+ expect(state.downloadCalls).toHaveLength(0);
170
+ });
171
+
172
+ test('errors on an unknown id', async () => {
173
+ state.manager = buildManager([makeBackup()]);
174
+ await expect(
175
+ run(backupDownloadCmd('nope', { out: outPath() }), { yes: true }),
176
+ ).rejects.toThrow(/No backup found with id nope/);
177
+ });
178
+
179
+ test('--latest picks the newest backup', async () => {
180
+ const older = makeBackup();
181
+ const newer = makeBackup({
182
+ id: 'backup-2',
183
+ backupAt: new Date('2026-08-02T00:00:00Z'),
184
+ });
185
+ state.manager = buildManager([older, newer]);
186
+ await run(backupDownloadCmd(undefined, { latest: true, out: outPath() }), {
187
+ yes: true,
188
+ });
189
+ expect(state.downloadCalls[0].backup.id).toBe('backup-2');
190
+ });
191
+
192
+ test('requires an id or --latest when prompts are skipped', async () => {
193
+ state.manager = buildManager([makeBackup()]);
194
+ await expect(
195
+ run(backupDownloadCmd(undefined, {}), { yes: true }),
196
+ ).rejects.toThrow(/Must specify a backup id or --latest/);
197
+ });
198
+
199
+ test('prompts for a backup and confirmation interactively', async () => {
200
+ const backup = makeBackup();
201
+ state.manager = buildManager([backup]);
202
+ // First the backup picker, then the download confirmation.
203
+ state.promptResponses = [backup, true];
204
+ await run(backupDownloadCmd(undefined, { out: outPath() }), {
205
+ yes: false,
206
+ });
207
+ expect(state.downloadCalls).toHaveLength(1);
208
+ expect(state.downloadCalls[0].backup).toEqual(backup);
209
+ });
210
+
211
+ test('reports a cancelled download', async () => {
212
+ state.manager = buildManager([makeBackup()]);
213
+ state.downloadError = Object.assign(new Error('aborted'), {
214
+ name: 'AbortError',
215
+ });
216
+ await run(backupDownloadCmd('backup-1', { out: outPath() }), {
217
+ yes: true,
218
+ });
219
+ expect(logs.join('\n')).toContain('Download cancelled.');
220
+ });
221
+ });
@@ -0,0 +1,10 @@
1
+ import { Effect } from 'effect';
2
+ import { BadArgsError } from '../../errors.ts';
3
+ import { GlobalOpts } from '../../context/globalOpts.ts';
4
+ import { PlatformApiError } from '../../context/platformApi.ts';
5
+ export declare const backupDownloadCmd: (backupId: string | undefined, opts: {
6
+ app?: string | undefined;
7
+ latest?: true | undefined;
8
+ out?: string | undefined;
9
+ }) => Effect.Effect<undefined, BadArgsError | import("../../lib/ui.ts").UIError | import("effect/Cause").UnknownException | import("effect/ConfigError").ConfigError | PlatformApiError, GlobalOpts | import("../../context/authToken.ts").AuthToken | import("../../context/currentApp.ts").CurrentApp>;
10
+ //# sourceMappingURL=download.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download.d.ts","sourceRoot":"","sources":["../../../src/commands/backup/download.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAShC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAyJhE,eAAO,MAAM,iBAAiB;;;;wSA+D5B,CAAC"}
@@ -0,0 +1,174 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { Effect } from 'effect';
5
+ import { backupZipName, estimateZipSize, formatFileSize, } from '@instantdb/platform';
6
+ import { BadArgsError } from "../../errors.js";
7
+ import { GlobalOpts } from "../../context/globalOpts.js";
8
+ import { PlatformApiError } from "../../context/platformApi.js";
9
+ import { buildBackupsManager, useBackupsManager } from "../../lib/backups.js";
10
+ import { downloadBackupToFile, } from "../../lib/backupDownload.js";
11
+ import { promptOk, runUIEffect } from "../../lib/ui.js";
12
+ import { onTerminate, renderUnwrap } from "../../ui/lib.js";
13
+ import { UI } from "../../ui/index.js";
14
+ import { relativeTime, renderBackupsTable, stripControlChars } from "./list.js";
15
+ const pickBackup = (backups, backupId, opts) => Effect.gen(function* () {
16
+ if (backupId && opts.latest) {
17
+ return yield* BadArgsError.make({
18
+ message: 'Pass either a backup id or --latest, not both.',
19
+ });
20
+ }
21
+ if (backupId) {
22
+ const found = backups.find((b) => b.id === backupId);
23
+ if (!found) {
24
+ return yield* BadArgsError.make({
25
+ message: `No backup found with id ${backupId}.`,
26
+ });
27
+ }
28
+ return found;
29
+ }
30
+ // The server returns newest first; sort anyway so --latest can't silently
31
+ // pick the wrong one.
32
+ const sorted = [...backups].sort((a, b) => b.backupAt.getTime() - a.backupAt.getTime());
33
+ if (opts.latest) {
34
+ return sorted[0];
35
+ }
36
+ const { yes } = yield* GlobalOpts;
37
+ if (yes) {
38
+ return yield* BadArgsError.make({
39
+ message: 'Must specify a backup id or --latest',
40
+ });
41
+ }
42
+ // The picker stays lean: id, age, and description are what you choose
43
+ // by, in the table's column order. Sizes and expiry show up in the
44
+ // confirm table right after, where headers explain them.
45
+ const cells = sorted.map((backup) => [
46
+ backup.id,
47
+ `created ${relativeTime(backup.backupAt)}`,
48
+ backup.description ? stripControlChars(backup.description) : '',
49
+ ]);
50
+ const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
51
+ return yield* runUIEffect(new UI.Select({
52
+ options: sorted.map((backup, idx) => ({
53
+ label: cells[idx].map((cell, i) => cell.padEnd(widths[i])).join(' '),
54
+ value: backup,
55
+ })),
56
+ promptText: 'Select a backup to download:',
57
+ }));
58
+ });
59
+ // One compact line for the spinner as the pipeline ticks.
60
+ function progressLine(p) {
61
+ const parts = [];
62
+ parts.push(p.entitiesTotal == null
63
+ ? 'listing namespaces…'
64
+ : `namespaces ${p.entitiesCompleted}/${p.entitiesTotal}`);
65
+ if (p.filesTotal !== 0) {
66
+ parts.push(p.filesTotal == null
67
+ ? 'listing storage files…'
68
+ : `storage files ${p.filesCompleted}/${p.filesTotal}`);
69
+ }
70
+ let bytes = formatFileSize(p.zipBytes);
71
+ if (p.bytesTotal != null && p.bytesTotal > 0) {
72
+ const pct = Math.min(100, Math.round((p.bytesRead / p.bytesTotal) * 100));
73
+ bytes += ` (${pct}%)`;
74
+ }
75
+ parts.push(bytes);
76
+ const currentEntry = p.currentEntity || p.currentFile;
77
+ if (currentEntry) {
78
+ parts.push(currentEntry);
79
+ }
80
+ let line = parts.join(' · ');
81
+ // The spinner prefixes a frame glyph; truncate so the line can't wrap.
82
+ const width = (process.stdout.columns || 80) - 4;
83
+ if (line.length > width) {
84
+ line = line.slice(0, Math.max(0, width - 1)) + '…';
85
+ }
86
+ return line;
87
+ }
88
+ // Returns null when the download was cancelled. While the spinner is
89
+ // attached the terminal is raw, so ctrl-c arrives through the UI's
90
+ // terminate hook rather than SIGINT; both routes abort the same controller
91
+ // and the pipeline removes its partial file before we return. Outside the
92
+ // spinner (non-TTY runs), SIGINT covers it.
93
+ async function runDownload(manager, backup, outPath) {
94
+ const controller = new AbortController();
95
+ const onSigint = () => controller.abort();
96
+ process.once('SIGINT', onSigint);
97
+ onTerminate(() => controller.abort());
98
+ try {
99
+ let spinner = null;
100
+ // Settled into a sentinel so the spinner always disappears cleanly and
101
+ // cancellation/error output stays with the command below.
102
+ const settled = downloadBackupToFile({
103
+ manager,
104
+ backup,
105
+ outPath,
106
+ signal: controller.signal,
107
+ onProgress: (p) => spinner?.updateText(progressLine(p)),
108
+ }).then((result) => ({ result, error: null }), (error) => ({ result: null, error }));
109
+ if (process.stdout.isTTY) {
110
+ spinner = new UI.Spinner({
111
+ promise: settled,
112
+ workingText: 'Preparing download…',
113
+ disappearWhenDone: true,
114
+ });
115
+ await renderUnwrap(spinner);
116
+ }
117
+ const { result, error } = await settled;
118
+ if (error) {
119
+ if (error?.name === 'AbortError')
120
+ return null;
121
+ throw error;
122
+ }
123
+ return result;
124
+ }
125
+ finally {
126
+ process.removeListener('SIGINT', onSigint);
127
+ onTerminate(undefined);
128
+ }
129
+ }
130
+ export const backupDownloadCmd = Effect.fn(function* (backupId, opts) {
131
+ const backups = yield* useBackupsManager((m) => m.list(), 'Error listing backups');
132
+ if (backups.length === 0) {
133
+ yield* Effect.log('No backups yet.');
134
+ return;
135
+ }
136
+ const backup = yield* pickBackup(backups, backupId, opts);
137
+ const outPath = path.resolve(opts.out ?? backupZipName(backup));
138
+ // Catch this before the download runs, not at the final rename.
139
+ if (existsSync(outPath) && statSync(outPath).isDirectory()) {
140
+ return yield* BadArgsError.make({
141
+ message: `${outPath} is a directory.`,
142
+ });
143
+ }
144
+ yield* renderBackupsTable([backup]);
145
+ const sizes = estimateZipSize(backup);
146
+ if (sizes) {
147
+ yield* Effect.log(`The zip file will be between ${formatFileSize(sizes.min)} and ${formatFileSize(sizes.max)}, depending on the compression ratio.`);
148
+ }
149
+ yield* Effect.log(chalk.dim(`Saving to ${outPath} (pass -o to change).`));
150
+ const ok = yield* promptOk({ promptText: 'Download this backup?' });
151
+ if (!ok)
152
+ return;
153
+ if (existsSync(outPath)) {
154
+ const overwrite = yield* promptOk({
155
+ promptText: `${path.basename(outPath)} already exists. Overwrite?`,
156
+ });
157
+ if (!overwrite)
158
+ return;
159
+ }
160
+ const manager = yield* buildBackupsManager;
161
+ const result = yield* Effect.tryPromise({
162
+ try: () => runDownload(manager, backup, outPath),
163
+ catch: (e) => new PlatformApiError({ message: 'Error downloading backup', cause: e }),
164
+ });
165
+ if (result === null) {
166
+ yield* Effect.log('Download cancelled.');
167
+ return;
168
+ }
169
+ const filesPart = result.files > 0
170
+ ? ` and ${result.files.toLocaleString()} storage files`
171
+ : '';
172
+ yield* Effect.log(`Saved ${result.entities.toLocaleString()} namespaces${filesPart} (${formatFileSize(result.zipBytes)})`);
173
+ });
174
+ //# sourceMappingURL=download.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download.js","sourceRoot":"","sources":["../../../src/commands/backup/download.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, statSync } from 'node:fs';\nimport path from 'node:path';\nimport chalk from 'chalk';\nimport { Effect } from 'effect';\nimport {\n backupZipName,\n estimateZipSize,\n formatFileSize,\n type AppBackup,\n type BackupsManager,\n} from '@instantdb/platform';\nimport type { backupDownloadDef, OptsFromCommand } from '../../index.ts';\nimport { BadArgsError } from '../../errors.ts';\nimport { GlobalOpts } from '../../context/globalOpts.ts';\nimport { PlatformApiError } from '../../context/platformApi.ts';\nimport { buildBackupsManager, useBackupsManager } from '../../lib/backups.ts';\nimport {\n downloadBackupToFile,\n type BackupDownloadProgress,\n type BackupDownloadResult,\n} from '../../lib/backupDownload.ts';\nimport { promptOk, runUIEffect } from '../../lib/ui.ts';\nimport { onTerminate, renderUnwrap } from '../../ui/lib.ts';\nimport { UI } from '../../ui/index.ts';\nimport { relativeTime, renderBackupsTable, stripControlChars } from './list.ts';\n\nconst pickBackup = (\n backups: AppBackup[],\n backupId: string | undefined,\n opts: { latest?: boolean },\n) =>\n Effect.gen(function* () {\n if (backupId && opts.latest) {\n return yield* BadArgsError.make({\n message: 'Pass either a backup id or --latest, not both.',\n });\n }\n if (backupId) {\n const found = backups.find((b) => b.id === backupId);\n if (!found) {\n return yield* BadArgsError.make({\n message: `No backup found with id ${backupId}.`,\n });\n }\n return found;\n }\n\n // The server returns newest first; sort anyway so --latest can't silently\n // pick the wrong one.\n const sorted = [...backups].sort(\n (a, b) => b.backupAt.getTime() - a.backupAt.getTime(),\n );\n if (opts.latest) {\n return sorted[0];\n }\n\n const { yes } = yield* GlobalOpts;\n if (yes) {\n return yield* BadArgsError.make({\n message: 'Must specify a backup id or --latest',\n });\n }\n\n // The picker stays lean: id, age, and description are what you choose\n // by, in the table's column order. Sizes and expiry show up in the\n // confirm table right after, where headers explain them.\n const cells = sorted.map((backup) => [\n backup.id,\n `created ${relativeTime(backup.backupAt)}`,\n backup.description ? stripControlChars(backup.description) : '',\n ]);\n const widths = cells[0].map((_, i) =>\n Math.max(...cells.map((row) => row[i].length)),\n );\n return yield* runUIEffect(\n new UI.Select({\n options: sorted.map((backup, idx) => ({\n label: cells[idx].map((cell, i) => cell.padEnd(widths[i])).join(' '),\n value: backup,\n })),\n promptText: 'Select a backup to download:',\n }),\n );\n });\n\n// One compact line for the spinner as the pipeline ticks.\nfunction progressLine(p: BackupDownloadProgress): string {\n const parts: string[] = [];\n parts.push(\n p.entitiesTotal == null\n ? 'listing namespaces…'\n : `namespaces ${p.entitiesCompleted}/${p.entitiesTotal}`,\n );\n if (p.filesTotal !== 0) {\n parts.push(\n p.filesTotal == null\n ? 'listing storage files…'\n : `storage files ${p.filesCompleted}/${p.filesTotal}`,\n );\n }\n let bytes = formatFileSize(p.zipBytes);\n if (p.bytesTotal != null && p.bytesTotal > 0) {\n const pct = Math.min(100, Math.round((p.bytesRead / p.bytesTotal) * 100));\n bytes += ` (${pct}%)`;\n }\n parts.push(bytes);\n const currentEntry = p.currentEntity || p.currentFile;\n if (currentEntry) {\n parts.push(currentEntry);\n }\n let line = parts.join(' · ');\n // The spinner prefixes a frame glyph; truncate so the line can't wrap.\n const width = (process.stdout.columns || 80) - 4;\n if (line.length > width) {\n line = line.slice(0, Math.max(0, width - 1)) + '…';\n }\n return line;\n}\n\n// Returns null when the download was cancelled. While the spinner is\n// attached the terminal is raw, so ctrl-c arrives through the UI's\n// terminate hook rather than SIGINT; both routes abort the same controller\n// and the pipeline removes its partial file before we return. Outside the\n// spinner (non-TTY runs), SIGINT covers it.\nasync function runDownload(\n manager: BackupsManager,\n backup: AppBackup,\n outPath: string,\n): Promise<BackupDownloadResult | null> {\n const controller = new AbortController();\n const onSigint = () => controller.abort();\n process.once('SIGINT', onSigint);\n onTerminate(() => controller.abort());\n try {\n let spinner: UI.Spinner<unknown> | null = null;\n // Settled into a sentinel so the spinner always disappears cleanly and\n // cancellation/error output stays with the command below.\n const settled = downloadBackupToFile({\n manager,\n backup,\n outPath,\n signal: controller.signal,\n onProgress: (p) => spinner?.updateText(progressLine(p)),\n }).then(\n (result) => ({ result, error: null as unknown }),\n (error: unknown) => ({ result: null, error }),\n );\n if (process.stdout.isTTY) {\n spinner = new UI.Spinner({\n promise: settled,\n workingText: 'Preparing download…',\n disappearWhenDone: true,\n });\n await renderUnwrap(spinner);\n }\n const { result, error } = await settled;\n if (error) {\n if ((error as { name?: string })?.name === 'AbortError') return null;\n throw error;\n }\n return result;\n } finally {\n process.removeListener('SIGINT', onSigint);\n onTerminate(undefined);\n }\n}\n\nexport const backupDownloadCmd = Effect.fn(function* (\n backupId: string | undefined,\n opts: OptsFromCommand<typeof backupDownloadDef>,\n) {\n const backups = yield* useBackupsManager(\n (m) => m.list(),\n 'Error listing backups',\n );\n\n if (backups.length === 0) {\n yield* Effect.log('No backups yet.');\n return;\n }\n\n const backup = yield* pickBackup(backups, backupId, opts);\n\n const outPath = path.resolve(opts.out ?? backupZipName(backup));\n // Catch this before the download runs, not at the final rename.\n if (existsSync(outPath) && statSync(outPath).isDirectory()) {\n return yield* BadArgsError.make({\n message: `${outPath} is a directory.`,\n });\n }\n\n yield* renderBackupsTable([backup]);\n const sizes = estimateZipSize(backup);\n if (sizes) {\n yield* Effect.log(\n `The zip file will be between ${formatFileSize(sizes.min)} and ${formatFileSize(sizes.max)}, depending on the compression ratio.`,\n );\n }\n yield* Effect.log(chalk.dim(`Saving to ${outPath} (pass -o to change).`));\n\n const ok = yield* promptOk({ promptText: 'Download this backup?' });\n if (!ok) return;\n\n if (existsSync(outPath)) {\n const overwrite = yield* promptOk({\n promptText: `${path.basename(outPath)} already exists. Overwrite?`,\n });\n if (!overwrite) return;\n }\n\n const manager = yield* buildBackupsManager;\n\n const result = yield* Effect.tryPromise({\n try: () => runDownload(manager, backup, outPath),\n catch: (e) =>\n new PlatformApiError({ message: 'Error downloading backup', cause: e }),\n });\n\n if (result === null) {\n yield* Effect.log('Download cancelled.');\n return;\n }\n\n const filesPart =\n result.files > 0\n ? ` and ${result.files.toLocaleString()} storage files`\n : '';\n yield* Effect.log(\n `Saved ${result.entities.toLocaleString()} namespaces${filesPart} (${formatFileSize(result.zipBytes)})`,\n );\n});\n"]}
@@ -0,0 +1,11 @@
1
+ import { Effect } from 'effect';
2
+ import { type AppBackup } from '@instantdb/platform';
3
+ export declare const formatBackupDate: (date: Date) => string;
4
+ export declare const stripControlChars: (s: string) => string;
5
+ export declare const relativeTime: (date: Date) => string;
6
+ export declare const renderBackupsTable: (backups: AppBackup[]) => Effect.Effect<void, never, never>;
7
+ export declare const backupListCmd: (opts: {
8
+ app?: string | undefined;
9
+ json?: true | undefined;
10
+ }) => Effect.Effect<void, import("../../errors.ts").BadArgsError | import("effect/Cause").UnknownException | import("effect/ConfigError").ConfigError | import("../../context/platformApi.ts").PlatformApiError, import("../../context/authToken.ts").AuthToken | import("../../context/currentApp.ts").CurrentApp>;
11
+ //# sourceMappingURL=list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../../src/commands/backup/list.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAIrE,eAAO,MAAM,gBAAgB,GAAI,MAAM,IAAI,WACiB,CAAC;AAI7D,eAAO,MAAM,iBAAiB,GAAI,GAAG,MAAM,WAA8B,CAAC;AAG1E,eAAO,MAAM,YAAY,GAAI,MAAM,IAAI,KAAG,MAezC,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAAI,SAAS,SAAS,EAAE,sCAmCnD,CAAC;AAEL,eAAO,MAAM,aAAa;;;mTAuBxB,CAAC"}