@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
package/src/generate.ts
CHANGED
|
@@ -3,52 +3,32 @@
|
|
|
3
3
|
// the CLI passes `describeEntities()` and the types below mirror `EntityDescription` field for
|
|
4
4
|
// field. Every generated migration must be reversible; a drop that loses data refuses instead.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { systemClock } from '@ultimat3/core';
|
|
7
7
|
import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
|
|
8
8
|
import { defaultExpression } from './column-default';
|
|
9
9
|
import { isDestructive } from './destructive';
|
|
10
10
|
import { dropOrder } from './drop-order';
|
|
11
|
-
import type {
|
|
12
|
-
ColumnDescriptionLike,
|
|
13
|
-
EntityDescriptionLike,
|
|
14
|
-
IndexDescriptionLike,
|
|
15
|
-
} from './entity-shape';
|
|
16
|
-
import { migrationIrreversible } from './errors';
|
|
11
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
17
12
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
18
13
|
import type { Regeneration } from './generated-column';
|
|
19
14
|
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
20
|
-
import {
|
|
15
|
+
import { createIndex, impliedByColumnClause } from './index-ddl';
|
|
16
|
+
import { indexPlan } from './index-plan';
|
|
21
17
|
import {
|
|
22
18
|
type ColumnDescription,
|
|
23
19
|
findTable,
|
|
24
|
-
type IndexDescription,
|
|
25
20
|
type SchemaDescription,
|
|
26
21
|
type TableDescription,
|
|
27
22
|
} from './introspect';
|
|
28
23
|
import { declaredIndexes } from './invariant-ddl';
|
|
24
|
+
import { migrationIrreversible } from './migration-errors';
|
|
25
|
+
import type { MovedAside } from './retype-dependents';
|
|
26
|
+
import { moveDependentsAside } from './retype-dependents';
|
|
27
|
+
import { moveKeysAside, retypedColumns, retypedIn } from './retype-keys';
|
|
29
28
|
import { identifier } from './sql';
|
|
29
|
+
import { sqlType } from './sql-type';
|
|
30
30
|
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
31
31
|
|
|
32
|
-
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
33
|
-
uuid: 'uuid',
|
|
34
|
-
text: 'text',
|
|
35
|
-
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
36
|
-
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
37
|
-
// Without the length no currency ever fits the constraint the same statement demands.
|
|
38
|
-
char: 'char(3)',
|
|
39
|
-
boolean: 'boolean',
|
|
40
|
-
integer: 'integer',
|
|
41
|
-
bigint: 'bigint',
|
|
42
|
-
numeric: 'numeric',
|
|
43
|
-
timestamptz: 'timestamptz',
|
|
44
|
-
date: 'date',
|
|
45
|
-
jsonb: 'jsonb',
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
function sqlType(kind: string): string {
|
|
49
|
-
return SQL_TYPES[kind] ?? kind;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
32
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
53
33
|
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
54
34
|
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
@@ -74,34 +54,6 @@ function columnClause(column: ColumnDescriptionLike): string {
|
|
|
74
54
|
return parts.join(' ');
|
|
75
55
|
}
|
|
76
56
|
|
|
77
|
-
/**
|
|
78
|
-
* A `unique` column clause already creates an index, and Postgres names it exactly what the
|
|
79
|
-
* entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
|
|
80
|
-
* it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
|
|
81
|
-
* Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
|
|
82
|
-
*
|
|
83
|
-
* A **partial** unique index is not that index: the column clause constrains every row, so
|
|
84
|
-
* skipping the partial one would silently widen the constraint the entity declared.
|
|
85
|
-
*/
|
|
86
|
-
function impliedByColumnClause(
|
|
87
|
-
entity: EntityDescriptionLike,
|
|
88
|
-
index: IndexDescriptionLike,
|
|
89
|
-
added: ReadonlySet<string>,
|
|
90
|
-
): boolean {
|
|
91
|
-
const [only] = index.columns;
|
|
92
|
-
if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
const column = entity.columns.find((each) => each.column === only);
|
|
96
|
-
// `columnClause` writes `unique` under exactly this condition — keep the two in step.
|
|
97
|
-
//
|
|
98
|
-
// NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
|
|
99
|
-
// `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
|
|
100
|
-
// unsafe for exactly this reason — applying it turned a green typecheck red.
|
|
101
|
-
// biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
|
|
102
|
-
return column !== undefined && column.unique && !column.primaryKey && added.has(only);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
57
|
export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
|
|
106
58
|
const tables = [...entities]
|
|
107
59
|
.sort((a, b) => (a.table < b.table ? -1 : 1))
|
|
@@ -172,68 +124,39 @@ function createTable(entity: EntityDescriptionLike): readonly string[] {
|
|
|
172
124
|
return statements;
|
|
173
125
|
}
|
|
174
126
|
|
|
175
|
-
/**
|
|
176
|
-
* Every part of the declaration reaches the statement: the whole column list in its declared
|
|
177
|
-
* order, the direction when one was asked for, and the predicate that makes it partial. A part
|
|
178
|
-
* dropped here is a constraint the database does not hold or an index the planner cannot use.
|
|
179
|
-
*/
|
|
180
|
-
function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
181
|
-
assert(
|
|
182
|
-
index.columns.length > 0,
|
|
183
|
-
`index "${index.name}" on "${table}" names no columns`,
|
|
184
|
-
`indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
|
|
185
|
-
);
|
|
186
|
-
const method = index.using ?? 'btree';
|
|
187
|
-
// Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
|
|
188
|
-
// GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
|
|
189
|
-
// as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
|
|
190
|
-
// and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
|
|
191
|
-
// index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
|
|
192
|
-
assert(
|
|
193
|
-
method === 'btree' || !index.unique,
|
|
194
|
-
`index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
|
|
195
|
-
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
|
|
196
|
-
);
|
|
197
|
-
assert(
|
|
198
|
-
method === 'btree' || index.order === null,
|
|
199
|
-
`index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
|
|
200
|
-
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
|
|
201
|
-
);
|
|
202
|
-
const kind = index.unique ? 'create unique index' : 'create index';
|
|
203
|
-
const direction = index.order === null ? '' : ` ${index.order}`;
|
|
204
|
-
const columns = index.columns
|
|
205
|
-
.map((column) => `${identifier(column).text}${direction}`)
|
|
206
|
-
.join(', ');
|
|
207
|
-
const predicate = index.where === null ? '' : ` where (${index.where})`;
|
|
208
|
-
// Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
|
|
209
|
-
// an index that declared no method emits the statement this generator always emitted, byte for
|
|
210
|
-
// byte, and one that declared a method Postgres does not have is refused instead of built.
|
|
211
|
-
return (
|
|
212
|
-
`${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
|
|
213
|
-
`${indexMethodSql(method)} (${columns})${predicate};`
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
127
|
/**
|
|
218
128
|
* Skipping an existing column by name alone missed the type moving under it: a table created
|
|
219
129
|
* while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
|
|
220
130
|
* snapshot this run records claims `char(3)` — two claims with no statement between them. Both
|
|
221
131
|
* sides are generated spellings (`current` is a previous migration's own snapshot), so any
|
|
222
132
|
* difference is a real kind change, not a catalog alias.
|
|
133
|
+
*
|
|
134
|
+
* The ALTER is not the whole statement list: Postgres compiled every predicate written against
|
|
135
|
+
* this column with its OLD type and cannot recompile one, so a partial index or a CHECK that reads
|
|
136
|
+
* it is dropped FIRST and `moved` carries the names on to the arms that would otherwise act on
|
|
137
|
+
* them. Without that the retype is `42883` and the migration aborts mid-run
|
|
138
|
+
* (`retype-dependents.ts`).
|
|
223
139
|
*/
|
|
224
140
|
function retypeColumn(
|
|
225
|
-
|
|
141
|
+
live: TableDescription,
|
|
226
142
|
column: ColumnDescriptionLike,
|
|
227
143
|
recorded: ColumnDescription,
|
|
228
144
|
plan: Plan,
|
|
145
|
+
moved: MovedAside,
|
|
146
|
+
retyped: ReadonlySet<string>,
|
|
229
147
|
): Regeneration {
|
|
230
148
|
const wanted = sqlType(column.kind);
|
|
149
|
+
const table = live.name;
|
|
231
150
|
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
232
151
|
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
233
152
|
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
234
|
-
return regenerate(
|
|
153
|
+
return regenerate(live, column, wanted, recorded, plan, moved);
|
|
235
154
|
}
|
|
236
|
-
|
|
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';
|
|
159
|
+
moveDependentsAside(live, column.column, plan, moved);
|
|
237
160
|
const alter = (type: string): string =>
|
|
238
161
|
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
239
162
|
`type ${type} using ${identifier(column.column).text}::${type};`;
|
|
@@ -242,64 +165,23 @@ function retypeColumn(
|
|
|
242
165
|
return 'altered';
|
|
243
166
|
}
|
|
244
167
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
[...index.columns],
|
|
249
|
-
index.unique,
|
|
250
|
-
index.where,
|
|
251
|
-
index.order ?? null,
|
|
252
|
-
indexMethodOf(index),
|
|
253
|
-
]);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* A same-named index whose definition moved is dropped and recreated, because Postgres has no
|
|
258
|
-
* `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
|
|
259
|
-
* are all fixed at creation.
|
|
260
|
-
*
|
|
261
|
-
* Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
|
|
262
|
-
* entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
|
|
263
|
-
* migration and the database kept serving the old one. Both sides here are *generated* spellings
|
|
264
|
-
* — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
|
|
265
|
-
* text difference in `where` is a real change and not a formatting one.
|
|
266
|
-
*/
|
|
267
|
-
function redefineIndex(
|
|
268
|
-
table: string,
|
|
269
|
-
index: IndexDescriptionLike,
|
|
270
|
-
recorded: IndexDescription,
|
|
168
|
+
function diffTable(
|
|
169
|
+
entity: EntityDescriptionLike,
|
|
170
|
+
live: TableDescription,
|
|
271
171
|
plan: Plan,
|
|
172
|
+
retyped: ReadonlySet<string>,
|
|
272
173
|
): void {
|
|
273
|
-
if (indexShape(index) === indexShape(recorded)) return;
|
|
274
|
-
plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index));
|
|
275
|
-
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
276
|
-
// the recorded definition is what must land last, after the new one is dropped.
|
|
277
|
-
plan.down.push(
|
|
278
|
-
createIndex(table, {
|
|
279
|
-
name: recorded.name,
|
|
280
|
-
columns: recorded.columns,
|
|
281
|
-
unique: recorded.unique,
|
|
282
|
-
where: recorded.where,
|
|
283
|
-
order: recorded.order,
|
|
284
|
-
// `declaredMethod`, never a cast: `recorded` is a snapshot's, typed open because the catalog
|
|
285
|
-
// shares the shape, and a method this generator cannot emit must refuse rather than be
|
|
286
|
-
// rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
|
|
287
|
-
...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
|
|
288
|
-
}),
|
|
289
|
-
`drop index ${identifier(index.name).text};`,
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
|
|
294
174
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
295
175
|
const added = new Set<string>();
|
|
296
176
|
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
297
177
|
// over it has to be stated again even though its own definition never moved.
|
|
298
178
|
const rebuilt = new Set<string>();
|
|
179
|
+
// What a retype dropped ahead of itself, read by the two arms below.
|
|
180
|
+
const moved: MovedAside = { indexes: new Set(), checks: new Set() };
|
|
299
181
|
for (const column of entity.columns) {
|
|
300
182
|
const recorded = existing.get(column.column);
|
|
301
183
|
if (recorded !== undefined) {
|
|
302
|
-
if (retypeColumn(
|
|
184
|
+
if (retypeColumn(live, column, recorded, plan, moved, retyped) === 'rebuilt') {
|
|
303
185
|
rebuilt.add(column.column);
|
|
304
186
|
}
|
|
305
187
|
continue;
|
|
@@ -326,26 +208,15 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
326
208
|
);
|
|
327
209
|
}
|
|
328
210
|
|
|
329
|
-
|
|
330
|
-
for
|
|
331
|
-
|
|
332
|
-
// A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:
|
|
333
|
-
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
334
|
-
if (recorded !== undefined && !index.columns.some((column) => rebuilt.has(column))) {
|
|
335
|
-
redefineIndex(entity.table, index, recorded, plan);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
// `added` only: an index over a column that was already there is implied by no clause this
|
|
339
|
-
// migration emits, so it still needs a statement of its own.
|
|
340
|
-
if (impliedByColumnClause(entity, index, added)) continue;
|
|
341
|
-
plan.up.push(createIndex(entity.table, index));
|
|
342
|
-
plan.down.push(`drop index ${identifier(index.name).text};`);
|
|
343
|
-
}
|
|
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 });
|
|
344
214
|
|
|
345
215
|
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
|
|
346
216
|
// that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
|
|
347
|
-
// column dropped and re-added lost its constraint while the snapshot still records it
|
|
348
|
-
|
|
217
|
+
// column dropped and re-added lost its constraint while the snapshot still records it, and
|
|
218
|
+
// `moved.checks` because a retype already dropped the ones written against the old type.
|
|
219
|
+
checkPlan(entity, live, plan, rebuilt, moved.checks);
|
|
349
220
|
}
|
|
350
221
|
|
|
351
222
|
export interface GenerateOptions {
|
|
@@ -399,12 +270,20 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
399
270
|
// Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
|
|
400
271
|
// migration drops makes that `drop table` `2BP01`.
|
|
401
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: [] };
|
|
402
277
|
const wanted = new Set(options.entities.map((entity) => entity.table));
|
|
403
278
|
|
|
404
279
|
const doomed = new Set(
|
|
405
280
|
current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
|
|
406
281
|
);
|
|
407
|
-
|
|
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 };
|
|
408
287
|
|
|
409
288
|
for (const entity of options.entities) {
|
|
410
289
|
const live = findTable(current, entity.table);
|
|
@@ -414,7 +293,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
414
293
|
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
415
294
|
continue;
|
|
416
295
|
}
|
|
417
|
-
diffTable(entity, live, plan);
|
|
296
|
+
diffTable(entity, live, plan, retypedIn(retyped, entity.table));
|
|
418
297
|
const kept = new Set(entity.columns.map((column) => column.column));
|
|
419
298
|
for (const column of live.columns) {
|
|
420
299
|
if (kept.has(column.name)) continue;
|
|
@@ -472,15 +351,17 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
472
351
|
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
|
|
473
352
|
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
|
|
474
353
|
const unrendered = unrenderedOf(options.entities, current);
|
|
475
|
-
const body = plan.up.join('\n');
|
|
354
|
+
const body = [...preAlters.up, ...plan.up].join('\n');
|
|
476
355
|
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
|
|
477
356
|
return {
|
|
478
357
|
id,
|
|
479
358
|
name: options.name,
|
|
480
359
|
fileName: `migrations/${id}.sql`,
|
|
481
360
|
up,
|
|
482
|
-
// Reverse order: the last thing created is the first thing dropped.
|
|
483
|
-
|
|
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'),
|
|
484
365
|
snapshot: snapshotOf(options.entities),
|
|
485
366
|
destructive: isDestructive(up),
|
|
486
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
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Single responsibility: the DDL an entity's INDEX declaration becomes — the `create index` a
|
|
2
|
+
// declaration writes out, what makes two definitions the same index, and what a moved definition
|
|
3
|
+
// rebuilds. Split out of `generate.ts` at the 500-line ceiling, along the seam `check-ddl.ts` and
|
|
4
|
+
// `generated-column.ts` already drew: `generate.ts` assembles a plan, this file writes the index
|
|
5
|
+
// statements it puts in it, and `invariant-ddl.ts` decides which indexes a table declares.
|
|
6
|
+
|
|
7
|
+
import { assert } from '@ultimat3/core';
|
|
8
|
+
import type { EntityDescriptionLike, IndexDescriptionLike } from './entity-shape';
|
|
9
|
+
import type { Plan } from './foreign-key-plan';
|
|
10
|
+
import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
|
|
11
|
+
import type { IndexDescription } from './introspect';
|
|
12
|
+
import { identifier } from './sql';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A `unique` column clause already creates an index, and Postgres names it exactly what the
|
|
16
|
+
* entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
|
|
17
|
+
* it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
|
|
18
|
+
* Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
|
|
19
|
+
*
|
|
20
|
+
* A **partial** unique index is not that index: the column clause constrains every row, so
|
|
21
|
+
* skipping the partial one would silently widen the constraint the entity declared.
|
|
22
|
+
*/
|
|
23
|
+
export function impliedByColumnClause(
|
|
24
|
+
entity: EntityDescriptionLike,
|
|
25
|
+
index: IndexDescriptionLike,
|
|
26
|
+
added: ReadonlySet<string>,
|
|
27
|
+
): boolean {
|
|
28
|
+
const [only] = index.columns;
|
|
29
|
+
if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const column = entity.columns.find((each) => each.column === only);
|
|
33
|
+
// `columnClause` writes `unique` under exactly this condition — keep the two in step.
|
|
34
|
+
//
|
|
35
|
+
// NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
|
|
36
|
+
// `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
|
|
37
|
+
// unsafe for exactly this reason — applying it turned a green typecheck red.
|
|
38
|
+
// biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
|
|
39
|
+
return column !== undefined && column.unique && !column.primaryKey && added.has(only);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every part of the declaration reaches the statement: the whole column list in its declared
|
|
44
|
+
* order, the direction when one was asked for, and the predicate that makes it partial. A part
|
|
45
|
+
* dropped here is a constraint the database does not hold or an index the planner cannot use.
|
|
46
|
+
*/
|
|
47
|
+
export function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
48
|
+
assert(
|
|
49
|
+
index.columns.length > 0,
|
|
50
|
+
`index "${index.name}" on "${table}" names no columns`,
|
|
51
|
+
`indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
|
|
52
|
+
);
|
|
53
|
+
const method = index.using ?? 'btree';
|
|
54
|
+
// Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
|
|
55
|
+
// GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
|
|
56
|
+
// as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
|
|
57
|
+
// and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
|
|
58
|
+
// index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
|
|
59
|
+
assert(
|
|
60
|
+
method === 'btree' || !index.unique,
|
|
61
|
+
`index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
|
|
62
|
+
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
|
|
63
|
+
);
|
|
64
|
+
assert(
|
|
65
|
+
method === 'btree' || index.order === null,
|
|
66
|
+
`index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
|
|
67
|
+
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
|
|
68
|
+
);
|
|
69
|
+
const kind = index.unique ? 'create unique index' : 'create index';
|
|
70
|
+
const direction = index.order === null ? '' : ` ${index.order}`;
|
|
71
|
+
const columns = index.columns
|
|
72
|
+
.map((column) => `${identifier(column).text}${direction}`)
|
|
73
|
+
.join(', ');
|
|
74
|
+
const predicate = index.where === null ? '' : ` where (${index.where})`;
|
|
75
|
+
// Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
|
|
76
|
+
// an index that declared no method emits the statement this generator always emitted, byte for
|
|
77
|
+
// byte, and one that declared a method Postgres does not have is refused instead of built.
|
|
78
|
+
return (
|
|
79
|
+
`${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
|
|
80
|
+
`${indexMethodSql(method)} (${columns})${predicate};`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `drop index "n";` — the one spelling, so a drop and its recreate cannot name it differently. */
|
|
85
|
+
export const dropIndex = (name: string): string => `drop index ${identifier(name).text};`;
|
|
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
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
|
|
140
|
+
* cast: the recorded side is typed open because the catalog shares the shape, and a method this
|
|
141
|
+
* generator cannot write must refuse rather than be rebuilt as a btree. One copy, because
|
|
142
|
+
* `redefineIndex`'s `down` and `retype-dependents.ts`'s restore ask the same question.
|
|
143
|
+
*/
|
|
144
|
+
export function asDeclared(index: IndexDescription): IndexDescriptionLike {
|
|
145
|
+
return {
|
|
146
|
+
name: index.name,
|
|
147
|
+
columns: index.columns,
|
|
148
|
+
unique: index.unique,
|
|
149
|
+
where: index.where,
|
|
150
|
+
order: index.order,
|
|
151
|
+
...(index.using === undefined ? {} : { using: declaredMethod(index.using) }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
|
|
156
|
+
export function indexShape(index: IndexDescriptionLike | IndexDescription): string {
|
|
157
|
+
return JSON.stringify([
|
|
158
|
+
[...index.columns],
|
|
159
|
+
index.unique,
|
|
160
|
+
index.where,
|
|
161
|
+
index.order ?? null,
|
|
162
|
+
indexMethodOf(index),
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A same-named index whose definition moved is dropped and recreated, because Postgres has no
|
|
168
|
+
* `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
|
|
169
|
+
* are all fixed at creation.
|
|
170
|
+
*
|
|
171
|
+
* Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
|
|
172
|
+
* entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
|
|
173
|
+
* migration and the database kept serving the old one. Both sides here are *generated* spellings
|
|
174
|
+
* — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
|
|
175
|
+
* text difference in `where` is a real change and not a formatting one.
|
|
176
|
+
*/
|
|
177
|
+
export function redefineIndex(
|
|
178
|
+
table: string,
|
|
179
|
+
index: IndexDescriptionLike,
|
|
180
|
+
recorded: IndexDescription,
|
|
181
|
+
plan: Plan,
|
|
182
|
+
): void {
|
|
183
|
+
if (indexShape(index) === indexShape(recorded)) return;
|
|
184
|
+
plan.up.push(dropIndex(index.name), createIndex(table, index));
|
|
185
|
+
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
186
|
+
// the recorded definition is what must land last, after the new one is dropped.
|
|
187
|
+
plan.down.push(createIndex(table, asDeclared(recorded)), dropIndex(index.name));
|
|
188
|
+
}
|