@everystack/cli 0.4.84 → 0.4.86

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,414 @@
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 VALIDATABLE 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 would validate, so the
12
+ * report can name the COMPLETE list — how many rows, on which key, and what a few of them look
13
+ * like — before anything runs.
14
+ *
15
+ * WHAT THE ANSWER IS FOR, and this is the part that changed. It used to refuse the WHOLE apply
16
+ * if any key had one orphan: unrelated grants, unrelated clean keys, the entire edge, held
17
+ * behind a data audit. On a long-lived database that is the wrong trade — ~11,500 legitimately
18
+ * orphaned rows across 12 of 23 keys is ordinary decay, not a broken deploy. Now the answer is
19
+ * PER KEY: the offending key's `VALIDATE` is withheld (the key stays installed and NOT VALID,
20
+ * enforced on every new write), it is recorded as a pending validation, and everything else in
21
+ * the edge commits. Fix the data when you choose (`db:exec` is the credential-free lane) and the
22
+ * next plan emits that one `VALIDATE` alone — or declare `validate: false` on the reference and
23
+ * the question is never asked about that key again.
24
+ *
25
+ * THE UNIT IS A `VALIDATE` STATEMENT, not a foreign key add. That is the whole rule, and it is
26
+ * why a key declared `validate: false` is never scanned: the differ emits no `VALIDATE` for it,
27
+ * so there is nothing here to ask about. An `ADD … NOT VALID` and the `VALIDATE` that follows it
28
+ * in the same edge are ONE question — the key is the unit, not the statement — and an
29
+ * `ADD … NOT VALID` with NO `VALIDATE` behind it is not a question at all.
30
+ *
31
+ * WHICH VALIDATED KEYS NEED THE QUESTION ASKED. Only the ones where an existing row could
32
+ * already violate the constraint, which is a property of the LIVE database, not of the statement:
33
+ *
34
+ * - a key on a table this edge CREATES → no. The table has no rows yet, and it does
35
+ * not exist to be queried before the edge runs.
36
+ * - a key on a column this edge ADDS → no. Every existing row's value is NULL, which
37
+ * satisfies a foreign key (MATCH SIMPLE), and
38
+ * the column cannot be queried yet either.
39
+ * - a key whose TARGET this edge creates → no, and this one is a deliberate gap: the
40
+ * parent is empty until the edge runs, so the
41
+ * answer cannot be read beforehand. The VALIDATE
42
+ * then fails and the key stays NOT VALID — a
43
+ * pending state, not a rolled-back apply.
44
+ * - a key already live but NOT VALID → YES. This is the interrupted two-step add, and
45
+ * the rows it was never checked against are
46
+ * exactly what VALIDATE is about to scan.
47
+ * - a key on an existing table and columns → YES.
48
+ *
49
+ * Pure: the parsing, the SQL, the findings, and the text. The caller runs the query.
50
+ */
51
+
52
+ import type { SchemaSnapshot, TableSchema, ForeignKey } from './schema-introspect';
53
+ import type { PendingValidation } from './state-apply';
54
+ import { statementBody } from './sql-statement';
55
+ import { quoteIdent, quoteQualified } from './pg-ident';
56
+ import { escapeLiteral } from './derived-apply';
57
+
58
+ /** One foreign key whose orphans must be counted before any DDL runs. */
59
+ export interface FkOrphanCheck {
60
+ /** Schema-qualified, as the live snapshot spells it (`public.posts`). */
61
+ table: string;
62
+ /** The constraint name as it will exist when the VALIDATE runs. */
63
+ constraint: string;
64
+ columns: string[];
65
+ /** Schema-qualified target table. */
66
+ refTable: string;
67
+ refColumns: string[];
68
+ }
69
+
70
+ /** Strip quoting from one identifier token. */
71
+ const unquote = (id: string): string => (/^".*"$/.test(id) ? id.slice(1, -1).replace(/""/g, '"') : id);
72
+
73
+ /** `"public"."posts"` / `posts` / `public.posts` → `public.posts` (the snapshot's spelling). */
74
+ export function normalizeTableName(raw: string): string {
75
+ const parts = raw.trim().replace(/;$/, '').match(/("(?:[^"]|"")*"|[^.\s]+)/g) ?? [];
76
+ const names = parts.map(unquote);
77
+ if (names.length === 0) return raw;
78
+ return names.length === 1 ? `public.${names[0]}` : names.slice(-2).join('.');
79
+ }
80
+
81
+ /** `("a", "b")` → `['a','b']`. */
82
+ const parseColumns = (list: string): string[] =>
83
+ (list.replace(/^\(|\)$/g, '').match(/("(?:[^"]|"")*"|[^,\s]+)/g) ?? []).map(unquote);
84
+
85
+ const CREATE_TABLE_RE = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*)(?:\.(?:"(?:[^"]|"")*"|[A-Za-z_][\w$]*))?)/i;
86
+ 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;
87
+ 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;
88
+ const VALIDATE_RE = /^ALTER\s+TABLE\s+(?:ONLY\s+)?(\S+)\s+VALIDATE\s+CONSTRAINT\s+("(?:[^"]|"")*"|[A-Za-z_][\w$]*)/i;
89
+ 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;
90
+
91
+ /** The live table under its qualified name, or undefined. */
92
+ const liveTable = (live: SchemaSnapshot, table: string): TableSchema | undefined =>
93
+ live.tables.find((t) => t.table === table);
94
+
95
+ /**
96
+ * The foreign keys an edge is about to VALIDATE that could ALREADY be violated by rows in the
97
+ * live database — see the rules in the module docblock.
98
+ *
99
+ * `statements` is the edge exactly as it will execute (held drops and notices included; they are
100
+ * comments and match nothing). `live` is the target's state as the apply just read it — the same
101
+ * snapshot the concurrency lock was computed from, so the two cannot describe different databases.
102
+ *
103
+ * Driven by the `VALIDATE` statements, never by the `ADD`s. An `ADD … NOT VALID` used to imply
104
+ * the question because the emitter always paired the two; a key declared `validate: false` is
105
+ * emitted with no pair, and scanning for it would be scanning a table to answer a question
106
+ * nobody asked. The ADD is still read — it is where a NEW key's columns and target come from,
107
+ * which the live snapshot cannot supply.
108
+ */
109
+ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapshot): FkOrphanCheck[] {
110
+ const bodies = statements.map(statementBody);
111
+
112
+ // Tables this edge creates: their rows do not exist yet, and neither do they.
113
+ const created = new Set<string>();
114
+ for (const body of bodies) {
115
+ const m = CREATE_TABLE_RE.exec(body);
116
+ if (m) created.add(normalizeTableName(m[1]));
117
+ }
118
+
119
+ // Columns this edge adds: every existing row holds NULL there, which satisfies the key.
120
+ const addedColumns = new Set<string>();
121
+ for (const body of bodies) {
122
+ const m = ADD_COLUMN_RE.exec(body);
123
+ if (m) addedColumns.add(`${normalizeTableName(m[1])}.${unquote(m[2])}`);
124
+ }
125
+
126
+ // A constraint renamed by this edge is validated under its NEW name, but it is LIVE under the
127
+ // old one — so the live lookup has to walk back through the rename.
128
+ const renamedFrom = new Map<string, string>();
129
+ for (const body of bodies) {
130
+ const m = RENAME_CONSTRAINT_RE.exec(body);
131
+ if (m) renamedFrom.set(`${normalizeTableName(m[1])}::${unquote(m[3])}`, unquote(m[2]));
132
+ }
133
+
134
+ // The keys this edge actually asks to be PROVEN. Everything below is about them and only
135
+ // them: a key whose `ADD … NOT VALID` stands alone is the declared state, not a pending step.
136
+ const validated = new Set<string>();
137
+ for (const body of bodies) {
138
+ const m = VALIDATE_RE.exec(body);
139
+ if (m) validated.add(`${normalizeTableName(m[1])}::${unquote(m[2])}`);
140
+ }
141
+
142
+ const checks: FkOrphanCheck[] = [];
143
+ const seen = new Set<string>();
144
+
145
+ const push = (check: FkOrphanCheck): void => {
146
+ const key = `${check.table}::${check.constraint}`;
147
+ if (seen.has(key)) return;
148
+ seen.add(key);
149
+ checks.push(check);
150
+ };
151
+
152
+ for (const body of bodies) {
153
+ const add = ADD_FK_RE.exec(body);
154
+ if (add) {
155
+ const table = normalizeTableName(add[1]);
156
+ // No `VALIDATE` behind this ADD — `validate: false`. Nothing to prove, nothing to scan.
157
+ if (!validated.has(`${table}::${unquote(add[2])}`)) continue;
158
+ const columns = parseColumns(add[3]);
159
+ const target = liveTable(live, table);
160
+ // Not live yet, or one of its columns is not: nothing to scan, and nothing to scan it with.
161
+ if (!target) continue;
162
+ const liveColumns = new Set(target.columns.map((c) => c.name));
163
+ if (columns.some((c) => !liveColumns.has(c) || addedColumns.has(`${table}.${c}`))) continue;
164
+ const refTable = normalizeTableName(add[4]);
165
+ if (created.has(refTable) || !liveTable(live, refTable)) continue;
166
+ push({ table, constraint: unquote(add[2]), columns, refTable, refColumns: parseColumns(add[5]) });
167
+ continue;
168
+ }
169
+
170
+ const validate = VALIDATE_RE.exec(body);
171
+ if (validate) {
172
+ const table = normalizeTableName(validate[1]);
173
+ const constraint = unquote(validate[2]);
174
+ // Already covered when this edge also ADDs it — one key, one question.
175
+ if (seen.has(`${table}::${constraint}`)) continue;
176
+ const target = liveTable(live, table);
177
+ if (!target) continue;
178
+ const liveName = renamedFrom.get(`${table}::${constraint}`) ?? constraint;
179
+ const fk: ForeignKey | undefined = target.foreignKeys.find((f) => f.name === liveName);
180
+ // A VALIDATE naming a key the live database does not have is not a question this preflight
181
+ // can ask — it would be guessing at columns. The statement's own failure is the answer.
182
+ if (!fk) continue;
183
+ push({ table, constraint, columns: fk.columns, refTable: normalizeTableName(fk.refTable), refColumns: fk.refColumns });
184
+ }
185
+ }
186
+
187
+ return checks;
188
+ }
189
+
190
+ /** One row of {@link fkOrphanSql}'s result. */
191
+ export interface FkOrphanRow {
192
+ constraint_name: unknown;
193
+ table_name: unknown;
194
+ columns: unknown;
195
+ ref_table: unknown;
196
+ orphans: unknown;
197
+ samples: unknown;
198
+ }
199
+
200
+ /**
201
+ * ONE read answering the question for every key at once — a first-failure refusal would make the
202
+ * operator fix one table, re-run, and discover the next.
203
+ *
204
+ * The scan is a real anti-join over the child table, which costs about what the VALIDATE it is
205
+ * protecting would cost. That is the price of knowing BEFORE the lock instead of during it, and
206
+ * it buys an exact count rather than pg's one-row-and-stop.
207
+ *
208
+ * NULLs are excluded, not counted: under MATCH SIMPLE (the only mode everystack emits) a row with
209
+ * a NULL in any key column satisfies the constraint. Counting them would refuse an apply that
210
+ * PostgreSQL would have accepted, which is the worse failure — a gate nobody can pass gets
211
+ * disabled.
212
+ *
213
+ * THE COUNT IS UNBOUNDED AND THE SAMPLE IS NOT, and the asymmetry is deliberate. The count is
214
+ * the full scan, which is the price of the answer and is paid either way. The SAMPLE is three
215
+ * values for the operator to recognise a row by — so it is taken from a `LIMIT 3` subquery and
216
+ * aggregated over those three rows alone. Aggregating the whole set and slicing it afterwards
217
+ * (`(array_agg(…))[1:3]`) builds the ENTIRE array in work memory first, which array_agg cannot
218
+ * spill to disk: on a table with millions of orphans the gate that exists to protect the
219
+ * database is the thing that takes it down.
220
+ */
221
+ export function fkOrphanSql(checks: readonly FkOrphanCheck[]): string {
222
+ if (checks.length === 0) throw new Error('fkOrphanSql: no checks — the caller must skip the read');
223
+ return checks
224
+ .map((c) => {
225
+ // The columns come out of a REGEX over the ADD CONSTRAINT text. A short or empty parse is
226
+ // not a shape this read can paper over: joining the child's own column name against the
227
+ // parent errors on most schemas but SUCCEEDS on a parent that happens to carry the same
228
+ // name — and then counts zero, reporting a clean pass on a key nobody actually checked.
229
+ // Refuse, exactly as an empty check list is refused; the caller fails closed on the throw.
230
+ if (c.columns.length === 0) {
231
+ throw new Error(`fkOrphanSql: ${c.table}.${c.constraint} has no columns — the read cannot be built`);
232
+ }
233
+ if (c.refColumns.length !== c.columns.length) {
234
+ throw new Error(
235
+ `fkOrphanSql: ${c.table}.${c.constraint} names ${c.columns.length} column(s) but `
236
+ + `${c.refColumns.length} reference column(s) in ${c.refTable} — the read cannot be built `
237
+ + 'without guessing the join, and a guessed join can report a false clean.',
238
+ );
239
+ }
240
+ const child = quoteQualified(c.table);
241
+ const parent = quoteQualified(c.refTable);
242
+ const notNull = c.columns.map((col) => `c.${quoteIdent(col)} IS NOT NULL`).join(' AND ');
243
+ const join = c.columns.map((col, i) => `p.${quoteIdent(c.refColumns[i]!)} = c.${quoteIdent(col)}`).join(' AND ');
244
+ const where = (pad: string): string =>
245
+ `WHERE ${notNull}\n${pad} AND NOT EXISTS (SELECT 1 FROM ${parent} p WHERE ${join})`;
246
+ // One printable value per offending row: a single-column key prints the value, a composite
247
+ // one prints the tuple. Capped, because a sample is for recognising the row, not for
248
+ // reading its contents into a refusal message.
249
+ const sample = c.columns.length === 1
250
+ ? `left(c.${quoteIdent(c.columns[0]!)}::text, 80)`
251
+ : `left(concat_ws(', ', ${c.columns.map((col) => `c.${quoteIdent(col)}::text`).join(', ')}), 80)`;
252
+ // Two scalar subqueries over no FROM: exactly ONE row per arm, at any orphan count —
253
+ // zero orphans gives 0 and a NULL sample array rather than no row at all. The caller
254
+ // asserts that shape (checkOrphanRowShape) and fails closed if it does not hold.
255
+ return `SELECT ${escapeLiteral(c.constraint)} AS constraint_name,
256
+ ${escapeLiteral(c.table)} AS table_name,
257
+ ${escapeLiteral(c.columns.join(', '))} AS columns,
258
+ ${escapeLiteral(c.refTable)} AS ref_table,
259
+ (SELECT count(*)::bigint
260
+ FROM ${child} c
261
+ ${where(' ')}) AS orphans,
262
+ (SELECT array_agg(s.sample)
263
+ FROM (SELECT ${sample} AS sample
264
+ FROM ${child} c
265
+ ${where(' ')}
266
+ ORDER BY 1
267
+ LIMIT 3) s) AS samples`;
268
+ })
269
+ .join('\nUNION ALL\n');
270
+ }
271
+
272
+ /**
273
+ * The read returns exactly one row per key — see {@link fkOrphanSql}. Anything else is an answer
274
+ * nobody can read, and an unreadable answer is not "no orphans": it is the gate not running.
275
+ *
276
+ * FAIL CLOSED. A driver that hands back `[]` (a shape quirk, a transport that dropped the rows,
277
+ * a future change to the read) would otherwise walk straight through {@link fkOrphanFindings},
278
+ * which reports an empty list — "every key is satisfied" — for an empty answer. Asserted here, in
279
+ * the module that owns the read's shape, so the assertion cannot drift from the SQL it is about.
280
+ */
281
+ export function checkOrphanRowShape(rows: unknown, expected: number): asserts rows is FkOrphanRow[] {
282
+ const got = Array.isArray(rows) ? rows.length : null;
283
+ if (got === expected) return;
284
+ throw new Error(
285
+ `the foreign-key row check answered with ${got === null ? 'something that is not a row set' : `${got} row(s)`} `
286
+ + `for ${expected} key(s) — the read produces exactly one row per key, so this answer cannot be read.`,
287
+ );
288
+ }
289
+
290
+ /**
291
+ * The orphan count as a number, or a THROW.
292
+ *
293
+ * It used to coerce null / unreadable text to 0, which is the answer "this key is satisfied" —
294
+ * the single most dangerous default in this module. `0` is a finding; `null` is a missing
295
+ * answer, and the two must never be spelled the same way. The caller turns the throw into the
296
+ * fail-closed refusal an unreachable database gets.
297
+ */
298
+ const toCount = (v: unknown, where: string): number => {
299
+ if (v === null || v === undefined) throw new Error(`the orphan count for ${where} could not be read (it came back empty).`);
300
+ if (typeof v === 'object') throw new Error(`the orphan count for ${where} could not be read (unexpected shape).`);
301
+ if (typeof v === 'string' && v.trim() === '') throw new Error(`the orphan count for ${where} could not be read (it came back blank).`);
302
+ const n = typeof v === 'bigint' ? Number(v) : Number(v);
303
+ if (!Number.isFinite(n)) throw new Error(`the orphan count for ${where} could not be read (got ${JSON.stringify(String(v))}).`);
304
+ return n;
305
+ };
306
+
307
+ const toSamples = (v: unknown): string[] =>
308
+ Array.isArray(v) ? v.filter((s) => s != null).map((s) => String(s)) : [];
309
+
310
+ /**
311
+ * The refusal when the read itself could not run (no privilege on a referenced table, a lock
312
+ * timeout, a dropped connection).
313
+ *
314
+ * FAIL CLOSED, the same rule the reconciler applies to grants it cannot see: a gate that did not
315
+ * run has not passed, and an apply that proceeds on an unanswered question is exactly the
316
+ * mid-VALIDATE surprise this preflight exists to remove. Nothing has run when this is raised.
317
+ */
318
+ export function fkPreflightUnavailableRefusal(error: string): string {
319
+ return [
320
+ `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.`,
321
+ ` ${error}`,
322
+ `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.`,
323
+ ].join('\n');
324
+ }
325
+
326
+ /**
327
+ * One key the read found orphaned rows for.
328
+ *
329
+ * This, not the prose, is what the apply and the plan report from: the apply has to decide PER
330
+ * KEY which `VALIDATE` to withhold, and the plan has to cross-reference the column against the
331
+ * row-ownership declarations. Neither can be done by parsing a message.
332
+ */
333
+ export interface FkOrphanFinding {
334
+ /** Schema-qualified child table (`public.posts`). */
335
+ table: string;
336
+ /** The constraint name the withheld `VALIDATE` would have named. */
337
+ constraint: string;
338
+ /** The key's own columns, in order. */
339
+ columns: string[];
340
+ /** Schema-qualified target table. */
341
+ refTable: string;
342
+ /** How many rows hold a value with no matching parent. Exact — the read counts, it does not sample. */
343
+ orphans: number;
344
+ /** At most three KEY VALUES, for recognising a row. Never row contents. */
345
+ samples: string[];
346
+ }
347
+
348
+ /**
349
+ * The keys with orphaned rows, as data. Empty when every key's rows already satisfy it.
350
+ *
351
+ * THROWS on a count it cannot read, for exactly the reason {@link toCount} does: an empty
352
+ * findings list means "asked and satisfied", so an unreadable answer must never be able to
353
+ * produce one.
354
+ */
355
+ export function fkOrphanFindings(rows: readonly FkOrphanRow[]): FkOrphanFinding[] {
356
+ const out: FkOrphanFinding[] = [];
357
+ for (const r of rows) {
358
+ const table = String(r.table_name);
359
+ const constraint = String(r.constraint_name);
360
+ const orphans = toCount(r.orphans, `${table}.${constraint}`);
361
+ if (orphans <= 0) continue;
362
+ out.push({
363
+ table,
364
+ constraint,
365
+ columns: String(r.columns).split(', ').filter((c) => c.length > 0),
366
+ refTable: String(r.ref_table),
367
+ orphans,
368
+ samples: toSamples(r.samples),
369
+ });
370
+ }
371
+ return out;
372
+ }
373
+
374
+ /**
375
+ * Split an edge into the statements to RUN and the `VALIDATE`s to WITHHOLD, one per orphaned key.
376
+ *
377
+ * The withheld statement is not a failure and not a refusal — it is a check whose answer is
378
+ * already known, so running it would take a lock and a full scan to be told what the preflight
379
+ * just counted. It goes into the SAME `pendingValidations` record a check that ran and failed
380
+ * goes into: the operator's situation is identical (key installed, enforced forward, unproven
381
+ * against the old rows) and so is the next move.
382
+ *
383
+ * Matching is by table + constraint name, which is the name the `VALIDATE` will speak — a rename
384
+ * in the same edge lands first, and {@link fkOrphanChecks} already keys the check on the NEW
385
+ * name, so the two agree.
386
+ */
387
+ export function withheldValidations(
388
+ statements: readonly string[],
389
+ findings: readonly FkOrphanFinding[],
390
+ ): { statements: string[]; withheld: PendingValidation[] } {
391
+ if (findings.length === 0) return { statements: [...statements], withheld: [] };
392
+ const byKey = new Map(findings.map((f) => [`${f.table}::${f.constraint}`, f]));
393
+ const kept: string[] = [];
394
+ const withheld: PendingValidation[] = [];
395
+ for (const statement of statements) {
396
+ const m = VALIDATE_RE.exec(statementBody(statement));
397
+ const finding = m ? byKey.get(`${normalizeTableName(m[1])}::${unquote(m[2])}`) : undefined;
398
+ if (!finding) {
399
+ kept.push(statement);
400
+ continue;
401
+ }
402
+ withheld.push({
403
+ sql: statement,
404
+ // Phrased so it reads as the orphan-data cause it IS — `validationPendingReason` sends
405
+ // the operator to the data fix, which is the right move here.
406
+ error:
407
+ `not attempted: ${finding.orphans} row(s) already in ${finding.table} hold a value in `
408
+ + `(${finding.columns.join(', ')}) with no matching row in ${finding.refTable}, counted by the foreign-key `
409
+ + 'row check before any DDL ran. The key is installed and enforced on new writes; the rest of the edge applied.',
410
+ skipped: true,
411
+ });
412
+ }
413
+ return { statements: kept, withheld };
414
+ }
@@ -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
@@ -549,6 +549,14 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
549
549
  if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
