New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/db

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/db - npm Package Compare versions

Comparing version
14.0.0
to
15.0.0
+231
src/drift-findings.ts
// 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`,
};
}
// 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": {

@@ -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`

@@ -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));

@@ -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;

@@ -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 @@

@@ -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