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