@ultimat3/db
Advanced tools
| // Single responsibility: what a schema difference is CALLED and what its `fix:` line says — one | ||
| // constructor per `DriftKind`, and nothing that compares anything. Split out of `drift.ts` at the | ||
| // 500-line ceiling, along the seam that file already drew: comparison decides *whether* two | ||
| // schemas disagree, and this decides how the disagreement reads. | ||
| // | ||
| // The rendered `X_DB_DRIFT` output is byte-for-byte pinned by the framework contract and | ||
| // duplicated in `@ultimat3/entity` — do not reword a `cause` without changing both. | ||
| // | ||
| // Two rules run through every one of them. A `fix:` is a command the reader can RUN: `x db | ||
| // migrate` where the migration has not been applied, and the statement itself where it has, since | ||
| // re-running the migrator applies nothing a ledger row already claims. And a difference names the | ||
| // declared side's own spelling, never the catalog's, because the catalog's is Postgres' rewriting. | ||
| import { onDeleteRule, rebuildForeignKey } from './foreign-key'; | ||
| import type { CheckDescription, ForeignKeyDescription } from './introspect'; | ||
| import type { Migration } from './migrate'; | ||
| export type DriftKind = | ||
| | 'unexpected-column' | ||
| | 'missing-column' | ||
| | 'changed-column' | ||
| | 'unexpected-table' | ||
| | 'missing-table' | ||
| | 'unknown-schema' | ||
| | 'missing-index' | ||
| | 'changed-index' | ||
| | 'missing-check' | ||
| | 'missing-foreign-key' | ||
| | 'changed-foreign-key'; | ||
| export interface DriftDifference { | ||
| readonly kind: DriftKind; | ||
| readonly table: string; | ||
| readonly column: string | null; | ||
| readonly cause: string; | ||
| readonly fix: string; | ||
| } | ||
| export interface DriftReport { | ||
| readonly ok: boolean; | ||
| readonly differences: readonly DriftDifference[]; | ||
| } | ||
| export function unexpectedColumn(table: string, column: string): DriftDifference { | ||
| return { | ||
| kind: 'unexpected-column', | ||
| table, | ||
| column, | ||
| // Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT. | ||
| cause: `table "${table}" has column "${column}" not present in any migration`, | ||
| fix: `x db gen "add ${column}"`, | ||
| }; | ||
| } | ||
| export function missingColumn(table: string, column: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-column', | ||
| table, | ||
| column, | ||
| cause: `table "${table}" is missing column "${column}" that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * The column exists on both sides and one of them lets it be `NULL`. | ||
| * | ||
| * This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT | ||
| * NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the | ||
| * strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing | ||
| * a human has to remember. Nobody did, and `compareTable` compared columns by name and by type | ||
| * while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever | ||
| * against an entity schema that said otherwise, with `ok: true` on every check. The first | ||
| * `undefined` write then lands as `NULL` and crashes three services away from the migration. | ||
| * | ||
| * `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a | ||
| * `set not null`, so naming it would send a reader to a command that generates an empty migration. | ||
| */ | ||
| export function changedColumn( | ||
| table: string, | ||
| column: string, | ||
| liveNullable: boolean, | ||
| ): DriftDifference { | ||
| const clause = liveNullable ? 'set not null' : 'drop not null'; | ||
| return { | ||
| kind: 'changed-column', | ||
| table, | ||
| column, | ||
| cause: liveNullable | ||
| ? `table "${table}" allows NULL in column "${column}" that migrations declare not null` | ||
| : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`, | ||
| fix: | ||
| `alter table "${table}" alter column "${column}" ${clause}; # in a new migration` + | ||
| (liveNullable ? ' — backfill the existing NULLs first' : ''), | ||
| }; | ||
| } | ||
| export function unexpectedTable(table: string): DriftDifference { | ||
| return { | ||
| kind: 'unexpected-table', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is not present in any migration`, | ||
| fix: `x db gen "add ${table}"`, | ||
| }; | ||
| } | ||
| export function missingTable(table: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-table', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is declared by migrations but does not exist`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * Not a difference between two schemas but the absence of one to compare against — reported | ||
| * through the same channel so it reaches an operator, since a check that quietly answered "clean" | ||
| * because it had nothing to check is the one failure mode drift detection cannot have. | ||
| */ | ||
| export function unknownSchema(migrations: readonly Migration[]): DriftDifference { | ||
| const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1); | ||
| return { | ||
| kind: 'unknown-schema', | ||
| table: '', | ||
| column: null, | ||
| cause: | ||
| `migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` + | ||
| 'cannot be established', | ||
| // The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is | ||
| // the same condition. It used to lead with `x db gen`, which raises that error and whose own | ||
| // fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The | ||
| // pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory. | ||
| fix: | ||
| `git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` + | ||
| `delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`, | ||
| }; | ||
| } | ||
| export function missingIndex(table: string, index: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-index', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is missing index "${index}" that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| export function changedIndex(table: string, index: string, detail: string): DriftDifference { | ||
| return { | ||
| kind: 'changed-index', | ||
| table, | ||
| column: null, | ||
| cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * A CHECK a migration declares and the catalog does not hold. | ||
| * | ||
| * There is no `changed-check` beside it and there never will be, for the reason | ||
| * `IndexDescription.where` gives: `pg_get_constraintdef` answers Postgres' own rewriting — | ||
| * `status in ('draft','published')` reads back as `CHECK ((status = ANY (ARRAY['draft'::text, | ||
| * 'published'::text])))` — so a text comparison reports drift on a correct database forever, and | ||
| * normalising it is an expression parser competing with the server's. Presence is not text. | ||
| * | ||
| * The `fix` is the statement, not `x db migrate`: the migration that declares this constraint is | ||
| * already in the ledger, so re-running the migrator applies nothing. Same reasoning as | ||
| * `changedColumn` and `changedForeignKey` — the declared side holds the author's own spelling of | ||
| * the predicate, which is what makes an executable fix possible at all. | ||
| */ | ||
| export function missingCheck(table: string, check: CheckDescription): DriftDifference { | ||
| return { | ||
| kind: 'missing-check', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is missing check constraint "${check.name}" that migrations declare`, | ||
| // The command rides on the same line as the statement, and not only because `check` is a | ||
| // banned advice word the `errors` gate demands a command beside: writing the migration is half | ||
| // the repair and applying it is the other half, and `changedColumn`'s bare `# in a new | ||
| // migration` leaves the second half to be guessed. | ||
| fix: | ||
| `alter table "${table}" add constraint "${check.name}" ` + | ||
| `check (${check.expression}); # in a new migration, then x db migrate`, | ||
| }; | ||
| } | ||
| export function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference { | ||
| return { | ||
| kind: 'missing-foreign-key', | ||
| table, | ||
| column: null, | ||
| cause: | ||
| `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` + | ||
| `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * The key points where it was declared to point and one side's `on delete` rule is not the other's | ||
| * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is | ||
| * there, and what changed is what happens to the child rows. | ||
| * | ||
| * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint` | ||
| * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so | ||
| * naming a command would send a reader to one that generates an empty migration. Same reasoning | ||
| * as `changedColumn`. | ||
| */ | ||
| export function changedForeignKey( | ||
| table: string, | ||
| declared: ForeignKeyDescription, | ||
| held: ForeignKeyDescription, | ||
| ): DriftDifference { | ||
| const rule = onDeleteRule(held.onDelete); | ||
| return { | ||
| kind: 'changed-foreign-key', | ||
| table, | ||
| column: null, | ||
| cause: | ||
| `foreign key on "${table}" (${declared.columns.join(', ')}) to ` + | ||
| `"${declared.referencedTable}" ` + | ||
| `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` + | ||
| 'migrations declare', | ||
| fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`, | ||
| }; | ||
| } |
+137
| // Single responsibility: the DDL an entity's INDEX declaration becomes — the `create index` a | ||
| // declaration writes out, what makes two definitions the same index, and what a moved definition | ||
| // rebuilds. Split out of `generate.ts` at the 500-line ceiling, along the seam `check-ddl.ts` and | ||
| // `generated-column.ts` already drew: `generate.ts` assembles a plan, this file writes the index | ||
| // statements it puts in it, and `invariant-ddl.ts` decides which indexes a table declares. | ||
| import { assert } from '@ultimat3/core'; | ||
| import type { EntityDescriptionLike, IndexDescriptionLike } from './entity-shape'; | ||
| import type { Plan } from './foreign-key-plan'; | ||
| import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method'; | ||
| import type { IndexDescription } from './introspect'; | ||
| import { identifier } from './sql'; | ||
| /** | ||
| * A `unique` column clause already creates an index, and Postgres names it exactly what the | ||
| * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for | ||
| * it too is the same index twice: `42P07`, and a migration that cannot be applied at all. | ||
| * Mirrors the rule `entity()` already applies to a foreign key indexing its own column. | ||
| * | ||
| * A **partial** unique index is not that index: the column clause constrains every row, so | ||
| * skipping the partial one would silently widen the constraint the entity declared. | ||
| */ | ||
| export function impliedByColumnClause( | ||
| entity: EntityDescriptionLike, | ||
| index: IndexDescriptionLike, | ||
| added: ReadonlySet<string>, | ||
| ): boolean { | ||
| const [only] = index.columns; | ||
| if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) { | ||
| return false; | ||
| } | ||
| const column = entity.columns.find((each) => each.column === only); | ||
| // `columnClause` writes `unique` under exactly this condition — keep the two in step. | ||
| // | ||
| // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is | ||
| // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix | ||
| // unsafe for exactly this reason — applying it turned a green typecheck red. | ||
| // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined | ||
| return column !== undefined && column.unique && !column.primaryKey && added.has(only); | ||
| } | ||
| /** | ||
| * Every part of the declaration reaches the statement: the whole column list in its declared | ||
| * order, the direction when one was asked for, and the predicate that makes it partial. A part | ||
| * dropped here is a constraint the database does not hold or an index the planner cannot use. | ||
| */ | ||
| export function createIndex(table: string, index: IndexDescriptionLike): string { | ||
| assert( | ||
| index.columns.length > 0, | ||
| `index "${index.name}" on "${table}" names no columns`, | ||
| `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`, | ||
| ); | ||
| const method = index.using ?? 'btree'; | ||
| // Two rules Postgres has and a declaration can break, refused here rather than at migrate time: | ||
| // GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server | ||
| // as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words | ||
| // and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an | ||
| // index naming no columns: a declaration this build cannot honour is refused, never reinterpreted. | ||
| assert( | ||
| method === 'btree' || !index.unique, | ||
| `index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`, | ||
| `indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`, | ||
| ); | ||
| assert( | ||
| method === 'btree' || index.order === null, | ||
| `index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`, | ||
| `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`, | ||
| ); | ||
| const kind = index.unique ? 'create unique index' : 'create index'; | ||
| const direction = index.order === null ? '' : ` ${index.order}`; | ||
| const columns = index.columns | ||
| .map((column) => `${identifier(column).text}${direction}`) | ||
| .join(', '); | ||
| const predicate = index.where === null ? '' : ` where (${index.where})`; | ||
| // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so | ||
| // an index that declared no method emits the statement this generator always emitted, byte for | ||
| // byte, and one that declared a method Postgres does not have is refused instead of built. | ||
| return ( | ||
| `${kind} ${identifier(index.name).text} on ${identifier(table).text}` + | ||
| `${indexMethodSql(method)} (${columns})${predicate};` | ||
| ); | ||
| } | ||
| /** `drop index "n";` — the one spelling, so a drop and its recreate cannot name it differently. */ | ||
| export const dropIndex = (name: string): string => `drop index ${identifier(name).text};`; | ||
| /** | ||
| * A RECORDED index as a declaration this generator can emit again — `declaredMethod`, never a | ||
| * cast: the recorded side is typed open because the catalog shares the shape, and a method this | ||
| * generator cannot write must refuse rather than be rebuilt as a btree. One copy, because | ||
| * `redefineIndex`'s `down` and `retype-dependents.ts`'s restore ask the same question. | ||
| */ | ||
| export function asDeclared(index: IndexDescription): IndexDescriptionLike { | ||
| return { | ||
| name: index.name, | ||
| columns: index.columns, | ||
| unique: index.unique, | ||
| where: index.where, | ||
| order: index.order, | ||
| ...(index.using === undefined ? {} : { using: declaredMethod(index.using) }), | ||
| }; | ||
| } | ||
| /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */ | ||
| export function indexShape(index: IndexDescriptionLike | IndexDescription): string { | ||
| return JSON.stringify([ | ||
| [...index.columns], | ||
| index.unique, | ||
| index.where, | ||
| index.order ?? null, | ||
| indexMethodOf(index), | ||
| ]); | ||
| } | ||
| /** | ||
| * A same-named index whose definition moved is dropped and recreated, because Postgres has no | ||
| * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction | ||
| * are all fixed at creation. | ||
| * | ||
| * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an | ||
| * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty | ||
| * migration and the database kept serving the old one. Both sides here are *generated* spellings | ||
| * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a | ||
| * text difference in `where` is a real change and not a formatting one. | ||
| */ | ||
| export function redefineIndex( | ||
| table: string, | ||
| index: IndexDescriptionLike, | ||
| recorded: IndexDescription, | ||
| plan: Plan, | ||
| ): void { | ||
| if (indexShape(index) === indexShape(recorded)) return; | ||
| plan.up.push(dropIndex(index.name), createIndex(table, index)); | ||
| // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating | ||
| // the recorded definition is what must land last, after the new one is dropped. | ||
| plan.down.push(createIndex(table, asDeclared(recorded)), dropIndex(index.name)); | ||
| } |
| // Single responsibility: which RECORDED objects a retype of one column breaks, and the statements | ||
| // that take them out of the way before the ALTER and put them back in the `down`. | ||
| // | ||
| // Postgres compiles a partial index's predicate and a CHECK's expression against the column's type | ||
| // at creation and cannot recompile either: `alter table "posts" alter column "status" type text | ||
| // using "status"::text` answers `42883 operator does not exist: text = post_status` and the | ||
| // migration aborts mid-run — inside `ROLE=migrate`, with the ledger recording nothing. Measured on | ||
| // Postgres 18.4 (`generate-retype.live.test.ts`), one dependent shape at a time: | ||
| // | ||
| // | recorded object | survives the ALTER | | ||
| // |------------------------------------------|--------------------| | ||
| // | btree on the column, plain or unique | yes — Postgres rebuilds it itself | | ||
| // | composite btree including the column | yes | | ||
| // | partial index whose predicate names it | **no — 42883** | | ||
| // | partial index naming another column | yes | | ||
| // | CHECK whose expression names it | **no — 42883** | | ||
| // | ||
| // So only an expression that MENTIONS the column is dependent, and dropping the rest would be a | ||
| // table scan per index for nothing. | ||
| import { addCheck, dropCheck } from './check-ddl'; | ||
| import type { Plan } from './foreign-key-plan'; | ||
| import { asDeclared, createIndex, dropIndex } from './index-ddl'; | ||
| import type { CheckDescription, IndexDescription, TableDescription } from './introspect'; | ||
| import { IDENTIFIER_PART, noiseAt } from './sql-scan'; | ||
| /** | ||
| * Whether `expression` reads `column`, over-approximating on purpose. | ||
| * | ||
| * The two errors are not symmetrical. A dependent object missed is `42883` in the release phase; | ||
| * one reported that was not is a rebuild nobody asked for — so every ambiguous case answers `true`, | ||
| * and the folding is case-insensitive because Postgres folds an unquoted identifier to lower case | ||
| * and `"Status"` naming a different column is a rarity beside a predicate this must not miss. | ||
| * | ||
| * What it does NOT count is noise, through this package's one lexer (`sql-scan.ts`): the `status` | ||
| * in `where kind = 'status'` is data, not a reference, and the one in `-- status` is prose. A | ||
| * QUOTED identifier is counted — `"status"` is the reference the catalog stores for an author who | ||
| * quoted it, and skipping it as noise is exactly the miss that ends in `42883`. | ||
| */ | ||
| export function referencesColumn(expression: string, column: string): boolean { | ||
| const wanted = column.toLowerCase(); | ||
| let at = 0; | ||
| while (at < expression.length) { | ||
| const noise = noiseAt(expression, at); | ||
| if (noise !== null) { | ||
| if ( | ||
| noise.kind === 'identifier' && | ||
| expression.slice(at + 1, noise.end - 1).toLowerCase() === wanted | ||
| ) { | ||
| return true; | ||
| } | ||
| at = noise.end; | ||
| continue; | ||
| } | ||
| if (!IDENTIFIER_PART.test(expression[at] ?? '')) { | ||
| at += 1; | ||
| continue; | ||
| } | ||
| let end = at; | ||
| while (end < expression.length && IDENTIFIER_PART.test(expression[end] ?? '')) end += 1; | ||
| if (expression.slice(at, end).toLowerCase() === wanted) return true; | ||
| at = end; | ||
| } | ||
| return false; | ||
| } | ||
| /** The recorded objects a retype of `column` cannot leave in place. */ | ||
| export interface RetypeDependents { | ||
| /** | ||
| * Partial indexes whose predicate reads the column. A `primary` one is structurally impossible — | ||
| * a primary key index has no predicate — which is what keeps `drop index` off the two indexes | ||
| * Postgres refuses it on: a primary key's and a unique constraint's. | ||
| */ | ||
| readonly indexes: readonly IndexDescription[]; | ||
| /** CHECK constraints whose expression reads the column. */ | ||
| readonly checks: readonly CheckDescription[]; | ||
| } | ||
| /** | ||
| * What a retype of `table`.`column` breaks, read off the RECORDED schema — never the catalog. | ||
| * `x db gen` runs with no database open, so a hand-added expression index over the same column is | ||
| * invisible here and still `42883`; what this can see is every object a migration wrote down. | ||
| */ | ||
| export function retypeDependents(column: string, live: TableDescription): RetypeDependents { | ||
| return { | ||
| indexes: live.indexes.filter( | ||
| (index) => !index.primary && index.where !== null && referencesColumn(index.where, column), | ||
| ), | ||
| checks: (live.checks ?? []).filter((check) => referencesColumn(check.expression, column)), | ||
| }; | ||
| } | ||
| /** | ||
| * What this plan has already dropped ahead of a retype — names only, because that is all the two | ||
| * readers need. The ordinary diff runs AFTER the ALTER and must not act on an object that is no | ||
| * longer there: the index loop CREATES a name in `indexes` instead of comparing it (a `drop index` | ||
| * on a name already dropped is `42704`, and a definition that never moved would emit nothing at | ||
| * all, leaving the table with no index), and `checkPlan` neither drops nor re-adds a name in | ||
| * `checks` — the declared side is added back by its own arm, and a recorded constraint the entity | ||
| * no longer declares is simply gone, which is what `checkPlan` would have done to it anyway. | ||
| */ | ||
| export interface MovedAside { | ||
| readonly indexes: Set<string>; | ||
| readonly checks: Set<string>; | ||
| } | ||
| /** | ||
| * Drop every dependent in `up`, restore it in `down`, and record what was moved. | ||
| * | ||
| * `down` is reversed at assembly, so the restores are pushed FORWARDS here and the retype's own | ||
| * reversal is pushed after them — the reversed script therefore reads: retype back to the old | ||
| * type, then recreate the objects that were compiled against it. Restoring first would recreate a | ||
| * predicate against a type the column no longer has, which is `42883` in the other direction. | ||
| * | ||
| * What is restored is what the snapshot RECORDED, never what the entity declares: an object still | ||
| * declared is re-created by the ordinary diff, one statement later, in its current shape. | ||
| */ | ||
| export function moveDependentsAside( | ||
| live: TableDescription, | ||
| column: string, | ||
| plan: Plan, | ||
| moved: MovedAside, | ||
| ): void { | ||
| const dependents = retypeDependents(column, live); | ||
| for (const index of dependents.indexes) { | ||
| plan.up.push(dropIndex(index.name)); | ||
| plan.down.push(createIndex(live.name, asDeclared(index))); | ||
| moved.indexes.add(index.name); | ||
| } | ||
| for (const check of dependents.checks) { | ||
| plan.up.push(dropCheck(live.name, check.name)); | ||
| plan.down.push(addCheck(live.name, check)); | ||
| moved.checks.add(check.name); | ||
| } | ||
| } |
+2
-2
| { | ||
| "name": "@ultimat3/db", | ||
| "version": "14.0.0", | ||
| "version": "15.0.0", | ||
| "description": "Postgres access, transactions, migrations and drift detection", | ||
@@ -34,3 +34,3 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "14.0.0" | ||
| "@ultimat3/core": "15.0.0" | ||
| }, | ||
@@ -37,0 +37,0 @@ "peerDependencies": { |
+13
-2
@@ -39,3 +39,3 @@ # @ultimat3/db 🐘 | ||
| | `isPlainRead()` | `As of 2026-08-24`: whether a statement may leave the primary. An allow-list — everything it cannot vouch for is the primary's | | ||
| | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (access method `As of 2026-08-24`, columns, uniqueness, direction, and whether a predicate is there at all — never its text) and declared foreign keys, matched on where the key points and not on its constraint name, with the `on delete` rule compared through one normalisation `As of 2026-08-19` | | ||
| | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (access method `As of 2026-08-24`, columns, uniqueness, direction, and whether a predicate is there at all — never its text), declared CHECK constraints by NAME (`missing-check`, `As of 2026-08-25` — never a predicate, which the catalog answers rewritten) and declared foreign keys, matched on where the key points and not on its constraint name, with the `on delete` rule compared through one normalisation `As of 2026-08-19` | | ||
| | `declaredSchema()` / `expectedSchema()` | `As of 2026-08`: the schema the migrations write down, or `undefined` when the newest one carries no snapshot — never an older snapshot standing in for it | | ||
@@ -46,3 +46,3 @@ | `parseSnapshot()` | `As of 2026-08`: a `<id>.snapshot.json` sidecar validated to the last nested field, or `undefined`. `{"tables":[null]}` is valid JSON and is not a schema | | ||
| | `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift | | ||
| | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all | | ||
| | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all. `As of 2026-08-25` a **retype** drops the partial indexes and CHECK constraints written against that column first and restores them in `down`: Postgres compiles both predicates against the old type and cannot recompile either, so `alter column … type text using …::text` was `42883 operator does not exist: text = post_status` and the migration aborted mid-run. A plain btree over the column is left alone — measured, Postgres rebuilds that one itself | | ||
| | `declaredIndexes()` / `invariantChecks()` / `constraintNameFor()` | `As of 2026-08-25`: the DDL an entity **invariant** becomes — a `check` as a named `CONSTRAINT`, a `unique` as a partial-capable unique INDEX, an `assert` as nothing. `EntityDescriptionLike` had no `invariants` field for three majors, so a regenerated migration silently held **none** of them, including the composite UNIQUE `upsertAll`'s `on conflict` is inferred against | | ||
@@ -190,2 +190,13 @@ | `declaredChecks()` / `checkClauses()` / `checkPlan()` / `columnChecks()` / `columnCheckName()` / `columnNamesConstraint()` | `As of 2026-08-25`: **every** CHECK a table declares — a column's own (`enumerated()`'s value set, `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound) and an invariant's — on ONE list, so `createTable`, `diffTable` and `snapshotOf` agree about what exists. A column's check reached `create table` **inline and anonymous** and nothing else: the snapshot recorded none and the diff had no arm, so a value added to `enumerated()` generated no migration and a regenerated ENUM column came back as bare `text`. The name is `<table>_<column>_check` because that is the name **Postgres itself mints** for the old anonymous form — measured — so the repair lands on the constraint an already-generated database is holding; `checkPlan` emits `drop constraint if exists` before the `add` for exactly that column, because a bare add is `42710` there and a no-op everywhere else | | ||
| **A CHECK the catalog no longer holds is drift, `As of 2026-08-25` — by NAME.** | ||
| `pg_get_constraintdef` answers Postgres' own rewriting (`status in ('draft', 'published')` reads | ||
| back as `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`), so the catalog side is | ||
| read as `conname` alone and lands on `TableDescription.checkNames`, a **separate field** from the | ||
| declaration's `checks`. Only the declared side is judged, so a NOT NULL, an `enumerated()` column's | ||
| old anonymous form and an extension's own constraint are all silent; a declared one the catalog | ||
| does not hold is `missing-check`, whose `fix:` is the `add constraint` statement itself, because the | ||
| migration that declares it is already in the ledger and `x db migrate` would apply nothing. There is | ||
| no `changed-check`: presence is a boolean, a predicate is text, and normalising the text is an | ||
| expression parser competing with the server's. | ||
| **Nor is a relation an extension owns, `As of 2026-08-24`.** `create extension pg_stat_statements` | ||
@@ -192,0 +203,0 @@ in `public` is the CNPG, RDS, Supabase and Neon default, and its view read as `unexpected-table` |
+17
-5
@@ -128,7 +128,9 @@ // Single responsibility: every CHECK constraint a table declares — a COLUMN's own and an | ||
| const addCheck = (table: string, check: CheckDescription): string => | ||
| /** Exported for `retype-dependents.ts`: a constraint moved out of a retype's way is put back by | ||
| * the same statement that would have added it, never by a second spelling of `add constraint`. */ | ||
| export const addCheck = (table: string, check: CheckDescription): string => | ||
| `alter table ${identifier(table).text} add constraint ${identifier(check.name).text} ` + | ||
| `check (${check.expression});`; | ||
| const dropCheck = (table: string, name: string): string => | ||
| export const dropCheck = (table: string, name: string): string => | ||
| `alter table ${identifier(table).text} drop constraint ${identifier(name).text};`; | ||
@@ -165,2 +167,10 @@ | ||
| * one level in. | ||
| * | ||
| * `predropped` names the CONSTRAINTS this plan already dropped, ahead of a retype whose predicate | ||
| * they were compiled against (`retype-dependents.ts`). Two arms read it and both are about a name | ||
| * that is provably free: a declared one takes the bare `add constraint` rather than the | ||
| * drop-if-exists pair, and a recorded one the entity no longer declares is left alone entirely — | ||
| * `drop constraint` on it a second time is `42704`, and its `down` belongs to the retype that | ||
| * moved it. Keyed by name and not by column because an INVARIANT's check reads a column without | ||
| * being derived from one, which is exactly the constraint `examples/dummy` retypes under. | ||
| */ | ||
@@ -172,2 +182,3 @@ export function checkPlan( | ||
| rebuilt: ReadonlySet<string> = new Set(), | ||
| predropped: ReadonlySet<string> = new Set(), | ||
| ): void { | ||
@@ -195,6 +206,7 @@ const recorded = new Map((live.checks ?? []).map((check) => [check.name, check])); | ||
| for (const check of wanted) { | ||
| const held = dropped.has(check.name) ? undefined : recorded.get(check.name); | ||
| const gone = dropped.has(check.name) || predropped.has(check.name); | ||
| const held = gone ? undefined : recorded.get(check.name); | ||
| if (held === undefined) { | ||
| plan.up.push( | ||
| ...(exposed.has(check.name) | ||
| ...(exposed.has(check.name) && !predropped.has(check.name) | ||
| ? rebuildCheck(entity.table, check) | ||
@@ -212,3 +224,3 @@ : [addCheck(entity.table, check)]), | ||
| for (const check of live.checks ?? []) { | ||
| if (declared.has(check.name)) continue; | ||
| if (declared.has(check.name) || predropped.has(check.name)) continue; | ||
| plan.up.push(dropCheck(entity.table, check.name)); | ||
@@ -215,0 +227,0 @@ plan.down.push(addCheck(entity.table, check)); |
+47
-182
@@ -7,34 +7,26 @@ // Single responsibility: prove the live schema and the migration ledger agree. Drift is the | ||
| import { baseClient, type DbClient } from './client'; | ||
| import type { DriftDifference } from './drift-findings'; | ||
| import { | ||
| changedColumn, | ||
| changedForeignKey, | ||
| changedIndex, | ||
| missingCheck, | ||
| missingColumn, | ||
| missingForeignKey, | ||
| missingIndex, | ||
| missingTable, | ||
| unexpectedColumn, | ||
| unexpectedTable, | ||
| unknownSchema, | ||
| } from './drift-findings'; | ||
| import { DbError } from './errors'; | ||
| import { foreignKeyTarget, onDeleteRule, rebuildForeignKey } from './foreign-key'; | ||
| import { foreignKeyTarget, onDeleteRule } from './foreign-key'; | ||
| import { indexMethodOf } from './index-method'; | ||
| import { | ||
| type ForeignKeyDescription, | ||
| findTable, | ||
| introspect, | ||
| type SchemaDescription, | ||
| type TableDescription, | ||
| } from './introspect'; | ||
| import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect'; | ||
| import { type LedgerRow, type Migration, readLedger } from './migrate'; | ||
| export type DriftKind = | ||
| | 'unexpected-column' | ||
| | 'missing-column' | ||
| | 'changed-column' | ||
| | 'unexpected-table' | ||
| | 'missing-table' | ||
| | 'unknown-schema' | ||
| | 'missing-index' | ||
| | 'changed-index' | ||
| | 'missing-foreign-key' | ||
| | 'changed-foreign-key'; | ||
| // Re-exported explicitly, never `export *`: `src/index.ts` publishes both from `'./drift'`, so the | ||
| // split is invisible to `@ultimat3/db`'s public surface and no consumer moves with it. | ||
| export type { DriftDifference, DriftKind } from './drift-findings'; | ||
| export interface DriftDifference { | ||
| readonly kind: DriftKind; | ||
| readonly table: string; | ||
| readonly column: string | null; | ||
| readonly cause: string; | ||
| readonly fix: string; | ||
| } | ||
| export interface DriftReport { | ||
@@ -45,158 +37,3 @@ readonly ok: boolean; | ||
| function unexpectedColumn(table: string, column: string): DriftDifference { | ||
| return { | ||
| kind: 'unexpected-column', | ||
| table, | ||
| column, | ||
| // Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT. | ||
| cause: `table "${table}" has column "${column}" not present in any migration`, | ||
| fix: `x db gen "add ${column}"`, | ||
| }; | ||
| } | ||
| function missingColumn(table: string, column: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-column', | ||
| table, | ||
| column, | ||
| cause: `table "${table}" is missing column "${column}" that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * The column exists on both sides and one of them lets it be `NULL`. | ||
| * | ||
| * This is the finding the expand/contract flow needs and never had. `generate.ts` emits a `NOT | ||
| * NULL` add as nullable plus a `-- backfill "c", then: … set not null;` comment, because the | ||
| * strict version cannot succeed on a populated table — and phase 2 is a comment, so it is a thing | ||
| * a human has to remember. Nobody did, and `compareTable` compared columns by name and by type | ||
| * while `snapshotOf` had recorded `nullable` all along, so the column stayed nullable forever | ||
| * against an entity schema that said otherwise, with `ok: true` on every check. The first | ||
| * `undefined` write then lands as `NULL` and crashes three services away from the migration. | ||
| * | ||
| * `x db gen` is deliberately not the fix: it diffs types and indexes and has never emitted a | ||
| * `set not null`, so naming it would send a reader to a command that generates an empty migration. | ||
| */ | ||
| function changedColumn(table: string, column: string, liveNullable: boolean): DriftDifference { | ||
| const clause = liveNullable ? 'set not null' : 'drop not null'; | ||
| return { | ||
| kind: 'changed-column', | ||
| table, | ||
| column, | ||
| cause: liveNullable | ||
| ? `table "${table}" allows NULL in column "${column}" that migrations declare not null` | ||
| : `table "${table}" forbids NULL in column "${column}" that migrations declare nullable`, | ||
| fix: | ||
| `alter table "${table}" alter column "${column}" ${clause}; # in a new migration` + | ||
| (liveNullable ? ' — backfill the existing NULLs first' : ''), | ||
| }; | ||
| } | ||
| function unexpectedTable(table: string): DriftDifference { | ||
| return { | ||
| kind: 'unexpected-table', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is not present in any migration`, | ||
| fix: `x db gen "add ${table}"`, | ||
| }; | ||
| } | ||
| function missingTable(table: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-table', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is declared by migrations but does not exist`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * Not a difference between two schemas but the absence of one to compare against — reported | ||
| * through the same channel so it reaches an operator, since a check that quietly answered "clean" | ||
| * because it had nothing to check is the one failure mode drift detection cannot have. | ||
| */ | ||
| function unknownSchema(migrations: readonly Migration[]): DriftDifference { | ||
| const newest = [...migrations].sort((a, b) => (a.id < b.id ? -1 : 1)).at(-1); | ||
| return { | ||
| kind: 'unknown-schema', | ||
| table: '', | ||
| column: null, | ||
| cause: | ||
| `migration "${newest?.id ?? ''}" records no schema snapshot, so what this database owes ` + | ||
| 'cannot be established', | ||
| // The same two remedies `X_MIGRATION_SNAPSHOT_MISSING` names, in the same order, because it is | ||
| // the same condition. It used to lead with `x db gen`, which raises that error and whose own | ||
| // fix pointed back here — a cycle a scaffolded app hit on its first `x db migrate`. The | ||
| // pathspec is a glob because this package is tier 1: only `@ultimat3/cli` knows the directory. | ||
| fix: | ||
| `git checkout -- "*${newest?.id ?? ''}.snapshot.json" # or, if it was never written: ` + | ||
| `delete migration "${newest?.id ?? ''}" and rerun x db gen "${newest?.name ?? 'initial'}"`, | ||
| }; | ||
| } | ||
| function missingIndex(table: string, index: string): DriftDifference { | ||
| return { | ||
| kind: 'missing-index', | ||
| table, | ||
| column: null, | ||
| cause: `table "${table}" is missing index "${index}" that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| function changedIndex(table: string, index: string, detail: string): DriftDifference { | ||
| return { | ||
| kind: 'changed-index', | ||
| table, | ||
| column: null, | ||
| cause: `index "${index}" on "${table}" ${detail}, not what migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| function missingForeignKey(table: string, key: ForeignKeyDescription): DriftDifference { | ||
| return { | ||
| kind: 'missing-foreign-key', | ||
| table, | ||
| column: null, | ||
| cause: | ||
| `table "${table}" has no foreign key on (${key.columns.join(', ')}) to ` + | ||
| `"${key.referencedTable}" (${key.referencedColumns.join(', ')}) that migrations declare`, | ||
| fix: 'x db migrate', | ||
| }; | ||
| } | ||
| /** | ||
| * The key points where it was declared to point and one side's `on delete` rule is not the other's | ||
| * — reported apart from `missing-foreign-key` because it is a different repair: the constraint is | ||
| * there, and what changed is what happens to the child rows. | ||
| * | ||
| * The `fix` is the pair, not `x db migrate`: a rule cannot be altered in place, `add constraint` | ||
| * alone is `42710` on a name already taken, and no `x db gen` diff emits either statement, so | ||
| * naming a command would send a reader to one that generates an empty migration. Same reasoning | ||
| * as `changedColumn`. | ||
| */ | ||
| function changedForeignKey( | ||
| table: string, | ||
| declared: ForeignKeyDescription, | ||
| held: ForeignKeyDescription, | ||
| ): DriftDifference { | ||
| const rule = onDeleteRule(held.onDelete); | ||
| return { | ||
| kind: 'changed-foreign-key', | ||
| table, | ||
| column: null, | ||
| cause: | ||
| `foreign key on "${table}" (${declared.columns.join(', ')}) to ` + | ||
| `"${declared.referencedTable}" ` + | ||
| `${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` + | ||
| 'migrations declare', | ||
| fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`, | ||
| }; | ||
| } | ||
| /** | ||
| * Indexes migrations declare, against the ones the catalog holds — by column list and by | ||
@@ -320,2 +157,29 @@ * uniqueness, which is what caught a composite index rebuilt with its columns the other way round | ||
| /** | ||
| * CHECK constraints migrations declare, against the NAMES the catalog holds — `checkNames`, which | ||
| * is a separate field from `checks` precisely so this comparison cannot reach a definition it must | ||
| * not read (`introspect.ts`). | ||
| * | ||
| * Two absences, and they mean opposite things. `expected.checks` absent is a sidecar written | ||
| * before constraints were recorded: it declares nothing, so nothing can be missing. `live.checkNames` | ||
| * absent is a description that never asked the catalog — a stub, a fake client's rows, a | ||
| * `TableDescription` built by hand — and reading that as "the database holds none" is one finding | ||
| * per declared constraint against a database nobody looked at. `introspect()` always answers with | ||
| * the field, `[]` included, so a real read is never mistaken for an unread one. | ||
| * | ||
| * Only the declared side is judged, the rule `compareIndexes` and `compareForeignKeys` both state: | ||
| * a NOT NULL, an `enumerated()` column's old anonymous form, a constraint an extension brought and | ||
| * every hand-written CHECK an app has ever added would each be a finding against a database that | ||
| * is exactly right. | ||
| */ | ||
| function compareChecks(live: TableDescription, expected: TableDescription): DriftDifference[] { | ||
| const declared = expected.checks; | ||
| const held = live.checkNames; | ||
| if (declared === undefined || held === undefined) return []; | ||
| const present = new Set(held); | ||
| return declared | ||
| .filter((check) => !present.has(check.name)) | ||
| .map((check) => missingCheck(live.name, check)); | ||
| } | ||
| /** | ||
| * A primary key column is `NOT NULL` in the catalog whether or not anything declared it — Postgres | ||
@@ -356,2 +220,3 @@ * adds the constraint with the key. Both sides are therefore read through the union of the two | ||
| differences.push(...compareIndexes(live, expected)); | ||
| differences.push(...compareChecks(live, expected)); | ||
| differences.push(...compareForeignKeys(live, expected)); | ||
@@ -358,0 +223,0 @@ return differences; |
+26
-133
@@ -6,3 +6,3 @@ // Single responsibility: turn an entity snapshot into a timestamped, reversible migration. | ||
| import { assert, systemClock } from '@ultimat3/core'; | ||
| import { systemClock } from '@ultimat3/core'; | ||
| import { checkClauses, checkPlan, declaredChecks } from './check-ddl'; | ||
@@ -12,7 +12,3 @@ import { defaultExpression } from './column-default'; | ||
| import { dropOrder } from './drop-order'; | ||
| import type { | ||
| ColumnDescriptionLike, | ||
| EntityDescriptionLike, | ||
| IndexDescriptionLike, | ||
| } from './entity-shape'; | ||
| import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape'; | ||
| import { migrationIrreversible } from './errors'; | ||
@@ -22,7 +18,6 @@ import { type ConstraintPlans, foreignKeyPlan, foreignKeysOf, type Plan } from './foreign-key-plan'; | ||
| import { generatedClause, isGenerated, regenerate } from './generated-column'; | ||
| import { declaredMethod, indexMethodOf, indexMethodSql } from './index-method'; | ||
| import { createIndex, dropIndex, impliedByColumnClause, redefineIndex } from './index-ddl'; | ||
| import { | ||
| type ColumnDescription, | ||
| findTable, | ||
| type IndexDescription, | ||
| type SchemaDescription, | ||
@@ -32,2 +27,4 @@ type TableDescription, | ||
| import { declaredIndexes } from './invariant-ddl'; | ||
| import type { MovedAside } from './retype-dependents'; | ||
| import { moveDependentsAside } from './retype-dependents'; | ||
| import { identifier } from './sql'; | ||
@@ -81,30 +78,2 @@ import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered'; | ||
| /** | ||
| * A `unique` column clause already creates an index, and Postgres names it exactly what the | ||
| * entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for | ||
| * it too is the same index twice: `42P07`, and a migration that cannot be applied at all. | ||
| * Mirrors the rule `entity()` already applies to a foreign key indexing its own column. | ||
| * | ||
| * A **partial** unique index is not that index: the column clause constrains every row, so | ||
| * skipping the partial one would silently widen the constraint the entity declared. | ||
| */ | ||
| function impliedByColumnClause( | ||
| entity: EntityDescriptionLike, | ||
| index: IndexDescriptionLike, | ||
| added: ReadonlySet<string>, | ||
| ): boolean { | ||
| const [only] = index.columns; | ||
| if (!index.unique || index.where !== null || index.columns.length !== 1 || only === undefined) { | ||
| return false; | ||
| } | ||
| const column = entity.columns.find((each) => each.column === only); | ||
| // `columnClause` writes `unique` under exactly this condition — keep the two in step. | ||
| // | ||
| // NOT an optional chain, despite what biome's useOptionalChain suggests: `column?.unique` is | ||
| // `boolean | undefined`, and this function returns `boolean`. The lint rule marks its own fix | ||
| // unsafe for exactly this reason — applying it turned a green typecheck red. | ||
| // biome-ignore lint/complexity/useOptionalChain: an optional chain widens the return to include undefined | ||
| return column !== undefined && column.unique && !column.primaryKey && added.has(only); | ||
| } | ||
| export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription { | ||
@@ -181,44 +150,2 @@ const tables = [...entities] | ||
| /** | ||
| * Every part of the declaration reaches the statement: the whole column list in its declared | ||
| * order, the direction when one was asked for, and the predicate that makes it partial. A part | ||
| * dropped here is a constraint the database does not hold or an index the planner cannot use. | ||
| */ | ||
| function createIndex(table: string, index: IndexDescriptionLike): string { | ||
| assert( | ||
| index.columns.length > 0, | ||
| `index "${index.name}" on "${table}" names no columns`, | ||
| `indexes: [{ on: ['<column>'] }] # name the columns in the entity(), then x db gen`, | ||
| ); | ||
| const method = index.using ?? 'btree'; | ||
| // Two rules Postgres has and a declaration can break, refused here rather than at migrate time: | ||
| // GIN supports neither a unique index nor an ASC/DESC option, and either one reaches the server | ||
| // as a syntax error inside `ROLE=migrate` — a release phase that fails with the server's words | ||
| // and none of the entity's. `X_INVARIANT` for the reason `createIndex` already uses it on an | ||
| // index naming no columns: a declaration this build cannot honour is refused, never reinterpreted. | ||
| assert( | ||
| method === 'btree' || !index.unique, | ||
| `index "${index.name}" on "${table}" is unique and ${method}; Postgres has no unique ${method} index`, | ||
| `indexes: [{ on: ['<column>'], using: '${method}' }] # drop unique, or drop using`, | ||
| ); | ||
| assert( | ||
| method === 'btree' || index.order === null, | ||
| `index "${index.name}" on "${table}" is ${method} and ${index.order}; only a btree orders its keys`, | ||
| `indexes: [{ on: ['<column>'], using: '${method}' }] # drop order, or drop using`, | ||
| ); | ||
| const kind = index.unique ? 'create unique index' : 'create index'; | ||
| const direction = index.order === null ? '' : ` ${index.order}`; | ||
| const columns = index.columns | ||
| .map((column) => `${identifier(column).text}${direction}`) | ||
| .join(', '); | ||
| const predicate = index.where === null ? '' : ` where (${index.where})`; | ||
| // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so | ||
| // an index that declared no method emits the statement this generator always emitted, byte for | ||
| // byte, and one that declared a method Postgres does not have is refused instead of built. | ||
| return ( | ||
| `${kind} ${identifier(index.name).text} on ${identifier(table).text}` + | ||
| `${indexMethodSql(method)} (${columns})${predicate};` | ||
| ); | ||
| } | ||
| /** | ||
| * Skipping an existing column by name alone missed the type moving under it: a table created | ||
@@ -229,10 +156,18 @@ * while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the | ||
| * difference is a real kind change, not a catalog alias. | ||
| * | ||
| * The ALTER is not the whole statement list: Postgres compiled every predicate written against | ||
| * this column with its OLD type and cannot recompile one, so a partial index or a CHECK that reads | ||
| * it is dropped FIRST and `moved` carries the names on to the arms that would otherwise act on | ||
| * them. Without that the retype is `42883` and the migration aborts mid-run | ||
| * (`retype-dependents.ts`). | ||
| */ | ||
| function retypeColumn( | ||
| table: string, | ||
| live: TableDescription, | ||
| column: ColumnDescriptionLike, | ||
| recorded: ColumnDescription, | ||
| plan: Plan, | ||
| moved: MovedAside, | ||
| ): Regeneration { | ||
| const wanted = sqlType(column.kind); | ||
| const table = live.name; | ||
| // A generated column moves by its own rules — see `generated-column.ts`. Asked whenever EITHER | ||
@@ -244,2 +179,3 @@ // side is one, because becoming generated and ceasing to be are both changes with a statement. | ||
| if (recorded.dataType === wanted) return 'unchanged'; | ||
| moveDependentsAside(live, column.column, plan, moved); | ||
| const alter = (type: string): string => | ||
@@ -253,50 +189,2 @@ `alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` + | ||
| /** The parts of an index Postgres cannot alter in place — every one of them is a rebuild. */ | ||
| function indexShape(index: IndexDescriptionLike | IndexDescription): string { | ||
| return JSON.stringify([ | ||
| [...index.columns], | ||
| index.unique, | ||
| index.where, | ||
| index.order ?? null, | ||
| indexMethodOf(index), | ||
| ]); | ||
| } | ||
| /** | ||
| * A same-named index whose definition moved is dropped and recreated, because Postgres has no | ||
| * `alter index` for any of it — the column list, the uniqueness, the predicate and the direction | ||
| * are all fixed at creation. | ||
| * | ||
| * Matching on the name alone was the gap: `where` and `order` were not even recorded, so an | ||
| * entity narrowing an index to a predicate, or reversing it to `desc`, generated an empty | ||
| * migration and the database kept serving the old one. Both sides here are *generated* spellings | ||
| * — `recorded` is a previous migration's own snapshot, never the catalog's rewriting of it — so a | ||
| * text difference in `where` is a real change and not a formatting one. | ||
| */ | ||
| function redefineIndex( | ||
| table: string, | ||
| index: IndexDescriptionLike, | ||
| recorded: IndexDescription, | ||
| plan: Plan, | ||
| ): void { | ||
| if (indexShape(index) === indexShape(recorded)) return; | ||
| plan.up.push(`drop index ${identifier(index.name).text};`, createIndex(table, index)); | ||
| // `down` is reversed at assembly, so the pair is pushed forwards and read backwards: recreating | ||
| // the recorded definition is what must land last, after the new one is dropped. | ||
| plan.down.push( | ||
| createIndex(table, { | ||
| name: recorded.name, | ||
| columns: recorded.columns, | ||
| unique: recorded.unique, | ||
| where: recorded.where, | ||
| order: recorded.order, | ||
| // `declaredMethod`, never a cast: `recorded` is a snapshot's, typed open because the catalog | ||
| // shares the shape, and a method this generator cannot emit must refuse rather than be | ||
| // rebuilt as a btree — a `down` that recreates the wrong structure is worse than none. | ||
| ...(recorded.using === undefined ? {} : { using: declaredMethod(recorded.using) }), | ||
| }), | ||
| `drop index ${identifier(index.name).text};`, | ||
| ); | ||
| } | ||
| function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void { | ||
@@ -308,6 +196,8 @@ const existing = new Map(live.columns.map((column) => [column.name, column])); | ||
| const rebuilt = new Set<string>(); | ||
| // What a retype dropped ahead of itself, read by the two arms below. | ||
| const moved: MovedAside = { indexes: new Set(), checks: new Set() }; | ||
| for (const column of entity.columns) { | ||
| const recorded = existing.get(column.column); | ||
| if (recorded !== undefined) { | ||
| if (retypeColumn(entity.table, column, recorded, plan) === 'rebuilt') { | ||
| if (retypeColumn(live, column, recorded, plan, moved) === 'rebuilt') { | ||
| rebuilt.add(column.column); | ||
@@ -342,5 +232,7 @@ } | ||
| const recorded = indexed.get(index.name); | ||
| // A rebuilt column took its indexes down with it, so this one is CREATED rather than compared: | ||
| // A rebuilt column took its indexes down with it, and a retype dropped the ones whose | ||
| // predicate it could not survive — either way this one is CREATED rather than compared: | ||
| // `redefineIndex` sees a definition that never moved and would emit nothing at all. | ||
| if (recorded !== undefined && !index.columns.some((column) => rebuilt.has(column))) { | ||
| const gone = moved.indexes.has(index.name) || index.columns.some((each) => rebuilt.has(each)); | ||
| if (recorded !== undefined && !gone) { | ||
| redefineIndex(entity.table, index, recorded, plan); | ||
@@ -353,3 +245,3 @@ continue; | ||
| plan.up.push(createIndex(entity.table, index)); | ||
| plan.down.push(`drop index ${identifier(index.name).text};`); | ||
| plan.down.push(dropIndex(index.name)); | ||
| } | ||
@@ -359,4 +251,5 @@ | ||
| // that does not exist yet is `42703`. `check-ddl.ts` owns which of them move; `rebuilt` because a | ||
| // column dropped and re-added lost its constraint while the snapshot still records it. | ||
| checkPlan(entity, live, plan, rebuilt); | ||
| // column dropped and re-added lost its constraint while the snapshot still records it, and | ||
| // `moved.checks` because a retype already dropped the ones written against the old type. | ||
| checkPlan(entity, live, plan, rebuilt, moved.checks); | ||
| } | ||
@@ -363,0 +256,0 @@ |
+46
-1
@@ -90,2 +90,20 @@ // Single responsibility: read the live schema out of `information_schema` / `pg_catalog` into a | ||
| readonly checks?: readonly CheckDescription[] | undefined; | ||
| /** | ||
| * The catalog's half of `checks`, and a **separate field rather than the same one** — names | ||
| * only, `conname` for `contype = 'c'`, never a definition. | ||
| * | ||
| * The two readings cannot share `checks` because they are not the same value. A catalog read | ||
| * carries Postgres' own rewriting of the predicate and a declaration carries this generator's | ||
| * spelling, so a `checks` filled from `pg_constraint` would put a rewritten expression on the | ||
| * field `checkPlan` diffs against a generated one — every regenerated migration would then drop | ||
| * and re-add every constraint in the app, forever, because the two strings can never be equal. | ||
| * Splitting them means the type says which reading a value came from, and `checkPlan` cannot be | ||
| * handed a catalog value by accident. | ||
| * | ||
| * `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks` | ||
| * alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody | ||
| * asked the catalog", which is what keeps `compareChecks` silent on a description that never | ||
| * read one instead of reporting every declared constraint as missing. | ||
| */ | ||
| readonly checkNames?: readonly string[] | undefined; | ||
| } | ||
@@ -139,2 +157,8 @@ | ||
| /** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */ | ||
| interface CheckRow { | ||
| readonly table_name: string; | ||
| readonly constraint_name: string; | ||
| } | ||
| const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1); | ||
@@ -211,3 +235,17 @@ | ||
| return buildSchema(schema, excluded, columns, indexes, foreignKeys); | ||
| // `conname` and nothing else. `pg_get_constraintdef(c.oid)` is one word further along this line | ||
| // and is the reason this query did not exist: it answers Postgres' rewriting of the predicate, | ||
| // which no generated spelling can ever equal, so reading it would make every drift check report | ||
| // a correct database as wrong. `contype = 'c'` is the CHECK constraints alone — Postgres 17 | ||
| // onwards records a NOT NULL as `'n'`, and a domain's as `'c'` on the domain rather than here. | ||
| const checks = await client.query<CheckRow>(sql` | ||
| select src.relname as table_name, c.conname as constraint_name | ||
| from pg_constraint c | ||
| join pg_class src on src.oid = c.conrelid | ||
| join pg_namespace n on n.oid = src.relnamespace | ||
| where c.contype = 'c' and n.nspname = ${schema} and src.relkind = 'r' | ||
| order by src.relname, c.conname | ||
| `); | ||
| return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks); | ||
| } | ||
@@ -222,2 +260,3 @@ | ||
| foreignKeys: readonly ForeignKeyRow[], | ||
| checks: readonly CheckRow[] = [], | ||
| ): SchemaDescription { | ||
@@ -268,2 +307,8 @@ const names = [...new Set(columns.map((row) => row.table_name))] | ||
| .sort(byName), | ||
| // Always written, `[]` included: this reading of a table HAS asked the catalog, and absence | ||
| // is reserved for a description that has not (`compareChecks` is silent on that one). | ||
| checkNames: checks | ||
| .filter((row) => row.table_name === name) | ||
| .map((row) => row.constraint_name) | ||
| .sort(), | ||
| }; | ||
@@ -270,0 +315,0 @@ }); |
Sorry, the diff of this file is too big to display
499231
5.5%58
5.45%7136
4.42%442
2.55%+ Added
- Removed
Updated