@everystack/cli 0.4.83 → 0.4.85

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,331 @@
1
+ /**
2
+ * fk-preflight — the orphan check that runs BEFORE a foreign key's DDL, not during it.
3
+ *
4
+ * A foreign key is only installable if the rows already in the table satisfy it. PostgreSQL
5
+ * finds that out DURING `VALIDATE CONSTRAINT`, by raising on the first row that does not — one
6
+ * row, no count, no second offender, and (before the two-step split) after the apply had already
7
+ * taken an ACCESS EXCLUSIVE lock and scanned most of the table. The operator learned "there is a
8
+ * problem" at the worst possible moment and learned nothing else.
9
+ *
10
+ * So the apply asks the question first, the same way the declared-owner preflight in
11
+ * derived-apply does: ONE read that answers it for EVERY key the edge touches, so the refusal
12
+ * can name the COMPLETE list — how many rows, on which key, and what a few of them look like —
13
+ * and NOTHING has run when it is raised. Fix the data (`db:exec` is the credential-free lane for
14
+ * exactly that), re-plan, apply.
15
+ *
16
+ * WHICH KEYS NEED THE QUESTION ASKED. Only the ones where an existing row could already violate
17
+ * the constraint, which is a property of the LIVE database, not of the statement:
18
+ *
19
+ * - a key on a table this edge CREATES → no. The table has no rows yet, and it does
20
+ * not exist to be queried before the edge runs.
21
+ * - a key on a column this edge ADDS → no. Every existing row's value is NULL, which
22
+ * satisfies a foreign key (MATCH SIMPLE), and
23
+ * the column cannot be queried yet either.
24
+ * - a key whose TARGET this edge creates → no, and this one is a deliberate gap: the
25
+ * parent is empty until the edge runs, so the
26
+ * answer cannot be read beforehand. The VALIDATE
27
+ * then fails and the key stays NOT VALID — a
28
+ * pending state, not a rolled-back apply.
29
+ * - a key already live but NOT VALID → YES. This is the interrupted two-step add, and
30
+ * the rows it was never checked against are
31
+ * exactly what VALIDATE is about to scan.
32
+ * - a key on an existing table and columns → YES.
33
+ *
34
+ * An `ADD … NOT VALID` and the `VALIDATE` that follows it in the same edge are ONE question, not
35
+ * two — the key is the unit, not the statement.
36
+ *
37
+ * Pure: the parsing, the SQL, and the refusal text. The caller runs the query.
38
+ */
39
+
40
+ import type { SchemaSnapshot, TableSchema, ForeignKey } from './schema-introspect';
41
+ import { statementBody } from './sql-statement';
42
+ import { quoteIdent, quoteQualified } from './pg-ident';
43
+ import { escapeLiteral } from './derived-apply';
44
+
45
+ /** One foreign key whose orphans must be counted before any DDL runs. */
46
+ export interface FkOrphanCheck {
47
+ /** Schema-qualified, as the live snapshot spells it (`public.posts`). */
48
+ table: string;
49
+ /** The constraint name as it will exist when the VALIDATE runs. */
50
+ constraint: string;
51
+ columns: string[];
52
+ /** Schema-qualified target table. */
53
+ refTable: string;
54
+ refColumns: string[];
55
+ }
56
+
57
+ /** Strip quoting from one identifier token. */
58
+ const unquote = (id: string): string => (/^".*"$/.test(id) ? id.slice(1, -1).replace(/""/g, '"') : id);
59
+
60
+ /** `"public"."posts"` / `posts` / `public.posts` → `public.posts` (the snapshot's spelling). */
61
+ export function normalizeTableName(raw: string): string {
62
+ const parts = raw.trim().replace(/;$/, '').match(/("(?:[^"]|"")*"|[^.\s]+)/g) ?? [];
63
+ const names = parts.map(unquote);
64
+ if (names.length === 0) return raw;
65
+ return names.length === 1 ? `public.${names[0]}` : names.slice(-2).join('.');
66
+ }
67
+
68
+ /** `("a", "b")` → `['a','b']`. */
69
+ const parseColumns = (list: string): string[] =>
70
+ (list.replace(/^\(|\)$/g, '').match(/("(?:[^"]|"")*"|[^,\s]+)/g) ?? []).map(unquote);
71
+
72
+ const CREATE_TABLE_RE = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*)(?:\.(?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*))?)/i;
73
+ const ADD_COLUMN_RE = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?("(?:[^"]|"")*"|[A-Za-z_][\w$]*)/i;
74
+ const ADD_FK_RE = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+ADD\s+CONSTRAINT\s+("(?:[^"]|"")*"|[A-Za-z_][\w$]*)\s+FOREIGN\s+KEY\s*(\([^)]*\))\s*REFERENCES\s+((?:"(?:[^"]|"")*"|[^\s(]+)(?:\.(?:"(?:[^"]|"")*"|[^\s(]+))?)\s*(\([^)]*\))/i;
75
+ const VALIDATE_RE = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+VALIDATE\s+CONSTRAINT\s+("(?:[^"]|"")*"|[A-Za-z_][\w$]*)/i;
76
+ const RENAME_CONSTRAINT_RE = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+RENAME\s+CONSTRAINT\s+("(?:[^"]|"")*"|[A-Za-z_][\w$]*)\s+TO\s+("(?:[^"]|"")*"|[A-Za-z_][\w$]*)/i;
77
+
78
+ /** The live table under its qualified name, or undefined. */
79
+ const liveTable = (live: SchemaSnapshot, table: string): TableSchema | undefined =>
80
+ live.tables.find((t) => t.table === table);
81
+
82
+ /**
83
+ * The foreign keys an edge is about to install or validate that could ALREADY be violated by
84
+ * rows in the live database — see the rules in the module docblock.
85
+ *
86
+ * `statements` is the edge exactly as it will execute (held drops and notices included; they are
87
+ * comments and match nothing). `live` is the target's state as the apply just read it — the same
88
+ * snapshot the concurrency lock was computed from, so the two cannot describe different databases.
89
+ */
90
+ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapshot): FkOrphanCheck[] {
91
+ const bodies = statements.map(statementBody);
92
+
93
+ // Tables this edge creates: their rows do not exist yet, and neither do they.
94
+ const created = new Set<string>();
95
+ for (const body of bodies) {
96
+ const m = CREATE_TABLE_RE.exec(body);
97
+ if (m) created.add(normalizeTableName(m[1]));
98
+ }
99
+
100
+ // Columns this edge adds: every existing row holds NULL there, which satisfies the key.
101
+ const addedColumns = new Set<string>();
102
+ for (const body of bodies) {
103
+ const m = ADD_COLUMN_RE.exec(body);
104
+ if (m) addedColumns.add(`${normalizeTableName(m[1])}.${unquote(m[2])}`);
105
+ }
106
+
107
+ // A constraint renamed by this edge is validated under its NEW name, but it is LIVE under the
108
+ // old one — so the live lookup has to walk back through the rename.
109
+ const renamedFrom = new Map<string, string>();
110
+ for (const body of bodies) {
111
+ const m = RENAME_CONSTRAINT_RE.exec(body);
112
+ if (m) renamedFrom.set(`${normalizeTableName(m[1])}::${unquote(m[3])}`, unquote(m[2]));
113
+ }
114
+
115
+ const checks: FkOrphanCheck[] = [];
116
+ const seen = new Set<string>();
117
+
118
+ const push = (check: FkOrphanCheck): void => {
119
+ const key = `${check.table}::${check.constraint}`;
120
+ if (seen.has(key)) return;
121
+ seen.add(key);
122
+ checks.push(check);
123
+ };
124
+
125
+ for (const body of bodies) {
126
+ const add = ADD_FK_RE.exec(body);
127
+ if (add) {
128
+ const table = normalizeTableName(add[1]);
129
+ const columns = parseColumns(add[3]);
130
+ const target = liveTable(live, table);
131
+ // Not live yet, or one of its columns is not: nothing to scan, and nothing to scan it with.
132
+ if (!target) continue;
133
+ const liveColumns = new Set(target.columns.map((c) => c.name));
134
+ if (columns.some((c) => !liveColumns.has(c) || addedColumns.has(`${table}.${c}`))) continue;
135
+ const refTable = normalizeTableName(add[4]);
136
+ if (created.has(refTable) || !liveTable(live, refTable)) continue;
137
+ push({ table, constraint: unquote(add[2]), columns, refTable, refColumns: parseColumns(add[5]) });
138
+ continue;
139
+ }
140
+
141
+ const validate = VALIDATE_RE.exec(body);
142
+ if (validate) {
143
+ const table = normalizeTableName(validate[1]);
144
+ const constraint = unquote(validate[2]);
145
+ // Already covered when this edge also ADDs it — one key, one question.
146
+ if (seen.has(`${table}::${constraint}`)) continue;
147
+ const target = liveTable(live, table);
148
+ if (!target) continue;
149
+ const liveName = renamedFrom.get(`${table}::${constraint}`) ?? constraint;
150
+ const fk: ForeignKey | undefined = target.foreignKeys.find((f) => f.name === liveName);
151
+ // A VALIDATE naming a key the live database does not have is not a question this preflight
152
+ // can ask — it would be guessing at columns. The statement's own failure is the answer.
153
+ if (!fk) continue;
154
+ push({ table, constraint, columns: fk.columns, refTable: normalizeTableName(fk.refTable), refColumns: fk.refColumns });
155
+ }
156
+ }
157
+
158
+ return checks;
159
+ }
160
+
161
+ /** One row of {@link fkOrphanSql}'s result. */
162
+ export interface FkOrphanRow {
163
+ constraint_name: unknown;
164
+ table_name: unknown;
165
+ columns: unknown;
166
+ ref_table: unknown;
167
+ orphans: unknown;
168
+ samples: unknown;
169
+ }
170
+
171
+ /**
172
+ * ONE read answering the question for every key at once — a first-failure refusal would make the
173
+ * operator fix one table, re-run, and discover the next.
174
+ *
175
+ * The scan is a real anti-join over the child table, which costs about what the VALIDATE it is
176
+ * protecting would cost. That is the price of knowing BEFORE the lock instead of during it, and
177
+ * it buys an exact count rather than pg's one-row-and-stop.
178
+ *
179
+ * NULLs are excluded, not counted: under MATCH SIMPLE (the only mode everystack emits) a row with
180
+ * a NULL in any key column satisfies the constraint. Counting them would refuse an apply that
181
+ * PostgreSQL would have accepted, which is the worse failure — a gate nobody can pass gets
182
+ * disabled.
183
+ *
184
+ * THE COUNT IS UNBOUNDED AND THE SAMPLE IS NOT, and the asymmetry is deliberate. The count is
185
+ * the full scan, which is the price of the answer and is paid either way. The SAMPLE is three
186
+ * values for the operator to recognise a row by — so it is taken from a `LIMIT 3` subquery and
187
+ * aggregated over those three rows alone. Aggregating the whole set and slicing it afterwards
188
+ * (`(array_agg(…))[1:3]`) builds the ENTIRE array in work memory first, which array_agg cannot
189
+ * spill to disk: on a table with millions of orphans the gate that exists to protect the
190
+ * database is the thing that takes it down.
191
+ */
192
+ export function fkOrphanSql(checks: readonly FkOrphanCheck[]): string {
193
+ if (checks.length === 0) throw new Error('fkOrphanSql: no checks — the caller must skip the read');
194
+ return checks
195
+ .map((c) => {
196
+ // The columns come out of a REGEX over the ADD CONSTRAINT text. A short or empty parse is
197
+ // not a shape this read can paper over: joining the child's own column name against the
198
+ // parent errors on most schemas but SUCCEEDS on a parent that happens to carry the same
199
+ // name — and then counts zero, reporting a clean pass on a key nobody actually checked.
200
+ // Refuse, exactly as an empty check list is refused; the caller fails closed on the throw.
201
+ if (c.columns.length === 0) {
202
+ throw new Error(`fkOrphanSql: ${c.table}.${c.constraint} has no columns — the read cannot be built`);
203
+ }
204
+ if (c.refColumns.length !== c.columns.length) {
205
+ throw new Error(
206
+ `fkOrphanSql: ${c.table}.${c.constraint} names ${c.columns.length} column(s) but `
207
+ + `${c.refColumns.length} reference column(s) in ${c.refTable} — the read cannot be built `
208
+ + 'without guessing the join, and a guessed join can report a false clean.',
209
+ );
210
+ }
211
+ const child = quoteQualified(c.table);
212
+ const parent = quoteQualified(c.refTable);
213
+ const notNull = c.columns.map((col) => `c.${quoteIdent(col)} IS NOT NULL`).join(' AND ');
214
+ const join = c.columns.map((col, i) => `p.${quoteIdent(c.refColumns[i]!)} = c.${quoteIdent(col)}`).join(' AND ');
215
+ const where = (pad: string): string =>
216
+ `WHERE ${notNull}\n${pad} AND NOT EXISTS (SELECT 1 FROM ${parent} p WHERE ${join})`;
217
+ // One printable value per offending row: a single-column key prints the value, a composite
218
+ // one prints the tuple. Capped, because a sample is for recognising the row, not for
219
+ // reading its contents into a refusal message.
220
+ const sample = c.columns.length === 1
221
+ ? `left(c.${quoteIdent(c.columns[0]!)}::text, 80)`
222
+ : `left(concat_ws(', ', ${c.columns.map((col) => `c.${quoteIdent(col)}::text`).join(', ')}), 80)`;
223
+ // Two scalar subqueries over no FROM: exactly ONE row per arm, at any orphan count —
224
+ // zero orphans gives 0 and a NULL sample array rather than no row at all. The caller
225
+ // asserts that shape (checkOrphanRowShape) and fails closed if it does not hold.
226
+ return `SELECT ${escapeLiteral(c.constraint)} AS constraint_name,
227
+ ${escapeLiteral(c.table)} AS table_name,
228
+ ${escapeLiteral(c.columns.join(', '))} AS columns,
229
+ ${escapeLiteral(c.refTable)} AS ref_table,
230
+ (SELECT count(*)::bigint
231
+ FROM ${child} c
232
+ ${where(' ')}) AS orphans,
233
+ (SELECT array_agg(s.sample)
234
+ FROM (SELECT ${sample} AS sample
235
+ FROM ${child} c
236
+ ${where(' ')}
237
+ ORDER BY 1
238
+ LIMIT 3) s) AS samples`;
239
+ })
240
+ .join('\nUNION ALL\n');
241
+ }
242
+
243
+ /**
244
+ * The read returns exactly one row per key — see {@link fkOrphanSql}. Anything else is an answer
245
+ * nobody can read, and an unreadable answer is not "no orphans": it is the gate not running.
246
+ *
247
+ * FAIL CLOSED. A driver that hands back `[]` (a shape quirk, a transport that dropped the rows,
248
+ * a future change to the read) would otherwise walk straight through `fkOrphanRefusal`, which
249
+ * reports null — "every key is satisfied" — for an empty list. Asserted here, in the module that
250
+ * owns the read's shape, so the assertion cannot drift from the SQL it is about.
251
+ */
252
+ export function checkOrphanRowShape(rows: unknown, expected: number): asserts rows is FkOrphanRow[] {
253
+ const got = Array.isArray(rows) ? rows.length : null;
254
+ if (got === expected) return;
255
+ throw new Error(
256
+ `the foreign-key row check answered with ${got === null ? 'something that is not a row set' : `${got} row(s)`} `
257
+ + `for ${expected} key(s) — the read produces exactly one row per key, so this answer cannot be read.`,
258
+ );
259
+ }
260
+
261
+ /**
262
+ * The orphan count as a number, or a THROW.
263
+ *
264
+ * It used to coerce null / unreadable text to 0, which is the answer "this key is satisfied" —
265
+ * the single most dangerous default in this module. `0` is a finding; `null` is a missing
266
+ * answer, and the two must never be spelled the same way. The caller turns the throw into the
267
+ * fail-closed refusal an unreachable database gets.
268
+ */
269
+ const toCount = (v: unknown, where: string): number => {
270
+ if (v === null || v === undefined) throw new Error(`the orphan count for ${where} could not be read (it came back empty).`);
271
+ if (typeof v === 'object') throw new Error(`the orphan count for ${where} could not be read (unexpected shape).`);
272
+ if (typeof v === 'string' && v.trim() === '') throw new Error(`the orphan count for ${where} could not be read (it came back blank).`);
273
+ const n = typeof v === 'bigint' ? Number(v) : Number(v);
274
+ if (!Number.isFinite(n)) throw new Error(`the orphan count for ${where} could not be read (got ${JSON.stringify(String(v))}).`);
275
+ return n;
276
+ };
277
+
278
+ const toSamples = (v: unknown): string[] =>
279
+ Array.isArray(v) ? v.filter((s) => s != null).map((s) => String(s)) : [];
280
+
281
+ /**
282
+ * The refusal when the read itself could not run (no privilege on a referenced table, a lock
283
+ * timeout, a dropped connection).
284
+ *
285
+ * FAIL CLOSED, the same rule the reconciler applies to grants it cannot see: a gate that did not
286
+ * run has not passed, and an apply that proceeds on an unanswered question is exactly the
287
+ * mid-VALIDATE surprise this preflight exists to remove. Nothing has run when this is raised.
288
+ */
289
+ export function fkPreflightUnavailableRefusal(error: string): string {
290
+ return [
291
+ `the foreign-key row check could not run against this target — refused before any DDL, because a check that did not run has not passed.`,
292
+ ` ${error}`,
293
+ `Grant the applying role SELECT on the tables the keys join, or run the apply from a role that has it, then re-plan and apply.`,
294
+ ].join('\n');
295
+ }
296
+
297
+ /**
298
+ * The refusal text, or null when every key's rows already satisfy it.
299
+ *
300
+ * It names the COUNT and a SAMPLE per key, because those are the two things the operator needs
301
+ * and neither is in PostgreSQL's own error: how much work the fix is, and what the broken rows
302
+ * look like. The fix is data, so the escape is a data fix and a re-plan — never a flag. There is
303
+ * deliberately no `--force`: an unsatisfiable foreign key is not an intent someone can attest to.
304
+ *
305
+ * THROWS when a row's count cannot be read — see {@link toCount}. `null` returned from here means
306
+ * "asked and satisfied", so an unreadable count must not be able to produce it.
307
+ */
308
+ export function fkOrphanRefusal(rows: readonly FkOrphanRow[]): string | null {
309
+ const where = (r: FkOrphanRow): string => `${String(r.table_name)}.${String(r.constraint_name)}`;
310
+ const counted = rows.map((r) => ({ row: r, count: toCount(r.orphans, where(r)) }));
311
+ const offenders = counted.filter((c) => c.count > 0);
312
+ if (offenders.length === 0) return null;
313
+
314
+ const lines = [
315
+ `${offenders.length} foreign key(s) cannot be validated — rows already in the table do not satisfy them. Refused before any DDL, so nothing is half-applied.`,
316
+ `Fix the data first (everystack db:exec runs a DML-only file against the target, credential-free), then re-plan and apply.`,
317
+ ];
318
+ for (const { row, count } of offenders) {
319
+ const samples = toSamples(row.samples);
320
+ lines.push(
321
+ ` ORPHANED ROWS ${String(row.table_name)}.${String(row.constraint_name)}: ${count} row(s) hold a value in (${String(row.columns)}) with no matching row in ${String(row.ref_table)}.`,
322
+ );
323
+ if (samples.length > 0) {
324
+ lines.push(` sample value(s): ${samples.join(', ')}${count > samples.length ? ', …' : ''}`);
325
+ }
326
+ lines.push(
327
+ ` the rows: SELECT * FROM ${row.table_name} c WHERE NOT EXISTS (SELECT 1 FROM ${row.ref_table} p WHERE p.<key> = c.<fk>);`,
328
+ );
329
+ }
330
+ return lines.join('\n');
331
+ }
@@ -21,7 +21,7 @@ import type { AuthzContract, SequenceGrantContract } from './authz-contract';
21
21
  import { compileTableSchema, compileRenames, compileTableRenames, compileTableMoves, compileCreateTable, compileEnums, compileSequences } from './schema-compile';
