@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.
- package/CHANGELOG.md +18 -1
- package/package.json +3 -3
- package/src/cli/apply-execute.ts +123 -2
- package/src/cli/apply-refusals.ts +112 -0
- package/src/cli/commands/db-apply.ts +104 -7
- package/src/cli/commands/db-plan.ts +30 -0
- package/src/cli/derived-apply.ts +17 -0
- package/src/cli/edge-plan.ts +64 -13
- package/src/cli/fk-orphan-advisory.ts +170 -0
- package/src/cli/fk-preflight.ts +414 -0
- package/src/cli/migration-generate.ts +8 -7
- package/src/cli/model-render.ts +10 -0
- package/src/cli/plain-output.ts +10 -2
- package/src/cli/schema-compile.ts +14 -1
- package/src/cli/schema-diff.ts +137 -13
- package/src/cli/schema-fingerprint-v12.ts +239 -0
- package/src/cli/schema-fingerprint.ts +52 -3
- package/src/cli/schema-introspect.ts +36 -0
- package/src/cli/state-apply.ts +227 -13
|
@@ -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,
|
|
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 =
|
|
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`.
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
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((
|
|
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
|
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
|
}
|
package/src/cli/plain-output.ts
CHANGED
|
@@ -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
|
}
|