@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 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": "12.0.0",
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": "12.0.0"
34
+ "@ultimat3/core": "13.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -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
- const parts = [`"${column.column}"`, sqlType(column.kind)];
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
- ): void {
208
+ ): Regeneration {
201
209
  const wanted = sqlType(column.kind);
202
- if (recorded.dataType === wanted) return;
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
- const nullable = column.notNull && defaultExpression(column) === null;
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
- if (recorded !== undefined) {
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 {