22
22
  import { compileTableContract } from './authz-compile';
23
23
  import { emitReconcileSql, governedRoleSet } from './authz-reconcile';
24
- import { diffSchema, emitSchemaSql, ownSequenceName, type SchemaChange } from './schema-diff';
24
+ import { diffSchema, emitSchemaStatements, ownSequenceName, type SchemaChange } from './schema-diff';
25
25
  import { schemaOf } from './schema-fingerprint';
26
26
 
27
27
  export interface GenerateOptions {
@@ -195,7 +195,7 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
195
195
  const changes = diffSchema(desired, scopedCurrent, renames, tableRenames, tableMoves);
196
196
 
197
197
  const modelByTable = new Map(models.map((m) => [`${schemaOf(m)}.${m.table}`, m]));
198
- const emitted = emitSchemaSql(changes, {
198
+ const emitted = emitSchemaStatements(changes, {
199
199
  createTable: (change) => {
200
200
  const model = modelByTable.get(change.table);
201
201
  if (!model) throw new Error(`db:generate: no model found for new table ${change.table}`);
@@ -205,17 +205,18 @@ export function generateMigrationSql(models: ModelDescriptor[], current: SchemaS
205
205
 
206
206
  // Non-destructive by default: a column/constraint/table in the database but absent
207
207
  // from the Models would otherwise become a DROP — silent data loss for a partial or
208
- // lossy Model set. Hold removals back as comments unless `allowDrops`. emitSchemaSql
209
- // returns one statement per change, index-aligned, so the gate stays a pure
210
- // post-pass — `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
211
- let dataPhase = emitted;
208
+ // lossy Model set. Hold removals back as comments unless `allowDrops`. Each emitted
209
+ // statement carries the change it came from (a foreign-key add is TWO statements, so a
210
+ // parallel index is not a pairing), which keeps the gate a pure post-pass —
211
+ // `diffSchema`/`emitSchemaSql` remain honest (they always emit the DROP).
212
+ let dataPhase = emitted.map((e) => e.sql);
212
213
  if (!opts.allowDrops) {
213
214
  const replacedConstraints = new Set(
214
215
  changes
215
216
  .filter((c): c is Extract<SchemaChange, { kind: 'addConstraint' }> => c.kind === 'addConstraint')
216
217
  .map((c) => `${c.table}::${c.constraint.name}`),
217
218
  );
218
- dataPhase = emitted.map((sql, i) => (isRemoval(changes[i], replacedConstraints) ? holdDrop(sql) : sql));
219
+ dataPhase = emitted.map(({ change, sql }) => (isRemoval(change, replacedConstraints) ? holdDrop(sql) : sql));
219
220
  }
220
221
 
221
222
  // Authz phase — appended after the data phase so a policy/grant references columns the
@@ -157,6 +157,12 @@ export function describeChange(statement: string, opts: DescribeOptions = {}): P
157
157
  if ((a = /^DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?("[^"]+"|[\w$]+)/i.exec(rest))) {
158
158
  return { symbol: '-', text: `remove constraint ${unquote(a[1])} on ${table}` };
159
159
  }
160
+ // The second half of a two-step foreign-key add (and the whole of a re-plan that finds one
161
+ // half-applied). It adds nothing and removes nothing — it CHECKS the rows already there —
162
+ // so without its own line it fell through to the raw-statement fallback.
163
+ if ((a = /^VALIDATE\s+CONSTRAINT\s+("[^"]+"|[\w$]+)/i.exec(rest))) {
164
+ return { symbol: '+', text: `check existing rows against constraint ${unquote(a[1])} on ${table}` };
165
+ }
160
166
  if ((a = /^ALTER\s+COLUMN\s+("[^"]+"|[\w$]+)\s+([\s\S]+)$/i.exec(rest))) {
161
167
  const column = `${table}.${unquote(a[1])}`;
162
168
  const what = a[2].trim();
@@ -394,18 +400,20 @@ export interface PlanReportInput {
394
400
  stage?: string | null;
395
401
  /** The plan file to name in the Run line; null when the plan went to stdout. */
396
402
  planPath: string | null;
403
+ /** --verbose: the promise behind "(--verbose for the full list)" — show every line, uncapped. */
404
+ verbose?: boolean;
397
405
  }
398
406
 
399
407
  /** db:plan's default output — the whole thing. */
400
408
  export function renderPlanReport(input: PlanReportInput): string[] {
401
- const { executable, target, stage, planPath } = input;
409
+ const { executable, target, stage, planPath, verbose = false } = input;
402
410
  if (executable.length === 0) return ['Up to date. Nothing to plan.'];
403
411
 
404
412
  const destructive = new Set(input.destructive);
405
413
  const lines: string[] = [
406
414
  `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
407
415
  '',
408
- ...changeLines(executable, destructive, MAX_LISTED_CHANGES),
416
+ ...changeLines(executable, destructive, verbose ? executable.length : MAX_LISTED_CHANGES),
409
417
  '',
410
418
  ];
411
419
 
@@ -41,7 +41,11 @@ import type {
41
41
  export type ConstraintSpec =
42
42
  | { type: 'unique'; name: string; columns: string[] }
43
43
  | { type: 'check'; name: string; expr: string }
44
- | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean };
44
+ | { type: 'foreignKey'; name: string; columns: string[]; refTable: string; refColumns: string[]; onDelete?: string; onUpdate?: string; nameDeclared?: boolean;
45
+ /** LIVE-SIDE ONLY: the key is installed but unchecked (see {@link ForeignKey.notValid}).
46
+ * It is never part of {@link constraintContentKey} — a NOT VALID key IS the declared key,
47
+ * one step short — and the emitter ignores it, because an ADD is always emitted NOT VALID. */
48
+ notValid?: boolean };
45
49
 
46
50
  export type SchemaChange =
47
51
  | { kind: 'createTable'; table: string; schema: TableSchema }
@@ -64,6 +68,15 @@ export type SchemaChange =
64
68
  /** A type change → real `ALTER COLUMN ... TYPE`. `risk` decides USING + a data-loss warning. */
65
69
  | { kind: 'alterType'; table: string; column: string; from: string; to: string; risk: TypeChangeRisk }
66
70
  | { kind: 'addConstraint'; table: string; constraint: ConstraintSpec; note?: string }
71
+ /**
72
+ * A foreign key that is already installed on the live table but has never been checked
73
+ * against its rows (`NOT VALID`) → the second half of the two-step add, on its own.
74
+ *
75
+ * It is a DISTINCT kind from `addConstraint` because the ADD must not be re-emitted: the
76
+ * constraint is there, and a second `ADD CONSTRAINT` under the same name fails outright.
77
+ * This is what the state after an interrupted two-step add re-plans to — VALIDATE only.
78
+ */
79
+ | { kind: 'validateConstraint'; table: string; name: string }
67
80
  | { kind: 'dropConstraint'; table: string; name: string }
68
81
  | { kind: 'addPrimaryKey'; table: string; columns: string[] }
69
82
  /** A PK change other than first-time add — a notice (the drop needs its constraint name). */
@@ -791,7 +804,7 @@ function diffColumns(d: TableSchema, c: TableSchema, tableRenames: Record<string
791
804
  function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
792
805
  return [
793
806
  ...t.uniques.map((u): ConstraintSpec => ({ type: 'unique', name: u.name, columns: u.columns })),
794
- ...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.nameDeclared ? { nameDeclared: true } : {}) })),
807
+ ...t.foreignKeys.map((fk): ConstraintSpec => ({ type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.nameDeclared ? { nameDeclared: true } : {}), ...(fk.notValid ? { notValid: true } : {}) })),
795
808
  ];
796
809
  }
797
810
 
@@ -857,6 +870,17 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
857
870
  if (des.type === 'foreignKey' && des.nameDeclared && matched.name !== des.name) {
858
871
  renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
859
872
  }
873
+ // The live key is there but was never checked against the rows already in the table —
874
+ // the state an interrupted two-step add leaves behind, and the state a hand-run
875
+ // `ADD CONSTRAINT … NOT VALID` leaves behind. The declared side is a VALIDATED key, so
876
+ // this is a real difference and the edge finishes it: VALIDATE alone, never a second
877
+ // ADD (which would fail on the name that is already taken). Under the live name, which
878
+ // may not be the declared one — a rename emitted just above lands FIRST, so the
879
+ // validate must speak the name the constraint will have by then.
880
+ if (matched.type === 'foreignKey' && matched.notValid) {
881
+ const name = des.type === 'foreignKey' && des.nameDeclared ? des.name : matched.name;
882
+ addConstraints.push({ kind: 'validateConstraint', table: d.table, name });
883
+ }
860
884
  } else {
861
885
  addConstraints.push({ kind: 'addConstraint', table: d.table, constraint: des });
862
886
  }
@@ -934,11 +958,39 @@ function createTableSql(t: TableSchema): string {
934
958
  return `CREATE TABLE ${t.table} (\n${lines.join(',\n')}\n);`;
935
959
  }
936
960
 
937
- function addConstraintSql(table: string, c: ConstraintSpec): string {
961
+ /**
962
+ * `ALTER TABLE … VALIDATE CONSTRAINT …` — the second half of a two-step foreign key add.
963
+ *
964
+ * ONE definition, read by the FK add (which always emits the pair) and by the change kind that
965
+ * finishes a key some earlier apply left NOT VALID. Two spellings of the same statement is how
966
+ * the "did this already run?" question ends up with two answers.
967
+ */
968
+ export function validateConstraintSql(table: string, name: string): string {
969
+ return `ALTER TABLE ${table} VALIDATE CONSTRAINT ${quote(name)};`;
970
+ }
971
+
972
+ /**
973
+ * Rendering an `ADD CONSTRAINT`: ONE statement, except a foreign key, which is TWO.
974
+ *
975
+ * A validated FK add takes an ACCESS EXCLUSIVE lock on the table for the length of a full scan
976
+ * of every row already in it — on a large, live table that is a write outage measured in
977
+ * minutes. PostgreSQL's own answer is to split the work:
978
+ *
979
+ * 1. `ADD CONSTRAINT … NOT VALID` — catalog-only and near-instant. The key is enforced on
980
+ * every row written from this moment on; the rows already there are simply unchecked.
981
+ * 2. `VALIDATE CONSTRAINT …` — the scan, under a SHARE UPDATE EXCLUSIVE lock that readers
982
+ * and writers both pass through.
983
+ *
984
+ * The split only buys that if the two run in SEPARATE transactions (step 1's lock is held to
985
+ * commit), which is why the apply path runs every VALIDATE after the edge's batch commits — see
986
+ * `splitValidations` in state-apply. The text is produced HERE for both `db:diff` (no database)
987
+ * and `db:plan` (against a live one), so the two can never render the same edge differently.
988
+ */
989
+ function addConstraintSql(table: string, c: ConstraintSpec): string[] {
938
990
  const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
939
991
  switch (c.type) {
940
- case 'unique': return `${head} UNIQUE (${colList(c.columns)});`;
941
- case 'check': return `${head} CHECK ${wrapOnce(c.expr)};`;
992
+ case 'unique': return [`${head} UNIQUE (${colList(c.columns)});`];
993
+ case 'check': return [`${head} CHECK ${wrapOnce(c.expr)};`];
942
994
  case 'foreignKey': {
943
995
  // refTable may be schema-qualified (`auth.users`) for a cross-schema FK — quote each part,
944
996
  // not the whole string, or `"auth.users"` reads as one identifier.
@@ -946,7 +998,7 @@ function addConstraintSql(table: string, c: ConstraintSpec): string {
946
998
  let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
947
999
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
948
1000
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
949
- return `${s};`;
1001
+ return [`${s} NOT VALID;`, validateConstraintSql(table, c.name)];
950
1002
  }
951
1003
  }
952
1004
  }
@@ -959,8 +1011,16 @@ export interface EmitOptions {
959
1011
  createTable?: (change: Extract<SchemaChange, { kind: 'createTable' }>) => string;
960
1012
  }
961
1013
 
962
- /** Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up. */
963
- function emitOne(change: SchemaChange, opts: EmitOptions): string {
1014
+ /**
1015
+ * Render one change to SQL — or a `-- NOTICE:` comment for the cases held for a follow-up.
1016
+ *
1017
+ * Returns a STRING for the one-statement kinds and an ARRAY for the kinds that are genuinely
1018
+ * more than one statement (today: a foreign-key add, which is `NOT VALID` + `VALIDATE`).
1019
+ * `emitSchemaStatements` flattens it and keeps each statement paired with the change it came
1020
+ * from, so the callers that decide something per change (the held-drop gate) never have to
1021
+ * assume one-statement-per-change.
1022
+ */
1023
+ function emitOne(change: SchemaChange, opts: EmitOptions): string | string[] {
964
1024
  switch (change.kind) {
965
1025
  case 'createTable':
966
1026
  return opts.createTable ? opts.createTable(change) : createTableSql(change.schema);
@@ -1011,8 +1071,14 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1011
1071
  : `-- 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.`;
1012
1072
  return `${warn}\n${ddl}`;
1013
1073
  }
1014
- case 'addConstraint':
1015
- return (change.note ? `${change.note}\n` : '') + addConstraintSql(change.table, change.constraint);
1074
+ case 'addConstraint': {
1075
+ const [first, ...rest] = addConstraintSql(change.table, change.constraint);
1076
+ // The note rides the FIRST statement only — it documents the change, and repeating it on
1077
+ // the VALIDATE would read as a second finding.
1078
+ return [(change.note ? `${change.note}\n` : '') + first, ...rest];
1079
+ }
1080
+ case 'validateConstraint':
1081
+ return validateConstraintSql(change.table, change.name);
1016
1082
  case 'dropConstraint':
1017
1083
  return `ALTER TABLE ${change.table} DROP CONSTRAINT IF EXISTS ${quote(change.name)};`;
1018
1084
  case 'addPrimaryKey':
@@ -1088,7 +1154,28 @@ function emitOne(change: SchemaChange, opts: EmitOptions): string {
1088
1154
  }
1089
1155
  }
1090
1156
 
1157
+ /** One emitted statement and the change that produced it. */
1158
+ export interface EmittedStatement {
1159
+ change: SchemaChange;
1160
+ sql: string;
1161
+ }
1162
+
1163
+ /**
1164
+ * The change set as an ordered list of statements, each still carrying its change.
1165
+ *
1166
+ * A change is NOT one statement any more (a foreign-key add is `NOT VALID` + `VALIDATE`), so
1167
+ * callers that decide something per change — db:generate's held-drop gate is the only one —
1168
+ * read the pairing from here instead of indexing a parallel array. Index alignment was a real
1169
+ * invariant right up until it silently was not.
1170
+ */
1171
+ export function emitSchemaStatements(changes: SchemaChange[], opts: EmitOptions = {}): EmittedStatement[] {
1172
+ return changes.flatMap((change) => {
1173
+ const emitted = emitOne(change, opts);
1174
+ return (Array.isArray(emitted) ? emitted : [emitted]).map((sql) => ({ change, sql }));
1175
+ });
1176
+ }
1177
+
1091
1178
  /** The change set as an ordered list of SQL statements (and notice comments). */
1092
1179
  export function emitSchemaSql(changes: SchemaChange[], opts: EmitOptions = {}): string[] {
1093
- return changes.map((c) => emitOne(c, opts));
1180
+ return emitSchemaStatements(changes, opts).map((e) => e.sql);
1094
1181
  }