@ultimat3/db 15.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 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: the database rebuilds them.
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
@@ -685,6 +688,36 @@ is the `add constraint` statement itself, not `x db migrate`: the migration decl
685
688
  in the ledger, so the migrator applies nothing, and the declared side carries the predicate that
686
689
  makes an executable fix possible at all.
687
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
+
688
721
  **A retype takes the objects written against the column out of its way first, `As of 2026-08-25`,
689
722
  and `retype-dependents.ts` decides which those are.** Postgres compiles a partial index's predicate
690
723
  and a CHECK's expression against the column's type at creation and cannot recompile either:
@@ -702,7 +735,7 @@ shape at a time on 18.4):
702
735
  | partial index whose predicate names the column | **no — 42883** |
703
736
  | partial index naming another column | yes |
704
737
  | CHECK whose expression names the column | **no — 42883** |
705
- | a view over the column | no, `0A000`, and no snapshot records a view |
738
+ | a view over the column | no, `0A000`; no snapshot records a view, so `migrate()` refuses it instead (`dependent-view.ts`) |
706
739
 
707
740
  So only an expression that MENTIONS the column is moved, and a plain btree is left alone — dropping
708
741
  it is a table scan to rebuild for nothing.
@@ -729,25 +762,151 @@ pushes the restores forwards and is reversed as a whole, so it reads: drop the n
729
762
  back, then recreate the ones compiled against the old type — restoring first is `42883` in the
730
763
  other direction. What it restores is what the snapshot RECORDED, never what the entity declares.
731
764
 
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`
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`
748
864
  constructor and the `DriftKind` union, with `drift.ts` keeping the comparisons and re-exporting both
749
865
  types explicitly so the public surface does not move.
750
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
+
751
910
  **An entity's INVARIANTS reach the DDL, `As of 2026-08-25`, and `invariant-ddl.ts` is what they
752
911
  become.** `EntityDescriptionLike` had no `invariants` field at all — the same seam gap
753
912
  `onDelete` carried until 3.0 — so a regenerated migration held **none** of them: measured on
@@ -961,8 +1120,10 @@ module's vocabulary; `snapshotOf` imports `foreignKeysOf` back, one direction on
961
1120
  **`foreignKeyPlan` walks both directions, `As of 2026-08-19`.** A *removed* `references()` used to
962
1121
  emit nothing while the snapshot beside it recorded `foreignKeys: []` — so the orphan constraint
963
1122
  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
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
966
1127
  constraint **the previous snapshot recorded**, never the one this generator would have chosen — a
967
1128
  hand-written `fk_legacy` is `42704` under the generated spelling — and a key whose columns this
