@ultimat3/db 14.0.0 → 16.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 +261 -5
- package/README.md +13 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +17 -5
- package/src/dependent-view.ts +224 -0
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -182
- package/src/errors.ts +2 -100
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +53 -172
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +188 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +9 -6
- package/src/introspect.ts +46 -1
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-dependents.ts +135 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
package/CLAUDE.md
CHANGED
|
@@ -9,11 +9,11 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
|
|
|
9
9
|
|---|---|
|
|
10
10
|
| Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing |
|
|
11
11
|
| SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
|
|
12
|
-
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
|
|
12
|
+
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point. `literal()` is the tree's ONE SQL-string-literal escape (`scripts/sql-literal-copies.ts`, pinned at zero) and it emits `E'…'` when the value carries a backslash |
|
|
13
13
|
| SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
|
|
14
14
|
| Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
|
|
15
15
|
| Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
|
|
16
|
-
| New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
|
|
16
|
+
| New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` — always there, whichever file the CONSTRUCTOR lives in. `errors.ts` reached the 500-line ceiling on 2026-08-25, so a migration's constructors are `migration-errors.ts` and an invariant's are `invariant-errors.ts`; both import `DbError` from `errors.ts` and neither is imported back, and `src/index.ts` re-exports every one of them so no consumer can tell |
|
|
17
17
|
| A value ambient across an `await` | `asyncContext<T>(subject)` from `@ultimat3/core` — never `new AsyncLocalStorage`. Three scopes here use it: `transaction.ts`, `attribution.ts`, `expected-loop.ts` |
|
|
18
18
|
| Exports | explicit in `src/index.ts`; no `export *` |
|
|
19
19
|
| Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source |
|
|
@@ -511,7 +511,10 @@ migration ever generated and a marker on all of them marks none. **A closed list
|
|
|
511
511
|
`drop table`, `drop column`, `truncate`, `alter column … type`; a rail enumerating every Postgres
|
|
512
512
|
foot-gun is a second SQL parser competing with the server's, and every one of these four is a
|
|
513
513
|
statement `generateMigration` emits, so each has a generated case holding it honest. `drop
|
|
514
|
-
constraint`/`default`/`not null` and `drop index` are excluded by name
|
|
514
|
+
constraint`/`default`/`not null` and `drop index` are excluded by name — a `drop index` holds no
|
|
515
|
+
rows of its own, its `down` recreates the recorded definition, and `redefineIndex` has emitted one
|
|
516
|
+
on every index rename since it existed, so classifying it marks nearly every migration and a marker
|
|
517
|
+
on all is none.
|
|
515
518
|
**Decide on blanked text, report the original** — `statementsOf` + `stripSqlNoise` before a keyword
|
|
516
519
|
is looked for, so `-- drop table users` is prose and `values ('drop table users')` is data; but the
|
|
517
520
|
excerpt in the error keeps its identifiers, because `drop table ""` names nothing an author can act
|
|
@@ -653,6 +656,257 @@ right database, and `x db gen`'s `retypeColumn` owns that question where both si
|
|
|
653
656
|
The `fix:` is the `alter table … set not null` itself and deliberately not `x db gen`, which has
|
|
654
657
|
never emitted one and would answer with an empty migration.
|
|
655
658
|
|
|
659
|
+
**A CHECK that went missing is drift, `As of 2026-08-25`, and it is compared by NAME because it
|
|
660
|
+
cannot be compared any other way.** `pg_get_constraintdef` answers Postgres' own rewriting —
|
|
661
|
+
`status in ('draft', 'published')` reads back as
|
|
662
|
+
`CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text])))`, measured on 18.4
|
|
663
|
+
(`drift-check.live.test.ts`) — so a catalog value could never equal a generated one and a text
|
|
664
|
+
comparison reports a correct database as wrong forever. That is why nothing here read
|
|
665
|
+
`pg_constraint` for CHECKs at all, and why `alter table … drop constraint` in a psql session was
|
|
666
|
+
`ok: true` on every check that followed it.
|
|
667
|
+
|
|
668
|
+
**The two readings do not share a field, and that split is the whole design.**
|
|
669
|
+
`TableDescription.checks` is the DECLARED side — name **and** expression, `snapshotOf`'s own
|
|
670
|
+
spelling, the value `checkPlan` diffs. `TableDescription.checkNames` is the CATALOG side — `conname`
|
|
671
|
+
for `contype = 'c'`, names and nothing else, written only by `introspect()`. Filling `checks` from
|
|
672
|
+
the catalog instead would put a rewritten expression where `checkPlan` expects a generated one, and
|
|
673
|
+
every `x db gen` in every app would then drop and re-add every constraint it has, forever, because
|
|
674
|
+
the two strings can never be equal. Split, the TYPE says which reading a value came from and
|
|
675
|
+
`checkPlan` cannot be handed a catalog value by accident.
|
|
676
|
+
|
|
677
|
+
Three rules ride with it. **Absent and `[]` are different on both sides** — an absent `checks` is a
|
|
678
|
+
sidecar written before the field existed (declares nothing, so nothing can be missing), and an
|
|
679
|
+
absent `checkNames` is a description that never asked the catalog, which reading as "the database
|
|
680
|
+
holds none" is one finding per declared constraint against a database nobody looked at.
|
|
681
|
+
`introspect()` therefore always writes `checkNames`, `[]` included. **Only the declared side is
|
|
682
|
+
judged**, the rule `compareIndexes` and `compareForeignKeys` already state: a NOT NULL (`contype =
|
|
683
|
+
'n'` from Postgres 17 on), an `enumerated()` column's old anonymous form and every constraint an
|
|
684
|
+
extension brought would each be a finding against a database that is exactly right. **There is no
|
|
685
|
+
`changed-check` and there never will be** — presence is a boolean, the predicate is text, and
|
|
686
|
+
normalising the text is an expression parser competing with the server's. `missing-check`'s `fix:`
|
|
687
|
+
is the `add constraint` statement itself, not `x db migrate`: the migration declaring it is already
|
|
688
|
+
in the ledger, so the migrator applies nothing, and the declared side carries the predicate that
|
|
689
|
+
makes an executable fix possible at all.
|
|
690
|
+
|
|
691
|
+
**`literal()` DOES receive caller input, and this file's own source said otherwise until
|
|
692
|
+
2026-08-25.** `column-default.ts:43` renders `ColumnDefaultLike` through it — an app's own
|
|
693
|
+
`.default('C:\\logs')`, crossing the tier seam from `@ultimat3/entity`, validated by nothing and
|
|
694
|
+
guarded by no `identifier()`. Measured through `generateMigration` on 18.4: the emitted
|
|
695
|
+
`default 'C:\logs'` stores `C:\logs` with `standard_conforming_strings` on and **`C:logs`** with it
|
|
696
|
+
off. A declaration that type-checks, a migration that applies, a column defaulting to a value nobody
|
|
697
|
+
wrote, and no error anywhere. A value ENDING in a backslash is worse — the escaped quote leaves the
|
|
698
|
+
literal unterminated.
|
|
699
|
+
|
|
700
|
+
The rule is `E'…'` **only** when the value actually carries a backslash: without one there is no
|
|
701
|
+
escape mechanism for the two GUC settings to disagree about, so every migration already on disk
|
|
702
|
+
stays byte for byte what it was and nothing regenerates spuriously. That property is load-bearing —
|
|
703
|
+
both tracked apps hold applied migrations whose `.hash` covers this text — and
|
|
704
|
+
`generate-default.live.test.ts` pins both halves against a real server, applying the same generated
|
|
705
|
+
migration under `on` and under `off` and reading the stored default back. `sql.test.ts` pins the
|
|
706
|
+
five shapes; the round trip through `statementsOf` is there too, because this package's own lexer
|
|
707
|
+
has to read back what its escape writes or `migrate()` starts miscounting statements
|
|
708
|
+
(`sql-scan.ts`'s `escapesAt` already knew the `E''` prefix).
|
|
709
|
+
|
|
710
|
+
**The other two callers here are safe by CONSTRUCTION, never by input, and the difference matters
|
|
711
|
+
if either is refactored.** `readonly-role.ts:71` sits in the same `sql` template as
|
|
712
|
+
`identifier(role)`, which throws on a backslash before the tag function runs; `branch.ts:85` runs
|
|
713
|
+
after an already-awaited `identifier(base)`. Neither is validating the value it passes to
|
|
714
|
+
`literal()` — a caller moved out of that ordering loses the guard silently.
|
|
715
|
+
|
|
716
|
+
`literal()` is now the tree's ONE answer, enforced: `scripts/sql-literal-copies.ts` refuses a
|
|
717
|
+
`replace`/`replaceAll` whose replacement is `''` anywhere but `packages/db/src/sql.ts`, matched on
|
|
718
|
+
the TRANSFORMATION rather than on a name — the three copies were called `literal`, `literalText`
|
|
719
|
+
and an unnamed inline template. Pinned at zero.
|
|
720
|
+
|
|
721
|
+
**A retype takes the objects written against the column out of its way first, `As of 2026-08-25`,
|
|
722
|
+
and `retype-dependents.ts` decides which those are.** Postgres compiles a partial index's predicate
|
|
723
|
+
and a CHECK's expression against the column's type at creation and cannot recompile either:
|
|
724
|
+
`alter table "posts" alter column "status" type text using "status"::text` answered
|
|
725
|
+
`42883 operator does not exist: text = post_status` and the migration aborted mid-run — inside
|
|
726
|
+
`ROLE=migrate`, with the ledger recording nothing. It is what blocked `examples/dummy` from
|
|
727
|
+
regenerating at all.
|
|
728
|
+
|
|
729
|
+
**Which objects are dependent is measured, never assumed** (`generate-retype.live.test.ts`, one
|
|
730
|
+
shape at a time on 18.4):
|
|
731
|
+
|
|
732
|
+
| recorded object | survives the ALTER |
|
|
733
|
+
|---|---|
|
|
734
|
+
| btree over the column — plain, unique or composite | **yes**, Postgres rebuilds it itself |
|
|
735
|
+
| partial index whose predicate names the column | **no — 42883** |
|
|
736
|
+
| partial index naming another column | yes |
|
|
737
|
+
| CHECK whose expression names the column | **no — 42883** |
|
|
738
|
+
| a view over the column | no, `0A000`; no snapshot records a view, so `migrate()` refuses it instead (`dependent-view.ts`) |
|
|
739
|
+
|
|
740
|
+
So only an expression that MENTIONS the column is moved, and a plain btree is left alone — dropping
|
|
741
|
+
it is a table scan to rebuild for nothing.
|
|
742
|
+
|
|
743
|
+
**The reference test over-approximates on purpose, and it cannot be narrowed by type name.**
|
|
744
|
+
Measured: `char(1)` → `char(3)`, `varchar(80)` → `text` and `numeric` → `integer` all re-derive
|
|
745
|
+
their predicates cleanly, while `integer` → `text` under `check (c >= 0)` is `42883` — both sides
|
|
746
|
+
built-ins. Whether an expression re-resolves depends on operator resolution, which is exactly the
|
|
747
|
+
knowledge a generator with no database cannot have, so every ambiguous case answers "dependent":
|
|
748
|
+
a miss is `42883` in the release phase, a false positive is a rebuild on a statement that is
|
|
749
|
+
already rewriting the whole table under ACCESS EXCLUSIVE. `referencesColumn` walks `sql-scan.ts`
|
|
750
|
+
rather than matching a substring — a name inside a literal or a comment is not a reference,
|
|
751
|
+
`status_code` is not `status`, and a **quoted** identifier IS one, which is the one span the lexer
|
|
752
|
+
calls noise and this reader must not skip.
|
|
753
|
+
|
|
754
|
+
**What is moved aside is put back by the ORDINARY diff, never twice.** `up` drops the dependents
|
|
755
|
+
before the ALTER and `MovedAside` carries their names to the two arms that would otherwise act on a
|
|
756
|
+
thing that is no longer there: the index loop CREATES a declared name instead of comparing it
|
|
757
|
+
(`redefineIndex` is silent on a definition that never moved, which here means the table comes out
|
|
758
|
+
with no index at all), and `checkPlan` neither drops nor re-adds a predropped name — a declared one
|
|
759
|
+
takes the bare `add constraint` because the name is provably free, and a recorded one the entity no
|
|
760
|
+
longer declares is simply gone, which is what `checkPlan` would have done to it anyway. `down`
|
|
761
|
+
pushes the restores forwards and is reversed as a whole, so it reads: drop the new objects, retype
|
|
762
|
+
back, then recreate the ones compiled against the old type — restoring first is `42883` in the
|
|
763
|
+
other direction. What it restores is what the snapshot RECORDED, never what the entity declares.
|
|
764
|
+
|
|
765
|
+
**A FOREIGN KEY over the retyped column is moved too, `As of 2026-08-25`, and `retype-keys.ts`
|
|
766
|
+
decides which — above `diffTable`, which is the whole point.** Postgres re-checks a key's two ends
|
|
767
|
+
against each other on every `alter column … type`: measured on 18.4, `42804 foreign key constraint
|
|
768
|
+
"rk_posts_org_code_fkey" cannot be implemented — Key columns "org_code" … and "code" … are of
|
|
769
|
+
incompatible types: integer and text`, thrown by the ALTER itself, inside `ROLE=migrate`, with the
|
|
770
|
+
ledger recording nothing.
|
|
771
|
+
|
|
772
|
+
**It could not be answered from inside `diffTable` and that is not an implementation detail.** The
|
|
773
|
+
constraint that breaks is recorded on the table that OWNS it, so for a retype of the key's TARGET it
|
|
774
|
+
is a different entity's row — `diffTable(orgs)` is handed `orgs`'s record and can never see
|
|
775
|
+
`posts.foreignKeys`. So `retypedColumns(entities, current)` derives the whole schema's retype set
|
|
776
|
+
once, before the entity loop, and `retypeColumn` READS it instead of asking
|
|
777
|
+
`recorded.dataType === wanted` a second time: two answers to "is this column being retyped" is the
|
|
778
|
+
axiom-1 split this package has spent the week closing.
|
|
779
|
+
|
|
780
|
+
Four rules ride with it.
|
|
781
|
+
|
|
782
|
+
| Rule | Why |
|
|
783
|
+
|---|---|
|
|
784
|
+
| the drop goes in a `preAlters` bucket merged at the TOP of `up` and at the FRONT of `down` | both ends of one key can move in two different entities' diffs, so the drop must precede every ALTER in the migration and the restore must follow every one of them. `down` is reversed at assembly, so the front becomes the end: drop the new key, retype both ends back, then add the recorded one. Restoring any earlier is `42804` in the other direction |
|
|
785
|
+
| what comes back in `up` is written by `foreignKeyPlan`, never here | `moveKeysAside` answers a set of `keyId`s and `ConstraintPlans.predropped` reads it as "the schema does not record this key" — the same reading `checkPlan` gives its own `predropped`. That is what makes the three outcomes fall out of code that already exists: still declared (added back in the `constraints` bucket that already runs after every table statement), no longer declared (gone, exactly as the removal arm would have left it), `on delete` moved (added back carrying the new rule). Three branches restating them here is the collision this was deferred over |
|
|
786
|
+
| **both** ends of `breaksOn` earn their line, and they do not overlap | the OWNER arm catches a key whose table is retyped while its TARGET's table is being dropped; the TARGET arm catches the mirror — the key's own table is doomed, so nothing retypes its column and `foreignKeyPlan` is never called for it at all, while `drop table` is emitted at the END of `up`, long after the ALTER it would have unblocked. Both are pinned live (`generate-retype-key.live.test.ts`), because when both tables survive either arm alone would do |
|
|
787
|
+
| a key whose own table or whose target is doomed gets a `--` note in `down` | `add constraint` against a table no `down` can restore is a rollback that cannot run — the rule `unrestorableDrop` already states |
|
|
788
|
+
|
|
789
|
+
**Re-adding the key is still the SERVER's judgement, deliberately.** An entity that retypes one end
|
|
790
|
+
and not the other declares a pairing Postgres has no operator for, and the `add constraint` at the
|
|
791
|
+
end of `up` is where that is said. Refusing it at generation would need to know whether two types
|
|
792
|
+
share an equality operator — `varchar(80)` and `text` do, `integer` and `text` do not — which is the
|
|
793
|
+
operator-resolution knowledge a generator with no database cannot have, and the same reason
|
|
794
|
+
`referencesColumn` over-approximates. What it cannot see at all is a key the recorded schema does
|
|
795
|
+
not hold: a hand-written migration's, or a sidecar written before `foreignKeys` was recorded.
|
|
796
|
+
|
|
797
|
+
`sql-type.ts` holds `SQL_TYPES`/`sqlType`, split out of `generate.ts` so the pre-pass can ask what a
|
|
798
|
+
kind renders to without importing the module that imports it. The read is **guarded** with
|
|
799
|
+
`Object.hasOwn`, and db's `proto-index` pin dropped 5 → 4 in the same commit — the ratchet reports a
|
|
800
|
+
count that drops as `stale`, so the two could not land apart. `kind` is data: unguarded,
|
|
801
|
+
`SQL_TYPES['constructor']` answered the `Object` function and its source went into the type position
|
|
802
|
+
of an `alter` statement, and `'__proto__'` answered `[object Object]`. Guarded, both pass through as
|
|
803
|
+
themselves like any other unknown kind, and no other input's answer moves.
|
|
804
|
+
|
|
805
|
+
**A generated column's REBUILD moves its dependents aside too, `As of 2026-08-25`, and it reuses
|
|
806
|
+
`retypeDependents` rather than answering again.** Plain → generated has no `set expression`, so
|
|
807
|
+
`regenerate` drops the column and adds it back — and `drop column` silently takes every partial
|
|
808
|
+
index whose PREDICATE names it and every CHECK whose expression does (measured, 18.4). The `rebuilt`
|
|
809
|
+
set `diffTable` carries into its index loop is keyed on an index's COLUMNS, so neither is a name it
|
|
810
|
+
can find: the table came back without them, the snapshot still recording both, and `down` unable to
|
|
811
|
+
restore either. `regenerate` therefore takes `live` and `moved` and calls `moveDependentsAside`,
|
|
812
|
+
which drops each explicitly, restores it in `down`, and puts the name where the ordinary diff will
|
|
813
|
+
CREATE it. `generate-generated-rebuild.live.test.ts` applies it both ways.
|
|
814
|
+
|
|
815
|
+
**A generated column's own `alter … type` deliberately does NOT move them, and the reason is
|
|
816
|
+
measured.** It trips the same `42883` (`operator does not exist: text > integer`, on a generated
|
|
817
|
+
`integer` column under `where (doubled > 0)`) — but moving the index aside only relocates the
|
|
818
|
+
failure to the `create index` that puts it back, because a predicate whose operator the NEW type has
|
|
819
|
+
no resolution for cannot be written either. The plain path's dependents survive precisely because an
|
|
820
|
+
untyped literal re-resolves (`status = 'published'` under an enum and under `text`), and a generated
|
|
821
|
+
column reaching that shape needs its EXPRESSION changed in the same migration, which `regenerate`
|
|
822
|
+
emits AFTER the type statement. Left open with the failure named in the source rather than closed
|
|
823
|
+
with a change no test could fail on.
|
|
824
|
+
|
|
825
|
+
And **what no migration wrote down** is still invisible to the generator by construction — `x db gen`
|
|
826
|
+
runs with no database open, so a hand-added expression index over the column is `42883` whatever
|
|
827
|
+
this does, since `SchemaDescription` has a field for it nowhere.
|
|
828
|
+
|
|
829
|
+
**A VIEW is NOT discoverable from anything this generator reads, and the honest ceiling is a
|
|
830
|
+
refusal one statement earlier, `As of 2026-08-25`.** `SchemaDescription` has no field for a view,
|
|
831
|
+
`introspect()` reads none by construction (`app-relation.ts` excludes every non-table relation), and
|
|
832
|
+
no `entity()` can declare one — so a `GenerateOptions.views` with no caller to fill it would be the
|
|
833
|
+
declared-and-never-wired defect this release exists to eliminate, and the caller is
|
|
834
|
+
`@ultimat3/cli`'s. What DOES have a connection is `migrate()`. `dependent-view.ts` is the preflight:
|
|
835
|
+
`refuseDependentViews(tx, script)` runs inside each migration's own transaction, before its first
|
|
836
|
+
statement, and both `migrate()` and `rollback()` call it.
|
|
837
|
+
|
|
838
|
+
It repairs nothing and does not claim to — the deploy still stops. What it replaces is
|
|
839
|
+
`X_DB_UNAVAILABLE: cannot reach the database`, whose registered `fix:` is "set `DATABASE_URL` to a
|
|
840
|
+
reachable Postgres url", on a database the migrator is connected to and mid-transaction on. The
|
|
841
|
+
server's own words name the view in a **DETAIL** field nothing printed:
|
|
842
|
+
`0A000 cannot alter type of a column used by a view or rule` /
|
|
843
|
+
`rule _RETURN on view dv_docs_published depends on column "rank"`. `X_MIGRATION_VIEW_DEPENDS` names
|
|
844
|
+
the view, the table and the column, and its `fix:` is the `drop view` plus the `create view` built
|
|
845
|
+
from `pg_get_viewdef(oid, true)` — a paste, not an archaeology.
|
|
846
|
+
|
|
847
|
+
Four rules.
|
|
848
|
+
|
|
849
|
+
| Rule | Why |
|
|
850
|
+
|---|---|
|
|
851
|
+
| `retypeTargets` is a WORD scan over `sql-scan.ts`, never a regex | a retype inside a `--` comment is prose and one inside a literal is data, and both reach the scan when they sit inside an `alter table` statement — read as code either invents a target on a column the statement never touches. A **quoted** name is never a keyword: `alter table "t" alter "column" type text` retypes a column called `column`, and read as the keyword it names `type` and matches nothing |
|
|
852
|
+
| the matcher is **narrow on purpose** | a miss costs exactly what happens today — the server's own `0A000`, one statement later — while a false positive refuses a migration that would have applied. Every retype `generateMigration` emits is `alter table <t> … alter [column] <c> type`; a hand-written `ALTER TABLE ONLY t …` is not, and is left to the server |
|
|
853
|
+
| one catalog round trip, and the PAIR is filtered in JS | the query asks every retyped table against every retyped column, so it answers pairs nobody retypes — `dv_notes.rank` out of `dv_docs.rank` and `dv_notes.mark`. Refusing on one is a deploy stopped over a view standing in nobody's way, which is worse than the message this exists to improve. Pinned live |
|
|
854
|
+
| the `fix:` is built through `identifier()` **inside a `try`** | `identifier()` refuses a name holding a quote, a space or a backslash, all three legal inside a quoted Postgres name, and a `fix:` may not throw — the rule `rebuildForeignKey` already states, with the same shape. `errors.ts` takes the finished string rather than importing `sql.ts`: that module imports `identifierUnsafe` from it, and an import cycle around the module whose evaluation REGISTERS every code is not one worth having for a quoted name |
|
|
855
|
+
|
|
856
|
+
A script that retypes nothing costs one text scan and no round trip, which is nearly every migration
|
|
857
|
+
an app writes.
|
|
858
|
+
|
|
859
|
+
**`index-ddl.ts` holds `createIndex`, `redefineIndex`, `indexShape`, `dropIndex`,
|
|
860
|
+
`dropRecordedIndex`, `mayBeConstraintBacked` and `asDeclared`**, split out of `generate.ts` at the
|
|
861
|
+
500-line ceiling along the seam `check-ddl.ts` and `generated-column.ts` already drew —
|
|
862
|
+
`generate.ts` assembles a plan, `index-plan.ts` decides which index statements go in it, and
|
|
863
|
+
`index-ddl.ts` writes them. `drift-findings.ts` is the same split on the other file: every `DriftDifference`
|
|
864
|
+
constructor and the `DriftKind` union, with `drift.ts` keeping the comparisons and re-exporting both
|
|
865
|
+
types explicitly so the public surface does not move.
|
|
866
|
+
|
|
867
|
+
**`index-plan.ts` walks both directions, `As of 2026-08-25`** — the third arm to learn it, after
|
|
868
|
+
`checkPlan` and `foreignKeyPlan`. `diffTable`'s index loop walked `declaredIndexes(entity)` and
|
|
869
|
+
matched by name with **no reverse pass**, so an index the entities stopped declaring stayed on the
|
|
870
|
+
database forever while the sidecar beside it stopped recording it: measured on `examples/dummy`,
|
|
871
|
+
`member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` all survived a regeneration
|
|
872
|
+
that recorded none of them, and the `drift` gate step was green over all three because drift judges
|
|
873
|
+
the declared side. `indexPlan(entity, live, plan, context)` is the whole question now — declared
|
|
874
|
+
first and removed last, the order `checkPlan` uses — and `generate.ts` calls it.
|
|
875
|
+
|
|
876
|
+
**A recorded UNIQUE index cannot be told from a UNIQUE CONSTRAINT's, and it never will be.**
|
|
877
|
+
`TableDescription` carries no discriminator and cannot usefully be given one: the *same*
|
|
878
|
+
declaration reaches the server as either, depending on which migration created it. A `unique` column
|
|
879
|
+
on a table `createTable` writes goes out as `create table … slug text unique`, which Postgres backs
|
|
880
|
+
with a **constraint** named `posts_slug_key`; the same column gaining `unique` later takes
|
|
881
|
+
`diffTable`'s `create unique index "posts_slug_key"` and is a plain index. `snapshotOf` records both
|
|
882
|
+
as `{ unique: true, primary: false }`, and every sidecar already on disk was written that way, so a
|
|
883
|
+
new field could not classify one retroactively. Measured on 18.4
|
|
884
|
+
(`index-removal.live.test.ts`):
|
|
885
|
+
|
|
886
|
+
| statement | on a constraint's index | on a plain index |
|
|
887
|
+
|---|---|---|
|
|
888
|
+
| `drop index "n"` | **2BP01** | ok |
|
|
889
|
+
| `drop index if exists "n"` | **2BP01** — `if exists` does not suppress it | ok |
|
|
890
|
+
| `alter table … drop constraint if exists "n"` | drops it, index and all | notice, no-op |
|
|
891
|
+
|
|
892
|
+
So `dropRecordedIndex` emits the **pair**, constraint first — reversed, the `drop index` reaches a
|
|
893
|
+
constraint's index and is the 2BP01 this exists to avoid — and only for the shape a constraint could
|
|
894
|
+
be backing: `mayBeConstraintBacked` is unique, non-primary, total, unordered and btree, because
|
|
895
|
+
`add constraint … unique` and a `unique` column clause can produce nothing else. A partial or
|
|
896
|
+
ordered or GIN index takes the bare `drop index`. The asymmetry that remains is named rather than
|
|
897
|
+
hidden: `down` recreates it with `create unique index`, so a constraint comes back as an index. That
|
|
898
|
+
is the one statement this generator has, and it restores what the record described.
|
|
899
|
+
|
|
900
|
+
Four names are skipped by the removal arm, and each is a statement Postgres would refuse or repeat:
|
|
901
|
+
a `primary` index (2BP01, and the key is `TableDescription.primaryKey`), one already in
|
|
902
|
+
`MovedAside.indexes` (a retype dropped it ahead of the ALTER — 42704), one over a column
|
|
903
|
+
`regenerate` rebuilt (it went with the `drop column` — 42704), and one over a column this migration
|
|
904
|
+
DROPS (`alter table … drop column` takes it, the rule `foreignKeyPlan` already applies to a
|
|
905
|
+
constraint on a dropped column). A doomed **table** needs no arm at all: `generate.ts` only reaches
|
|
906
|
+
a diff for a table an entity still declares. The known limit is written in the file header — a
|
|
907
|
+
unique index a foreign key on ANOTHER table still references cannot be dropped (2BP01), and this arm
|
|
908
|
+
sees one table at a time.
|
|
909
|
+
|
|
656
910
|
**An entity's INVARIANTS reach the DDL, `As of 2026-08-25`, and `invariant-ddl.ts` is what they
|
|
657
911
|
become.** `EntityDescriptionLike` had no `invariants` field at all — the same seam gap
|
|
658
912
|
`onDelete` carried until 3.0 — so a regenerated migration held **none** of them: measured on
|
|
@@ -866,8 +1120,10 @@ module's vocabulary; `snapshotOf` imports `foreignKeysOf` back, one direction on
|
|
|
866
1120
|
**`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
|
|
867
1121
|
emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
|
|
868
1122
|
stayed on the database **and** the record denied one the catalog holds, which `compareForeignKeys`
|
|
869
|
-
can never see because it judges the declared side.
|
|
870
|
-
removed index leaves the snapshot correct by omission, and
|
|
1123
|
+
can never see because it judges the declared side. **This paragraph said "that is not parity with a
|
|
1124
|
+
removed index: a removed index leaves the snapshot correct by omission", and that was wrong** — see
|
|
1125
|
+
`index-plan.ts` below: a removed index's snapshot lied in exactly the same way, and the arm to fix
|
|
1126
|
+
it did not land until 2026-08-25. The drop names the
|
|
871
1127
|
constraint **the previous snapshot recorded**, never the one this generator would have chosen — a
|
|
872
1128
|
hand-written `fk_legacy` is `42704` under the generated spelling — and a key whose columns this
|
|
873
1129
|
migration is dropping is skipped, because `drop column` takes the constraint with it. A key whose
|
package/README.md
CHANGED
|
@@ -36,13 +36,13 @@ 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
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
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
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` |
|
|
@@ -185,6 +185,17 @@ table in the `x_` namespace — `x_migrations`, the queue's tables, the outbox a
|
|
|
185
185
|
snapshot, so `appTables()` drops them before the diff. `introspect()` keeps its own narrower
|
|
186
186
|
exclusion (the ledger alone), reserving `x_users` for a schema view that wants it.
|
|
187
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
|
+
|
|
188
199
|
**Nor is a relation an extension owns, `As of 2026-08-24`.** `create extension pg_stat_statements`
|
|
189
200
|
in `public` is the CNPG, RDS, Supabase and Neon default, and its view read as `unexpected-table`
|
|
190
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": "16.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": "16.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/check-ddl.ts
CHANGED
|
@@ -125,11 +125,13 @@ export function checkClauses(entity: EntityDescriptionLike): readonly string[] {
|
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
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 =>
|
|
129
131
|
`alter table ${identifier(table).text} add constraint ${identifier(check.name).text} ` +
|
|
130
132
|
`check (${check.expression});`;
|
|
131
133
|
|
|
132
|
-
const dropCheck = (table: string, name: string): string =>
|
|
134
|
+
export const dropCheck = (table: string, name: string): string =>
|
|
133
135
|
`alter table ${identifier(table).text} drop constraint ${identifier(name).text};`;
|
|
134
136
|
|
|
135
137
|
/**
|
|
@@ -161,12 +163,21 @@ const rebuildCheck = (table: string, check: CheckDescription): readonly string[]
|
|
|
161
163
|
* plain -> generated path). The constraint went with the column and the snapshot still records it,
|
|
162
164
|
* so without this the check would be silently gone — the defect class this file exists against,
|
|
163
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.
|
|
164
174
|
*/
|
|
165
175
|
export function checkPlan(
|
|
166
176
|
entity: EntityDescriptionLike,
|
|
167
177
|
live: TableDescription,
|
|
168
178
|
plan: { up: string[]; down: string[] },
|
|
169
179
|
rebuilt: ReadonlySet<string> = new Set(),
|
|
180
|
+
predropped: ReadonlySet<string> = new Set(),
|
|
170
181
|
): void {
|
|
171
182
|
const recorded = new Map((live.checks ?? []).map((check) => [check.name, check]));
|
|
172
183
|
const present = new Set(live.columns.map((column) => column.name));
|
|
@@ -189,10 +200,11 @@ export function checkPlan(
|
|
|
189
200
|
);
|
|
190
201
|
const wanted = declaredChecks(entity);
|
|
191
202
|
for (const check of wanted) {
|
|
192
|
-
const
|
|
203
|
+
const gone = dropped.has(check.name) || predropped.has(check.name);
|
|
204
|
+
const held = gone ? undefined : recorded.get(check.name);
|
|
193
205
|
if (held === undefined) {
|
|
194
206
|
plan.up.push(
|
|
195
|
-
...(exposed.has(check.name)
|
|
207
|
+
...(exposed.has(check.name) && !predropped.has(check.name)
|
|
196
208
|
? rebuildCheck(entity.table, check)
|
|
197
209
|
: [addCheck(entity.table, check)]),
|
|
198
210
|
);
|
|
@@ -205,7 +217,7 @@ export function checkPlan(
|
|
|
205
217
|
}
|
|
206
218
|
const declared = new Set(wanted.map((check) => check.name));
|
|
207
219
|
for (const check of live.checks ?? []) {
|
|
208
|
-
if (declared.has(check.name)) continue;
|
|
220
|
+
if (declared.has(check.name) || predropped.has(check.name)) continue;
|
|
209
221
|
plan.up.push(dropCheck(entity.table, check.name));
|
|
210
222
|
plan.down.push(addCheck(entity.table, check));
|
|
211
223
|
}
|