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
@@ -0,0 +1,94 @@
1
+ import chalk from 'chalk';
2
+ import { Effect } from 'effect';
3
+ import { formatFileSize, type AppBackup } from '@instantdb/platform';
4
+ import type { backupListDef, OptsFromCommand } from '../../index.ts';
5
+ import { useBackupsManager } from '../../lib/backups.ts';
6
+
7
+ export const formatBackupDate = (date: Date) =>
8
+ `${date.toISOString().replace('T', ' ').slice(0, 16)} UTC`;
9
+
10
+ // Backup descriptions are user-controlled text headed for the terminal;
11
+ // strip control characters so a crafted value can't inject escape sequences.
12
+ export const stripControlChars = (s: string) => s.replace(/\p{Cc}/gu, '');
13
+
14
+ // Relative times in both directions: "3 hours ago", "6 days from now".
15
+ export const relativeTime = (date: Date): string => {
16
+ const diffMs = date.getTime() - Date.now();
17
+ const abs = Math.abs(diffMs);
18
+ if (abs < 60_000) return diffMs <= 0 ? 'just now' : 'now';
19
+ const minutes = Math.round(abs / 60_000);
20
+ const hours = Math.round(abs / 3_600_000);
21
+ const days = Math.round(abs / 86_400_000);
22
+ const [count, unit] =
23
+ minutes < 60
24
+ ? [minutes, 'minute']
25
+ : hours < 24
26
+ ? [hours, 'hour']
27
+ : [days, 'day'];
28
+ const label = `${count} ${unit}${count === 1 ? '' : 's'}`;
29
+ return diffMs < 0 ? `${label} ago` : `${label} from now`;
30
+ };
31
+
32
+ // One aligned row per backup, newest first: id first, relative times.
33
+ // `--json` carries the precise values.
34
+ export const renderBackupsTable = (backups: AppBackup[]) =>
35
+ Effect.gen(function* () {
36
+ const header = [
37
+ 'ID',
38
+ 'CREATED AT',
39
+ 'DB SIZE',
40
+ 'STORAGE',
41
+ 'EXPIRES AT',
42
+ 'DESCRIPTION',
43
+ ];
44
+ const rows = backups.map((backup) => [
45
+ backup.id,
46
+ relativeTime(backup.backupAt),
47
+ backup.dbSize != null ? formatFileSize(backup.dbSize) : '-',
48
+ backup.filesSize != null ? formatFileSize(backup.filesSize) : '-',
49
+ backup.expiresAt
50
+ ? backup.expiresAt.getTime() <= Date.now()
51
+ ? 'expired'
52
+ : relativeTime(backup.expiresAt)
53
+ : '-',
54
+ backup.description ? stripControlChars(backup.description) : '',
55
+ ]);
56
+ const widths = header.map((h, i) =>
57
+ Math.max(h.length, ...rows.map((row) => row[i].length)),
58
+ );
59
+ const line = (cells: string[]) =>
60
+ cells
61
+ .map((cell, i) => cell.padEnd(widths[i]))
62
+ .join(' ')
63
+ .trimEnd();
64
+ yield* Effect.log(chalk.dim(line(header)));
65
+ yield* Effect.log(chalk.dim(widths.map((w) => '-'.repeat(w)).join(' ')));
66
+ for (const row of rows) {
67
+ yield* Effect.log(line(row));
68
+ }
69
+ });
70
+
71
+ export const backupListCmd = Effect.fn(function* (
72
+ opts: OptsFromCommand<typeof backupListDef>,
73
+ ) {
74
+ const backups = yield* useBackupsManager(
75
+ (m) => m.list(),
76
+ 'Error listing backups',
77
+ );
78
+
79
+ if (opts.json) {
80
+ yield* Effect.log(JSON.stringify(backups, null, 2));
81
+ return;
82
+ }
83
+
84
+ if (backups.length === 0) {
85
+ yield* Effect.log('No backups yet.');
86
+ return;
87
+ }
88
+
89
+ // The server returns newest first; sort anyway so the table can't lie.
90
+ const sorted = [...backups].sort(
91
+ (a, b) => b.backupAt.getTime() - a.backupAt.getTime(),
92
+ );
93
+ yield* renderBackupsTable(sorted);
94
+ });
package/src/index.ts CHANGED
@@ -51,6 +51,8 @@ import { webhooksEventsResendCmd } from './commands/webhooks/events/resend.ts';
51
51
  import { emailStatusCmd } from './commands/auth/email/status.ts';
