@ultimat3/db 15.0.0 → 17.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 +249 -22
- package/README.md +3 -2
- package/package.json +2 -2
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +14 -232
- package/src/connection-url.ts +60 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/dependent-view.ts +224 -0
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +2 -109
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +36 -48
- package/src/generated-column.ts +31 -6
- package/src/index-ddl.ts +51 -0
- package/src/index-plan.ts +119 -0
- package/src/index.ts +24 -21
- package/src/migrate.ts +30 -12
- package/src/migration-errors.ts +132 -0
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +64 -10
package/CLAUDE.md
CHANGED
|
@@ -9,11 +9,12 @@ 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
|
-
|
|
|
12
|
+
| A name reaching a `fix:` | `shellInertIdentifier()` (`sql.ts`), the tree's ONE screen for it, `As of 2026-08-26`. `identifier()` alone does not close it: it refuses `"`, `\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its fast path — which are the two characters a shell substitutes inside DOUBLE quotes. A refused name is left OUT of the command, never escaped into it |
|
|
13
|
+
| 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
14
|
| SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
|
|
14
15
|
| 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
16
|
| 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` |
|
|
17
|
+
| 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
18
|
| 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
19
|
| Exports | explicit in `src/index.ts`; no `export *` |
|
|
19
20
|
| Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source |
|
|
@@ -511,7 +512,10 @@ migration ever generated and a marker on all of them marks none. **A closed list
|
|
|
511
512
|
`drop table`, `drop column`, `truncate`, `alter column … type`; a rail enumerating every Postgres
|
|
512
513
|
foot-gun is a second SQL parser competing with the server's, and every one of these four is a
|
|
513
514
|
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
|
|
515
|
+
constraint`/`default`/`not null` and `drop index` are excluded by name — a `drop index` holds no
|
|
516
|
+
rows of its own, its `down` recreates the recorded definition, and `redefineIndex` has emitted one
|
|
517
|
+
on every index rename since it existed, so classifying it marks nearly every migration and a marker
|
|
518
|
+
on all is none.
|
|
515
519
|
**Decide on blanked text, report the original** — `statementsOf` + `stripSqlNoise` before a keyword
|
|
516
520
|
is looked for, so `-- drop table users` is prose and `values ('drop table users')` is data; but the
|
|
517
521
|
excerpt in the error keeps its identifiers, because `drop table ""` names nothing an author can act
|
|
@@ -685,6 +689,36 @@ is the `add constraint` statement itself, not `x db migrate`: the migration decl
|
|
|
685
689
|
in the ledger, so the migrator applies nothing, and the declared side carries the predicate that
|
|
686
690
|
makes an executable fix possible at all.
|
|
687
691
|
|
|
692
|
+
**`literal()` DOES receive caller input, and this file's own source said otherwise until
|
|
693
|
+
2026-08-25.** `column-default.ts:43` renders `ColumnDefaultLike` through it — an app's own
|
|
694
|
+
`.default('C:\\logs')`, crossing the tier seam from `@ultimat3/entity`, validated by nothing and
|
|
695
|
+
guarded by no `identifier()`. Measured through `generateMigration` on 18.4: the emitted
|
|
696
|
+
`default 'C:\logs'` stores `C:\logs` with `standard_conforming_strings` on and **`C:logs`** with it
|
|
697
|
+
off. A declaration that type-checks, a migration that applies, a column defaulting to a value nobody
|
|
698
|
+
wrote, and no error anywhere. A value ENDING in a backslash is worse — the escaped quote leaves the
|
|
699
|
+
literal unterminated.
|
|
700
|
+
|
|
701
|
+
The rule is `E'…'` **only** when the value actually carries a backslash: without one there is no
|
|
702
|
+
escape mechanism for the two GUC settings to disagree about, so every migration already on disk
|
|
703
|
+
stays byte for byte what it was and nothing regenerates spuriously. That property is load-bearing —
|
|
704
|
+
both tracked apps hold applied migrations whose `.hash` covers this text — and
|
|
705
|
+
`generate-default.live.test.ts` pins both halves against a real server, applying the same generated
|
|
706
|
+
migration under `on` and under `off` and reading the stored default back. `sql.test.ts` pins the
|
|
707
|
+
five shapes; the round trip through `statementsOf` is there too, because this package's own lexer
|
|
708
|
+
has to read back what its escape writes or `migrate()` starts miscounting statements
|
|
709
|
+
(`sql-scan.ts`'s `escapesAt` already knew the `E''` prefix).
|
|
710
|
+
|
|
711
|
+
**The other two callers here are safe by CONSTRUCTION, never by input, and the difference matters
|
|
712
|
+
if either is refactored.** `readonly-role.ts:71` sits in the same `sql` template as
|
|
713
|
+
`identifier(role)`, which throws on a backslash before the tag function runs; `branch.ts:85` runs
|
|
714
|
+
after an already-awaited `identifier(base)`. Neither is validating the value it passes to
|
|
715
|
+
`literal()` — a caller moved out of that ordering loses the guard silently.
|
|
716
|
+
|
|
717
|
+
`literal()` is now the tree's ONE answer, enforced: `scripts/sql-literal-copies.ts` refuses a
|
|
718
|
+
`replace`/`replaceAll` whose replacement is `''` anywhere but `packages/db/src/sql.ts`, matched on
|
|
719
|
+
the TRANSFORMATION rather than on a name — the three copies were called `literal`, `literalText`
|
|
720
|
+
and an unnamed inline template. Pinned at zero.
|
|
721
|
+
|
|
688
722
|
**A retype takes the objects written against the column out of its way first, `As of 2026-08-25`,
|
|
689
723
|
and `retype-dependents.ts` decides which those are.** Postgres compiles a partial index's predicate
|
|
690
724
|
and a CHECK's expression against the column's type at creation and cannot recompile either:
|
|
@@ -702,7 +736,7 @@ shape at a time on 18.4):
|
|
|
702
736
|
| partial index whose predicate names the column | **no — 42883** |
|
|
703
737
|
| partial index naming another column | yes |
|
|
704
738
|
| CHECK whose expression names the column | **no — 42883** |
|
|
705
|
-
| a view over the column | no, `0A000
|
|
739
|
+
| a view over the column | no, `0A000`; no snapshot records a view, so `migrate()` refuses it instead (`dependent-view.ts`) |
|
|
706
740
|
|
|
707
741
|
So only an expression that MENTIONS the column is moved, and a plain btree is left alone — dropping
|
|
708
742
|
it is a table scan to rebuild for nothing.
|
|
@@ -729,25 +763,151 @@ pushes the restores forwards and is reversed as a whole, so it reads: drop the n
|
|
|
729
763
|
back, then recreate the ones compiled against the old type — restoring first is `42883` in the
|
|
730
764
|
other direction. What it restores is what the snapshot RECORDED, never what the entity declares.
|
|
731
765
|
|
|
732
|
-
**
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
766
|
+
**A FOREIGN KEY over the retyped column is moved too, `As of 2026-08-25`, and `retype-keys.ts`
|
|
767
|
+
decides which — above `diffTable`, which is the whole point.** Postgres re-checks a key's two ends
|
|
768
|
+
against each other on every `alter column … type`: measured on 18.4, `42804 foreign key constraint
|
|
769
|
+
"rk_posts_org_code_fkey" cannot be implemented — Key columns "org_code" … and "code" … are of
|
|
770
|
+
incompatible types: integer and text`, thrown by the ALTER itself, inside `ROLE=migrate`, with the
|
|
771
|
+
ledger recording nothing.
|
|
772
|
+
|
|
773
|
+
**It could not be answered from inside `diffTable` and that is not an implementation detail.** The
|
|
774
|
+
constraint that breaks is recorded on the table that OWNS it, so for a retype of the key's TARGET it
|
|
775
|
+
is a different entity's row — `diffTable(orgs)` is handed `orgs`'s record and can never see
|
|
776
|
+
`posts.foreignKeys`. So `retypedColumns(entities, current)` derives the whole schema's retype set
|
|
777
|
+
once, before the entity loop, and `retypeColumn` READS it instead of asking
|
|
778
|
+
`recorded.dataType === wanted` a second time: two answers to "is this column being retyped" is the
|
|
779
|
+
axiom-1 split this package has spent the week closing.
|
|
780
|
+
|
|
781
|
+
Four rules ride with it.
|
|
782
|
+
|
|
783
|
+
| Rule | Why |
|
|
784
|
+
|---|---|
|
|
785
|
+
| 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 |
|
|
786
|
+
| 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 |
|
|
787
|
+
| **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 |
|
|
788
|
+
| 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 |
|
|
789
|
+
|
|
790
|
+
**Re-adding the key is still the SERVER's judgement, deliberately.** An entity that retypes one end
|
|
791
|
+
and not the other declares a pairing Postgres has no operator for, and the `add constraint` at the
|
|
792
|
+
end of `up` is where that is said. Refusing it at generation would need to know whether two types
|
|
793
|
+
share an equality operator — `varchar(80)` and `text` do, `integer` and `text` do not — which is the
|
|
794
|
+
operator-resolution knowledge a generator with no database cannot have, and the same reason
|
|
795
|
+
`referencesColumn` over-approximates. What it cannot see at all is a key the recorded schema does
|
|
796
|
+
not hold: a hand-written migration's, or a sidecar written before `foreignKeys` was recorded.
|
|
797
|
+
|
|
798
|
+
`sql-type.ts` holds `SQL_TYPES`/`sqlType`, split out of `generate.ts` so the pre-pass can ask what a
|
|
799
|
+
kind renders to without importing the module that imports it. The read is **guarded** with
|
|
800
|
+
`Object.hasOwn`, and db's `proto-index` pin dropped 5 → 4 in the same commit — the ratchet reports a
|
|
801
|
+
count that drops as `stale`, so the two could not land apart. `kind` is data: unguarded,
|
|
802
|
+
`SQL_TYPES['constructor']` answered the `Object` function and its source went into the type position
|
|
803
|
+
of an `alter` statement, and `'__proto__'` answered `[object Object]`. Guarded, both pass through as
|
|
804
|
+
themselves like any other unknown kind, and no other input's answer moves.
|
|
805
|
+
|
|
806
|
+
**A generated column's REBUILD moves its dependents aside too, `As of 2026-08-25`, and it reuses
|
|
807
|
+
`retypeDependents` rather than answering again.** Plain → generated has no `set expression`, so
|
|
808
|
+
`regenerate` drops the column and adds it back — and `drop column` silently takes every partial
|
|
809
|
+
index whose PREDICATE names it and every CHECK whose expression does (measured, 18.4). The `rebuilt`
|
|
810
|
+
set `diffTable` carries into its index loop is keyed on an index's COLUMNS, so neither is a name it
|
|
811
|
+
can find: the table came back without them, the snapshot still recording both, and `down` unable to
|
|
812
|
+
restore either. `regenerate` therefore takes `live` and `moved` and calls `moveDependentsAside`,
|
|
813
|
+
which drops each explicitly, restores it in `down`, and puts the name where the ordinary diff will
|
|
814
|
+
CREATE it. `generate-generated-rebuild.live.test.ts` applies it both ways.
|
|
815
|
+
|
|
816
|
+
**A generated column's own `alter … type` deliberately does NOT move them, and the reason is
|
|
817
|
+
measured.** It trips the same `42883` (`operator does not exist: text > integer`, on a generated
|
|
818
|
+
`integer` column under `where (doubled > 0)`) — but moving the index aside only relocates the
|
|
819
|
+
failure to the `create index` that puts it back, because a predicate whose operator the NEW type has
|
|
820
|
+
no resolution for cannot be written either. The plain path's dependents survive precisely because an
|
|
821
|
+
untyped literal re-resolves (`status = 'published'` under an enum and under `text`), and a generated
|
|
822
|
+
column reaching that shape needs its EXPRESSION changed in the same migration, which `regenerate`
|
|
823
|
+
emits AFTER the type statement. Left open with the failure named in the source rather than closed
|
|
824
|
+
with a change no test could fail on.
|
|
825
|
+
|
|
826
|
+
And **what no migration wrote down** is still invisible to the generator by construction — `x db gen`
|
|
827
|
+
runs with no database open, so a hand-added expression index over the column is `42883` whatever
|
|
828
|
+
this does, since `SchemaDescription` has a field for it nowhere.
|
|
829
|
+
|
|
830
|
+
**A VIEW is NOT discoverable from anything this generator reads, and the honest ceiling is a
|
|
831
|
+
refusal one statement earlier, `As of 2026-08-25`.** `SchemaDescription` has no field for a view,
|
|
832
|
+
`introspect()` reads none by construction (`app-relation.ts` excludes every non-table relation), and
|
|
833
|
+
no `entity()` can declare one — so a `GenerateOptions.views` with no caller to fill it would be the
|
|
834
|
+
declared-and-never-wired defect this release exists to eliminate, and the caller is
|
|
835
|
+
`@ultimat3/cli`'s. What DOES have a connection is `migrate()`. `dependent-view.ts` is the preflight:
|
|
836
|
+
`refuseDependentViews(tx, script)` runs inside each migration's own transaction, before its first
|
|
837
|
+
statement, and both `migrate()` and `rollback()` call it.
|
|
838
|
+
|
|
839
|
+
It repairs nothing and does not claim to — the deploy still stops. What it replaces is
|
|
840
|
+
`X_DB_UNAVAILABLE: cannot reach the database`, whose registered `fix:` is "set `DATABASE_URL` to a
|
|
841
|
+
reachable Postgres url", on a database the migrator is connected to and mid-transaction on. The
|
|
842
|
+
server's own words name the view in a **DETAIL** field nothing printed:
|
|
843
|
+
`0A000 cannot alter type of a column used by a view or rule` /
|
|
844
|
+
`rule _RETURN on view dv_docs_published depends on column "rank"`. `X_MIGRATION_VIEW_DEPENDS` names
|
|
845
|
+
the view, the table and the column, and its `fix:` is the `drop view` plus the `create view` built
|
|
846
|
+
from `pg_get_viewdef(oid, true)` — a paste, not an archaeology.
|
|
847
|
+
|
|
848
|
+
Four rules.
|
|
849
|
+
|
|
850
|
+
| Rule | Why |
|
|
851
|
+
|---|---|
|
|
852
|
+
| `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 |
|
|
853
|
+
| 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 |
|
|
854
|
+
| 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 |
|
|
855
|
+
| 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 |
|
|
856
|
+
|
|
857
|
+
A script that retypes nothing costs one text scan and no round trip, which is nearly every migration
|
|
858
|
+
an app writes.
|
|
859
|
+
|
|
860
|
+
**`index-ddl.ts` holds `createIndex`, `redefineIndex`, `indexShape`, `dropIndex`,
|
|
861
|
+
`dropRecordedIndex`, `mayBeConstraintBacked` and `asDeclared`**, split out of `generate.ts` at the
|
|
862
|
+
500-line ceiling along the seam `check-ddl.ts` and `generated-column.ts` already drew —
|
|
863
|
+
`generate.ts` assembles a plan, `index-plan.ts` decides which index statements go in it, and
|
|
864
|
+
`index-ddl.ts` writes them. `drift-findings.ts` is the same split on the other file: every `DriftDifference`
|
|
748
865
|
constructor and the `DriftKind` union, with `drift.ts` keeping the comparisons and re-exporting both
|
|
749
866
|
types explicitly so the public surface does not move.
|
|
750
867
|
|
|
868
|
+
**`index-plan.ts` walks both directions, `As of 2026-08-25`** — the third arm to learn it, after
|
|
869
|
+
`checkPlan` and `foreignKeyPlan`. `diffTable`'s index loop walked `declaredIndexes(entity)` and
|
|
870
|
+
matched by name with **no reverse pass**, so an index the entities stopped declaring stayed on the
|
|
871
|
+
database forever while the sidecar beside it stopped recording it: measured on `examples/dummy`,
|
|
872
|
+
`member_unique_per_org`, `members_tz_idx` and `post_slug_unique_per_org` all survived a regeneration
|
|
873
|
+
that recorded none of them, and the `drift` gate step was green over all three because drift judges
|
|
874
|
+
the declared side. `indexPlan(entity, live, plan, context)` is the whole question now — declared
|
|
875
|
+
first and removed last, the order `checkPlan` uses — and `generate.ts` calls it.
|
|
876
|
+
|
|
877
|
+
**A recorded UNIQUE index cannot be told from a UNIQUE CONSTRAINT's, and it never will be.**
|
|
878
|
+
`TableDescription` carries no discriminator and cannot usefully be given one: the *same*
|
|
879
|
+
declaration reaches the server as either, depending on which migration created it. A `unique` column
|
|
880
|
+
on a table `createTable` writes goes out as `create table … slug text unique`, which Postgres backs
|
|
881
|
+
with a **constraint** named `posts_slug_key`; the same column gaining `unique` later takes
|
|
882
|
+
`diffTable`'s `create unique index "posts_slug_key"` and is a plain index. `snapshotOf` records both
|
|
883
|
+
as `{ unique: true, primary: false }`, and every sidecar already on disk was written that way, so a
|
|
884
|
+
new field could not classify one retroactively. Measured on 18.4
|
|
885
|
+
(`index-removal.live.test.ts`):
|
|
886
|
+
|
|
887
|
+
| statement | on a constraint's index | on a plain index |
|
|
888
|
+
|---|---|---|
|
|
889
|
+
| `drop index "n"` | **2BP01** | ok |
|
|
890
|
+
| `drop index if exists "n"` | **2BP01** — `if exists` does not suppress it | ok |
|
|
891
|
+
| `alter table … drop constraint if exists "n"` | drops it, index and all | notice, no-op |
|
|
892
|
+
|
|
893
|
+
So `dropRecordedIndex` emits the **pair**, constraint first — reversed, the `drop index` reaches a
|
|
894
|
+
constraint's index and is the 2BP01 this exists to avoid — and only for the shape a constraint could
|
|
895
|
+
be backing: `mayBeConstraintBacked` is unique, non-primary, total, unordered and btree, because
|
|
896
|
+
`add constraint … unique` and a `unique` column clause can produce nothing else. A partial or
|
|
897
|
+
ordered or GIN index takes the bare `drop index`. The asymmetry that remains is named rather than
|
|
898
|
+
hidden: `down` recreates it with `create unique index`, so a constraint comes back as an index. That
|
|
899
|
+
is the one statement this generator has, and it restores what the record described.
|
|
900
|
+
|
|
901
|
+
Four names are skipped by the removal arm, and each is a statement Postgres would refuse or repeat:
|
|
902
|
+
a `primary` index (2BP01, and the key is `TableDescription.primaryKey`), one already in
|
|
903
|
+
`MovedAside.indexes` (a retype dropped it ahead of the ALTER — 42704), one over a column
|
|
904
|
+
`regenerate` rebuilt (it went with the `drop column` — 42704), and one over a column this migration
|
|
905
|
+
DROPS (`alter table … drop column` takes it, the rule `foreignKeyPlan` already applies to a
|
|
906
|
+
constraint on a dropped column). A doomed **table** needs no arm at all: `generate.ts` only reaches
|
|
907
|
+
a diff for a table an entity still declares. The known limit is written in the file header — a
|
|
908
|
+
unique index a foreign key on ANOTHER table still references cannot be dropped (2BP01), and this arm
|
|
909
|
+
sees one table at a time.
|
|
910
|
+
|
|
751
911
|
**An entity's INVARIANTS reach the DDL, `As of 2026-08-25`, and `invariant-ddl.ts` is what they
|
|
752
912
|
become.** `EntityDescriptionLike` had no `invariants` field at all — the same seam gap
|
|
753
913
|
`onDelete` carried until 3.0 — so a regenerated migration held **none** of them: measured on
|
|
@@ -961,8 +1121,10 @@ module's vocabulary; `snapshotOf` imports `foreignKeysOf` back, one direction on
|
|
|
961
1121
|
**`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
|
|
962
1122
|
emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
|
|
963
1123
|
stayed on the database **and** the record denied one the catalog holds, which `compareForeignKeys`
|
|
964
|
-
can never see because it judges the declared side.
|
|
965
|
-
removed index leaves the snapshot correct by omission, and
|
|
1124
|
+
can never see because it judges the declared side. **This paragraph said "that is not parity with a
|
|
1125
|
+
removed index: a removed index leaves the snapshot correct by omission", and that was wrong** — see
|
|
1126
|
+
`index-plan.ts` below: a removed index's snapshot lied in exactly the same way, and the arm to fix
|
|
1127
|
+
it did not land until 2026-08-25. The drop names the
|
|
966
1128
|
constraint **the previous snapshot recorded**, never the one this generator would have chosen — a
|
|
967
1129
|
hand-written `fk_legacy` is `42704` under the generated spelling — and a key whose columns this
|
|
968
1130
|
migration is dropping is skipped, because `drop column` takes the constraint with it. A key whose
|
|
@@ -1115,12 +1277,77 @@ takes, and left unsaid an `alter database … set statement_timeout` on the serv
|
|
|
1115
1277
|
that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
|
|
1116
1278
|
survives the round trip whole.
|
|
1117
1279
|
|
|
1280
|
+
- **Every numeric option this package bounds anything with is screened, `As of 2026-08-26`** —
|
|
1281
|
+
through core's `finiteCount`, which borrows `X_INVARIANT` as this package already does.
|
|
1282
|
+
`replicaClient`'s `breakerFailures` and `breakerCooldownMs` (a breaker is two comparisons and
|
|
1283
|
+
nothing else: `failures >= NaN` never opens it, `monotonic() < NaN` never parks it, so every read
|
|
1284
|
+
keeps going to the replica that is failing), `migrate`'s `lockWaitMs` (`NaN - elapsed <= 0` is
|
|
1285
|
+
false and `Bun.sleep(NaN)` does not sleep — a tight spin re-taking `pg_try_advisory_lock`, not an
|
|
1286
|
+
unbounded wait) and `readonlyQuery`'s `timeoutMs`, plus `client.ts`'s pool profile. The last one
|
|
1287
|
+
is a **behaviour change**: it used to normalise `NaN` to the default silently, so an agent read
|
|
1288
|
+
ran under a ceiling nobody wrote. Only an explicit `0` disables that layer, which is why its floor
|
|
1289
|
+
is 0 and not 1.
|
|
1290
|
+
|
|
1291
|
+
- **`client.ts` reached the 500-line ceiling on 2026-08-26, and shed the five jobs that were not
|
|
1292
|
+
"open a connection and send a statement".** `pool-profile.ts` owns the five numbers a pool runs
|
|
1293
|
+
on — the per-role table, `DATABASE_POOL_MAX` and the screen every merged profile passes;
|
|
1294
|
+
`connection-url.ts` builds the connection string (the libpq `options` merge and the
|
|
1295
|
+
`application_name` label); `bun-sql.ts` declares the slice of `Bun.SQL` this package uses and
|
|
1296
|
+
looks the global up lazily;
|
|
1297
|
+
`pool-reserve.ts` is `reserve()` under the acquire deadline; `db-health.ts` is `checkDb`, the
|
|
1298
|
+
`/readyz` report. `client.ts` keeps connecting, the statement funnel and the ambient `db()` —
|
|
1299
|
+
and it still opens no socket at import, because `bunSqlFactory()` is reached from inside
|
|
1300
|
+
`connect()`. **The public surface did not move**: `src/index.ts` exports every one of those
|
|
1301
|
+
names from its new module, so `@ultimat3/db` is byte-identical to what it was. The same day,
|
|
1302
|
+
`drift.test.ts` split three ways along the three questions it was asking — `drift.test.ts`
|
|
1303
|
+
(tables and columns), `drift-index.test.ts` and `drift-ledger.test.ts` (what the migrations
|
|
1304
|
+
declare, and the post-migrate check) — over one shared `drift-fixtures.ts`, which
|
|
1305
|
+
`drift-foreign-key.test.ts` now imports instead of carrying its own byte-identical copy.
|
|
1306
|
+
|
|
1307
|
+
- **`unexpectedTable`'s `fix:` no longer names `x db gen`, `As of 2026-08-26`** (issue #345). That
|
|
1308
|
+
command diffs the ENTITY REGISTRY against the newest snapshot, and a table nothing declares is on
|
|
1309
|
+
neither side of it — so the diff came back empty, the generator's empty-diff branch writes NO
|
|
1310
|
+
file, and the reader had nothing to run and the same finding on the next deploy. The two edits
|
|
1311
|
+
that do resolve it are named instead: a `create table if not exists` in a migration (which
|
|
1312
|
+
`x db migrate` then accepts, through `@ultimat3/cli`'s `acceptCreatedTables`), or `psql … drop
|
|
1313
|
+
table` for a table nothing owns. No migration PATH is named — where an app keeps its migrations is
|
|
1314
|
+
the CLI's fact. `X_DB_DRIFT` is a shipped code and is unchanged; only this `fix:` text moved.
|
|
1315
|
+
|
|
1316
|
+
- **A name a `fix:` puts in a command is screened ONCE, by `shellInertIdentifier()` (`sql.ts`),
|
|
1317
|
+
`As of 2026-08-26`.** `identifier()` answers about SQL and cannot close this: it refuses `"`,
|
|
1318
|
+
`\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its
|
|
1319
|
+
fast path — which are exactly the two characters a shell substitutes inside DOUBLE quotes. A
|
|
1320
|
+
`fix:` is pasted into a shell at least as often as into a psql session, so a column named
|
|
1321
|
+
`$(id)` inside `x db gen "add $(id)"` RUNS `id` the moment its reader pastes the line, and a
|
|
1322
|
+
screen reusing `identifier()` unchanged ships a green suite over a live command-execution hole.
|
|
1323
|
+
It began as a private `writableName` in `drift-findings.ts` and was promoted rather than copied:
|
|
1324
|
+
three copies of a string-literal escape shipped here once and two were wrong the same way
|
|
1325
|
+
(`scripts/sql-literal-copies.ts`). Callers **degrade to prose** — the argument to `x db gen` is a
|
|
1326
|
+
migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass,
|
|
1327
|
+
and the name is read off `cause`/`meta` instead. Every benign rendering is byte-identical: the
|
|
1328
|
+
screen sits on the refusal branch alone, because roughly ten pages across `packages/cli`,
|
|
1329
|
+
`packages/core`, `wiki/` and `docs/` quote `x db gen "add <name>"` verbatim.
|
|
1330
|
+
|
|
1331
|
+
- **`dbDrift()` lives in `drift-errors.ts` and not in `errors.ts`, for exactly the reason
|
|
1332
|
+
`dependent-view.ts` states.** Its `fix:` needs `shellInertIdentifier` and `sql.ts` imports
|
|
1333
|
+
`errors.ts`, so keeping the constructor there is an import cycle around the module whose
|
|
1334
|
+
evaluation REGISTERS every code. `dependent-view.ts` avoided the same cycle by handing
|
|
1335
|
+
`errors.ts` a finished string; that is not available here, because `dbDrift(table, column)` is
|
|
1336
|
+
public API shipped since 1.0 and its signature cannot change. So the constructor moved instead,
|
|
1337
|
+
the way `migration-errors.ts` and `invariant-errors.ts` did — `X_DB_DRIFT` is still declared,
|
|
1338
|
+
titled and registered in `errors.ts`, and `src/index.ts` still exports the same name, so the
|
|
1339
|
+
public surface is byte-identical. `@ultimat3/entity`'s mirror screens through the **same**
|
|
1340
|
+
export across the tier seam (tier 2 → tier 1), which is what keeps the "keep in sync" comment on
|
|
1341
|
+
both declarations true; `packages/entity/src/errors.test.ts` asserts the two texts are equal,
|
|
1342
|
+
so a one-sided edit is a failing test rather than a comment nobody read.
|
|
1343
|
+
|
|
1118
1344
|
```bash
|
|
1119
1345
|
bun test # from packages/db
|
|
1120
1346
|
bun run typecheck
|
|
1121
1347
|
```
|
|
1122
1348
|
|
|
1123
1349
|
Gotchas:
|
|
1350
|
+
|
|
1124
1351
|
- `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
|
|
1125
1352
|
- `noUncheckedIndexedAccess` — array reads are `T | undefined`; `chunks[i] ?? ''` everywhere.
|
|
1126
1353
|
- Tests use `createRecordingClient()` + `setDbClient()`; no test may need a live database.
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ await withTransaction(async (tx) => {
|
|
|
26
26
|
| Export | |
|
|
27
27
|
|---|---|
|
|
28
28
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
29
|
+
| `shellInertIdentifier()` | `As of 2026-08-26`: a quoted identifier that is also inert wherever a human PASTES it — or `null`. The one screen a catalog name goes through before it reaches a `fix:`. `identifier()` answers about SQL and **accepts** a backtick and a `$`, which are exactly what a shell substitutes inside double quotes, so a column called `$(id)` inside `x db gen "add $(id)"` runs `id` on paste |
|
|
29
30
|
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
30
31
|
| `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused |
|
|
31
32
|
| `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all |
|
|
@@ -216,9 +217,9 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
216
217
|
|
|
217
218
|
| Difference | cause | fix |
|
|
218
219
|
|---|---|---|
|
|
219
|
-
| live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` |
|
|
220
|
+
| live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` — the name goes through `shellInertIdentifier()`, and one it refuses is left OUT of the command rather than escaped into it (`x db gen "add the undeclared column"`, the name in the cause) |
|
|
220
221
|
| migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
|
|
221
|
-
| live table, no migration | `table "T" is not present in any migration` | `x db gen
|
|
222
|
+
| live table, no migration | `table "T" is not present in any migration` | a `create table if not exists` in a migration, then `x db migrate` — or `drop table` in `psql` where nothing owns it. Never `x db gen`, which diffs a table nothing declares against nothing and writes no file (issue #345). The name goes through `shellInertIdentifier()`, and one it refuses leaves the fix as prose |
|
|
222
223
|
| migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
|
|
223
224
|
| index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
|
|
224
225
|
| foreign key, rule moved | `foreign key on "T" (C) to "R" is on delete cascade, not what migrations declare` | the `drop constraint` + `add constraint` pair, in a new migration |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.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": "17.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/bun-sql.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, and the
|
|
2
|
+
// lazy lookup of the global that provides it. Reached through a function so importing the client
|
|
3
|
+
// never touches `Bun` at module evaluation — the CLI imports it to print help.
|
|
4
|
+
|
|
5
|
+
import { dbUnavailable } from './errors';
|
|
6
|
+
|
|
7
|
+
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
8
|
+
export interface BunSqlReserved {
|
|
9
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
10
|
+
release(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
|
|
14
|
+
export interface BunSqlDriver {
|
|
15
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
16
|
+
reserve(): Promise<BunSqlReserved>;
|
|
17
|
+
close(options?: { readonly timeout?: number }): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BunSqlFactory = new (
|
|
21
|
+
url: string,
|
|
22
|
+
options?: Readonly<Record<string, unknown>>,
|
|
23
|
+
) => BunSqlDriver;
|
|
24
|
+
|
|
25
|
+
export function bunSqlFactory(): BunSqlFactory {
|
|
26
|
+
const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } };
|
|
27
|
+
const factory = host.Bun?.SQL;
|
|
28
|
+
if (typeof factory !== 'function') {
|
|
29
|
+
throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3');
|
|
30
|
+
}
|
|
31
|
+
return factory as BunSqlFactory;
|
|
32
|
+
}
|