@everystack/cli 0.2.39 → 0.3.0

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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * The structural schema diff — the data layer's reconcile.
3
+ *
4
+ * The counterpart to authz-reconcile's `emitReconcileSql`: that transforms the live
5
+ * database's RLS/grants/policies into the declared authz; this transforms the live
6
+ * *structure* (columns, constraints) into the declared schema. Both diff one IR —
7
+ * `diffSchema` works on the `TableSchema` shape both producers target (the compiled
8
+ * Model and whole-DB introspection), so it never needs the Model or the database.
9
+ *
10
+ * It is the brownfield generalization of `compileMigration`: greenfield emits "vs an
11
+ * empty database"; this emits "vs whatever the database currently is". The two stages
12
+ * mirror authz too — `diffSchema` produces a structured change set (the analog of the
13
+ * reconcile findings), `emitSchemaSql` turns it into `ALTER TABLE` DDL.
14
+ *
15
+ * Deliberately scoped, with clear seams for the follow-ups:
16
+ * - A column gone from desired and a new one appearing are an independent drop + add.
17
+ * RENAME is intent that cannot be recovered from two snapshots, so it is opt-in via
18
+ * a `renamedFrom` marker on the Model (a later increment) — never guessed here.
19
+ * - A type change emits a real `ALTER COLUMN ... TYPE`, classified safe/lossy/risky: a
20
+ * safe widening is a bare conversion; a lossy or risky one gets an explicit `USING`
21
+ * cast and a `-- WARNING` (it can truncate, or fail on rows that don't convert). The
22
+ * classifier defaults to risky, so it never silently under-warns.
23
+ * - Defaults compare *semantically* (`normalizeDefault`) so a deparser cast
24
+ * (`'draft'` vs `'draft'::text`) never produces spurious churn.
25
+ */
26
+
27
+ import type {
28
+ SchemaSnapshot,
29
+ TableSchema,
30
+ ColumnSchema,
31
+ EnumType,
32
+ IndexSchema,
33
+ } from './schema-introspect.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The change set.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** A constraint to add, in the form the emitter renders `ADD CONSTRAINT` from. */
40
+ export type ConstraintSpec =
41
+ | { type: 'unique'; name: string; columns: string[] }
42
+ | { type: 'check'; name: string; expr: string }
43
+ | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string };
44
+
45
+ export type SchemaChange =
46
+ | { kind: 'createTable'; table: string; schema: TableSchema }
47
+ | { kind: 'dropTable'; table: string }
48
+ | { kind: 'addColumn'; table: string; column: ColumnSchema }
49
+ | { kind: 'dropColumn'; table: string; column: string }
50
+ | { kind: 'setNotNull'; table: string; column: string; notNull: boolean }
51
+ /** `expr: null` means DROP DEFAULT. */
52
+ | { kind: 'setDefault'; table: string; column: string; expr: string | null }
53
+ /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
54
+ | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
55
+ | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec }
56
+ | { kind: 'dropConstraint'; table: string; name: string }
57
+ | { kind: 'addPrimaryKey'; table: string; columns: string[] }
58
+ /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
59
+ | { kind: 'primaryKeyChange'; table: string; from: string[]; to: string[] }
60
+ /** A `renamedFrom` marker satisfied: old column present, new absent → preserve the data. */
61
+ | { kind: 'renameColumn'; table: string; from: string; to: string }
62
+ /** A marker whose rename is already applied (new name present) — inert, a notice. */
63
+ | { kind: 'renameSatisfied'; table: string; column: string; from: string }
64
+ /** A marker pointing at a column the database doesn't have — fell back to ADD, a notice. */
65
+ | { kind: 'renameSourceMissing'; table: string; column: string; from: string }
66
+ /** An unmarked drop+add that *might* be a rename — a hint comment, the SQL still drops+adds. */
67
+ | { kind: 'renameHint'; table: string; from: string; to: string }
68
+ /** A new enum type → `CREATE TYPE … AS ENUM (…)`, emitted before the tables that use it. */
69
+ | { kind: 'createType'; name: string; values: string[] }
70
+ /** A value appended to an existing enum → `ALTER TYPE … ADD VALUE` (carries a -- WARNING). */
71
+ | { kind: 'addEnumValue'; type: string; value: string }
72
+ /** An enum type present in the DB but no longer declared → `DROP TYPE` (a removal — gated like drops). */
73
+ | { kind: 'dropType'; name: string }
74
+ /** An enum value the DB has but the Model dropped/renamed — Postgres can't drop one in place (a notice). */
75
+ | { kind: 'enumValuesChanged'; name: string; from: string[]; to: string[] }
76
+ /** A standalone index to create → `CREATE [UNIQUE] INDEX … [WHERE …]`. */
77
+ | { kind: 'createIndex'; table: string; name: string; columns: string[]; unique: boolean; where?: string }
78
+ /** A standalone index present in the DB but no longer declared → `DROP INDEX` (a removal — gated). */
79
+ | { kind: 'dropIndex'; table: string; name: string };
80
+
81
+ /**
82
+ * Rename intent, keyed like the snapshot's tables (`public.posts`) → { newColumn: oldColumn }.
83
+ * Produced by `compileRenames` off the Models' `.renamedFrom()` markers. It rides *beside*
84
+ * the snapshots, never on a column: introspection can't produce it (the DB has no rename
85
+ * history), so keeping it off the IR preserves the round-trip both producers share.
86
+ */
87
+ export type RenameMap = Record<string, Record<string, string>>;
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Normalization — so semantically-equal expressions don't read as drift.
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Canonicalize a column DEFAULT so the Model's form and the database's deparsed form
95
+ * compare equal. The Model emits `'draft'`; `pg_get_expr` gives `'draft'::text`. The
96
+ * Model emits `now()`; a `CURRENT_TIMESTAMP` default deparses to `now()`. We strip a
97
+ * trailing type cast and canonicalize the timestamp keyword — internal casts (inside a
98
+ * function call like `nextval('s'::regclass)`) are left alone.
99
+ */
100
+ export function normalizeDefault(expr: string | null): string | null {
101
+ if (expr == null) return null;
102
+ let s = expr.trim();
103
+ if (s === '') return null;
104
+
105
+ // Strip a trailing `::type` cast (type may be quoted, multi-word, or array): the
106
+ // literal `'draft'::text` -> `'draft'`, `0::bigint` -> `0`, `'{}'::jsonb` -> `'{}'`.
107
+ let prev: string;
108
+ do {
109
+ prev = s;
110
+ s = s.replace(/::\s*"?[A-Za-z_][A-Za-z0-9_ ]*"?(\s*\([^)]*\))?(\s*\[\])?$/, '').trim();
111
+ } while (s !== prev);
112
+
113
+ if (/^current_timestamp$/i.test(s)) return 'now()';
114
+ return s;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Type-change classification — does the conversion need a USING cast and a warning?
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** safe: a lossless widening. lossy: an assignment cast that can truncate. risky: a cast that can fail at runtime. */
122
+ export type TypeChangeRisk = 'safe' | 'lossy' | 'risky';
123
+
124
+ /** Integer/numeric widening rank — a value at a lower rank always fits a higher one. */
125
+ const NUMERIC_RANK: Record<string, number> = { smallint: 1, integer: 2, bigint: 3, numeric: 4 };
126
+
127
+ /** Split a `format_type` string into its base and any precision/length args: `numeric(12,2)` -> {base:'numeric', args:[12,2]}. */
128
+ function parseType(t: string): { base: string; args: number[] } {
129
+ const m = t.match(/^(.*?)\s*\(([^)]*)\)\s*$/);
130
+ if (!m) return { base: t.trim(), args: [] };
131
+ return { base: m[1].trim(), args: m[2].split(',').map((s) => parseInt(s.trim(), 10)).filter((n) => !Number.isNaN(n)) };
132
+ }
133
+
134
+ /** True when every target arg is >= the source's (or the target is unbounded) — a widening of precision/length. */
135
+ function argsWiden(from: number[], to: number[]): boolean {
136
+ if (to.length === 0) return true; // unbounded target (e.g. numeric, varchar) holds anything
137
+ if (from.length === 0) return false; // bounding a previously-unbounded source narrows it
138
+ return to.every((v, i) => from[i] == null || v >= from[i]);
139
+ }
140
+
141
+ /**
142
+ * Classify a column type change. Conservative by construction — anything not provably safe
143
+ * or merely lossy falls through to `risky`, so a dangerous conversion is never mistaken for
144
+ * a free one. (The vocabulary is the field bridge's: text, the integer/numeric family,
145
+ * varchar, timestamp(/tz), uuid, boolean, jsonb.)
146
+ */
147
+ export function classifyTypeChange(from: string, to: string): TypeChangeRisk {
148
+ const f = parseType(from);
149
+ const t = parseType(to);
150
+
151
+ if (t.base === 'text') return 'safe'; // text is the universal sink: every type assignment-casts to it losslessly
152
+
153
+ const fr = NUMERIC_RANK[f.base];
154
+ const tr = NUMERIC_RANK[t.base];
155
+ if (fr && tr) {
156
+ if (f.base === t.base) return argsWiden(f.args, t.args) ? 'safe' : 'lossy';
157
+ return tr > fr ? 'safe' : 'lossy'; // widen up the integer/numeric ranks is safe; down can overflow
158
+ }
159
+
160
+ if (f.base === t.base) return argsWiden(f.args, t.args) ? 'safe' : 'lossy'; // same base, only precision/length moved
161
+
162
+ const timestamps = new Set(['timestamp', 'timestamp with time zone', 'timestamp without time zone']);
163
+ if (timestamps.has(f.base) && timestamps.has(t.base)) return 'lossy'; // tz semantics shift
164
+
165
+ if (f.base === 'text' || f.base === 'character varying') return 'risky'; // parsing text into a scalar can fail per-row
166
+ if (t.base === 'character varying') return 'lossy'; // bounding into a length-limited string can truncate
167
+
168
+ return 'risky';
169
+ }
170
+
171
+ /** True when `s`'s outer parens already wrap the whole expression. */
172
+ function isWrapped(s: string): boolean {
173
+ if (!s.startsWith('(') || !s.endsWith(')')) return false;
174
+ let depth = 0;
175
+ for (let i = 0; i < s.length; i++) {
176
+ if (s[i] === '(') depth++;
177
+ else if (s[i] === ')') {
178
+ depth--;
179
+ if (depth === 0 && i < s.length - 1) return false;
180
+ }
181
+ }
182
+ return depth === 0;
183
+ }
184
+
185
+ /** A predicate parenthesized exactly once (mirrors authz-reconcile's `clause`). */
186
+ function wrapOnce(expr: string): string {
187
+ return isWrapped(expr) ? expr : `(${expr})`;
188
+ }
189
+
190
+ /**
191
+ * Canonicalize a CHECK predicate for comparison: drop one layer of wrapping parens and
192
+ * collapse whitespace. The Model emits `char_length(title) >= 1`; `pg_get_constraintdef`
193
+ * gives `(char_length(title) >= 1)`. Deeper deparser-faithful matching (the same concern
194
+ * type changes raise) is a follow-up — this kills the common paren/whitespace churn.
195
+ */
196
+ export function normalizeCheck(expr: string): string {
197
+ let s = expr.trim();
198
+ while (isWrapped(s)) s = s.slice(1, -1).trim();
199
+ return s.replace(/\s+/g, ' ');
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Diff.
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Diff desired (compiled Model) against current (introspected DB) into an ordered,
208
+ * dependency-safe change set. The order each change is emitted in is the order it must
209
+ * be applied: new tables, then new columns, then column alters, then dropped
210
+ * constraints, then added constraints, then dropped columns, then dropped tables — so a
211
+ * replaced constraint never clashes with its old self and a column is never dropped out
212
+ * from under a constraint that still references it.
213
+ */
214
+ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, renames: RenameMap = {}): SchemaChange[] {
215
+ const creates: SchemaChange[] = [];
216
+ const renameColumns: SchemaChange[] = [];
217
+ const addColumns: SchemaChange[] = [];
218
+ const alters: SchemaChange[] = [];
219
+ const dropConstraints: SchemaChange[] = [];
220
+ const addConstraints: SchemaChange[] = [];
221
+ const createIndexes: SchemaChange[] = [];
222
+ const dropIndexes: SchemaChange[] = [];
223
+ const dropColumns: SchemaChange[] = [];
224
+ const dropTables: SchemaChange[] = [];
225
+ const notices: SchemaChange[] = [];
226
+
227
+ const currentByName = new Map(current.tables.map((t) => [t.table, t]));
228
+ const desiredNames = new Set(desired.tables.map((t) => t.table));
229
+
230
+ for (const d of desired.tables) {
231
+ const c = currentByName.get(d.table);
232
+ if (!c) {
233
+ // Whole new table: CREATE TABLE (PK/unique/check inline), then its FKs as
234
+ // separate adds so every table exists before any FK references it.
235
+ creates.push({ kind: 'createTable', table: d.table, schema: d });
236
+ for (const fk of d.foreignKeys) {
237
+ addConstraints.push({
238
+ kind: 'addConstraint',
239
+ table: d.table,
240
+ constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate },
241
+ });
242
+ }
243
+ for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
244
+ continue;
245
+ }
246
+ const cd = diffColumns(d, c, renames[d.table] ?? {});
247
+ renameColumns.push(...cd.renameColumns);
248
+ addColumns.push(...cd.addColumns);
249
+ alters.push(...cd.alters);
250
+ dropColumns.push(...cd.dropColumns);
251
+ notices.push(...cd.notices);
252
+ diffConstraints(d, c, dropConstraints, addConstraints, alters);
253
+ diffIndexes(d, c, createIndexes, dropIndexes);
254
+ }
255
+
256
+ for (const c of current.tables) {
257
+ if (!desiredNames.has(c.table)) dropTables.push({ kind: 'dropTable', table: c.table });
258
+ }
259
+
260
+ const e = diffEnums(desired.enums ?? [], current.enums ?? []);
261
+
262
+ // Enum types come first (a table or column may reference one); value adds before the
263
+ // columns that use them. Renames run before adds (a rename frees a name an add may
264
+ // reuse) and before alters (which target the new name). Enum drops sit with the other
265
+ // removals; notices are comments — emitted last, after the DDL.
266
+ return [
267
+ ...e.creates, ...e.adds,
268
+ ...creates, ...renameColumns, ...addColumns, ...alters,
269
+ ...dropConstraints, ...addConstraints, ...createIndexes,
270
+ ...dropColumns, ...dropIndexes, ...dropTables,
271
+ ...e.drops, ...notices, ...e.notices,
272
+ ];
273
+ }
274
+
275
+ /** A `createIndex` change from an index spec (the new-table path and the per-table diff share it). */
276
+ function createIndexChange(table: string, ix: IndexSchema): SchemaChange {
277
+ return { kind: 'createIndex', table, name: ix.name, columns: ix.columns, unique: ix.unique, ...(ix.where ? { where: ix.where } : {}) };
278
+ }
279
+
280
+ /** An index's content identity — columns, uniqueness, and partial predicate, name-independent (like checks). */
281
+ function indexKey(ix: IndexSchema): string {
282
+ return `${ix.unique ? 'u' : ''}|${ix.columns.join(',')}|${normalizeCheck(ix.where ?? '')}`;
283
+ }
284
+
285
+ /**
286
+ * Diff standalone indexes by content, not name: an index with the same columns, uniqueness, and
287
+ * partial predicate is the same index whatever it's called, so a pulled index (whose DB name we
288
+ * didn't choose) re-generates clean. A dropped index is a removal, gated like the others.
289
+ */
290
+ function diffIndexes(d: TableSchema, c: TableSchema, creates: SchemaChange[], drops: SchemaChange[]): void {
291
+ const desiredByKey = new Map(d.indexes.map((ix) => [indexKey(ix), ix]));
292
+ const currentByKey = new Map(c.indexes.map((ix) => [indexKey(ix), ix]));
293
+ for (const [k, ix] of currentByKey) {
294
+ if (!desiredByKey.has(k)) drops.push({ kind: 'dropIndex', table: d.table, name: ix.name });
295
+ }
296
+ for (const [k, ix] of desiredByKey) {
297
+ if (!currentByKey.has(k)) creates.push(createIndexChange(d.table, ix));
298
+ }
299
+ }
300
+
301
+ interface EnumDiff {
302
+ creates: SchemaChange[];
303
+ adds: SchemaChange[];
304
+ drops: SchemaChange[];
305
+ notices: SchemaChange[];
306
+ }
307
+
308
+ /**
309
+ * Diff declared enum types against introspected ones. A new type is a `CREATE TYPE`; a
310
+ * value appended to the END of an existing type is an `ALTER TYPE ADD VALUE` (Postgres
311
+ * can only append, and only one at a time — so each is its own change, warned at emit). A
312
+ * type the database has but no model declares is a `dropType` (gated like other removals).
313
+ * Any other value-set change — a removed value, a reorder, a rename — Postgres cannot do in
314
+ * place; it is surfaced as a notice, never a silent or destructive guess.
315
+ */
316
+ function diffEnums(desired: EnumType[], current: EnumType[]): EnumDiff {
317
+ const out: EnumDiff = { creates: [], adds: [], drops: [], notices: [] };
318
+ const currentByName = new Map(current.map((e) => [e.name, e]));
319
+ const desiredNames = new Set(desired.map((e) => e.name));
320
+
321
+ for (const d of desired) {
322
+ const c = currentByName.get(d.name);
323
+ if (!c) {
324
+ out.creates.push({ kind: 'createType', name: d.name, values: d.values });
325
+ continue;
326
+ }
327
+ if (d.values.join(',') === c.values.join(',')) continue;
328
+ // Pure append? current must be a prefix of desired — then each extra value is ADD VALUE.
329
+ const isAppend = d.values.length > c.values.length && c.values.every((v, i) => d.values[i] === v);
330
+ if (isAppend) {
331
+ for (const value of d.values.slice(c.values.length)) out.adds.push({ kind: 'addEnumValue', type: d.name, value });
332
+ } else {
333
+ out.notices.push({ kind: 'enumValuesChanged', name: d.name, from: c.values, to: d.values });
334
+ }
335
+ }
336
+
337
+ for (const c of current) {
338
+ if (!desiredNames.has(c.name)) out.drops.push({ kind: 'dropType', name: c.name });
339
+ }
340
+
341
+ return out;
342
+ }
343
+
344
+ interface ColumnDiff {
345
+ renameColumns: SchemaChange[];
346
+ addColumns: SchemaChange[];
347
+ alters: SchemaChange[];
348
+ dropColumns: SchemaChange[];
349
+ notices: SchemaChange[];
350
+ }
351
+
352
+ /** Column-level type/nullability/default changes between a renamed-or-matched column and its source. */
353
+ function columnAlters(table: string, desired: ColumnSchema, source: ColumnSchema, into: SchemaChange[]): void {
354
+ if (desired.type !== source.type) into.push({ kind: 'alterType', table, column: desired.name, from: source.type, to: desired.type, risk: classifyTypeChange(source.type, desired.type) });
355
+ if (desired.notNull !== source.notNull) into.push({ kind: 'setNotNull', table, column: desired.name, notNull: desired.notNull });
356
+ if (normalizeDefault(desired.default) !== normalizeDefault(source.default)) into.push({ kind: 'setDefault', table, column: desired.name, expr: desired.default });
357
+ }
358
+
359
+ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string, string>): ColumnDiff {
360
+ const out: ColumnDiff = { renameColumns: [], addColumns: [], alters: [], dropColumns: [], notices: [] };
361
+ const currentByName = new Map(c.columns.map((col) => [col.name, col]));
362
+ const desiredNames = new Set(d.columns.map((col) => col.name));
363
+ const consumedByRename = new Set<string>(); // current columns claimed by a rename (so not dropped)
364
+
365
+ for (const col of d.columns) {
366
+ const cur = currentByName.get(col.name);
367
+ const oldName = tableRenames[col.name]; // a `.renamedFrom()` marker on this field, if any
368
+
369
+ if (cur) {
370
+ // New name already present. A marker here means the rename is already applied → inert.
371
+ if (oldName) out.notices.push({ kind: 'renameSatisfied', table: d.table, column: col.name, from: oldName });
372
+ columnAlters(d.table, col, cur, out.alters);
373
+ continue;
374
+ }
375
+
376
+ // New name absent: an add, or a rename of an existing column.
377
+ const source = oldName ? currentByName.get(oldName) : undefined;
378
+ if (oldName && source) {
379
+ out.renameColumns.push({ kind: 'renameColumn', table: d.table, from: oldName, to: col.name });
380
+ consumedByRename.add(oldName);
381
+ columnAlters(d.table, col, source, out.alters); // a rename may also change type/default/nullability
382
+ } else if (oldName) {
383
+ // Marker points at a column the database doesn't have — fall back to an honest add.
384
+ out.addColumns.push({ kind: 'addColumn', table: d.table, column: col });
385
+ out.notices.push({ kind: 'renameSourceMissing', table: d.table, column: col.name, from: oldName });
386
+ } else {
387
+ out.addColumns.push({ kind: 'addColumn', table: d.table, column: col });
388
+ }
389
+ }
390
+
391
+ for (const col of c.columns) {
392
+ if (!desiredNames.has(col.name) && !consumedByRename.has(col.name)) {
393
+ out.dropColumns.push({ kind: 'dropColumn', table: d.table, column: col.name });
394
+ }
395
+ }
396
+
397
+ // Unmarked-rename hint: with no marker involved on this table, a lone same-type drop+add
398
+ // *might* be a rename the author forgot to mark. Surface it as a comment — never act on it
399
+ // (that guess is exactly the data-corrupting heuristic the marker exists to avoid).
400
+ if (out.notices.length === 0 && out.addColumns.length === 1 && out.dropColumns.length === 1) {
401
+ const added = (out.addColumns[0] as { column: ColumnSchema }).column;
402
+ const dropped = currentByName.get((out.dropColumns[0] as { column: string }).column)!;
403
+ if (added.type === dropped.type) {
404
+ out.notices.push({ kind: 'renameHint', table: d.table, from: dropped.name, to: added.name });
405
+ }
406
+ }
407
+
408
+ return out;
409
+ }
410
+
411
+ /**
412
+ * Name-keyed constraints whose names are content-derived and stable — uniques (named by
413
+ * their columns) and foreign keys. Checks are handled separately, matched by predicate, because
414
+ * a `check(...)` carries no author-given name and the database's name is arbitrary.
415
+ */
416
+ function namedConstraints(t: TableSchema): Map<string, ConstraintSpec> {
417
+ const m = new Map<string, ConstraintSpec>();
418
+ for (const u of t.uniques) m.set(u.name, { type: 'unique', name: u.name, columns: u.columns });
419
+ for (const fk of t.foreignKeys) m.set(fk.name, { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate });
420
+ return m;
421
+ }
422
+
423
+ /**
424
+ * Diff CHECK constraints by their normalized predicate, not their name: a check with the
425
+ * same predicate is the same check no matter what it's called, so a pulled check (whose DB
426
+ * name we didn't choose) re-generates clean. A changed predicate drops the old (by its real
427
+ * name) and adds the new.
428
+ */
429
+ function diffChecks(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[]): void {
430
+ const desiredByPred = new Map(d.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
431
+ const currentByPred = new Map(c.checks.map((ck) => [normalizeCheck(ck.expr), ck]));
432
+ for (const [pred, ck] of currentByPred) {
433
+ if (!desiredByPred.has(pred)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name: ck.name });
434
+ }
435
+ for (const [pred, ck] of desiredByPred) {
436
+ if (!currentByPred.has(pred)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: { type: 'check', name: ck.name, expr: ck.expr } });
437
+ }
438
+ }
439
+
440
+ function constraintsEqual(a: ConstraintSpec, b: ConstraintSpec): boolean {
441
+ if (a.type !== b.type) return false;
442
+ if (a.type === 'unique' && b.type === 'unique') return a.columns.join(',') === b.columns.join(',');
443
+ if (a.type === 'check' && b.type === 'check') return normalizeCheck(a.expr) === normalizeCheck(b.expr);
444
+ if (a.type === 'foreignKey' && b.type === 'foreignKey') {
445
+ return a.columns.join(',') === b.columns.join(',')
446
+ && a.refTable === b.refTable
447
+ && a.refColumns.join(',') === b.refColumns.join(',')
448
+ && (a.onDelete ?? '') === (b.onDelete ?? '')
449
+ && (a.onUpdate ?? '') === (b.onUpdate ?? '');
450
+ }
451
+ return false;
452
+ }
453
+
454
+ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: SchemaChange[], addConstraints: SchemaChange[], alters: SchemaChange[]): void {
455
+ const desired = namedConstraints(d);
456
+ const current = namedConstraints(c);
457
+
458
+ // Drop removed or changed constraints first (a changed one is dropped here, re-added below).
459
+ for (const [name, cur] of current) {
460
+ const des = desired.get(name);
461
+ if (!des || !constraintsEqual(des, cur)) dropConstraints.push({ kind: 'dropConstraint', table: d.table, name });
462
+ }
463
+ for (const [name, des] of desired) {
464
+ const cur = current.get(name);
465
+ if (!cur || !constraintsEqual(des, cur)) addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
466
+ }
467
+
468
+ diffChecks(d, c, dropConstraints, addConstraints);
469
+
470
+ // Primary key: a first-time add is safe; any other change needs the existing
471
+ // constraint's name to drop it (which the snapshot discards), so it is surfaced.
472
+ const dPk = d.primaryKey.join(',');
473
+ const cPk = c.primaryKey.join(',');
474
+ if (dPk !== cPk) {
475
+ if (c.primaryKey.length === 0) addConstraints.push({ kind: 'addPrimaryKey', table: d.table, columns: d.primaryKey });
476
+ else alters.push({ kind: 'primaryKeyChange', table: d.table, from: c.primaryKey, to: d.primaryKey });
477
+ }
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Emit — change set to DDL.
482
+ // ---------------------------------------------------------------------------
483
+
484
+ const quote = (name: string): string => `"${name}"`;
485
+ const colList = (cols: string[]): string => cols.map(quote).join(', ');
486
+ /** A single-quoted enum value literal, doubling any embedded quote. */
487
+ const enumLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
488
+
489
+ /** A column definition body (no leading indent): `"name" type [DEFAULT expr] [NOT NULL]`. */
490
+ function columnSql(col: ColumnSchema): string {
491
+ let s = `${quote(col.name)} ${col.type}`;
492
+ if (col.default != null) s += ` DEFAULT ${col.default}`;
493
+ if (col.notNull) s += ' NOT NULL';
494
+ return s;
495
+ }
496
+
497
+ /** `CREATE TABLE` from the IR: columns + PK/unique/check inline; FKs are emitted separately. */
498
+ function createTableSql(t: TableSchema): string {
499
+ const lines = t.columns.map((c) => `\t${columnSql(c)}`);
500
+ if (t.primaryKey.length) lines.push(`\tPRIMARY KEY (${colList(t.primaryKey)})`);
501
+ for (const u of t.uniques) lines.push(`\tCONSTRAINT ${quote(u.name)} UNIQUE (${colList(u.columns)})`);
502
+ for (const ck of t.checks) lines.push(`\tCONSTRAINT ${quote(ck.name)} CHECK ${wrapOnce(ck.expr)}`);
503
+ return `CREATE TABLE ${t.table} (\n${lines.join(',\n')}\n);`;
504
+ }
505
+
506
+ function addConstraintSql(table: string, c: ConstraintSpec): string {
507
+ const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
508
+ switch (c.type) {
509
+ case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
510
+ case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
511
+ case 'foreignKey': {
512
+ let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${quote(c.refTable)}(${colList(c.refColumns)})`;
513
+ if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
514
+ if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
515
+ return `${s};`;
516
+ }
517
+ }
518
+ }
519
+
520
+ export interface EmitOptions {
521
+ /**
522
+ * Override how a whole-new-table `createTable` renders. `db:generate` passes the greenfield
523
+ * `compileCreateTable` (byte-identical to what the app ships) instead of the IR renderer.
524
+ */
525
+ createTable?: (change: Extract<SchemaChange, { kind: 'createTable' }>) => string;
526
+ }
527
+
528
+ /** Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up. */
529
+ function emitOne(change: SchemaChange, opts: EmitOptions): string {
530
+ switch (change.kind) {
531
+ case 'createTable':
532
+ return opts.createTable ? opts.createTable(change) : createTableSql(change.schema);
533
+ case 'dropTable':
534
+ return `DROP TABLE ${change.table};`;
535
+ case 'addColumn': {
536
+ const ddl = `ALTER TABLE ${change.table} ADD COLUMN ${columnSql(change.column)};`;
537
+ // Adding a NOT NULL column with no default fails on a table that already has rows.
538
+ if (change.column.notNull && change.column.default == null) {
539
+ return `-- WARNING: ADD COLUMN ${quote(change.column.name)} NOT NULL on ${change.table} has no default and FAILS if the table has rows; add a default or backfill first.\n${ddl}`;
540
+ }
541
+ return ddl;
542
+ }
543
+ case 'dropColumn':
544
+ return `ALTER TABLE ${change.table} DROP COLUMN ${quote(change.column)};`;
545
+ case 'setNotNull': {
546
+ const ddl = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} ${change.notNull ? 'SET NOT NULL' : 'DROP NOT NULL'};`;
547
+ // SET NOT NULL fails if any existing row holds a null in that column.
548
+ if (change.notNull) {
549
+ return `-- WARNING: SET NOT NULL on ${quote(change.column)} (${change.table}) FAILS if existing rows hold nulls; backfill the column first.\n${ddl}`;
550
+ }
551
+ return ddl;
552
+ }
553
+ case 'setDefault':
554
+ return change.expr == null
555
+ ? `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} DROP DEFAULT;`
556
+ : `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DEFAULT ${change.expr};`;
557
+ case 'alterType': {
558
+ const setType = `ALTER TABLE ${change.table} ALTER COLUMN ${quote(change.column)} SET DATA TYPE ${change.to}`;
559
+ if (change.risk === 'safe') return `${setType};`;
560
+ const ddl = `${setType} USING ${quote(change.column)}::${change.to};`;
561
+ const warn = change.risk === 'lossy'
562
+ ? `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) may truncate or lose data; review before running.`
563
+ : `-- WARNING: ${change.from} → ${change.to} on column ${quote(change.column)} (${change.table}) casts with USING and may FAIL on rows that don't convert; review before running.`;
564
+ return `${warn}\n${ddl}`;
565
+ }
566
+ case 'addConstraint':
567
+ return addConstraintSql(change.table, change.constraint);
568
+ case 'dropConstraint':
569
+ return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
570
+ case 'addPrimaryKey':
571
+ return `ALTER TABLE ${change.table} ADD PRIMARY KEY (${colList(change.columns)});`;
572
+ case 'primaryKeyChange':
573
+ return `-- NOTICE: primary key on ${change.table} changed (${change.from.join(', ')} → ${change.to.join(', ')}); dropping a primary key needs its constraint name — regenerate this change manually.`;
574
+ case 'renameColumn':
575
+ return `ALTER TABLE ${change.table} RENAME COLUMN ${quote(change.from)} TO ${quote(change.to)};`;
576
+ case 'renameSatisfied':
577
+ return `-- NOTICE: the renamedFrom marker on column ${quote(change.column)} (${change.table}) is satisfied — the rename from "${change.from}" is applied, the marker is now inert and can be removed.`;
578
+ case 'renameSourceMissing':
579
+ return `-- NOTICE: a renamedFrom marker on column ${quote(change.column)} (${change.table}) points at column "${change.from}", which the database doesn't have; emitting ADD COLUMN instead — verify the marker.`;
580
+ case 'renameHint':
581
+ return `-- NOTICE: dropping ${quote(change.from)} and adding ${quote(change.to)} on ${change.table}; if this is a rename, replace those two with: ALTER TABLE ${change.table} RENAME COLUMN ${quote(change.from)} TO ${quote(change.to)}; (or mark the field .renamedFrom('${change.from}')).`;
582
+ case 'createType':
583
+ return `CREATE TYPE ${quote(change.name)} AS ENUM (${change.values.map(enumLiteral).join(', ')});`;
584
+ case 'addEnumValue':
585
+ return `-- WARNING: ADD VALUE to enum ${quote(change.type)} cannot run inside a transaction on PostgreSQL < 12, and the new value can't be used until the statement commits; apply it on its own.\nALTER TYPE ${quote(change.type)} ADD VALUE ${enumLiteral(change.value)};`;
586
+ case 'dropType':
587
+ return `DROP TYPE ${quote(change.name)};`;
588
+ case 'createIndex': {
589
+ const unique = change.unique ? 'UNIQUE ' : '';
590
+ const where = change.where ? ` WHERE ${change.where}` : '';
591
+ return `CREATE ${unique}INDEX ${quote(change.name)} ON ${change.table} (${colList(change.columns)})${where};`;
592
+ }
593
+ case 'dropIndex': {
594
+ const schema = change.table.includes('.') ? `${change.table.split('.')[0]}.` : '';
595
+ return `DROP INDEX ${schema}${quote(change.name)};`;
596
+ }
597
+ case 'enumValuesChanged':
598
+ return `-- NOTICE: enum ${quote(change.name)} values changed ([${change.from.join(', ')}] → [${change.to.join(', ')}]) in a way Postgres can't apply in place (a removal, reorder, or rename). ADD VALUE only appends; reshaping an enum needs a manual type swap (create new type, ALTER COLUMN ... TYPE using a cast, drop old).`;
599
+ }
600
+ }
601
+
602
+ /** The change set as an ordered list of SQL statements (and notice comments). */
603
+ export function emitSchemaSql(changes: SchemaChange[], opts: EmitOptions = {}): string[] {
604
+ return changes.map((c) => emitOne(c, opts));
605
+ }