@ultimat3/db 13.0.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -653,6 +653,208 @@ right database, and `x db gen`'s `retypeColumn` owns that question where both si
653
653
  The `fix:` is the `alter table … set not null` itself and deliberately not `x db gen`, which has
654
654
  never emitted one and would answer with an empty migration.
655
655
 
656
+ **A CHECK that went missing is drift, `As of 2026-08-25`, and it is compared by NAME because it
657
+ cannot be compared any other way.** `pg_get_constraintdef` answers Postgres' own rewriting —
658
+ `status in ('draft', 'published')` reads back as
659
+ `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`, measured on 18.4
660
+ (`drift-check.live.test.ts`) — so a catalog value could never equal a generated one and a text
661
+ comparison reports a correct database as wrong forever. That is why nothing here read
662
+ `pg_constraint` for CHECKs at all, and why `alter table … drop constraint` in a psql session was
663
+ `ok: true` on every check that followed it.
664
+
665
+ **The two readings do not share a field, and that split is the whole design.**
666
+ `TableDescription.checks` is the DECLARED side — name **and** expression, `snapshotOf`'s own
667
+ spelling, the value `checkPlan` diffs. `TableDescription.checkNames` is the CATALOG side — `conname`
668
+ for `contype = 'c'`, names and nothing else, written only by `introspect()`. Filling `checks` from
669
+ the catalog instead would put a rewritten expression where `checkPlan` expects a generated one, and
670
+ every `x db gen` in every app would then drop and re-add every constraint it has, forever, because
671
+ the two strings can never be equal. Split, the TYPE says which reading a value came from and
672
+ `checkPlan` cannot be handed a catalog value by accident.
673
+
674
+ Three rules ride with it. **Absent and `[]` are different on both sides** — an absent `checks` is a
675
+ sidecar written before the field existed (declares nothing, so nothing can be missing), and an
676
+ absent `checkNames` is a description that never asked the catalog, which reading as "the database
677
+ holds none" is one finding per declared constraint against a database nobody looked at.
678
+ `introspect()` therefore always writes `checkNames`, `[]` included. **Only the declared side is
679
+ judged**, the rule `compareIndexes` and `compareForeignKeys` already state: a NOT NULL (`contype =
680
+ 'n'` from Postgres 17 on), an `enumerated()` column's old anonymous form and every constraint an
681
+ extension brought would each be a finding against a database that is exactly right. **There is no
682
+ `changed-check` and there never will be** — presence is a boolean, the predicate is text, and
683
+ normalising the text is an expression parser competing with the server's. `missing-check`'s `fix:`
684
+ is the `add constraint` statement itself, not `x db migrate`: the migration declaring it is already
685
+ in the ledger, so the migrator applies nothing, and the declared side carries the predicate that
686
+ makes an executable fix possible at all.
687
+
688
+ **A retype takes the objects written against the column out of its way first, `As of 2026-08-25`,
689
+ and `retype-dependents.ts` decides which those are.** Postgres compiles a partial index's predicate
690
+ and a CHECK's expression against the column's type at creation and cannot recompile either:
691
+ `alter table "posts" alter column "status" type text using "status"::text` answered
692
+ `42883 operator does not exist: text = post_status` and the migration aborted mid-run — inside
693
+ `ROLE=migrate`, with the ledger recording nothing. It is what blocked `examples/dummy` from
694
+ regenerating at all.
695
+
696
+ **Which objects are dependent is measured, never assumed** (`generate-retype.live.test.ts`, one
697
+ shape at a time on 18.4):
698
+
699
+ | recorded object | survives the ALTER |
700
+ |---|---|
701
+ | btree over the column — plain, unique or composite | **yes**, Postgres rebuilds it itself |
702
+ | partial index whose predicate names the column | **no — 42883** |
703
+ | partial index naming another column | yes |
704
+ | CHECK whose expression names the column | **no — 42883** |
705
+ | a view over the column | no, `0A000`, and no snapshot records a view |
706
+
707
+ So only an expression that MENTIONS the column is moved, and a plain btree is left alone — dropping
708
+ it is a table scan to rebuild for nothing.
709
+
710
+ **The reference test over-approximates on purpose, and it cannot be narrowed by type name.**
711
+ Measured: `char(1)` → `char(3)`, `varchar(80)` → `text` and `numeric` → `integer` all re-derive
712
+ their predicates cleanly, while `integer` → `text` under `check (c >= 0)` is `42883` — both sides
713
+ built-ins. Whether an expression re-resolves depends on operator resolution, which is exactly the
714
+ knowledge a generator with no database cannot have, so every ambiguous case answers "dependent":
715
+ a miss is `42883` in the release phase, a false positive is a rebuild on a statement that is
716
+ already rewriting the whole table under ACCESS EXCLUSIVE. `referencesColumn` walks `sql-scan.ts`
717
+ rather than matching a substring — a name inside a literal or a comment is not a reference,
718
+ `status_code` is not `status`, and a **quoted** identifier IS one, which is the one span the lexer
719
+ calls noise and this reader must not skip.
720
+
721
+ **What is moved aside is put back by the ORDINARY diff, never twice.** `up` drops the dependents
722
+ before the ALTER and `MovedAside` carries their names to the two arms that would otherwise act on a
723
+ thing that is no longer there: the index loop CREATES a declared name instead of comparing it
724
+ (`redefineIndex` is silent on a definition that never moved, which here means the table comes out
725
+ with no index at all), and `checkPlan` neither drops nor re-adds a predropped name — a declared one
726
+ takes the bare `add constraint` because the name is provably free, and a recorded one the entity no
727
+ longer declares is simply gone, which is what `checkPlan` would have done to it anyway. `down`
728
+ pushes the restores forwards and is reversed as a whole, so it reads: drop the new objects, retype
729
+ back, then recreate the ones compiled against the old type — restoring first is `42883` in the
730
+ other direction. What it restores is what the snapshot RECORDED, never what the entity declares.
731
+
732
+ **Three things it does not move, and each is measured rather than assumed.** A **foreign key** over
733
+ the retyped column is `ERROR: foreign key constraint "c_k_fkey" cannot be implemented` — the
734
+ dependents are in `live.foreignKeys` *and* in every OTHER table's, which `diffTable` cannot see from
735
+ one entity, and the statements belong to `foreign-key-plan.ts`'s own buckets, so half a fix here
736
+ would collide with it. Neither tracked app can reach it (every key is `uuid` on both sides) and it
737
+ is the next thing to close. A **generated column's** own retype (`regenerate`) does not move
738
+ dependents either: it emits `set expression` and `alter … type` with no `using`, and nothing covers
739
+ a partial index over one. And **what no migration wrote down** is invisible by construction —
740
+ `x db gen` runs with no database open, so a hand-added expression index over the column is still
741
+ `42883` and a VIEW over it is `0A000` whatever this does, since `SchemaDescription` has a field for
742
+ neither.
743
+
744
+ **`index-ddl.ts` holds `createIndex`, `redefineIndex`, `indexShape`, `dropIndex` and
745
+ `asDeclared`**, split out of `generate.ts` at the 500-line ceiling along the seam `check-ddl.ts` and
746
+ `generated-column.ts` already drew — `generate.ts` assembles a plan, `index-ddl.ts` writes the index
747
+ statements in it. `drift-findings.ts` is the same split on the other file: every `DriftDifference`
748
+ constructor and the `DriftKind` union, with `drift.ts` keeping the comparisons and re-exporting both
749
+ types explicitly so the public surface does not move.
750
+
751
+ **An entity's INVARIANTS reach the DDL, `As of 2026-08-25`, and `invariant-ddl.ts` is what they
752
+ become.** `EntityDescriptionLike` had no `invariants` field at all — the same seam gap
753
+ `onDelete` carried until 3.0 — so a regenerated migration held **none** of them: measured on
754
+ `examples/dummy`, nine database-expressible rules across six tables, including
755
+ `member_unique_per_org UNIQUE(org_id, user_id)`, which is the constraint `upsertAll`'s inferred
756
+ `on conflict` rests on, and `post_slug_unique`. The `drift` gate step hashes entity SOURCE against a
757
+ sidecar and never reads the SQL, so the squash that lost them would have been **green**.
758
+
759
+ Four rules, none optional.
760
+
761
+ | Rule | Why |
762
+ |---|---|
763
+ | a `check` is a named `CONSTRAINT`, a `unique` is a unique **INDEX**, an `assert` is nothing | a soft-deleting entity stamps `deleted_at is null` onto a unique invariant and Postgres has no partial unique CONSTRAINT — only a partial unique index. An `assert` declares itself as a rule only the app can judge (`sql: null`), which is what `hasJsOnlyInvariant` already reads it as, so on its own it is not an unrendered loss — see the next paragraph for the case where it is |
764
+ | a `unique` invariant joins the ONE declared index list (`declaredIndexes`) | `createTable`, `diffTable` and `snapshotOf` must agree about what exists. A `create unique index` emitted and not recorded is `42P07` on the very next `x db gen` — worse than the silent drop |
765
+ | a `check` is recorded on `TableDescription.checks`, **absent** and never `[]` | a sidecar that predates the field must read as "nothing recorded" so the next generation ADDS the constraints the database is genuinely missing. `[]` would mean "declares none" and leave every already-generated app's invariants unenforced forever. That absence is the repair path, and `parseSnapshot` preserves it |
766
+ | the constraint name is `<table>_<name>_<check\|key>`, re-derived, bounded at 63 bytes, and **validated as an identifier** | nothing validates an invariant name at declaration, so `invariant('x" ); drop table t; --', …)` type-checks all the way to `create table` — the identical hole `columnName` carried. `identifier()` is the one rule; `constraintNameUnsafe` (`invariant-errors.ts`) exists only for its `fix:`, which names the `invariant()` call an author edits, and `generate-invariant.test.ts` pins that line because a guard whose value is its message is proven by nothing else |
767
+
768
+ **An `assert` IS an unrendered loss the moment a migration recorded its CHECK, `As of 2026-08-25`,
769
+ and that is the half `unrenderedOf` could not see.** `checkPlan` drops a recorded check nothing
770
+ declares — "a snapshot may not lie" — and an `assert` declares nothing in SQL, so regenerating
771
+ **deletes the database's half of a rule the entity still states**, with nothing added back and no
772
+ `-- destructive:` marker (`destructive.ts` excludes `drop constraint` by name, on the argument that
773
+ the database rebuilds it; here nothing does). Measured on `examples/dummy`: `x db gen` emitted
774
+ `alter table "posts" drop constraint "post_slug_shape"` and four more, and `unrenderedOf` answered
775
+ `[]` — so `@ultimat3/cli`'s `repairFix`, whose whole job is to refuse `x db gen` as the instruction
776
+ when the generator would lose something, read the empty list and handed out
777
+ `x db gen "drop post_slug_shape"`: the command that performs the loss, offered as the repair for it.
778
+
779
+ **The discriminator is what the recorded schema holds, never the kind.** An `assert` with nothing
780
+ recorded behind it loses nothing and is reported by nothing — the previous reading was right about
781
+ that, and a marker on nearly every app's every migration marks none. `unrenderedOf(entities,
782
+ current)` therefore takes the recorded schema, **required and nullable**: a caller with no sidecar
783
+ (the first migration) has to say `undefined`, because an argument nobody passes is a blind answer
784
+ nobody notices, which is exactly how the five drops shipped. `namesConstraint` (`invariant-ddl.ts`)
785
+ is the match, under **both** spellings — this generator's `<table>_<name>_check` and the rule's own
786
+ name, which is what a hand-written `0001_init.sql` calls it — and it never throws, because its
787
+ caller is a reporter reached by the `drift` gate step where a throw replaces a finding with a crash.
788
+ Self-clearing: once the drop is applied and the new sidecar written, nothing records the check and
789
+ the next generation reports nothing.
790
+
791
+ **A COLUMN declares a CHECK too, and until 2026-08-25 it reached `create table` and nothing else.**
792
+ `check-ddl.ts` is what it becomes. `columnClause` wrote `check (…)` **inline and anonymous**,
793
+ `snapshotOf` recorded no check for a column and `diffTable` had no arm for one — so the constraint
794
+ existed only in the statement that created the table and was invisible to every generation after it.
795
+ Neither `drift` nor `unrendered` could see the loss: the gate's `drift` step hashes entity SOURCE
796
+ against a sidecar and never reads the SQL, and `unrenderedOf` keys on declared **invariants**, which
797
+ these are not — they are minted by the column builder (`enumerated()`'s value set,
798
+ `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound;
799
+ `packages/entity/src/enum-column.ts` implements `enumerated(V)` as `kind: 'text'` plus
800
+ `check: oneOf(V)`). Three consequences, measured on `examples/dummy`: a value added to
801
+ `enumerated()` generated **no migration at all**, so the app accepted `'archived'` and the database
802
+ answered `23514`; a regenerated migration retyped every Postgres-ENUM column to bare `text` with no
803
+ CHECK beside it; and the sidecar claimed a schema the database did not have, so `down` and every
804
+ later diff reasoned off a lie.
805
+
806
+ Four rules, none optional.
807
+
808
+ | Rule | Why |
809
+ |---|---|
810
+ | every CHECK is a **named** constraint on ONE list (`declaredChecks` = `columnChecks` then `invariantChecks`) | `createTable`, `diffTable` and `snapshotOf` must agree about what exists, the rule `declaredIndexes` already states. An anonymous constraint is not diffable at all — there is nothing to match a recorded name against |
811
+ | the name is `<table>_<column>_check`, and it is **not a convention chosen here** | it is the name Postgres itself mints for an anonymous single-column CHECK — measured, `check-ddl.live.test.ts`, including for a multi-clause predicate like `scaleCheck`'s. Any other spelling makes the repair add a SECOND constraint beside the one an already-generated database is holding |
812
+ | an ADD onto a column the recorded schema already had is `drop constraint if exists` **then** `add constraint` | the two databases the generator cannot tell apart read identically in the snapshot — one is holding the old anonymous form under exactly this name, one is holding nothing because the old `diffTable` emitted nothing. A bare `add constraint` is `42710` on the first (measured), inside `ROLE=migrate`, with the server's words and none of the entity's. A column this migration ADDS, or one `regenerate` rebuilt, takes the bare add: the name provably cannot be taken |
813
+ | two declarations naming one constraint are **refused**, never deduped | `invariant('status', …)` on a table whose `status` is an `enumerated()` derives the same `posts_status_check` the column owns, and two `add constraint` under one name is `42710` — a migration nobody can apply, which is worse than either declaration being dropped. `X_INVARIANT` through core's `assert`, the refusal `createIndex` already gives a unique GIN. Unlike two identical index definitions there is nothing to dedup: the predicates differ |
814
+
815
+ **What an app with an existing sidecar sees on its first `x db gen` after this.** One
816
+ `drop constraint if exists` / `add constraint` pair per checked column, on every table it already
817
+ has — the same absent-never-`[]` discipline `checks` was given for invariants, read the other way
818
+ round: the sidecar says nothing, so the generator emits the pair that is correct whether the
819
+ database is holding the constraint or not. Self-clearing — the new sidecar records the check and the
820
+ next generation emits nothing. It is not free: `add constraint … check` takes `ACCESS EXCLUSIVE` and
821
+ scans the table, under `migrate`'s 3s `lock_timeout`. Validating is deliberate over `NOT VALID`,
822
+ which would accept the rows already in the table — and a database holding the identical constraint
823
+ has none that can fail.
824
+
825
+ **`checkPlan` takes the `rebuilt` set for the same reason `diffTable`'s index loop does.**
826
+ `regenerate`'s plain -> generated path is `drop column` + `add column`, which takes the constraint
827
+ with it while the snapshot still records it — so without the set the check is silently gone, which
828
+ is this file's own defect one level in.
829
+
830
+ `rebuildCheck` is NOT `destructive.ts`'s concern: `drop constraint` is excluded there by name on
831
+ the argument that the database rebuilds it, and here the very next statement does.
832
+
833
+
834
+ **A default's VALUE crosses the seam too.** `ColumnDescriptionLike.default` carries
835
+ `ColumnDefaultLike` and `defaultExpression` renders it; `hasDefault` stays beside it as the older,
836
+ narrower fact `generatedClause` reads. `@ultimat3/entity` projects the value beside the flag
837
+ (`packages/entity/src/describe.ts:175`), so the nine defaults in `examples/dummy` — `plan_code`,
838
+ `billing_currency`, `role`, `tz`, `locale`, `theme`, `digest_opt_in`, `status`, `like_count` — do
839
+ reach the SQL. A description whose producer does not project it still reads `hasDefault` alone, and
840
+ that half **is not silent**: `unrenderedOf` reports each one on `GeneratedMigration.unrendered` and
841
+ `unrenderedComment` writes a `-- UNRENDERED` block at the top of the emitted `up`.
842
+
843
+ Comments, never a refusal, and never onto an EMPTY diff. A refusal would be a generator no app with
844
+ a `.default('draft')` could run at all until tier 2 ships one line, and a migration nobody can
845
+ generate repairs nothing. The empty-diff exclusion is `@ultimat3/cli`'s
846
+ `generateAppMigration`, which reads `up.trim().length === 0` as "nothing changed": a comment there
847
+ makes every `x db gen` write a file holding no statement — a ledger row, a checksum and a place in
848
+ the apply order for nothing.
849
+
850
+ **`REPLICA IDENTITY FULL` is still emitted by nothing, and it does not belong here.** Which tables
851
+ need it is derived from the `live: true` queries in the manifest, not from any entity — this package
852
+ is tier 1 and can see neither. An `EntityDescriptionLike.replicaIdentity` field would be a
853
+ declared-and-never-wired key, which is the defect class this release exists to eliminate. The shape
854
+ that works is a `GenerateOptions.replicaIdentityFull: readonly string[]` passed by
855
+ `@ultimat3/cli`'s `db-generate.ts` from the live-query set, and it lands with that caller or not at
856
+ all.
857
+
656
858
  **A column the DATABASE computes is a different thing at every step, and `generated-column.ts` is
