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
13.0.0
to
14.0.0
+212
src/check-ddl.ts
// Single responsibility: every CHECK constraint a table declares — a COLUMN's own and an
// INVARIANT's — and which of them a migration adds, rebuilds or drops. Split out of `generate.ts`
// and `invariant-ddl.ts` because a column check was the one part of a column that reached
// `create table` and nothing else: `columnClause` wrote `check (…)` inline and ANONYMOUS,
// `snapshotOf` recorded nothing for it and `diffTable` had no arm for it, so the SECOND `x db gen`
// turned `enumerated(POST_STATUSES)` into bare `text` accepting any string, and the value set the
// entity still declares left the database with no statement anywhere saying so.
//
// One list, for the reason `declaredIndexes` is one list: `createTable`, `diffTable` and
// `snapshotOf` must agree about what exists, and two producers of `add constraint` that never met
// is `42710` on the very next generation — a migration nobody can apply.
import { assert } from '@ultimat3/core';
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
import type { CheckDescription, TableDescription } from './introspect';
import { invariantChecks, isIdentifier, MAX_IDENTIFIER_BYTES } from './invariant-ddl';
import { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
import { identifier } from './sql';
import { statementsOf } from './statement-split';
/**
* The convention alone, with nothing validated and nothing refused — one copy, so `columnCheckName`
* and `columnNamesConstraint` can never disagree about what a column's CHECK is called. The shape
* `spellConstraintName` already has in `invariant-ddl.ts`, and for the same reason.
*
* **Not a convention chosen here.** It is the name Postgres itself mints for an anonymous
* single-column CHECK — measured against a real server in `check-ddl.live.test.ts`, including for a
* multi-clause predicate like `scaleCheck`'s, which still names only one column. That is the whole
* reason this spelling and no other: every database generated before this landed is holding the old
* inline anonymous form under exactly this name, so a repair migration lands ON the constraint it
* means to correct instead of beside it under a second name.
*/
const spellColumnCheckName = (table: string, column: string): string => `${table}_${column}_check`;
/**
* Whether a RECORDED constraint is one of THIS entity's columns' own CHECKs. Validates nothing and
* never throws, exactly as `namesConstraint` does not, because its caller is a REPORTER
* (`unrendered.ts`) reached by the `drift` gate step where a throw replaces a finding with a crash.
*
* It exists because the two conventions can land on one string: an entity whose `slug` column is
* checked and that also declares `invariant('slug', …)` as an ASSERT derives `posts_slug_check`
* twice, and `namesConstraint` matches on the name alone — which it must, since a hand-written
* migration names the constraint after the rule. Without this the assert reads as "this run drops
* the CHECK recorded for me" while the run declares and keeps it: a `-- UNRENDERED` block on a
* migration that lost nothing, which is a marker the next reviewer learns to ignore.
*/
export function columnNamesConstraint(entity: EntityDescriptionLike, recorded: string): boolean {
return entity.columns.some(
(column) =>
column.check !== null && recorded === spellColumnCheckName(entity.table, column.column),
);
}
/**
* What a column's own CHECK is called, with both operands validated — the reason `constraintNameFor`
* validates its own one file over: a physical column name arrives from a projection this package
* cannot typecheck, `add constraint` takes no parameters, and a name that closes its own quote
* produced a real `drop table` through `generateMigration` once already.
*/
export function columnCheckName(table: string, column: string): string {
if (!isIdentifier(table) || !isIdentifier(column)) throw constraintNameUnsafe(table, column);
const name = spellColumnCheckName(table, column);
// Bytes, never characters: 63 is what the server counts, and a truncation it performs silently
// makes two constraints one on the server while both names still differ in the snapshot.
const bytes = new TextEncoder().encode(name).length;
assert(
bytes <= MAX_IDENTIFIER_BYTES,
`constraint name "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES} and says nothing`,
`.column('<shorter>') # shorten the physical name of "${column}", then x db gen`,
);
return name;
}
/**
* The CHECK constraints an entity's COLUMNS declare, in column order — `enumerated()`'s closed value
* set, `tz()`'s IANA whitelist, `locale()`'s tag list, money's currency pattern and scale bound.
*
* Read off `ColumnDescriptionLike.check`, which is the one field `@ultimat3/entity` projects them
* through; a column carrying `null` declares none and contributes no row.
*/
export function columnChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
return entity.columns
.filter((column: ColumnDescriptionLike) => column.check !== null)
.map((column) => ({
name: columnCheckName(entity.table, column.column),
expression: column.check ?? '',
}));
}
/**
* Every CHECK this table declares. Columns first, then invariants — the order the old generator
* emitted them in, so a table declaring only invariants produces the statement it always produced.
*
* A duplicate name is REFUSED rather than deduped. Two `add constraint` statements under one name
* is `42710`, and the two sides mean different things: `invariant('status', …)` on a table whose
* `status` column is an `enumerated()` derives the same `posts_status_check` the column already
* owns, and silently keeping either one would enforce a rule the entity does not state. Same
* argument `declaredIndexes` makes for `42P07`, with the opposite remedy, because unlike two
* identical index definitions these two carry different predicates.
*/
export function declaredChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
const checks = [...columnChecks(entity), ...invariantChecks(entity)];
const seen = new Set<string>();
for (const check of checks) {
assert(
!seen.has(check.name),
`two declarations on "${entity.table}" name the constraint "${check.name}"`,
`invariant('${entity.table}_${check.name}', …) # rename the invariant — a column's own CHECK already holds that name, then x db gen`,
);
seen.add(check.name);
// One command, over the merged list: `statementsOf` is this package's one lexer, so a `;`
// inside a string literal — `check (tag <> ';')`, and every `oneOf()` value list — is data and
// not a split. Applied here rather than per producer so a column's predicate, which arrives
// from an app's own `enumerated([...])` array, is guarded by the same rule an invariant's is.
const commands = statementsOf(check.expression).length;
if (commands > 1) throw constraintExpressionUnsafe(check.name, commands);
}
return checks;
}
/** `constraint "n" check (…)` — the clause form, for a table this migration creates. */
export function checkClauses(entity: EntityDescriptionLike): readonly string[] {
return declaredChecks(entity).map(
(check) => `constraint ${identifier(check.name).text} check (${check.expression})`,
);
}
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 =>
`alter table ${identifier(table).text} drop constraint ${identifier(name).text};`;
/**
* The one statement that is correct on BOTH databases this generator cannot tell apart.
*
* A database generated before column checks were recorded is holding Postgres' own auto-named
* `<table>_<column>_check` from the old inline anonymous form; a database whose entity gained the
* check after the table was created is holding nothing, because the old `diffTable` emitted nothing.
* The snapshot reads identically in both — it records no check either way — so a bare
* `add constraint` is `42710` on the first, inside `ROLE=migrate`, with the server's words and none
* of the entity's. `drop constraint if exists` costs a notice on the second and repairs the first.
*/
const rebuildCheck = (table: string, check: CheckDescription): readonly string[] => [
`alter table ${identifier(table).text} drop constraint if exists ${identifier(check.name).text};`,
addCheck(table, check),
];
/**
* Which CHECK constraints an existing table gains, loses or has rebuilt. Postgres has no `alter
* constraint` for a predicate, so a moved expression is a drop and an add — the same shape
* `redefineIndex` uses, and `down` is pushed forwards and read backwards for the same reason.
*
* Both directions, the rule `foreignKeyPlan` states: a snapshot may not lie. A recorded constraint
* the entity no longer declares is DROPPED, and its `down` re-adds it from the expression the
* snapshot holds — so unlike a dropped column there is nothing to restore and nothing to refuse.
* `destructive.ts` deliberately excludes `drop constraint` for exactly this reason.
*
* `rebuilt` names the columns this migration dropped and re-added outright (`regenerate`'s
* plain -> generated path). The constraint went with the column and the snapshot still records it,
* so without this the check would be silently gone — the defect class this file exists against,
* one level in.
*/
export function checkPlan(
entity: EntityDescriptionLike,
live: TableDescription,
plan: { up: string[]; down: string[] },
rebuilt: ReadonlySet<string> = new Set(),
): void {
const recorded = new Map((live.checks ?? []).map((check) => [check.name, check]));
const present = new Set(live.columns.map((column) => column.name));
// Checked columns only, in both sets — `columnCheckName` REFUSES a name it cannot spell, and a
// column declaring no check contributes no constraint for either set to be consulted about. Over
// every column this would refuse to generate a migration that touches none of them.
const checked = entity.columns.filter((column) => column.check !== null);
// Which names the OLD anonymous form could be holding: a column the recorded schema already had,
// whose check it did not record. A column this migration adds cannot have one, and a rebuilt one
// lost it with the column, so both take the bare add.
const exposed = new Set(
checked
.filter((column) => present.has(column.column) && !rebuilt.has(column.column))
.map((column) => columnCheckName(entity.table, column.column)),
);
const dropped = new Set(
checked
.filter((column) => rebuilt.has(column.column))
.map((column) => columnCheckName(entity.table, column.column)),
);
const wanted = declaredChecks(entity);
for (const check of wanted) {
const held = dropped.has(check.name) ? undefined : recorded.get(check.name);
if (held === undefined) {
plan.up.push(
...(exposed.has(check.name)
? rebuildCheck(entity.table, check)
: [addCheck(entity.table, check)]),
);
plan.down.push(dropCheck(entity.table, check.name));
continue;
}
if (held.expression === check.expression) continue;
plan.up.push(dropCheck(entity.table, check.name), addCheck(entity.table, check));
plan.down.push(addCheck(entity.table, held), dropCheck(entity.table, check.name));
}
const declared = new Set(wanted.map((check) => check.name));
for (const check of live.checks ?? []) {
if (declared.has(check.name)) continue;
plan.up.push(dropCheck(entity.table, check.name));
plan.down.push(addCheck(entity.table, check));
}
}
// Single responsibility: what a column's DEFAULT is, as SQL. Split out of `generate.ts` because
// the question has two halves that must be answered together — what a declared default renders as,
// and what it MEANS when a column claims one this generator cannot write down. That second half
// had no answer at all until 2026-08-25: `hasDefault` reached the generator with no expression
// beside it, so nine scalar defaults in the reference app's own schema were dropped in silence and
// only `now()` and `gen_random_uuid()` survived, because those two are inferable from the kind.
import { assert } from '@ultimat3/core';
import { literal } from './sql';
/**
* Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDefault`. Declared here
* rather than in `entity-shape.ts` so `defaultExpression` can name it without an import cycle,
* exactly as `IndexMethod` lives in `index-method.ts` and is read from the mirror.
*
* `uuid-v7` is what `entity()` stamps on a generated uuid key. It renders `gen_random_uuid()` —
* a v4 — because that is the only server-side generator Postgres 17 ships and it is what this
* generator has always emitted for such a key. The v7 is minted in JS on the write path; the
* column default is the fallback for a row nothing in this framework inserted.
*/
export type ColumnDefaultLike =
| { readonly kind: 'value'; readonly value: string | number | boolean | null }
| { readonly kind: 'generated'; readonly by: 'uuid-v7' | 'now' };
/** The parts of a column description this file reads — nothing else. */
interface DefaultedColumn {
readonly column: string;
readonly property: string;
readonly kind: string;
readonly primaryKey: boolean;
readonly hasDefault: boolean;
readonly default?: ColumnDefaultLike | undefined;
}
/**
* A literal reaches the statement TEXT — `create table` takes no parameters — so a string is
* quoted through the one escape this package has (`literal`), and a number this build cannot
* write down is refused rather than emitted as `NaN`, which is a syntax error whose first reader
* would be `ROLE=migrate`.
*/
function literalSql(column: DefaultedColumn, value: string | number | boolean | null): string {
if (value === null) return 'null';
if (typeof value === 'string') return literal(value).text;
if (typeof value === 'boolean') return value ? 'true' : 'false';
assert(
Number.isFinite(value),
`column "${column.column}" declares a default of ${String(value)}, which is not a number Postgres can hold`,
`.default(0) # give "${column.property}" a finite number, or drop the default`,
);
return String(value);
}
/**
* The `default …` a column clause carries, or `null` for a column that has none this generator can
* write. A DECLARED default wins over an inferred one; the two inferences below stay as the
* fallback for a description whose producer does not yet project the expression — see
* `unrenderedDefault`, which is what stops that fallback from being a silent loss.
*/
export function defaultExpression(column: DefaultedColumn): string | null {
if (!column.hasDefault) return null;
const declared = column.default;
if (declared !== undefined) {
return declared.kind === 'value'
? literalSql(column, declared.value)
: declared.by === 'now'
? 'now()'
: 'gen_random_uuid()';
}
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
if (column.kind === 'timestamptz') return 'now()';
return null;
}
/**
* Whether this column claims a default the generated SQL does not carry. Exactly the condition
* that lost nine columns' defaults in silence: `hasDefault: true` with nothing to render it from.
*/
export function hasUnrenderedDefault(column: DefaultedColumn): boolean {
return column.hasDefault && defaultExpression(column) === null;
}
// Single responsibility: the DDL an entity's INVARIANTS become — what a rule is called, the index a
// `unique` becomes, and the CHECK list a caller merges. `check-ddl.ts` owns the plan those checks
// join, because a column declares one of its own and the two are one list on the server.
//
// A `check` becomes a named CONSTRAINT, inline on a created table and `alter table … add
// constraint` on an existing one. A `unique` becomes a unique INDEX — never a UNIQUE constraint —
// because a soft-deleting entity stamps `deleted_at is null` onto it and Postgres has no partial
// unique constraint, only a partial unique index. An `assert` becomes nothing: it is declared as a
// rule only the app can judge (`sql: null`), which is what `hasJsOnlyInvariant` reads it as.
import { assert } from '@ultimat3/core';
import type {
EntityDescriptionLike,
IndexDescriptionLike,
InvariantDescriptionLike,
} from './entity-shape';
import { indexMethodOf } from './index-method';
import type { CheckDescription } from './introspect';
import { constraintNameUnsafe } from './invariant-errors';
import { identifier } from './sql';
/**
* `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says nothing, so two constraints
* sharing their first 63 bytes are ONE constraint on the server while both names still differ in
* the snapshot — invisible to a drift check comparing declared names. `@ultimat3/entity` bounds
* the index names it mints for the same reason; this bound is Postgres', not a convention, so
* stating it on both sides of the tier seam is one fact written twice rather than two rules.
*
* Exported so `check-ddl.ts` bounds a column's constraint name against the same number: two copies
* of NAMEDATALEN in one package is two rules that can drift, which is the thing it exists against.
*/
export const MAX_IDENTIFIER_BYTES = 63;
/**
* The convention alone, with nothing validated and nothing refused. One copy, so `constraintNameFor`
* and `namesConstraint` can never disagree about what a rule's constraint is called — the two
* questions "what do I emit" and "is this recorded constraint that rule's" are the same string.
*/
function spellConstraintName(table: string, invariant: InvariantDescriptionLike): string {
return `${table}_${invariant.name}_${invariant.kind === 'unique' ? 'key' : 'check'}`;
}
/**
* Whether a CHECK a migration RECORDED is this rule's own enforcement in the database. Two
* spellings, because two writers name it: this generator's `<table>_<name>_check`, and a
* hand-written migration that used the rule's own name — which is what `examples/dummy`'s
* `0001_init.sql` did for every one of its app-judged rules.
*
* Never throws, unlike `constraintNameFor`: its caller is a REPORTER (`unrendered.ts`), reached by
* `x verify`'s drift step, where a throw replaces a finding with a crash. The RECORDED name is
* required to be an identifier and the invariant's is not, because only the recorded one is written
* back out — into a `--` comment and into a `fix:` — and a sidecar is a hand-editable file.
*/
export function namesConstraint(
table: string,
invariant: InvariantDescriptionLike,
recorded: string,
): boolean {
if (!isIdentifier(recorded)) return false;
return recorded === invariant.name || recorded === spellConstraintName(table, invariant);
}
/**
* The constraint an invariant becomes: `<table>_<name>_check` / `<table>_<name>_key`.
*
* The same string `@ultimat3/entity`'s `constraintName` builds, and it has to be re-derived here
* rather than read off the description because the projection carries the rule's own name and not
* the constraint's. Both spellings are pinned — entity's by `invariants.test.ts`, this one by
* `generate-invariant.test.ts` — and a divergence would show up as a constraint this generator
* adds twice under two names.
*/
export function constraintNameFor(table: string, invariant: InvariantDescriptionLike): string {
const name = spellConstraintName(table, invariant);
// Through the package's one identifier rule, never a second regex: an invariant name is
// validated by nobody at declaration, so this is where a name that closes the quote is stopped.
if (!isIdentifier(invariant.name) || !isIdentifier(table)) {
throw constraintNameUnsafe(table, invariant.name);
}
// Bytes and not characters: 63 is what the server counts, and `.length` stops seeing the
// truncation the moment a name is not ASCII.
const bytes = new TextEncoder().encode(name).length;
assert(
bytes <= MAX_IDENTIFIER_BYTES,
`constraint name "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES} and says nothing`,
`invariant('${invariant.name.slice(0, 20)}…', …) # shorten the invariant name, then x db gen`,
);
return name;
}
/** Whether `identifier()` would accept this name — the package's one rule, asked rather than run. */
export function isIdentifier(value: string): boolean {
try {
identifier(value);
return true;
} catch {
// `identifier` throws `X_SQL_UNSAFE` for exactly one reason and the caller re-throws its own,
// naming the invariant rather than the raw name — so nothing is swallowed here.
return false;
}
}
/**
* The physical columns a `unique` invariant names. `columns` when the description carries it;
* otherwise the `sql` field, which for a `unique` IS the comma-joined column list.
*
* The fallback is a re-read, not a name parsed back out of a convention: every part is validated
* as an identifier and a part that is not one is REFUSED, so the failure mode `parseIndexName` had
* — `posts_org_id_created_at_idx` silently becoming the column `"org_id_created_at"` — cannot
* happen, because a physical column name cannot contain a comma. It exists so this package can
* emit the constraint before `@ultimat3/entity` (tier 2, which this one may not import) projects
* `Invariant.columns`; the field it already holds is what makes the fallback deletable later.
*/
export function uniqueColumns(
table: string,
invariant: InvariantDescriptionLike,
): readonly string[] {
const declared = invariant.columns ?? (invariant.sql ?? '').split(',').map((part) => part.trim());
assert(
declared.length > 0 && declared.every((column) => column.length > 0),
`unique invariant "${invariant.name}" on "${table}" names no columns`,
`invariant('${invariant.name}', c.unique(['<column>'])) # name the columns, then x db gen`,
);
for (const column of declared) {
if (!isIdentifier(column)) throw constraintNameUnsafe(table, column);
}
return declared;
}
/** A `unique` invariant as the index it is — so one list of indexes is created, diffed and recorded. */
function uniqueIndexOf(
entity: EntityDescriptionLike,
invariant: InvariantDescriptionLike,
): IndexDescriptionLike {
return {
name: constraintNameFor(entity.table, invariant),
columns: uniqueColumns(entity.table, invariant),
unique: true,
where: invariant.where,
order: null,
};
}
/** Every part of an index Postgres fixes at creation — the dedup key, and `redefineIndex`'s. */
const shapeOf = (index: IndexDescriptionLike): string =>
JSON.stringify([
[...index.columns],
index.unique,
index.where,
index.order,
indexMethodOf(index),
]);
/**
* The indexes this entity declares: its own, plus one per `unique` invariant. ONE list, because
* `createTable`, `diffTable` and `snapshotOf` must agree about what exists — a unique index emitted
* but not recorded is `42P07` on the next `x db gen`, which is a worse failure than the silent drop
* this whole change is against.
*
* Deduped on the whole definition and never on the name, the rule `@ultimat3/entity` already
* applies. The case that bites: `invariant('slug', c.unique(['slug']))` on `members` derives
* `members_slug_key`, byte for byte what Postgres calls the index a `unique` column clause creates
* — so an entity declaring both pushes two `create unique index` statements under one name, which
* is `42P07` and a migration that cannot be applied at all. The entity's own index wins, because
* `impliedByColumnClause` is written against that name.
*/
export function declaredIndexes(entity: EntityDescriptionLike): readonly IndexDescriptionLike[] {
const invariants = entity.invariants ?? [];
if (invariants.length === 0) return entity.indexes;
const seen = new Set(entity.indexes.map(shapeOf));
const extra: IndexDescriptionLike[] = [];
for (const invariant of invariants) {
if (invariant.kind !== 'unique') continue;
const index = uniqueIndexOf(entity, invariant);
if (seen.has(shapeOf(index))) continue;
seen.add(shapeOf(index));
extra.push(index);
}
return [...entity.indexes, ...extra];
}
/**
* The CHECK constraints this entity's INVARIANTS declare, in declaration order. The predicate is
* handed on unvalidated: `check-ddl.ts` refuses a second command over the MERGED list, so one rule
* covers a rule's expression and a column's alike rather than one guard per producer.
*/
export function invariantChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
return (entity.invariants ?? [])
.filter((invariant) => invariant.kind === 'check' && invariant.sql !== null)
.map((invariant) => ({
name: constraintNameFor(entity.table, invariant),
expression: invariant.sql ?? '',
}));
}
// Single responsibility: the two refusals an entity INVARIANT earns before its DDL exists — a name
// that cannot be an identifier, and a predicate holding a second command. Split out of `errors.ts`
// only because that file reached the 500-line ceiling; both carry `X_SQL_UNSAFE`, which
// `DB_OWNED_ERROR_CODES` there still declares and registers. No new code, and none is needed: an
// invariant name reaching a statement text is the same hazard a branch name or an isolation level
// is, and axiom 1 says one situation gets one code.
import { describeValue } from '@ultimat3/core';
import { DbError } from './errors';
/**
* A name an invariant contributes to a statement — its own, or a column its `unique` list names —
* that cannot be an identifier. `X_SQL_UNSAFE` for the reason `branchNameInvalid` uses it:
* `create table` and `add constraint` take no parameters, so the name is SPLICED into the
* statement text, and NOTHING validates an invariant name at declaration, so
* `invariant('x" ); drop table t; --', …)` type-checks all the way to the generator. The identical
* hole `columnName` carried when it was `meta.name ?? snake(property)` with only the first branch
* checked, measured through `generateMigration` as a real `drop table`.
*
* Its own factory rather than `identifierUnsafe`, and that is the whole of its value — `identifier`
* refuses the same string one call later, at every site that emits it. What only this one carries
* is the REPAIR: `identifierUnsafe` says "pass a plain table/column name" to a caller holding a
* name, and an author holding a schema module needs the `invariant()` call named instead. Pinned
* on the `fix:` line, because a guard whose only value is its message is proven by nothing else.
*/
export const constraintNameUnsafe = (table: string, received: unknown): DbError =>
new DbError({
code: 'X_SQL_UNSAFE',
cause: `an invariant on "${table}" contributes ${describeValue(received)} to a statement, which cannot be a Postgres identifier`,
fix: "invariant('post_slug_unique', c.unique(['slug'])) # every name is [A-Za-z_][A-Za-z0-9_$]*, then x db gen",
meta: { table },
});
/**
* A constraint predicate holding more than one command. Read through `statementsOf` — this
* package's one lexer, so a `;` inside a string literal is data and not a second statement — and
* refused before it is spliced into `check (…)`. The predicate arrives from `Expr.toSql()` at tier
* 2 or from a hand-built description, and an operand TypeScript never saw closing the parenthesis
* is an injection rather than a typo, which is what `X_SQL_UNSAFE` is for.
*/
export const constraintExpressionUnsafe = (constraint: string, count: number): DbError =>
new DbError({
code: 'X_SQL_UNSAFE',
cause: `the predicate of constraint "${constraint}" holds ${count} commands; a CHECK is one expression`,
fix: `invariant('${constraint}', c.column.atLeast(0)) # build the predicate with the column DSL, never as text`,
meta: { constraint, count },
});
// Single responsibility: one SQL statement as one capped line, for an error to print. Its own
// module because two rails now report statements — `destructive.ts` and `ungeneratable.ts` — and a
// second copy of "what does a reported statement look like" is two answers to one question.
/**
* Only the comments *preceding* the statement come off, the ones `statementsOf` carries in from the
* file header or from the `-- backfill …` note above it; the SQL itself stays verbatim.
*
* Blanking is for **deciding**, never for reporting: `stripSqlNoise` empties quoted identifiers, so
* a report built from it says `drop table ""`, which names nothing an author can act on.
*/
export function statementExcerpt(statement: string): string {
const line = statement
.replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
.replace(/\s+/g, ' ')
.trim();
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
}
// Single responsibility: which statements in a migration's `up` half `x db gen` could never have
// written — the SQL a squash discards in silence. `REPLICA IDENTITY FULL`, `CREATE EXTENSION`, a
// `GRANT`, a data backfill: none of them is a declaration, none reaches a `.snapshot.json`, and no
// declaration-based drift check can see them, because a regenerated sidecar equals the declaration
// by construction. The rail is the same shape `destructive.ts` is — statements in, statements out.
import { stripSqlNoise } from './sql-noise';
import { statementExcerpt } from './statement-excerpt';
import { statementsOf } from './statement-split';
/** One statement form `generateMigration` emits, matched on its leading verb phrase. */
export interface GeneratableForm {
/** What the form is called in a failing test's output. */
readonly name: string;
/** Anchored at the statement's start, against blanked and lowercased text. */
readonly pattern: RegExp;
}
/**
* Everything this package's generator can emit, and nothing else.
*
* **The list is not a hand-typed opinion, and `ungeneratable.test.ts` is what keeps it from
* becoming one.** Two assertions, in both directions, over a corpus that is the real output of
* `generateMigration`: no statement in that corpus may be reported (or the check fires on the
* framework's own migrations), and every entry here must match a statement in it (or the list has
* grown an entry excusing SQL the generator never writes — which is the exact thing this rail
* exists to report). A statement form added to `generate.ts` and not to this list fails the first;
* an entry added here to silence a finding fails the second.
*
* Matched on the leading **verb phrase**, never on the whole statement: `alter table` is four
* different operations and only some of them are generated, so the sub-clause is part of the
* phrase — `alter table … replica identity full` shares its first two words with `add column` and
* is the statement that started this.
*
* What it deliberately does not do is judge a statement's *body*. A hand-written `create table …
* partition by range (…)` reads as generatable, because its verb phrase is one the generator
* writes. Reporting that needs a schema comparison, which is `schema-drift`'s question and already
* has an answer; this one is only ever about a statement with no declaration behind it at all.
*/
export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
{ name: 'create table', pattern: /^create\s+table\b/ },
{ name: 'drop table', pattern: /^drop\s+table\b/ },
{ name: 'create index', pattern: /^create\s+index\b/ },
{ name: 'create unique index', pattern: /^create\s+unique\s+index\b/ },
{ name: 'drop index', pattern: /^drop\s+index\b/ },
{ name: 'add column', pattern: /^alter\s+table\s[\s\S]*?\badd\s+column\b/ },
{ name: 'drop column', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+column\b/ },
{ name: 'add constraint', pattern: /^alter\s+table\s[\s\S]*?\badd\s+constraint\b/ },
{ name: 'drop constraint', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+constraint\b/ },
{
name: 'alter column type',
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\btype\b/,
},
{
name: 'alter column set expression',
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\bset\s+expression\b/,
},
{
name: 'alter column drop expression',
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\bdrop\s+expression\b/,
},
];
/**
* Every statement in `up` that a regenerated migration would not carry, in apply order, as the one
* capped line an error prints — the statement, never a count, because the whole value is telling an
* author *which* line a squash discards.
*
* Decided on blanked text and reported from the original, the rule `destructiveStatements` states:
* `statementsOf` cuts on a `;` that is not inside a literal, an identifier, a dollar-quoted body or
* a comment, and `stripSqlNoise` blanks all four before a verb is looked for — so
* `-- create extension pg_trgm` is prose and `values ('grant select on posts')` is data. The
* excerpt keeps its identifiers, because `create extension ""` names nothing an author can act on.
*
* Only `up`, exactly as the destructive rail: `down` is full of statements the generator does emit
* and reversing it teaches nobody anything about what the committed file uniquely holds.
*/
export function ungeneratableStatements(up: string): readonly string[] {
const found: string[] = [];
for (const statement of statementsOf(up)) {
const bare = stripSqlNoise(statement).trim().toLowerCase();
if (GENERATABLE_FORMS.some((form) => form.pattern.test(bare))) continue;
found.push(statementExcerpt(statement));
}
return found;
}
// Single responsibility: what a generated migration declares it could NOT write, and how that
// reaches the file. A generator that silently emits less than the declaration is the defect this
// module exists against — ten invariants and nine defaults went missing between an entity and its
// own regenerated migration, and the `drift` gate step compares a source hash to a sidecar and
// never reads the SQL, so the loss was GREEN. A comment in the emitted `up` cannot be green.
import { assert } from '@ultimat3/core';
import { columnNamesConstraint } from './check-ddl';
import { hasUnrenderedDefault } from './column-default';
import type { EntityDescriptionLike, InvariantDescriptionLike } from './entity-shape';
import { findTable, type SchemaDescription, type TableDescription } from './introspect';
import { namesConstraint } from './invariant-ddl';
export interface UnrenderedDeclaration {
/** Which half of the declaration reached no SQL. */
readonly kind: 'default' | 'invariant';
/** The physical table it was declared on. */
readonly table: string;
/** The column or the invariant it was declared on. */
readonly name: string;
/** What was declared and why nothing was written. One line. */
readonly cause: string;
/** The edit or the command that makes the next generation carry it. One line. */
readonly fix: string;
}
/**
* Refused, not sanitised: a `\n` inside a `--` line comment ENDS the comment, so a cause carrying
* one would put the rest of itself into the migration as real SQL. Every value that reaches here
* is built from identifiers this generator already validated, so this is the assertion that keeps
* that true rather than a filter that quietly rewrites text an author has to act on.
*/
function commentLine(text: string): string {
assert(
!/[\r\n]/.test(text),
`a migration comment may not span lines: ${JSON.stringify(text)}`,
'report this — a validated identifier reached the comment renderer carrying a newline',
);
return text;
}
/**
* The block that goes at the TOP of `up`, or nothing at all. Nothing at all is the point: a marker
* on every migration marks none, which is the rule `destructive.ts` already states for its own.
*
* Comments, never a refusal. `x db gen` refusing here would be a generator no app with a
* `.default('draft')` could run at all — the whole tree is in that state until `@ultimat3/entity`
* projects the expression — and a migration nobody can generate repairs nothing. The comment
* survives into the committed file, where a reviewer and the next agent both read it, and
* `GeneratedMigration.unrendered` carries the same list for a caller that would rather refuse.
*/
export function unrenderedComment(entries: readonly UnrenderedDeclaration[]): string {
if (entries.length === 0) return '';
const header =
`-- UNRENDERED: ${entries.length} declaration${entries.length === 1 ? '' : 's'} reached no SQL. ` +
'This migration is SMALLER than the entities declare.';
const lines = entries.flatMap((entry) => [
commentLine(`-- ${entry.kind} on "${entry.table}"."${entry.name}": ${entry.cause}`),
commentLine(`-- fix: ${entry.fix}`),
]);
return [commentLine(header), ...lines, ''].join('\n');
}
/**
* A rule the app still declares and this migration TAKES AWAY. An `assert` reaches no SQL by
* design — `sql: null` says only the app can judge it — so on its own it is not a loss, and
* reporting every one would put a marker on nearly every app's every migration, which marks none.
*
* It becomes a loss the moment a migration RECORDED the rule as a real CHECK, because `checkPlan`
* drops a recorded check nothing declares: regenerating then deletes the database's half of a rule
* the entity still states — and it earns no `-- destructive:` marker of its own, because
* `destructive.ts` excludes `drop constraint` by name on the argument that the database rebuilds
* it, which here nothing does. Measured on `examples/dummy`: five constraints out of
* `0001_init.sql` dropped in one run, three of them declared as asserts and reported by this, and
* `unrendered` was empty — so `@ultimat3/cli`'s `repairFix` handed out
* `x db gen "drop post_slug_shape"` — the command that performs the loss — as the repair for it.
*
* Self-clearing, which is what keeps it off every later file: once the drop is applied and the new
* sidecar written, nothing records the check and the next generation reports nothing.
*/
function unrenderedInvariant(
entity: EntityDescriptionLike,
invariant: InvariantDescriptionLike,
recorded: string,
): UnrenderedDeclaration {
return {
kind: 'invariant',
table: entity.table,
// The RECORDED name, never the rule's: it is the string in this migration's own `drop
// constraint`, in the sidecar, and in the drift finding a caller matches this entry against.
name: recorded,
cause:
`the entity declares "${invariant.name}" as an assert — a rule only the app can judge — ` +
'and this migration drops the CHECK a migration recorded for it',
fix:
`invariant('${invariant.name}', …) # express it in SQL to keep the CHECK — ` +
'an assert has none, so the next x db gen drops it',
};
}
/** Every recorded CHECK on this table that an `assert` still declares and this run would drop. */
function droppedAsserts(
entity: EntityDescriptionLike,
live: TableDescription | undefined,
): UnrenderedDeclaration[] {
const recorded = live?.checks ?? [];
if (recorded.length === 0) return [];
const entries: UnrenderedDeclaration[] = [];
for (const invariant of entity.invariants ?? []) {
// `sql !== null` beside the kind, the pair `hasJsOnlyInvariant` already reads: a description
// carrying an expression is rendered by `declaredChecks` whatever its kind claims.
if (invariant.kind !== 'assert' || invariant.sql !== null) continue;
for (const check of recorded) {
if (!namesConstraint(entity.table, invariant, check.name)) continue;
// A recorded check one of this entity's COLUMNS still declares is not being dropped — the two
// naming conventions collide on `<table>_<column>_check` when an assert is named after a
// column, and only what this run DECLARES can tell the two apart.
if (columnNamesConstraint(entity, check.name)) continue;
entries.push(unrenderedInvariant(entity, invariant, check.name));
}
}
return entries;
}
/**
* What the entities declare and this migration does not carry. Two producers, and they are one
* question — "is this migration smaller than the declaration?" — never two:
*
* - a column whose description says `hasDefault` with no expression beside it, which is every
* non-`now()`, non-`gen_random_uuid()` default until `@ultimat3/entity` projects
* `ColumnMeta.default`;
* - an `assert` invariant whose CHECK a previous migration recorded, which this run drops.
*
* The defaults half is read off the ENTITIES and not off the plan, deliberately: a diff that
* emitted nothing for a table because nothing about it moved still has to report a default the
* create statement never carried, or the loss becomes invisible again on the second run. The
* invariants half needs `current` for the opposite reason — an assert with nothing recorded behind
* it is not a loss at all, and the recorded schema is the only thing that can tell the two apart.
*
* `current` is REQUIRED and may be `undefined`: a caller with no recorded schema (the first
* migration) has to say so, because the alternative is an argument nobody passes and a blind
* answer nobody notices — which is exactly how five drops shipped under an empty list.
*/
export function unrenderedOf(
entities: readonly EntityDescriptionLike[],
current: SchemaDescription | undefined,
): UnrenderedDeclaration[] {
const entries: UnrenderedDeclaration[] = [];
for (const entity of entities) {
for (const column of entity.columns) {
if (!hasUnrenderedDefault(column)) continue;
entries.push({
kind: 'default',
table: entity.table,
name: column.column,
cause: 'the entity description carries hasDefault with no expression beside it',
fix:
'project ColumnMeta.default onto ColumnDescription in ' +
'packages/entity/src/describe.ts, then re-run x db gen',
});
}
entries.push(
...droppedAsserts(
entity,
current === undefined ? undefined : findTable(current, entity.table),
),
);
}
return entries;
}
+2
-2
{
"name": "@ultimat3/db",
"version": "13.0.0",
"version": "14.0.0",
"description": "Postgres access, transactions, migrations and drift detection",

@@ -34,3 +34,3 @@ "license": "MIT",

"dependencies": {
"@ultimat3/core": "13.0.0"
"@ultimat3/core": "14.0.0"
},

@@ -37,0 +37,0 @@ "peerDependencies": {

@@ -46,2 +46,6 @@ # @ultimat3/db 🐘

| `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 |
| `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 |
| `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 |
| `defaultExpression()` / `ColumnDefaultLike` | `As of 2026-08-25`: a column's `default` as SQL. A DECLARED default (`{ kind: 'value', value }`) wins; `gen_random_uuid()` and `now()` stay as the inference for a description that carries only `hasDefault` |
| `unrenderedOf()` / `unrenderedComment()` / `UnrenderedDeclaration` | `As of 2026-08-25`: what the generator could **not** write, on `GeneratedMigration.unrendered` and as a `-- UNRENDERED` block at the top of a non-empty `up`. A generator that emits less than the declaration in silence is the defect the whole file exists against, and `x verify`'s `drift` step reads a source hash — it never reads the SQL, so the loss was green. **`unrenderedOf(entities, current)` takes the recorded schema**, required and nullable: a rule declared as an `assert` reaches no SQL by design and is no loss on its own, but one whose CHECK a previous migration RECORDED is dropped by this run and reported by nothing — five in `examples/dummy`, and `@ultimat3/cli`'s `repairFix` then offered `x db gen "drop <name>"` as the repair for the loss that command performs |
| `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructive()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |

@@ -48,0 +52,0 @@ | `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |

@@ -8,2 +8,3 @@ // Single responsibility: decide whether a migration's `up` half destroys data, and whether the

import { noiseAt } from './sql-scan';
import { statementExcerpt } from './statement-excerpt';
import { statementsOf } from './statement-split';

@@ -95,16 +96,2 @@

/**
* One capped line — an error prints this, not a whole script. Only the comments *preceding* the
* statement come off, the ones `statementsOf` carries in from the file header; the SQL itself stays
* verbatim, because `stripSqlNoise` blanks quoted identifiers and `drop table ""` names nothing an
* author can act on. Blanking is for deciding, never for reporting.
*/
function excerpt(statement: string): string {
const line = statement
.replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
.replace(/\s+/g, ' ')
.trim();
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
}
/**
* Every destructive statement in `up`, in apply order.

@@ -122,3 +109,3 @@ *

const rule = RULES.find(([, pattern]) => pattern.test(bare));
if (rule !== undefined) found.push({ kind: rule[0], statement: excerpt(statement) });
if (rule !== undefined) found.push({ kind: rule[0], statement: statementExcerpt(statement) });
}

@@ -125,0 +112,0 @@ return found;

@@ -8,3 +8,3 @@ // Single responsibility: prove the live schema and the migration ledger agree. Drift is the

import { DbError } from './errors';
import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
import { foreignKeyTarget, onDeleteRule, rebuildForeignKey } from './foreign-key';
import { indexMethodOf } from './index-method';

@@ -196,5 +196,3 @@ import {

'migrations declare',
fix:
`${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}` +
' # in a new migration',
fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
};

@@ -201,0 +199,0 @@ }

@@ -5,2 +5,3 @@ // Single responsibility: the structural mirror of `@ultimat3/entity`'s entity description. `db` is

import type { ColumnDefaultLike } from './column-default';
import type { IndexMethod } from './index-method';

@@ -36,5 +37,44 @@

readonly generated?: string | undefined;
/**
* What the column defaults to, when the declaration carries the value and not only the flag.
* Optional for the reason `onDelete` and `generated` are: this package cannot import
* `@ultimat3/entity`, so a field that is not on the projection reaches no DDL at all.
*
* `hasDefault` beside it is NOT redundant and is not being replaced. It is the older, narrower
* fact — "this column defaults to something" — and it is what `generatedClause` reads to refuse
* a column that is both generated and defaulted. Where `hasDefault` is true and this is absent,
* `defaultExpression` falls back to the two defaults inferable from the kind and everything else
* is REPORTED as unrendered rather than dropped (`unrendered.ts`).
*/
readonly default?: ColumnDefaultLike | undefined;
}
/**
* Structurally assignment-compatible with `@ultimat3/entity`'s `InvariantDescription`.
*
* An invariant is written once and enforced twice — in the app on every write, and in Postgres as
* a CHECK or a unique index. The second half reached no SQL at all until 2026-08-25, because this
* mirror had no `invariants` field: a regenerated migration silently held none of them, including
* the composite UNIQUE that `upsertAll`'s `on conflict` is inferred against, so a replay-safe
* write became a duplicate row on a database the framework itself generated.
*/
export interface InvariantDescriptionLike {
/** The rule's own name. `<table>_<name>_check` / `_key` is the constraint it becomes. */
readonly name: string;
/** `assert` is a rule only the app can run — no SQL, and nothing for a migration to emit. */
readonly kind: 'check' | 'unique' | 'assert';
readonly message: string;
/** The predicate for a `check`, the column list for a `unique`, `null` for an `assert`. */
readonly sql: string | null;
/** Partial-constraint predicate, e.g. `deleted_at is null`. `null` covers every row. */
readonly where: string | null;
/**
* The physical columns a `unique` names, when the declaration carries them. Optional, and
* `uniqueColumns()` falls back to splitting `sql` when it is absent — see the argument in
* `invariant-ddl.ts` for why that fallback is a validated re-read and not a name parsed back.
*/
readonly columns?: readonly string[] | undefined;
}
/**
* Structurally assignment-compatible with `@ultimat3/entity`'s `IndexDescription`.

@@ -71,2 +111,8 @@ *

readonly indexes: readonly IndexDescriptionLike[];
/**
* The domain rules the database must hold too. Optional so no existing description changes
* shape, exactly as `onDelete`, `generated` and `using` are — and absent reads as "declares
* none", which is what every hand-built description in this package's own tests is.
*/
readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
}

@@ -8,2 +8,3 @@ // Single responsibility: which foreign keys a migration must add or drop, and into which of the

import type { ForeignKeyDescription, TableDescription } from './introspect';
import { identifier } from './sql';

@@ -141,8 +142,15 @@ /** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */

/** A key whose target is being dropped: gone on the way up, a note on the way back. */
/**
* A key whose target is being dropped: gone on the way up, a note on the way back.
*
* The note goes through `identifier` too. A `--` comment ends at the first newline, so a name
* holding one is a second command on the line after it — the same escape `columnClause` closed,
* one quoting rule short of the statement above it.
*/
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
preDrops.up.push(dropForeignKey(table, constraint));
preDrops.down.push(
`-- constraint "${constraint}" on "${table}" cannot be restored; "${target}" is gone`,
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
`cannot be restored; ${identifier(target).text} is gone`,
);
}

@@ -7,2 +7,3 @@ // Single responsibility: what a foreign key *is* — where it points — and the two statements that

import type { ForeignKeyDescription } from './introspect';
import { identifier } from './sql';

@@ -55,3 +56,12 @@ /**

const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ');
/**
* Through `identifier`, never `"${…}"` — the package's one rule, which every name this file writes
* now goes through. A name that closes its own quote produced a real `drop table` through
* `generateMigration` once already, out of `columnClause`, and every name below arrives the same
* way: from a projection this package cannot typecheck, or from a `.snapshot.json` on disk that
* anything may edit. Being unreachable with a hostile name today is a property of the CALLERS, not
* of this file, and it survives exactly until the next refactor.
*/
const quoted = (names: readonly string[]): string =>
names.map((name) => identifier(name).text).join(', ');

@@ -81,5 +91,5 @@ /**

return (
`alter table "${table}" add constraint "${key.name}" ` +
`alter table ${identifier(table).text} add constraint ${identifier(key.name).text} ` +
`foreign key (${quoted(key.columns)}) ` +
`references "${key.referencedTable}" (${quoted(key.referencedColumns)})` +
`references ${identifier(key.referencedTable).text} (${quoted(key.referencedColumns)})` +
`${rule === null ? '' : ` on delete ${rule}`};`

@@ -91,3 +101,37 @@ );

export function dropForeignKey(table: string, constraint: string): string {
return `alter table "${table}" drop constraint "${constraint}";`;
return `alter table ${identifier(table).text} drop constraint ${identifier(constraint).text};`;
}
/**
* The drop/add pair that moves a key's `on delete` rule — a rebuild, because Postgres has no
* `alter constraint` for it — for a `fix:` line an author pastes into a new migration.
*
* It lives here, beside the two writers, because it is the one caller reading values neither of
* them may assume: `held` is the **live catalog's** and `declared` is a `.snapshot.json`'s. Both
* writers refuse rather than guess — `identifier()` on a name holding a quote, a space or a
* backslash (all three legal inside a quoted Postgres name), and `addForeignKey` on an `on delete`
* rule Postgres does not have. That is exactly right for DDL this package SENDS and wrong for a
* `fix:` line: `diffSchema` is documented pure and total, so a pair it cannot write is a sentence,
* never a throw — a drift check that raises in place of its report hands the caller an exception
* where a verdict was asked for. The constraint is still named, because it is the only thing
* identifying which one, quoted by `JSON.stringify`, which escapes rather than refuses; nothing
* runs this string either way.
*/
export function rebuildForeignKey(
table: string,
declared: ForeignKeyDescription,
held: ForeignKeyDescription,
): string {
// The writers are ASKED whether they can write the pair — never a second copy of their rules
// beside them, which is the copy that drifts. A refusal is the answer, and nothing here reads
// the thrown value.
try {
return `${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}`;
} catch {
return (
`drop constraint ${JSON.stringify(held.name)} on table ${JSON.stringify(table)} and add ` +
'it back with the on delete rule the migrations declare — by hand: x db gen cannot ' +
'write this pair'
);
}
}

@@ -7,2 +7,4 @@ // Single responsibility: turn an entity snapshot into a timestamped, reversible migration.

import { assert, systemClock } from '@ultimat3/core';
import { checkClauses, checkPlan, declaredChecks } from './check-ddl';
import { defaultExpression } from './column-default';
import { isDestructive } from './destructive';

@@ -27,2 +29,5 @@ import { dropOrder } from './drop-order';

} from './introspect';
import { declaredIndexes } from './invariant-ddl';
import { identifier } from './sql';
import { type UnrenderedDeclaration, unrenderedComment, unrenderedOf } from './unrendered';

@@ -49,13 +54,2 @@ const SQL_TYPES: Readonly<Record<string, string>> = {

/**
* Entity descriptions carry `hasDefault` but not the expression, so the two generated defaults
* are inferred from the blessed column helpers. Anything else is left to a follow-up migration.
*/
function defaultExpression(column: ColumnDescriptionLike): string | null {
if (!column.hasDefault) return null;
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
if (column.kind === 'timestamptz') return 'now()';
return null;
}
function columnClause(column: ColumnDescriptionLike): string {

@@ -65,3 +59,11 @@ // The generation clause sits directly after the type, and `generatedClause` refuses the pairs

// generated column: it may carry no default, and `hasDefault` is what the refusal reads.
const parts = [`"${column.column}"`, `${sqlType(column.kind)}${generatedClause(column)}`];
//
// Through `identifier`, never `"${…}"`: the name arrives from a projection this package cannot
// typecheck and a name that closes its own quote produced a real `drop table` through
// `generateMigration` once already. It is also what makes an unrendered report safe to write
// into a `--` comment, since generation refuses the dangerous name before the comment exists.
const parts = [
identifier(column.column).text,
`${sqlType(column.kind)}${generatedClause(column)}`,
];
const expression = defaultExpression(column);

@@ -71,5 +73,6 @@ if (expression !== null) parts.push(`default ${expression}`);

if (column.unique && !column.primaryKey) parts.push('unique');
if (column.check !== null) parts.push(`check (${column.check})`);
// No `references` clause. A foreign key is `alter table … add constraint`, emitted after every
// table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
// No `check` clause — written here it was ANONYMOUS and reached `create table` alone, so
// regenerating dropped the value set `enumerated()` declares; `check-ddl.ts` owns every CHECK now.
// No `references` clause either: a foreign key is `alter table … add constraint`, emitted after
// every table exists (`foreignKeyPlan`) — inline it must point at a table that already exists, and
// entity registration order is the app's import order, which says nothing about that.

@@ -123,2 +126,3 @@ return parts.join(' ');

}));
const checks = declaredChecks(entity);
return {

@@ -132,3 +136,6 @@ schema: 'public',

// silently kept as a total one is a constraint the entity no longer declares.
indexes: entity.indexes.map((index) => ({
//
// `declaredIndexes`, so a `unique` invariant's index is recorded exactly like an entity's
// own — a statement emitted and not recorded is `42P07` on the very next `x db gen`.
indexes: declaredIndexes(entity).map((index) => ({
name: index.name,

@@ -146,2 +153,6 @@ columns: [...index.columns],

foreignKeys: foreignKeysOf(entity),
// Absent, never `[]`, on a table declaring none: a sidecar that predates this field must
// read as "nothing recorded" so the next generation adds the constraints the database is
// genuinely missing — the rule `using` and `generated` already state one field up.
...(checks.length === 0 ? {} : { checks }),
};

@@ -155,8 +166,13 @@ });

if (entity.primaryKey.length > 0) {
clauses.push(`primary key (${entity.primaryKey.map((key) => `"${key}"`).join(', ')})`);
const key = entity.primaryKey.map((column) => identifier(column).text).join(', ');
clauses.push(`primary key (${key})`);
}
const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
// After the key, so a table declaring no invariant emits the statement it always emitted.
clauses.push(...checkClauses(entity));
const statements = [
`create table ${identifier(entity.table).text} (\n ${clauses.join(',\n ')}\n);`,
];
// Every column of a new table carries its own clause, so every `unique` one brings its index.
const added = new Set(entity.columns.map((column) => column.column));
for (const index of entity.indexes) {
for (const index of declaredIndexes(entity)) {
if (impliedByColumnClause(entity, index, added)) continue;

@@ -197,3 +213,5 @@ statements.push(createIndex(entity.table, index));

const direction = index.order === null ? '' : ` ${index.order}`;
const columns = index.columns.map((column) => `"${column}"${direction}`).join(', ');
const columns = index.columns
.map((column) => `${identifier(column).text}${direction}`)
.join(', ');
const predicate = index.where === null ? '' : ` where (${index.where})`;

@@ -203,3 +221,6 @@ // Re-derived from the closed set, never spliced: `indexMethodSql` answers `''` for a btree, so

// byte, and one that declared a method Postgres does not have is refused instead of built.
return `${kind} "${index.name}" on "${table}"${indexMethodSql(method)} (${columns})${predicate};`;
return (
`${kind} ${identifier(index.name).text} on ${identifier(table).text}` +
`${indexMethodSql(method)} (${columns})${predicate};`
);
}

@@ -228,4 +249,4 @@

const alter = (type: string): string =>
`alter table "${table}" alter column "${column.column}" type ${type} ` +
`using "${column.column}"::${type};`;
`alter table ${identifier(table).text} alter column ${identifier(column.column).text} ` +
`type ${type} using ${identifier(column.column).text}::${type};`;
plan.up.push(alter(wanted));

@@ -265,3 +286,3 @@ plan.down.push(alter(recorded.dataType));

if (indexShape(index) === indexShape(recorded)) return;
plan.up.push(`drop index "${index.name}";`, createIndex(table, index));
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

@@ -281,3 +302,3 @@ // the recorded definition is what must land last, after the new one is dropped.

}),
`drop index "${index.name}";`,
`drop index ${identifier(index.name).text};`,
);

@@ -310,14 +331,16 @@ }

const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
plan.up.push(`alter table "${entity.table}" add column ${clause};`);
plan.up.push(`alter table ${identifier(entity.table).text} add column ${clause};`);
if (nullable) {
plan.up.push(
`-- backfill "${column.column}", then: ` +
`alter table "${entity.table}" alter column "${column.column}" set not null;`,
`-- backfill ${identifier(column.column).text}, then: alter table ` +
`${identifier(entity.table).text} alter column ${identifier(column.column).text} set not null;`,
);
}
plan.down.push(`alter table "${entity.table}" drop column "${column.column}";`);
plan.down.push(
`alter table ${identifier(entity.table).text} drop column ${identifier(column.column).text};`,
);
}
const indexed = new Map(live.indexes.map((index) => [index.name, index]));
for (const index of entity.indexes) {
for (const index of declaredIndexes(entity)) {
const recorded = indexed.get(index.name);

@@ -334,4 +357,9 @@ // A rebuilt column took its indexes down with it, so this one is CREATED rather than compared:

plan.up.push(createIndex(entity.table, index));
plan.down.push(`drop index "${index.name}";`);
plan.down.push(`drop index ${identifier(index.name).text};`);
}
// Last: a CHECK may read a column this migration just added, and `add constraint` on a column
// 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);
}

@@ -362,2 +390,9 @@

readonly destructive: boolean;
/**
* Every declaration this generator could not write down. Empty on a migration that carries the
* whole schema, which is what makes it readable as a verdict rather than as noise — and the same
* list `unrenderedComment` writes into the top of `up`, so a caller that would rather refuse
* (`x db gen`) and a reviewer reading the committed file are looking at one answer.
*/
readonly unrendered: readonly UnrenderedDeclaration[];
}

@@ -396,3 +431,3 @@

plan.up.push(...createTable(entity));
plan.down.push(`drop table "${entity.table}";`);
plan.down.push(`drop table ${identifier(entity.table).text};`);
continue;

@@ -410,5 +445,8 @@ }

}
plan.up.push(`alter table "${entity.table}" drop column "${column.name}";`);
plan.up.push(
`alter table ${identifier(entity.table).text} drop column ${identifier(column.name).text};`,
);
plan.down.push(
`alter table "${entity.table}" add column "${column.name}" ${column.dataType};` +
`alter table ${identifier(entity.table).text} add column ` +
`${identifier(column.name).text} ${column.dataType};` +
' -- data is not restored',

@@ -431,4 +469,8 @@ );

for (const table of order.tables) {
plan.up.push(`drop table "${table.name}";`);
plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
plan.up.push(`drop table ${identifier(table.name).text};`);
// `identifier` in a comment too: a `--` ends at the first newline, so a name holding one
// would put a second command on the next line of `down`.
plan.down.push(
`-- ${identifier(table.name).text} cannot be restored; recover it from a backup`,
);
}

@@ -440,3 +482,17 @@

const id = `${migrationStamp(options.now ?? systemClock.now())}_${slugify(options.name)}`;
const up = plan.up.join('\n');
// At the TOP of `up`, so what is MISSING is the first thing read — and a line comment, so it is
// noise to every reader that matters: `statementsOf` drops a chunk of comments alone,
// `stripSqlNoise` blanks it before `isDestructive` looks for a verb, and the server ignores it.
//
// Never onto an EMPTY diff. `@ultimat3/cli`'s `generateAppMigration` reads `up.trim().length` as
// "nothing changed" and re-records the hash sidecar instead of writing a file; a comment there
// would make every `x db gen` on an app with an unrendered default write a migration holding no
// statement — a ledger row, a checksum and a place in the apply order for nothing. The list is
// still on `GeneratedMigration.unrendered`, which is where a caller with no file reads it.
//
// `current`, not the entities alone: an `assert` whose CHECK a previous migration recorded is a
// loss only because THIS plan drops it, and the recorded schema is the only thing that knows.
const unrendered = unrenderedOf(options.entities, current);
const body = plan.up.join('\n');
const up = body.length === 0 ? body : unrenderedComment(unrendered) + body;
return {

@@ -451,3 +507,4 @@ id,

destructive: isDestructive(up),
unrendered,
};
}

@@ -10,2 +10,3 @@ // Single responsibility: what a column the DATABASE computes contributes to DDL, and what changes

import type { ColumnDescription } from './introspect';
import { identifier } from './sql';

@@ -15,4 +16,10 @@ /** How a column that moved was brought back into line — what the caller has to do next, if anything. */

/**
* Through `identifier`, never `"${…}"`, for the reason `columnClause` states — and this path is
* the one `columnClause` does NOT cover: `regenerate` is reached from `retypeColumn` for a column
* that ALREADY exists, so nothing on the way here has looked at the name. Both operands arrive
* from a projection this package cannot typecheck and from a `.snapshot.json` anything may edit.
*/
const alterColumn = (table: string, column: string): string =>
`alter table "${table}" alter column "${column}"`;
`alter table ${identifier(table).text} alter column ${identifier(column).text}`;

@@ -87,12 +94,14 @@ /**

if (held === null) {
const dropColumn = `alter table ${identifier(table).text} drop column ${identifier(column.column).text};`;
plan.up.push(
`alter table "${table}" drop column "${column.column}";`,
`alter table "${table}" add column "${column.column}" ${wantedType}` +
`${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
dropColumn,
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
`${wantedType}${generatedClause(column)}${column.notNull ? ' not null' : ''};`,
);
// Pushed forwards and read backwards — `down` is reversed at assembly.
plan.down.push(
`alter table "${table}" add column "${column.column}" ${recorded.dataType};` +
`alter table ${identifier(table).text} add column ${identifier(column.column).text} ` +
`${recorded.dataType};` +
' -- was not a generated column',
`alter table "${table}" drop column "${column.column}";`,
dropColumn,
);

@@ -99,0 +108,0 @@ return 'rebuilt';

@@ -15,2 +15,10 @@ // Single responsibility: the public API of @ultimat3/db. Explicit named exports only —

} from './branch';
export {
checkClauses,
checkPlan,
columnCheckName,
columnChecks,
columnNamesConstraint,
declaredChecks,
} from './check-ddl';
export type {

@@ -36,2 +44,4 @@ DbClient,

} from './client';
export type { ColumnDefaultLike } from './column-default';
export { defaultExpression } from './column-default';
export { defaultClient, REPLICA_URL_ENV } from './default-client';

@@ -61,2 +71,3 @@ export type { DestructiveKind, DestructiveStatement } from './destructive';

IndexDescriptionLike,
InvariantDescriptionLike,
} from './entity-shape';

