schema-apply.ts115 lines · main
| 1 | import { |
| 2 | diff, |
| 3 | renderCreateTable, |
| 4 | type Change, |
| 5 | type ColumnDef, |
| 6 | type SchemaDef, |
| 7 | type TableDef, |
| 8 | } from '@briven/schema'; |
| 9 | |
| 10 | import { runInProjectDatabase } from '../db/data-plane.js'; |
| 11 | import { 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 | */ |
| 25 | export 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 | |
| 58 | export 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 | |
| 82 | export 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 | |
| 93 | function triggerName(table: string): string { |
| 94 | return `_briven_notify_${table}`; |
| 95 | } |
| 96 | |
| 97 | function triggerFnName(table: string): string { |
| 98 | return `_briven_notify_${table}_fn`; |
| 99 | } |
| 100 | |
| 101 | function 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 | |
| 109 | function 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 | |
| 115 | export type { SchemaDef, TableDef }; |