@ultimat3/db
Advanced tools
+212
| // 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": { |
+4
-0
@@ -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 | |
+2
-15
@@ -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; |
+2
-4
@@ -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 @@ } |
+46
-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`, | ||
| ); | ||
| } |
+48
-4
@@ -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' | ||
| ); | ||
| } | ||
| } |
+94
-37
@@ -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'; |
+23
-0
@@ -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'; |
+23
-0
@@ -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
473204
16.94%55
14.58%6834
16.8%431
0.94%+ Added
+ Added
- Removed
- Removed
Updated