@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
package/src/generate.ts
CHANGED
|
@@ -3,26 +3,28 @@
|
|
|
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
|
+
import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
|
|
8
|
+
import { defaultExpression } from './column-default';
|
|
7
9
|
import { isDestructive } from './destructive';
|
|
8
10
|
import { dropOrder } from './drop-order';
|
|
9
|
-
import type {
|
|
10
|
-
ColumnDescriptionLike,
|
|
11
|
-
EntityDescriptionLike,
|
|
12
|
-
IndexDescriptionLike,
|
|
13
|
-
} from './entity-shape';
|
|
11
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
14
12
|
import { migrationIrreversible } from './errors';
|
|
15
13
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
16
14
|
import type { Regeneration } from './generated-column';
|
|
17
15
|
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
18
|
-
import {
|
|
16
|
+
import { createIndex, dropIndex, impliedByColumnClause, redefineIndex } from './index-ddl';
|
|
19
17
|
import {
|
|
20
18
|
type ColumnDescription,
|
|
21
19
|
findTable,
|
|
22
|
-
type IndexDescription,
|
|
23
20
|
type SchemaDescription,
|
|
24
21
|
type TableDescription,
|
|
25
22
|
} from './introspect';
|
|
23
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
24
|
+
import type { MovedAside } from './retype-dependents';
|
|
25
|
+
import { moveDependentsAside } from './retype-dependents';
|
|
26
|
+
import { identifier } from './sql';
|
|
27
|
+
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
26
28
|
|
|
27
29
|
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
28
30
|
uuid: 'uuid',
|
|
@@ -44,61 +46,31 @@ function sqlType(kind: string): string {
|
|
|
44
46
|
return SQL_TYPES[kind] ?? kind;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/**
|
|
48
|
-
* Entity descriptions carry `hasDefault` but not the expression, so the two generated defaults
|
|
49
|
-
* are inferred from the blessed column helpers. Anything else is left to a follow-up migration.
|
|
50
|
-
*/
|
|
51
|
-
function defaultExpression(column: ColumnDescriptionLike): string | null {
|
|
52
|
-
if (!column.hasDefault) return null;
|
|
53
|
-
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
|
|
54
|
-
if (column.kind === 'timestamptz') return 'now()';
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
49
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
59
50
|
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
60
51
|
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
61
52
|
// generated column: it may carry no default, and `hasDefault` is what the refusal reads.
|
|
62
|
-
|
|
53
|
+
//
|
|
54
|
+
// Through `identifier`, never `"${…}"`: the name arrives from a projection this package cannot
|
|
55
|
+
// typecheck and a name that closes its own quote produced a real `drop table` through
|
|
56
|
+
// `generateMigration` once already. It is also what makes an unrendered report safe to write
|
|
57
|
+
// into a `--` comment, since generation refuses the dangerous name before the comment exists.
|
|
58
|
+
const parts = [
|
|
59
|
+
identifier(column.column).text,
|
|
60
|
+
`${sqlType(column.kind)}${generatedClause(column)}`,
|
|
61
|
+
];
|
|
63
62
|
const expression = defaultExpression(column);
|
|
64
63
|
if (expression !== null) parts.push(`default ${expression}`);
|
|
65
64
|
if (column.notNull) parts.push('not null');
|
|
66
65
|
if (column.unique && !column.primaryKey) parts.push('unique');
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
66
|
+
// No `check` clause — written here it was ANONYMOUS and reached `create table` alone, so
|
|
67
|
+
// regenerating dropped the value set `enumerated()` declares; `check-ddl.ts` owns every CHECK now.
|
|
68
|
+
// No `references` clause either: a foreign key is `alter table … add constraint`, emitted after
|
|
69
|
+
// every table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
|
|
70
70
|
// entity registration order is the app's import order, which says nothing about that.
|
|
71
71
|
return parts.join(' ');
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* A `unique` column clause already creates an index, and Postgres names it exactly what the
|
|
76
|
-
* entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
|
|
77
|
-
* it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
|
|
78
|
-
* Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
|
|
79
|
-
*
|
|
80
|
-
* A **partial** unique index is not that index: the column clause constrains every row, so
|
|
81
|
-
* skipping the partial one would silently widen the constraint the entity declared.
|
|
82
|
-
*/
|
|
83
|
-
function impliedByColumnClause(
|
|
84
|
-
entity: EntityDescriptionLike,
|
|
85
|
-
index: IndexDescriptionLike,
|
|
86
|
-
added: ReadonlySet<string>,
|
|
87
|
-
): boolean {
|
|
88
|
-
const [only] = index.columns;
|
|
89
|
-
if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
const column = entity.columns.find((each) => each.column === only);
|
|
93
|
-
// `columnClause` writes `unique` under exactly this condition — keep the two in step.
|
|
94
|
-
//
|
|
95
|
-
// NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
|
|
96
|
-
// `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
|
|
97
|
-
// unsafe for exactly this reason — applying it turned a green typecheck red.
|
|
98
|
-
// biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
|
|
99
|
-
return column !== undefined && column.unique && !column.primaryKey && added.has(only);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
74
|
export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
|
|
103
75
|
const tables = [...entities]
|
|
104
76
|
.sort((a, b) => (a.table < b.table ? -1 : 1))
|
|
@@ -115,6 +87,7 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
115
87
|
// field existed gains a key and no app's sidecar regenerates over a fact already true.
|
|
116
88
|
...(column.generated === undefined ? {} : { generated: column.generated }),
|
|
117
89
|
}));
|
|
90
|
+
const checks = declaredChecks(entity);
|
|
118
91
|
return {
|
|
119
92
|
schema: 'public',
|
|
120
93
|
name: entity.table,
|
|
@@ -123,7 +96,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
123
96
|
// Whole, never partly: a snapshot that recorded the name and dropped the predicate made
|
|
124
97
|
// the next generation blind to a `where` or an `order` changing, and a partial index
|
|
125
98
|
// silently kept as a total one is a constraint the entity no longer declares.
|
|
126
|
-
|
|
99
|
+
//
|
|
100
|
+
// `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
|
|
101
|
+
// own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
|
|
102
|
+
indexes: declaredIndexes(entity).map((index) => ({
|
|
127
103
|
name: index.name,
|
|
128
104
|
columns: [...index.columns],
|
|
129
105
|
unique: index.unique,
|
|
@@ -136,6 +112,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
136
112
|
...(index.using === undefined ? {} : { using: index.using }),
|
|
137
113
|
})),
|
|
138
114
|
foreignKeys: foreignKeysOf(entity),
|
|
115
|
+
// Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
|
|
116
|
+
// read as "nothing recorded" so the next generation adds the constraints the database is
|
|
117
|
+
// genuinely missing — the rule `using` and `generated` already state one field up.
|
|
118
|
+
...(checks.length === 0 ? {} : { checks }),
|
|
139
119
|
};
|
|
140
120
|
});
|
|
141
121
|
return { tables };
|
|
@@ -144,141 +124,72 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
144
124
|
function createTable(entity: EntityDescriptionLike): readonly string[] {
|
|
145
125
|
const clauses = entity.columns.map(columnClause);
|
|
146
126
|
if (entity.primaryKey.length > 0) {
|
|
147
|
-
|
|
127
|
+
const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
|
|
128
|
+
clauses.push(`primary key (${key})`);
|
|
148
129
|
}
|
|
149
|
-
|
|
130
|
+
// After the key, so a table declaring no invariant emits the statement it always emitted.
|
|
131
|
+
clauses.push(...checkClauses(entity));
|
|
132
|
+
const statements = [
|
|
133
|
+
`create table ${identifier(entity.table).text} (\n ${clauses.join(',\n ')}\n);`,
|
|
134
|
+
];
|
|
150
135
|
// Every column of a new table carries its own clause, so every `unique` one brings its index.
|
|
151
136
|
const added = new Set(entity.columns.map((column) => column.column));
|
|
152
|
-
for (const index of entity
|
|
137
|
+
for (const index of declaredIndexes(entity)) {
|
|
153
138
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
154
139
|
statements.push(createIndex(entity.table, index));
|
|
155
140
|
}
|
|
156
141
|
return statements;
|
|
157
142
|
}
|
|
158
143
|
|
|
159
|
-
/**
|
|
160
|
-
* Every part of the declaration reaches the statement: the whole column list in its declared
|
|
161
|
-
* order, the direction when one was asked for, and the predicate that makes it partial. A part
|
|
162
|
-
* dropped here is a constraint the database does not hold or an index the planner cannot use.
|
|
163
|
-
*/
|
|
164
|
-
function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
165
|
-
assert(
|
|
166
|
-
index.columns.length > 0,
|
|
167
|
-
`index "${index.name}" on "${table}" names no columns`,
|
|
168
|
-
`indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
|
|
169
|
-
);
|
|
170
|
-
const method = index.using ?? 'btree';
|
|
171
|
-
// Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
|
|
172
|
-
// GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
|
|
173
|
-
// as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
|
|
174
|
-
// and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
|
|
175
|
-
// index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
|
|
176
|
-
assert(
|
|
177
|
-
method === 'btree' || !index.unique,
|
|
178
|
-
`index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
|
|
179
|
-
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
|
|
180
|
-
);
|
|
181
|
-
assert(
|
|
182
|
-
method === 'btree' || index.order === null,
|
|
183
|
-
`index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
|
|
184
|
-
`indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
|
|
185
|
-
);
|
|
186
|
-
const kind = index.unique ? 'create unique index' : 'create index';
|
|
187
|
-
const direction = index.order === null ? '' : ` ${index.order}`;
|
|
188
|
-
const columns = index.columns.map((column) => `"${column}"${direction}`).join(', ');
|
|
189
|
-
const predicate = index.where === null ? '' : ` where (${index.where})`;
|
|
190
|
-
// Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
|
|
191
|
-
// an index that declared no method emits the statement this generator always emitted, byte for
|
|
192
|
-
// byte, and one that declared a method Postgres does not have is refused instead of built.
|
|
193
|
-
return `${kind} "${index.name}" on "${table}"${indexMethodSql(method)} (${columns})${predicate};`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
144
|
/**
|
|
197
145
|
* Skipping an existing column by name alone missed the type moving under it: a table created
|
|
198
146
|
* while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
|
|
199
147
|
* snapshot this run records claims `char(3)` — two claims with no statement between them. Both
|
|
200
148
|
* sides are generated spellings (`current` is a previous migration's own snapshot), so any
|
|
201
149
|
* difference is a real kind change, not a catalog alias.
|
|
150
|
+
*
|
|
151
|
+
* The ALTER is not the whole statement list: Postgres compiled every predicate written against
|
|
152
|
+
* this column with its OLD type and cannot recompile one, so a partial index or a CHECK that reads
|
|
153
|
+
* it is dropped FIRST and `moved` carries the names on to the arms that would otherwise act on
|
|
154
|
+
* them. Without that the retype is `42883` and the migration aborts mid-run
|
|
155
|
+
* (`retype-dependents.ts`).
|
|
202
156
|
*/
|
|
203
157
|
function retypeColumn(
|
|
204
|
-
|
|
158
|
+
live: TableDescription,
|
|
205
159
|
column: ColumnDescriptionLike,
|
|
206
160
|
recorded: ColumnDescription,
|
|
207
161
|
plan: Plan,
|
|
162
|
+
moved: MovedAside,
|
|
208
163
|
): Regeneration {
|
|
209
164
|
const wanted = sqlType(column.kind);
|
|
165
|
+
const table = live.name;
|
|
210
166
|
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
211
167
|
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
212
168
|
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
213
169
|
return regenerate(table, column, wanted, recorded, plan);
|
|
214
170
|
}
|
|
215
171
|
if (recorded.dataType === wanted) return 'unchanged';
|
|
172
|
+
moveDependentsAside(live, column.column, plan, moved);
|
|
216
173
|
const alter = (type: string): string =>
|
|
217
|
-
`alter table
|
|
218
|
-
`using
|
|
174
|
+
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
175
|
+
`type ${type} using ${identifier(column.column).text}::${type};`;
|
|
219
176
|
plan.up.push(alter(wanted));
|
|
220
177
|
plan.down.push(alter(recorded.dataType));
|
|
221
178
|
return 'altered';
|
|
222
179
|
}
|
|
223
180
|
|
|
224
|
-
/** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
|
|
225
|
-
function indexShape(index: IndexDescriptionLike | IndexDescription): string {
|
|
226
|
-
return JSON.stringify([
|
|
227
|
-
[...index.columns],
|
|
228
|
-
index.unique,
|
|
229
|
-
index.where,
|
|
230
|
-
index.order ?? null,
|
|
231
|
-
indexMethodOf(index),
|
|
232
|
-
]);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* A same-named index whose definition moved is dropped and recreated, because Postgres has no
|
|
237
|
-
* `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
|
|
238
|
-
* are all fixed at creation.
|
|
239
|
-
*
|
|
240
|
-
* Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
|
|
241
|
-
* entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
|
|
242
|
-
* migration and the database kept serving the old one. Both sides here are *generated* spellings
|
|
243
|
-
* — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
|
|
244
|
-
* text difference in `where` is a real change and not a formatting one.
|
|
245
|
-
*/
|
|
246
|
-
function redefineIndex(
|
|
247
|
-
table: string,
|
|
248
|
-
index: IndexDescriptionLike,
|
|
249
|
-
recorded: IndexDescription,
|
|
250
|
-
plan: Plan,
|
|
251
|
-
): void {
|
|
252
|
-
if (indexShape(index) === indexShape(recorded)) return;
|
|
253
|
-
plan.up.push(`drop index "${index.name}";`, createIndex(table, index));
|
|
254
|
-
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
255
|
-
// the recorded definition is what must land last, after the new one is dropped.
|
|
256
|
-
plan.down.push(
|
|
257
|
-
createIndex(table, {
|
|
258
|
-
name: recorded.name,
|
|
259
|
-
columns: recorded.columns,
|
|
260
|
-
unique: recorded.unique,
|
|
261
|
-
where: recorded.where,
|
|
262
|
-
order: recorded.order,
|
|
263
|
-
// `declaredMethod`, never a cast: `recorded` is a snapshot's, typed open because the catalog
|
|
264
|
-
// shares the shape, and a method this generator cannot emit must refuse rather than be
|
|
265
|
-
// rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
|
|
266
|
-
...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
|
|
267
|
-
}),
|
|
268
|
-
`drop index "${index.name}";`,
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
181
|
function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
|
|
273
182
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
274
183
|
const added = new Set<string>();
|
|
275
184
|
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
276
185
|
// over it has to be stated again even though its own definition never moved.
|
|
277
186
|
const rebuilt = new Set<string>();
|
|
187
|
+
// What a retype dropped ahead of itself, read by the two arms below.
|
|
188
|
+
const moved: MovedAside = { indexes: new Set(), checks: new Set() };
|
|
278
189
|
for (const column of entity.columns) {
|
|
279
190
|
const recorded = existing.get(column.column);
|
|
280
191
|
if (recorded !== undefined) {
|
|
281
|
-
if (retypeColumn(
|
|
192
|
+
if (retypeColumn(live, column, recorded, plan, moved) === 'rebuilt') {
|
|
282
193
|
rebuilt.add(column.column);
|
|
283
194
|
}
|
|
284
195
|
continue;
|
|
@@ -293,22 +204,26 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
293
204
|
// step nobody can perform, since a generated column cannot be written to.
|
|
294
205
|
const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
|
|
295
206
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
296
|
-
plan.up.push(`alter table
|
|
207
|
+
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
|
|
297
208
|
if (nullable) {
|
|
298
209
|
plan.up.push(
|
|
299
|
-
`-- backfill
|
|
300
|
-
|
|
210
|
+
`-- backfill ${identifier(column.column).text}, then: alter table ` +
|
|
211
|
+
`${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
|
|
301
212
|
);
|
|
302
213
|
}
|
|
303
|
-
plan.down.push(
|
|
214
|
+
plan.down.push(
|
|
215
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
|
|
216
|
+
);
|
|
304
217
|
}
|
|
305
218
|
|
|
306
219
|
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
307
|
-
for (const index of entity
|
|
220
|
+
for (const index of declaredIndexes(entity)) {
|
|
308
221
|
const recorded = indexed.get(index.name);
|
|
309
|
-
// A rebuilt column took its indexes down with it,
|
|
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:
|
|
310
224
|
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
311
|
-
|
|
225
|
+
const gone = moved.indexes.has(index.name) || index.columns.some((each) => rebuilt.has(each));
|
|
226
|
+
if (recorded !== undefined && !gone) {
|
|
312
227
|
redefineIndex(entity.table, index, recorded, plan);
|
|
313
228
|
continue;
|
|
314
229
|
}
|
|
@@ -316,8 +231,14 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
316
231
|
// migration emits, so it still needs a statement of its own.
|
|
317
232
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
318
233
|
plan.up.push(createIndex(entity.table, index));
|
|
319
|
-
plan.down.push(
|
|
234
|
+
plan.down.push(dropIndex(index.name));
|
|
320
235
|
}
|
|
236
|
+
|
|
237
|
+
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
|
|
238
|
+
// that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
|
|
239
|
+
// column dropped and re-added lost its constraint while the snapshot still records it, and
|
|
240
|
+
// `moved.checks` because a retype already dropped the ones written against the old type.
|
|
241
|
+
checkPlan(entity, live, plan, rebuilt, moved.checks);
|
|
321
242
|
}
|
|
322
243
|
|
|
323
244
|
export interface GenerateOptions {
|
|
@@ -343,6 +264,13 @@ export interface GeneratedMigration {
|
|
|
343
264
|
* cannot be written unmarked and then refused by `x verify` for lacking the mark.
|
|
344
265
|
*/
|
|
345
266
|
readonly destructive: boolean;
|
|
267
|
+
/**
|
|
268
|
+
* Every declaration this generator could not write down. Empty on a migration that carries the
|
|
269
|
+
* whole schema, which is what makes it readable as a verdict rather than as noise — and the same
|
|
270
|
+
* list `unrenderedComment` writes into the top of `up`, so a caller that would rather refuse
|
|
271
|
+
* (`x db gen`) and a reviewer reading the committed file are looking at one answer.
|
|
272
|
+
*/
|
|
273
|
+
readonly unrendered: readonly UnrenderedDeclaration[];
|
|
346
274
|
}
|
|
347
275
|
|
|
348
276
|
export function migrationStamp(now: Date): string {
|
|
@@ -376,7 +304,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
376
304
|
foreignKeyPlan(entity, live, plans);
|
|
377
305
|
if (live === undefined) {
|
|
378
306
|
plan.up.push(...createTable(entity));
|
|
379
|
-
plan.down.push(`drop table
|
|
307
|
+
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
380
308
|
continue;
|
|
381
309
|
}
|
|
382
310
|
diffTable(entity, live, plan);
|
|
@@ -389,9 +317,12 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
389
317
|
`x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
|
|
390
318
|
);
|
|
391
319
|
}
|
|
392
|
-
plan.up.push(
|
|
320
|
+
plan.up.push(
|
|
321
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
|
|
322
|
+
);
|
|
393
323
|
plan.down.push(
|
|
394
|
-
`alter table
|
|
324
|
+
`alter table ${identifier(entity.table).text} add column ` +
|
|
325
|
+
`${identifier(column.name).text} ${column.dataType};` +
|
|
395
326
|
' -- data is not restored',
|
|
396
327
|
);
|
|
397
328
|
}
|
|
@@ -409,15 +340,33 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
409
340
|
plan.up.push(...preDrops.up, ...order.constraints);
|
|
410
341
|
plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
|
|
411
342
|
for (const table of order.tables) {
|
|
412
|
-
plan.up.push(`drop table
|
|
413
|
-
|
|
343
|
+
plan.up.push(`drop table ${identifier(table.name).text};`);
|
|
344
|
+
// `identifier` in a comment too: a `--` ends at the first newline, so a name holding one
|
|
345
|
+
// would put a second command on the next line of `down`.
|
|
346
|
+
plan.down.push(
|
|
347
|
+
`-- ${identifier(table.name).text} cannot be restored; recover it from a backup`,
|
|
348
|
+
);
|
|
414
349
|
}
|
|
415
350
|
|
|
416
351
|
plan.up.push(...constraints.up);
|
|
417
352
|
plan.down.push(...constraints.down);
|
|
418
353
|
|
|
419
354
|
const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
|
|
420
|
-
|
|
355
|
+
// At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
|
|
356
|
+
// noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
|
|
357
|
+
// `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
|
|
358
|
+
//
|
|
359
|
+
// Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
|
|
360
|
+
// "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
|
|
361
|
+
// would make every `x db gen` on an app with an unrendered default write a migration holding no
|
|
362
|
+
// statement — a ledger row, a checksum and a place in the apply order for nothing. The list is
|
|
363
|
+
// still on `GeneratedMigration.unrendered`, which is where a caller with no file reads it.
|
|
364
|
+
//
|
|
365
|
+
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
|
|
366
|
+
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
|
|
367
|
+
const unrendered = unrenderedOf(options.entities, current);
|
|
368
|
+
const body = plan.up.join('\n');
|
|
369
|
+
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
|
|
421
370
|
return {
|
|
422
371
|
id,
|
|
423
372
|
name: options.name,
|
|
@@ -427,5 +376,6 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
427
376
|
down: [...plan.down].reverse().join('\n'),
|
|
428
377
|
snapshot: snapshotOf(options.entities),
|
|
429
378
|
destructive: isDestructive(up),
|
|
379
|
+
unrendered,
|
|
430
380
|
};
|
|
431
381
|
}
|
package/src/generated-column.ts
CHANGED
|
@@ -7,12 +7,19 @@ import { assert } from '@ultimat3/core';
|
|
|
7
7
|
import type { ColumnDescriptionLike } from './entity-shape';
|
|
8
8
|
import type { Plan } from './foreign-key-plan';
|
|
9
9
|
import type { ColumnDescription } from './introspect';
|
|
10
|
+
import { identifier } from './sql';
|
|
10
11
|
|
|
11
12
|
/** How a column that moved was brought back into line — what the caller has to do next, if anything. */
|
|
12
13
|
export type Regeneration = 'unchanged' | 'altered' | 'rebuilt';
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Through `identifier`, never `"${…}"`, for the reason `columnClause` states — and this path is
|
|
17
|
+
* the one `columnClause` does NOT cover: `regenerate` is reached from `retypeColumn` for a column
|
|
18
|
+
* that ALREADY exists, so nothing on the way here has looked at the name. Both operands arrive
|
|
19
|
+
* from a projection this package cannot typecheck and from a `.snapshot.json` anything may edit.
|
|
20
|
+
*/
|
|
14
21
|
const alterColumn = (table: string, column: string): string =>
|
|
15
|
-
`alter table
|
|
22
|
+
`alter table ${identifier(table).text} alter column ${identifier(column).text}`;
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* The `generated always as (…) stored` clause, or `''` for every ordinary column — so a description
|
|
@@ -82,16 +89,18 @@ export function regenerate(
|
|
|
82
89
|
// column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
|
|
83
90
|
// implies none of them.
|
|
84
91
|
if (held === null) {
|
|
92
|
+
const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
|
|
85
93
|
plan.up.push(
|
|
86
|
-
|
|
87
|
-
`alter table
|
|
88
|
-
`${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
|
|
94
|
+
dropColumn,
|
|
95
|
+
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
|
|
96
|
+
`${wantedType}${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
|
|
89
97
|
);
|
|
90
98
|
// Pushed forwards and read backwards — `down` is reversed at assembly.
|
|
91
99
|
plan.down.push(
|
|
92
|
-
`alter table
|
|
100
|
+
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
|
|
101
|
+
`${recorded.dataType};` +
|
|
93
102
|
' -- was not a generated column',
|
|
94
|
-
|
|
103
|
+
dropColumn,
|
|
95
104
|
);
|
|
96
105
|
return 'rebuilt';
|
|
97
106
|
}
|
package/src/index-ddl.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
* A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
|
|
89
|
+
* cast: the recorded side is typed open because the catalog shares the shape, and a method this
|
|
90
|
+
* generator cannot write must refuse rather than be rebuilt as a btree. One copy, because
|
|
91
|
+
* `redefineIndex`'s `down` and `retype-dependents.ts`'s restore ask the same question.
|
|
92
|
+
*/
|
|
93
|
+
export function asDeclared(index: IndexDescription): IndexDescriptionLike {
|
|
94
|
+
return {
|
|
95
|
+
name: index.name,
|
|
96
|
+
columns: index.columns,
|
|
97
|
+
unique: index.unique,
|
|
98
|
+
where: index.where,
|
|
99
|
+
order: index.order,
|
|
100
|
+
...(index.using === undefined ? {} : { using: declaredMethod(index.using) }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
|
|
105
|
+
export function indexShape(index: IndexDescriptionLike | IndexDescription): string {
|
|
106
|
+
return JSON.stringify([
|
|
107
|
+
[...index.columns],
|
|
108
|
+
index.unique,
|
|
109
|
+
index.where,
|
|
110
|
+
index.order ?? null,
|
|
111
|
+
indexMethodOf(index),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A same-named index whose definition moved is dropped and recreated, because Postgres has no
|
|
117
|
+
* `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
|
|
118
|
+
* are all fixed at creation.
|
|
119
|
+
*
|
|
120
|
+
* Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
|
|
121
|
+
* entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
|
|
122
|
+
* migration and the database kept serving the old one. Both sides here are *generated* spellings
|
|
123
|
+
* — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
|
|
124
|
+
* text difference in `where` is a real change and not a formatting one.
|
|
125
|
+
*/
|
|
126
|
+
export function redefineIndex(
|
|
127
|
+
table: string,
|
|
128
|
+
index: IndexDescriptionLike,
|
|
129
|
+
recorded: IndexDescription,
|
|
130
|
+
plan: Plan,
|
|
131
|
+
): void {
|
|
132
|
+
if (indexShape(index) === indexShape(recorded)) return;
|
|
133
|
+
plan.up.push(dropIndex(index.name), createIndex(table, index));
|
|
134
|
+
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
135
|
+
// the recorded definition is what must land last, after the new one is dropped.
|
|
136
|
+
plan.down.push(createIndex(table, asDeclared(recorded)), dropIndex(index.name));
|
|
137
|
+
}
|