@ultimat3/db 14.0.0 → 16.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,52 +3,32 @@
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
7
  import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
8
8
  import { defaultExpression } from './column-default';
9
9
  import { isDestructive } from './destructive';
10
10
  import { dropOrder } from './drop-order';
11
- import type {
12
- ColumnDescriptionLike,
13
- EntityDescriptionLike,
14
- IndexDescriptionLike,
15
- } from './entity-shape';
16
- import { migrationIrreversible } from './errors';
11
+ import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
17
12
  import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan';
18
13
  import type { Regeneration } from './generated-column';
19
14
  import { generatedClause, isGenerated, regenerate } from './generated-column';
20
- import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method';
15
+ import { createIndex, impliedByColumnClause } from './index-ddl';
16
+ import { indexPlan } from './index-plan';
21
17
  import {
22
18
  type ColumnDescription,
23
19
  findTable,
24
- type IndexDescription,
25
20
  type SchemaDescription,
26
21
  type TableDescription,
27
22
  } from './introspect';
28
23
  import { declaredIndexes } from './invariant-ddl';
24
+ import { migrationIrreversible } from './migration-errors';
25
+ import type { MovedAside } from './retype-dependents';
26
+ import { moveDependentsAside } from './retype-dependents';
27
+ import { moveKeysAside, retypedColumns, retypedIn } from './retype-keys';
29
28
  import { identifier } from './sql';
29
+ import { sqlType } from './sql-type';
30
30
  import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';
31
31
 
32
- const SQL_TYPES: Readonly<Record<string, string>> = {
33
- uuid: 'uuid',
34
- text: 'text',
35
- // Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
36
- // currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
37
- // Without the length no currency ever fits the constraint the same statement demands.
38
- char: 'char(3)',
39
- boolean: 'boolean',
40
- integer: 'integer',
41
- bigint: 'bigint',
42
- numeric: 'numeric',
43
- timestamptz: 'timestamptz',
44
- date: 'date',
45
- jsonb: 'jsonb',
46
- };
47
-
48
- function sqlType(kind: string): string {
49
- return SQL_TYPES[kind] ?? kind;
50
- }
51
-
52
32
  function columnClause(column: ColumnDescriptionLike): string {
53
33
  // The generation clause sits directly after the type, and `generatedClause` refuses the pairs
54
34
  // Postgres has no column for. Every other part below is unchanged and unreachable for a
@@ -74,34 +54,6 @@ function columnClause(column: ColumnDescriptionLike): string {
74
54
  return parts.join(' ');
75
55
  }
76
56
 
77
- /**
78
- * A `unique` column clause already creates an index, and Postgres names it exactly what the
79
- * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
80
- * it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
81
- * Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
82
- *
83
- * A **partial** unique index is not that index: the column clause constrains every row, so
84
- * skipping the partial one would silently widen the constraint the entity declared.
85
- */
86
- function impliedByColumnClause(
87
- entity: EntityDescriptionLike,
88
- index: IndexDescriptionLike,
89
- added: ReadonlySet<string>,
90
- ): boolean {
91
- const [only] = index.columns;
92
- if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) {
93
- return false;
94
- }
95
- const column = entity.columns.find((each) => each.column === only);
96
- // `columnClause` writes `unique` under exactly this condition — keep the two in step.
97
- //
98
- // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is
99
- // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix
100
- // unsafe for exactly this reason — applying it turned a green typecheck red.
101
- // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined
102
- return column !== undefined && column.unique && !column.primaryKey && added.has(only);
103
- }
104
-
105
57
  export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
106
58
  const tables = [...entities]
107
59
  .sort((a, b) => (a.table < b.table ? -1 : 1))
@@ -172,68 +124,39 @@ function createTable(entity: EntityDescriptionLike): readonly string[] {
172
124
  return statements;
173
125
  }
174
126
 
