@ultimat3/db 13.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 +107 -0
- 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 +46 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +94 -37
- package/src/generated-column.ts +15 -6
- package/src/index.ts +23 -0
- package/src/introspect.ts +23 -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.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 {
|
|
@@ -23,6 +25,9 @@ import {
|
|
|
23
25
|
type SchemaDescription,
|
|
24
26
|
type TableDescription,
|
|
25
27
|
} from './introspect';
|
|
28
|
+
import { declaredIndexes } from './invariant-ddl';
|
|
29
|
+
import { identifier } from './sql';
|
|
30
|
+
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
|
|
26
31
|
|
|
27
32
|
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
28
33
|
uuid: 'uuid',
|
|
@@ -44,29 +49,27 @@ function sqlType(kind: string): string {
|
|
|
44
49
|
return SQL_TYPES[kind] ?? kind;
|
|
45
50
|
}
|
|
46
51
|
|
|
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
52
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
59
53
|
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
60
54
|
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
61
55
|
// generated column: it may carry no default, and `hasDefault` is what the refusal reads.
|
|
62
|
-
|
|
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
|
+
];
|
|
63
65
|
const expression = defaultExpression(column);
|
|
64
66
|
if (expression !== null) parts.push(`default ${expression}`);
|
|
65
67
|
if (column.notNull) parts.push('not null');
|
|
66
68
|
if (column.unique && !column.primaryKey) parts.push('unique');
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
|
70
73
|
// entity registration order is the app's import order, which says nothing about that.
|
|
71
74
|
return parts.join(' ');
|
|
72
75
|
}
|
|
@@ -115,6 +118,7 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
115
118
|
// field existed gains a key and no app's sidecar regenerates over a fact already true.
|
|
116
119
|
...(column.generated === undefined ? {} : { generated: column.generated }),
|
|
117
120
|
}));
|
|
121
|
+
const checks = declaredChecks(entity);
|
|
118
122
|
return {
|
|
119
123
|
schema: 'public',
|
|
120
124
|
name: entity.table,
|
|
@@ -123,7 +127,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
123
127
|
// Whole, never partly: a snapshot that recorded the name and dropped the predicate made
|
|
124
128
|
// the next generation blind to a `where` or an `order` changing, and a partial index
|
|
125
129
|
// silently kept as a total one is a constraint the entity no longer declares.
|
|
126
|
-
|
|
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) => ({
|
|
127
134
|
name: index.name,
|
|
128
135
|
columns: [...index.columns],
|
|
129
136
|
unique: index.unique,
|
|
@@ -136,6 +143,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
136
143
|
...(index.using === undefined ? {} : { using: index.using }),
|
|
137
144
|
})),
|
|
138
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 }),
|
|
139
150
|
};
|
|
140
151
|
});
|
|
141
152
|
return { tables };
|
|
@@ -144,12 +155,17 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
144
155
|
function createTable(entity: EntityDescriptionLike): readonly string[] {
|
|
145
156
|
const clauses = entity.columns.map(columnClause);
|
|
146
157
|
if (entity.primaryKey.length > 0) {
|
|
147
|
-
|
|
158
|
+
const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
|
|
159
|
+
clauses.push(`primary key (${key})`);
|
|
148
160
|
}
|
|
149
|
-
|
|
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
|
+
];
|
|
150
166
|
// Every column of a new table carries its own clause, so every `unique` one brings its index.
|
|
151
167
|
const added = new Set(entity.columns.map((column) => column.column));
|
|
152
|
-
for (const index of entity
|
|
168
|
+
for (const index of declaredIndexes(entity)) {
|
|
153
169
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
154
170
|
statements.push(createIndex(entity.table, index));
|
|
155
171
|
}
|
|
@@ -185,12 +201,17 @@ function createIndex(table: string, index: IndexDescriptionLike): string {
|
|
|
185
201
|
);
|
|
186
202
|
const kind = index.unique ? 'create unique index' : 'create index';
|
|
187
203
|
const direction = index.order === null ? '' : ` ${index.order}`;
|
|
188
|
-
const columns = index.columns
|
|
204
|
+
const columns = index.columns
|
|
205
|
+
.map((column) => `${identifier(column).text}${direction}`)
|
|
206
|
+
.join(', ');
|
|
189
207
|
const predicate = index.where === null ? '' : ` where (${index.where})`;
|
|
190
208
|
// Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
|
|
191
209
|
// an index that declared no method emits the statement this generator always emitted, byte for
|
|
192
210
|
// byte, and one that declared a method Postgres does not have is refused instead of built.
|
|
193
|
-
return
|
|
211
|
+
return (
|
|
212
|
+
`${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
|
|
213
|
+
`${indexMethodSql(method)} (${columns})${predicate};`
|
|
214
|
+
);
|
|
194
215
|
}
|
|
195
216
|
|
|
196
217
|
/**
|
|
@@ -214,8 +235,8 @@ function retypeColumn(
|
|
|
214
235
|
}
|
|
215
236
|
if (recorded.dataType === wanted) return 'unchanged';
|
|
216
237
|
const alter = (type: string): string =>
|
|
217
|
-
`alter table
|
|
218
|
-
`using
|
|
238
|
+
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
|
|
239
|
+
`type ${type} using ${identifier(column.column).text}::${type};`;
|
|
219
240
|
plan.up.push(alter(wanted));
|
|
220
241
|
plan.down.push(alter(recorded.dataType));
|
|
221
242
|
return 'altered';
|
|
@@ -250,7 +271,7 @@ function redefineIndex(
|
|
|
250
271
|
plan: Plan,
|
|
251
272
|
): void {
|
|
252
273
|
if (indexShape(index) === indexShape(recorded)) return;
|
|
253
|
-
plan.up.push(`drop index
|
|
274
|
+
plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index));
|
|
254
275
|
// `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
|
|
255
276
|
// the recorded definition is what must land last, after the new one is dropped.
|
|
256
277
|
plan.down.push(
|
|
@@ -265,7 +286,7 @@ function redefineIndex(
|
|
|
265
286
|
// rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
|
|
266
287
|
...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
|
|
267
288
|
}),
|
|
268
|
-
`drop index
|
|
289
|
+
`drop index ${identifier(index.name).text};`,
|
|
269
290
|
);
|
|
270
291
|
}
|
|
271
292
|
|
|
@@ -293,18 +314,20 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
293
314
|
// step nobody can perform, since a generated column cannot be written to.
|
|
294
315
|
const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
|
|
295
316
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
296
|
-
plan.up.push(`alter table
|
|
317
|
+
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
|
|
297
318
|
if (nullable) {
|
|
298
319
|
plan.up.push(
|
|
299
|
-
`-- backfill
|
|
300
|
-
|
|
320
|
+
`-- backfill ${identifier(column.column).text}, then: alter table ` +
|
|
321
|
+
`${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
|
|
301
322
|
);
|
|
302
323
|
}
|
|
303
|
-
plan.down.push(
|
|
324
|
+
plan.down.push(
|
|
325
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
|
|
326
|
+
);
|
|
304
327
|
}
|
|
305
328
|
|
|
306
329
|
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
307
|
-
for (const index of entity
|
|
330
|
+
for (const index of declaredIndexes(entity)) {
|
|
308
331
|
const recorded = indexed.get(index.name);
|
|
309
332
|
// A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:
|
|
310
333
|
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
@@ -316,8 +339,13 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
316
339
|
// migration emits, so it still needs a statement of its own.
|
|
317
340
|
if (impliedByColumnClause(entity, index, added)) continue;
|
|
318
341
|
plan.up.push(createIndex(entity.table, index));
|
|
319
|
-
plan.down.push(`drop index
|
|
342
|
+
plan.down.push(`drop index ${identifier(index.name).text};`);
|
|
320
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);
|
|
321
349
|
}
|
|
322
350
|
|
|
323
351
|
export interface GenerateOptions {
|
|
@@ -343,6 +371,13 @@ export interface GeneratedMigration {
|
|
|
343
371
|
* cannot be written unmarked and then refused by `x verify` for lacking the mark.
|
|
344
372
|
*/
|
|
345
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[];
|
|
346
381
|
}
|
|
347
382
|
|
|
348
383
|
export function migrationStamp(now: Date): string {
|
|
@@ -376,7 +411,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
376
411
|
foreignKeyPlan(entity, live, plans);
|
|
377
412
|
if (live === undefined) {
|
|
378
413
|
plan.up.push(...createTable(entity));
|
|
379
|
-
plan.down.push(`drop table
|
|
414
|
+
plan.down.push(`drop table ${identifier(entity.table).text};`);
|
|
380
415
|
continue;
|
|
381
416
|
}
|
|
382
417
|
diffTable(entity, live, plan);
|
|
@@ -389,9 +424,12 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
389
424
|
`x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
|
|
390
425
|
);
|
|
391
426
|
}
|
|
392
|
-
plan.up.push(
|
|
427
|
+
plan.up.push(
|
|
428
|
+
`alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
|
|
429
|
+
);
|
|
393
430
|
plan.down.push(
|
|
394
|
-
`alter table
|
|
431
|
+
`alter table ${identifier(entity.table).text} add column ` +
|
|
432
|
+
`${identifier(column.name).text} ${column.dataType};` +
|
|
395
433
|
' -- data is not restored',
|
|
396
434
|
);
|
|
397
435
|
}
|
|
@@ -409,15 +447,33 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
409
447
|
plan.up.push(...preDrops.up, ...order.constraints);
|
|
410
448
|
plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
|
|
411
449
|
for (const table of order.tables) {
|
|
412
|
-
plan.up.push(`drop table
|
|
413
|
-
|
|
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
|
+
);
|
|
414
456
|
}
|
|
415
457
|
|
|
416
458
|
plan.up.push(...constraints.up);
|
|
417
459
|
plan.down.push(...constraints.down);
|
|
418
460
|
|
|
419
461
|
const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
|
|
420
|
-
|
|
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;
|
|
421
477
|
return {
|
|
422
478
|
id,
|
|
423
479
|
name: options.name,
|
|
@@ -427,5 +483,6 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
|
|
|
427
483
|
down: [...plan.down].reverse().join('\n'),
|
|
428
484
|
snapshot: snapshotOf(options.entities),
|
|
429
485
|
destructive: isDestructive(up),
|
|
486
|
+
unrendered,
|
|
430
487
|
};
|
|
431
488
|
}
|
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.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';
|
package/src/introspect.ts
CHANGED
|
@@ -57,6 +57,21 @@ export interface ForeignKeyDescription {
|
|
|
57
57
|
readonly onDelete: string | null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A named CHECK constraint, as the SNAPSHOT spells it — an entity invariant of kind `check`.
|
|
62
|
+
*
|
|
63
|
+
* Absent from every row this module reads out of the live catalog, deliberately and for the reason
|
|
64
|
+
* `ColumnDescription.generated` gives one field up: `pg_get_constraintdef` answers Postgres' own
|
|
65
|
+
* rewriting of the expression, so a catalog value could never compare equal to a generated one and
|
|
66
|
+
* drift would report a correct database forever. The diff that DOES read it is `x db gen`'s, where
|
|
67
|
+
* both sides are this generator's own spellings.
|
|
68
|
+
*/
|
|
69
|
+
export interface CheckDescription {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
/** The predicate, exactly as the entity's invariant spells it. */
|
|
72
|
+
readonly expression: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export interface TableDescription {
|
|
61
76
|
readonly schema: string;
|
|
62
77
|
readonly name: string;
|
|
@@ -64,6 +79,14 @@ export interface TableDescription {
|
|
|
64
79
|
readonly primaryKey: readonly string[];
|
|
65
80
|
readonly indexes: readonly IndexDescription[];
|
|
66
81
|
readonly foreignKeys: readonly ForeignKeyDescription[];
|
|
82
|
+
/**
|
|
83
|
+
* The CHECK constraints migrations declare. Absent — never `[]` — on a table that declares none
|
|
84
|
+
* and in every sidecar written before this field existed, matching `IndexDescription.using`: a
|
|
85
|
+
* snapshot that predates it must read as "nothing recorded" so the next `x db gen` emits the
|
|
86
|
+
* `add constraint` the database is genuinely missing, rather than as "recorded none", which
|
|
87
|
+
* would leave every already-generated app's invariants unenforced forever.
|
|
88
|
+
*/
|
|
89
|
+
readonly checks?: readonly CheckDescription[] | undefined;
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
export interface SchemaDescription {
|