52
52
  import { verifyCmd } from './commands/auth/email/verify.ts';
53
53
  import { resendEmailCmd } from './commands/auth/email/resend.ts';
54
+ import { backupListCmd } from './commands/backup/list.ts';
55
+ import { backupDownloadCmd } from './commands/backup/download.ts';
54
56
 
55
57
  export type OptsFromCommand<C> =
56
58
  C extends Command<any, infer R, any> ? R : never;
@@ -640,6 +642,64 @@ export const webhooksEventsPayloadDef = webhooksEvents
640
642
  );
641
643
  });
642
644
 
645
+ const backup = program
646
+ .command('backup')
647
+ .description('View and download backups of your app');
648
+
649
+ export const backupListDef = backup
650
+ .command('list')
651
+ .description('List downloadable backups for an app')
652
+ .option(
653
+ '-a --app <app-id>',
654
+ 'App ID to list backups for. Defaults to *_INSTANT_APP_ID in .env',
655
+ )
656
+ .option('--json', 'Output backups as JSON')
657
+ .action((opts) => {
658
+ return runCommandEffect(
659
+ backupListCmd(opts).pipe(
660
+ Effect.provide(
661
+ WithAppLayer({
662
+ coerce: false,
663
+ coerceAuth: false,
664
+ appId: opts.app,
665
+ allowAdminToken: true,
666
+ }).pipe(Layer.annotateLogs('silent', !!opts.json)),
667
+ ),
668
+ ),
669
+ );
670
+ });
671
+
672
+ export const backupDownloadDef = backup
673
+ .command('download')
674
+ .description('Download a backup as a zip file')
675
+ .argument(
676
+ '[backup-id]',
677
+ 'Backup ID to download. Defaults to an interactive picker',
678
+ )
679
+ .option(
680
+ '-a --app <app-id>',
681
+ 'App ID to download a backup of. Defaults to *_INSTANT_APP_ID in .env',
682
+ )
683
+ .option('--latest', 'Download the most recent backup')
684
+ .option(
685
+ '-o --out <path>',
686
+ 'Output zip path. Defaults to instant-backup-<timestamp>.zip',
687
+ )
688
+ .action((backupId, opts) => {
689
+ return runCommandEffect(
690
+ backupDownloadCmd(backupId, opts).pipe(
691
+ Effect.provide(
692
+ WithAppLayer({
693
+ coerce: false,
694
+ coerceAuth: false,
695
+ appId: opts.app,
696
+ allowAdminToken: true,
697
+ }),
698
+ ),
699
+ ),
700
+ );
701
+ });
702
+
643
703
  const authEmail = auth
644
704
  .command('email')
645
705
  .description('Manage custom magic code email templates');
