schema-apply.ts115 lines · main
1import {
2 diff,
3 renderCreateTable,
4 type Change,
5 type ColumnDef,
6 type SchemaDef,
7 type TableDef,
8} from '@briven/schema';
9
10import { runInProjectDatabase } from '../db/data-plane.js';
11import { log } from '../lib/logger.js';
12
13/**
14 * Apply a deployment's schema snapshot to the project's data-plane schema.
15 *
16 * Phase 1 strategy: compute a diff vs. the previous deployment, render
17 * each `Change` as a single idempotent SQL statement, run the whole batch
18 * in one transaction, then record the deployment id in `_briven_migrations`.
19 *
20 * Idempotency: every statement uses IF EXISTS / IF NOT EXISTS so retries
21 * after a partial failure don't error. Destructive changes (DROP) still
22 * commit irreversibly — the dashboard / CLI gate that with
23 * `--confirm-destructive` upstream.
24 */
25export async function applySchema(
26 projectId: string,
27 deploymentId: string,
28 next: SchemaDef,
29 prev: SchemaDef | null,
30): Promise<{ statements: number }> {
31 const result = diff(prev, next);
32 const statements = result.changes.flatMap(renderChange);
33
34 await runInProjectDatabase(projectId, async (tx) => {
35 for (const stmt of statements) {
36 await tx.unsafe(stmt);
37 }
38 // Bind every value — even though deploymentId is server-generated, raw
39 // string interpolation here was a foot-gun if a future caller ever
40 // routed user input through.
41 await tx.unsafe(
42 `INSERT INTO "_briven_migrations" (id, deployment_id, summary)
43 VALUES ($1, $2, $3::jsonb)
44 ON CONFLICT (id) DO NOTHING`,
45 [deploymentId, deploymentId, JSON.stringify(summarise(result.changes))],
46 );
47 });
48
49 log.info('schema_applied', {
50 projectId,
51 deploymentId,
52 statements: statements.length,
53 changes: result.changes.length,
54 });
55 return { statements: statements.length };
56}
57
58export function renderChange(change: Change): string[] {
59 switch (change.kind) {
60 case 'create_table':
61 return [renderCreateTable(change.table, change.def)];
62 case 'drop_table':
63 // Order matters: trigger first (depends on table), then function (no
64 // longer referenced once the trigger is gone), then the table itself.
65 // Without the explicit DROP FUNCTION, the trigger function would
66 // orphan in the project schema after every dropped table — invisible
67 // bloat that grows monotonically with schema churn.
68 return [
69 `DROP TRIGGER IF EXISTS ${triggerName(change.table)} ON "${change.table}"`,
70 `DROP FUNCTION IF EXISTS ${triggerFnName(change.table)}() CASCADE`,
71 `DROP TABLE IF EXISTS "${change.table}" CASCADE`,
72 ];
73 case 'add_column':
74 return [
75 `ALTER TABLE "${change.table}" ADD COLUMN IF NOT EXISTS "${change.column}" ${renderColumnType(change.def)}`,
76 ];
77 case 'drop_column':
78 return [`ALTER TABLE "${change.table}" DROP COLUMN IF EXISTS "${change.column}"`];
79 }
80}
81
82export function singlePkColumn(def: TableDef): string | null {
83 const pks = Object.entries(def.columns).filter(([, c]) => c.primaryKey);
84 return pks.length === 1 && pks[0] ? pks[0][0] : null;
85}
86
87// NOTE: per-table plpgsql NOTIFY triggers were removed (sprint S1.7). Realtime
88// detects changes by polling `DOLT_HASHOF('HEAD')` (see apps/realtime), not
89// LISTEN/NOTIFY, so emitting these on every create_table was dead work. The
90// `drop_table` case still issues idempotent `DROP TRIGGER/FUNCTION IF EXISTS`
91// to clean up triggers left behind by projects deployed before this change.
92
93function triggerName(table: string): string {
94 return `_briven_notify_${table}`;
95}
96
97function triggerFnName(table: string): string {
98 return `_briven_notify_${table}_fn`;
99}
100
101function renderColumnType(def: ColumnDef): string {
102 const parts = [def.sqlType];
103 if (!def.nullable && !def.primaryKey) parts.push('NOT NULL');
104 if (def.unique && !def.primaryKey) parts.push('UNIQUE');
105 if (def.default !== undefined) parts.push(`DEFAULT ${def.default}`);
106 return parts.join(' ');
107}
108
109function summarise(changes: readonly Change[]): Record<string, number> {
110 const counts: Record<string, number> = {};
111 for (const c of changes) counts[c.kind] = (counts[c.kind] ?? 0) + 1;
112 return counts;
113}
114
115export type { SchemaDef, TableDef };