550
550
  actions.push(`name: ${tsLiteral(fk.name)}`);
551
551
  }
552
+ // The key is live and has NEVER been checked against the rows already in the table. Rendering
553
+ // it as an ordinary reference would declare a state this database is not in — and the FIRST
554
+ // plan after the pull would then emit a `VALIDATE` the legacy rows cannot pass. On the schema
555
+ // this was measured against that was 12 of 23 keys, every one of them deliberately NOT VALID
556
+ // in the app's own migration ("enforce forward, grandfather legacy"). So the pull SAYS what it
557
+ // found, the adoption plan comes out empty, and removing the flag later is how an operator
558
+ // opts into the scan. Same rule as the index and constraint names one line up: be faithful.
559
+ if (fk.notValid) actions.push('validate: false');
552
560
  const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
553
561
  expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
554
562
  } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
@@ -631,6 +639,8 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
631
639
  if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
632
640
  actions.push(`name: ${tsLiteral(fk.name)}`);
633
641
  }
642
+ // Faithful about validity, exactly as the single-column case above is — see there for why.
643
+ if (fk.notValid) actions.push('validate: false');
634
644
  const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
635
645
  items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
636
646
  }
@@ -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
 
@@ -187,6 +187,12 @@ function refActions(spec: { onDelete?: string; onUpdate?: string }): { onDelete?
187
187
  * disagree. Single-column FKs come from each field's `.references()`; composite FKs come from
188
188
  * the table-level `foreignKey([...], () => Other, [...])` constraint. Both name themselves the
189
189
  * way drizzle does — `<table>_<cols>_<target>_<refcols>_fk` — so a brownfield FK round-trips.
190
+ *
191
+ * `validate: false` on either surface compiles to `notValid: true`. That is the DECLARED half of
192
+ * a field introspection also sets (see {@link ForeignKey.notValid}): the author is saying this
193
+ * key's permanent state is installed-and-unproven, so the differ must never emit a `VALIDATE`
194
+ * for it and the from-scratch build must land it `NOT VALID` — otherwise the declared state and
195
+ * the state a build of that declaration produces would be two different states.
190
196
  */
191
197
  export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
192
198
  const out: ForeignKey[] = [];
@@ -207,6 +213,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
207
213
  refTable: refTableName(target),
208
214
  refColumns: [targetPk],
209
215
  ...refActions(field.spec),
216
+ ...(field.spec.referencesValidate === false ? { notValid: true } : {}),
210
217
  });
