@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.
@@ -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
- /** A key whose target is being dropped: gone on the way up, a note on the way back. */
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 "${constraint}" on "${table}" cannot be restored; "${target}" is gone`,
151
+ `-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
152
+ `cannot be restored; ${identifier(target).text} is gone`,
145
153
  );
146
154
  }
@@ -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
- const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ');
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 "${table}" add constraint "${key.name}" ` +
89
+ `alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
80
90
  `foreign key (${quoted(key.columns)}) ` +
81
- `references "${key.referencedTable}" (${quoted(key.referencedColumns)})` +
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 "${table}" drop constraint "${constraint}";`;
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
- const parts = [`"${column.column}"`, sqlType(column.kind)];
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
- if (column.check !== null) parts.push(`check (${column.check})`);
63
- // No `references` clause. A foreign key is `alter table add constraint`, emitted after every
64
- // table exists (`foreignKeyPlan`) inline it must point at a table that already exists, and
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
- indexes: entity.indexes.map((index) => ({
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
- clauses.push(`primary key (${entity.primaryKey.map((key) => `"${key}"`).join(', ')})`);
158
+ const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
159
+ clauses.push(`primary key (${key})`);
140
160
  }
141
- const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
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.indexes) {
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.map((column) => `"${column}"${direction}`).join(', ');
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 `${kind} "${index.name}" on "${table}"${indexMethodSql(method)} (${columns})${predicate};`;
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
- ): void {
229
+ ): Regeneration {
201
230
  const wanted = sqlType(column.kind);
202
- if (recorded.dataType === wanted) return;
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 "${table}" alter column "${column.column}" type ${type} ` +
205
- `using "${column.column}"::${type};`;
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 "${index.name}";`, createIndex(table, 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 "${index.name}";`,
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
- const nullable = column.notNull && defaultExpression(column) === null;
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 "${entity.table}" add column ${clause};`);
317
+ plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
273
318
  if (nullable) {
274
319
  plan.up.push(
275
- `-- backfill "${column.column}", then: ` +
276
- `alter table "${entity.table}" alter column "${column.column}" set not null;`,
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(`alter table "${entity.table}" drop column "${column.column}";`);
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.indexes) {
330
+ for (const index of declaredIndexes(entity)) {
284
331
  const recorded = indexed.get(index.name);
285
- if (recorded !== undefined) {
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 "${index.name}";`);
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 "${entity.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(`alter table "${entity.table}" drop column "${column.name}";`);
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 "${entity.table}" add column "${column.name}" ${column.dataType};` +
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 "${table.name}";`);
387
- plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
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
- const up = plan.up.join('\n');
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';