175
- /**
176
- * Every part of the declaration reaches the statement: the whole column list in its declared
177
- * order, the direction when one was asked for, and the predicate that makes it partial. A part
178
- * dropped here is a constraint the database does not hold or an index the planner cannot use.
179
- */
180
- function createIndex(table: string, index: IndexDescriptionLike): string {
181
- assert(
182
- index.columns.length > 0,
183
- `index "${index.name}" on "${table}" names no columns`,
184
- `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`,
185
- );
186
- const method = index.using ?? 'btree';
187
- // Two rules Postgres has and a declaration can break, refused here rather than at migrate time:
188
- // GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server
189
- // as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words
190
- // and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an
191
- // index naming no columns: a declaration this build cannot honour is refused, never reinterpreted.
192
- assert(
193
- method === 'btree' || !index.unique,
194
- `index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`,
195
- `indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`,
196
- );
197
- assert(
198
- method === 'btree' || index.order === null,
199
- `index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`,
200
- `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`,
201
- );
202
- const kind = index.unique ? 'create unique index' : 'create index';
203
- const direction = index.order === null ? '' : ` ${index.order}`;
204
- const columns = index.columns
205
- .map((column) => `${identifier(column).text}${direction}`)
206
- .join(', ');
207
- const predicate = index.where === null ? '' : ` where (${index.where})`;
208
- // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so
209
- // an index that declared no method emits the statement this generator always emitted, byte for
210
- // byte, and one that declared a method Postgres does not have is refused instead of built.
211
- return (
212
- `${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
213
- `${indexMethodSql(method)} (${columns})${predicate};`
214
- );
215
- }
216
-
217
127
  /**
218
128
  * Skipping an existing column by name alone missed the type moving under it: a table created
219
129
  * while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
220
130
  * snapshot this run records claims `char(3)` — two claims with no statement between them. Both
221
131
  * sides are generated spellings (`current` is a previous migration's own snapshot), so any
222
132
  * difference is a real kind change, not a catalog alias.
133
+ *
134
+ * The ALTER is not the whole statement list: Postgres compiled every predicate written against
135
+ * this column with its OLD type and cannot recompile one, so a partial index or a CHECK that reads
136
+ * it is dropped FIRST and `moved` carries the names on to the arms that would otherwise act on
137
+ * them. Without that the retype is `42883` and the migration aborts mid-run
138
+ * (`retype-dependents.ts`).
223
139
  */
224
140
  function retypeColumn(
225
- table: string,
141
+ live: TableDescription,
226
142
  column: ColumnDescriptionLike,
227
143
  recorded: ColumnDescription,
228
144
  plan: Plan,
145
+ moved: MovedAside,
146
+ retyped: ReadonlySet<string>,
229
147
  ): Regeneration {
230
148
  const wanted = sqlType(column.kind);
149
+ const table = live.name;
231
150
  // A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER
232
151
  // side is one, because becoming generated and ceasing to be are both changes with a statement.
233
152
  if (isGenerated(column) || recorded.generated !== undefined) {
234
- return regenerate(table, column, wanted, recorded, plan);
153
+ return regenerate(live, column, wanted, recorded, plan, moved);
235
154
  }
236
- if (recorded.dataType === wanted) return 'unchanged';
155
+ // The set, never `recorded.dataType === wanted` a second time: `retypedColumns` decided this for
156
+ // the whole schema before any statement was written, because the foreign keys a retype breaks
157
+ // are recorded on tables this diff is not looking at (`retype-keys.ts`).
158
+ if (!retyped.has(column.column)) return 'unchanged';
159
+ moveDependentsAside(live, column.column, plan, moved);
237
160
  const alter = (type: string): string =>
238
161
  `alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
239
162
  `type ${type} using ${identifier(column.column).text}::${type};`;
@@ -242,64 +165,23 @@ function retypeColumn(
242
165
  return 'altered';
243
166
  }
244
167
 
245
- /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
246
- function indexShape(index: IndexDescriptionLike | IndexDescription): string {
247
- return JSON.stringify([
248
- [...index.columns],
249
- index.unique,
250
- index.where,
251
- index.order ?? null,
252
- indexMethodOf(index),
253
- ]);
254
- }
255
-
256
- /**
257
- * A same-named index whose definition moved is dropped and recreated, because Postgres has no
258
- * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
259
- * are all fixed at creation.
260
- *
261
- * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
262
- * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
263
- * migration and the database kept serving the old one. Both sides here are *generated* spellings
264
- * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
265
- * text difference in `where` is a real change and not a formatting one.
266
- */
267
- function redefineIndex(
268
- table: string,
269
- index: IndexDescriptionLike,
270
- recorded: IndexDescription,
168
+ function diffTable(
169
+ entity: EntityDescriptionLike,
170
+ live: TableDescription,
271
171
  plan: Plan,
172
+ retyped: ReadonlySet<string>,
272
173
  ): void {
273
- if (indexShape(index) === indexShape(recorded)) return;
274
- plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index));
275
- // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
276
- // the recorded definition is what must land last, after the new one is dropped.
277
- plan.down.push(
278
- createIndex(table, {
279
- name: recorded.name,
280
- columns: recorded.columns,
281
- unique: recorded.unique,
282
- where: recorded.where,
283
- order: recorded.order,
284
- // `declaredMethod`, never a cast: `recorded` is a snapshot's, typed open because the catalog
285
- // shares the shape, and a method this generator cannot emit must refuse rather than be
286
- // rebuilt as a btree — a `down` that recreates the wrong structure is worse than none.
287
- ...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }),
288
- }),
289
- `drop index ${identifier(index.name).text};`,
290
- );
291
- }
292
-
293
- function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
294
174
  const existing = new Map(live.columns.map((column) => [column.name, column]));
