@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 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
- | Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
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: the database rebuilds them.
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`, and no snapshot records a view |
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
- **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`
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. That is not parity with a removed index: a
965
- removed index leaves the snapshot correct by omission, and this snapshot lied. The drop names the
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 "add T"` |
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": "15.0.0",
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": "15.0.0"
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
+ }