@ultimat3/db 12.0.0 → 13.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 +31 -1
- package/package.json +2 -2
- package/src/entity-shape.ts +11 -0
- package/src/generate.ts +32 -6
- package/src/generated-column.ts +110 -0
- package/src/introspect.ts +9 -0
package/CLAUDE.md
CHANGED
|
@@ -653,6 +653,35 @@ right database, and `x db gen`'s `retypeColumn` owns that question where both si
|
|
|
653
653
|
The `fix:` is the `alter table … set not null` itself and deliberately not `x db gen`, which has
|
|
654
654
|
never emitted one and would answer with an empty migration.
|
|
655
655
|
|
|
656
|
+
**A column the DATABASE computes is a different thing at every step, and `generated-column.ts` is
|
|
657
|
+
all of them** — `As of 2026-08-24`. `ColumnDescriptionLike.generated` carries the
|
|
658
|
+
`generated always as (<expr>) stored` body across the tier seam (this package cannot import
|
|
659
|
+
`@ultimat3/entity`, so a field that is not on the projection reaches no DDL at all), and it reached
|
|
660
|
+
none until this date: `@ultimat3/entity`'s `.searchable()` emitted a `tsvector not null` column that
|
|
661
|
+
`columnClause` rendered plain, so nothing computed it and **the first insert was a `23502`**. Loud,
|
|
662
|
+
which was deliberate — but a feature nobody can insert into is not shipped. Four rules ride with it,
|
|
663
|
+
each one measured against a real server (`generate-generated-column.live.test.ts`):
|
|
664
|
+
|
|
665
|
+
| Rule | Why it is not the ordinary column's rule |
|
|
666
|
+
|---|---|
|
|
667
|
+
| the clause sits directly after the type | `"c" tsvector generated always as (…) stored not null check (…)` is what Postgres accepts; a column constraint may follow it |
|
|
668
|
+
| **generated and defaulted is refused** at `x db gen` | Postgres has no such column (`42601`) — a generated column's value IS its expression. `X_INVARIANT`, the same refusal `createIndex` gives a unique GIN, and for the same reason: the alternative is DDL whose first reader is `ROLE=migrate` |
|
|
669
|
+
| an expression that moved is **`set expression as (…)`**, never a drop and recreate | Postgres 17's statement, and it rewrites the table, recomputes every row and **keeps the column's indexes** — measured. Dropping the column takes its GIN index with it and nothing in the diff puts one back, and `alter table … drop column` is what `destructive.ts` reads as data loss: every expression change would then carry `-- destructive: true` on a migration that loses nothing, and a marker on all is none |
|
|
670
|
+
| a retype on it carries **no `using`** | Postgres refuses `using` on a generated column outright, which is exactly what `retypeColumn` emits for every other column — and there is nothing to convert, because the expression produces the new type itself |
|
|
671
|
+
| the NOT NULL add is **one statement**, never nullable-then-backfill | the database computes it for every existing row inside the same `add column`. The ordinary path's `-- backfill "c", then: … set not null;` names a step nobody can perform: writing to a generated column is `428C9` |
|
|
672
|
+
|
|
673
|
+
Two transitions have no `set expression`. **Generated → plain is `drop expression`**, which keeps
|
|
674
|
+
every value the column already computed. **Plain → generated is the whole column again** — drop,
|
|
675
|
+
add, and every index over it stated a second time, which is why `regenerate` answers `rebuilt` and
|
|
676
|
+
`diffTable` carries that set into its index loop: `redefineIndex` sees a definition that never moved
|
|
677
|
+
and would emit nothing, so the table would come back with no index at all.
|
|
678
|
+
|
|
679
|
+
**`introspect` deliberately does not read `generation_expression` back.** Postgres stores its own
|
|
680
|
+
rewriting (`COALESCE(title, ''::text)` for `coalesce("title", '')`), so a catalog value could never
|
|
681
|
+
compare equal to a generated one and drift would report a correct database forever. The diff that
|
|
682
|
+
DOES read it is `x db gen`'s, where both sides are this generator's own spellings — the rule
|
|
683
|
+
`IndexDescription.where` already states.
|
|
684
|
+
|
|
656
685
|
`compareTable` judges **declared** indexes: one the migrations name and the catalog does not hold is
|
|
657
686
|
`missing-index`, and one whose access method, column list or uniqueness moved is `changed-index` — which is what
|
|
658
687
|
catches a composite index rebuilt with its columns the other way round while the column diff said
|
|
@@ -750,7 +779,8 @@ rather than spliced DDL — the discipline `createIndex` already applies to an i
|
|
|
750
779
|
ceiling and along the seam the tier already draws: they are the structural mirror of
|
|
751
780
|
`@ultimat3/entity`'s description, which is how a snapshot crosses tier 2 → tier 1 with no import.
|
|
752
781
|
`ColumnDescriptionLike.onDelete` is optional for exactly that reason — a description written before
|
|
753
|
-
the field existed still satisfies the shape.
|
|
782
|
+
the field existed still satisfies the shape — and `ColumnDescriptionLike.generated` is optional for
|
|
783
|
+
the same one.
|
|
754
784
|
|
|
755
785
|
**`snapshot-json.ts` writes the sidecar's bytes, and they must be a fixed point of Biome.** A
|
|
756
786
|
scaffolded app's `lint` step is `biome check .` over `"includes": ["**"]`, and `.sql`/`.hash` are
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "13.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "13.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/entity-shape.ts
CHANGED
|
@@ -21,6 +21,17 @@ export interface ColumnDescriptionLike {
|
|
|
21
21
|
* the field travelling structurally is the *only* way the rule crosses the tier boundary.
|
|
22
22
|
*/
|
|
23
23
|
readonly onDelete?: string | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* The `generated always as (<expr>) stored` body, when the DATABASE computes this column rather
|
|
26
|
+
* than a writer. Absent on every ordinary column, exactly like `IndexDescriptionLike.using`: a
|
|
27
|
+
* description written before this existed emits the statement it always emitted.
|
|
28
|
+
*
|
|
29
|
+
* `@ultimat3/entity` (tier 2) is the declarer and this package cannot import it, so the
|
|
30
|
+
* expression crosses the seam structurally or it reaches no DDL at all — which is where it was
|
|
31
|
+
* until `As of 2026-08-24`: the column landed as a plain `tsvector not null` and the first insert
|
|
32
|
+
* was a `23502`, because nothing computed it.
|
|
33
|
+
*/
|
|
34
|
+
readonly generated?: string | undefined;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
/**
|
package/src/generate.ts
CHANGED
|
@@ -13,6 +13,8 @@ import type {
|
|
|
13
13
|
} from './entity-shape';
|
|
14
14
|
import { migrationIrreversible } from './errors';
|
|
15
15
|
import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
|
|
16
|
+
import type { Regeneration } from './generated-column';
|
|
17
|
+
import { generatedClause, isGenerated, regenerate } from './generated-column';
|
|
16
18
|
import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
|
|
17
19
|
import {
|
|
18
20
|
type ColumnDescription,
|
|
@@ -54,7 +56,10 @@ function defaultExpression(column: ColumnDescriptionLike): string | null {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function columnClause(column: ColumnDescriptionLike): string {
|
|
57
|
-
|
|
59
|
+
// The generation clause sits directly after the type, and `generatedClause` refuses the pairs
|
|
60
|
+
// Postgres has no column for. Every other part below is unchanged and unreachable for a
|
|
61
|
+
// generated column: it may carry no default, and `hasDefault` is what the refusal reads.
|
|
62
|
+
const parts = [`"${column.column}"`, `${sqlType(column.kind)}${generatedClause(column)}`];
|
|
58
63
|
const expression = defaultExpression(column);
|
|
59
64
|
if (expression !== null) parts.push(`default ${expression}`);
|
|
60
65
|
if (column.notNull) parts.push('not null');
|
|
@@ -106,6 +111,9 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
|
|
|
106
111
|
nullable: !column.notNull,
|
|
107
112
|
default: defaultExpression(column),
|
|
108
113
|
position: index + 1,
|
|
114
|
+
// Only when one was declared — absent stays absent, so no snapshot written before this
|
|
115
|
+
// field existed gains a key and no app's sidecar regenerates over a fact already true.
|
|
116
|
+
...(column.generated === undefined ? {} : { generated: column.generated }),
|
|
109
117
|
}));
|
|
110
118
|
return {
|
|
111
119
|
schema: 'public',
|
|
@@ -197,14 +205,20 @@ function retypeColumn(
|
|
|
197
205
|
column: ColumnDescriptionLike,
|
|
198
206
|
recorded: ColumnDescription,
|
|
199
207
|
plan: Plan,
|
|
200
|
-
):
|
|
208
|
+
): Regeneration {
|
|
201
209
|
const wanted = sqlType(column.kind);
|
|
202
|
-
|
|
210
|
+
// A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
|
|
211
|
+
// side is one, because becoming generated and ceasing to be are both changes with a statement.
|
|
212
|
+
if (isGenerated(column) || recorded.generated !== undefined) {
|
|
213
|
+
return regenerate(table, column, wanted, recorded, plan);
|
|
214
|
+
}
|
|
215
|
+
if (recorded.dataType === wanted) return 'unchanged';
|
|
203
216
|
const alter = (type: string): string =>
|
|
204
217
|
`alter table "${table}" alter column "${column.column}" type ${type} ` +
|
|
205
218
|
`using "${column.column}"::${type};`;
|
|
206
219
|
plan.up.push(alter(wanted));
|
|
207
220
|
plan.down.push(alter(recorded.dataType));
|
|
221
|
+
return 'altered';
|
|
208
222
|
}
|
|
209
223
|
|
|
210
224
|
/** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
|
|
@@ -258,16 +272,26 @@ function redefineIndex(
|
|
|
258
272
|
function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
|
|
259
273
|
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
260
274
|
const added = new Set<string>();
|
|
275
|
+
// A column `regenerate` had to replace outright: `add column` implies no index, so every index
|
|
276
|
+
// over it has to be stated again even though its own definition never moved.
|
|
277
|
+
const rebuilt = new Set<string>();
|
|
261
278
|
for (const column of entity.columns) {
|
|
262
279
|
const recorded = existing.get(column.column);
|
|
263
280
|
if (recorded !== undefined) {
|
|
264
|
-
retypeColumn(entity.table, column, recorded, plan)
|
|
281
|
+
if (retypeColumn(entity.table, column, recorded, plan) === 'rebuilt') {
|
|
282
|
+
rebuilt.add(column.column);
|
|
283
|
+
}
|
|
265
284
|
continue;
|
|
266
285
|
}
|
|
267
286
|
added.add(column.column);
|
|
268
287
|
// A NOT NULL add with no default cannot succeed on a populated table; emit it nullable and
|
|
269
288
|
// leave the agent the exact follow-up rather than a migration that fails at 3am.
|
|
270
|
-
|
|
289
|
+
//
|
|
290
|
+
// A GENERATED column is the exception and not a special case of it: the database computes it
|
|
291
|
+
// for every existing row inside the same `add column`, so it lands NOT NULL and populated in
|
|
292
|
+
// one statement — measured. Emitting it nullable would leave a `-- backfill` comment naming a
|
|
293
|
+
// step nobody can perform, since a generated column cannot be written to.
|
|
294
|
+
const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
|
|
271
295
|
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
272
296
|
plan.up.push(`alter table "${entity.table}" add column ${clause};`);
|
|
273
297
|
if (nullable) {
|
|
@@ -282,7 +306,9 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
|
|
|
282
306
|
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
|
|
283
307
|
for (const index of entity.indexes) {
|
|
284
308
|
const recorded = indexed.get(index.name);
|
|
285
|
-
|
|
309
|
+
// A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:
|
|
310
|
+
// `redefineIndex` sees a definition that never moved and would emit nothing at all.
|
|
311
|
+
if (recorded !== undefined && !index.columns.some((column) => rebuilt.has(column))) {
|
|
286
312
|
redefineIndex(entity.table, index, recorded, plan);
|
|
287
313
|
continue;
|
|
288
314
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
|
|
11
|
+
/** How a column that moved was brought back into line — what the caller has to do next, if anything. */
|
|
12
|
+
export type Regeneration = 'unchanged' | 'altered' | 'rebuilt';
|
|
13
|
+
|
|
14
|
+
const alterColumn = (table: string, column: string): string =>
|
|
15
|
+
`alter table "${table}" alter column "${column}"`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `generated always as (…) stored` clause, or `''` for every ordinary column — so a description
|
|
19
|
+
* written before this field existed emits the statement it always emitted, byte for byte.
|
|
20
|
+
*
|
|
21
|
+
* The two rules Postgres has about the pair are refused HERE, where the entity is still named, for
|
|
22
|
+
* the reason `createIndex` refuses a unique GIN in the same file: an unguarded generator writes DDL
|
|
23
|
+
* whose first reader is `ROLE=migrate`, and the server's message carries none of the declaration's
|
|
24
|
+
* words. A column may not be both DEFAULTED and GENERATED (`42601` — a generated column's value IS
|
|
25
|
+
* its expression), and an empty expression is not one.
|
|
26
|
+
*/
|
|
27
|
+
export function generatedClause(column: ColumnDescriptionLike): string {
|
|
28
|
+
const expression = column.generated;
|
|
29
|
+
if (expression === undefined || expression === null) return '';
|
|
30
|
+
assert(
|
|
31
|
+
!column.hasDefault,
|
|
32
|
+
`column "${column.column}" is declared both generated and defaulted, and Postgres has neither`,
|
|
33
|
+
`drop the default from "${column.property}" — a generated column's value is its expression, computed on every write`,
|
|
34
|
+
);
|
|
35
|
+
assert(
|
|
36
|
+
expression.trim().length > 0,
|
|
37
|
+
`column "${column.column}" is generated by an empty expression`,
|
|
38
|
+
`give "${column.property}" an expression, or drop the generated declaration`,
|
|
39
|
+
);
|
|
40
|
+
return ` generated always as (${expression}) stored`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const isGenerated = (column: ColumnDescriptionLike): boolean =>
|
|
44
|
+
typeof column.generated === 'string';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A generated column whose TYPE or whose EXPRESSION moved, brought into line without rebuilding it.
|
|
48
|
+
*
|
|
49
|
+
* `set expression as (…)` (Postgres 17) rewrites the table and recomputes every row, and the
|
|
50
|
+
* column's indexes survive — measured. Drop-and-recreate was the alternative and is worse in two
|
|
51
|
+
* ways that matter: dropping the column takes its indexes with it and nothing in this diff puts
|
|
52
|
+
* them back, and `alter table … drop column` is what `destructive.ts` reads as a data loss, so
|
|
53
|
+
* every expression change would have carried `-- destructive: true` on a migration that loses
|
|
54
|
+
* nothing. A marker on a migration that destroys nothing is a marker reviewers learn to ignore.
|
|
55
|
+
*
|
|
56
|
+
* The retype carries no `using`: Postgres refuses one on a generated column outright ("column … is
|
|
57
|
+
* a generated column"), which is exactly the statement `retypeColumn` emits for every other column
|
|
58
|
+
* — and there is nothing to convert, because the expression produces the new type itself.
|
|
59
|
+
*
|
|
60
|
+
* Two transitions this cannot express, and both are refused rather than half-emitted:
|
|
61
|
+
* plain → generated (there is no `set expression` for a column that has none) and a column whose
|
|
62
|
+
* recorded expression is unknown. `drop expression` is the one that HAS a statement, and it is the
|
|
63
|
+
* generated → plain direction, which keeps the values it computed.
|
|
64
|
+
*/
|
|
65
|
+
export function regenerate(
|
|
66
|
+
table: string,
|
|
67
|
+
column: ColumnDescriptionLike,
|
|
68
|
+
wantedType: string,
|
|
69
|
+
recorded: ColumnDescription,
|
|
70
|
+
plan: Plan,
|
|
71
|
+
): Regeneration {
|
|
72
|
+
const wanted = column.generated ?? null;
|
|
73
|
+
const held = recorded.generated ?? null;
|
|
74
|
+
if (wanted === null && held === null) return 'unchanged';
|
|
75
|
+
// Generated -> plain: the column keeps every value it computed and simply stops being derived.
|
|
76
|
+
if (wanted === null) {
|
|
77
|
+
plan.up.push(`${alterColumn(table, column.column)} drop expression;`);
|
|
78
|
+
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held ?? ''});`);
|
|
79
|
+
return 'altered';
|
|
80
|
+
}
|
|
81
|
+
// Plain -> generated: `set expression` needs a column that already has one, so this is the whole
|
|
82
|
+
// column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
|
|
83
|
+
// implies none of them.
|
|
84
|
+
if (held === null) {
|
|
85
|
+
plan.up.push(
|
|
86
|
+
`alter table "${table}" drop column "${column.column}";`,
|
|
87
|
+
`alter table "${table}" add column "${column.column}" ${wantedType}` +
|
|
88
|
+
`${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
|
|
89
|
+
);
|
|
90
|
+
// Pushed forwards and read backwards — `down` is reversed at assembly.
|
|
91
|
+
plan.down.push(
|
|
92
|
+
`alter table "${table}" add column "${column.column}" ${recorded.dataType};` +
|
|
93
|
+
' -- was not a generated column',
|
|
94
|
+
`alter table "${table}" drop column "${column.column}";`,
|
|
95
|
+
);
|
|
96
|
+
return 'rebuilt';
|
|
97
|
+
}
|
|
98
|
+
let moved = false;
|
|
99
|
+
if (recorded.dataType !== wantedType) {
|
|
100
|
+
plan.up.push(`${alterColumn(table, column.column)} type ${wantedType};`);
|
|
101
|
+
plan.down.push(`${alterColumn(table, column.column)} type ${recorded.dataType};`);
|
|
102
|
+
moved = true;
|
|
103
|
+
}
|
|
104
|
+
if (held !== wanted) {
|
|
105
|
+
plan.up.push(`${alterColumn(table, column.column)} set expression as (${wanted});`);
|
|
106
|
+
plan.down.push(`${alterColumn(table, column.column)} set expression as (${held});`);
|
|
107
|
+
moved = true;
|
|
108
|
+
}
|
|
109
|
+
return moved ? 'altered' : 'unchanged';
|
|
110
|
+
}
|
package/src/introspect.ts
CHANGED
|
@@ -14,6 +14,15 @@ export interface ColumnDescription {
|
|
|
14
14
|
readonly nullable: boolean;
|
|
15
15
|
readonly default: string | null;
|
|
16
16
|
readonly position: number;
|
|
17
|
+
/**
|
|
18
|
+
* The generation expression, as the SNAPSHOT spells it. Absent for an ordinary column and absent
|
|
19
|
+
* for every row this module reads out of the live catalog — deliberately: Postgres stores its own
|
|
20
|
+
* rewriting of the expression (`COALESCE(title, ''::text)` for `coalesce("title", '')`), so a
|
|
21
|
+
* catalog value could never compare equal to a generated one, and drift would report a correct
|
|
22
|
+
* database forever. Both sides of the diff that DOES read it — `x db gen`'s — are generated
|
|
23
|
+
* spellings, which is the same rule `IndexDescription.where` states one field down.
|
|
24
|
+
*/
|
|
25
|
+
readonly generated?: string | undefined;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export interface IndexDescription {
|