dev.ts360 lines · main
1import chokidar from 'chokidar';
2import pc from 'picocolors';
3
4import { diff, type SchemaDef, type SchemaSnapshotWire } from '@briven/schema';
5
6import type { SchemaSnapshot } from '../codegen.js';
7
8import { apiCall, ApiCallError } from '../api-client.js';
9import { discoverFunctions, loadProjectSchema } from '../bundler.js';
10import { readCredentials, readUserCredential, type ProjectCredential } from '../config.js';
11import { readProjectConfig } from '../project-config.js';
12import { banner, blankLine, error as printError, step, success } from '../output.js';
13import { decideBranch, runWizard } from '../wizard.js';
14
15/**
16 * Tier-aware policy: does this schema diff need a y/N prompt before we
17 * push it? Destructive ops always prompt. Non-destructive ops auto-push
18 * on free; prompt on pro/team where prod data is on the line.
19 *
20 * Wire-up into the watcher loop is a follow-up — see plan §C7 step 3.
21 */
22export function shouldPromptForDiff(opts: {
23 tier: 'free' | 'pro' | 'team';
24 destructive: boolean;
25}): boolean {
26 if (opts.destructive) return true;
27 return opts.tier !== 'free';
28}
29
30interface DevArgs {
31 quiet: boolean;
32 confirmDestructive: boolean;
33}
34
35interface Snapshot {
36 schema: SchemaDef | null;
37 bundle: Readonly<Record<string, string>>;
38}
39
40export async function runDev(argv: readonly string[]): Promise<number> {
41 const args = parseFlags(argv);
42
43 {
44 const wizardLocal = await readProjectConfig();
45 const wizardUser = await readUserCredential();
46 const branch = decideBranch({
47 hasBrivenJson: !!wizardLocal,
48 hasUserToken: !!wizardUser,
49 });
50 if (branch !== 'watch') {
51 await runWizard({
52 apiOrigin: process.env.BRIVEN_API_ORIGIN ?? 'https://api.briven.tech',
53 dashboardOrigin: process.env.BRIVEN_DASHBOARD_ORIGIN ?? 'https://app.briven.tech',
54 });
55 // Re-read briven.json + creds since the wizard wrote them — fall through
56 // to existing logic which will pick them up.
57 }
58 }
59
60 const local = await readProjectConfig();
61 if (!local) {
62 printError('no briven.json in this directory.');
63 step('run: briven init');
64 return 1;
65 }
66 if (!local.projectId) {
67 printError('briven.json has no projectId — link this directory first.');
68 step('run: briven link');
69 return 1;
70 }
71 const creds = await readCredentials();
72 const cred = creds.projects[local.projectId];
73 if (!cred) {
74 printError(`no stored credentials for ${local.projectId}.`);
75 return 1;
76 }
77
78 banner(`dev ${local.projectId}`);
79 step(`origin ${cred.apiOrigin}`);
80 step(
81 args.confirmDestructive
82 ? pc.red('destructive schema diffs allowed')
83 : 'safe mode — destructive schema diffs refused',
84 );
85 blankLine();
86
87 const cwd = process.cwd();
88 const snapshot: Snapshot = { schema: null, bundle: {} };
89 let pushing = false;
90 let pending = false;
91 let debounce: NodeJS.Timeout | null = null;
92
93 const watcher = chokidar.watch(['briven/schema.ts', 'briven/functions/**/*.ts'], {
94 cwd,
95 ignoreInitial: false,
96 awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 50 },
97 });
98
99 const trigger = (): void => {
100 if (debounce) clearTimeout(debounce);
101 debounce = setTimeout(() => {
102 debounce = null;
103 if (pushing) {
104 pending = true;
105 return;
106 }
107 pushing = true;
108 void push(cwd, local.projectId!, cred, snapshot, args)
109 .catch((err: unknown) => {
110 printError(err instanceof Error ? err.message : 'unknown error');
111 })
112 .finally(() => {
113 pushing = false;
114 if (pending) {
115 pending = false;
116 trigger();
117 }
118 });
119 }, 300);
120 };
121
122 watcher.on('add', trigger);
123 watcher.on('change', trigger);
124 watcher.on('unlink', trigger);
125
126 // Optional log stream — run in parallel. Don't await; let it loop.
127 if (!args.quiet) {
128 void streamLogs(cred, local.projectId);
129 }
130
131 step(pc.dim('watching briven/schema.ts and briven/functions/**/*.ts'));
132 blankLine();
133
134 await new Promise<void>((resolve) => {
135 const cleanup = (): void => {
136 void watcher.close().finally(() => resolve());
137 };
138 process.on('SIGINT', cleanup);
139 process.on('SIGTERM', cleanup);
140 });
141
142 blankLine();
143 success('stopped');
144 return 0;
145}
146
147async function push(
148 cwd: string,
149 projectId: string,
150 cred: ProjectCredential,
151 snapshot: Snapshot,
152 args: DevArgs,
153): Promise<void> {
154 const nextSchema = await loadProjectSchema(cwd);
155 const next = await discoverFunctions(cwd);
156 const nextBundle = next.bundle;
157
158 const schemaChanged = !sameSchema(snapshot.schema, nextSchema);
159 const { changedFunctions, removedFunctions } = bundleDelta(snapshot.bundle, nextBundle);
160
161 if (
162 !schemaChanged &&
163 Object.keys(changedFunctions).length === 0 &&
164 removedFunctions.length === 0
165 ) {
166 return; // nothing to push
167 }
168
169 if (schemaChanged && nextSchema) {
170 const result = diff(snapshot.schema, nextSchema);
171 if (result.destructive && !args.confirmDestructive) {
172 step(pc.red('destructive schema diff detected — refusing to push'));
173 for (const c of result.changes) {
174 if (c.kind === 'drop_table') step(` - table ${c.table}`);
175 else if (c.kind === 'drop_column') step(` - ${c.table}.${c.column}`);
176 }
177 step(pc.dim('re-run briven dev --confirm-destructive, or fix briven/schema.ts'));
178 return;
179 }
180 }
181
182 // SchemaDef from the user's briven/schema.ts is structurally the wire
183 // format the API expects (schemaSnapshotSchema in @briven/schema/wire).
184 // Hoist it into a typed variable so we can reuse the exact same shape
185 // for both the PATCH body and the local codegen call below.
186 const wireSnapshot: SchemaSnapshotWire = nextSchema
187 ? (nextSchema as unknown as SchemaSnapshotWire)
188 : { version: 1, tables: {} };
189
190 const body: Record<string, unknown> = {};
191 if (Object.keys(changedFunctions).length > 0) body.changedFunctions = changedFunctions;
192 if (removedFunctions.length > 0) body.removedFunctions = removedFunctions;
193 if (schemaChanged && nextSchema) body.schemaSnapshot = wireSnapshot;
194 if (args.confirmDestructive) body.confirmDestructive = true;
195
196 const started = Date.now();
197 try {
198 const res = await apiCall<{
199 deployment: { id: string; status: string };
200 }>(`/v1/projects/${projectId}/deployments/latest`, {
201 method: 'PATCH',
202 apiOrigin: cred.apiOrigin,
203 apiKey: cred.apiKey,
204 body,
205 });
206 const clock = new Date().toISOString().slice(11, 19);
207 step(
208 `${clock} ${pc.dim('pushed')} ${res.deployment.id} ${pc.green(res.deployment.status)} ${
209 Date.now() - started
210 }ms`,
211 );
212 snapshot.schema = nextSchema;
213 snapshot.bundle = nextBundle;
214
215 // Regenerate ./briven/_generated/ from the snapshot we just pushed.
216 const { generate } = await import('../codegen.js');
217 const { writeFile, mkdir, readFile, readdir } = await import('node:fs/promises');
218 const { join } = await import('node:path');
219 let fnFiles: string[] = [];
220 try {
221 const all = await readdir(join(process.cwd(), 'briven', 'functions'));
222 fnFiles = all.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
223 } catch {
224 fnFiles = [];
225 }
226 // wireSnapshot and codegen's SchemaSnapshot are structurally identical
227 // (see packages/schema/src/wire.ts vs packages/cli/src/codegen.ts).
228 const generated = generate(wireSnapshot as SchemaSnapshot, fnFiles);
229 for (const [rel, content] of generated) {
230 const abs = join(process.cwd(), rel);
231 await mkdir(join(abs, '..'), { recursive: true });
232 let existing: string | null = null;
233 try {
234 existing = await readFile(abs, 'utf8');
235 } catch {
236 existing = null;
237 }
238 if (existing !== content) await writeFile(abs, content, 'utf8');
239 }
240 } catch (err) {
241 if (err instanceof ApiCallError) {
242 printError(`push failed: ${err.code} (${err.status})`);
243 } else {
244 printError(err instanceof Error ? err.message : 'unknown error');
245 }
246 }
247}
248
249function bundleDelta(
250 prev: Readonly<Record<string, string>>,
251 next: Readonly<Record<string, string>>,
252): { changedFunctions: Record<string, string>; removedFunctions: string[] } {
253 const changed: Record<string, string> = {};
254 const removed: string[] = [];
255 for (const [k, v] of Object.entries(next)) {
256 if (prev[k] !== v) changed[k] = v;
257 }
258 for (const k of Object.keys(prev)) {
259 if (!(k in next)) removed.push(k);
260 }
261 return { changedFunctions: changed, removedFunctions: removed };
262}
263
264function sameSchema(a: SchemaDef | null, b: SchemaDef | null): boolean {
265 if (a === null && b === null) return true;
266 if (a === null || b === null) return false;
267 // Fast structural compare via JSON — SchemaDef is plain data.
268 return JSON.stringify(a) === JSON.stringify(b);
269}
270
271async function streamLogs(cred: ProjectCredential, projectId: string): Promise<void> {
272 const url = `${cred.apiOrigin}/v1/projects/${projectId}/logs/stream`;
273 let backoff = 1_000;
274 while (true) {
275 try {
276 const res = await fetch(url, {
277 headers: { accept: 'text/event-stream', authorization: `Bearer ${cred.apiKey}` },
278 });
279 if (!res.ok || !res.body) {
280 throw new Error(`logs stream ${res.status}`);
281 }
282 const reader = res.body.getReader();
283 const dec = new TextDecoder();
284 let buf = '';
285 while (true) {
286 const { value, done } = await reader.read();
287 if (done) break;
288 buf += dec.decode(value, { stream: true });
289 let idx: number;
290 while ((idx = buf.indexOf('\n\n')) !== -1) {
291 const block = buf.slice(0, idx);
292 buf = buf.slice(idx + 2);
293 const msg = parseSseBlock(block);
294 if (!msg || msg.event === 'ping' || !msg.data) continue;
295 try {
296 renderInvocation(JSON.parse(msg.data) as Record<string, string>);
297 } catch {
298 // ignore malformed frame
299 }
300 }
301 }
302 backoff = 1_000;
303 } catch {
304 await new Promise((r) => setTimeout(r, backoff));
305 backoff = Math.min(backoff * 2, 10_000);
306 }
307 }
308}
309
310function parseSseBlock(block: string): { event?: string; data?: string } | null {
311 const out: { event?: string; data?: string } = {};
312 for (const line of block.split('\n')) {
313 if (line.startsWith(':')) continue;
314 const colon = line.indexOf(':');
315 if (colon === -1) continue;
316 const field = line.slice(0, colon);
317 let value = line.slice(colon + 1);
318 if (value.startsWith(' ')) value = value.slice(1);
319 if (field === 'event') out.event = value;
320 else if (field === 'data') out.data = (out.data ?? '') + value;
321 }
322 return out;
323}
324
325interface UserLog {
326 level: string;
327 message: string;
328}
329
330function renderInvocation(raw: Record<string, string>): void {
331 const ts = raw.ts ? new Date(raw.ts) : new Date();
332 const clock = ts.toISOString().slice(11, 19);
333 const status = raw.status === 'ok' ? pc.green('ok ') : pc.red('err');
334 const duration = `${raw.durationMs ?? '0'}ms`;
335 const name = (raw.functionName ?? '<unknown>').padEnd(22);
336 step(`${clock} ${name} ${status} ${duration}`);
337
338 let userLogs: UserLog[] = [];
339 try {
340 userLogs = JSON.parse(raw.logs ?? '[]') as UserLog[];
341 } catch {
342 // ignore
343 }
344 for (const entry of userLogs) {
345 const label = entry.level === 'error' ? pc.red('err') : pc.dim('log');
346 step(` ${pc.dim('↳')} ${label} ${entry.message}`);
347 }
348 if (raw.status !== 'ok' && raw.errMessage) {
349 step(` ${pc.dim('↳')} ${pc.red('err')} ${raw.errMessage}`);
350 }
351}
352
353function parseFlags(argv: readonly string[]): DevArgs {
354 const out: DevArgs = { quiet: false, confirmDestructive: false };
355 for (const a of argv) {
356 if (a === '--quiet' || a === '-q') out.quiet = true;
357 else if (a === '--confirm-destructive') out.confirmDestructive = true;
358 }
359 return out;
360}