@@ -102,2 +113,3 @@ export type { DbErrorCode, DbErrorInit } from './errors';

export type {
CheckDescription,
ColumnDescription,

@@ -111,2 +123,9 @@ ForeignKeyDescription,

export { buildSchema, findTable, introspect } from './introspect';
export {
constraintNameFor,
declaredIndexes,
invariantChecks,
uniqueColumns,
} from './invariant-ddl';
export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
export type {

@@ -180,1 +199,5 @@ AppliedMigration,

export { beginStatement, currentTx, withTransaction } from './transaction';
export type { GeneratableForm } from './ungeneratable';
export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
export type { UnrenderedDeclaration } from './unrendered';
export { unrenderedComment, unrenderedOf } from './unrendered';

@@ -60,2 +60,17 @@ // Single responsibility: read the live schema out of `information_schema` / `pg_catalog` into a

/**
* A named CHECK constraint, as the SNAPSHOT spells it — an entity invariant of kind `check`.
*
* Absent from every row this module reads out of the live catalog, deliberately and for the reason
* `ColumnDescription.generated` gives one field up: `pg_get_constraintdef` answers Postgres' own
* rewriting of the expression, so a catalog value could never compare equal to a generated one and
* drift would report a correct database forever. The diff that DOES read it is `x db gen`'s, where
* both sides are this generator's own spellings.
*/
export interface CheckDescription {
readonly name: string;
/** The predicate, exactly as the entity's invariant spells it. */
readonly expression: string;
}
export interface TableDescription {

@@ -68,2 +83,10 @@ readonly schema: string;

readonly foreignKeys: readonly ForeignKeyDescription[];
/**
* The CHECK constraints migrations declare. Absent — never `[]` — on a table that declares none
* and in every sidecar written before this field existed, matching `IndexDescription.using`: a
* snapshot that predates it must read as "nothing recorded" so the next `x db gen` emits the
* `add constraint` the database is genuinely missing, rather than as "recorded none", which
* would leave every already-generated app's invariants unenforced forever.
*/
readonly checks?: readonly CheckDescription[] | undefined;
}

@@ -70,0 +93,0 @@

@@ -7,2 +7,3 @@ // Single responsibility: turn the JSON of a `<id>.snapshot.json` sidecar into a `SchemaDescription`

import type {
CheckDescription,
ColumnDescription,

@@ -32,8 +33,28 @@ ForeignKeyDescription,

if (!isRow(value)) return undefined;
const { name, dataType, nullable, default: fallback, position } = value;
const { name, dataType, nullable, default: fallback, position, generated } = value;
if (!str(name) || !str(dataType) || !bool(nullable) || !nullableStr(fallback)) return undefined;
if (typeof position !== 'number') return undefined;
return { name, dataType, nullable, default: fallback, position };
// `generated` was recorded by `snapshotOf` and dropped HERE, silently, for as long as the field
// has existed: the sidecar carried the expression and the parse handed back a column without it,
// so `retypeColumn` read every generated column as newly generated and rebuilt it on every
// `x db gen`. Absent stays absent — an ordinary column must gain no key.
if (!(generated === undefined || str(generated))) return undefined;
return {
name,
dataType,
nullable,
default: fallback,
position,
...(generated === undefined ? {} : { generated }),
};
}
/** The predicate is the snapshot's own spelling, so both halves are plain strings or nothing. */
function check(value: unknown): CheckDescription | undefined {
if (!isRow(value)) return undefined;
const { name, expression } = value;
if (!str(name) || !str(expression)) return undefined;
return { name, expression };
}
function index(value: unknown): IndexDescription | undefined {

@@ -93,3 +114,10 @@ if (!isRow(value)) return undefined;

if (columns === undefined || indexes === undefined || foreignKeys === undefined) return undefined;
return { schema, name, columns, primaryKey, indexes, foreignKeys };
// Absent, never `[]`. A sidecar written before constraints were recorded says nothing about
// them, and reading that as "this table declares none" would drop every invariant an app has
// already generated instead of adding the ones its database is missing.
const raw = value['checks'];
if (raw === undefined) return { schema, name, columns, primaryKey, indexes, foreignKeys };
const checks = all(raw, check);
if (checks === undefined) return undefined;
return { schema, name, columns, primaryKey, indexes, foreignKeys, checks };
}

@@ -96,0 +124,0 @@

Sorry, the diff of this file is too big to display