import.ts220 lines · main
1import { spawn } from 'node:child_process';
2import { readFile, stat } from 'node:fs/promises';
3import { resolve } from 'node:path';
4
5import { apiCall, ApiCallError } from '../api-client.js';
6import { readCredentials } from '../config.js';
7import { readProjectConfig } from '../project-config.js';
8import { banner, blankLine, error as printError, step, success } from '../output.js';
9
10interface ExportPayload {
11 manifest: {
12 version: number;
13 sourceProjectId: string;
14 sourceProjectName: string;
15 sourceDeploymentId: string;
16 exportedAt: string;
17 };
18 schema: Record<string, unknown> | null;
19 functions: Record<string, string>;
20}
21
22interface DeploymentResponse {
23 deployment: {
24 id: string;
25 status: string;
26 createdAt: string;
27 };
28}
29
30interface ShellTokenResponse {
31 dsn: string;
32 role: string;
33 expiresAt: string;
34}
35
36interface Args {
37 path: string | null;
38 confirmDestructive: boolean;
39 restoreData: boolean;
40}
41
42function parse(argv: readonly string[]): Args {
43 const out: Args = { path: null, confirmDestructive: false, restoreData: false };
44 for (let i = 0; i < argv.length; i++) {
45 const a = argv[i];
46 if (a === '--confirm-destructive') {
47 out.confirmDestructive = true;
48 } else if (a === '--restore-data') {
49 out.restoreData = true;
50 } else if (a === '--help' || a === '-h') {
51 out.path = '__HELP__';
52 } else if (a && !a.startsWith('--') && !out.path) {
53 out.path = a;
54 }
55 }
56 return out;
57}
58
59function printUsage(): void {
60 banner('import');
61 blankLine();
62 step('usage: briven import <path> [--confirm-destructive] [--restore-data]');
63 step('');
64 step('reads a briven-export.json and creates a deployment on the linked');
65 step('target project. the linked project is the one in briven.json (or');
66 step('the default credential). cross-project imports rewrite the schema,');
67 step('which can drop tables — pass --confirm-destructive to allow.');
68 step('');
69 step('with --restore-data, also pg_restores the sibling <path>.data.dump');
70 step('against the target project schema (requires pg_restore on PATH).');
71}
72
73export async function runImport(argv: readonly string[]): Promise<number> {
74 const args = parse(argv);
75 if (args.path === '__HELP__') {
76 printUsage();
77 return 0;
78 }
79 if (!args.path) {
80 printUsage();
81 return 1;
82 }
83
84 const local = await readProjectConfig();
85 const file = await readCredentials();
86 const targetId = local?.projectId ?? file.default;
87 if (!targetId) {
88 printError('no linked project found — link a destination first.');
89 step('run: briven login --project <p_...> --key <brk_...>');
90 return 1;
91 }
92 const cred = file.projects[targetId];
93 if (!cred) {
94 printError(`no credentials stored for ${targetId}`);
95 return 1;
96 }
97
98 let payload: ExportPayload;
99 try {
100 const raw = await readFile(resolve(args.path), 'utf8');
101 payload = JSON.parse(raw) as ExportPayload;
102 } catch (err) {
103 printError(
104 `could not read export: ${err instanceof Error ? err.message : 'unknown error'}`,
105 );
106 return 1;
107 }
108
109 if (payload.manifest?.version !== 1) {
110 printError(`unsupported export version: ${String(payload.manifest?.version)} (expected 1)`);
111 return 1;
112 }
113
114 banner('import');
115 step(`source ${payload.manifest.sourceProjectId} (${payload.manifest.sourceProjectName})`);
116 step(`exported ${payload.manifest.exportedAt}`);
117 step(`target ${targetId}`);
118 step(`origin ${cred.apiOrigin}`);
119 step(`schema ${payload.schema ? 'present' : 'absent'}`);
120 step(`functions ${Object.keys(payload.functions).length}`);
121
122 const functionNames = Object.keys(payload.functions);
123
124 // The deployments POST already enforces destructive guards on the
125 // server — we mirror it as a CLI flag for parity with `briven deploy`.
126 // Pass-through: when --confirm-destructive isn't set, the server
127 // refuses if the inbound schema would drop a table on the target.
128 let res: DeploymentResponse;
129 try {
130 res = await apiCall<DeploymentResponse>(`/v1/projects/${targetId}/deployments`, {
131 method: 'POST',
132 apiOrigin: cred.apiOrigin,
133 apiKey: cred.apiKey,
134 body: {
135 schemaSnapshot: payload.schema ?? undefined,
136 functionCount: functionNames.length,
137 functionNames,
138 bundle: payload.functions,
139 schemaDiffSummary: { import: 1, source: payload.manifest.sourceProjectId },
140 },
141 });
142 } catch (err) {
143 blankLine();
144 if (err instanceof ApiCallError) {
145 printError(`server rejected: ${err.code} (${err.status})`);
146 if (err.code === 'destructive_changes_refused' && !args.confirmDestructive) {
147 step('re-run with --confirm-destructive to allow drops on the target');
148 }
149 } else {
150 printError(err instanceof Error ? err.message : 'unknown error');
151 }
152 return 1;
153 }
154 blankLine();
155 success(`deployment ${res.deployment.id} · ${res.deployment.status}`);
156
157 if (!args.restoreData) return 0;
158
159 // Locate the sibling .data.dump. The export command writes it next to
160 // the .json with a stripped extension: foo.briven-export.json →
161 // foo.briven-export.data.dump.
162 const dumpPath = resolve(args.path).replace(/\.json$/, '') + '.data.dump';
163 try {
164 await stat(dumpPath);
165 } catch {
166 printError(`--restore-data set but no sibling dump found at ${dumpPath}`);
167 step('re-export with `briven export --with-data` to produce one');
168 return 1;
169 }
170 blankLine();
171 step('requesting short-lived dsn for pg_restore');
172 let token: ShellTokenResponse;
173 try {
174 token = await apiCall<ShellTokenResponse>(
175 `/v1/projects/${targetId}/db/shell-token`,
176 { method: 'POST', apiOrigin: cred.apiOrigin, apiKey: cred.apiKey },
177 );
178 } catch (err) {
179 printError(
180 `couldn't issue dsn: ${err instanceof ApiCallError ? `${err.code} (${err.status})` : err instanceof Error ? err.message : 'unknown'}`,
181 );
182 return 1;
183 }
184 step(`dsn expires ${token.expiresAt}`);
185 step(`pg_restore ← ${dumpPath}`);
186 const code = await runPgRestore(token.dsn, dumpPath);
187 if (code !== 0) {
188 printError(`pg_restore exited ${code}`);
189 step('check pg_restore is on PATH and inspect the output above for details');
190 return 1;
191 }
192 blankLine();
193 success('data restored');
194 return 0;
195}
196
197/**
198 * pg_restore from a custom-format dump into a target dsn. --no-owner
199 * and --no-privileges so the restore doesn't try to recreate the source
200 * roles. --clean drops + recreates objects that exist; the deploy step
201 * above already created the schema, so this is largely additive.
202 */
203function runPgRestore(dsn: string, dumpPath: string): Promise<number> {
204 return new Promise((resolveExit) => {
205 const child = spawn(
206 'pg_restore',
207 [
208 '--no-owner',
209 '--no-privileges',
210 '--clean',
211 '--if-exists',
212 `--dbname=${dsn}`,
213 dumpPath,
214 ],
215 { stdio: ['ignore', 'inherit', 'inherit'] },
216 );
217 child.on('exit', (code) => resolveExit(code ?? 1));
218 child.on('error', () => resolveExit(127));
219 });
220}