968
1129
  migration is dropping is skipped, because `drop column` takes the constraint with it. A key whose
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "15.0.0",
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": "15.0.0"
34
+ "@ultimat3/core": "16.0.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
@@ -0,0 +1,224 @@
1
+ // Single responsibility: refuse a migration whose `alter column … type` a VIEW is compiled against,
2
+ // before the statement is sent — and name the view, the column and the statement that recreates it.
3
+ //
4
+ // **This is the honest ceiling for views, and the reason it is not in the generator.** `x db gen`
5
+ // runs with no database open; `SchemaDescription` has no field for a view; `introspect()` reads
6
+ // none by construction (`app-relation.ts` excludes every non-table relation); and no `entity()` can
7
+ // declare one. So nothing the generator reads knows a view exists, and a `GenerateOptions.views`
8
+ // with no caller to fill it is the declared-and-never-wired defect this release exists to
9
+ // eliminate. What DOES have a connection is `migrate()`, one statement before the abort — and the
10
+ // catalog answers the question exactly, including for a view no migration in this repo wrote.
11
+ //
12
+ // Measured on 18.4: `alter table "dv_docs" alter column "rank" type text using "rank"::text` under
13
+ // a view selecting that column answers `0A000 cannot alter type of a column used by a view or
14
+ // rule`, with `rule _RETURN on view dv_docs_published depends on column "rank"` in a DETAIL field
15
+ // nothing printed — surfaced as `X_DB_UNAVAILABLE: cannot reach the database`, whose registered
16
+ // `fix:` says to set `DATABASE_URL`.
17
+ //
18
+ // It does not repair anything and does not claim to: the deploy still stops. What it replaces is
19
+ // wrong advice about a healthy database with the two statements that unblock it.
20
+
21
+ import type { DbClient } from './client';
22
+ import { migrationViewDepends } from './migration-errors';
23
+ import { identifier, join, sql } from './sql';
24
+ import { IDENTIFIER_PART, noiseAt } from './sql-scan';
25
+ import { statementsOf } from './statement-split';
26
+
27
+ /** One `alter table <table> alter column <column> type …`, as the catalog spells both names. */
28
+ export interface RetypeTarget {
29
+ readonly table: string;
30
+ readonly column: string;
31
+ }
32
+
33
+ /**
34
+ * One name in a statement. Called a WORD and not the obvious lexer noun deliberately:
35
+ * `scripts/secret-compare.ts` reads a comparison whose operand is NAMED like a credential, and
36
+ * that noun is one of the names it reads — a `.text === spelling` under it is indistinguishable
37
+ * from an auth check to a static rule that has only the name to go on.
38
+ */
39
+ interface SqlWord {
40
+ readonly text: string;
41
+ /** A quoted name is never a keyword — `"type"` is a column called type, not the clause. */
42
+ readonly quoted: boolean;
43
+ }
44
+
45
+ /**
46
+ * The names in one statement, in order, folded the way Postgres folds them: an unquoted identifier
47
+ * to lower case, a quoted one verbatim. Comments, string literals and dollar-quoted bodies
48
+ * contribute nothing, through this package's one lexer — `-- alter column` is prose and
49
+ * `'alter column'` is data.
50
+ */
51
+ function wordsOf(statement: string): readonly SqlWord[] {
52
+ const words: SqlWord[] = [];
53
+ let at = 0;
54
+ while (at < statement.length) {
55
+ const noise = noiseAt(statement, at);
56
+ if (noise !== null) {
57
+ if (noise.kind === 'identifier') {
58
+ words.push({ text: statement.slice(at + 1, noise.end - 1), quoted: true });
59
+ }
60
+ at = noise.end;
61
+ continue;
62
+ }
63
+ if (!IDENTIFIER_PART.test(statement[at] ?? '')) {
64
+ at += 1;
65
+ continue;
66
+ }
67
+ let end = at;
68
+ while (end < statement.length && IDENTIFIER_PART.test(statement[end] ?? '')) end += 1;
69
+ words.push({ text: statement.slice(at, end).toLowerCase(), quoted: false });
70
+ at = end;
71
+ }
72
+ return words;
73
+ }
74
+
75
+ const keyword = (word: SqlWord | undefined, spelling: string): boolean =>
76
+ word !== undefined && !word.quoted && word.text === spelling;
77
+
78
+ /**
79
+ * Every column this script retypes. Narrow ON PURPOSE — `alter table <t> … alter [column] <c> type`
80
+ * and nothing else — because a miss costs exactly what happens today (the server's own `0A000`,
81
+ * one statement later) while a false positive costs a catalog read and a refusal on a migration
82
+ * that would have applied. Every retype `generateMigration` emits is this shape; a hand-written
83
+ * `ALTER TABLE ONLY t …` is not, and is deliberately left to the server.
84
+ */
85
+ export function retypeTargets(script: string): readonly RetypeTarget[] {
86
+ const targets: RetypeTarget[] = [];
87
+ for (const statement of statementsOf(script)) {
88
+ const words = wordsOf(statement);
89
+ const table = words[2];
90
+ if (!keyword(words[0], 'alter') || !keyword(words[1], 'table') || table === undefined) {
91
+ continue;
92
+ }
93
+ for (let index = 3; index < words.length; index += 1) {
94
+ if (!keyword(words[index], 'alter')) continue;
95
+ const at = keyword(words[index + 1], 'column') ? index + 2 : index + 1;
96
+ const column = words[at];
97
+ if (column === undefined || !keyword(words[at + 1], 'type')) continue;
98
+ targets.push({ table: table.text, column: column.text });
99
+ }
100
+ }
101
+ return targets;
102
+ }
103
+
104
+ interface ViewRow {
105
+ readonly view_name: string;
106
+ readonly table_name: string;
107
+ readonly column_name: string;
108
+ readonly definition: string;
109
+ /** `v` or `m`. A MATERIALISED view needs different DDL to drop and to recreate. */
110
+ readonly relkind: string;
111
+ }
112
+
113
+ /**
114
+ * `pg_depend` -> `pg_rewrite` is the only edge that records this: a view depends on a column
115
+ * through its `_RETURN` rule, never through a row in `pg_class` alone. Materialised views are
116
+ * included (`relkind = 'm'`) because they carry the same rule and fail the same way.
117
+ *
118
+ * One round trip for every target, `in` over both name lists, and the exact pairing filtered in
119
+ * the caller — a per-target query would be a loop of statements inside the migration's own
120
+ * transaction, and a cross-product read is cheap where a false pair is not.
121
+ */
122
+ async function dependentViews(
123
+ client: DbClient,
124
+ targets: readonly RetypeTarget[],
125
+ ): Promise<readonly ViewRow[]> {
126
+ const tables = join(
127
+ [...new Set(targets.map((target) => target.table))].map((name) => sql`${name}`),
128
+ );
129
+ const columns = join(
130
+ [...new Set(targets.map((target) => target.column))].map((name) => sql`${name}`),
131
+ );
132
+ return client.query<ViewRow>(sql`
133
+ select distinct v.relname as view_name, c.relname as table_name, a.attname as column_name,
134
+ pg_get_viewdef(v.oid, true) as definition, v.relkind as relkind
135
+ from pg_depend d
136
+ join pg_rewrite r on r.oid = d.objid and d.classid = 'pg_rewrite'::regclass
137
+ join pg_class v on v.oid = r.ev_class
138
+ join pg_class c on c.oid = d.refobjid and d.refclassid = 'pg_class'::regclass
139
+ join pg_attribute a on a.attrelid = c.oid and a.attnum = d.refobjsubid
140
+ where v.relkind in ('v', 'm') and v.oid <> c.oid
141
+ and c.relname in (${tables}) and a.attname in (${columns})
142
+ order by v.relname
143
+ `);
144
+ }
145
+
146
+ /**
147
+ * One SQL statement as a single argv word for `psql -c`.
148
+ *
149
+ * SINGLE quotes, unlike `migrationConflict`'s `-c "…"`: `identifier()` writes the view's name in
150
+ * DOUBLE quotes, so a double-quoted shell word would end at the name. The definition is the
151
+ * server's own text and may hold a `'` of its own — `where status = 'published'` — so the one
152
+ * escape a POSIX shell has for it is spelled out here. This is not the SQL literal escape
153
+ * (`sql.ts`'s `literal()`, the tree's one copy of that); nothing below is sent to a server.
154
+ */
155
+ const shellArg = (statement: string): string => `'${statement.replaceAll("'", `'\\''`)}'`;
156
+
157
+ /** The invocation `migrationConflict` already writes, with the statement as its own argv word. */
158
+ const psql = (statement: string): string => `psql "$DATABASE_URL" -c ${shellArg(statement)}`;
159
+
160
+ /**
161
+ * The two statements that unblock the deploy, as one line an operator pastes.
162
+ *
163
+ * It leads with the command to RUN and carries the follow-up in a `#` comment, the shape
164
+ * `migrateConcurrent` and `migrationSnapshotMissing` already write. It used to lead with bare DDL
165
+ * and a `#`: `#` is not a comment in Postgres, so psql read the whole line and failed on it, while
166
+ * a shell read `drop` as a program that does not exist. Neither reader could run it (axiom 4).
167
+ *
168
+ * `identifier()` REFUSES a name holding a quote, a space or a backslash — all three legal inside a
169
+ * quoted Postgres name — and a `fix:` may not throw: the rule `rebuildForeignKey` already states,
170
+ * with the same shape. A refusal that raised `X_SQL_UNSAFE` in place of the finding would hand the
171
+ * operator an exception where a verdict was asked for, over a view name that is perfectly legal.
172
+ * The fallback still leads with a command that runs — a psql session — because quoting that name
173
+ * is the one step this package will not do twice: `identifier()` is its only identifier writer.
174
+ *
175
+ * The definition is collapsed to one line because `pg_get_viewdef(oid, true)` pretty-prints across
176
+ * several and a `fix:` is read as a command.
177
+ *
178
+ * `relkind` decides the DDL and is not cosmetic: `dependentViews` deliberately selects `'m'` as
179
+ * well as `'v'`, and Postgres refuses `drop view` on a materialised one — `WRONG_OBJECT_TYPE`,
180
+ * "use DROP MATERIALIZED VIEW". So the one case the query went out of its way to include was the
181
+ * one whose `fix:` could not run. `pg_get_viewdef` answers the SELECT for both kinds, so only the
182
+ * two keywords differ; a matview's indexes and its `WITH DATA` population are NOT carried, and
183
+ * the fix says so rather than implying the recreate is complete.
184
+ */
185
+ function restoreView(view: string, definition: string, relkind: string): string {
186
+ const body = definition.replace(/\s+/g, ' ').replace(/;\s*$/, '').trim();
187
+ const materialised = relkind === 'm';
188
+ const kind = materialised ? 'materialized view' : 'view';
189
+ const note = materialised
190
+ ? ' # then re-create its indexes: a matview keeps none of them across a drop'
191
+ : '';
192
+ try {
193
+ const name = identifier(view).text;
194
+ return (
195
+ `${psql(`drop ${kind} ${name}`)} # then x db migrate, then: ` +
196
+ `${psql(`create ${kind} ${name} as ${body}`)}${note}`
197
+ );
198
+ } catch {
199
+ return (
200
+ `psql "$DATABASE_URL" # quote the ${kind} name ${JSON.stringify(view)} yourself, then: ` +
201
+ `drop ${kind} <name>; \\q; x db migrate; and create it again as: create ${kind} <name> as ${body}${note}`
202
+ );
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Refuse before the ALTER, or return having sent nothing at all. A script that retypes no column
208
+ * costs one text scan and no round trip, which is every migration an app writes that is not a
209
+ * retype.
210
+ */
211
+ export async function refuseDependentViews(client: DbClient, script: string): Promise<void> {
212
+ const targets = retypeTargets(script);
213
+ if (targets.length === 0) return;
214
+ const wanted = new Set(targets.map((target) => `${target.table}.${target.column}`));
215
+ for (const row of await dependentViews(client, targets)) {
216
+ if (!wanted.has(`${row.table_name}.${row.column_name}`)) continue;
217
+ throw migrationViewDepends(
218
+ row.view_name,
219
+ row.table_name,
220
+ row.column_name,
221
+ restoreView(row.view_name, row.definition, row.relkind),
222
+ );
223
+ }
224
+ }
package/src/errors.ts CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  stringField,
12
12
  UltimateError,
13
13
  } from '@ultimat3/core';
14
- import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
15
14
  import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
16
15
 
17
16
  /**
@@ -31,6 +30,7 @@ export const DB_OWNED_ERROR_CODES = [
31
30
  'X_MIGRATION_IRREVERSIBLE',
32
31
  'X_MIGRATION_DESTRUCTIVE',
33
32
  'X_MIGRATION_SNAPSHOT_MISSING',
33
+ 'X_MIGRATION_VIEW_DEPENDS',
34
34
  'X_MIGRATE_CONCURRENT',
35
35
  'X_SQL_UNSAFE',
36
36
  'X_BRANCH_EXISTS',
@@ -72,6 +72,7 @@ export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
72
72
  X_MIGRATION_IRREVERSIBLE: 'this migration cannot be reversed without data loss',
73
73
  X_MIGRATION_DESTRUCTIVE: 'this migration destroys data and does not say so',
74
74
  X_MIGRATION_SNAPSHOT_MISSING: 'the newest migration records no schema snapshot',
75
+ X_MIGRATION_VIEW_DEPENDS: 'a view is compiled against a column this migration retypes',
75
76
  X_SQL_UNSAFE: 'SQL was built by string interpolation',
76
77
  X_BRANCH_EXISTS: 'that branch database already exists',
77
78
  };
@@ -284,26 +285,6 @@ export const serializationExhausted = (attempts: number, sourceError: unknown):
284
285
  sourceError,
285
286
  });
286
287
 
287
- /**
288
- * The migration advisory lock was still held when the wait ran out. `pg_advisory_lock` blocks with
289
- * no timeout, so a migrator wedged on a partition — or OOM-killed with its backend still alive —
290
- * left `helm upgrade --wait` sitting inside one statement, printing nothing, with the job never
291
- * failing so `backoffLimit` never fired. A bounded `pg_try_advisory_lock` poll turns that into an
292
- * exit code.
293
- */
294
- export const migrateConcurrent = (lockKey: number, waitedMs: number): DbError =>
295
- new DbError({
296
- code: 'X_MIGRATE_CONCURRENT',
297
- cause:
298
- `another session still holds pg_advisory_lock(${lockKey}) after waiting ${waitedMs}ms, ` +
299
- 'so this migrator refused rather than block a deploy forever',
300
- fix:
301
- 'psql "$DATABASE_URL" -c "select pid, application_name, state from pg_stat_activity ' +
302
- "join pg_locks using (pid) where locktype = 'advisory'\"" +
303
- ' # pg_terminate_backend(pid) the wedged migrator, then: x db migrate',
304
- meta: { lockKey, waitedMs },
305
- });
306
-
307
288
  /** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
308
289
  export const dbDrift = (tableName: string, columnName: string): DbError =>
309
290
  new DbError({
@@ -313,85 +294,6 @@ export const dbDrift = (tableName: string, columnName: string): DbError =>
313
294
  meta: { table: tableName, column: columnName },
314
295
  });
315
296
 
316
- export const migrationConflict = (cause: string, fix: string): DbError =>
317
- new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
318
-
319
- export const migrationIrreversible = (cause: string, fix: string): DbError =>
320
- new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
321
-
322
- /**
323
- * A rollback step count this build cannot honour. `steps` reaches `Array.prototype.slice`, where a
324
- * negative count counts from the END: `steps: -1` selected every applied migration except the
325
- * newest and reversed four of five, which is the one class of mistake a rollback cannot undo.
326
- * Refused rather than coerced, exactly as `DATABASE_POOL_MAX` is — a number silently reinterpreted
327
- * as a different one is the failure a validated argument exists to prevent.
328
- */
329
- export const rollbackStepsInvalid = (received: number): DbError =>
330
- new DbError({
331
- code: 'X_INVARIANT',
332
- cause: `rollback was asked to reverse ${String(received)} migrations, which is not a positive integer`,
333
- fix: 'rollback({ migrations, steps: 1 }) # a whole number of migrations, newest first',
334
- meta: { steps: received },
335
- });
336
-
337
- /**
338
- * `packages/db/migrations/0000_initial.snapshot.json` → `packages/db/migrations/0000_initial.*` —
339
- * every file that one migration owns, as one `rm` argument. Derived from the path the caller passed
340
- * rather than rebuilt from a directory this package does not know: `db` is tier 1 and where an app
341
- * keeps its migrations is `@ultimat3/cli`'s answer, not this one's.
342
- */
343
- const snapshotSiblings = (file: string): string => file.replace(/\.snapshot\.json$/, '.*');
344
-
345
- /**
346
- * `20260817120000_add_posts` → `add_posts`, the argument `x db gen` takes. The name is free text
347
- * and only ever labels a *new* id, so an id carrying no stamp answers with itself rather than with
348
- * the empty string — a `fix:` ending in `x db gen ""` is a command that cannot be run.
349
- */
350
- const migrationNameOf = (id: string): string => id.replace(/^\d+_/, '') || id;
351
-
352
- /**
353
- * The sidecar every generated migration writes is what the *next* generation diffs against, so a
354
- * newest migration without one leaves nothing to diff. Refused rather than defaulted to the empty
355
- * schema, which would generate `create table` for every table the database already holds.
356
- */
357
- export const migrationSnapshotMissing = (id: string, file: string): DbError =>
358
- new DbError({
359
- code: 'X_MIGRATION_SNAPSHOT_MISSING',
360
- cause: `migration "${id}" records no schema snapshot (${file}), so there is nothing to diff against`,
361
- // Two remedies, both commands, in the order they are safe to try. "restore from version
362
- // control" alone was neither: on a scaffolded app the sidecar was never written, so there is
363
- // nothing to restore — and the drift this refusal answers named `x db gen` as *its* fix, so
364
- // the two errors pointed at each other and an app's first migration had no way out.
365
- // `x db gen` is named only *after* the files it would trip over are gone.
366
- fix:
367
- `git checkout -- ${file} # or, if it was never written: ` +
368
- `rm ${snapshotSiblings(file)} && x db gen "${migrationNameOf(id)}"`,
369
- meta: { id, file },
370
- });
371
-
372
- /**
373
- * One error per file, never one per statement: the marker declares the whole migration, so a
374
- * second finding would repeat an instruction the first already gave. `file` is app-relative and
375
- * arrives from the caller — `db` is tier 1 and does not know where an app keeps its migrations.
376
- *
377
- * Irreversible and destructive are two questions. `X_MIGRATION_IRREVERSIBLE` refuses to *generate*
378
- * a plan whose `down` cannot restore the rows; this one refuses to *ship* a plan whose `up`
379
- * destroys them without saying so — a retype is reversible in DDL and still rewrites every row.
380
- */
381
- export const migrationDestructive = (
382
- file: string,
383
- first: DestructiveStatement,
384
- more = 0,
385
- ): DbError =>
386
- new DbError({
387
- code: 'X_MIGRATION_DESTRUCTIVE',
388
- cause:
389
- `${file} ${DESTRUCTIVE_CAUSE[first.kind]} and does not declare it` +
390
- `${more === 0 ? '' : ` (and ${more} more destructive)`}: ${first.statement}`,
391
- fix: `add the line "${DESTRUCTIVE_MARKER}" to ${file}, or regenerate it: x db gen "<name>" --allow-destructive`,
392
- meta: { file, kind: first.kind, statements: more + 1 },
393
- });
394
-
395
297
  export const sqlUnsafe = (received: string, position: number): DbError =>
396
298
  new DbError({
397
299
  code: 'X_SQL_UNSAFE',