@ultimat3/db 13.0.0 → 15.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/src/generate.ts CHANGED
@@ -3,26 +3,28 @@
3
3
  // the CLI passes `describeEntities()` and the types below mirror `EntityDescription` field for
4
4
  // field. Every generated migration must be reversible; a drop that loses data refuses instead.
5
5
 
6
- import { assert, systemClock } from '@ultimat3/core';
6
+ import { 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
- import type {
10
- ColumnDescriptionLike,
11
- EntityDescriptionLike,
12
- IndexDescriptionLike,
13
- } from './entity-shape';
11
+ import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
14
12
  import { migrationIrreversible } from './errors';
15
13
  import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
16
14
  import type { Regeneration } from './generated-column';
17
15
  import { generatedClause, isGenerated, regenerate } from './generated-column';
18
- import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
16
+ import { createIndex, dropIndex, impliedByColumnClause, redefineIndex } from './index-ddl';
19
17
  import {
20
18
  type ColumnDescription,
21
19
  findTable,
22
- type IndexDescription,
23
20
  type SchemaDescription,
24
21
  type TableDescription,
25
22
  } from './introspect';
23
+ import { declaredIndexes } from './invariant-ddl';
24
+ import type { MovedAside } from './retype-dependents';
25
+ import { moveDependentsAside } from './retype-dependents';
26
+ import { identifier } from './sql';
27
+ import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
26
28
 
27
29
  const SQL_TYPES: Readonly<Record<string, string>> = {
28
30
  uuid: 'uuid',
@@ -44,61 +46,31 @@ function sqlType(kind: string): string {
44
46
  return SQL_TYPES[kind] ?? kind;
45
47
  }
46
48
 
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
49
  function columnClause(column: ColumnDescriptionLike): string {
59
50
  // The generation clause sits directly after the type, and `generatedClause` refuses the pairs
60
51
  // Postgres has no column for. Every other part below is unchanged and unreachable for a
61
52
  // generated column: it may carry no default, and `hasDefault` is what the refusal reads.
62
- const parts = [`"${column.column}"`, `${sqlType(column.kind)}${generatedClause(column)}`];
53
+ //
54
+ // Through `identifier`, never `"${…}"`: the name arrives from a projection this package cannot
55
+ // typecheck and a name that closes its own quote produced a real `drop table` through
56
+ // `generateMigration` once already. It is also what makes an unrendered report safe to write
57
+ // into a `--` comment, since generation refuses the dangerous name before the comment exists.
58
+ const parts = [
59
+ identifier(column.column).text,
60
+ `${sqlType(column.kind)}${generatedClause(column)}`,
61
+ ];
63
62
  const expression = defaultExpression(column);
64
63
  if (expression !== null) parts.push(`default ${expression}`);
65
64
  if (column.notNull) parts.push('not null');
66
65
  if (column.unique && !column.primaryKey) parts.push('unique');
67
- if (column.check !== null) parts.push(`check (${column.check})`);
68
- // No `references` clause. A foreign key is `alter table add constraint`, emitted after every
69
- // table exists (`foreignKeyPlan`) inline it must point at a table that already exists, and
66
+ // No `check` clause written here it was ANONYMOUS and reached `create table` alone, so
67
+ // regenerating dropped the value set `enumerated()` declares; `check-ddl.ts` owns every CHECK now.
68
+ // No `references` clause either: a foreign key is `alter table add constraint`, emitted after
69
+ // every table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
70
70
  // entity registration order is the app's import order, which says nothing about that.
71
71
  return parts.join(' ');
72
72
  }
73
73
 
74
- /**
75
- * A `unique` column clause already creates an index, and Postgres names it exactly what the
76
- * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
77
- * it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
78
- * Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
79
- *
80
- * A **partial** unique index is not that index: the column clause constrains every row, so
81
- * skipping the partial one would silently widen the constraint the entity declared.
82
- */
83
- function impliedByColumnClause(
84
- entity: EntityDescriptionLike,
85
- index: IndexDescriptionLike,
86
- added: ReadonlySet<string>,
87
- ): boolean {
88
- const [only] = index.columns;
89
- if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
90
- return false;
91
- }
92
- const column = entity.columns.find((each) => each.column === only);
93
- // `columnClause` writes `unique` under exactly this condition — keep the two in step.
94
- //
95
- // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
96
- // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
97
- // unsafe for exactly this reason — applying it turned a green typecheck red.
98
- // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
99
- return column !== undefined && column.unique && !column.primaryKey && added.has(only);
100
- }
101
-
102
74
  export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
103
75
  const tables = [...entities]
104
76
  .sort((a, b) => (a.table < b.table ? -1 : 1))
@@ -115,6 +87,7 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
115
87
  // field existed gains a key and no app's sidecar regenerates over a fact already true.
116
88
  ...(column.generated === undefined ? {} : { generated: column.generated }),
117
89
  }));
