@ultimat3/db 12.0.0 → 14.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 +138 -1
- package/README.md +4 -0
- package/package.json +2 -2
- package/src/check-ddl.ts +212 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift.ts +2 -4
- package/src/entity-shape.ts +57 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +125 -42
- package/src/generated-column.ts +119 -0
- package/src/index.ts +23 -0
- package/src/introspect.ts +32 -0
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -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/foreign-key-plan.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
6
|
import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
7
7
|
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
+
import { identifier } from './sql';
|
|
8
9
|
|
|
9
10
|
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
10
11
|
export interface Plan {
|
|
@@ -137,10 +138,17 @@ export interface ConstraintPlans {
|
|
|
137
138
|
readonly doomed: ReadonlySet<string>;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A key whose target is being dropped: gone on the way up, a note on the way back.
|
|
143
|
+
*
|
|
144
|
+
* The note goes through `identifier` too. A `--` comment ends at the first newline, so a name
|
|
145
|
+
* holding one is a second command on the line after it — the same escape `columnClause` closed,
|
|
146
|
+
* one quoting rule short of the statement above it.
|
|
147
|
+
*/
|
|
141
148
|
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
142
149
|
preDrops.up.push(dropForeignKey(table, constraint));
|
|
143
150
|
preDrops.down.push(
|
|
144
|
-
`-- constraint
|
|
151
|
+
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
152
|
+
`cannot be restored; ${identifier(target).text} is gone`,
|
|
145
153
|
);
|
|
146
154
|
}
|
package/src/foreign-key.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { assert } from '@ultimat3/core';
|
|
6
6
|
import type { ForeignKeyDescription } from './introspect';
|
|
7
|
+
import { identifier } from './sql';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `pg_constraint.confdeltype`. The catalog's vocabulary; a description holds the rule's name.
|
|
@@ -51,7 +52,16 @@ export function foreignKeyTarget(key: ForeignKeyDescription): string {
|
|
|
51
52
|
return JSON.stringify([[...key.columns], key.referencedTable, [...key.referencedColumns]]);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
|
|
57
|
+
* now goes through. A name that closes its own quote produced a real `drop table` through
|
|
58
|
+
* `generateMigration` once already, out of `columnClause`, and every name below arrives the same
|
|
59
|
+
* way: from a projection this package cannot typecheck, or from a `.snapshot.json` on disk that
|
|
60
|
+
* anything may edit. Being unreachable with a hostile name today is a property of the CALLERS, not
|
|
61
|
+
* of this file, and it survives exactly until the next refactor.
|
|
62
|
+
*/
|
|
63
|
+
const quoted = (names: readonly string[]): string =>
|
|
64
|
+
names.map((name) => identifier(name).text).join(', ');
|
|
55
65
|
|
|
56
66
|
/**
|
|
57
67
|
* A statement of its own, never a clause inside `create table`. Inline, the constraint is created
|
|
@@ -76,14 +86,48 @@ export function addForeignKey(table: string, key: ForeignKeyDescription): string
|
|
|
76
86
|
`references(() => target.id, { onDelete: 'cascade' }) # cascade | restrict | set null`,
|
|
77
87
|
);
|
|
78
88
|
return (
|
|
79
|
-
`alter table
|
|
89
|
+
`alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
|
|
80
90
|
`foreign key (${quoted(key.columns)}) ` +
|
|
81
|
-
`references
|
|
91
|
+
`references ${identifier(key.referencedTable).text} (${quoted(key.referencedColumns)})` +
|
|
82
92
|
`${rule === null ? '' : ` on delete ${rule}`};`
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** The reverse. Dropping a constraint loses nothing the database cannot rebuild. */
|
|
87
97
|
export function dropForeignKey(table: string, constraint: string): string {
|
|
88
|
-
return `alter table
|
|
98
|
+
return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
|
|
103
|
+
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
|
|
104
|
+
*
|
|
105
|
+
* It lives here, beside the two writers, because it is the one caller reading values neither of
|
|
106
|
+
* them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
|
|
107
|
+
* writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
|
|
108
|
+
* backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
|
|
109
|
+
* rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
|
|
110
|
+
* `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
|
|
111
|
+
* never a throw — a drift check that raises in place of its report hands the caller an exception
|
|
112
|
+
* where a verdict was asked for. The constraint is still named, because it is the only thing
|
|
113
|
+
* identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
|
|
114
|
+
* runs this string either way.
|
|
115
|
+
*/
|
|
116
|
+
export function rebuildForeignKey(
|
|
117
|
+
table: string,
|
|
118
|
+
declared: ForeignKeyDescription,
|
|
119
|
+
held: ForeignKeyDescription,
|
|
120
|
+
): string {
|
|
121
|
+
// The writers are ASKED whether they can write the pair — never a second copy of their rules
|
|
122
|
+
// beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
|
|
123
|
+
// the thrown value.
|
|
124
|
+
try {
|
|
125
|
+
return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
|
|
126
|
+
} catch {
|
|
127
|
+
return (
|
|
128
|
+
`drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
|
|
129
|
+
'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
|
|
130
|
+
'write this pair'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
89
133
|
}
|
package/src/generate.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// field. Every generated migration must be reversible; a drop that loses data refuses instead.
|
|
5
5
|
|
|
6
6
|
import { assert, 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
11
|
import type {
|
|
@@ -13,6 +15,8 @@ import type {
|
|
|
13
15
|
} from './entity-shape';
|
|
14
16
|
import { migrationIrreversible } from './errors';
|
|
15
17
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
18
|
+
import type { Regeneration } from './generated-column';
|
|
19
|
+
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
16
20
|
import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
|
|
17
21
|
import {
|
|
18
22
|
type ColumnDescription,
|
|
@@ -21,6 +25,9 @@ import {
|
|
|
21
25
|
type SchemaDescription,
|
|
22
26
|
type TableDescription,
|
|
23
27
|
} from './introspect';
|
|
28
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
29
|
+
import { identifier } from './sql';
|
|
30
|
+
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
24
31
|
|
|
25
32
|
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
26
33
|
uuid: 'uuid',
|
|
@@ -42,26 +49,27 @@ function sqlType(kind: string): string {
|
|
|
42
49
|
return SQL_TYPES[kind] ?? kind;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
/**
|
|
46
|
-
* Entity descriptions carry `hasDefault` but not the expression, so the two generated defaults
|
|
47
|
-
* are inferred from the blessed column helpers. Anything else is left to a follow-up migration.
|
|
48
|
-
*/
|
|
49
|
-
function defaultExpression(column: ColumnDescriptionLike): string | null {
|
|
50
|
-
if (!column.hasDefault) return null;
|
|
51
|
-
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
|
|
52
|
-
if (column.kind === 'timestamptz') return 'now()';
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
57
|
-
|
|
53
|
+
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
54
|
+
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
55
|
+
// generated column: it may carry no default, and `hasDefault` is what the refusal reads.
|
|
56
|
+
//
|
|
57
|
+
// Through `identifier`, never `"${…}"`: the name arrives from a projection this package cannot
|
|
58
|
+
// typecheck and a name that closes its own quote produced a real `drop table` through
|
|
59
|
+
// `generateMigration` once already. It is also what makes an unrendered report safe to write
|
|
60
|
+
// into a `--` comment, since generation refuses the dangerous name before the comment exists.
|
|
61
|
+
const parts = [
|
|
62
|
+
identifier(column.column).text,
|
|
63
|
+
`${sqlType(column.kind)}${generatedClause(column)}`,
|
|
64
|
+
];
|
|
58
65
|
const expression = defaultExpression(column);
|
|
59
66
|
if (expression !== null) parts.push(`default ${expression}`);
|
|
60
67
|
if (column.notNull) parts.push('not null');
|
|
61
68
|
if (column.unique && !column.primaryKey) parts.push('unique');
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
69
|
+
// No `check` clause — written here it was ANONYMOUS and reached `create table` alone, so
|
|
70
|
+
// regenerating dropped the value set `enumerated()` declares; `check-ddl.ts` owns every CHECK now.
|
|
71
|
+
// No `references` clause either: a foreign key is `alter table … add constraint`, emitted after
|
|
72
|
+
// every table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
|
|
65
73
|
// entity registration order is the app's import order, which says nothing about that.
|
|
66
74
|
return parts.join(' ');
|
|
67
75
|
}
|
|
@@ -106,7 +114,11 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
106
114
|
nullable: !column.notNull,
|
|
107
115
|
default: defaultExpression(column),
|
|
108
116
|
position: index + 1,
|
|
117
|
+
// Only when one was declared — absent stays absent, so no snapshot written before this
|
|
118
|
+
// field existed gains a key and no app's sidecar regenerates over a fact already true.
|
|
119
|
+
...(column.generated === undefined ? {} : { generated: column.generated }),
|
|
109
120
|
}));
|
|
121
|
+
const checks = declaredChecks(entity);
|
|
110
122
|
return {
|
|
111
123
|
schema: 'public',
|
|
112
124
|
name: entity.table,
|
|
@@ -115,7 +127,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
115
127
|
// Whole, never partly: a snapshot that recorded the name and dropped the predicate made
|
|
116
128
|
// the next generation blind to a `where` or an `order` changing, and a partial index
|
|
117
129
|
// silently kept as a total one is a constraint the entity no longer declares.
|
|
118
|
-
|
|
130
|
+
//
|
|
131
|
+
// `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
|
|
132
|
+
// own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
|
|
133
|
+
indexes: declaredIndexes(entity).map((index) => ({
|
|
119
134
|
name: index.name,
|
|
120
135
|
columns: [...index.columns],
|
|
121
136
|
unique: index.unique,
|
|
@@ -128,6 +143,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
128
143
|
...(index.using === undefined ? {} : { using: index.using }),
|
|
129
144
|
})),
|
|
130
145
|
foreignKeys: foreignKeysOf(entity),
|
|
146
|
+
// Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
|
|
147
|
+
// read as "nothing recorded" so the next generation adds the constraints the database is
|
|
148
|
+
// genuinely missing — the rule `using` and `generated` already state one field up.
|
|
149
|
+
...(checks.length === 0 ? {} : { checks }),
|
|
131
150
|
};
|
|
132
151
|
});
|
|
133
152
|
return { tables };
|
|
@@ -136,12 +155,17 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
136
155
|
function createTable(entity: EntityDescriptionLike): readonly string[] {
|
|
137
156
|
const clauses = entity.columns.map(columnClause);
|
|
138
157
|
if (entity.primaryKey.length > 0) {
|
|
139
|
-
|
|
158
|
+
const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
|
|
159
|
+
clauses.push(`primary key (${key})`);
|
|
140
160
|
}
|
|
141
|
-
|
|
161
|
+
// After the key, so a table declaring no invariant emits the statement it always emitted.
|
|
162
|
+
clauses.push(...checkClauses(entity));
|
|
163
|
+
const statements = [
|
|
164
|
+
`create table ${identifier(entity.table).text} (\n ${clauses.join(',\n ')}\n);`,
|
|
165
|
+
];
|
|
142
166
|
// Every column of a new table carries its own clause, so every `unique` one brings its index.
|
|
143
167
|
const added = new Set(entity.columns.map((column) => column.column));
|
|
144
|
-
for (const index of entity
|
|
168
|
+
for (const index of declaredIndexes(entity)) {
|
|
145
169
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
146
170
|
statements.push(createIndex(entity.table, index));
|
|
147
171
|
}
|
|
@@ -177,12 +201,17 @@ function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
|
177
201
|
);
|
|
178
202
|
const kind = index.unique ? 'create unique index' : 'create index';
|
|
179
203
|
const direction = index.order === null ? '' : ` ${index.order}`;
|
|
180
|
-
const columns = index.columns
|
|
204
|
+
const columns = index.columns
|
|
205
|
+
.map((column) => `${identifier(column).text}${direction}`)
|
|
206
|
+
.join(', ');
|
|
181
207
|
const predicate = index.where === null ? '' : ` where (${index.where})`;
|
|
182
208
|
// Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
|
|
183
209
|
// an index that declared no method emits the statement this generator always emitted, byte for
|
|
184
210
|
// byte, and one that declared a method Postgres does not have is refused instead of built.
|
|
185
|
-
return
|
|
211
|
+
return (
|
|
212
|
+
`${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
|
|
213
|
+
`${indexMethodSql(method)} (${columns})${predicate};`
|
|
214
|
+
);
|
|
186
215
|
}
|
|
187
216
|
|
|
188
217
|
/**
|
|
@@ -197,14 +226,20 @@ function retypeColumn(
|
|
|
197
226
|
column: ColumnDescriptionLike,
|
|
198
227
|
recorded: ColumnDescription,
|
|
199
228
|
plan: Plan,
|
|
200
|
-
):
|
|
229
|
+
): Regeneration {
|
|
201
230
|
const wanted = sqlType(column.kind);
|
|
202
|
-
|
|
231
|
+
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
232
|
+
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
233
|
+
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
234
|
+
return regenerate(table, column, wanted, recorded, plan);
|
|
235
|
+
}
|
|
236
|
+
if (recorded.dataType === wanted) return 'unchanged';
|
|
203
237
|
const alter = (type: string): string =>
|
|
204
|
-
`alter table
|
|
205
|
-
`using
|
|
238
|
+
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
239
|
+
`type ${type} using ${identifier(column.column).text}::${type};`;
|
|
206
240
|
plan.up.push(alter(wanted));
|
|
207
241
|
plan.down.push(alter(recorded.dataType));
|
|
242
|
+
return 'altered';
|
|
208
243
|
}
|
|
209
244
|
|
|
210
245
|
/** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
|
|
@@ -236,7 +271,7 @@ function redefineIndex(
|
|
|
236
271
|
plan: Plan,
|
|
237
272
|
): void {
|
|
238
273
|
if (indexShape(index) === indexShape(recorded)) return;
|
|
239
|
-
plan.up.push(`drop index
|
|
274
|
+
plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index));
|
|
240
275
|
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
241
276
|
// the recorded definition is what must land last, after the new one is dropped.
|
|
242
277
|
plan.down.push(
|
|
@@ -251,38 +286,52 @@ function redefineIndex(
|
|
|
251
286
|
// rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
|
|
252
287
|
...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
|
|
253
288
|
}),
|
|
254
|
-
`drop index
|
|
289
|
+
`drop index ${identifier(index.name).text};`,
|
|
255
290
|
);
|
|
256
291
|
}
|
|
257
292
|
|
|
258
293
|
function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
|
|
259
294
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
260
295
|
const added = new Set<string>();
|
|
296
|
+
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
297
|
+
// over it has to be stated again even though its own definition never moved.
|
|
298
|
+
const rebuilt = new Set<string>();
|
|
261
299
|
for (const column of entity.columns) {
|
|
262
300
|
const recorded = existing.get(column.column);
|
|
263
301
|
if (recorded !== undefined) {
|
|
264
|
-
retypeColumn(entity.table, column, recorded, plan)
|
|
302
|
+
if (retypeColumn(entity.table, column, recorded, plan) === 'rebuilt') {
|
|
303
|
+
rebuilt.add(column.column);
|
|
304
|
+
}
|
|
265
305
|
continue;
|
|
266
306
|
}
|
|
267
307
|
added.add(column.column);
|
|
268
308
|
// A NOT NULL add with no default cannot succeed on a populated table; emit it nullable and
|
|
269
309
|
// leave the agent the exact follow-up rather than a migration that fails at 3am.
|
|
270
|
-
|
|
310
|
+
//
|
|
311
|
+
// A GENERATED column is the exception and not a special case of it: the database computes it
|
|
312
|
+
// for every existing row inside the same `add column`, so it lands NOT NULL and populated in
|
|
313
|
+
// one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
|
|
314
|
+
// step nobody can perform, since a generated column cannot be written to.
|
|
315
|
+
const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
|
|
271
316
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
272
|
-
plan.up.push(`alter table
|
|
317
|
+
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
|
|
273
318
|
if (nullable) {
|
|
274
319
|
plan.up.push(
|
|
275
|
-
`-- backfill
|
|
276
|
-
|
|
320
|
+
`-- backfill ${identifier(column.column).text}, then: alter table ` +
|
|
321
|
+
`${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
|
|
277
322
|
);
|
|
278
323
|
}
|
|
279
|
-
plan.down.push(
|
|
324
|
+
plan.down.push(
|
|
325
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
|
|
326
|
+
);
|
|
280
327
|
}
|
|
281
328
|
|
|
282
329
|
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
283
|
-
for (const index of entity
|
|
330
|
+
for (const index of declaredIndexes(entity)) {
|
|
284
331
|
const recorded = indexed.get(index.name);
|
|
285
|
-
|
|
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))) {
|
|
286
335
|
redefineIndex(entity.table, index, recorded, plan);
|
|
287
336
|
continue;
|
|
288
337
|
}
|
|
@@ -290,8 +339,13 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
290
339
|
// migration emits, so it still needs a statement of its own.
|
|
291
340
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
292
341
|
plan.up.push(createIndex(entity.table, index));
|
|
293
|
-
plan.down.push(`drop index
|
|
342
|
+
plan.down.push(`drop index ${identifier(index.name).text};`);
|
|
294
343
|
}
|
|
344
|
+
|
|
345
|
+
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
|
|
346
|
+
// 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
|
+
checkPlan(entity, live, plan, rebuilt);
|
|
295
349
|
}
|
|
296
350
|
|
|
297
351
|
export interface GenerateOptions {
|
|
@@ -317,6 +371,13 @@ export interface GeneratedMigration {
|
|
|
317
371
|
* cannot be written unmarked and then refused by `x verify` for lacking the mark.
|
|
318
372
|
*/
|
|
319
373
|
readonly destructive: boolean;
|
|
374
|
+
/**
|
|
375
|
+
* Every declaration this generator could not write down. Empty on a migration that carries the
|
|
376
|
+
* whole schema, which is what makes it readable as a verdict rather than as noise — and the same
|
|
377
|
+
* list `unrenderedComment` writes into the top of `up`, so a caller that would rather refuse
|
|
378
|
+
* (`x db gen`) and a reviewer reading the committed file are looking at one answer.
|
|
379
|
+
*/
|
|
380
|
+
readonly unrendered: readonly UnrenderedDeclaration[];
|
|
320
381
|
}
|
|
321
382
|
|
|
322
383
|
export function migrationStamp(now: Date): string {
|
|
@@ -350,7 +411,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
350
411
|
foreignKeyPlan(entity, live, plans);
|
|
351
412
|
if (live === undefined) {
|
|
352
413
|
plan.up.push(...createTable(entity));
|
|
353
|
-
plan.down.push(`drop table
|
|
414
|
+
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
354
415
|
continue;
|
|
355
416
|
}
|
|
356
417
|
diffTable(entity, live, plan);
|
|
@@ -363,9 +424,12 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
363
424
|
`x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
|
|
364
425
|
);
|
|
365
426
|
}
|
|
366
|
-
plan.up.push(
|
|
427
|
+
plan.up.push(
|
|
428
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
|
|
429
|
+
);
|
|
367
430
|
plan.down.push(
|
|
368
|
-
`alter table
|
|
431
|
+
`alter table ${identifier(entity.table).text} add column ` +
|
|
432
|
+
`${identifier(column.name).text} ${column.dataType};` +
|
|
369
433
|
' -- data is not restored',
|
|
370
434
|
);
|
|
371
435
|
}
|
|
@@ -383,15 +447,33 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
383
447
|
plan.up.push(...preDrops.up, ...order.constraints);
|
|
384
448
|
plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
|
|
385
449
|
for (const table of order.tables) {
|
|
386
|
-
plan.up.push(`drop table
|
|
387
|
-
|
|
450
|
+
plan.up.push(`drop table ${identifier(table.name).text};`);
|
|
451
|
+
// `identifier` in a comment too: a `--` ends at the first newline, so a name holding one
|
|
452
|
+
// would put a second command on the next line of `down`.
|
|
453
|
+
plan.down.push(
|
|
454
|
+
`-- ${identifier(table.name).text} cannot be restored; recover it from a backup`,
|
|
455
|
+
);
|
|
388
456
|
}
|
|
389
457
|
|
|
390
458
|
plan.up.push(...constraints.up);
|
|
391
459
|
plan.down.push(...constraints.down);
|
|
392
460
|
|
|
393
461
|
const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
|
|
394
|
-
|
|
462
|
+
// At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
|
|
463
|
+
// noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
|
|
464
|
+
// `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
|
|
465
|
+
//
|
|
466
|
+
// Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
|
|
467
|
+
// "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
|
|
468
|
+
// would make every `x db gen` on an app with an unrendered default write a migration holding no
|
|
469
|
+
// statement — a ledger row, a checksum and a place in the apply order for nothing. The list is
|
|
470
|
+
// still on `GeneratedMigration.unrendered`, which is where a caller with no file reads it.
|
|
471
|
+
//
|
|
472
|
+
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
|
|
473
|
+
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
|
|
474
|
+
const unrendered = unrenderedOf(options.entities, current);
|
|
475
|
+
const body = plan.up.join('\n');
|
|
476
|
+
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
|
|
395
477
|
return {
|
|
396
478
|
id,
|
|
397
479
|
name: options.name,
|
|
@@ -401,5 +483,6 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
401
483
|
down: [...plan.down].reverse().join('\n'),
|
|
402
484
|
snapshot: snapshotOf(options.entities),
|
|
403
485
|
destructive: isDestructive(up),
|
|
486
|
+
unrendered,
|
|
404
487
|
};
|
|
405
488
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Single responsibility: what a column the DATABASE computes contributes to DDL, and what changes
|
|
2
|
+
// to one a migration may emit. Split from `generate.ts` because Postgres treats a generated column
|
|
3
|
+
// as a different thing at every step — its clause, its retype, its NOT NULL add and the way its
|
|
4
|
+
// expression moves are each a rule of their own, and none of them is the ordinary column's.
|
|
5
|
+
|
|
6
|
+
import { assert } from '@ultimat3/core';
|
|
7
|
+
import type { ColumnDescriptionLike } from './entity-shape';
|
|
8
|
+
import type { Plan } from './foreign-key-plan';
|
|
9
|
+
import type { ColumnDescription } from './introspect';
|
|
10
|
+
import { identifier } from './sql';
|
|
11
|
+
|
|
12
|
+
/** How a column that moved was brought back into line — what the caller has to do next, if anything. */
|
|
13
|
+
export type Regeneration = 'unchanged' | 'altered' | 'rebuilt';
|
|
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
|
+
*/
|
|
21
|
+
const alterColumn = (table: string, column: string): string =>
|
|
22
|
+
`alter table ${identifier(table).text} alter column ${identifier(column).text}`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `generated always as (…) stored` clause, or `''` for every ordinary column — so a description
|
|
26
|
+
* written before this field existed emits the statement it always emitted, byte for byte.
|
|
27
|
+
*
|
|
28
|
+
* The two rules Postgres has about the pair are refused HERE, where the entity is still named, for
|
|
29
|
+
* the reason `createIndex` refuses a unique GIN in the same file: an unguarded generator writes DDL
|
|
30
|
+
* whose first reader is `ROLE=migrate`, and the server's message carries none of the declaration's
|
|
31
|
+
* words. A column may not be both DEFAULTED and GENERATED (`42601` — a generated column's value IS
|
|
32
|
+
* its expression), and an empty expression is not one.
|
|
33
|
+
*/
|
|
34
|
+
export function generatedClause(column: ColumnDescriptionLike): string {
|
|
35
|
+
const expression = column.generated;
|
|
36
|
+
if (expression === undefined || expression === null) return '';
|
|
37
|
+
assert(
|
|
38
|
+
!column.hasDefault,
|
|
39
|
+
`column "${column.column}" is declared both generated and defaulted, and Postgres has neither`,
|
|
40
|
+
`drop the default from "${column.property}" — a generated column's value is its expression, computed on every write`,
|
|
41
|
+
);
|
|
42
|
+
assert(
|
|
43
|
+
expression.trim().length > 0,
|
|
44
|
+
`column "${column.column}" is generated by an empty expression`,
|
|
45
|
+
`give "${column.property}" an expression, or drop the generated declaration`,
|
|
46
|
+
);
|
|
47
|
+
return ` generated always as (${expression}) stored`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const isGenerated = (column: ColumnDescriptionLike): boolean =>
|
|
51
|
+
typeof column.generated === 'string';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A generated column whose TYPE or whose EXPRESSION moved, brought into line without rebuilding it.
|
|
55
|
+
*
|
|
56
|
+
* `set expression as (…)` (Postgres 17) rewrites the table and recomputes every row, and the
|
|
57
|
+
* column's indexes survive — measured. Drop-and-recreate was the alternative and is worse in two
|
|
58
|
+
* ways that matter: dropping the column takes its indexes with it and nothing in this diff puts
|
|
59
|
+
* them back, and `alter table … drop column` is what `destructive.ts` reads as a data loss, so
|
|
60
|
+
* every expression change would have carried `-- destructive: true` on a migration that loses
|
|
61
|
+
* nothing. A marker on a migration that destroys nothing is a marker reviewers learn to ignore.
|
|
62
|
+
*
|
|
63
|
+
* The retype carries no `using`: Postgres refuses one on a generated column outright ("column … is
|
|
64
|
+
* a generated column"), which is exactly the statement `retypeColumn` emits for every other column
|
|
65
|
+
* — and there is nothing to convert, because the expression produces the new type itself.
|
|
66
|
+
*
|
|
67
|
+
* Two transitions this cannot express, and both are refused rather than half-emitted:
|
|
68
|
+
* plain → generated (there is no `set expression` for a column that has none) and a column whose
|
|
69
|
+
* recorded expression is unknown. `drop expression` is the one that HAS a statement, and it is the
|
|
70
|
+
* generated → plain direction, which keeps the values it computed.
|
|
71
|
+
*/
|
|
72
|
+
export function regenerate(
|
|
73
|
+
table: string,
|
|
74
|
+
column: ColumnDescriptionLike,
|
|
75
|
+
wantedType: string,
|
|
76
|
+
recorded: ColumnDescription,
|
|
77
|
+
plan: Plan,
|
|
78
|
+
): Regeneration {
|
|
79
|
+
const wanted = column.generated ?? null;
|
|
80
|
+
const held = recorded.generated ?? null;
|
|
81
|
+
if (wanted === null && held === null) return 'unchanged';
|
|
82
|
+
// Generated -> plain: the column keeps every value it computed and simply stops being derived.
|
|
83
|
+
if (wanted === null) {
|
|
84
|
+
plan.up.push(`${alterColumn(table, column.column)} drop expression;`);
|
|
85
|
+
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held ?? ''});`);
|
|
86
|
+
return 'altered';
|
|
87
|
+
}
|
|
88
|
+
// Plain -> generated: `set expression` needs a column that already has one, so this is the whole
|
|
89
|
+
// column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
|
|
90
|
+
// implies none of them.
|
|
91
|
+
if (held === null) {
|
|
92
|
+
const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
|
|
93
|
+
plan.up.push(
|
|
94
|
+
dropColumn,
|
|
95
|
+
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
|
|
96
|
+
`${wantedType}${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
|
|
97
|
+
);
|
|
98
|
+
// Pushed forwards and read backwards — `down` is reversed at assembly.
|
|
99
|
+
plan.down.push(
|
|
100
|
+
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
|
|
101
|
+
`${recorded.dataType};` +
|
|
102
|
+
' -- was not a generated column',
|
|
103
|
+
dropColumn,
|
|
104
|
+
);
|
|
105
|
+
return 'rebuilt';
|
|
106
|
+
}
|
|
107
|
+
let moved = false;
|
|
108
|
+
if (recorded.dataType !== wantedType) {
|
|
109
|
+
plan.up.push(`${alterColumn(table, column.column)} type ${wantedType};`);
|
|
110
|
+
plan.down.push(`${alterColumn(table, column.column)} type ${recorded.dataType};`);
|
|
111
|
+
moved = true;
|
|
112
|
+
}
|
|
113
|
+
if (held !== wanted) {
|
|
114
|
+
plan.up.push(`${alterColumn(table, column.column)} set expression as (${wanted});`);
|
|
115
|
+
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held});`);
|
|
116
|
+
moved = true;
|
|
117
|
+
}
|
|
118
|
+
return moved ? 'altered' : 'unchanged';
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,14 @@ export {
|
|
|
12
12
|
listBranches,
|
|
13
13
|
reapBranches,
|
|
14
14
|
} from './branch';
|
|
15
|
+
export {
|
|
16
|
+
checkClauses,
|
|
17
|
+
checkPlan,
|
|
18
|
+
columnCheckName,
|
|
19
|
+
columnChecks,
|
|
20
|
+
columnNamesConstraint,
|
|
21
|
+
declaredChecks,
|
|
22
|
+
} from './check-ddl';
|
|
15
23
|
export type {
|
|
16
24
|
DbClient,
|
|
17
25
|
DbConnection,
|
|
@@ -32,6 +40,8 @@ export {
|
|
|
32
40
|
poolProfileFor,
|
|
33
41
|
setDbClient,
|
|
34
42
|
} from './client';
|
|
43
|
+
export type { ColumnDefaultLike } from './column-default';
|
|
44
|
+
export { defaultExpression } from './column-default';
|
|
35
45
|
export { defaultClient, REPLICA_URL_ENV } from './default-client';
|
|
36
46
|
export type { DestructiveKind, DestructiveStatement } from './destructive';
|
|
37
47
|
export {
|
|
@@ -56,6 +66,7 @@ export type {
|
|
|
56
66
|
ColumnDescriptionLike,
|
|
57
67
|
EntityDescriptionLike,
|
|
58
68
|
IndexDescriptionLike,
|
|
69
|
+
InvariantDescriptionLike,
|
|
59
70
|
} from './entity-shape';
|
|
60
71
|
export type { DbErrorCode, DbErrorInit } from './errors';
|
|
61
72
|
export {
|
|
@@ -96,6 +107,7 @@ export {
|
|
|
96
107
|
isIndexMethod,
|
|
97
108
|
} from './index-method';
|
|
98
109
|
export type {
|
|
110
|
+
CheckDescription,
|
|
99
111
|
ColumnDescription,
|
|
100
112
|
ForeignKeyDescription,
|
|
101
113
|
IndexDescription,
|
|
@@ -104,6 +116,13 @@ export type {
|
|
|
104
116
|
TableDescription,
|
|
105
117
|
} from './introspect';
|
|
106
118
|
export { buildSchema, findTable, introspect } from './introspect';
|
|
119
|
+
export {
|
|
120
|
+
constraintNameFor,
|
|
121
|
+
declaredIndexes,
|
|
122
|
+
invariantChecks,
|
|
123
|
+
uniqueColumns,
|
|
124
|
+
} from './invariant-ddl';
|
|
125
|
+
export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
|
|
107
126
|
export type {
|
|
108
127
|
AppliedMigration,
|
|
109
128
|
LedgerRow,
|
|
@@ -172,3 +191,7 @@ export { STATEMENT_ATTRIBUTE } from './statement-span';
|
|
|
172
191
|
export { statementsOf } from './statement-split';
|
|
173
192
|
export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
|
|
174
193
|
export { beginStatement, currentTx, withTransaction } from './transaction';
|
|
194
|
+
export type { GeneratableForm } from './ungeneratable';
|
|
195
|
+
export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
|
|
196
|
+
export type { UnrenderedDeclaration } from './unrendered';
|
|
197
|
+
export { unrenderedComment, unrenderedOf } from './unrendered';
|