295
175
  const added = new Set<string>();
296
176
  // A column `regenerate` had to replace outright: `add column` implies no index, so every index
297
177
  // over it has to be stated again even though its own definition never moved.
298
178
  const rebuilt = new Set<string>();
179
+ // What a retype dropped ahead of itself, read by the two arms below.
180
+ const moved: MovedAside = { indexes: new Set(), checks: new Set() };
299
181
  for (const column of entity.columns) {
300
182
  const recorded = existing.get(column.column);
301
183
  if (recorded !== undefined) {
302
- if (retypeColumn(entity.table, column, recorded, plan) === 'rebuilt') {
184
+ if (retypeColumn(live, column, recorded, plan, moved, retyped) === 'rebuilt') {
303
185
  rebuilt.add(column.column);
304
186
  }
305
187
  continue;
@@ -326,26 +208,15 @@ function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan:
326
208
  );
327
209
  }
328
210
 
329
- const indexed = new Map(live.indexes.map((index) => [index.name, index]));
330
- for (const index of declaredIndexes(entity)) {
331
- const recorded = indexed.get(index.name);
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))) {
335
- redefineIndex(entity.table, index, recorded, plan);
336
- continue;
337
- }
338
- // `added` only: an index over a column that was already there is implied by no clause this
339
- // migration emits, so it still needs a statement of its own.
340
- if (impliedByColumnClause(entity, index, added)) continue;
341
- plan.up.push(createIndex(entity.table, index));
342
- plan.down.push(`drop index ${identifier(index.name).text};`);
343
- }
211
+ // Both directions, in `index-plan.ts`: a recorded index the entity no longer declares is DROPPED
212
+ // there, which is the arm this loop did not have for as long as it lived here.
213
+ indexPlan(entity, live, plan, { added, rebuilt, moved: moved.indexes });
344
214
 
345
215
  // Last: a CHECK may read a column this migration just added, and `add constraint` on a column
346
216
  // 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);
217
+ // column dropped and re-added lost its constraint while the snapshot still records it, and
218
+ // `moved.checks` because a retype already dropped the ones written against the old type.
219
+ checkPlan(entity, live, plan, rebuilt, moved.checks);
349
220
  }
350
221
 