@@ -0,0 +1,145 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { open, rename, unlink } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { once } from 'node:events';
5
+ import { get as httpGet, type IncomingMessage } from 'node:http';
6
+ import { get as httpsGet } from 'node:https';
7
+ import { Readable, Writable, type Duplex } from 'node:stream';
8
+ import zlib from 'node:zlib';
9
+ import type {
10
+ AppBackup,
11
+ BackupArchiveWriter,
12
+ BackupDownloadProgress,
13
+ BackupDownloadResult,
14
+ BackupsManager,
15
+ } from '@instantdb/platform';
16
+
17
+ export type {
18
+ BackupDownloadProgress,
19
+ BackupDownloadResult,
20
+ } from '@instantdb/platform';
21
+
22
+ // zstd landed in node:zlib in 22.15 / 23.8; on older Nodes the property is
23
+ // absent, so feature-detect instead of assuming the type declarations match
24
+ // the runtime.
25
+ const createZstdDecompress: (() => Duplex) | undefined = (
26
+ zlib as { createZstdDecompress?: () => Duplex }
27
+ ).createZstdDecompress;
28
+
29
+ function fetchStream(
30
+ url: string,
31
+ signal: AbortSignal,
32
+ ): Promise<IncomingMessage> {
33
+ return new Promise((resolve, reject) => {
34
+ const get = url.startsWith('https:') ? httpsGet : httpGet;
35
+ const req = get(url, { signal }, resolve);
36
+ req.on('error', reject);
37
+ });
38
+ }
39
+
40
+ // .pipe doesn't forward errors, so a source failure would otherwise leave the
41
+ // destination (and the zip writer reading from it) hanging forever.
42
+ function pipe(src: Readable, dst: Duplex): Duplex {
43
+ src.on('error', (e) => dst.destroy(e));
44
+ return src.pipe(dst);
45
+ }
46
+
47
+ // Fetches a presigned URL with node:http(s), decompressing explicitly: the
48
+ // entity shards are served with `Content-Encoding: zstd` and Node doesn't
49
+ // auto-decompress that. downloadBackupToFile refuses to run without zstd
50
+ // support, so the assertion below can't fire.
51
+ async function fetchBody(
52
+ url: string,
53
+ signal: AbortSignal,
54
+ ): Promise<ReadableStream<Uint8Array>> {
55
+ const res = await fetchStream(url, signal);
56
+ if (res.statusCode !== 200) {
57
+ res.resume();
58
+ throw new Error(`HTTP ${res.statusCode}`);
59
+ }
60
+ const encoding = res.headers['content-encoding'];
61
+ let stream: Readable = res;
62
+ if (encoding === 'zstd') {
63
+ stream = pipe(stream, createZstdDecompress!());
64
+ } else if (encoding === 'gzip') {
65
+ stream = pipe(stream, zlib.createGunzip());
66
+ } else if (encoding) {
67
+ res.destroy();
68
+ throw new Error(`Unsupported content encoding: ${encoding}`);
69
+ }
70
+ return Readable.toWeb(stream) as ReadableStream<Uint8Array>;
71
+ }
72
+
73
+ async function createZipWriter(
74
+ sink: WritableStream<Uint8Array>,
75
+ signal: AbortSignal,
76
+ ): Promise<BackupArchiveWriter> {
77
+ // Loaded on demand so every other CLI command skips parsing it.
78
+ const { ZipWriter } = await import('@zip.js/zip.js');
79
+ // zip64: without it any archive whose central-directory offset passes 4GB
80
+ // writes a wrapped 32-bit offset and the zip is unreadable.
81
+ return new ZipWriter(sink, { zip64: true, signal });
82
+ }
83
+
84
+ /**
85
+ * Downloads a backup into a zip file at `outPath` via the shared
86
+ * `BackupsManager.downloadArchive` pipeline, supplying the Node-specific
87
+ * pieces:
88
+ * presigned URLs are fetched with node:http(s) and decompressed explicitly,
89
+ * and the archive streams to disk with backpressure so memory stays flat
90
+ * regardless of backup size.
91
+ *
92
+ * Writes to `<outPath>.partial` and renames on success; a failed or aborted
93
+ * download removes the partial file.
94
+ */
95
+ export async function downloadBackupToFile(opts: {
96
+ manager: BackupsManager;
97
+ backup: AppBackup;
98
+ outPath: string;
99
+ signal: AbortSignal;
100
+ onProgress: (progress: BackupDownloadProgress) => void;
101
+ }): Promise<BackupDownloadResult> {
102
+ // The entity shards are served with `Content-Encoding: zstd`; fail before
103
+ // writing anything if this Node can't decompress them.
104
+ if (!createZstdDecompress) {
105
+ throw new Error(
106
+ 'Downloading backups requires Node 22.15 or newer (for zstd support).',
107
+ );
108
+ }
109
+
110
+ // Randomized so a stale partial or a concurrent download of the same
111
+ // backup can't collide; 'wx' turns any remaining collision into an error
112
+ // instead of silently truncating another run's file.
113
+ const partialPath = `${opts.outPath}.partial-${randomBytes(4).toString('hex')}`;
114
+ const fileStream = createWriteStream(partialPath, { flags: 'wx' });
115
+ const awaitFileClosed = async () => {
116
+ if (!fileStream.closed) await once(fileStream, 'close');
117
+ };
118
+ try {
119
+ const result = await opts.manager.downloadArchive({
120
+ backup: opts.backup,
121
+ fetchBody,
122
+ sink: Writable.toWeb(fileStream) as WritableStream<Uint8Array>,
123
+ createWriter: createZipWriter,
124
+ signal: opts.signal,
125
+ onProgress: opts.onProgress,
126
+ });
127
+ await awaitFileClosed();
128
+ // Flush to disk before the rename so a crash right after can't leave a
129
+ // complete-looking zip with unwritten tails.
130
+ const fh = await open(partialPath, 'r+');
131
+ try {
132
+ await fh.sync();
133
+ } finally {
134
+ await fh.close();
135
+ }
136
+ await rename(partialPath, opts.outPath);
137
+ return result;
138
+ } catch (e) {
139
+ // The pipeline already aborted the sink; wait for the fd to close, then
140
+ // discard the partial file on disk.
141
+ await awaitFileClosed().catch(() => {});
142
+ await unlink(partialPath).catch(() => {});
143
+ throw e;
144
+ }
145
+ }
@@ -0,0 +1,33 @@
1
+ import { Effect } from 'effect';
2
+ import type { BackupsManager } from '@instantdb/platform';
3
+ import { CurrentApp } from '../context/currentApp.ts';
4
+ import { PlatformApiError } from '../context/platformApi.ts';
5
+ import { getAuthedPlatformApi } from './platformApi.ts';
6
+
7
+ export const useBackupsManager = <R>(
8
+ fun: (manager: BackupsManager) => Promise<R>,
9
+ errorMessage?: string,
10
+ ) =>
11
+ Effect.gen(function* () {
12
+ const api = yield* getAuthedPlatformApi;
13
+ const { appId } = yield* CurrentApp;
14
+ return yield* Effect.tryPromise({
15
+ try: () => fun(api.backups(appId)),
16
+ catch: (e) =>
17
+ new PlatformApiError({
18
+ message: errorMessage ?? 'Error using backups api',
19
+ cause: e,
20
+ }),
21
+ });
22
+ });
23
+
24
+ /**
25
+ * Yields a `BackupsManager` instance scoped to the current app. Use when you
26
+ * need to hold on to the manager outside an Effect (e.g. to drive the
27
+ * long-running download pipeline).
28
+ */
29
+ export const buildBackupsManager = Effect.gen(function* () {
30
+ const api = yield* getAuthedPlatformApi;
31
+ const { appId } = yield* CurrentApp;
32
+ return api.backups(appId);
33
+ });
@@ -0,0 +1,11 @@
1
+ import { Effect } from 'effect';
2
+ import { PlatformApi as InstantPlatformApi } from '@instantdb/platform';
3
+ import { AuthToken } from '../context/authToken.ts';
4
+ import { getBaseUrl } from './config.ts';
5
+
6
+ export const getAuthedPlatformApi = Effect.gen(function* () {
7
+ const apiURI = yield* getBaseUrl;
8
+ const authToken = yield* AuthToken;
9
+ const token = yield* authToken.getAuthToken;
10
+ return new InstantPlatformApi({ apiURI, auth: { token } });
11
+ });
@@ -1,22 +1,13 @@
1
1
  import { Effect } from 'effect';
