@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.
@@ -0,0 +1,119 @@
1
+ // Single responsibility: which indexes an existing table gains, has rebuilt, or LOSES. Split out
2
+ // of `generate.ts` along the seam `check-ddl.ts` and `index-ddl.ts` already drew — `generate.ts`
3
+ // assembles a plan, `index-ddl.ts` writes the statements, and this file decides which of them go
4
+ // in. `checkPlan` is its shape, deliberately: "what does the record hold that the declaration does
5
+ // not" is one question, and a fourth spelling of it is the split axiom 1 refuses.
6
+ //
7
+ // The removal arm is why this file exists. `diffTable` walked `declaredIndexes(entity)` and matched
8
+ // by name, with no reverse pass, so an index the entities stopped declaring stayed on the database
9
+ // forever while the sidecar beside it stopped recording it — `examples/dummy` carried
10
+ // `member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` through every
11
+ // regeneration, and `x verify`'s drift step was green over all three because drift judges the
12
+ // declared side. The same defect `foreignKeyPlan` closed on 2026-08-19, one arm over.
13
+ //
14
+ // KNOWN LIMIT, named rather than half-built: a UNIQUE index that a foreign key on ANOTHER table
15
+ // still references cannot be dropped (2BP01), and this arm sees one table at a time. That
16
+ // declaration is already broken — the key has nothing to point at — and the failure arrives with
17
+ // the server's own words naming both ends.
18
+
19
+ import type { EntityDescriptionLike } from './entity-shape';
20
+ import type { Plan } from './foreign-key-plan';
21
+ import {
22
+ asDeclared,
23
+ createIndex,
24
+ dropIndex,
25
+ dropRecordedIndex,
26
+ impliedByColumnClause,
27
+ redefineIndex,
28
+ } from './index-ddl';
29
+ import type { TableDescription } from './introspect';
30
+ import { declaredIndexes } from './invariant-ddl';
31
+
32
+ /**
33
+ * What the rest of this migration has already done to the columns underneath the indexes — every
34
+ * field is a set of names some other arm produced, and each answers "this index is already gone".
35
+ */
36
+ export interface IndexPlanContext {
37
+ /** Columns this migration ADDS, whose own `unique` clause brings an index Postgres names. */
38
+ readonly added: ReadonlySet<string>;
39
+ /** Columns `regenerate` dropped and re-added outright — every index over one went with it. */
40
+ readonly rebuilt: ReadonlySet<string>;
41
+ /** Indexes a retype already dropped ahead of its ALTER (`moveDependentsAside`). */
42
+ readonly moved: ReadonlySet<string>;
43
+ }
44
+
45
+ /**
46
+ * Which indexes an existing table gains, has rebuilt, or loses.
47
+ *
48
+ * Declared first and removed last, the order `checkPlan` uses. Both orders are safe — two indexes
49
+ * over the same columns may coexist for the length of one migration — so the tie goes to the file
50
+ * this one is a copy of.
51
+ *
52
+ * `down` is pushed FORWARDS and read backwards, because assembly reverses it: the restore of a
53
+ * removed index therefore lands after the drop of everything created beside it.
54
+ */
55
+ export function indexPlan(
56
+ entity: EntityDescriptionLike,
57
+ live: TableDescription,
58
+ plan: Plan,
59
+ context: IndexPlanContext,
60
+ ): void {
61
+ const indexed = new Map(live.indexes.map((index) => [index.name, index]));
62
+ const declared = new Set<string>();
63
+ for (const index of declaredIndexes(entity)) {
64
+ declared.add(index.name);
65
+ const recorded = indexed.get(index.name);
66
+ // A rebuilt column took its indexes down with it, and a retype dropped the ones whose
67
+ // predicate it could not survive — either way this one is CREATED rather than compared:
68
+ // `redefineIndex` sees a definition that never moved and would emit nothing at all.
69
+ const gone =
70
+ context.moved.has(index.name) || index.columns.some((each) => context.rebuilt.has(each));
71
+ if (recorded !== undefined && !gone) {
72
+ redefineIndex(entity.table, index, recorded, plan);
73
+ continue;
74
+ }
75
+ // `added` only: an index over a column that was already there is implied by no clause this
76
+ // migration emits, so it still needs a statement of its own.
77
+ if (impliedByColumnClause(entity, index, context.added)) continue;
78
+ plan.up.push(createIndex(entity.table, index));
79
+ // The plain drop, always: this migration CREATED it, with `create index`, so it is an index
80
+ // and never a constraint's — `dropRecordedIndex`'s ambiguity is about the recorded side only.
81
+ plan.down.push(dropIndex(index.name));
82
+ }
83
+ removeUndeclared(entity, live, plan, context, declared);
84
+ }
85
+
86
+ /**
87
+ * Every recorded index this entity no longer declares, dropped — and restored in `down` from what
88
+ * the SNAPSHOT recorded, never from what the entity declares, since the entity is precisely what
89
+ * stopped describing it. The rule the retype path already states.
90
+ *
91
+ * Four names are skipped, and each one is a statement Postgres would refuse or repeat:
92
+ *
93
+ * | skipped | because |
94
+ * |------------------------------------------|---------|
95
+ * | `primary` | `drop index` on a primary key's index is 2BP01; the key is `TableDescription.primaryKey`, a different question |
96
+ * | already in `context.moved` | a retype dropped it ahead of the ALTER — a second drop is 42704 |
97
+ * | over a column in `context.rebuilt` | it went with the `drop column` half of `regenerate` — 42704 |
98
+ * | over a column this migration DROPS | `alter table … drop column` takes it, so a drop beside it says nothing new. The rule `foreignKeyPlan` applies to a constraint on a dropped column |
99
+ *
100
+ * A doomed TABLE needs no arm: `generate.ts` only reaches a diff for a table an entity still
101
+ * declares, so `drop table` and this function never meet.
102
+ */
103
+ function removeUndeclared(
104
+ entity: EntityDescriptionLike,
105
+ live: TableDescription,
106
+ plan: Plan,
107
+ context: IndexPlanContext,
108
+ declared: ReadonlySet<string>,
109
+ ): void {
110
+ const columns = new Set(entity.columns.map((column) => column.column));
111
+ for (const recorded of live.indexes) {
112
+ if (recorded.primary || declared.has(recorded.name)) continue;
113
+ if (context.moved.has(recorded.name)) continue;
114
+ if (recorded.columns.some((column) => context.rebuilt.has(column))) continue;
115
+ if (!recorded.columns.every((column) => columns.has(column))) continue;
116
+ plan.up.push(...dropRecordedIndex(live.name, recorded));
117
+ plan.down.push(createIndex(live.name, asDeclared(recorded)));
118
+ }
119
+ }
package/src/index.ts CHANGED
@@ -81,15 +81,9 @@ export {
81
81
  dbUnavailable,
82
82
  driverError,
83
83
  identifierUnsafe,
84
- migrateConcurrent,
85
- migrationConflict,
86
- migrationDestructive,
87
- migrationIrreversible,
88
- migrationSnapshotMissing,
89
84
  multipleStatements,
90
85
  poolAcquireTimeout,
91
86
  poolMaxInvalid,
92
- rollbackStepsInvalid,
93
87
  serializationExhausted,
94
88
  sqlUnsafe,
95
89
  } from './errors';