351
222
  export interface GenerateOptions {
@@ -399,12 +270,20 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
399
270
  // Merged in BEFORE them, for the mirror-image reason: a key still pointing at a table this
400
271
  // migration drops makes that `drop table` `2BP01`.
401
272
  const preDrops: Plan = { up: [], down: [] };
273
+ // Ahead of EVERYTHING, and at the far end of `down`: a foreign key compiled against a column
274
+ // being retyped has to be gone before the first ALTER and back after the last one, and both ends
275
+ // of one key can move in two different entities' diffs (`retype-keys.ts`).
276
+ const preAlters: Plan = { up: [], down: [] };
402
277
  const wanted = new Set(options.entities.map((entity) => entity.table));
403
278
 
404
279
  const doomed = new Set(
405
280
  current.tables.filter((table) => !wanted.has(table.name)).map((table) => table.name),
406
281
  );
407
- const plans: ConstraintPlans = { constraints, preDrops, doomed };
282
+ // Before the loop, because the answer spans it: `diffTable` is handed one entity's recorded row
283
+ // and the key that a retype of its column breaks is recorded on whichever table OWNS the key.
284
+ const retyped = retypedColumns(options.entities, current);
285
+ const predropped = moveKeysAside(current, retyped, doomed, preAlters);
286
+ const plans: ConstraintPlans = { constraints, preDrops, doomed, predropped };
408
287
 
409
288
  for (const entity of options.entities) {
410
289
  const live = findTable(current, entity.table);
@@ -414,7 +293,7 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
414
293
  plan.down.push(`drop table ${identifier(entity.table).text};`);
415
294
  continue;
416
295
  }
417
- diffTable(entity, live, plan);
296
+ diffTable(entity, live, plan, retypedIn(retyped, entity.table));
418
297
  const kept = new Set(entity.columns.map((column) => column.column));
419
298
  for (const column of live.columns) {
420
299
  if (kept.has(column.name)) continue;
@@ -472,15 +351,17 @@ export function generateMigration(options: GenerateOptions): GeneratedMigration
472
351
  // `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
473
352
  // loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
474
353
  const unrendered = unrenderedOf(options.entities, current);
475
- const body = plan.up.join('\n');
354
+ const body = [...preAlters.up, ...plan.up].join('\n');
476
355
  const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
477
356
  return {
478
357
  id,
479
358
  name: options.name,
480
359
  fileName: `migrations/${id}.sql`,
481
360
  up,
482
- // Reverse order: the last thing created is the first thing dropped.
483
- down: [...plan.down].reverse().join('\n'),
361
+ // Reverse order: the last thing created is the first thing dropped. `preAlters` goes in at the
362
+ // FRONT here precisely so reversal puts it last — a key is added back only once both of its
363
+ // ends have been retyped back, which is every other statement in the script.
364
+ down: [...preAlters.down, ...plan.down].reverse().join('\n'),
484
365
  snapshot: snapshotOf(options.entities),
485
366
  destructive: isDestructive(up),
486
367
  unrendered,
@@ -6,7 +6,9 @@
6
6
  import { assert } from '@ultimat3/core';
7
7
  import type { ColumnDescriptionLike } from './entity-shape';
8
8
  import type { Plan } from './foreign-key-plan';
9
- import type { ColumnDescription } from './introspect';
9
+ import type { ColumnDescription, TableDescription } from './introspect';
10
+ import type { MovedAside } from './retype-dependents';
11
+ import { moveDependentsAside } from './retype-dependents';
10
12
  import { identifier } from './sql';
11
13
 
12
14
  /** How a column that moved was brought back into line — what the caller has to do next, if anything. */
@@ -68,14 +70,26 @@ export const isGenerated = (column: ColumnDescriptionLike): boolean =>
68
70
  * plain → generated (there is no `set expression` for a column that has none) and a column whose
69
71
  * recorded expression is unknown. `drop expression` is the one that HAS a statement, and it is the
70
72
  * generated → plain direction, which keeps the values it computed.
73
+ *
74
+ * **The rebuild moves the column's dependents aside first, and it is `retype-dependents.ts` that
75
+ * says which — never a second answer written here.** The `rebuilt`
76
+ * set `diffTable` carries into its index loop is keyed on an index's COLUMNS, so a partial index
77
+ * whose `where` names this column and whose key columns do not was dropped with the column by
78
+ * `drop column` and re-created by nothing: measured, the table came back with the index gone, the
79
+ * snapshot still recording it, and `down` unable to restore it. An invariant's CHECK reading the
80
+ * column is the same loss one arm over. `moveDependentsAside` drops each of them explicitly,
81
+ * restores them in `down`, and puts the name in `moved` — which is what makes the ordinary diff
82
+ * CREATE the declared one instead of comparing a definition that never moved.
71
83
  */
72
84
  export function regenerate(
73
- table: string,
85
+ live: TableDescription,
74
86
  column: ColumnDescriptionLike,
75
87
  wantedType: string,
76
88
  recorded: ColumnDescription,
77
89
  plan: Plan,
90
+ moved: MovedAside,
78
91
  ): Regeneration {
92
+ const table = live.name;
79
93
  const wanted = column.generated ?? null;
80
94
  const held = recorded.generated ?? null;
81
95
  if (wanted === null && held === null) return 'unchanged';
@@ -89,6 +103,7 @@ export function regenerate(
89
103
  // column again. Reported as `rebuilt` so the caller can put the indexes back — an `add column`
90
104
  // implies none of them.
91
105
  if (held === null) {
106
+ moveDependentsAside(live, column.column, plan, moved);
92
107
  const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
93
108
  plan.up.push(
94
109
  dropColumn,
@@ -104,16 +119,26 @@ export function regenerate(
104
119
  );
105
120
  return 'rebuilt';
106
121
  }
107
- let moved = false;
122
+ let changed = false;
123
+ // NOT `moveDependentsAside`, and the reason is measured rather than assumed. This ALTER trips the
124
+ // same `42883` (`operator does not exist: text > integer`, on a generated `integer` column under
125
+ // `where (doubled > 0)`) — but moving the index aside only relocates the failure to the
126
+ // `create index` that puts it back, because a predicate whose operator the NEW type has no
127
+ // resolution for cannot be written either. The plain path's dependents survive precisely because
128
+ // an untyped literal re-resolves (`status = 'published'` under an enum and under `text`), and a
129
+ // generated column reaching that shape needs its EXPRESSION changed in the same migration, which
130
+ // `regenerate` emits AFTER this statement. Left open deliberately, with the failure named.
108
131
  if (recorded.dataType !== wantedType) {
109
132
  plan.up.push(`${alterColumn(table, column.column)} type ${wantedType};`);
110
133
  plan.down.push(`${alterColumn(table, column.column)} type ${recorded.dataType};`);
111
- moved = true;
134
+ changed = true;
112
135
  }
136
+ // Nothing moves for this one either, and here it is free: `set expression` recomputes every value
137
+ // and leaves the type alone, so nothing compiled against the type has anything to recompile.
113
138
  if (held !== wanted) {
114
139
  plan.up.push(`${alterColumn(table, column.column)} set expression as (${wanted});`);
115
140
  plan.down.push(`${alterColumn(table, column.column)} set expression as (${held});`);
116
- moved = true;
141
+ changed = true;
117
142
  }
118
- return moved ? 'altered' : 'unchanged';
143
+ return changed ? 'altered' : 'unchanged';
119
144
  }
@@ -0,0 +1,188 @@
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
+ * Whether Postgres could be backing this RECORDED index with a UNIQUE constraint rather than
89
+ * holding it as an index of its own — which decides the only question `dropIndex` cannot answer.
90
+ *
91
+ * A UNIQUE constraint's index is unique, total, unordered and btree; `add constraint … unique`
92
+ * and a `unique` column clause can produce nothing else. So an index missing any one of those is
93
+ * provably an index, and `drop index` on it is right. Everything else is genuinely ambiguous —
94
+ * see `dropRecordedIndex`.
95
+ */
96
+ export function mayBeConstraintBacked(index: IndexDescription): boolean {
97
+ return (
98
+ index.unique &&
99
+ !index.primary &&
100
+ index.where === null &&
101
+ index.order === null &&
102
+ indexMethodOf(index) === 'btree'
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Remove a RECORDED index whose kind this generator cannot know, in statements that are correct
108
+ * on both databases it cannot tell apart.
109
+ *
110
+ * `TableDescription` carries no discriminator, and it cannot be given one that would help: the
111
+ * SAME declaration reaches the server as a CONSTRAINT or as an INDEX depending on which migration
112
+ * created it. A `unique` column on a table `createTable` writes emits `create table … slug text
113
+ * unique`, and Postgres backs that with a constraint named `posts_slug_key`; the same column
114
+ * gaining `unique` later takes `diffTable`'s `create unique index "posts_slug_key"` and is a plain
115
+ * index. `snapshotOf` records both as `{ unique: true, primary: false }`, and every sidecar already
116
+ * on disk was written that way — a new field could not classify one of them retroactively.
117
+ *
118
+ * Measured on 18.4 (`index-removal.live.test.ts`), which is why the pair and not a guess:
119
+ *
120
+ * | statement | on a constraint's index | on a plain index |
121
+ * |--------------------------------------------|-------------------------|------------------|
122
+ * | `drop index "n"` | **2BP01** | ok |
123
+ * | `drop index if exists "n"` | **2BP01** — not suppressed | ok |
124
+ * | `alter table … drop constraint if exists` | drops it, index and all | notice, no-op |
125
+ *
126
+ * Constraint first, then the index: reversed, the `drop index` reaches a constraint's index and is
127
+ * the 2BP01 this exists to avoid. Both halves carry `if exists`, so whichever one did nothing says
128
+ * so with a notice rather than 42704.
129
+ */
130
+ export function dropRecordedIndex(table: string, index: IndexDescription): readonly string[] {
131
+ if (!mayBeConstraintBacked(index)) return [dropIndex(index.name)];
132
+ return [
133
+ `alter table ${identifier(table).text} drop constraint if exists ${identifier(index.name).text};`,
134
+ `drop index if exists ${identifier(index.name).text};`,
135
+ ];
136
+ }
137
+
138
+ /**
139
+ * A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a
140
+ * cast: the recorded side is typed open because the catalog shares the shape, and a method this
141
+ * generator cannot write must refuse rather than be rebuilt as a btree. One copy, because
142
+ * `redefineIndex`'s `down` and `retype-dependents.ts`'s restore ask the same question.
143
+ */
144
+ export function asDeclared(index: IndexDescription): IndexDescriptionLike {
145
+ return {
146
+ name: index.name,
147
+ columns: index.columns,
148
+ unique: index.unique,
149
+ where: index.where,
150
+ order: index.order,
151
+ ...(index.using === undefined ? {} : { using: declaredMethod(index.using) }),
152
+ };
153
+ }
154
+
155
+ /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */
156
+ export function indexShape(index: IndexDescriptionLike | IndexDescription): string {
157
+ return JSON.stringify([
158
+ [...index.columns],
159
+ index.unique,
160
+ index.where,
161
+ index.order ?? null,
162
+ indexMethodOf(index),
163
+ ]);
164
+ }
165
+
166
+ /**
167
+ * A same-named index whose definition moved is dropped and recreated, because Postgres has no
168
+ * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction
169
+ * are all fixed at creation.
170
+ *
171
+ * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an
172
+ * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty
173
+ * migration and the database kept serving the old one. Both sides here are *generated* spellings
174
+ * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a
175
+ * text difference in `where` is a real change and not a formatting one.
176
+ */
177
+ export function redefineIndex(
178
+ table: string,
179
+ index: IndexDescriptionLike,
180
+ recorded: IndexDescription,
181
+ plan: Plan,
182
+ ): void {
183
+ if (indexShape(index) === indexShape(recorded)) return;
184
+ plan.up.push(dropIndex(index.name), createIndex(table, index));
185
+ // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating
186
+ // the recorded definition is what must land last, after the new one is dropped.
187
+ plan.down.push(createIndex(table, asDeclared(recorded)), dropIndex(index.name));
188
+ }