@everystack/cli 0.4.85 → 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,170 @@
1
+ /**
2
+ * fk-orphan-advisory — the RECURRING NOTICE about foreign keys that stay unproven.
3
+ *
4
+ * Its sibling, fk-preflight, owns the fail-closed REFUSAL (the row check could not run) and the
5
+ * data the read produces. This owns the other half: what `db:plan` and `db:apply` say, at the
6
+ * DEFAULT verbosity, about keys that are installed, enforced on every new write, and carrying
7
+ * rows nobody has ever checked.
8
+ *
9
+ * THREE RULES, and each one is a decision somebody could get wrong:
10
+ *
11
+ * IT NEVER BLOCKS. Not db:plan, not db:apply, not db:check. There is no refusal here and no
12
+ * flag to get past one. Unproven legacy rows are DEBT, and a gate that stops work over debt
13
+ * gets disabled — which is how the whole edge came to be refused for one orphaned key.
14
+ *
15
+ * IT NEVER PROPOSES A DELETE. It names rows; a human decides. No DML is emitted, suggested,
16
+ * or implied, ever. The samples are KEY VALUES only — three of them, enough to recognise a
17
+ * row, never its contents.
18
+ *
19
+ * IT RECURS. This prints on every run while the condition holds, because the condition is
20
+ * durable: the keys really are unproven, today and next month. A one-time alert on a
21
+ * permanent fact is a fact nobody ever sees again. The escape is not acknowledgement, it is
22
+ * one of the two real answers — fix the rows, or declare `validate: false` and mean it.
23
+ *
24
+ * WHAT IS ABSENT FROM IT, deliberately: any key the Model declared `validate: false`. That
25
+ * declaration IS the decision this notice is asking for, so continuing to nag about it would
26
+ * make the flag worthless. The mechanism is structural rather than a filter — the differ emits
27
+ * no `VALIDATE` for such a key, so fk-preflight never scans it and it can never reach here.
28
+ * What is left is exactly the UNDECIDED set: keys whose model has said nothing either way.
29
+ *
30
+ * NONE OF IT RIDES IN THE PLAN FILE. The plan is a deterministic DDL list under a content hash,
31
+ * and live row counts go stale between minting and applying. A file that asserted "9,412 rows"
32
+ * would be asserting something that need not still be true when someone reads it.
33
+ */
34
+
35
+ import type { ModelDescriptor } from '@everystack/model';
36
+ import type { FkOrphanFinding } from './fk-preflight';
37
+ import { toSnakeCase } from './schema-compile';
38
+
39
+ /**
40
+ * The columns a model reads to decide WHO MAY SEE OR WRITE A ROW, per schema-qualified table.
41
+ *
42
+ * Two shapes qualify, and they fail differently:
43
+ *
44
+ * `can(…, { owner: 'userId' })` — the column IS the row's owner. `rowOwnership` in the
45
+ * derived handler config is the same column (derive.ts takes
46
+ * the first owner-shaped ability), so covering the ability
47
+ * covers both surfaces.
48
+ * `can(…, { via: 'uploadId' })` — the column is the LINK to the owner: the policy compiles to
49
+ * `<col> IN (SELECT id FROM <parent> WHERE <owner> = <claim>)`.
50
+ * An orphan here is the sharper case — the parent row is
51
+ * gone, so the subquery matches nothing and the row answers
52
+ * "belongs to nobody".
53
+ *
54
+ * Column-scoped abilities (`columns: [...]`) are covered by the same `owner`/`via` read; the
55
+ * column list narrows what is exposed, not what decides access.
56
+ */
57
+ export function ownershipColumns(models: readonly ModelDescriptor[]): Map<string, Set<string>> {
58
+ const out = new Map<string, Set<string>>();
59
+ for (const model of models) {
60
+ const table = `${model.schema ?? 'public'}.${model.table}`;
61
+ for (const ability of model.abilities) {
62
+ for (const key of [ability.condition.owner, ability.condition.via]) {
63
+ if (!key) continue;
64
+ // Field KEYS are camelCase in the model, columns are snake_case in the database —
65
+ // through the compiler's own conversion, so this and `.references()` can never disagree
66
+ // about which column an ability names.
67
+ const set = out.get(table) ?? out.set(table, new Set()).get(table)!;
68
+ set.add(toSnakeCase(key));
69
+ }
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** A thousands-separated count — `11500` is read wrong at a glance, `11,500` is not. */
76
+ const count = (n: number): string => n.toLocaleString('en-US');
77
+
78
+ const plural = (n: number, one: string, many: string): string => (n === 1 ? one : many);
79
+
80
+ /** Which of a finding's own columns decide access on its table. Empty for the ordinary case. */
81
+ function gatingColumns(finding: FkOrphanFinding, ownership: ReadonlyMap<string, Set<string>>): string[] {
82
+ const gated = ownership.get(finding.table);
83
+ return gated ? finding.columns.filter((c) => gated.has(c)) : [];
84
+ }
85
+
86
+ /** The per-key detail — counts, the columns, and KEY VALUES only. Behind --verbose. */
87
+ function detailLines(findings: readonly FkOrphanFinding[]): string[] {
88
+ const lines: string[] = [];
89
+ for (const f of findings) {
90
+ lines.push(
91
+ ` ${f.table}.${f.constraint}: ${count(f.orphans)} ${plural(f.orphans, 'row', 'rows')} `
92
+ + `hold a value in (${f.columns.join(', ')}) with no matching row in ${f.refTable}.`,
93
+ );
94
+ if (f.samples.length > 0) {
95
+ lines.push(` sample key value(s): ${f.samples.join(', ')}${f.orphans > f.samples.length ? ', …' : ''}`);
96
+ }
97
+ // A read, never a write. This module has no DELETE to offer and never will. The parent's
98
+ // key columns are a PLACEHOLDER — the read that produced these counts knows them, this
99
+ // report does not carry them, and inventing a name would hand over a query that errors.
100
+ const predicate = f.columns.length === 1
101
+ ? `p.<key> = c.${f.columns[0]}`
102
+ : `(p.<keys>) = (${f.columns.map((c) => `c.${c}`).join(', ')})`;
103
+ lines.push(
104
+ ` the rows: SELECT * FROM ${f.table} c WHERE NOT EXISTS `
105
+ + `(SELECT 1 FROM ${f.refTable} p WHERE ${predicate});`,
106
+ );
107
+ }
108
+ return lines;
109
+ }
110
+
111
+ export interface FkOrphanAdvisoryOptions {
112
+ /** --verbose: add the per-key counts and key-value samples under each headline. */
113
+ verbose?: boolean;
114
+ }
115
+
116
+ /**
117
+ * The advisory, as lines. Empty when nothing is unproven.
118
+ *
119
+ * Every HEADLINE it returns is a problem line — it starts with `!`, the same mark and the same
120
+ * severity tier the ownership report's findings use — so the caller prints whatever comes back
121
+ * without having to classify it: at the default verbosity that is headlines only, and `verbose`
122
+ * adds the indented detail under each one. The headline states the CONSEQUENCE — enforced
123
+ * forward, N rows unchecked — because a bare count reads as an error nobody can size.
124
+ *
125
+ * Findings whose column also decides row access get their OWN headline. Same severity, same
126
+ * non-blocking behaviour: what changes is that the reader is told this is not only a data
127
+ * consistency question, because a row that points at a deleted parent through an ownership link
128
+ * answers "who owns me" with nothing.
129
+ */
130
+ export function renderFkOrphanAdvisory(
131
+ findings: readonly FkOrphanFinding[],
132
+ ownership: ReadonlyMap<string, Set<string>> = new Map(),
133
+ opts: FkOrphanAdvisoryOptions = {},
134
+ ): string[] {
135
+ if (findings.length === 0) return [];
136
+ const verbose = opts.verbose === true;
137
+
138
+ const gating = findings.filter((f) => gatingColumns(f, ownership).length > 0);
139
+ const plain = findings.filter((f) => gatingColumns(f, ownership).length === 0);
140
+
141
+ const lines: string[] = [];
142
+ const declareIt = 'declare `validate: false` on the reference if that is intentional';
143
+
144
+ if (plain.length > 0) {
145
+ const rows = plain.reduce((sum, f) => sum + f.orphans, 0);
146
+ lines.push(
147
+ `! ${plain.length} foreign ${plural(plain.length, 'key stays', 'keys stay')} NOT VALID: new writes enforced, `
148
+ + `${count(rows)} existing ${plural(rows, 'row', 'rows')} unchecked `
149
+ + `(--verbose for detail; ${declareIt}).`,
150
+ );
151
+ if (verbose) lines.push(...detailLines(plain));
152
+ }
153
+
154
+ if (gating.length > 0) {
155
+ const rows = gating.reduce((sum, f) => sum + f.orphans, 0);
156
+ const where = gating
157
+ .map((f) => `${f.table}.${gatingColumns(f, ownership).join('/')}`)
158
+ .join(', ');
159
+ lines.push(
160
+ `! ROW SECURITY — ${gating.length} unproven foreign ${plural(gating.length, 'key also decides', 'keys also decide')} `
161
+ + `who may read or write the row (${where}): `
162
+ + `${count(rows)} existing ${plural(rows, 'row', 'rows')} point at a parent that is gone, so the owner check `
163
+ + `on ${plural(gating.length, 'it', 'them')} matches nobody `
164
+ + `(--verbose for detail; ${declareIt}).`,
165
+ );
166
+ if (verbose) lines.push(...detailLines(gating));
167
+ }
168
+
169
+ return lines;
170
+ }
@@ -1,20 +1,35 @@
1
1
  /**
2
2
  * fk-preflight — the orphan check that runs BEFORE a foreign key's DDL, not during it.
3
3
  *
4
- * A foreign key is only installable if the rows already in the table satisfy it. PostgreSQL
4
+ * A foreign key is only VALIDATABLE if the rows already in the table satisfy it. PostgreSQL
5
5
  * finds that out DURING `VALIDATE CONSTRAINT`, by raising on the first row that does not — one
6
6
  * row, no count, no second offender, and (before the two-step split) after the apply had already
7
7
  * taken an ACCESS EXCLUSIVE lock and scanned most of the table. The operator learned "there is a
8
8
  * problem" at the worst possible moment and learned nothing else.
9
9
  *
10
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.
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.
15
14
  *
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:
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:
18
33
  *
19
34
  * - a key on a table this edge CREATES → no. The table has no rows yet, and it does
20
35
  * not exist to be queried before the edge runs.
@@ -31,13 +46,11 @@
31
46
  * exactly what VALIDATE is about to scan.
32
47
  * - a key on an existing table and columns → YES.
33
48
  *
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.
49
+ * Pure: the parsing, the SQL, the findings, and the text. The caller runs the query.
38
50
  */
39
51
 
40
52
  import type { SchemaSnapshot, TableSchema, ForeignKey } from './schema-introspect';
53
+ import type { PendingValidation } from './state-apply';
41
54
  import { statementBody } from './sql-statement';
42
55
  import { quoteIdent, quoteQualified } from './pg-ident';
43
56
  import { escapeLiteral } from './derived-apply';
@@ -80,12 +93,18 @@ const liveTable = (live: SchemaSnapshot, table: string): TableSchema | undefined
80
93
  live.tables.find((t) => t.table === table);
81
94
 
82
95
  /**
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.
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.
85
98
  *
86
99
  * `statements` is the edge exactly as it will execute (held drops and notices included; they are
87
100
  * comments and match nothing). `live` is the target's state as the apply just read it — the same
88
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.
89
108
  */
90
109
  export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapshot): FkOrphanCheck[] {
91
110
  const bodies = statements.map(statementBody);
@@ -112,6 +131,14 @@ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapsh
112
131
  if (m) renamedFrom.set(`${normalizeTableName(m[1])}::${unquote(m[3])}`, unquote(m[2]));
113
132
  }
114
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
+
115
142
  const checks: FkOrphanCheck[] = [];
116
143
  const seen = new Set<string>();
117
144
 
@@ -126,6 +153,8 @@ export function fkOrphanChecks(statements: readonly string[], live: SchemaSnapsh
126
153
  const add = ADD_FK_RE.exec(body);
127
154
  if (add) {
128
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;
129
158
  const columns = parseColumns(add[3]);
130
159
  const target = liveTable(live, table);
131
160
  // Not live yet, or one of its columns is not: nothing to scan, and nothing to scan it with.
@@ -245,9 +274,9 @@ export function fkOrphanSql(checks: readonly FkOrphanCheck[]): string {
245
274
  * nobody can read, and an unreadable answer is not "no orphans": it is the gate not running.
246
275
  *
247
276
  * 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.
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.
251
280
  */
252
281
  export function checkOrphanRowShape(rows: unknown, expected: number): asserts rows is FkOrphanRow[] {
253
282
  const got = Array.isArray(rows) ? rows.length : null;
@@ -295,37 +324,91 @@ export function fkPreflightUnavailableRefusal(error: string): string {
295
324
  }
296
325
 
297
326
  /**
298
- * The refusal text, or null when every key's rows already satisfy it.
327
+ * One key the read found orphaned rows for.
299
328
  *
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.
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.
304
350
  *
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.
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.
307
354
  */
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;
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
+ }
313
373
 
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 ? ', …' : ''}`);
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;
325
401
  }
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
- );
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
+ });
329
412
  }
330
- return lines.join('\n');
413
+ return { statements: kept, withheld };
331
414
  }
@@ -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
  }
@@ -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
  }
@@ -29,6 +29,7 @@ import type {
29
29
  TableSchema,
30
30
  ColumnSchema,
31
31
  EnumType,
32
+ ForeignKey,
32
33
  IndexSchema,
33
34
  SequenceSchema,
34
35
  } from './schema-introspect';
@@ -42,9 +43,16 @@ export type ConstraintSpec =
42
43
  | { type: 'unique'; name: string; columns: string[] }
43
44
  | { type: 'check'; name: string; expr: string }
44
45
  | { 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. */
46
+ /**
47
+ * The key is installed but unchecked — on the LIVE side an observation, on the DESIRED
48
+ * side the author's `validate: false` (see {@link ForeignKey.notValid}).
49
+ *
50
+ * It is never part of {@link constraintContentKey}: a NOT VALID key IS the declared key,
51
+ * one step short, so validity can never make a key match a DIFFERENT key. What it decides
52
+ * is the SECOND statement — a desired `notValid` key emits `ADD … NOT VALID` alone, and a
53
+ * live `notValid` key is finished with a `VALIDATE` only when the desired side asked for
54
+ * one.
55
+ */
48
56
  notValid?: boolean };
49
57
 
50
58
  export type SchemaChange =
@@ -512,7 +520,7 @@ export function diffSchema(desired: SchemaSnapshot, current: SchemaSnapshot, ren
512
520
  addConstraints.push({
513
521
  kind: 'addConstraint',
514
522
  table: d.table,
515
- constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate },
523
+ constraint: { type: 'foreignKey', name: fk.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, onDelete: fk.onDelete, onUpdate: fk.onUpdate, ...(fk.notValid ? { notValid: true } : {}) },
516
524
  });
517
525
  }
518
526
  for (const ix of d.indexes) createIndexes.push(createIndexChange(d.table, ix));
@@ -817,10 +825,25 @@ function tableConstraintSpecs(t: TableSchema): ConstraintSpec[] {
817
825
  */
818
826
  function constraintContentKey(c: ConstraintSpec): string {
819
827
  if (c.type === 'unique') return `u|${c.columns.join(',')}`;
820
- if (c.type === 'foreignKey') return `f|${c.columns.join(',')}|${c.refTable}|${c.refColumns.join(',')}|${c.onDelete ?? ''}|${c.onUpdate ?? ''}`;
828
+ if (c.type === 'foreignKey') return foreignKeyContentKey(c);
821
829
  return `c|${normalizeCheck(c.expr)}`; // checks take their own predicate-matched path (diffChecks)
822
830
  }
823
831
 
832
+ /**
833
+ * A foreign key's content identity — columns, target, target columns, referential actions.
834
+ * VALIDITY IS NOT IN IT (see {@link ConstraintSpec}), and neither is the name.
835
+ *
836
+ * Exported because a second surface asks the same question: the plan's predicted endpoint has to
837
+ * find the live counterpart of a declared key to carry its validity through. Two spellings of
838
+ * "are these the same key" is how the differ and the prediction end up disagreeing about whether
839
+ * an edge changed anything.
840
+ */
841
+ export function foreignKeyContentKey(
842
+ fk: Pick<ForeignKey, 'columns' | 'refTable' | 'refColumns' | 'onDelete' | 'onUpdate'>,
843
+ ): string {
844
+ return `f|${fk.columns.join(',')}|${fk.refTable}|${fk.refColumns.join(',')}|${fk.onDelete ?? ''}|${fk.onUpdate ?? ''}`;
845
+ }
846
+
824
847
  /**
825
848
  * Diff CHECK constraints by their normalized predicate, not their name: a check with the
826
849
  * same predicate is the same check no matter what it's called, so a pulled check (whose DB
@@ -871,13 +894,22 @@ function diffConstraints(d: TableSchema, c: TableSchema, dropConstraints: Schema
871
894
  renames.push({ kind: 'renameConstraint', table: d.table, from: matched.name, to: des.name });
872
895
  }
873
896
  // 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
897
+ // what an interrupted two-step add leaves behind, and what a hand-run
898
+ // `ADD CONSTRAINT … NOT VALID` leaves behind. When the declared side is a VALIDATED key
899
+ // that is a real difference and the edge finishes it: VALIDATE alone, never a second
877
900
  // ADD (which would fail on the name that is already taken). Under the live name, which
878
901
  // may not be the declared one — a rename emitted just above lands FIRST, so the
879
902
  // validate must speak the name the constraint will have by then.
880
- if (matched.type === 'foreignKey' && matched.notValid) {
903
+ //
904
+ // …UNLESS the declared side ASKED for NOT VALID (`validate: false`). Then live and
905
+ // declared agree and there is nothing to emit: validating would be the edge doing the
906
+ // one thing the author declared it must never do. Note the direction — a live key that
907
+ // is fully VALID under a `validate: false` declaration also falls through here with no
908
+ // statement, because `matched.notValid` is absent. That is the accept-either rule: there
909
+ // is no `ALTER TABLE … INVALIDATE CONSTRAINT`, so a key proven MORE than the model asked
910
+ // for is not a difference anyone could act on.
911
+ const desiredNotValid = des.type === 'foreignKey' && des.notValid === true;
912
+ if (matched.type === 'foreignKey' && matched.notValid && !desiredNotValid) {
881
913
  const name = des.type === 'foreignKey' && des.nameDeclared ? des.name : matched.name;
882
914
  addConstraints.push({ kind: 'validateConstraint', table: d.table, name });
883
915
  }
@@ -985,6 +1017,10 @@ export function validateConstraintSql(table: string, name: string): string {
985
1017
  * commit), which is why the apply path runs every VALIDATE after the edge's batch commits — see
986
1018
  * `splitValidations` in state-apply. The text is produced HERE for both `db:diff` (no database)
987
1019
  * and `db:plan` (against a live one), so the two can never render the same edge differently.
1020
+ *
1021
+ * ONE statement again for a key the model declared `validate: false`: step 1 alone IS the whole
1022
+ * declared state there, so step 2 is not deferred, it does not exist. See
1023
+ * `FieldSpec.referencesValidate` for why a schema needs to be able to say that.
988
1024
  */
989
1025
  function addConstraintSql(table: string, c: ConstraintSpec): string[] {
990
1026
  const head = `ALTER TABLE ${table} ADD CONSTRAINT ${quote(c.name)}`;
@@ -998,7 +1034,8 @@ function addConstraintSql(table: string, c: ConstraintSpec): string[] {
998
1034
  let s = `${head} FOREIGN KEY (${colList(c.columns)}) REFERENCES ${refTable}(${colList(c.refColumns)})`;
999
1035
  if (c.onDelete) s += ` ON DELETE ${c.onDelete.toUpperCase()}`;
1000
1036
  if (c.onUpdate) s += ` ON UPDATE ${c.onUpdate.toUpperCase()}`;
1001
- return [`${s} NOT VALID;`, validateConstraintSql(table, c.name)];
1037
+ const add = `${s} NOT VALID;`;
1038
+ return c.notValid === true ? [add] : [add, validateConstraintSql(table, c.name)];
1002
1039
  }
1003
1040
  }
1004
1041
  }
@@ -155,6 +155,27 @@ import { fingerprintV12 } from './schema-fingerprint-v12';
155
155
  // re-baselined (db:reconcile --rebaseline) — no DDL, the fingerprint is a content address. The
156
156
  // v12 canon is frozen in schema-fingerprint-v12 and registered in RETAINED_CANONS so a schema_log
157
157
  // row recorded under it still descends.
158
+ //
159
+ // NO v14 FOR `validate: false` — worked out here so the next reader does not ask again.
160
+ // The Model can now DECLARE a foreign key permanently NOT VALID (`references(…, { validate:
161
+ // false })` → `ForeignKey.notValid` on the COMPILED side, which v13 said only introspection ever
162
+ // set). A bump is required when the CANONICAL FORM changes — when the same database would hash
163
+ // differently before and after. Neither half of that happens here:
164
+ //
165
+ // 1. The form is byte-identical. `canonicalTable` below already reads `f.notValid` and already
166
+ // writes it only when true. No key is added, removed, or normalized differently; the shape
167
+ // that hashes a live snapshot is untouched.
168
+ // 2. Every hash anyone RECORDED is a live hash. `RETAINED_CANONS`/`fingerprintLiveAtVersion`
169
+ // recompute from a SchemaSnapshot read out of the database, and this feature does not change
170
+ // what introspection reports about any existing key. So every schema_log row still descends.
171
+ //
172
+ // What moves is the DECLARED hash of a model that newly writes `validate: false` — which is a
173
+ // state change the author made on purpose, exactly like adding a column. That is the format
174
+ // working, not the format changing. (Deliberately kept in step: the from-scratch build emits
175
+ // `ADD … NOT VALID` for such a key too — schema-diff's `addConstraintSql` and schema-compile's
176
+ // `foreignKeySql` — so building a database FROM that declaration lands on the declared hash
177
+ // rather than one step past it. Without that half the declared side would hash a state no build
178
+ // of it could produce, and `db:check` could never reach MATCH.)
158
179
  export const FINGERPRINT_VERSION = 13;
159
180
 
160
181
  // ---------------------------------------------------------------------------
@@ -198,6 +219,11 @@ function canonicalTable(table: TableSchema): Record<string, unknown> {
198
219
  // hashed identically to a validated one, so `db:fingerprint` reported MATCH on a database
199
220
  // whose data no constraint had ever been proved against. Written only when true, so every
200
221
  // fully-valid database keeps the hash it had.
222
+ //
223
+ // The flag now arrives from EITHER side — introspection's observation, or a Model's
224
+ // `validate: false` (see the FINGERPRINT_VERSION note above for why that needed no bump).
225
+ // The canonical form does not care which: one bit, one meaning — this key has never been
226
+ // checked against the rows that were already there.
201
227
  foreignKeys: byKey(
202
228
  table.foreignKeys.map((f) => ({
203
229
  columns: f.columns, refTable: f.refTable, refColumns: f.refColumns,