2
2
  import {
3
- PlatformApi as InstantPlatformApi,
4
3
  type WebhookAction,
5
4
  type WebhookEventInfo,
6
5
  type WebhooksManager,
7
6
  } from '@instantdb/platform';
8
- import { AuthToken } from '../context/authToken.ts';
9
7
  import { CurrentApp } from '../context/currentApp.ts';
10
8
  import { PlatformApiError } from '../context/platformApi.ts';
11
9
  import { BadArgsError } from '../errors.ts';
12
- import { getBaseUrl } from './http.ts';
13
-
14
- const getAuthedPlatformApi = Effect.gen(function* () {
15
- const apiURI = yield* getBaseUrl;
16
- const authToken = yield* AuthToken;
17
- const token = yield* authToken.getAuthToken;
18
- return new InstantPlatformApi({ apiURI, auth: { token } });
19
- });
10
+ import { getAuthedPlatformApi } from './platformApi.ts';
20
11
 
21
12
  export const WEBHOOK_ACTIONS: readonly WebhookAction[] = [
22
13
  'create',
package/src/ui/lib.ts CHANGED
@@ -372,7 +372,7 @@ let terminateHandler:
372
372
  | undefined;
373
373
 
374
374
  export function onTerminate(
375
- callback: (stdin: ReadStream, stdout: WriteStream) => void | undefined,
375
+ callback: ((stdin: ReadStream, stdout: WriteStream) => void) | undefined,
376
376
  ) {
377
377
  terminateHandler = callback;
378
378
  }