90
+ const checks = declaredChecks(entity);
118
91
  return {
119
92
  schema: 'public',
120
93
  name: entity.table,
@@ -123,7 +96,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
123
96
  // Whole, never partly: a snapshot that recorded the name and dropped the predicate made
124
97
  // the next generation blind to a `where` or an `order` changing, and a partial index
125
98
  // silently kept as a total one is a constraint the entity no longer declares.
126
- indexes: entity.indexes.map((index) => ({
99
+ //
100
+ // `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
101
+ // own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
102
+ indexes: declaredIndexes(entity).map((index) => ({
127
103
  name: index.name,
128
104
  columns: [...index.columns],
129
105
  unique: index.unique,
@@ -136,6 +112,10 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
136
112
  ...(index.using === undefined ? {} : { using: index.using }),
137
113
  })),
138
114
  foreignKeys: foreignKeysOf(entity),
115
+ // Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
116
+ // read as "nothing recorded" so the next generation adds the constraints the database is
117
+ // genuinely missing — the rule `using` and `generated` already state one field up.
118
+ ...(checks.length === 0 ? {} : { checks }),
139
119
  };
140
120
  });
141
121
  return { tables };
@@ -144,141 +124,72 @@ export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDe
144
124
  function createTable(entity: EntityDescriptionLike): readonly string[] {
145
125
  const clauses = entity.columns.map(columnClause);
146
126
  if (entity.primaryKey.length > 0) {
147
- clauses.push(`primary key (${entity.primaryKey.map((key) => `"${key}"`).join(', ')})`);
127
+ const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
128
+ clauses.push(`primary key (${key})`);
148
129
  }
149
- const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
130
+ // After the key, so a table declaring no invariant emits the statement it always emitted.
131
+ clauses.push(...checkClauses(entity));
132
+ const statements = [
133
+ `create table ${identifier(entity.table).text} (\n ${clauses.join(',\n ')}\n);`,
134
+ ];
150
135
  // Every column of a new table carries its own clause, so every `unique` one brings its index.
151
136
  const added = new Set(entity.columns.map((column) => column.column));
152
- for (const index of entity.indexes) {
137
+ for (const index of declaredIndexes(entity)) {
153
138
  if (impliedByColumnClause(entity, index, added)) continue;
154
139
  statements.push(createIndex(entity.table, index));
155
140
  }
156
141
  return statements;
157
142
  }
158
143
 
159
- /**
160
- * Every part of the declaration reaches the statement: the whole column list in its declared
161
- * order, the direction when one was asked for, and the predicate that makes it partial. A part
162
- * dropped here is a constraint the database does not hold or an index the planner cannot use.
163
- */
164
- function createIndex(table: string, index: IndexDescriptionLike): string {
165
- assert(
166
- index.columns.length > 0,
167
- `index "${index.name}" on "${table}" names no columns`,
168
- `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
169
- );
170
- const method = index.using ?? 'btree';
171
- // Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
172
- // GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
173
- // as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
174
- // and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
175
- // index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
176
- assert(
177
- method === 'btree' || !index.unique,
178
- `index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
179
- `indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
180
- );
181
- assert(
182
- method === 'btree' || index.order === null,
183
- `index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
184
- `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
185
- );
186
- const kind = index.unique ? 'create unique index' : 'create index';
187
- const direction = index.order === null ? '' : ` ${index.order}`;
188
- const columns = index.columns.map((column) => `"${column}"${direction}`).join(', ');
189
- const predicate = index.where === null ? '' : ` where (${index.where})`;
190
- // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
191
- // an index that declared no method emits the statement this generator always emitted, byte for
192
- // byte, and one that declared a method Postgres does not have is refused instead of built.
193
- return `${kind} "${index.name}" on "${table}"${indexMethodSql(method)} (${columns})${predicate};`;
194
- }
195
-
196
144
  /**
197
145
  * Skipping an existing column by name alone missed the type moving under it: a table created
198
146
  * while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
199
147
  * snapshot this run records claims `char(3)` — two claims with no statement between them. Both
200
148
  * sides are generated spellings (`current` is a previous migration's own snapshot), so any
201
149
  * difference is a real kind change, not a catalog alias.
150
+ *
151
+ * The ALTER is not the whole statement list: Postgres compiled every predicate written against
152
+ * this column with its OLD type and cannot recompile one, so a partial index or a CHECK that reads
153
+ * it is dropped FIRST and `moved` carries the names on to the arms that would otherwise act on
154
+ * them. Without that the retype is `42883` and the migration aborts mid-run
155
+ * (`retype-dependents.ts`).
202
156
  */
203
157
  function retypeColumn(
204
- table: string,
158
+ live: TableDescription,
205
159
  column: ColumnDescriptionLike,
206
160
  recorded: ColumnDescription,
207
161
  plan: Plan,
162
+ moved: MovedAside,
208
163
  ): Regeneration {
209
164
  const wanted = sqlType(column.kind);
165
+ const table = live.name;
210
166
  // A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
211
167
  // side is one, because becoming generated and ceasing to be are both changes with a statement.
212
168
  if (isGenerated(column) || recorded.generated !== undefined) {
213
169
  return regenerate(table, column, wanted, recorded, plan);
214
170
  }
215
171
  if (recorded.dataType === wanted) return 'unchanged';
172
+ moveDependentsAside(live, column.column, plan, moved);
216
173
  const alter = (type: string): string =>
217
- `alter table "${table}" alter column "${column.column}" type ${type} ` +
218
- `using "${column.column}"::${type};`;
174
+ `alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
175
+ `type ${type} using ${identifier(column.column).text}::${type};`;
219
176
  plan.up.push(alter(wanted));
220
177
  plan.down.push(alter(recorded.dataType));
221
178
  return 'altered';
222
179
  }
223
180
 
224
- /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
225
- function indexShape(index: IndexDescriptionLike | IndexDescription): string {
226
- return JSON.stringify([
227
- [...index.columns],
228
- index.unique,
229
- index.where,
230
- index.order ?? null,
231
- indexMethodOf(index),
232
- ]);
233
- }
234
-
235
- /**
236
- * A same-named index whose definition moved is dropped and recreated, because Postgres has no
237
- * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
238
- * are all fixed at creation.
239
- *
240
- * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
241
- * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
242
- * migration and the database kept serving the old one. Both sides here are *generated* spellings
243
- * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
244
- * text difference in `where` is a real change and not a formatting one.
245
- */
246
- function redefineIndex(
247
- table: string,
248
- index: IndexDescriptionLike,
249
- recorded: IndexDescription,
250
- plan: Plan,
251
- ): void {
252
- if (indexShape(index) === indexShape(recorded)) return;
253
- plan.up.push(`drop index "${index.name}";`, createIndex(table, index));
254
- // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
255
- // the recorded definition is what must land last, after the new one is dropped.
256
- plan.down.push(
257
- createIndex(table, {
258
- name: recorded.name,
259
- columns: recorded.columns,
260
- unique: recorded.unique,
261
- where: recorded.where,
262
- order: recorded.order,
263
- // `declaredMethod`, never a cast: `recorded` is a snapshot's, typed open because the catalog
264
- // shares the shape, and a method this generator cannot emit must refuse rather than be
265
- // rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
266
- ...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
267
- }),
268
- `drop index "${index.name}";`,
269
- );
270
- }
271
-
272
181
  function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
273
182
  const existing = new Map(live.columns.map((column) => [column.name, column]));
274
183
  const added = new Set<string>();
275
184
  // A column `regenerate` had to replace outright: `add column` implies no index, so every index
276
185
  // over it has to be stated again even though its own definition never moved.
277
186
  const rebuilt = new Set<string>();
187
+ // What a retype dropped ahead of itself, read by the two arms below.
188
+ const moved: MovedAside = { indexes: new Set(), checks: new Set() };
278
189
  for (const column of entity.columns) {
279
190
  const recorded = existing.get(column.column);
280
191
  if (recorded !== undefined) {
281
- if (retypeColumn(entity.table, column, recorded, plan) === 'rebuilt') {
192
+ if (retypeColumn(live, column, recorded, plan, moved) === 'rebuilt') {
282
193
  rebuilt.add(column.column);
283
194
  }
284
195
  continue;
@@ -293,22 +204,26 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
293
204
  // step nobody can perform, since a generated column cannot be written to.
294
205
  const nullable = column.notNull && !isGenerated(column) && defaultExpression(column) === null;
295
206
  const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
296
- plan.up.push(`alter table "${entity.table}" add column ${clause};`);
207
+ plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
297
208
  if (nullable) {
298
209
  plan.up.push(
299
- `-- backfill "${column.column}", then: ` +
300
- `alter table "${entity.table}" alter column "${column.column}" set not null;`,
210
+ `-- backfill ${identifier(column.column).text}, then: alter table ` +
211
+ `${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
301
212
  );
302
213
  }
303
- plan.down.push(`alter table "${entity.table}" drop column "${column.column}";`);
214
+ plan.down.push(
215
+ `alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
216
+ );
304
217
  }
305
218
 
306
219
  const indexed = new Map(live.indexes.map((index) => [index.name, index]));
307
- for (const index of entity.indexes) {
220
+ for (const index of declaredIndexes(entity)) {
308
221
  const recorded = indexed.get(index.name);
309
- // A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:
222
+ // A rebuilt column took its indexes down with it, and a retype dropped the ones whose
223
+ // predicate it could not survive — either way this one is CREATED rather than compared:
310
224
  // `redefineIndex` sees a definition that never moved and would emit nothing at all.
311
- if (recorded !== undefined && !index.columns.some((column) => rebuilt.has(column))) {
225
+ const gone = moved.indexes.has(index.name) || index.columns.some((each) => rebuilt.has(each));
226
+ if (recorded !== undefined && !gone) {
312
227
  redefineIndex(entity.table, index, recorded, plan);
313
228
  continue;
314
229
  }
@@ -316,8 +231,14 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
316
231
  // migration emits, so it still needs a statement of its own.
317
232
  if (impliedByColumnClause(entity, index, added)) continue;
318
233
  plan.up.push(createIndex(entity.table, index));
319
- plan.down.push(`drop index "${index.name}";`);
234
+ plan.down.push(dropIndex(index.name));
320
235
  }
236
+
237
+ // Last: a CHECK may read a column this migration just added, and `add constraint` on a column
238
+ // that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a
239
+ // column dropped and re-added lost its constraint while the snapshot still records it, and
240
+ // `moved.checks` because a retype already dropped the ones written against the old type.
241
+ checkPlan(entity, live, plan, rebuilt, moved.checks);
321
242
  }
322
243
 
323
244
  export interface GenerateOptions {
@@ -343,6 +264,13 @@ export interface GeneratedMigration {
343
264
  * cannot be written unmarked and then refused by `x verify` for lacking the mark.
344
265
  */
345
266
  readonly destructive: boolean;
267
+ /**
268
+ * Every declaration this generator could not write down. Empty on a migration that carries the
269
+ * whole schema, which is what makes it readable as a verdict rather than as noise — and the same
270
+ * list `unrenderedComment` writes into the top of `up`, so a caller that would rather refuse
271
+ * (`x db gen`) and a reviewer reading the committed file are looking at one answer.
272
+ */
273
+ readonly unrendered: readonly UnrenderedDeclaration[];
346
274
  }
347
275
 
348
276
  export function migrationStamp(now: Date): string {
@@ -376,7 +304,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
376
304
  foreignKeyPlan(entity, live, plans);
377
305
  if (live === undefined) {
378
306
  plan.up.push(...createTable(entity));
379
- plan.down.push(`drop table "${entity.table}";`);
307
+ plan.down.push(`drop table ${identifier(entity.table).text};`);
380
308
  continue;
381
309
  }
382
310
  diffTable(entity, live, plan);
@@ -389,9 +317,12 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
389
317
  `x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
390
318
  );
391
319
  }
392
- plan.up.push(`alter table "${entity.table}" drop column "${column.name}";`);
320
+ plan.up.push(
321
+ `alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
322
+ );
393
323
  plan.down.push(
394
- `alter table "${entity.table}" add column "${column.name}" ${column.dataType};` +
324
+ `alter table ${identifier(entity.table).text} add column ` +
325
+ `${identifier(column.name).text} ${column.dataType};` +
395
326
  ' -- data is not restored',
396
327
  );
397
328
  }
@@ -409,15 +340,33 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
409
340
  plan.up.push(...preDrops.up, ...order.constraints);
410
341
  plan.down.push(...preDrops.down, ...order.constraints.map(() => '-- constraint not restored'));
411
342
  for (const table of order.tables) {
412
- plan.up.push(`drop table "${table.name}";`);
413
- plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
343
+ plan.up.push(`drop table ${identifier(table.name).text};`);
344
+ // `identifier` in a comment too: a `--` ends at the first newline, so a name holding one
345
+ // would put a second command on the next line of `down`.
346
+ plan.down.push(
347
+ `-- ${identifier(table.name).text} cannot be restored; recover it from a backup`,
348
+ );
414
349
  }
415
350
 
416
351
  plan.up.push(...constraints.up);
417
352
  plan.down.push(...constraints.down);
418
353
 
419
354
  const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
420
- const up = plan.up.join('\n');
355
+ // At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
356
+ // noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
357
+ // `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
358
+ //
359
+ // Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
360
+ // "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
361
+ // would make every `x db gen` on an app with an unrendered default write a migration holding no
362
+ // statement — a ledger row, a checksum and a place in the apply order for nothing. The list is
363
+ // still on `GeneratedMigration.unrendered`, which is where a caller with no file reads it.
364
+ //
365
+ // `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
366
+ // loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
367
+ const unrendered = unrenderedOf(options.entities, current);
368
+ const body = plan.up.join('\n');
369
+ const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
421
370
  return {
422
371
  id,
423
372
  name: options.name,
@@ -427,5 +376,6 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
427
376
  down: [...plan.down].reverse().join('\n'),
428
377
  snapshot: snapshotOf(options.entities),
429
378
  destructive: isDestructive(up),
379
+ unrendered,
430
380
  };
431
381
  }
@@ -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 "${table}" alter column "${column}"`;
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
- `alter table "${table}" drop column "${column.column}";`,
87
- `alter table "${table}" add column "${column.column}" ${wantedType}` +
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 "${table}" add column "${column.column}" ${recorded.dataType};` +
100
+ `alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
101
+ `${recorded.dataType};` +
93
102
  ' -- was not a generated column',
94
- `alter table "${table}" drop column "${column.column}";`,
103
+ dropColumn,
95
104
  );
96
105
  return 'rebuilt';
97
106
  }
@@ -0,0 +1,137 @@
1
+ // Single responsibility: the DDL an entity's INDEX declaration becomes — the `create index` a
2
+ // declaration writes out, what makes two definitions the same index, and what a moved definition
3
+ // rebuilds. Split out of `generate.ts` at the 500-line ceiling, along the seam `check-ddl.ts` and
4
+ // `generated-column.ts` already drew: `generate.ts` assembles a plan, this file writes the index
5
+ // statements it puts in it, and `invariant-ddl.ts` decides which indexes a table declares.
6
+
7
+ import { assert } from '@ultimat3/core';
8
+ import type { EntityDescriptionLike, IndexDescriptionLike } from './entity-shape';
9
+ import type { Plan } from './foreign-key-plan';
10
+ import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
11
+ import type { IndexDescription } from './introspect';
12
+ import { identifier } from './sql';
13
+
14
+ /**
15
+ * A `unique` column clause already creates an index, and Postgres names it exactly what the
16
+ * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
17
+ * it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
18
+ * Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
19
+ *
20
+ * A **partial** unique index is not that index: the column clause constrains every row, so
21
+ * skipping the partial one would silently widen the constraint the entity declared.
22
+ */
23
+ export function impliedByColumnClause(
24
+ entity: EntityDescriptionLike,
25
+ index: IndexDescriptionLike,
26
+ added: ReadonlySet<string>,
27
+ ): boolean {
28
+ const [only] = index.columns;
29
+ if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
30
+ return false;
31
+ }
32
+ const column = entity.columns.find((each) => each.column === only);
33
+ // `columnClause` writes `unique` under exactly this condition — keep the two in step.
34
+ //
35
+ // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
36
+ // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
37
+ // unsafe for exactly this reason — applying it turned a green typecheck red.
38
+ // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
39
+ return column !== undefined && column.unique && !column.primaryKey && added.has(only);
40
+ }
41
+
42
+ /**
43
+ * Every part of the declaration reaches the statement: the whole column list in its declared
44
+ * order, the direction when one was asked for, and the predicate that makes it partial. A part
45
+ * dropped here is a constraint the database does not hold or an index the planner cannot use.
46
+ */
47
+ export function createIndex(table: string, index: IndexDescriptionLike): string {
48
+ assert(
49
+ index.columns.length > 0,
50
+ `index "${index.name}" on "${table}" names no columns`,
51
+ `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
52
+ );
53
+ const method = index.using ?? 'btree';
54
+ // Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
55
+ // GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
56
+ // as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
57
+ // and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
58
+ // index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
59
+ assert(
60
+ method === 'btree' || !index.unique,
61
+ `index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
62
+ `indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
63
+ );
64
+ assert(
65
+ method === 'btree' || index.order === null,
66
+ `index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
67
+ `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
68
+ );
69
+ const kind = index.unique ? 'create unique index' : 'create index';
70
+ const direction = index.order === null ? '' : ` ${index.order}`;
71
+ const columns = index.columns
72
+ .map((column) => `${identifier(column).text}${direction}`)
73
+ .join(', ');
74
+ const predicate = index.where === null ? '' : ` where (${index.where})`;
75
+ // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
76
+ // an index that declared no method emits the statement this generator always emitted, byte for
77
+ // byte, and one that declared a method Postgres does not have is refused instead of built.
78
+ return (
79
+ `${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
80
+ `${indexMethodSql(method)} (${columns})${predicate};`
81
+ );
82
+ }
83
+
84
+ /** `drop index "n";` — the one spelling, so a drop and its recreate cannot name it differently. */
85
+ export const dropIndex = (name: string): string => `drop index ${identifier(name).text};`;
86
+
87
+ /**
88
+ * A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
89
+ * cast: the recorded side is typed open because the catalog shares the shape, and a method this
90
+ * generator cannot write must refuse rather than be rebuilt as a btree. One copy, because
91
+ * `redefineIndex`'s `down` and `retype-dependents.ts`'s restore ask the same question.
92
+ */
93
+ export function asDeclared(index: IndexDescription): IndexDescriptionLike {
94
+ return {
95
+ name: index.name,
96
+ columns: index.columns,
97
+ unique: index.unique,
98
+ where: index.where,
99
+ order: index.order,
100
+ ...(index.using === undefined ? {} : { using: declaredMethod(index.using) }),
101
+ };
102
+ }
103
+
104
+ /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
105
+ export function indexShape(index: IndexDescriptionLike | IndexDescription): string {
106
+ return JSON.stringify([
107
+ [...index.columns],
108
+ index.unique,
109
+ index.where,
110
+ index.order ?? null,
111
+ indexMethodOf(index),
112
+ ]);
113
+ }
114
+
115
+ /**
116
+ * A same-named index whose definition moved is dropped and recreated, because Postgres has no
117
+ * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
118
+ * are all fixed at creation.
119
+ *
120
+ * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
121
+ * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
122
+ * migration and the database kept serving the old one. Both sides here are *generated* spellings
123
+ * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
124
+ * text difference in `where` is a real change and not a formatting one.
125
+ */
126
+ export function redefineIndex(
127
+ table: string,
128
+ index: IndexDescriptionLike,
129
+ recorded: IndexDescription,
130
+ plan: Plan,
131
+ ): void {
132
+ if (indexShape(index) === indexShape(recorded)) return;
133
+ plan.up.push(dropIndex(index.name), createIndex(table, index));
134
+ // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
135
+ // the recorded definition is what must land last, after the new one is dropped.
136
+ plan.down.push(createIndex(table, asDeclared(recorded)), dropIndex(index.name));
137
+ }