@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 +202 -0
- package/README.md +17 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +224 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -184
- package/src/entity-shape.ts +46 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +108 -158
- package/src/generated-column.ts +15 -6
- package/src/index-ddl.ts +137 -0
- package/src/index.ts +23 -0
- package/src/introspect.ts +69 -1
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -0
- package/src/retype-dependents.ts +135 -0
- package/src/snapshot-parse.ts +31 -3
- package/src/statement-excerpt.ts +18 -0
- package/src/ungeneratable.ts +86 -0
- package/src/unrendered.ts +170 -0
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": "
|
|
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": "
|
|
34
|
+
"@ultimat3/core": "15.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/check-ddl.ts
ADDED
|
@@ -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
|
+
}
|
package/src/destructive.ts
CHANGED
|
@@ -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:
|
|
107
|
+
if (rule !== undefined) found.push({ kind: rule[0], statement: statementExcerpt(statement) });
|
|
121
108
|
}
|
|
122
109
|
return found;
|
|
123
110
|
}
|