@ultimat3/db 15.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 +183 -22
- package/package.json +2 -2
- package/src/dependent-view.ts +224 -0
- 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 +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +9 -6
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
package/src/foreign-key-plan.ts
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
// BEFORE them. `generate.ts` assembles the plan; `foreign-key.ts` writes the SQL.
|
|
4
4
|
|
|
5
5
|
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
addForeignKey,
|
|
8
|
+
dropForeignKey,
|
|
9
|
+
foreignKeyTarget,
|
|
10
|
+
keyId,
|
|
11
|
+
onDeleteRule,
|
|
12
|
+
unrestorableNote,
|
|
13
|
+
} from './foreign-key';
|
|
7
14
|
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
-
import { identifier } from './sql';
|
|
9
15
|
|
|
10
16
|
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
11
17
|
export interface Plan {
|
|
@@ -73,19 +79,31 @@ export function foreignKeysOf(entity: EntityDescriptionLike): ForeignKeyDescript
|
|
|
73
79
|
* Both directions, because a snapshot may not lie: a removed `references()` used to emit nothing
|
|
74
80
|
* while the snapshot beside it recorded `foreignKeys: []`, so the orphan constraint stayed on the
|
|
75
81
|
* database *and* the record denied one the catalog holds — and `compareForeignKeys` judges the
|
|
76
|
-
* declared side, so no drift check could ever see it.
|
|
77
|
-
* leaves the snapshot correct by omission
|
|
82
|
+
* declared side, so no drift check could ever see it. This comment used to add "not parity with a
|
|
83
|
+
* removed index either: that leaves the snapshot correct by omission", which was **wrong** — a
|
|
84
|
+
* removed index lied in the identical way, and `index-plan.ts` is the arm that closed it. The drop names the constraint the previous snapshot
|
|
78
85
|
* recorded, never the name this generator would have chosen — a hand-written `fk_legacy` is
|
|
79
86
|
* `42704` under the generated spelling.
|
|
87
|
+
*
|
|
88
|
+
* `plans.predropped` names the constraints a RETYPE already took out of the way (`retype-keys.ts`),
|
|
89
|
+
* and it is read as "the schema does not record this key" — the same reading `checkPlan` gives its
|
|
90
|
+
* own `predropped` set. That is what makes this function the one writer of an `add constraint`
|
|
91
|
+
* after a retype: a key still declared is added back here, in the bucket that already runs after
|
|
92
|
+
* every table statement; one the entity dropped is left where the retype left it, because dropping
|
|
93
|
+
* it a second time is `42704`; and one whose `on delete` moved comes back carrying the new rule.
|
|
94
|
+
* Its `down` is the retype's, pushed where reversal puts it after both ends are back.
|
|
80
95
|
*/
|
|
81
96
|
export function foreignKeyPlan(
|
|
82
97
|
entity: EntityDescriptionLike,
|
|
83
98
|
live: TableDescription | undefined,
|
|
84
99
|
plans: ConstraintPlans,
|
|
85
100
|
): void {
|
|
86
|
-
const { constraints, preDrops, doomed } = plans;
|
|
101
|
+
const { constraints, preDrops, doomed, predropped } = plans;
|
|
87
102
|
const wanted = foreignKeysOf(entity);
|
|
88
|
-
const
|
|
103
|
+
const recordedKeys = (live?.foreignKeys ?? []).filter(
|
|
104
|
+
(key) => !predropped.has(keyId(entity.table, key.name)),
|
|
105
|
+
);
|
|
106
|
+
const held = new Map(recordedKeys.map((key) => [foreignKeyTarget(key), key]));
|
|
89
107
|
for (const key of wanted) {
|
|
90
108
|
const recorded = held.get(foreignKeyTarget(key));
|
|
91
109
|
if (doomed.has(key.referencedTable)) {
|
|
@@ -115,7 +133,7 @@ export function foreignKeyPlan(
|
|
|
115
133
|
}
|
|
116
134
|
const declared = new Set(wanted.map(foreignKeyTarget));
|
|
117
135
|
const columns = new Set(entity.columns.map((column) => column.column));
|
|
118
|
-
for (const key of
|
|
136
|
+
for (const key of recordedKeys) {
|
|
119
137
|
if (declared.has(foreignKeyTarget(key))) continue;
|
|
120
138
|
// `drop column` takes the constraint with it, so a `drop constraint` after that statement is
|
|
121
139
|
// `42704` on a constraint that is already gone.
|
|
@@ -136,19 +154,22 @@ export interface ConstraintPlans {
|
|
|
136
154
|
readonly preDrops: Plan;
|
|
137
155
|
/** The tables this migration drops, by name. */
|
|
138
156
|
readonly doomed: ReadonlySet<string>;
|
|
157
|
+
/**
|
|
158
|
+
* Recorded keys a retype already dropped ahead of the ALTERs, by `keyId` (`retype-keys.ts`).
|
|
159
|
+
* Read as "not recorded", never as "leave it alone": the declared side still needs its
|
|
160
|
+
* `add constraint`, and it is this function that writes it.
|
|
161
|
+
*/
|
|
162
|
+
readonly predropped: ReadonlySet<string>;
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
/**
|
|
142
166
|
* A key whose target is being dropped: gone on the way up, a note on the way back.
|
|
143
167
|
*
|
|
144
|
-
* The note
|
|
145
|
-
*
|
|
146
|
-
*
|
|
168
|
+
* The note itself is `unrestorableNote` (`foreign-key.ts`) and not a string built here —
|
|
169
|
+
* `retype-keys.ts` says the same thing about the same failed rollback, and two spellings of one
|
|
170
|
+
* fact is whichever module emitted last deciding what an operator reads.
|
|
147
171
|
*/
|
|
148
172
|
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
149
173
|
preDrops.up.push(dropForeignKey(table, constraint));
|
|
150
|
-
preDrops.down.push(
|
|
151
|
-
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
152
|
-
`cannot be restored; ${identifier(target).text} is gone`,
|
|
153
|
-
);
|
|
174
|
+
preDrops.down.push(unrestorableNote(table, constraint, target));
|
|
154
175
|
}
|
package/src/foreign-key.ts
CHANGED
|
@@ -52,6 +52,19 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
|
|
|
52
52
|
return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Which constraint, on which table — a key's NAME, where `foreignKeyTarget` is its meaning.
|
|
57
|
+
*
|
|
58
|
+
* The two exist for opposite questions and neither substitutes for the other. Drift asks whether
|
|
59
|
+
* two keys point the same way and must ignore the name; a plan that has already DROPPED a
|
|
60
|
+
* constraint asks whether this is that exact constraint, which is the name and nothing else. The
|
|
61
|
+
* table is in it because two tables may each hold a `..._org_id_fkey`, and `checkPlan`'s
|
|
62
|
+
* `predropped` set is the same shape one file over.
|
|
63
|
+
*/
|
|
64
|
+
export function keyId(table: string, constraint: string): string {
|
|
65
|
+
return JSON.stringify([table, constraint]);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
|
|
57
70
|
* now goes through. A name that closes its own quote produced a real `drop table` through
|
|
@@ -98,6 +111,26 @@ export function dropForeignKey(table: string, constraint: string): string {
|
|
|
98
111
|
return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What a `down` says in place of an `add constraint` it cannot run: the key's table or its target
|
|
116
|
+
* is dropped by this migration, so there is nothing to add the constraint back onto.
|
|
117
|
+
*
|
|
118
|
+
* ONE text, two writers — `foreign-key-plan.ts`'s `unrestorableDrop` (a key pointing at a doomed
|
|
119
|
+
* table) and `retype-keys.ts`'s `restore` (a key a retype moved aside whose ends are doomed).
|
|
120
|
+
* They spelled the same fact two ways and had already drifted, so an operator reading a failed
|
|
121
|
+
* rollback saw whichever module emitted last. It lives here because both import this module and
|
|
122
|
+
* neither imports the other.
|
|
123
|
+
*
|
|
124
|
+
* Every name goes through `identifier`, including `gone`: a `--` comment ends at the first
|
|
125
|
+
* newline, so a name holding one puts a second command on the line after it.
|
|
126
|
+
*/
|
|
127
|
+
export function unrestorableNote(table: string, constraint: string, gone: string): string {
|
|
128
|
+
return (
|
|
129
|
+
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
130
|
+
`cannot be restored; ${identifier(gone).text} is gone`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
/**
|
|
102
135
|
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
|
|
103
136
|
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
|
package/src/generate.ts
CHANGED
|
@@ -9,11 +9,11 @@ import { defaultExpression } from './column-default';
|
|
|
9
9
|
import { isDestructive } from './destructive';
|
|
10
10
|
import { dropOrder } from './drop-order';
|
|
11
11
|
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
12
|
-
import { migrationIrreversible } from './errors';
|
|
13
12
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
14
13
|
import type { Regeneration } from './generated-column';
|
|
15
14
|
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
16
|
-
import { createIndex,
|
|
15
|
+
import { createIndex, impliedByColumnClause } from './index-ddl';
|
|
16
|
+
import { indexPlan } from './index-plan';
|
|
17
17
|
import {
|
|
18
18
|
type ColumnDescription,
|
|
19
19
|
findTable,
|
|
@@ -21,31 +21,14 @@ import {
|
|
|
21
21
|
type TableDescription,
|
|
22
22
|
} from './introspect';
|
|
23
23
|
import { declaredIndexes } from './invariant-ddl';
|
|
24
|
+
import { migrationIrreversible } from './migration-errors';
|
|
24
25
|
import type { MovedAside } from './retype-dependents';
|
|
25
26
|
import { moveDependentsAside } from './retype-dependents';
|
|
27
|
+
import { moveKeysAside, retypedColumns, retypedIn } from './retype-keys';
|
|
26
28
|
import { identifier } from './sql';
|
|
29
|
+
import { sqlType } from './sql-type';
|
|
27
30
|
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
28
31
|
|
|
29
|
-
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
30
|
-
uuid: 'uuid',
|
|
31
|
-
text: 'text',
|
|
32
|
-
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
33
|
-
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
34
|
-
// Without the length no currency ever fits the constraint the same statement demands.
|
|
35
|
-
char: 'char(3)',
|
|
36
|
-
boolean: 'boolean',
|
|
37
|
-
integer: 'integer',
|
|
38
|
-
bigint: 'bigint',
|
|
39
|
-
numeric: 'numeric',
|
|
40
|
-
timestamptz: 'timestamptz',
|
|
41
|
-
date: 'date',
|
|
42
|
-
jsonb: 'jsonb',
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function sqlType(kind: string): string {
|
|
46
|
-
return SQL_TYPES[kind] ?? kind;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
32
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
50
33
|
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
51
34
|
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
@@ -160,15 +143,19 @@ function retypeColumn(
|
|
|
160
143
|
recorded: ColumnDescription,
|
|
161
144
|
plan: Plan,
|
|
162
145
|
moved: MovedAside,
|
|
146
|
+
retyped: ReadonlySet<string>,
|
|
163
147
|
): Regeneration {
|
|
164
148
|
const wanted = sqlType(column.kind);
|
|
165
149
|
const table = live.name;
|
|
166
150
|
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
167
151
|
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
168
152
|
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
169
|
-
return regenerate(
|
|
153
|
+
return regenerate(live, column, wanted, recorded, plan, moved);
|
|
170
154
|
}
|
|
171
|
-
|
|
155
|
+
// The set, never `recorded.dataType === wanted` a second time: `retypedColumns` decided this for
|
|
156
|
+
// the whole schema before any statement was written, because the foreign keys a retype breaks
|
|
157
|
+
// are recorded on tables this diff is not looking at (`retype-keys.ts`).
|
|
158
|
+
if (!retyped.has(column.column)) return 'unchanged';
|
|
172
159
|
moveDependentsAside(live, column.column, plan, moved);
|
|
173
160
|
const alter = (type: string): string =>
|
|
174
161
|
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
@@ -178,7 +165,12 @@ function retypeColumn(
|
|
|
178
165
|
return 'altered';
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
function diffTable(
|
|
168
|
+
function diffTable(
|
|
169
|
+
entity: EntityDescriptionLike,
|
|
170
|
+
live: TableDescription,
|
|
171
|
+
plan: Plan,
|
|
172
|
+
retyped: ReadonlySet<string>,
|
|
173
|
+
): void {
|
|
182
174
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
183
175
|
const added = new Set<string>();
|
|
184
176
|
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
@@ -189,7 +181,7 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
189
181
|
for (const column of entity.columns) {
|
|
190
182
|
const recorded = existing.get(column.column);
|
|
191
183
|
if (recorded !== undefined) {
|
|
192
|
-
if (retypeColumn(live, column, recorded, plan, moved) === 'rebuilt') {
|
|
184
|
+
if (retypeColumn(live, column, recorded, plan, moved, retyped) === 'rebuilt') {
|
|
193
185
|
rebuilt.add(column.column);
|
|
194
186
|
}
|
|
195
187
|
continue;
|
|
@@ -216,23 +208,9 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
216
208
|
);
|
|
217
209
|
}
|
|
218
210
|
|
|
219
|
-
|
|
220
|
-
for
|
|
221
|
-
|
|
222
|
-
// A rebuilt column took its indexes down with it, and a retype dropped the ones whose
|
|
223
|
-
// predicate it could not survive — either way this one is CREATED rather than compared:
|
|
224
|
-
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
225
|
-
const gone = moved.indexes.has(index.name) || index.columns.some((each) => rebuilt.has(each));
|
|
226
|
-
if (recorded !== undefined && !gone) {
|
|
227
|
-
redefineIndex(entity.table, index, recorded, plan);
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
// `added` only: an index over a column that was already there is implied by no clause this
|
|
231
|
-
// migration emits, so it still needs a statement of its own.
|
|
232
|
-
if (impliedByColumnClause(entity, index, added)) continue;
|
|
233
|
-
plan.up.push(createIndex(entity.table, index));
|
|
234
|
-
plan.down.push(dropIndex(index.name));
|
|
235
|
-
}
|
|
211
|
+
// Both directions, in `index-plan.ts`: a recorded index the entity no longer declares is DROPPED
|
|
212
|
+
// there, which is the arm this loop did not have for as long as it lived here.
|
|
213
|
+
indexPlan(entity, live, plan, { added, rebuilt, moved: moved.indexes });
|
|
236
214
|
|
|
237
215
|
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
|
|
238
216
|
// that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
|
|
@@ -292,12 +270,20 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
292
270
|
// Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
|
|
293
271
|
// migration drops makes that `drop table` `2BP01`.
|
|
294
272
|
const preDrops: Plan = { up: [], down: [] };
|
|
273
|
+
// Ahead of EVERYTHING, and at the far end of `down`: a foreign key compiled against a column
|
|
274
|
+
// being retyped has to be gone before the first ALTER and back after the last one, and both ends
|
|
275
|
+
// of one key can move in two different entities' diffs (`retype-keys.ts`).
|
|
276
|
+
const preAlters: Plan = { up: [], down: [] };
|
|
295
277
|
const wanted = new Set(options.entities.map((entity) => entity.table));
|
|
296
278
|
|
|
297
279
|
const doomed = new Set(
|
|
298
280
|
current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
|
|
299
281
|
);
|
|
300
|
-
|
|
282
|
+
// Before the loop, because the answer spans it: `diffTable` is handed one entity's recorded row
|
|
283
|
+
// and the key that a retype of its column breaks is recorded on whichever table OWNS the key.
|
|
284
|
+
const retyped = retypedColumns(options.entities, current);
|
|
285
|
+
const predropped = moveKeysAside(current, retyped, doomed, preAlters);
|
|
286
|
+
const plans: ConstraintPlans = { constraints, preDrops, doomed, predropped };
|
|
301
287
|
|
|
302
288
|
for (const entity of options.entities) {
|
|
303
289
|
const live = findTable(current, entity.table);
|
|
@@ -307,7 +293,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
307
293
|
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
308
294
|
continue;
|
|
309
295
|
}
|
|
310
|
-
diffTable(entity, live, plan);
|
|
296
|
+
diffTable(entity, live, plan, retypedIn(retyped, entity.table));
|
|
311
297
|
const kept = new Set(entity.columns.map((column) => column.column));
|
|
312
298
|
for (const column of live.columns) {
|
|
313
299
|
if (kept.has(column.name)) continue;
|
|
@@ -365,15 +351,17 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
365
351
|
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
|
|
366
352
|
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
|
|
367
353
|
const unrendered = unrenderedOf(options.entities, current);
|
|
368
|
-
const body = plan.up.join('\n');
|
|
354
|
+
const body = [...preAlters.up, ...plan.up].join('\n');
|
|
369
355
|
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
|
|
370
356
|
return {
|
|
371
357
|
id,
|
|
372
358
|
name: options.name,
|
|
373
359
|
fileName: `migrations/${id}.sql`,
|
|
374
360
|
up,
|
|
375
|
-
// Reverse order: the last thing created is the first thing dropped.
|
|
376
|
-
|
|
361
|
+
// Reverse order: the last thing created is the first thing dropped. `preAlters` goes in at the
|
|
362
|
+
// FRONT here precisely so reversal puts it last — a key is added back only once both of its
|
|
363
|
+
// ends have been retyped back, which is every other statement in the script.
|
|
364
|
+
down: [...preAlters.down, ...plan.down].reverse().join('\n'),
|
|
377
365
|
snapshot: snapshotOf(options.entities),
|
|
378
366
|
destructive: isDestructive(up),
|
|
379
367
|
unrendered,
|
package/src/generated-column.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { assert } from '@ultimat3/core';
|
|
7
7
|
import type { ColumnDescriptionLike } from './entity-shape';
|
|
8
8
|
import type { Plan } from './foreign-key-plan';
|
|
9
|
-
import type { ColumnDescription } from './introspect';
|
|
9
|
+
import type { ColumnDescription, TableDescription } from './introspect';
|
|
10
|
+
import type { MovedAside } from './retype-dependents';
|
|
11
|
+
import { moveDependentsAside } from './retype-dependents';
|
|
10
12
|
import { identifier } from './sql';
|
|
11
13
|
|
|
12
14
|
/** How a column that moved was brought back into line — what the caller has to do next, if anything. */
|
|
@@ -68,14 +70,26 @@ export const isGenerated = (column: ColumnDescriptionLike): boolean =>
|
|
|
68
70
|
* plain → generated (there is no `set expression` for a column that has none) and a column whose
|
|
69
71
|
* recorded expression is unknown. `drop expression` is the one that HAS a statement, and it is the
|
|
70
72
|
* generated → plain direction, which keeps the values it computed.
|
|
73
|
+
*
|
|
74
|
+
* **The rebuild moves the column's dependents aside first, and it is `retype-dependents.ts` that
|
|
75
|
+
* says which — never a second answer written here.** The `rebuilt`
|
|
76
|
+
* set `diffTable` carries into its index loop is keyed on an index's COLUMNS, so a partial index
|
|
77
|
+
* whose `where` names this column and whose key columns do not was dropped with the column by
|
|
78
|
+
* `drop column` and re-created by nothing: measured, the table came back with the index gone, the
|
|
79
|
+
* snapshot still recording it, and `down` unable to restore it. An invariant's CHECK reading the
|
|
80
|
+
* column is the same loss one arm over. `moveDependentsAside` drops each of them explicitly,
|
|
81
|
+
* restores them in `down`, and puts the name in `moved` — which is what makes the ordinary diff
|
|
82
|
+
* CREATE the declared one instead of comparing a definition that never moved.
|
|
71
83
|
*/
|
|
72
84
|
export function regenerate(
|
|
73
|
-
|
|
85
|
+
live: TableDescription,
|
|
74
86
|
column: ColumnDescriptionLike,
|
|
75
87
|
wantedType: string,
|
|
76
88
|
recorded: ColumnDescription,
|
|
77
89
|
plan: Plan,
|
|
90
|
+
moved: MovedAside,
|
|
78
91
|
): Regeneration {
|
|
92
|
+
const table = live.name;
|
|
79
93
|
const wanted = column.generated ?? null;
|
|
80
94
|
const held = recorded.generated ?? null;
|
|
81
95
|
if (wanted === null && held === null) return 'unchanged';
|
|
@@ -89,6 +103,7 @@ export function regenerate(
|
|
|
89
103
|
// column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
|
|
90
104
|
// implies none of them.
|
|
91
105
|
if (held === null) {
|
|
106
|
+
moveDependentsAside(live, column.column, plan, moved);
|
|
92
107
|
const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
|
|
93
108
|
plan.up.push(
|
|
94
109
|
dropColumn,
|
|
@@ -104,16 +119,26 @@ export function regenerate(
|
|
|
104
119
|
);
|
|
105
120
|
return 'rebuilt';
|
|
106
121
|
}
|
|
107
|
-
let
|
|
122
|
+
let changed = false;
|
|
123
|
+
// NOT `moveDependentsAside`, and the reason is measured rather than assumed. This ALTER trips the
|
|
124
|
+
// same `42883` (`operator does not exist: text > integer`, on a generated `integer` column under
|
|
125
|
+
// `where (doubled > 0)`) — but moving the index aside only relocates the failure to the
|
|
126
|
+
// `create index` that puts it back, because a predicate whose operator the NEW type has no
|
|
127
|
+
// resolution for cannot be written either. The plain path's dependents survive precisely because
|
|
128
|
+
// an untyped literal re-resolves (`status = 'published'` under an enum and under `text`), and a
|
|
129
|
+
// generated column reaching that shape needs its EXPRESSION changed in the same migration, which
|
|
130
|
+
// `regenerate` emits AFTER this statement. Left open deliberately, with the failure named.
|
|
108
131
|
if (recorded.dataType !== wantedType) {
|
|
109
132
|
plan.up.push(`${alterColumn(table, column.column)} type ${wantedType};`);
|
|
110
133
|
plan.down.push(`${alterColumn(table, column.column)} type ${recorded.dataType};`);
|
|
111
|
-
|
|
134
|
+
changed = true;
|
|
112
135
|
}
|
|
136
|
+
// Nothing moves for this one either, and here it is free: `set expression` recomputes every value
|
|
137
|
+
// and leaves the type alone, so nothing compiled against the type has anything to recompile.
|
|
113
138
|
if (held !== wanted) {
|
|
114
139
|
plan.up.push(`${alterColumn(table, column.column)} set expression as (${wanted});`);
|
|
115
140
|
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held});`);
|
|
116
|
-
|
|
141
|
+
changed = true;
|
|
117
142
|
}
|
|
118
|
-
return
|
|
143
|
+
return changed ? 'altered' : 'unchanged';
|
|
119
144
|
}
|
package/src/index-ddl.ts
CHANGED
|
@@ -84,6 +84,57 @@ export function createIndex(table: string, index: IndexDescriptionLike): string
|
|
|
84
84
|
/** `drop index "n";` — the one spelling, so a drop and its recreate cannot name it differently. */
|
|
85
85
|
export const dropIndex = (name: string): string => `drop index ${identifier(name).text};`;
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Whether Postgres could be backing this RECORDED index with a UNIQUE constraint rather than
|
|
89
|
+
* holding it as an index of its own — which decides the only question `dropIndex` cannot answer.
|
|
90
|
+
*
|
|
91
|
+
* A UNIQUE constraint's index is unique, total, unordered and btree; `add constraint … unique`
|
|
92
|
+
* and a `unique` column clause can produce nothing else. So an index missing any one of those is
|
|
93
|
+
* provably an index, and `drop index` on it is right. Everything else is genuinely ambiguous —
|
|
94
|
+
* see `dropRecordedIndex`.
|
|
95
|
+
*/
|
|
96
|
+
export function mayBeConstraintBacked(index: IndexDescription): boolean {
|
|
97
|
+
return (
|
|
98
|
+
index.unique &&
|
|
99
|
+
!index.primary &&
|
|
100
|
+
index.where === null &&
|
|
101
|
+
index.order === null &&
|
|
102
|
+
indexMethodOf(index) === 'btree'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a RECORDED index whose kind this generator cannot know, in statements that are correct
|
|
108
|
+
* on both databases it cannot tell apart.
|
|
109
|
+
*
|
|
110
|
+
* `TableDescription` carries no discriminator, and it cannot be given one that would help: the
|
|
111
|
+
* SAME declaration reaches the server as a CONSTRAINT or as an INDEX depending on which migration
|
|
112
|
+
* created it. A `unique` column on a table `createTable` writes emits `create table … slug text
|
|
113
|
+
* unique`, and Postgres backs that with a constraint named `posts_slug_key`; the same column
|
|
114
|
+
* gaining `unique` later takes `diffTable`'s `create unique index "posts_slug_key"` and is a plain
|
|
115
|
+
* index. `snapshotOf` records both as `{ unique: true, primary: false }`, and every sidecar already
|
|
116
|
+
* on disk was written that way — a new field could not classify one of them retroactively.
|
|
117
|
+
*
|
|
118
|
+
* Measured on 18.4 (`index-removal.live.test.ts`), which is why the pair and not a guess:
|
|
119
|
+
*
|
|
120
|
+
* | statement | on a constraint's index | on a plain index |
|
|
121
|
+
* |--------------------------------------------|-------------------------|------------------|
|
|
122
|
+
* | `drop index "n"` | **2BP01** | ok |
|
|
123
|
+
* | `drop index if exists "n"` | **2BP01** — not suppressed | ok |
|
|
124
|
+
* | `alter table … drop constraint if exists` | drops it, index and all | notice, no-op |
|
|
125
|
+
*
|
|
126
|
+
* Constraint first, then the index: reversed, the `drop index` reaches a constraint's index and is
|
|
127
|
+
* the 2BP01 this exists to avoid. Both halves carry `if exists`, so whichever one did nothing says
|
|
128
|
+
* so with a notice rather than 42704.
|
|
129
|
+
*/
|
|
130
|
+
export function dropRecordedIndex(table: string, index: IndexDescription): readonly string[] {
|
|
131
|
+
if (!mayBeConstraintBacked(index)) return [dropIndex(index.name)];
|
|
132
|
+
return [
|
|
133
|
+
`alter table ${identifier(table).text} drop constraint if exists ${identifier(index.name).text};`,
|
|
134
|
+
`drop index if exists ${identifier(index.name).text};`,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
87
138
|
/**
|
|
88
139
|
* A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
|
|
89
140
|
* cast: the recorded side is typed open because the catalog shares the shape, and a method this
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: which indexes an existing table gains, has rebuilt, or LOSES. Split out
|
|
2
|
+
// of `generate.ts` along the seam `check-ddl.ts` and `index-ddl.ts` already drew — `generate.ts`
|
|
3
|
+
// assembles a plan, `index-ddl.ts` writes the statements, and this file decides which of them go
|
|
4
|
+
// in. `checkPlan` is its shape, deliberately: "what does the record hold that the declaration does
|
|
5
|
+
// not" is one question, and a fourth spelling of it is the split axiom 1 refuses.
|
|
6
|
+
//
|
|
7
|
+
// The removal arm is why this file exists. `diffTable` walked `declaredIndexes(entity)` and matched
|
|
8
|
+
// by name, with no reverse pass, so an index the entities stopped declaring stayed on the database
|
|
9
|
+
// forever while the sidecar beside it stopped recording it — `examples/dummy` carried
|
|
10
|
+
// `member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` through every
|
|
11
|
+
// regeneration, and `x verify`'s drift step was green over all three because drift judges the
|
|
12
|
+
// declared side. The same defect `foreignKeyPlan` closed on 2026-08-19, one arm over.
|
|
13
|
+
//
|
|
14
|
+
// KNOWN LIMIT, named rather than half-built: a UNIQUE index that a foreign key on ANOTHER table
|
|
15
|
+
// still references cannot be dropped (2BP01), and this arm sees one table at a time. That
|
|
16
|
+
// declaration is already broken — the key has nothing to point at — and the failure arrives with
|
|
17
|
+
// the server's own words naming both ends.
|
|
18
|
+
|
|
19
|
+
import type { EntityDescriptionLike } from './entity-shape';
|
|
20
|
+
import type { Plan } from './foreign-key-plan';
|
|
21
|
+
import {
|
|
22
|
+
asDeclared,
|
|
23
|
+
createIndex,
|
|
24
|
+
dropIndex,
|
|
25
|
+
dropRecordedIndex,
|
|
26
|
+
impliedByColumnClause,
|
|
27
|
+
redefineIndex,
|
|
28
|
+
} from './index-ddl';
|
|
29
|
+
import type { TableDescription } from './introspect';
|
|
30
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What the rest of this migration has already done to the columns underneath the indexes — every
|
|
34
|
+
* field is a set of names some other arm produced, and each answers "this index is already gone".
|
|
35
|
+
*/
|
|
36
|
+
export interface IndexPlanContext {
|
|
37
|
+
/** Columns this migration ADDS, whose own `unique` clause brings an index Postgres names. */
|
|
38
|
+
readonly added: ReadonlySet<string>;
|
|
39
|
+
/** Columns `regenerate` dropped and re-added outright — every index over one went with it. */
|
|
40
|
+
readonly rebuilt: ReadonlySet<string>;
|
|
41
|
+
/** Indexes a retype already dropped ahead of its ALTER (`moveDependentsAside`). */
|
|
42
|
+
readonly moved: ReadonlySet<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which indexes an existing table gains, has rebuilt, or loses.
|
|
47
|
+
*
|
|
48
|
+
* Declared first and removed last, the order `checkPlan` uses. Both orders are safe — two indexes
|
|
49
|
+
* over the same columns may coexist for the length of one migration — so the tie goes to the file
|
|
50
|
+
* this one is a copy of.
|
|
51
|
+
*
|
|
52
|
+
* `down` is pushed FORWARDS and read backwards, because assembly reverses it: the restore of a
|
|
53
|
+
* removed index therefore lands after the drop of everything created beside it.
|
|
54
|
+
*/
|
|
55
|
+
export function indexPlan(
|
|
56
|
+
entity: EntityDescriptionLike,
|
|
57
|
+
live: TableDescription,
|
|
58
|
+
plan: Plan,
|
|
59
|
+
context: IndexPlanContext,
|
|
60
|
+
): void {
|
|
61
|
+
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
62
|
+
const declared = new Set<string>();
|
|
63
|
+
for (const index of declaredIndexes(entity)) {
|
|
64
|
+
declared.add(index.name);
|
|
65
|
+
const recorded = indexed.get(index.name);
|
|
66
|
+
// A rebuilt column took its indexes down with it, and a retype dropped the ones whose
|
|
67
|
+
// predicate it could not survive — either way this one is CREATED rather than compared:
|
|
68
|
+
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
69
|
+
const gone =
|
|
70
|
+
context.moved.has(index.name) || index.columns.some((each) => context.rebuilt.has(each));
|
|
71
|
+
if (recorded !== undefined && !gone) {
|
|
72
|
+
redefineIndex(entity.table, index, recorded, plan);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// `added` only: an index over a column that was already there is implied by no clause this
|
|
76
|
+
// migration emits, so it still needs a statement of its own.
|
|
77
|
+
if (impliedByColumnClause(entity, index, context.added)) continue;
|
|
78
|
+
plan.up.push(createIndex(entity.table, index));
|
|
79
|
+
// The plain drop, always: this migration CREATED it, with `create index`, so it is an index
|
|
80
|
+
// and never a constraint's — `dropRecordedIndex`'s ambiguity is about the recorded side only.
|
|
81
|
+
plan.down.push(dropIndex(index.name));
|
|
82
|
+
}
|
|
83
|
+
removeUndeclared(entity, live, plan, context, declared);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every recorded index this entity no longer declares, dropped — and restored in `down` from what
|
|
88
|
+
* the SNAPSHOT recorded, never from what the entity declares, since the entity is precisely what
|
|
89
|
+
* stopped describing it. The rule the retype path already states.
|
|
90
|
+
*
|
|
91
|
+
* Four names are skipped, and each one is a statement Postgres would refuse or repeat:
|
|
92
|
+
*
|
|
93
|
+
* | skipped | because |
|
|
94
|
+
* |------------------------------------------|---------|
|
|
95
|
+
* | `primary` | `drop index` on a primary key's index is 2BP01; the key is `TableDescription.primaryKey`, a different question |
|
|
96
|
+
* | already in `context.moved` | a retype dropped it ahead of the ALTER — a second drop is 42704 |
|
|
97
|
+
* | over a column in `context.rebuilt` | it went with the `drop column` half of `regenerate` — 42704 |
|
|
98
|
+
* | over a column this migration DROPS | `alter table … drop column` takes it, so a drop beside it says nothing new. The rule `foreignKeyPlan` applies to a constraint on a dropped column |
|
|
99
|
+
*
|
|
100
|
+
* A doomed TABLE needs no arm: `generate.ts` only reaches a diff for a table an entity still
|
|
101
|
+
* declares, so `drop table` and this function never meet.
|
|
102
|
+
*/
|
|
103
|
+
function removeUndeclared(
|
|
104
|
+
entity: EntityDescriptionLike,
|
|
105
|
+
live: TableDescription,
|
|
106
|
+
plan: Plan,
|
|
107
|
+
context: IndexPlanContext,
|
|
108
|
+
declared: ReadonlySet<string>,
|
|
109
|
+
): void {
|
|
110
|
+
const columns = new Set(entity.columns.map((column) => column.column));
|
|
111
|
+
for (const recorded of live.indexes) {
|
|
112
|
+
if (recorded.primary || declared.has(recorded.name)) continue;
|
|
113
|
+
if (context.moved.has(recorded.name)) continue;
|
|
114
|
+
if (recorded.columns.some((column) => context.rebuilt.has(column))) continue;
|
|
115
|
+
if (!recorded.columns.every((column) => columns.has(column))) continue;
|
|
116
|
+
plan.up.push(...dropRecordedIndex(live.name, recorded));
|
|
117
|
+
plan.down.push(createIndex(live.name, asDeclared(recorded)));
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -81,15 +81,9 @@ export {
|
|
|
81
81
|
dbUnavailable,
|
|
82
82
|
driverError,
|
|
83
83
|
identifierUnsafe,
|
|
84
|
-
migrateConcurrent,
|
|
85
|
-
migrationConflict,
|
|
86
|
-
migrationDestructive,
|
|
87
|
-
migrationIrreversible,
|
|
88
|
-
migrationSnapshotMissing,
|
|
89
84
|
multipleStatements,
|
|
90
85
|
poolAcquireTimeout,
|
|
91
86
|
poolMaxInvalid,
|
|
92
|
-
rollbackStepsInvalid,
|
|
93
87
|
serializationExhausted,
|
|
94
88
|
sqlUnsafe,
|
|
95
89
|
} from './errors';
|
|
@@ -147,6 +141,15 @@ export {
|
|
|
147
141
|
rollback,
|
|
148
142
|
runningAppVersion,
|
|
149
143
|
} from './migrate';
|
|
144
|
+
export {
|
|
145
|
+
migrateConcurrent,
|
|
146
|
+
migrationConflict,
|
|
147
|
+
migrationDestructive,
|
|
148
|
+
migrationIrreversible,
|
|
149
|
+
migrationSnapshotMissing,
|
|
150
|
+
migrationViewDepends,
|
|
151
|
+
rollbackStepsInvalid,
|
|
152
|
+
} from './migration-errors';
|
|
150
153
|
export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
|
|
151
154
|
export { setStatementObserver, statementObserver } from './observe';
|
|
152
155
|
export type {
|
package/src/migrate.ts
CHANGED
|
@@ -11,9 +11,10 @@ import {
|
|
|
11
11
|
isReservable,
|
|
12
12
|
poolProfileFor,
|
|
13
13
|
} from './client';
|
|
14
|
-
import {
|
|
14
|
+
import { refuseDependentViews } from './dependent-view';
|
|
15
15
|
import { expectedQueryLoop } from './expected-loop';
|
|
16
16
|
import type { SchemaDescription } from './introspect';
|
|
17
|
+
import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
|
|
17
18
|
import { raw, sql } from './sql';
|
|
18
19
|
import { SQLSTATE, sqlState } from './sqlstate';
|
|
19
20
|
import { statementsOf } from './statement-split';
|
|
@@ -348,6 +349,12 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
|
|
|
348
349
|
await withTransaction(
|
|
349
350
|
async (tx) => {
|
|
350
351
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
352
|
+
// Before the first statement, never after the failure: a view compiled against a
|
|
353
|
+
// column this script retypes is `0A000` from the server with the view named only in
|
|
354
|
+
// a DETAIL field nothing prints, surfaced as "cannot reach the database". Costs one
|
|
355
|
+
// text scan and no round trip on a migration that retypes nothing, which is nearly
|
|
356
|
+
// all of them (`dependent-view.ts`).
|
|
357
|
+
await refuseDependentViews(tx, migration.up);
|
|
351
358
|
await applyScript(tx, migration.up);
|
|
352
359
|
const durationMs = Math.round(performance.now() - at);
|
|
353
360
|
await tx.execute(sql`
|
|
@@ -438,6 +445,9 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
|
|
|
438
445
|
await withTransaction(
|
|
439
446
|
async (tx) => {
|
|
440
447
|
await setLockTimeout(tx, lockTimeoutMs);
|
|
448
|
+
// Both directions: a reversal retypes the same column back, and a view created
|
|
449
|
+
// since the migration applied blocks it exactly as one created before would.
|
|
450
|
+
await refuseDependentViews(tx, migration.down);
|
|
441
451
|
await applyScript(tx, migration.down);
|
|
442
452
|
await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
|
|
443
453
|
},
|