@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 +183 -22
- package/package.json +2 -2
- package/src/dependent-view.ts +224 -0
- package/src/errors.ts +2 -100
- package/src/foreign-key-plan.ts +35 -14
- package/src/foreign-key.ts +33 -0
- package/src/generate.ts +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 +9 -6
- package/src/migrate.ts +11 -1
- package/src/migration-errors.ts +132 -0
- package/src/retype-keys.ts +139 -0
- package/src/sql-type.ts +35 -0
- package/src/sql.ts +28 -10
package/CLAUDE.md
CHANGED
|
@@ -9,11 +9,11 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
|
|
|
9
9
|
|---|---|
|
|
10
10
|
| Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing |
|
|
11
11
|
| SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
|
|
12
|
-
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point |
|
|
12
|
+
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point. `literal()` is the tree's ONE SQL-string-literal escape (`scripts/sql-literal-copies.ts`, pinned at zero) and it emits `E'…'` when the value carries a backslash |
|
|
13
13
|
| SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
|
|
14
14
|
| Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
|
|
15
15
|
| Errors | subclass `DbError`; never `throw new Error` **in source**. A test simulating a *database* failure throws `dbUnavailable()`; a test simulating the *caller's body* failing throws a bare `Error` on purpose — an arbitrary throw is exactly what rollback and disposal must survive, and a `DbError` there would prove the narrower thing |
|
|
16
|
-
| New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` |
|
|
16
|
+
| New code | add to `DB_ERROR_CODES` **and** `DB_ERROR_TITLES` in `errors.ts` — always there, whichever file the CONSTRUCTOR lives in. `errors.ts` reached the 500-line ceiling on 2026-08-25, so a migration's constructors are `migration-errors.ts` and an invariant's are `invariant-errors.ts`; both import `DbError` from `errors.ts` and neither is imported back, and `src/index.ts` re-exports every one of them so no consumer can tell |
|
|
17
17
|
| A value ambient across an `await` | `asyncContext<T>(subject)` from `@ultimat3/core` — never `new AsyncLocalStorage`. Three scopes here use it: `transaction.ts`, `attribution.ts`, `expected-loop.ts` |
|
|
18
18
|
| Exports | explicit in `src/index.ts`; no `export *` |
|
|
19
19
|
| Files | < 200 LOC, one responsibility, `kebab-case.ts`, test beside source |
|
|
@@ -511,7 +511,10 @@ migration ever generated and a marker on all of them marks none. **A closed list
|
|
|
511
511
|
`drop table`, `drop column`, `truncate`, `alter column … type`; a rail enumerating every Postgres
|
|
512
512
|
foot-gun is a second SQL parser competing with the server's, and every one of these four is a
|
|
513
513
|
statement `generateMigration` emits, so each has a generated case holding it honest. `drop
|
|
514
|
-
constraint`/`default`/`not null` and `drop index` are excluded by name
|
|
514
|
+
constraint`/`default`/`not null` and `drop index` are excluded by name — a `drop index` holds no
|
|
515
|
+
rows of its own, its `down` recreates the recorded definition, and `redefineIndex` has emitted one
|
|
516
|
+
on every index rename since it existed, so classifying it marks nearly every migration and a marker
|
|
517
|
+
on all is none.
|
|
515
518
|
**Decide on blanked text, report the original** — `statementsOf` + `stripSqlNoise` before a keyword
|
|
516
519
|
is looked for, so `-- drop table users` is prose and `values ('drop table users')` is data; but the
|
|
517
520
|
excerpt in the error keeps its identifiers, because `drop table ""` names nothing an author can act
|
|
@@ -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
|
|
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
|
-
**
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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.
|
|
965
|
-
removed index leaves the snapshot correct by omission, and
|
|
1123
|
+
can never see because it judges the declared side. **This paragraph said "that is not parity with a
|
|
1124
|
+
removed index: a removed index leaves the snapshot correct by omission", and that was wrong** — see
|
|
1125
|
+
`index-plan.ts` below: a removed index's snapshot lied in exactly the same way, and the arm to fix
|
|
1126
|
+
it did not land until 2026-08-25. The drop names the
|
|
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": "
|
|
3
|
+
"version": "16.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "16.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
|
@@ -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',
|