211
218
  }
212
219
 
@@ -222,6 +229,7 @@ export function modelForeignKeys(model: ModelDescriptor): ForeignKey[] {
222
229
  refTable: refTableName(target),
223
230
  refColumns: refCols,
224
231
  ...refActions(con),
232
+ ...(con.validate === false ? { notValid: true } : {}),
225
233
  });
226
234
  }
227
235
 
@@ -614,6 +622,10 @@ export { sequenceCreateSql } from './schema-diff';
614
622
  * are emitted *after* every `CREATE TABLE` (a FK's target table must already exist), so they
615
623
  * are separate from the table DDL — which is why FKs are not rendered inline by
616
624
  * compileCreateTable. Named the way drizzle names them: `<table>_<cols>_<target>_<refcols>_fk`.
625
+ *
626
+ * A key declared `validate: false` is rendered `… NOT VALID` here too. The declared state IS
627
+ * unproven-against-old-rows, so a migration artifact that installed it validated would build a
628
+ * database that does not match the models it was generated from.
617
629
  */
618
630
  export function foreignKeySql(model: ModelDescriptor): string[] {
619
631
  return modelForeignKeys(model).map((fk) => {
@@ -624,7 +636,8 @@ export function foreignKeySql(model: ModelDescriptor): string[] {
624
636
  // stays bare-but-quoted (`"uploads"`), preserving the proven output.
625
637
  return (
626
638
  `ALTER TABLE ${qualifiedTable(model)} ADD CONSTRAINT "${fk.name}" ` +
627
- `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)};`
639
+ `FOREIGN KEY (${cols}) REFERENCES ${quoteParts(fk.refTable)}(${refCols})${refActionSql(fk)}`
640
+ + `${fk.notValid ? ' NOT VALID' : ''};`
628
641
  );
629
642
  });
630
643
  }