@ultimat3/db 14.0.0 → 16.0.0
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/CLAUDE.md +261 -5
- package/README.md +13 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +17 -5
- package/src/dependent-view.ts +224 -0
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -182
- package/src/errors.ts +2 -100
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +53 -172
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +188 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +9 -6
- package/src/introspect.ts +46 -1
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-dependents.ts +135 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Single responsibility: refuse a migration whose `alter column … type` a VIEW is compiled against,
|
|
2
|
+
// before the statement is sent — and name the view, the column and the statement that recreates it.
|
|
3
|
+
//
|
|
4
|
+
// **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
|
|
5
|
+
// runs with no database open; `SchemaDescription` has no field for a view; `introspect()` reads
|
|
6
|
+
// none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
|
|
7
|
+
// declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
|
|
8
|
+
// with no caller to fill it is the declared-and-never-wired defect this release exists to
|
|
9
|
+
// eliminate. What DOES have a connection is `migrate()`, one statement before the abort — and the
|
|
10
|
+
// catalog answers the question exactly, including for a view no migration in this repo wrote.
|
|
11
|
+
//
|
|
12
|
+
// Measured on 18.4: `alter table "dv_docs" alter column "rank" type text using "rank"::text` under
|
|
13
|
+
// a view selecting that column answers `0A000 cannot alter type of a column used by a view or
|
|
14
|
+
// rule`, with `rule _RETURN on view dv_docs_published depends on column "rank"` in a DETAIL field
|
|
15
|
+
// nothing printed — surfaced as `X_DB_UNAVAILABLE: cannot reach the database`, whose registered
|
|
16
|
+
// `fix:` says to set `DATABASE_URL`.
|
|
17
|
+
//
|
|
18
|
+
// It does not repair anything and does not claim to: the deploy still stops. What it replaces is
|
|
19
|
+
// wrong advice about a healthy database with the two statements that unblock it.
|
|
20
|
+
|
|
21
|
+
import type { DbClient } from './client';
|
|
22
|
+
import { migrationViewDepends } from './migration-errors';
|
|
23
|
+
import { identifier, join, sql } from './sql';
|
|
24
|
+
import { IDENTIFIER_PART, noiseAt } from './sql-scan';
|
|
25
|
+
import { statementsOf } from './statement-split';
|
|
26
|
+
|
|
27
|
+
/** One `alter table <table> alter column <column> type …`, as the catalog spells both names. */
|
|
28
|
+
export interface RetypeTarget {
|
|
29
|
+
readonly table: string;
|
|
30
|
+
readonly column: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One name in a statement. Called a WORD and not the obvious lexer noun deliberately:
|
|
35
|
+
* `scripts/secret-compare.ts` reads a comparison whose operand is NAMED like a credential, and
|
|
36
|
+
* that noun is one of the names it reads — a `.text === spelling` under it is indistinguishable
|
|
37
|
+
* from an auth check to a static rule that has only the name to go on.
|
|
38
|
+
*/
|
|
39
|
+
interface SqlWord {
|
|
40
|
+
readonly text: string;
|
|
41
|
+
/** A quoted name is never a keyword — `"type"` is a column called type, not the clause. */
|
|
42
|
+
readonly quoted: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The names in one statement, in order, folded the way Postgres folds them: an unquoted identifier
|
|
47
|
+
* to lower case, a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
48
|
+
* contribute nothing, through this package's one lexer — `-- alter column` is prose and
|
|
49
|
+
* `'alter column'` is data.
|
|
50
|
+
*/
|
|
51
|
+
function wordsOf(statement: string): readonly SqlWord[] {
|
|
52
|
+
const words: SqlWord[] = [];
|
|
53
|
+
let at = 0;
|
|
54
|
+
while (at < statement.length) {
|
|
55
|
+
const noise = noiseAt(statement, at);
|
|
56
|
+
if (noise !== null) {
|
|
57
|
+
if (noise.kind === 'identifier') {
|
|
58
|
+
words.push({ text: statement.slice(at + 1, noise.end - 1), quoted: true });
|
|
59
|
+
}
|
|
60
|
+
at = noise.end;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!IDENTIFIER_PART.test(statement[at] ?? '')) {
|
|
64
|
+
at += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let end = at;
|
|
68
|
+
while (end < statement.length && IDENTIFIER_PART.test(statement[end] ?? '')) end += 1;
|
|
69
|
+
words.push({ text: statement.slice(at, end).toLowerCase(), quoted: false });
|
|
70
|
+
at = end;
|
|
71
|
+
}
|
|
72
|
+
return words;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const keyword = (word: SqlWord | undefined, spelling: string): boolean =>
|
|
76
|
+
word !== undefined && !word.quoted && word.text === spelling;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every column this script retypes. Narrow ON PURPOSE — `alter table <t> … alter [column] <c> type`
|
|
80
|
+
* and nothing else — because a miss costs exactly what happens today (the server's own `0A000`,
|
|
81
|
+
* one statement later) while a false positive costs a catalog read and a refusal on a migration
|
|
82
|
+
* that would have applied. Every retype `generateMigration` emits is this shape; a hand-written
|
|
83
|
+
* `ALTER TABLE ONLY t …` is not, and is deliberately left to the server.
|
|
84
|
+
*/
|
|
85
|
+
export function retypeTargets(script: string): readonly RetypeTarget[] {
|
|
86
|
+
const targets: RetypeTarget[] = [];
|
|
87
|
+
for (const statement of statementsOf(script)) {
|
|
88
|
+
const words = wordsOf(statement);
|
|
89
|
+
const table = words[2];
|
|
90
|
+
if (!keyword(words[0], 'alter') || !keyword(words[1], 'table') || table === undefined) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (let index = 3; index < words.length; index += 1) {
|
|
94
|
+
if (!keyword(words[index], 'alter')) continue;
|
|
95
|
+
const at = keyword(words[index + 1], 'column') ? index + 2 : index + 1;
|
|
96
|
+
const column = words[at];
|
|
97
|
+
if (column === undefined || !keyword(words[at + 1], 'type')) continue;
|
|
98
|
+
targets.push({ table: table.text, column: column.text });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return targets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ViewRow {
|
|
105
|
+
readonly view_name: string;
|
|
106
|
+
readonly table_name: string;
|
|
107
|
+
readonly column_name: string;
|
|
108
|
+
readonly definition: string;
|
|
109
|
+
/** `v` or `m`. A MATERIALISED view needs different DDL to drop and to recreate. */
|
|
110
|
+
readonly relkind: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `pg_depend` -> `pg_rewrite` is the only edge that records this: a view depends on a column
|
|
115
|
+
* through its `_RETURN` rule, never through a row in `pg_class` alone. Materialised views are
|
|
116
|
+
* included (`relkind = 'm'`) because they carry the same rule and fail the same way.
|
|
117
|
+
*
|
|
118
|
+
* One round trip for every target, `in` over both name lists, and the exact pairing filtered in
|
|
119
|
+
* the caller — a per-target query would be a loop of statements inside the migration's own
|
|
120
|
+
* transaction, and a cross-product read is cheap where a false pair is not.
|
|
121
|
+
*/
|
|
122
|
+
async function dependentViews(
|
|
123
|
+
client: DbClient,
|
|
124
|
+
targets: readonly RetypeTarget[],
|
|
125
|
+
): Promise<readonly ViewRow[]> {
|
|
126
|
+
const tables = join(
|
|
127
|
+
[...new Set(targets.map((target) => target.table))].map((name) => sql`${name}`),
|
|
128
|
+
);
|
|
129
|
+
const columns = join(
|
|
130
|
+
[...new Set(targets.map((target) => target.column))].map((name) => sql`${name}`),
|
|
131
|
+
);
|
|
132
|
+
return client.query<ViewRow>(sql`
|
|
133
|
+
select distinct v.relname as view_name, c.relname as table_name, a.attname as column_name,
|
|
134
|
+
pg_get_viewdef(v.oid, true) as definition, v.relkind as relkind
|
|
135
|
+
from pg_depend d
|
|
136
|
+
join pg_rewrite r on r.oid = d.objid and d.classid = 'pg_rewrite'::regclass
|
|
137
|
+
join pg_class v on v.oid = r.ev_class
|
|
138
|
+
join pg_class c on c.oid = d.refobjid and d.refclassid = 'pg_class'::regclass
|
|
139
|
+
join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
|
|
140
|
+
where v.relkind in ('v', 'm') and v.oid <> c.oid
|
|
141
|
+
and c.relname in (${tables}) and a.attname in (${columns})
|
|
142
|
+
order by v.relname
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One SQL statement as a single argv word for `psql -c`.
|
|
148
|
+
*
|
|
149
|
+
* SINGLE quotes, unlike `migrationConflict`'s `-c "…"`: `identifier()` writes the view's name in
|
|
150
|
+
* DOUBLE quotes, so a double-quoted shell word would end at the name. The definition is the
|
|
151
|
+
* server's own text and may hold a `'` of its own — `where status = 'published'` — so the one
|
|
152
|
+
* escape a POSIX shell has for it is spelled out here. This is not the SQL literal escape
|
|
153
|
+
* (`sql.ts`'s `literal()`, the tree's one copy of that); nothing below is sent to a server.
|
|
154
|
+
*/
|
|
155
|
+
const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
|
|
156
|
+
|
|
157
|
+
/** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
|
|
158
|
+
const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The two statements that unblock the deploy, as one line an operator pastes.
|
|
162
|
+
*
|
|
163
|
+
* It leads with the command to RUN and carries the follow-up in a `#` comment, the shape
|
|
164
|
+
* `migrateConcurrent` and `migrationSnapshotMissing` already write. It used to lead with bare DDL
|
|
165
|
+
* and a `#`: `#` is not a comment in Postgres, so psql read the whole line and failed on it, while
|
|
166
|
+
* a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
|
|
167
|
+
*
|
|
168
|
+
* `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
|
|
169
|
+
* quoted Postgres name — and a `fix:` may not throw: the rule `rebuildForeignKey` already states,
|
|
170
|
+
* with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
|
|
171
|
+
* operator an exception where a verdict was asked for, over a view name that is perfectly legal.
|
|
172
|
+
* The fallback still leads with a command that runs — a psql session — because quoting that name
|
|
173
|
+
* is the one step this package will not do twice: `identifier()` is its only identifier writer.
|
|
174
|
+
*
|
|
175
|
+
* The definition is collapsed to one line because `pg_get_viewdef(oid, true)` pretty-prints across
|
|
176
|
+
* several and a `fix:` is read as a command.
|
|
177
|
+
*
|
|
178
|
+
* `relkind` decides the DDL and is not cosmetic: `dependentViews` deliberately selects `'m'` as
|
|
179
|
+
* well as `'v'`, and Postgres refuses `drop view` on a materialised one — `WRONG_OBJECT_TYPE`,
|
|
180
|
+
* "use DROP MATERIALIZED VIEW". So the one case the query went out of its way to include was the
|
|
181
|
+
* one whose `fix:` could not run. `pg_get_viewdef` answers the SELECT for both kinds, so only the
|
|
182
|
+
* two keywords differ; a matview's indexes and its `WITH DATA` population are NOT carried, and
|
|
183
|
+
* the fix says so rather than implying the recreate is complete.
|
|
184
|
+
*/
|
|
185
|
+
function restoreView(view: string, definition: string, relkind: string): string {
|
|
186
|
+
const body = definition.replace(/\s+/g, ' ').replace(/;\s*$/, '').trim();
|
|
187
|
+
const materialised = relkind === 'm';
|
|
188
|
+
const kind = materialised ? 'materialized view' : 'view';
|
|
189
|
+
const note = materialised
|
|
190
|
+
? ' # then re-create its indexes: a matview keeps none of them across a drop'
|
|
191
|
+
: '';
|
|
192
|
+
try {
|
|
193
|
+
const name = identifier(view).text;
|
|
194
|
+
return (
|
|
195
|
+
`${psql(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
|
|
196
|
+
`${psql(`create ${kind} ${name} as ${body}`)}${note}`
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
return (
|
|
200
|
+
`psql "$DATABASE_URL" # quote the ${kind} name ${JSON.stringify(view)} yourself, then: ` +
|
|
201
|
+
`drop ${kind} <name>; \\q; x db migrate; and create it again as: create ${kind} <name> as ${body}${note}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Refuse before the ALTER, or return having sent nothing at all. A script that retypes no column
|
|
208
|
+
* costs one text scan and no round trip, which is every migration an app writes that is not a
|
|
209
|
+
* retype.
|
|
210
|
+
*/
|
|
211
|
+
export async function refuseDependentViews(client: DbClient, script: string): Promise<void> {
|
|
212
|
+
const targets = retypeTargets(script);
|
|
213
|
+
if (targets.length === 0) return;
|
|
214
|
+
const wanted = new Set(targets.map((target) => `${target.table}.${target.column}`));
|
|
215
|
+
for (const row of await dependentViews(client, targets)) {
|
|
216
|
+
if (!wanted.has(`${row.table_name}.${row.column_name}`)) continue;
|
|
217
|
+
throw migrationViewDepends(
|
|
218
|
+
row.view_name,
|
|
219
|
+
row.table_name,
|
|
220
|
+
row.column_name,
|
|
221
|
+
restoreView(row.view_name, row.definition, row.relkind),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Single responsibility: what a schema difference is CALLED and what its `fix:` line says — one
|
|
2
|
+
// constructor per `DriftKind`, and nothing that compares anything. Split out of `drift.ts` at the
|
|
3
|
+
// 500-line ceiling, along the seam that file already drew: comparison decides *whether* two
|
|
4
|
+
// schemas disagree, and this decides how the disagreement reads.
|
|
5
|
+
//
|
|
6
|
+
// The rendered `X_DB_DRIFT` output is byte-for-byte pinned by the framework contract and
|
|
7
|
+
// duplicated in `@ultimat3/entity` — do not reword a `cause` without changing both.
|
|
8
|
+
//
|
|
9
|
+
// Two rules run through every one of them. A `fix:` is a command the reader can RUN: `x db
|
|
10
|
+
// migrate` where the migration has not been applied, and the statement itself where it has, since
|
|
11
|
+
// re-running the migrator applies nothing a ledger row already claims. And a difference names the
|
|
12
|
+
// declared side's own spelling, never the catalog's, because the catalog's is Postgres' rewriting.
|
|
13
|
+
|
|
14
|
+
import { onDeleteRule, rebuildForeignKey } from './foreign-key';
|
|
15
|
+
import type { CheckDescription, ForeignKeyDescription } from './introspect';
|
|
16
|
+
import type { Migration } from './migrate';
|
|
17
|
+
|
|
18
|
+
export type DriftKind =
|
|
19
|
+
| 'unexpected-column'
|
|
20
|
+
| 'missing-column'
|
|
21
|
+
| 'changed-column'
|
|
22
|
+
| 'unexpected-table'
|
|
23
|
+
| 'missing-table'
|
|
24
|
+
| 'unknown-schema'
|
|
25
|
+
| 'missing-index'
|
|
26
|
+
| 'changed-index'
|
|
27
|
+
| 'missing-check'
|
|
28
|
+
| 'missing-foreign-key'
|
|
29
|
+
| 'changed-foreign-key';
|
|
30
|
+
|
|
31
|
+
export interface DriftDifference {
|
|
32
|
+
readonly kind: DriftKind;
|
|
33
|
+
readonly table: string;
|
|
34
|
+
readonly column: string | null;
|
|
35
|
+
readonly cause: string;
|
|
36
|
+
readonly fix: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DriftReport {
|
|
40
|
+
readonly ok: boolean;
|
|
41
|
+
readonly differences: readonly DriftDifference[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unexpectedColumn(table: string, column: string): DriftDifference {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'unexpected-column',
|
|
47
|
+
table,
|
|
48
|
+
column,
|
|
49
|
+
// Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
|
|
50
|
+
cause: `table "${table}" has column "${column}" not present in any migration`,
|
|
51
|
+
fix: `x db gen "add ${column}"`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function missingColumn(table: string, column: string): DriftDifference {
|
|
56
|
+
return {
|
|
57
|
+
kind: 'missing-column',
|
|
58
|
+
table,
|
|
59
|
+
column,
|
|
60
|
+
cause: `table "${table}" is missing column "${column}" that migrations declare`,
|
|
61
|
+
fix: 'x db migrate',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The column exists on both sides and one of them lets it be `NULL`.
|
|
67
|
+
*
|
|
68
|
+
* This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT
|
|
69
|
+
* NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the
|
|
70
|
+
* strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing
|
|
71
|
+
* a human has to remember. Nobody did, and `compareTable` compared columns by name and by type
|
|
72
|
+
* while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever
|
|
73
|
+
* against an entity schema that said otherwise, with `ok: true` on every check. The first
|
|
74
|
+
* `undefined` write then lands as `NULL` and crashes three services away from the migration.
|
|
75
|
+
*
|
|
76
|
+
* `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a
|
|
77
|
+
* `set not null`, so naming it would send a reader to a command that generates an empty migration.
|
|
78
|
+
*/
|
|
79
|
+
export function changedColumn(
|
|
80
|
+
table: string,
|
|
81
|
+
column: string,
|
|
82
|
+
liveNullable: boolean,
|
|
83
|
+
): DriftDifference {
|
|
84
|
+
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
85
|
+
return {
|
|
86
|
+
kind: 'changed-column',
|
|
87
|
+
table,
|
|
88
|
+
column,
|
|
89
|
+
cause: liveNullable
|
|
90
|
+
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
91
|
+
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
92
|
+
fix:
|
|
93
|
+
`alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
|
|
94
|
+
(liveNullable ? ' — backfill the existing NULLs first' : ''),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function unexpectedTable(table: string): DriftDifference {
|
|
99
|
+
return {
|
|
100
|
+
kind: 'unexpected-table',
|
|
101
|
+
table,
|
|
102
|
+
column: null,
|
|
103
|
+
cause: `table "${table}" is not present in any migration`,
|
|
104
|
+
fix: `x db gen "add ${table}"`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function missingTable(table: string): DriftDifference {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'missing-table',
|
|
111
|
+
table,
|
|
112
|
+
column: null,
|
|
113
|
+
cause: `table "${table}" is declared by migrations but does not exist`,
|
|
114
|
+
fix: 'x db migrate',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Not a difference between two schemas but the absence of one to compare against — reported
|
|
120
|
+
* through the same channel so it reaches an operator, since a check that quietly answered "clean"
|
|
121
|
+
* because it had nothing to check is the one failure mode drift detection cannot have.
|
|
122
|
+
*/
|
|
123
|
+
export function unknownSchema(migrations: readonly Migration[]): DriftDifference {
|
|
124
|
+
const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
|
|
125
|
+
return {
|
|
126
|
+
kind: 'unknown-schema',
|
|
127
|
+
table: '',
|
|
128
|
+
column: null,
|
|
129
|
+
cause:
|
|
130
|
+
`migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
|
|
131
|
+
'cannot be established',
|
|
132
|
+
// The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
|
|
133
|
+
// the same condition. It used to lead with `x db gen`, which raises that error and whose own
|
|
134
|
+
// fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
|
|
135
|
+
// pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
|
|
136
|
+
fix:
|
|
137
|
+
`git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
|
|
138
|
+
`delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function missingIndex(table: string, index: string): DriftDifference {
|
|
143
|
+
return {
|
|
144
|
+
kind: 'missing-index',
|
|
145
|
+
table,
|
|
146
|
+
column: null,
|
|
147
|
+
cause: `table "${table}" is missing index "${index}" that migrations declare`,
|
|
148
|
+
fix: 'x db migrate',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function changedIndex(table: string, index: string, detail: string): DriftDifference {
|
|
153
|
+
return {
|
|
154
|
+
kind: 'changed-index',
|
|
155
|
+
table,
|
|
156
|
+
column: null,
|
|
157
|
+
cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
|
|
158
|
+
fix: 'x db migrate',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A CHECK a migration declares and the catalog does not hold.
|
|
164
|
+
*
|
|
165
|
+
* There is no `changed-check` beside it and there never will be, for the reason
|
|
166
|
+
* `IndexDescription.where` gives: `pg_get_constraintdef` answers Postgres' own rewriting —
|
|
167
|
+
* `status in ('draft','published')` reads back as `CHECK ((status = ANY (ARRAY['draft'::text,
|
|
168
|
+
* 'published'::text])))` — so a text comparison reports drift on a correct database forever, and
|
|
169
|
+
* normalising it is an expression parser competing with the server's. Presence is not text.
|
|
170
|
+
*
|
|
171
|
+
* The `fix` is the statement, not `x db migrate`: the migration that declares this constraint is
|
|
172
|
+
* already in the ledger, so re-running the migrator applies nothing. Same reasoning as
|
|
173
|
+
* `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling of
|
|
174
|
+
* the predicate, which is what makes an executable fix possible at all.
|
|
175
|
+
*/
|
|
176
|
+
export function missingCheck(table: string, check: CheckDescription): DriftDifference {
|
|
177
|
+
return {
|
|
178
|
+
kind: 'missing-check',
|
|
179
|
+
table,
|
|
180
|
+
column: null,
|
|
181
|
+
cause: `table "${table}" is missing check constraint "${check.name}" that migrations declare`,
|
|
182
|
+
// The command rides on the same line as the statement, and not only because `check` is a
|
|
183
|
+
// banned advice word the `errors` gate demands a command beside: writing the migration is half
|
|
184
|
+
// the repair and applying it is the other half, and `changedColumn`'s bare `# in a new
|
|
185
|
+
// migration` leaves the second half to be guessed.
|
|
186
|
+
fix:
|
|
187
|
+
`alter table "${table}" add constraint "${check.name}" ` +
|
|
188
|
+
`check (${check.expression}); # in a new migration, then x db migrate`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
|
|
193
|
+
return {
|
|
194
|
+
kind: 'missing-foreign-key',
|
|
195
|
+
table,
|
|
196
|
+
column: null,
|
|
197
|
+
cause:
|
|
198
|
+
`table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
|
|
199
|
+
`"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
|
|
200
|
+
fix: 'x db migrate',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The key points where it was declared to point and one side's `on delete` rule is not the other's
|
|
206
|
+
* — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
|
|
207
|
+
* there, and what changed is what happens to the child rows.
|
|
208
|
+
*
|
|
209
|
+
* The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
|
|
210
|
+
* alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
|
|
211
|
+
* naming a command would send a reader to one that generates an empty migration. Same reasoning
|
|
212
|
+
* as `changedColumn`.
|
|
213
|
+
*/
|
|
214
|
+
export function changedForeignKey(
|
|
215
|
+
table: string,
|
|
216
|
+
declared: ForeignKeyDescription,
|
|
217
|
+
held: ForeignKeyDescription,
|
|
218
|
+
): DriftDifference {
|
|
219
|
+
const rule = onDeleteRule(held.onDelete);
|
|
220
|
+
return {
|
|
221
|
+
kind: 'changed-foreign-key',
|
|
222
|
+
table,
|
|
223
|
+
column: null,
|
|
224
|
+
cause:
|
|
225
|
+
`foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
226
|
+
`"${declared.referencedTable}" ` +
|
|
227
|
+
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
228
|
+
'migrations declare',
|
|
229
|
+
fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
|
|
230
|
+
};
|
|
231
|
+
}
|