@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.
- package/CHANGELOG.md +11 -0
- package/package.json +2 -2
- package/src/cli/apply-execute.ts +80 -19
- package/src/cli/apply-refusals.ts +47 -9
- package/src/cli/commands/db-apply.ts +85 -4
- package/src/cli/commands/db-plan.ts +29 -0
- package/src/cli/edge-plan.ts +55 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +126 -43
- package/src/cli/model-render.ts +10 -0
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +47 -10
- package/src/cli/schema-fingerprint.ts +26 -0
- package/src/cli/schema-introspect.ts +20 -7
- package/src/cli/state-apply.ts +84 -17
|
@@ -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
|
+
}
|
package/src/cli/fk-preflight.ts
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
* can name the COMPLETE list — how many rows, on which key, and what a few of them look
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
|
84
|
-
*
|
|
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
|
|
249
|
-
* reports
|
|
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
|
-
*
|
|
327
|
+
* One key the read found orphaned rows for.
|
|
299
328
|
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
|
|
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
|
|
306
|
-
* "asked and satisfied", so an unreadable
|
|
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
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
413
|
+
return { statements: kept, withheld };
|
|
331
414
|
}
|
package/src/cli/model-render.ts
CHANGED
|
@@ -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
|
}
|
package/src/cli/schema-diff.ts
CHANGED
|
@@ -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
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
-
//
|
|
875
|
-
// `ADD CONSTRAINT … NOT VALID` leaves behind.
|
|
876
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|