@@ -147,6 +141,15 @@ export {
147
141
  rollback,
148
142
  runningAppVersion,
149
143
  } from './migrate';
144
+ export {
145
+ migrateConcurrent,
146
+ migrationConflict,
147
+ migrationDestructive,
148
+ migrationIrreversible,
149
+ migrationSnapshotMissing,
150
+ migrationViewDepends,
151
+ rollbackStepsInvalid,
152
+ } from './migration-errors';
150
153
  export type { StatementAttribution, StatementEvent, StatementObserver } from './observe';
151
154
  export { setStatementObserver, statementObserver } from './observe';
152
155
  export type {
package/src/introspect.ts CHANGED
@@ -87,6 +87,24 @@ export interface TableDescription {
87
87
  * would leave every already-generated app's invariants unenforced forever.
88
88
  */
89
89
  readonly checks?: readonly CheckDescription[] | undefined;
90
+ /**
91
+ * The catalog's half of `checks`, and a **separate field rather than the same one** — names
92
+ * only, `conname` for `contype = 'c'`, never a definition.
93
+ *
94
+ * The two readings cannot share `checks` because they are not the same value. A catalog read
95
+ * carries Postgres' own rewriting of the predicate and a declaration carries this generator's
96
+ * spelling, so a `checks` filled from `pg_constraint` would put a rewritten expression on the
97
+ * field `checkPlan` diffs against a generated one — every regenerated migration would then drop
98
+ * and re-add every constraint in the app, forever, because the two strings can never be equal.
99
+ * Splitting them means the type says which reading a value came from, and `checkPlan` cannot be
100
+ * handed a catalog value by accident.
101
+ *
102
+ * `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
103
+ * alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody
104
+ * asked the catalog", which is what keeps `compareChecks` silent on a description that never
105
+ * read one instead of reporting every declared constraint as missing.
106
+ */
107
+ readonly checkNames?: readonly string[] | undefined;
90
108
  }
91
109
 
92
110
  export interface SchemaDescription {
@@ -135,6 +153,12 @@ interface ForeignKeyRow {
135
153
  readonly on_delete: string | null;
136
154
  }
137
155
 
156
+ /** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */
157
+ interface CheckRow {
158
+ readonly table_name: string;
159
+ readonly constraint_name: string;
160
+ }
161
+
138
162
  const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
139
163
 
140
164
  export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
@@ -206,7 +230,21 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
206
230
  order by src.relname, c.conname
207
231
  `);
208
232
 
209
- return buildSchema(schema, excluded, columns, indexes, foreignKeys);
233
+ // `conname` and nothing else. `pg_get_constraintdef(c.oid)` is one word further along this line
234
+ // and is the reason this query did not exist: it answers Postgres' rewriting of the predicate,
235
+ // which no generated spelling can ever equal, so reading it would make every drift check report
236
+ // a correct database as wrong. `contype = 'c'` is the CHECK constraints alone — Postgres 17
237
+ // onwards records a NOT NULL as `'n'`, and a domain's as `'c'` on the domain rather than here.
238
+ const checks = await client.query<CheckRow>(sql`
239
+ select src.relname as table_name, c.conname as constraint_name
240
+ from pg_constraint c
241
+ join pg_class src on src.oid = c.conrelid
242
+ join pg_namespace n on n.oid = src.relnamespace
243
+ where c.contype = 'c' and n.nspname = ${schema} and src.relkind = 'r'
244
+ order by src.relname, c.conname
245
+ `);
246
+
247
+ return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks);
210
248
  }
211
249
 
212
250
  /** Pure, so the row -> description mapping is testable without a database. */
@@ -216,6 +254,7 @@ export function buildSchema(
216
254
  columns: readonly ColumnRow[],
217
255
  indexes: readonly IndexRow[],
218
256
  foreignKeys: readonly ForeignKeyRow[],
257
+ checks: readonly CheckRow[] = [],
219
258
  ): SchemaDescription {
220
259
  const names = [...new Set(columns.map((row) => row.table_name))]
221
260
  .filter((name) => !excluded.includes(name))
@@ -261,6 +300,12 @@ export function buildSchema(
261
300
  onDelete: row.on_delete,
262
301
  }))
263
302
  .sort(byName),
303
+ // Always written, `[]` included: this reading of a table HAS asked the catalog, and absence
304
+ // is reserved for a description that has not (`compareChecks` is silent on that one).
305
+ checkNames: checks
306
+ .filter((row) => row.table_name === name)
307
+ .map((row) => row.constraint_name)
308
+ .sort(),
264
309
  };
265
310
  });
266
311
 
package/src/migrate.ts CHANGED
@@ -11,9 +11,10 @@ import {
11
11
  isReservable,
12
12
  poolProfileFor,
13
13
  } from './client';
14
- import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './errors';
14
+ import { refuseDependentViews } from './dependent-view';
15
15
  import { expectedQueryLoop } from './expected-loop';
16
16
  import type { SchemaDescription } from './introspect';
17
+ import { migrateConcurrent, migrationConflict, rollbackStepsInvalid } from './migration-errors';
17
18
  import { raw, sql } from './sql';
18
19
  import { SQLSTATE, sqlState } from './sqlstate';
19
20
  import { statementsOf } from './statement-split';
@@ -348,6 +349,12 @@ export async function migrate(options: MigrateOptions): Promise<MigrationReport>
348
349
  await withTransaction(
349
350
  async (tx) => {
350
351
  await setLockTimeout(tx, lockTimeoutMs);
352
+ // Before the first statement, never after the failure: a view compiled against a
353
+ // column this script retypes is `0A000` from the server with the view named only in
354
+ // a DETAIL field nothing prints, surfaced as "cannot reach the database". Costs one
355
+ // text scan and no round trip on a migration that retypes nothing, which is nearly
356
+ // all of them (`dependent-view.ts`).
357
+ await refuseDependentViews(tx, migration.up);
351
358
  await applyScript(tx, migration.up);
352
359
  const durationMs = Math.round(performance.now() - at);
353
360
  await tx.execute(sql`
@@ -438,6 +445,9 @@ export async function rollback(options: RollbackOptions): Promise<readonly strin
438
445
  await withTransaction(
439
446
  async (tx) => {
440
447
  await setLockTimeout(tx, lockTimeoutMs);
448
+ // Both directions: a reversal retypes the same column back, and a view created
449
+ // since the migration applied blocks it exactly as one created before would.
450
+ await refuseDependentViews(tx, migration.down);
441
451
  await applyScript(tx, migration.down);
442
452
  await tx.execute(sql`delete from ${raw(LEDGER_TABLE)} where id = ${row.id}`);
443
453
  },
@@ -0,0 +1,132 @@
1
+ // Single responsibility: the refusals a MIGRATION earns — the lock it could not take, a ledger that
2
+ // disagrees with this build, a plan that cannot be reversed, a plan that destroys rows without
3
+ // saying so, a snapshot that was never written, and a view standing in a retype's way. Split out of
4
+ // `errors.ts` only because that file reached the 500-line ceiling, exactly as `invariant-errors.ts`
5
+ // was: every code below is still declared, titled and registered there, and `DbError` is still the
6
+ // one class. One direction only — nothing here is imported back.
7
+
8
+ import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
9
+ import { DbError } from './errors';
10
+
11
+ /**
12
+ * The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
13
+ * no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
14
+ * left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
15
+ * failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
16
+ * exit code.
17
+ */
18
+ export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
19
+ new DbError({
20
+ code: 'X_MIGRATE_CONCURRENT',
21
+ cause:
22
+ `another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
23
+ 'so this migrator refused rather than block a deploy forever',
24
+ fix:
25
+ 'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
26
+ "join pg_locks using (pid) where locktype = 'advisory'\"" +
27
+ ' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
28
+ meta: { lockKey, waitedMs },
29
+ });
30
+
31
+ export const migrationConflict = (cause: string, fix: string): DbError =>
32
+ new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
33
+
34
+ export const migrationIrreversible = (cause: string, fix: string): DbError =>
35
+ new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
36
+
37
+ /**
38
+ * A view standing in the way of a retype, refused one statement before Postgres would have.
39
+ *
40
+ * `restore` is the caller's, the way `migrationIrreversible`'s `fix:` is: it is a pair of
41
+ * `psql "$DATABASE_URL" -c '…'` invocations — the shape `migrationConflict` above already writes —
42
+ * built out of live catalog values through `identifier()`. This file may not import `sql.ts`: that
43
+ * module imports `identifierUnsafe` from here, and an import cycle around the module whose
44
+ * evaluation REGISTERS every code is not a cycle worth having for one quoted name.
45
+ */
46
+ export const migrationViewDepends = (
47
+ view: string,
48
+ table: string,
49
+ column: string,
50
+ restore: string,
51
+ ): DbError =>
52
+ new DbError({
53
+ code: 'X_MIGRATION_VIEW_DEPENDS',
54
+ cause:
55
+ `view "${view}" is compiled against "${table}"."${column}", which this migration retypes; ` +
56
+ 'Postgres answers 0A000 and rolls the whole migration back',
57
+ fix: restore,
58
+ meta: { view, table, column },
59
+ });
60
+
61
+ /**
62
+ * A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
63
+ * negative count counts from the END: `steps: -1` selected every applied migration except the
64
+ * newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
65
+ * Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
66
+ * as a different one is the failure a validated argument exists to prevent.
67
+ */
68
+ export const rollbackStepsInvalid = (received: number): DbError =>
69
+ new DbError({
70
+ code: 'X_INVARIANT',
71
+ cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
72
+ fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
73
+ meta: { steps: received },
74
+ });
75
+
76
+ /**
77
+ * `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
78
+ * every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
79
+ * rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
80
+ * keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
81
+ */
82
+ const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
83
+
84
+ /**
85
+ * `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
86
+ * and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
87
+ * the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
88
+ */
89
+ const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
90
+
91
+ /**
92
+ * The sidecar every generated migration writes is what the *next* generation diffs against, so a
93
+ * newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
94
+ * schema, which would generate `create table` for every table the database already holds.
95
+ */
96
+ export const migrationSnapshotMissing = (id: string, file: string): DbError =>
97
+ new DbError({
98
+ code: 'X_MIGRATION_SNAPSHOT_MISSING',
99
+ cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
100
+ // Two remedies, both commands, in the order they are safe to try. "restore from version
101
+ // control" alone was neither: on a scaffolded app the sidecar was never written, so there is
102
+ // nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
103
+ // the two errors pointed at each other and an app's first migration had no way out.
104
+ // `x db gen` is named only *after* the files it would trip over are gone.
105
+ fix:
106
+ `git checkout -- ${file} # or, if it was never written: ` +
107
+ `rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
108
+ meta: { id, file },
109
+ });
110
+
111
+ /**
112
+ * One error per file, never one per statement: the marker declares the whole migration, so a
113
+ * second finding would repeat an instruction the first already gave. `file` is app-relative and
114
+ * arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
115
+ *
116
+ * Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
117
+ * a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
118
+ * destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
119
+ */
120
+ export const migrationDestructive = (
121
+ file: string,
122
+ first: DestructiveStatement,
123
+ more = 0,
124
+ ): DbError =>
125
+ new DbError({
126
+ code: 'X_MIGRATION_DESTRUCTIVE',
127
+ cause:
128
+ `${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
129
+ `${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
130
+ fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
131
+ meta: { file, kind: first.kind, statements: more + 1 },
132
+ });
@@ -0,0 +1,135 @@
1
+ // Single responsibility: which RECORDED objects a retype of one column breaks, and the statements
2
+ // that take them out of the way before the ALTER and put them back in the `down`.
3
+ //
4
+ // Postgres compiles a partial index's predicate and a CHECK's expression against the column's type
5
+ // at creation and cannot recompile either: `alter table "posts" alter column "status" type text
6
+ // using "status"::text` answers `42883 operator does not exist: text = post_status` and the
7
+ // migration aborts mid-run — inside `ROLE=migrate`, with the ledger recording nothing. Measured on
8
+ // Postgres 18.4 (`generate-retype.live.test.ts`), one dependent shape at a time:
9
+ //
10
+ // | recorded object | survives the ALTER |
11
+ // |------------------------------------------|--------------------|
12
+ // | btree on the column, plain or unique | yes — Postgres rebuilds it itself |
13
+ // | composite btree including the column | yes |
14
+ // | partial index whose predicate names it | **no — 42883** |
15
+ // | partial index naming another column | yes |
16
+ // | CHECK whose expression names it | **no — 42883** |
17
+ //
18
+ // So only an expression that MENTIONS the column is dependent, and dropping the rest would be a
19
+ // table scan per index for nothing.
20
+
21
+ import { addCheck, dropCheck } from './check-ddl';
22
+ import type { Plan } from './foreign-key-plan';
23
+ import { asDeclared, createIndex, dropIndex } from './index-ddl';
24
+ import type { CheckDescription, IndexDescription, TableDescription } from './introspect';
25
+ import { IDENTIFIER_PART, noiseAt } from './sql-scan';
26
+
27
+ /**
28
+ * Whether `expression` reads `column`, over-approximating on purpose.
29
+ *
30
+ * The two errors are not symmetrical. A dependent object missed is `42883` in the release phase;
31
+ * one reported that was not is a rebuild nobody asked for — so every ambiguous case answers `true`,
32
+ * and the folding is case-insensitive because Postgres folds an unquoted identifier to lower case
33
+ * and `"Status"` naming a different column is a rarity beside a predicate this must not miss.
34
+ *
35
+ * What it does NOT count is noise, through this package's one lexer (`sql-scan.ts`): the `status`
36
+ * in `where kind = 'status'` is data, not a reference, and the one in `-- status` is prose. A
37
+ * QUOTED identifier is counted — `"status"` is the reference the catalog stores for an author who
38
+ * quoted it, and skipping it as noise is exactly the miss that ends in `42883`.
39
+ */
40
+ export function referencesColumn(expression: string, column: string): boolean {
41
+ const wanted = column.toLowerCase();
42
+ let at = 0;
43
+ while (at < expression.length) {
44
+ const noise = noiseAt(expression, at);
45
+ if (noise !== null) {
46
+ if (
47
+ noise.kind === 'identifier' &&
48
+ expression.slice(at + 1, noise.end - 1).toLowerCase() === wanted
49
+ ) {
50
+ return true;
51
+ }
52
+ at = noise.end;
53
+ continue;
54
+ }
55
+ if (!IDENTIFIER_PART.test(expression[at] ?? '')) {
56
+ at += 1;
57
+ continue;
58
+ }
59
+ let end = at;
60
+ while (end < expression.length && IDENTIFIER_PART.test(expression[end] ?? '')) end += 1;
61
+ if (expression.slice(at, end).toLowerCase() === wanted) return true;
62
+ at = end;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /** The recorded objects a retype of `column` cannot leave in place. */
68
+ export interface RetypeDependents {
69
+ /**
70
+ * Partial indexes whose predicate reads the column. A `primary` one is structurally impossible —
71
+ * a primary key index has no predicate — which is what keeps `drop index` off the two indexes
72
+ * Postgres refuses it on: a primary key's and a unique constraint's.
73
+ */
74
+ readonly indexes: readonly IndexDescription[];
75
+ /** CHECK constraints whose expression reads the column. */
76
+ readonly checks: readonly CheckDescription[];
77
+ }
78
+
79
+ /**
80
+ * What a retype of `table`.`column` breaks, read off the RECORDED schema — never the catalog.
81
+ * `x db gen` runs with no database open, so a hand-added expression index over the same column is
82
+ * invisible here and still `42883`; what this can see is every object a migration wrote down.
83
+ */
84
+ export function retypeDependents(column: string, live: TableDescription): RetypeDependents {
85
+ return {
86
+ indexes: live.indexes.filter(
87
+ (index) => !index.primary && index.where !== null && referencesColumn(index.where, column),
88
+ ),
89
+ checks: (live.checks ?? []).filter((check) => referencesColumn(check.expression, column)),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * What this plan has already dropped ahead of a retype — names only, because that is all the two
95
+ * readers need. The ordinary diff runs AFTER the ALTER and must not act on an object that is no
96
+ * longer there: the index loop CREATES a name in `indexes` instead of comparing it (a `drop index`
97
+ * on a name already dropped is `42704`, and a definition that never moved would emit nothing at
98
+ * all, leaving the table with no index), and `checkPlan` neither drops nor re-adds a name in
99
+ * `checks` — the declared side is added back by its own arm, and a recorded constraint the entity
100
+ * no longer declares is simply gone, which is what `checkPlan` would have done to it anyway.
101
+ */
102
+ export interface MovedAside {
103
+ readonly indexes: Set<string>;
104
+ readonly checks: Set<string>;
105
+ }
106
+
107
+ /**
108
+ * Drop every dependent in `up`, restore it in `down`, and record what was moved.
109
+ *
110
+ * `down` is reversed at assembly, so the restores are pushed FORWARDS here and the retype's own
111
+ * reversal is pushed after them — the reversed script therefore reads: retype back to the old
112
+ * type, then recreate the objects that were compiled against it. Restoring first would recreate a
113
+ * predicate against a type the column no longer has, which is `42883` in the other direction.
114
+ *
115
+ * What is restored is what the snapshot RECORDED, never what the entity declares: an object still
116
+ * declared is re-created by the ordinary diff, one statement later, in its current shape.
117
+ */
118
+ export function moveDependentsAside(
119
+ live: TableDescription,
120
+ column: string,
121
+ plan: Plan,
122
+ moved: MovedAside,
123
+ ): void {
124
+ const dependents = retypeDependents(column, live);
125
+ for (const index of dependents.indexes) {
126
+ plan.up.push(dropIndex(index.name));
127
+ plan.down.push(createIndex(live.name, asDeclared(index)));
128
+ moved.indexes.add(index.name);
129
+ }
130
+ for (const check of dependents.checks) {
131
+ plan.up.push(dropCheck(live.name, check.name));
132
+ plan.down.push(addCheck(live.name, check));
133
+ moved.checks.add(check.name);
134
+ }
135
+ }