657
859
  all of them** — `As of 2026-08-24`. `ColumnDescriptionLike.generated` carries the
658
860
  `generated always as (<expr>) stored` body across the tier seam (this package cannot import
package/README.md CHANGED
@@ -36,13 +36,17 @@ await withTransaction(async (tx) => {
36
36
  | `replicatedClient()` / `ReplicaStats` / `REPLICA_URL_ENV` | `As of 2026-08-24`: one `DbClient` over a primary and a standby. `baseClient()` builds one when `DATABASE_REPLICA_URL` is set and the single-pool client when it is not |
37
37
  | `INDEX_METHODS` / `IndexMethod` / `indexMethodOf()` / `indexMethodSql()` / `declaredMethod()` / `isIndexMethod()` | `As of 2026-08-24`: an index's access method — `btree` or `gin`, closed. Absent is `btree`, the live side is read open (whatever `pg_am` said), and the DDL literal is re-derived from the set rather than spliced from the input |
38
38
  | `isPlainRead()` | `As of 2026-08-24`: whether a statement may leave the primary. An allow-list — everything it cannot vouch for is the primary's |
39
- | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (access method `As of 2026-08-24`, columns, uniqueness, direction, and whether a predicate is there at all — never its text) and declared foreign keys, matched on where the key points and not on its constraint name, with the `on delete` rule compared through one normalisation `As of 2026-08-19` |
39
+ | `checkDrift()` / `diffSchema()` / `assertNoDrift()` | drift, with a `--json` report. `checkDrift()` is the **post-migrate verification** — the live database against the ledger: columns, declared indexes (access method `As of 2026-08-24`, columns, uniqueness, direction, and whether a predicate is there at all — never its text), declared CHECK constraints by NAME (`missing-check`, `As of 2026-08-25` — never a predicate, which the catalog answers rewritten) and declared foreign keys, matched on where the key points and not on its constraint name, with the `on delete` rule compared through one normalisation `As of 2026-08-19` |
40
40
  | `declaredSchema()` / `expectedSchema()` | `As of 2026-08`: the schema the migrations write down, or `undefined` when the newest one carries no snapshot — never an older snapshot standing in for it |
41
41
  | `parseSnapshot()` | `As of 2026-08`: a `<id>.snapshot.json` sidecar validated to the last nested field, or `undefined`. `{"tables":[null]}` is valid JSON and is not a schema |
42
42
  | `snapshotJson()` | `As of 2026-08`: the sidecar's **bytes** — the JSON Biome would have printed, trailing newline included. The one writer of a `<id>.snapshot.json`, because `JSON.stringify(…, null, 2)` is not formatter-clean and an app's `lint` step rejected the file `x db gen` had just written |
43
43
  | `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
44
44
  | `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
45
- | `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 |
45
+ | `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all. `As of 2026-08-25` a **retype** drops the partial indexes and CHECK constraints written against that column first and restores them in `down`: Postgres compiles both predicates against the old type and cannot recompile either, so `alter column … type text using …::text` was `42883 operator does not exist: text = post_status` and the migration aborted mid-run. A plain btree over the column is left alone — measured, Postgres rebuilds that one itself |
46
+ | `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 |
47
+ | `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 |
48
+ | `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` |
49
+ | `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 |
46
50
  | `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 |
47
51
  | `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 |
48
52
  | `introspect()` | live schema → `SchemaDescription`. **App tables only**, `As of 2026-08-24`: a relation an extension owns (`pg_depend`, `deptype = 'e'`) and anything that is not an ordinary or partitioned table are excluded before the fold, and an explicit `exclude` cannot bring them back |
@@ -181,6 +185,17 @@ table in the `x_` namespace — `x_migrations`, the queue's tables, the outbox a
181
185
  snapshot, so `appTables()` drops them before the diff. `introspect()` keeps its own narrower
182
186
  exclusion (the ledger alone), reserving `x_users` for a schema view that wants it.
183
187
 
188
+ **A CHECK the catalog no longer holds is drift, `As of 2026-08-25` — by NAME.**
189
+ `pg_get_constraintdef` answers Postgres' own rewriting (`status in ('draft', 'published')` reads
190
+ back as `CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`), so the catalog side is
191
+ read as `conname` alone and lands on `TableDescription.checkNames`, a **separate field** from the
192
+ declaration's `checks`. Only the declared side is judged, so a NOT NULL, an `enumerated()` column's
193
+ old anonymous form and an extension's own constraint are all silent; a declared one the catalog
194
+ does not hold is `missing-check`, whose `fix:` is the `add constraint` statement itself, because the
195
+ migration that declares it is already in the ledger and `x db migrate` would apply nothing. There is
196
+ no `changed-check`: presence is a boolean, a predicate is text, and normalising the text is an
197
+ expression parser competing with the server's.
198
+
184
199
  **Nor is a relation an extension owns, `As of 2026-08-24`.** `create extension pg_stat_statements`
185
200
  in `public` is the CNPG, RDS, Supabase and Neon default, and its view read as `unexpected-table`
186
201
  with `x db gen "add pg_stat_statements"` as the fix — so every deploy failed terminally and the fix
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "13.0.0",
3
+ "version": "15.0.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "13.0.0"
34
+ "@ultimat3/core": "15.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -0,0 +1,224 @@
1
+ // Single responsibility: every CHECK constraint a table declares — a COLUMN's own and an
2
+ // INVARIANT's — and which of them a migration adds, rebuilds or drops. Split out of `generate.ts`
3
+ // and `invariant-ddl.ts` because a column check was the one part of a column that reached
4
+ // `create table` and nothing else: `columnClause` wrote `check (…)` inline and ANONYMOUS,
5
+ // `snapshotOf` recorded nothing for it and `diffTable` had no arm for it, so the SECOND `x db gen`
6
+ // turned `enumerated(POST_STATUSES)` into bare `text` accepting any string, and the value set the
7
+ // entity still declares left the database with no statement anywhere saying so.
8
+ //
9
+ // One list, for the reason `declaredIndexes` is one list: `createTable`, `diffTable` and
10
+ // `snapshotOf` must agree about what exists, and two producers of `add constraint` that never met
11
+ // is `42710` on the very next generation — a migration nobody can apply.
12
+
13
+ import { assert } from '@ultimat3/core';
14
+ import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
15
+ import type { CheckDescription, TableDescription } from './introspect';
16
+ import { invariantChecks, isIdentifier, MAX_IDENTIFIER_BYTES } from './invariant-ddl';
17
+ import { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
18
+ import { identifier } from './sql';
19
+ import { statementsOf } from './statement-split';
20
+
21
+ /**
22
+ * The convention alone, with nothing validated and nothing refused — one copy, so `columnCheckName`
23
+ * and `columnNamesConstraint` can never disagree about what a column's CHECK is called. The shape
24
+ * `spellConstraintName` already has in `invariant-ddl.ts`, and for the same reason.
25
+ *
26
+ * **Not a convention chosen here.** It is the name Postgres itself mints for an anonymous
27
+ * single-column CHECK — measured against a real server in `check-ddl.live.test.ts`, including for a
28
+ * multi-clause predicate like `scaleCheck`'s, which still names only one column. That is the whole
29
+ * reason this spelling and no other: every database generated before this landed is holding the old
30
+ * inline anonymous form under exactly this name, so a repair migration lands ON the constraint it
31
+ * means to correct instead of beside it under a second name.
32
+ */
33
+ const spellColumnCheckName = (table: string, column: string): string => `${table}_${column}_check`;
34
+
35
+ /**
36
+ * Whether a RECORDED constraint is one of THIS entity's columns' own CHECKs. Validates nothing and
37
+ * never throws, exactly as `namesConstraint` does not, because its caller is a REPORTER
38
+ * (`unrendered.ts`) reached by the `drift` gate step where a throw replaces a finding with a crash.
39
+ *
40
+ * It exists because the two conventions can land on one string: an entity whose `slug` column is
41
+ * checked and that also declares `invariant('slug', …)` as an ASSERT derives `posts_slug_check`
42
+ * twice, and `namesConstraint` matches on the name alone — which it must, since a hand-written
43
+ * migration names the constraint after the rule. Without this the assert reads as "this run drops
44
+ * the CHECK recorded for me" while the run declares and keeps it: a `-- UNRENDERED` block on a
45
+ * migration that lost nothing, which is a marker the next reviewer learns to ignore.
46
+ */
47
+ export function columnNamesConstraint(entity: EntityDescriptionLike, recorded: string): boolean {
48
+ return entity.columns.some(
49
+ (column) =>
50
+ column.check !== null && recorded === spellColumnCheckName(entity.table, column.column),
51
+ );
52
+ }
53
+
54
+ /**
55
+ * What a column's own CHECK is called, with both operands validated — the reason `constraintNameFor`
56
+ * validates its own one file over: a physical column name arrives from a projection this package
57
+ * cannot typecheck, `add constraint` takes no parameters, and a name that closes its own quote
58
+ * produced a real `drop table` through `generateMigration` once already.
59
+ */
60
+ export function columnCheckName(table: string, column: string): string {
61
+ if (!isIdentifier(table) || !isIdentifier(column)) throw constraintNameUnsafe(table, column);
62
+ const name = spellColumnCheckName(table, column);
63
+ // Bytes, never characters: 63 is what the server counts, and a truncation it performs silently
64
+ // makes two constraints one on the server while both names still differ in the snapshot.
65
+ const bytes = new TextEncoder().encode(name).length;
66
+ assert(
67
+ bytes <= MAX_IDENTIFIER_BYTES,
68
+ `constraint name "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES} and says nothing`,
69
+ `.column('<shorter>') # shorten the physical name of "${column}", then x db gen`,
70
+ );
71
+ return name;
72
+ }
73
+
74
+ /**
75
+ * The CHECK constraints an entity's COLUMNS declare, in column order — `enumerated()`'s closed value
76
+ * set, `tz()`'s IANA whitelist, `locale()`'s tag list, money's currency pattern and scale bound.
77
+ *
78
+ * Read off `ColumnDescriptionLike.check`, which is the one field `@ultimat3/entity` projects them
79
+ * through; a column carrying `null` declares none and contributes no row.
80
+ */
81
+ export function columnChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
82
+ return entity.columns
83
+ .filter((column: ColumnDescriptionLike) => column.check !== null)
84
+ .map((column) => ({
85
+ name: columnCheckName(entity.table, column.column),
86
+ expression: column.check ?? '',
87
+ }));
88
+ }
89
+
90
+ /**
91
+ * Every CHECK this table declares. Columns first, then invariants — the order the old generator
92
+ * emitted them in, so a table declaring only invariants produces the statement it always produced.
93
+ *
94
+ * A duplicate name is REFUSED rather than deduped. Two `add constraint` statements under one name
95
+ * is `42710`, and the two sides mean different things: `invariant('status', …)` on a table whose
96
+ * `status` column is an `enumerated()` derives the same `posts_status_check` the column already
97
+ * owns, and silently keeping either one would enforce a rule the entity does not state. Same
98
+ * argument `declaredIndexes` makes for `42P07`, with the opposite remedy, because unlike two
99
+ * identical index definitions these two carry different predicates.
100
+ */
101
+ export function declaredChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
102
+ const checks = [...columnChecks(entity), ...invariantChecks(entity)];
103
+ const seen = new Set<string>();
104
+ for (const check of checks) {
105
+ assert(
106
+ !seen.has(check.name),
107
+ `two declarations on "${entity.table}" name the constraint "${check.name}"`,
108
+ `invariant('${entity.table}_${check.name}', …) # rename the invariant — a column's own CHECK already holds that name, then x db gen`,
109
+ );
110
+ seen.add(check.name);
111
+ // One command, over the merged list: `statementsOf` is this package's one lexer, so a `;`
112
+ // inside a string literal — `check (tag <> ';')`, and every `oneOf()` value list — is data and
113
+ // not a split. Applied here rather than per producer so a column's predicate, which arrives
114
+ // from an app's own `enumerated([...])` array, is guarded by the same rule an invariant's is.
115
+ const commands = statementsOf(check.expression).length;
116
+ if (commands > 1) throw constraintExpressionUnsafe(check.name, commands);
117
+ }
118
+ return checks;
119
+ }
120
+
121
+ /** `constraint "n" check (…)` — the clause form, for a table this migration creates. */
122
+ export function checkClauses(entity: EntityDescriptionLike): readonly string[] {
123
+ return declaredChecks(entity).map(
124
+ (check) => `constraint ${identifier(check.name).text} check (${check.expression})`,
125
+ );
126
+ }
127
+
128
+ /** Exported for `retype-dependents.ts`: a constraint moved out of a retype's way is put back by
129
+ * the same statement that would have added it, never by a second spelling of `add constraint`. */
130
+ export const addCheck = (table: string, check: CheckDescription): string =>
131
+ `alter table ${identifier(table).text} add constraint ${identifier(check.name).text} ` +
132
+ `check (${check.expression});`;
133
+
134
+ export const dropCheck = (table: string, name: string): string =>
135
+ `alter table ${identifier(table).text} drop constraint ${identifier(name).text};`;
136
+
137
+ /**
138
+ * The one statement that is correct on BOTH databases this generator cannot tell apart.
139
+ *
140
+ * A database generated before column checks were recorded is holding Postgres' own auto-named
141
+ * `<table>_<column>_check` from the old inline anonymous form; a database whose entity gained the
142
+ * check after the table was created is holding nothing, because the old `diffTable` emitted nothing.
143
+ * The snapshot reads identically in both — it records no check either way — so a bare
144
+ * `add constraint` is `42710` on the first, inside `ROLE=migrate`, with the server's words and none
145
+ * of the entity's. `drop constraint if exists` costs a notice on the second and repairs the first.
146
+ */
147
+ const rebuildCheck = (table: string, check: CheckDescription): readonly string[] => [
148
+ `alter table ${identifier(table).text} drop constraint if exists ${identifier(check.name).text};`,
149
+ addCheck(table, check),
150
+ ];
151
+
152
+ /**
153
+ * Which CHECK constraints an existing table gains, loses or has rebuilt. Postgres has no `alter
154
+ * constraint` for a predicate, so a moved expression is a drop and an add — the same shape
155
+ * `redefineIndex` uses, and `down` is pushed forwards and read backwards for the same reason.
156
+ *
157
+ * Both directions, the rule `foreignKeyPlan` states: a snapshot may not lie. A recorded constraint
158
+ * the entity no longer declares is DROPPED, and its `down` re-adds it from the expression the
159
+ * snapshot holds — so unlike a dropped column there is nothing to restore and nothing to refuse.
160
+ * `destructive.ts` deliberately excludes `drop constraint` for exactly this reason.
161
+ *
162
+ * `rebuilt` names the columns this migration dropped and re-added outright (`regenerate`'s
163
+ * plain -> generated path). The constraint went with the column and the snapshot still records it,
164
+ * so without this the check would be silently gone — the defect class this file exists against,
165
+ * one level in.
166
+ *
167
+ * `predropped` names the CONSTRAINTS this plan already dropped, ahead of a retype whose predicate
168
+ * they were compiled against (`retype-dependents.ts`). Two arms read it and both are about a name
169
+ * that is provably free: a declared one takes the bare `add constraint` rather than the
170
+ * drop-if-exists pair, and a recorded one the entity no longer declares is left alone entirely —
171
+ * `drop constraint` on it a second time is `42704`, and its `down` belongs to the retype that
172
+ * moved it. Keyed by name and not by column because an INVARIANT's check reads a column without
173
+ * being derived from one, which is exactly the constraint `examples/dummy` retypes under.
174
+ */
175
+ export function checkPlan(
176
+ entity: EntityDescriptionLike,
177
+ live: TableDescription,
178
+ plan: { up: string[]; down: string[] },
179
+ rebuilt: ReadonlySet<string> = new Set(),
180
+ predropped: ReadonlySet<string> = new Set(),
181
+ ): void {
182
+ const recorded = new Map((live.checks ?? []).map((check) => [check.name, check]));
183
+ const present = new Set(live.columns.map((column) => column.name));
184
+ // Checked columns only, in both sets — `columnCheckName` REFUSES a name it cannot spell, and a
185
+ // column declaring no check contributes no constraint for either set to be consulted about. Over
186
+ // every column this would refuse to generate a migration that touches none of them.
187
+ const checked = entity.columns.filter((column) => column.check !== null);
188
+ // Which names the OLD anonymous form could be holding: a column the recorded schema already had,
189
+ // whose check it did not record. A column this migration adds cannot have one, and a rebuilt one
190
+ // lost it with the column, so both take the bare add.
191
+ const exposed = new Set(
192
+ checked
193
+ .filter((column) => present.has(column.column) && !rebuilt.has(column.column))
194
+ .map((column) => columnCheckName(entity.table, column.column)),
195
+ );
196
+ const dropped = new Set(
197
+ checked
198
+ .filter((column) => rebuilt.has(column.column))
199
+ .map((column) => columnCheckName(entity.table, column.column)),
200
+ );
201
+ const wanted = declaredChecks(entity);
202
+ for (const check of wanted) {
203
+ const gone = dropped.has(check.name) || predropped.has(check.name);
204
+ const held = gone ? undefined : recorded.get(check.name);
205
+ if (held === undefined) {
206
+ plan.up.push(
207
+ ...(exposed.has(check.name) && !predropped.has(check.name)
208
+ ? rebuildCheck(entity.table, check)
209
+ : [addCheck(entity.table, check)]),
210
+ );
211
+ plan.down.push(dropCheck(entity.table, check.name));
212
+ continue;
213
+ }
214
+ if (held.expression === check.expression) continue;
215
+ plan.up.push(dropCheck(entity.table, check.name), addCheck(entity.table, check));
216
+ plan.down.push(addCheck(entity.table, held), dropCheck(entity.table, check.name));
217
+ }
218
+ const declared = new Set(wanted.map((check) => check.name));
219
+ for (const check of live.checks ?? []) {
220
+ if (declared.has(check.name) || predropped.has(check.name)) continue;
221
+ plan.up.push(dropCheck(entity.table, check.name));
222
+ plan.down.push(addCheck(entity.table, check));
223
+ }
224
+ }
@@ -0,0 +1,80 @@
1
+ // Single responsibility: what a column's DEFAULT is, as SQL. Split out of `generate.ts` because
2
+ // the question has two halves that must be answered together — what a declared default renders as,
3
+ // and what it MEANS when a column claims one this generator cannot write down. That second half
4
+ // had no answer at all until 2026-08-25: `hasDefault` reached the generator with no expression
5
+ // beside it, so nine scalar defaults in the reference app's own schema were dropped in silence and
6
+ // only `now()` and `gen_random_uuid()` survived, because those two are inferable from the kind.
7
+
8
+ import { assert } from '@ultimat3/core';
9
+ import { literal } from './sql';
10
+
11
+ /**
12
+ * Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDefault`. Declared here
13
+ * rather than in `entity-shape.ts` so `defaultExpression` can name it without an import cycle,
14
+ * exactly as `IndexMethod` lives in `index-method.ts` and is read from the mirror.
15
+ *
16
+ * `uuid-v7` is what `entity()` stamps on a generated uuid key. It renders `gen_random_uuid()` —
17
+ * a v4 — because that is the only server-side generator Postgres 17 ships and it is what this
18
+ * generator has always emitted for such a key. The v7 is minted in JS on the write path; the
19
+ * column default is the fallback for a row nothing in this framework inserted.
20
+ */
21
+ export type ColumnDefaultLike =
22
+ | { readonly kind: 'value'; readonly value: string | number | boolean | null }
23
+ | { readonly kind: 'generated'; readonly by: 'uuid-v7' | 'now' };
24
+
25
+ /** The parts of a column description this file reads — nothing else. */
26
+ interface DefaultedColumn {
27
+ readonly column: string;
28
+ readonly property: string;
29
+ readonly kind: string;
30
+ readonly primaryKey: boolean;
31
+ readonly hasDefault: boolean;
32
+ readonly default?: ColumnDefaultLike | undefined;
33
+ }
34
+
35
+ /**
36
+ * A literal reaches the statement TEXT — `create table` takes no parameters — so a string is
37
+ * quoted through the one escape this package has (`literal`), and a number this build cannot
38
+ * write down is refused rather than emitted as `NaN`, which is a syntax error whose first reader
39
+ * would be `ROLE=migrate`.
40
+ */
41
+ function literalSql(column: DefaultedColumn, value: string | number | boolean | null): string {
42
+ if (value === null) return 'null';
43
+ if (typeof value === 'string') return literal(value).text;
44
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
45
+ assert(
46
+ Number.isFinite(value),
47
+ `column "${column.column}" declares a default of ${String(value)}, which is not a number Postgres can hold`,
48
+ `.default(0) # give "${column.property}" a finite number, or drop the default`,
49
+ );
50
+ return String(value);
51
+ }
52
+
53
+ /**
54
+ * The `default …` a column clause carries, or `null` for a column that has none this generator can
55
+ * write. A DECLARED default wins over an inferred one; the two inferences below stay as the
56
+ * fallback for a description whose producer does not yet project the expression — see
57
+ * `unrenderedDefault`, which is what stops that fallback from being a silent loss.
58
+ */
59
+ export function defaultExpression(column: DefaultedColumn): string | null {
60
+ if (!column.hasDefault) return null;
61
+ const declared = column.default;
62
+ if (declared !== undefined) {
63
+ return declared.kind === 'value'
64
+ ? literalSql(column, declared.value)
65
+ : declared.by === 'now'
66
+ ? 'now()'
67
+ : 'gen_random_uuid()';
68
+ }
69
+ if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
70
+ if (column.kind === 'timestamptz') return 'now()';
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Whether this column claims a default the generated SQL does not carry. Exactly the condition
76
+ * that lost nine columns' defaults in silence: `hasDefault: true` with nothing to render it from.
77
+ */
78
+ export function hasUnrenderedDefault(column: DefaultedColumn): boolean {
79
+ return column.hasDefault && defaultExpression(column) === null;
80
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { stripSqlNoise } from './sql-noise';
7
7
  import { noiseAt } from './sql-scan';
8
+ import { statementExcerpt } from './statement-excerpt';
8
9
  import { statementsOf } from './statement-split';
9
10
 
10
11
  /** The line a migration carries to declare that applying it destroys data. */
@@ -90,20 +91,6 @@ const RULES: readonly (readonly [DestructiveKind, RegExp])[] = [
90
91
  ['retype-column', /\balter\s+column\b[\s\S]*?\btype\b/],
91
92
  ];
92
93
 
93
- /**
94
- * One capped line — an error prints this, not a whole script. Only the comments *preceding* the
95
- * statement come off, the ones `statementsOf` carries in from the file header; the SQL itself stays
96
- * verbatim, because `stripSqlNoise` blanks quoted identifiers and `drop table ""` names nothing an
97
- * author can act on. Blanking is for deciding, never for reporting.
98
- */
99
- function excerpt(statement: string): string {
100
- const line = statement
101
- .replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
102
- .replace(/\s+/g, ' ')
103
- .trim();
104
- return line.length > 120 ? `${line.slice(0, 117)}...` : line;
105
- }
106
-
107
94
  /**
108
95
  * Every destructive statement in `up`, in apply order.
109
96
  *
@@ -117,7 +104,7 @@ export function destructiveStatements(up: string): readonly DestructiveStatement
117
104
  for (const statement of statementsOf(up)) {
118
105
  const bare = stripSqlNoise(statement).toLowerCase();
119
106
  const rule = RULES.find(([, pattern]) => pattern.test(bare));
120
- if (rule !== undefined) found.push({ kind: rule[0], statement: excerpt(statement) });
107
+ if (rule !== undefined) found.push({ kind: rule[0], statement: statementExcerpt(statement) });
121
108
  }
122
109
  return found;
123
110
  }