@ultimat3/db 13.0.0 → 15.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 +202 -0
- package/README.md +17 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +224 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -184
- package/src/entity-shape.ts +46 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +108 -158
- package/src/generated-column.ts +15 -6
- package/src/index-ddl.ts +137 -0
- package/src/index.ts +23 -0
- package/src/introspect.ts +69 -1
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -0
- package/src/retype-dependents.ts +135 -0
- package/src/snapshot-parse.ts +31 -3
- package/src/statement-excerpt.ts +18 -0
- package/src/ungeneratable.ts +86 -0
- package/src/unrendered.ts +170 -0
|
@@ -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
|
+
}
|
package/src/drift.ts
CHANGED
|
@@ -4,200 +4,35 @@
|
|
|
4
4
|
// by the framework contract; `x verify` fails on it and `--json` carries every difference.
|
|
5
5
|
|
|
6
6
|
import { baseClient, type DbClient } from './client';
|
|
7
|
+
import type { DriftDifference } from './drift-findings';
|
|
8
|
+
import {
|
|
9
|
+
changedColumn,
|
|
10
|
+
changedForeignKey,
|
|
11
|
+
changedIndex,
|
|
12
|
+
missingCheck,
|
|
13
|
+
missingColumn,
|
|
14
|
+
missingForeignKey,
|
|
15
|
+
missingIndex,
|
|
16
|
+
missingTable,
|
|
17
|
+
unexpectedColumn,
|
|
18
|
+
unexpectedTable,
|
|
19
|
+
unknownSchema,
|
|
20
|
+
} from './drift-findings';
|
|
7
21
|
import { DbError } from './errors';
|
|
8
|
-
import {
|
|
22
|
+
import { foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
9
23
|
import { indexMethodOf } from './index-method';
|
|
10
|
-
import {
|
|
11
|
-
type ForeignKeyDescription,
|
|
12
|
-
findTable,
|
|
13
|
-
introspect,
|
|
14
|
-
type SchemaDescription,
|
|
15
|
-
type TableDescription,
|
|
16
|
-
} from './introspect';
|
|
24
|
+
import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
|
|
17
25
|
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
18
26
|
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
| 'changed-column'
|
|
23
|
-
| 'unexpected-table'
|
|
24
|
-
| 'missing-table'
|
|
25
|
-
| 'unknown-schema'
|
|
26
|
-
| 'missing-index'
|
|
27
|
-
| 'changed-index'
|
|
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
|
-
}
|
|
27
|
+
// Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the
|
|
28
|
+
// split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it.
|
|
29
|
+
export type { DriftDifference, DriftKind } from './drift-findings';
|
|
38
30
|
|
|
39
31
|
export interface DriftReport {
|
|
40
32
|
readonly ok: boolean;
|
|
41
33
|
readonly differences: readonly DriftDifference[];
|
|
42
34
|
}
|
|
43
35
|
|
|
44
|
-
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
|
-
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
|
-
function changedColumn(table: string, column: string, liveNullable: boolean): DriftDifference {
|
|
80
|
-
const clause = liveNullable ? 'set not null' : 'drop not null';
|
|
81
|
-
return {
|
|
82
|
-
kind: 'changed-column',
|
|
83
|
-
table,
|
|
84
|
-
column,
|
|
85
|
-
cause: liveNullable
|
|
86
|
-
? `table "${table}" allows NULL in column "${column}" that migrations declare not null`
|
|
87
|
-
: `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`,
|
|
88
|
-
fix:
|
|
89
|
-
`alter table "${table}" alter column "${column}" ${clause}; # in a new migration` +
|
|
90
|
-
(liveNullable ? ' — backfill the existing NULLs first' : ''),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function unexpectedTable(table: string): DriftDifference {
|
|
95
|
-
return {
|
|
96
|
-
kind: 'unexpected-table',
|
|
97
|
-
table,
|
|
98
|
-
column: null,
|
|
99
|
-
cause: `table "${table}" is not present in any migration`,
|
|
100
|
-
fix: `x db gen "add ${table}"`,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function missingTable(table: string): DriftDifference {
|
|
105
|
-
return {
|
|
106
|
-
kind: 'missing-table',
|
|
107
|
-
table,
|
|
108
|
-
column: null,
|
|
109
|
-
cause: `table "${table}" is declared by migrations but does not exist`,
|
|
110
|
-
fix: 'x db migrate',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Not a difference between two schemas but the absence of one to compare against — reported
|
|
116
|
-
* through the same channel so it reaches an operator, since a check that quietly answered "clean"
|
|
117
|
-
* because it had nothing to check is the one failure mode drift detection cannot have.
|
|
118
|
-
*/
|
|
119
|
-
function unknownSchema(migrations: readonly Migration[]): DriftDifference {
|
|
120
|
-
const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1);
|
|
121
|
-
return {
|
|
122
|
-
kind: 'unknown-schema',
|
|
123
|
-
table: '',
|
|
124
|
-
column: null,
|
|
125
|
-
cause:
|
|
126
|
-
`migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` +
|
|
127
|
-
'cannot be established',
|
|
128
|
-
// The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is
|
|
129
|
-
// the same condition. It used to lead with `x db gen`, which raises that error and whose own
|
|
130
|
-
// fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The
|
|
131
|
-
// pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory.
|
|
132
|
-
fix:
|
|
133
|
-
`git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` +
|
|
134
|
-
`delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function missingIndex(table: string, index: string): DriftDifference {
|
|
139
|
-
return {
|
|
140
|
-
kind: 'missing-index',
|
|
141
|
-
table,
|
|
142
|
-
column: null,
|
|
143
|
-
cause: `table "${table}" is missing index "${index}" that migrations declare`,
|
|
144
|
-
fix: 'x db migrate',
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function changedIndex(table: string, index: string, detail: string): DriftDifference {
|
|
149
|
-
return {
|
|
150
|
-
kind: 'changed-index',
|
|
151
|
-
table,
|
|
152
|
-
column: null,
|
|
153
|
-
cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`,
|
|
154
|
-
fix: 'x db migrate',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference {
|
|
159
|
-
return {
|
|
160
|
-
kind: 'missing-foreign-key',
|
|
161
|
-
table,
|
|
162
|
-
column: null,
|
|
163
|
-
cause:
|
|
164
|
-
`table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` +
|
|
165
|
-
`"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`,
|
|
166
|
-
fix: 'x db migrate',
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* The key points where it was declared to point and one side's `on delete` rule is not the other's
|
|
172
|
-
* — reported apart from `missing-foreign-key` because it is a different repair: the constraint is
|
|
173
|
-
* there, and what changed is what happens to the child rows.
|
|
174
|
-
*
|
|
175
|
-
* The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint`
|
|
176
|
-
* alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so
|
|
177
|
-
* naming a command would send a reader to one that generates an empty migration. Same reasoning
|
|
178
|
-
* as `changedColumn`.
|
|
179
|
-
*/
|
|
180
|
-
function changedForeignKey(
|
|
181
|
-
table: string,
|
|
182
|
-
declared: ForeignKeyDescription,
|
|
183
|
-
held: ForeignKeyDescription,
|
|
184
|
-
): DriftDifference {
|
|
185
|
-
const rule = onDeleteRule(held.onDelete);
|
|
186
|
-
return {
|
|
187
|
-
kind: 'changed-foreign-key',
|
|
188
|
-
table,
|
|
189
|
-
column: null,
|
|
190
|
-
cause:
|
|
191
|
-
`foreign key on "${table}" (${declared.columns.join(', ')}) to ` +
|
|
192
|
-
`"${declared.referencedTable}" ` +
|
|
193
|
-
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
194
|
-
'migrations declare',
|
|
195
|
-
fix:
|
|
196
|
-
`${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}` +
|
|
197
|
-
' # in a new migration',
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
36
|
/**
|
|
202
37
|
* Indexes migrations declare, against the ones the catalog holds — by column list and by
|
|
203
38
|
* uniqueness, which is what caught a composite index rebuilt with its columns the other way round
|
|
@@ -316,6 +151,33 @@ function compareForeignKeys(live: TableDescription, expected: TableDescription):
|
|
|
316
151
|
return differences;
|
|
317
152
|
}
|
|
318
153
|
|
|
154
|
+
/**
|
|
155
|
+
* CHECK constraints migrations declare, against the NAMES the catalog holds — `checkNames`, which
|
|
156
|
+
* is a separate field from `checks` precisely so this comparison cannot reach a definition it must
|
|
157
|
+
* not read (`introspect.ts`).
|
|
158
|
+
*
|
|
159
|
+
* Two absences, and they mean opposite things. `expected.checks` absent is a sidecar written
|
|
160
|
+
* before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames`
|
|
161
|
+
* absent is a description that never asked the catalog — a stub, a fake client's rows, a
|
|
162
|
+
* `TableDescription` built by hand — and reading that as "the database holds none" is one finding
|
|
163
|
+
* per declared constraint against a database nobody looked at. `introspect()` always answers with
|
|
164
|
+
* the field, `[]` included, so a real read is never mistaken for an unread one.
|
|
165
|
+
*
|
|
166
|
+
* Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state:
|
|
167
|
+
* a NOT NULL, an `enumerated()` column's old anonymous form, a constraint an extension brought and
|
|
168
|
+
* every hand-written CHECK an app has ever added would each be a finding against a database that
|
|
169
|
+
* is exactly right.
|
|
170
|
+
*/
|
|
171
|
+
function compareChecks(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
172
|
+
const declared = expected.checks;
|
|
173
|
+
const held = live.checkNames;
|
|
174
|
+
if (declared === undefined || held === undefined) return [];
|
|
175
|
+
const present = new Set(held);
|
|
176
|
+
return declared
|
|
177
|
+
.filter((check) => !present.has(check.name))
|
|
178
|
+
.map((check) => missingCheck(live.name, check));
|
|
179
|
+
}
|
|
180
|
+
|
|
319
181
|
/**
|
|
320
182
|
* A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres
|
|
321
183
|
* adds the constraint with the key. Both sides are therefore read through the union of the two
|
|
@@ -352,6 +214,7 @@ function compareTable(live: TableDescription, expected: TableDescription): Drift
|
|
|
352
214
|
}
|
|
353
215
|
}
|
|
354
216
|
differences.push(...compareIndexes(live, expected));
|
|
217
|
+
differences.push(...compareChecks(live, expected));
|
|
355
218
|
differences.push(...compareForeignKeys(live, expected));
|
|
356
219
|
return differences;
|
|
357
220
|
}
|
package/src/entity-shape.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// tier 1 and may never import `entity` (tier 2), so a snapshot arrives as a parameter and every
|
|
3
3
|
// part of it — a column's `on delete` rule included — crosses the seam by shape or not at all.
|
|
4
4
|
|
|
5
|
+
import type { ColumnDefaultLike } from './column-default';
|
|
5
6
|
import type { IndexMethod } from './index-method';
|
|
6
7
|
|
|
7
8
|
/** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
|
|
@@ -32,6 +33,45 @@ export interface ColumnDescriptionLike {
|
|
|
32
33
|
* was a `23502`, because nothing computed it.
|
|
33
34
|
*/
|
|
34
35
|
readonly generated?: string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* What the column defaults to, when the declaration carries the value and not only the flag.
|
|
38
|
+
* Optional for the reason `onDelete` and `generated` are: this package cannot import
|
|
39
|
+
* `@ultimat3/entity`, so a field that is not on the projection reaches no DDL at all.
|
|
40
|
+
*
|
|
41
|
+
* `hasDefault` beside it is NOT redundant and is not being replaced. It is the older, narrower
|
|
42
|
+
* fact — "this column defaults to something" — and it is what `generatedClause` reads to refuse
|
|
43
|
+
* a column that is both generated and defaulted. Where `hasDefault` is true and this is absent,
|
|
44
|
+
* `defaultExpression` falls back to the two defaults inferable from the kind and everything else
|
|
45
|
+
* is REPORTED as unrendered rather than dropped (`unrendered.ts`).
|
|
46
|
+
*/
|
|
47
|
+
readonly default?: ColumnDefaultLike | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Structurally assignment-compatible with `@ultimat3/entity`'s `InvariantDescription`.
|
|
52
|
+
*
|
|
53
|
+
* An invariant is written once and enforced twice — in the app on every write, and in Postgres as
|
|
54
|
+
* a CHECK or a unique index. The second half reached no SQL at all until 2026-08-25, because this
|
|
55
|
+
* mirror had no `invariants` field: a regenerated migration silently held none of them, including
|
|
56
|
+
* the composite UNIQUE that `upsertAll`'s `on conflict` is inferred against, so a replay-safe
|
|
57
|
+
* write became a duplicate row on a database the framework itself generated.
|
|
58
|
+
*/
|
|
59
|
+
export interface InvariantDescriptionLike {
|
|
60
|
+
/** The rule's own name. `<table>_<name>_check` / `_key` is the constraint it becomes. */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** `assert` is a rule only the app can run — no SQL, and nothing for a migration to emit. */
|
|
63
|
+
readonly kind: 'check' | 'unique' | 'assert';
|
|
64
|
+
readonly message: string;
|
|
65
|
+
/** The predicate for a `check`, the column list for a `unique`, `null` for an `assert`. */
|
|
66
|
+
readonly sql: string | null;
|
|
67
|
+
/** Partial-constraint predicate, e.g. `deleted_at is null`. `null` covers every row. */
|
|
68
|
+
readonly where: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* The physical columns a `unique` names, when the declaration carries them. Optional, and
|
|
71
|
+
* `uniqueColumns()` falls back to splitting `sql` when it is absent — see the argument in
|
|
72
|
+
* `invariant-ddl.ts` for why that fallback is a validated re-read and not a name parsed back.
|
|
73
|
+
*/
|
|
74
|
+
readonly columns?: readonly string[] | undefined;
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
/**
|
|
@@ -66,4 +106,10 @@ export interface EntityDescriptionLike {
|
|
|
66
106
|
readonly primaryKey: readonly string[];
|
|
67
107
|
readonly columns: readonly ColumnDescriptionLike[];
|
|
68
108
|
readonly indexes: readonly IndexDescriptionLike[];
|
|
109
|
+
/**
|
|
110
|
+
* The domain rules the database must hold too. Optional so no existing description changes
|
|
111
|
+
* shape, exactly as `onDelete`, `generated` and `using` are — and absent reads as "declares
|
|
112
|
+
* none", which is what every hand-built description in this package's own tests is.
|
|
113
|
+
*/
|
|
114
|
+
readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
|
|
69
115
|
}
|
package/src/foreign-key-plan.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
6
|
import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
7
7
|
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
+
import { identifier } from './sql';
|
|
8
9
|
|
|
9
10
|
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
10
11
|
export interface Plan {
|
|
@@ -137,10 +138,17 @@ export interface ConstraintPlans {
|
|
|
137
138
|
readonly doomed: ReadonlySet<string>;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A key whose target is being dropped: gone on the way up, a note on the way back.
|
|
143
|
+
*
|
|
144
|
+
* The note goes through `identifier` too. A `--` comment ends at the first newline, so a name
|
|
145
|
+
* holding one is a second command on the line after it — the same escape `columnClause` closed,
|
|
146
|
+
* one quoting rule short of the statement above it.
|
|
147
|
+
*/
|
|
141
148
|
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
142
149
|
preDrops.up.push(dropForeignKey(table, constraint));
|
|
143
150
|
preDrops.down.push(
|
|
144
|
-
`-- constraint
|
|
151
|
+
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
152
|
+
`cannot be restored; ${identifier(target).text} is gone`,
|
|
145
153
|
);
|
|
146
154
|
}
|
package/src/foreign-key.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { assert } from '@ultimat3/core';
|
|
6
6
|
import type { ForeignKeyDescription } from './introspect';
|
|
7
|
+
import { identifier } from './sql';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
|
|
@@ -51,7 +52,16 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
|
|
|
51
52
|
return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
|
|
57
|
+
* now goes through. A name that closes its own quote produced a real `drop table` through
|
|
58
|
+
* `generateMigration` once already, out of `columnClause`, and every name below arrives the same
|
|
59
|
+
* way: from a projection this package cannot typecheck, or from a `.snapshot.json` on disk that
|
|
60
|
+
* anything may edit. Being unreachable with a hostile name today is a property of the CALLERS, not
|
|
61
|
+
* of this file, and it survives exactly until the next refactor.
|
|
62
|
+
*/
|
|
63
|
+
const quoted = (names: readonly string[]): string =>
|
|
64
|
+
names.map((name) => identifier(name).text).join(', ');
|
|
55
65
|
|
|
56
66
|
/**
|
|
57
67
|
* A statement of its own, never a clause inside `create table`. Inline, the constraint is created
|
|
@@ -76,14 +86,48 @@ export function addForeignKey(table: string, key: ForeignKeyDescription): string
|
|
|
76
86
|
`references(() => target.id, { onDelete: 'cascade' }) # cascade | restrict | set null`,
|
|
77
87
|
);
|
|
78
88
|
return (
|
|
79
|
-
`alter table
|
|
89
|
+
`alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
|
|
80
90
|
`foreign key (${quoted(key.columns)}) ` +
|
|
81
|
-
`references
|
|
91
|
+
`references ${identifier(key.referencedTable).text} (${quoted(key.referencedColumns)})` +
|
|
82
92
|
`${rule === null ? '' : ` on delete ${rule}`};`
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** The reverse. Dropping a constraint loses nothing the database cannot rebuild. */
|
|
87
97
|
export function dropForeignKey(table: string, constraint: string): string {
|
|
88
|
-
return `alter table
|
|
98
|
+
return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
|
|
103
|
+
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
|
|
104
|
+
*
|
|
105
|
+
* It lives here, beside the two writers, because it is the one caller reading values neither of
|
|
106
|
+
* them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
|
|
107
|
+
* writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
|
|
108
|
+
* backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
|
|
109
|
+
* rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
|
|
110
|
+
* `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
|
|
111
|
+
* never a throw — a drift check that raises in place of its report hands the caller an exception
|
|
112
|
+
* where a verdict was asked for. The constraint is still named, because it is the only thing
|
|
113
|
+
* identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
|
|
114
|
+
* runs this string either way.
|
|
115
|
+
*/
|
|
116
|
+
export function rebuildForeignKey(
|
|
117
|
+
table: string,
|
|
118
|
+
declared: ForeignKeyDescription,
|
|
119
|
+
held: ForeignKeyDescription,
|
|
120
|
+
): string {
|
|
121
|
+
// The writers are ASKED whether they can write the pair — never a second copy of their rules
|
|
122
|
+
// beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
|
|
123
|
+
// the thrown value.
|
|
124
|
+
try {
|
|
125
|
+
return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
|
|
126
|
+
} catch {
|
|
127
|
+
return (
|
|
128
|
+
`drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
|
|
129
|
+
'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
|
|
130
|
+
'write this pair'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
89
133
|
}
|