@ultimat3/db 13.0.0 → 14.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 +107 -0
- package/README.md +4 -0
- package/package.json +2 -2
- package/src/check-ddl.ts +212 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift.ts +2 -4
- package/src/entity-shape.ts +46 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +94 -37
- package/src/generated-column.ts +15 -6
- package/src/index.ts +23 -0
- package/src/introspect.ts +23 -0
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -0
- package/src/snapshot-parse.ts +31 -3
- package/src/statement-excerpt.ts +18 -0
- package/src/ungeneratable.ts +86 -0
- package/src/unrendered.ts +170 -0
package/CLAUDE.md
CHANGED
|
@@ -653,6 +653,113 @@ right database, and `x db gen`'s `retypeColumn` owns that question where both si
|
|
|
653
653
|
The `fix:` is the `alter table … set not null` itself and deliberately not `x db gen`, which has
|
|
654
654
|
never emitted one and would answer with an empty migration.
|
|
655
655
|
|
|
656
|
+
**An entity's INVARIANTS reach the DDL, `As of 2026-08-25`, and `invariant-ddl.ts` is what they
|
|
657
|
+
become.** `EntityDescriptionLike` had no `invariants` field at all — the same seam gap
|
|
658
|
+
`onDelete` carried until 3.0 — so a regenerated migration held **none** of them: measured on
|
|
659
|
+
`examples/dummy`, nine database-expressible rules across six tables, including
|
|
660
|
+
`member_unique_per_org UNIQUE(org_id, user_id)`, which is the constraint `upsertAll`'s inferred
|
|
661
|
+
`on conflict` rests on, and `post_slug_unique`. The `drift` gate step hashes entity SOURCE against a
|
|
662
|
+
sidecar and never reads the SQL, so the squash that lost them would have been **green**.
|
|
663
|
+
|
|
664
|
+
Four rules, none optional.
|
|
665
|
+
|
|
666
|
+
| Rule | Why |
|
|
667
|
+
|---|---|
|
|
668
|
+
| a `check` is a named `CONSTRAINT`, a `unique` is a unique **INDEX**, an `assert` is nothing | a soft-deleting entity stamps `deleted_at is null` onto a unique invariant and Postgres has no partial unique CONSTRAINT — only a partial unique index. An `assert` declares itself as a rule only the app can judge (`sql: null`), which is what `hasJsOnlyInvariant` already reads it as, so on its own it is not an unrendered loss — see the next paragraph for the case where it is |
|
|
669
|
+
| a `unique` invariant joins the ONE declared index list (`declaredIndexes`) | `createTable`, `diffTable` and `snapshotOf` must agree about what exists. A `create unique index` emitted and not recorded is `42P07` on the very next `x db gen` — worse than the silent drop |
|
|
670
|
+
| a `check` is recorded on `TableDescription.checks`, **absent** and never `[]` | a sidecar that predates the field must read as "nothing recorded" so the next generation ADDS the constraints the database is genuinely missing. `[]` would mean "declares none" and leave every already-generated app's invariants unenforced forever. That absence is the repair path, and `parseSnapshot` preserves it |
|
|
671
|
+
| the constraint name is `<table>_<name>_<check\|key>`, re-derived, bounded at 63 bytes, and **validated as an identifier** | nothing validates an invariant name at declaration, so `invariant('x" ); drop table t; --', …)` type-checks all the way to `create table` — the identical hole `columnName` carried. `identifier()` is the one rule; `constraintNameUnsafe` (`invariant-errors.ts`) exists only for its `fix:`, which names the `invariant()` call an author edits, and `generate-invariant.test.ts` pins that line because a guard whose value is its message is proven by nothing else |
|
|
672
|
+
|
|
673
|
+
**An `assert` IS an unrendered loss the moment a migration recorded its CHECK, `As of 2026-08-25`,
|
|
674
|
+
and that is the half `unrenderedOf` could not see.** `checkPlan` drops a recorded check nothing
|
|
675
|
+
declares — "a snapshot may not lie" — and an `assert` declares nothing in SQL, so regenerating
|
|
676
|
+
**deletes the database's half of a rule the entity still states**, with nothing added back and no
|
|
677
|
+
`-- destructive:` marker (`destructive.ts` excludes `drop constraint` by name, on the argument that
|
|
678
|
+
the database rebuilds it; here nothing does). Measured on `examples/dummy`: `x db gen` emitted
|
|
679
|
+
`alter table "posts" drop constraint "post_slug_shape"` and four more, and `unrenderedOf` answered
|
|
680
|
+
`[]` — so `@ultimat3/cli`'s `repairFix`, whose whole job is to refuse `x db gen` as the instruction
|
|
681
|
+
when the generator would lose something, read the empty list and handed out
|
|
682
|
+
`x db gen "drop post_slug_shape"`: the command that performs the loss, offered as the repair for it.
|
|
683
|
+
|
|
684
|
+
**The discriminator is what the recorded schema holds, never the kind.** An `assert` with nothing
|
|
685
|
+
recorded behind it loses nothing and is reported by nothing — the previous reading was right about
|
|
686
|
+
that, and a marker on nearly every app's every migration marks none. `unrenderedOf(entities,
|
|
687
|
+
current)` therefore takes the recorded schema, **required and nullable**: a caller with no sidecar
|
|
688
|
+
(the first migration) has to say `undefined`, because an argument nobody passes is a blind answer
|
|
689
|
+
nobody notices, which is exactly how the five drops shipped. `namesConstraint` (`invariant-ddl.ts`)
|
|
690
|
+
is the match, under **both** spellings — this generator's `<table>_<name>_check` and the rule's own
|
|
691
|
+
name, which is what a hand-written `0001_init.sql` calls it — and it never throws, because its
|
|
692
|
+
caller is a reporter reached by the `drift` gate step where a throw replaces a finding with a crash.
|
|
693
|
+
Self-clearing: once the drop is applied and the new sidecar written, nothing records the check and
|
|
694
|
+
the next generation reports nothing.
|
|
695
|
+
|
|
696
|
+
**A COLUMN declares a CHECK too, and until 2026-08-25 it reached `create table` and nothing else.**
|
|
697
|
+
`check-ddl.ts` is what it becomes. `columnClause` wrote `check (…)` **inline and anonymous**,
|
|
698
|
+
`snapshotOf` recorded no check for a column and `diffTable` had no arm for one — so the constraint
|
|
699
|
+
existed only in the statement that created the table and was invisible to every generation after it.
|
|
700
|
+
Neither `drift` nor `unrendered` could see the loss: the gate's `drift` step hashes entity SOURCE
|
|
701
|
+
against a sidecar and never reads the SQL, and `unrenderedOf` keys on declared **invariants**, which
|
|
702
|
+
these are not — they are minted by the column builder (`enumerated()`'s value set,
|
|
703
|
+
`tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound;
|
|
704
|
+
`packages/entity/src/enum-column.ts` implements `enumerated(V)` as `kind: 'text'` plus
|
|
705
|
+
`check: oneOf(V)`). Three consequences, measured on `examples/dummy`: a value added to
|
|
706
|
+
`enumerated()` generated **no migration at all**, so the app accepted `'archived'` and the database
|
|
707
|
+
answered `23514`; a regenerated migration retyped every Postgres-ENUM column to bare `text` with no
|
|
708
|
+
CHECK beside it; and the sidecar claimed a schema the database did not have, so `down` and every
|
|
709
|
+
later diff reasoned off a lie.
|
|
710
|
+
|
|
711
|
+
Four rules, none optional.
|
|
712
|
+
|
|
713
|
+
| Rule | Why |
|
|
714
|
+
|---|---|
|
|
715
|
+
| every CHECK is a **named** constraint on ONE list (`declaredChecks` = `columnChecks` then `invariantChecks`) | `createTable`, `diffTable` and `snapshotOf` must agree about what exists, the rule `declaredIndexes` already states. An anonymous constraint is not diffable at all — there is nothing to match a recorded name against |
|
|
716
|
+
| the name is `<table>_<column>_check`, and it is **not a convention chosen here** | it is the name Postgres itself mints for an anonymous single-column CHECK — measured, `check-ddl.live.test.ts`, including for a multi-clause predicate like `scaleCheck`'s. Any other spelling makes the repair add a SECOND constraint beside the one an already-generated database is holding |
|
|
717
|
+
| an ADD onto a column the recorded schema already had is `drop constraint if exists` **then** `add constraint` | the two databases the generator cannot tell apart read identically in the snapshot — one is holding the old anonymous form under exactly this name, one is holding nothing because the old `diffTable` emitted nothing. A bare `add constraint` is `42710` on the first (measured), inside `ROLE=migrate`, with the server's words and none of the entity's. A column this migration ADDS, or one `regenerate` rebuilt, takes the bare add: the name provably cannot be taken |
|
|
718
|
+
| two declarations naming one constraint are **refused**, never deduped | `invariant('status', …)` on a table whose `status` is an `enumerated()` derives the same `posts_status_check` the column owns, and two `add constraint` under one name is `42710` — a migration nobody can apply, which is worse than either declaration being dropped. `X_INVARIANT` through core's `assert`, the refusal `createIndex` already gives a unique GIN. Unlike two identical index definitions there is nothing to dedup: the predicates differ |
|
|
719
|
+
|
|
720
|
+
**What an app with an existing sidecar sees on its first `x db gen` after this.** One
|
|
721
|
+
`drop constraint if exists` / `add constraint` pair per checked column, on every table it already
|
|
722
|
+
has — the same absent-never-`[]` discipline `checks` was given for invariants, read the other way
|
|
723
|
+
round: the sidecar says nothing, so the generator emits the pair that is correct whether the
|
|
724
|
+
database is holding the constraint or not. Self-clearing — the new sidecar records the check and the
|
|
725
|
+
next generation emits nothing. It is not free: `add constraint … check` takes `ACCESS EXCLUSIVE` and
|
|
726
|
+
scans the table, under `migrate`'s 3s `lock_timeout`. Validating is deliberate over `NOT VALID`,
|
|
727
|
+
which would accept the rows already in the table — and a database holding the identical constraint
|
|
728
|
+
has none that can fail.
|
|
729
|
+
|
|
730
|
+
**`checkPlan` takes the `rebuilt` set for the same reason `diffTable`'s index loop does.**
|
|
731
|
+
`regenerate`'s plain -> generated path is `drop column` + `add column`, which takes the constraint
|
|
732
|
+
with it while the snapshot still records it — so without the set the check is silently gone, which
|
|
733
|
+
is this file's own defect one level in.
|
|
734
|
+
|
|
735
|
+
`rebuildCheck` is NOT `destructive.ts`'s concern: `drop constraint` is excluded there by name on
|
|
736
|
+
the argument that the database rebuilds it, and here the very next statement does.
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
**A default's VALUE crosses the seam too.** `ColumnDescriptionLike.default` carries
|
|
740
|
+
`ColumnDefaultLike` and `defaultExpression` renders it; `hasDefault` stays beside it as the older,
|
|
741
|
+
narrower fact `generatedClause` reads. `@ultimat3/entity` projects the value beside the flag
|
|
742
|
+
(`packages/entity/src/describe.ts:175`), so the nine defaults in `examples/dummy` — `plan_code`,
|
|
743
|
+
`billing_currency`, `role`, `tz`, `locale`, `theme`, `digest_opt_in`, `status`, `like_count` — do
|
|
744
|
+
reach the SQL. A description whose producer does not project it still reads `hasDefault` alone, and
|
|
745
|
+
that half **is not silent**: `unrenderedOf` reports each one on `GeneratedMigration.unrendered` and
|
|
746
|
+
`unrenderedComment` writes a `-- UNRENDERED` block at the top of the emitted `up`.
|
|
747
|
+
|
|
748
|
+
Comments, never a refusal, and never onto an EMPTY diff. A refusal would be a generator no app with
|
|
749
|
+
a `.default('draft')` could run at all until tier 2 ships one line, and a migration nobody can
|
|
750
|
+
generate repairs nothing. The empty-diff exclusion is `@ultimat3/cli`'s
|
|
751
|
+
`generateAppMigration`, which reads `up.trim().length === 0` as "nothing changed": a comment there
|
|
752
|
+
makes every `x db gen` write a file holding no statement — a ledger row, a checksum and a place in
|
|
753
|
+
the apply order for nothing.
|
|
754
|
+
|
|
755
|
+
**`REPLICA IDENTITY FULL` is still emitted by nothing, and it does not belong here.** Which tables
|
|
756
|
+
need it is derived from the `live: true` queries in the manifest, not from any entity — this package
|
|
757
|
+
is tier 1 and can see neither. An `EntityDescriptionLike.replicaIdentity` field would be a
|
|
758
|
+
declared-and-never-wired key, which is the defect class this release exists to eliminate. The shape
|
|
759
|
+
that works is a `GenerateOptions.replicaIdentityFull: readonly string[]` passed by
|
|
760
|
+
`@ultimat3/cli`'s `db-generate.ts` from the live-query set, and it lands with that caller or not at
|
|
761
|
+
all.
|
|
762
|
+
|
|
656
763
|
**A column the DATABASE computes is a different thing at every step, and `generated-column.ts` is
|
|
657
764
|
all of them** — `As of 2026-08-24`. `ColumnDescriptionLike.generated` carries the
|
|
658
765
|
`generated always as (<expr>) stored` body across the tier seam (this package cannot import
|
package/README.md
CHANGED
|
@@ -43,6 +43,10 @@ await withTransaction(async (tx) => {
|
|
|
43
43
|
| `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
|
|
44
44
|
| `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
|
|
45
45
|
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all |
|
|
46
|
+
| `declaredIndexes()` / `invariantChecks()` / `constraintNameFor()` | `As of 2026-08-25`: the DDL an entity **invariant** becomes — a `check` as a named `CONSTRAINT`, a `unique` as a partial-capable unique INDEX, an `assert` as nothing. `EntityDescriptionLike` had no `invariants` field for three majors, so a regenerated migration silently held **none** of them, including the composite UNIQUE `upsertAll`'s `on conflict` is inferred against |
|
|
47
|
+
| `declaredChecks()` / `checkClauses()` / `checkPlan()` / `columnChecks()` / `columnCheckName()` / `columnNamesConstraint()` | `As of 2026-08-25`: **every** CHECK a table declares — a column's own (`enumerated()`'s value set, `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound) and an invariant's — on ONE list, so `createTable`, `diffTable` and `snapshotOf` agree about what exists. A column's check reached `create table` **inline and anonymous** and nothing else: the snapshot recorded none and the diff had no arm, so a value added to `enumerated()` generated no migration and a regenerated ENUM column came back as bare `text`. The name is `<table>_<column>_check` because that is the name **Postgres itself mints** for the old anonymous form — measured — so the repair lands on the constraint an already-generated database is holding; `checkPlan` emits `drop constraint if exists` before the `add` for exactly that column, because a bare add is `42710` there and a no-op everywhere else |
|
|
48
|
+
| `defaultExpression()` / `ColumnDefaultLike` | `As of 2026-08-25`: a column's `default` as SQL. A DECLARED default (`{ kind: 'value', value }`) wins; `gen_random_uuid()` and `now()` stay as the inference for a description that carries only `hasDefault` |
|
|
49
|
+
| `unrenderedOf()` / `unrenderedComment()` / `UnrenderedDeclaration` | `As of 2026-08-25`: what the generator could **not** write, on `GeneratedMigration.unrendered` and as a `-- UNRENDERED` block at the top of a non-empty `up`. A generator that emits less than the declaration in silence is the defect the whole file exists against, and `x verify`'s `drift` step reads a source hash — it never reads the SQL, so the loss was green. **`unrenderedOf(entities, current)` takes the recorded schema**, required and nullable: a rule declared as an `assert` reaches no SQL by design and is no loss on its own, but one whose CHECK a previous migration RECORDED is dropped by this run and reported by nothing — five in `examples/dummy`, and `@ultimat3/cli`'s `repairFix` then offered `x db gen "drop <name>"` as the repair for the loss that command performs |
|
|
46
50
|
| `destructiveStatements()` / `hasDestructiveMarker()` / `isDestructive()` / `DESTRUCTIVE_MARKER` | `As of 2026-08`: the destructive-SQL rail — does this `up` drop, truncate or retype, and does the file declare it with `-- destructive: true`? One classifier, read by `x db gen` when it writes the marker and by `x verify` when it demands one |
|
|
47
51
|
| `stripSqlNoise()` | comments, literals, dollar-quoted bodies and quoted identifiers blanked **in source order**, so a reader sees the operation and not the prose. Shared by `readOnlyQuery()` and the destructive rail |
|
|
48
52
|
| `introspect()` | live schema → `SchemaDescription`. **App tables only**, `As of 2026-08-24`: a relation an extension owns (`pg_depend`, `deptype = 'e'`) and anything that is not an ordinary or partitioned table are excluded before the fold, and an explicit `exclude` cannot bring them back |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.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": "14.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
package/src/check-ddl.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Single responsibility: every CHECK constraint a table declares — a COLUMN's own and an
|
|
2
|
+
// INVARIANT's — and which of them a migration adds, rebuilds or drops. Split out of `generate.ts`
|
|
3
|
+
// and `invariant-ddl.ts` because a column check was the one part of a column that reached
|
|
4
|
+
// `create table` and nothing else: `columnClause` wrote `check (…)` inline and ANONYMOUS,
|
|
5
|
+
// `snapshotOf` recorded nothing for it and `diffTable` had no arm for it, so the SECOND `x db gen`
|
|
6
|
+
// turned `enumerated(POST_STATUSES)` into bare `text` accepting any string, and the value set the
|
|
7
|
+
// entity still declares left the database with no statement anywhere saying so.
|
|
8
|
+
//
|
|
9
|
+
// One list, for the reason `declaredIndexes` is one list: `createTable`, `diffTable` and
|
|
10
|
+
// `snapshotOf` must agree about what exists, and two producers of `add constraint` that never met
|
|
11
|
+
// is `42710` on the very next generation — a migration nobody can apply.
|
|
12
|
+
|
|
13
|
+
import { assert } from '@ultimat3/core';
|
|
14
|
+
import type { ColumnDescriptionLike, EntityDescriptionLike } from './entity-shape';
|
|
15
|
+
import type { CheckDescription, TableDescription } from './introspect';
|
|
16
|
+
import { invariantChecks, isIdentifier, MAX_IDENTIFIER_BYTES } from './invariant-ddl';
|
|
17
|
+
import { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
|
|
18
|
+
import { identifier } from './sql';
|
|
19
|
+
import { statementsOf } from './statement-split';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The convention alone, with nothing validated and nothing refused — one copy, so `columnCheckName`
|
|
23
|
+
* and `columnNamesConstraint` can never disagree about what a column's CHECK is called. The shape
|
|
24
|
+
* `spellConstraintName` already has in `invariant-ddl.ts`, and for the same reason.
|
|
25
|
+
*
|
|
26
|
+
* **Not a convention chosen here.** It is the name Postgres itself mints for an anonymous
|
|
27
|
+
* single-column CHECK — measured against a real server in `check-ddl.live.test.ts`, including for a
|
|
28
|
+
* multi-clause predicate like `scaleCheck`'s, which still names only one column. That is the whole
|
|
29
|
+
* reason this spelling and no other: every database generated before this landed is holding the old
|
|
30
|
+
* inline anonymous form under exactly this name, so a repair migration lands ON the constraint it
|
|
31
|
+
* means to correct instead of beside it under a second name.
|
|
32
|
+
*/
|
|
33
|
+
const spellColumnCheckName = (table: string, column: string): string => `${table}_${column}_check`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a RECORDED constraint is one of THIS entity's columns' own CHECKs. Validates nothing and
|
|
37
|
+
* never throws, exactly as `namesConstraint` does not, because its caller is a REPORTER
|
|
38
|
+
* (`unrendered.ts`) reached by the `drift` gate step where a throw replaces a finding with a crash.
|
|
39
|
+
*
|
|
40
|
+
* It exists because the two conventions can land on one string: an entity whose `slug` column is
|
|
41
|
+
* checked and that also declares `invariant('slug', …)` as an ASSERT derives `posts_slug_check`
|
|
42
|
+
* twice, and `namesConstraint` matches on the name alone — which it must, since a hand-written
|
|
43
|
+
* migration names the constraint after the rule. Without this the assert reads as "this run drops
|
|
44
|
+
* the CHECK recorded for me" while the run declares and keeps it: a `-- UNRENDERED` block on a
|
|
45
|
+
* migration that lost nothing, which is a marker the next reviewer learns to ignore.
|
|
46
|
+
*/
|
|
47
|
+
export function columnNamesConstraint(entity: EntityDescriptionLike, recorded: string): boolean {
|
|
48
|
+
return entity.columns.some(
|
|
49
|
+
(column) =>
|
|
50
|
+
column.check !== null && recorded === spellColumnCheckName(entity.table, column.column),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a column's own CHECK is called, with both operands validated — the reason `constraintNameFor`
|
|
56
|
+
* validates its own one file over: a physical column name arrives from a projection this package
|
|
57
|
+
* cannot typecheck, `add constraint` takes no parameters, and a name that closes its own quote
|
|
58
|
+
* produced a real `drop table` through `generateMigration` once already.
|
|
59
|
+
*/
|
|
60
|
+
export function columnCheckName(table: string, column: string): string {
|
|
61
|
+
if (!isIdentifier(table) || !isIdentifier(column)) throw constraintNameUnsafe(table, column);
|
|
62
|
+
const name = spellColumnCheckName(table, column);
|
|
63
|
+
// Bytes, never characters: 63 is what the server counts, and a truncation it performs silently
|
|
64
|
+
// makes two constraints one on the server while both names still differ in the snapshot.
|
|
65
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
66
|
+
assert(
|
|
67
|
+
bytes <= MAX_IDENTIFIER_BYTES,
|
|
68
|
+
`constraint name "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES} and says nothing`,
|
|
69
|
+
`.column('<shorter>') # shorten the physical name of "${column}", then x db gen`,
|
|
70
|
+
);
|
|
71
|
+
return name;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The CHECK constraints an entity's COLUMNS declare, in column order — `enumerated()`'s closed value
|
|
76
|
+
* set, `tz()`'s IANA whitelist, `locale()`'s tag list, money's currency pattern and scale bound.
|
|
77
|
+
*
|
|
78
|
+
* Read off `ColumnDescriptionLike.check`, which is the one field `@ultimat3/entity` projects them
|
|
79
|
+
* through; a column carrying `null` declares none and contributes no row.
|
|
80
|
+
*/
|
|
81
|
+
export function columnChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
|
|
82
|
+
return entity.columns
|
|
83
|
+
.filter((column: ColumnDescriptionLike) => column.check !== null)
|
|
84
|
+
.map((column) => ({
|
|
85
|
+
name: columnCheckName(entity.table, column.column),
|
|
86
|
+
expression: column.check ?? '',
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every CHECK this table declares. Columns first, then invariants — the order the old generator
|
|
92
|
+
* emitted them in, so a table declaring only invariants produces the statement it always produced.
|
|
93
|
+
*
|
|
94
|
+
* A duplicate name is REFUSED rather than deduped. Two `add constraint` statements under one name
|
|
95
|
+
* is `42710`, and the two sides mean different things: `invariant('status', …)` on a table whose
|
|
96
|
+
* `status` column is an `enumerated()` derives the same `posts_status_check` the column already
|
|
97
|
+
* owns, and silently keeping either one would enforce a rule the entity does not state. Same
|
|
98
|
+
* argument `declaredIndexes` makes for `42P07`, with the opposite remedy, because unlike two
|
|
99
|
+
* identical index definitions these two carry different predicates.
|
|
100
|
+
*/
|
|
101
|
+
export function declaredChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
|
|
102
|
+
const checks = [...columnChecks(entity), ...invariantChecks(entity)];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
for (const check of checks) {
|
|
105
|
+
assert(
|
|
106
|
+
!seen.has(check.name),
|
|
107
|
+
`two declarations on "${entity.table}" name the constraint "${check.name}"`,
|
|
108
|
+
`invariant('${entity.table}_${check.name}', …) # rename the invariant — a column's own CHECK already holds that name, then x db gen`,
|
|
109
|
+
);
|
|
110
|
+
seen.add(check.name);
|
|
111
|
+
// One command, over the merged list: `statementsOf` is this package's one lexer, so a `;`
|
|
112
|
+
// inside a string literal — `check (tag <> ';')`, and every `oneOf()` value list — is data and
|
|
113
|
+
// not a split. Applied here rather than per producer so a column's predicate, which arrives
|
|
114
|
+
// from an app's own `enumerated([...])` array, is guarded by the same rule an invariant's is.
|
|
115
|
+
const commands = statementsOf(check.expression).length;
|
|
116
|
+
if (commands > 1) throw constraintExpressionUnsafe(check.name, commands);
|
|
117
|
+
}
|
|
118
|
+
return checks;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `constraint "n" check (…)` — the clause form, for a table this migration creates. */
|
|
122
|
+
export function checkClauses(entity: EntityDescriptionLike): readonly string[] {
|
|
123
|
+
return declaredChecks(entity).map(
|
|
124
|
+
(check) => `constraint ${identifier(check.name).text} check (${check.expression})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const addCheck = (table: string, check: CheckDescription): string =>
|
|
129
|
+
`alter table ${identifier(table).text} add constraint ${identifier(check.name).text} ` +
|
|
130
|
+
`check (${check.expression});`;
|
|
131
|
+
|
|
132
|
+
const dropCheck = (table: string, name: string): string =>
|
|
133
|
+
`alter table ${identifier(table).text} drop constraint ${identifier(name).text};`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The one statement that is correct on BOTH databases this generator cannot tell apart.
|
|
137
|
+
*
|
|
138
|
+
* A database generated before column checks were recorded is holding Postgres' own auto-named
|
|
139
|
+
* `<table>_<column>_check` from the old inline anonymous form; a database whose entity gained the
|
|
140
|
+
* check after the table was created is holding nothing, because the old `diffTable` emitted nothing.
|
|
141
|
+
* The snapshot reads identically in both — it records no check either way — so a bare
|
|
142
|
+
* `add constraint` is `42710` on the first, inside `ROLE=migrate`, with the server's words and none
|
|
143
|
+
* of the entity's. `drop constraint if exists` costs a notice on the second and repairs the first.
|
|
144
|
+
*/
|
|
145
|
+
const rebuildCheck = (table: string, check: CheckDescription): readonly string[] => [
|
|
146
|
+
`alter table ${identifier(table).text} drop constraint if exists ${identifier(check.name).text};`,
|
|
147
|
+
addCheck(table, check),
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Which CHECK constraints an existing table gains, loses or has rebuilt. Postgres has no `alter
|
|
152
|
+
* constraint` for a predicate, so a moved expression is a drop and an add — the same shape
|
|
153
|
+
* `redefineIndex` uses, and `down` is pushed forwards and read backwards for the same reason.
|
|
154
|
+
*
|
|
155
|
+
* Both directions, the rule `foreignKeyPlan` states: a snapshot may not lie. A recorded constraint
|
|
156
|
+
* the entity no longer declares is DROPPED, and its `down` re-adds it from the expression the
|
|
157
|
+
* snapshot holds — so unlike a dropped column there is nothing to restore and nothing to refuse.
|
|
158
|
+
* `destructive.ts` deliberately excludes `drop constraint` for exactly this reason.
|
|
159
|
+
*
|
|
160
|
+
* `rebuilt` names the columns this migration dropped and re-added outright (`regenerate`'s
|
|
161
|
+
* plain -> generated path). The constraint went with the column and the snapshot still records it,
|
|
162
|
+
* so without this the check would be silently gone — the defect class this file exists against,
|
|
163
|
+
* one level in.
|
|
164
|
+
*/
|
|
165
|
+
export function checkPlan(
|
|
166
|
+
entity: EntityDescriptionLike,
|
|
167
|
+
live: TableDescription,
|
|
168
|
+
plan: { up: string[]; down: string[] },
|
|
169
|
+
rebuilt: ReadonlySet<string> = new Set(),
|
|
170
|
+
): void {
|
|
171
|
+
const recorded = new Map((live.checks ?? []).map((check) => [check.name, check]));
|
|
172
|
+
const present = new Set(live.columns.map((column) => column.name));
|
|
173
|
+
// Checked columns only, in both sets — `columnCheckName` REFUSES a name it cannot spell, and a
|
|
174
|
+
// column declaring no check contributes no constraint for either set to be consulted about. Over
|
|
175
|
+
// every column this would refuse to generate a migration that touches none of them.
|
|
176
|
+
const checked = entity.columns.filter((column) => column.check !== null);
|
|
177
|
+
// Which names the OLD anonymous form could be holding: a column the recorded schema already had,
|
|
178
|
+
// whose check it did not record. A column this migration adds cannot have one, and a rebuilt one
|
|
179
|
+
// lost it with the column, so both take the bare add.
|
|
180
|
+
const exposed = new Set(
|
|
181
|
+
checked
|
|
182
|
+
.filter((column) => present.has(column.column) && !rebuilt.has(column.column))
|
|
183
|
+
.map((column) => columnCheckName(entity.table, column.column)),
|
|
184
|
+
);
|
|
185
|
+
const dropped = new Set(
|
|
186
|
+
checked
|
|
187
|
+
.filter((column) => rebuilt.has(column.column))
|
|
188
|
+
.map((column) => columnCheckName(entity.table, column.column)),
|
|
189
|
+
);
|
|
190
|
+
const wanted = declaredChecks(entity);
|
|
191
|
+
for (const check of wanted) {
|
|
192
|
+
const held = dropped.has(check.name) ? undefined : recorded.get(check.name);
|
|
193
|
+
if (held === undefined) {
|
|
194
|
+
plan.up.push(
|
|
195
|
+
...(exposed.has(check.name)
|
|
196
|
+
? rebuildCheck(entity.table, check)
|
|
197
|
+
: [addCheck(entity.table, check)]),
|
|
198
|
+
);
|
|
199
|
+
plan.down.push(dropCheck(entity.table, check.name));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (held.expression === check.expression) continue;
|
|
203
|
+
plan.up.push(dropCheck(entity.table, check.name), addCheck(entity.table, check));
|
|
204
|
+
plan.down.push(addCheck(entity.table, held), dropCheck(entity.table, check.name));
|
|
205
|
+
}
|
|
206
|
+
const declared = new Set(wanted.map((check) => check.name));
|
|
207
|
+
for (const check of live.checks ?? []) {
|
|
208
|
+
if (declared.has(check.name)) continue;
|
|
209
|
+
plan.up.push(dropCheck(entity.table, check.name));
|
|
210
|
+
plan.down.push(addCheck(entity.table, check));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Single responsibility: what a column's DEFAULT is, as SQL. Split out of `generate.ts` because
|
|
2
|
+
// the question has two halves that must be answered together — what a declared default renders as,
|
|
3
|
+
// and what it MEANS when a column claims one this generator cannot write down. That second half
|
|
4
|
+
// had no answer at all until 2026-08-25: `hasDefault` reached the generator with no expression
|
|
5
|
+
// beside it, so nine scalar defaults in the reference app's own schema were dropped in silence and
|
|
6
|
+
// only `now()` and `gen_random_uuid()` survived, because those two are inferable from the kind.
|
|
7
|
+
|
|
8
|
+
import { assert } from '@ultimat3/core';
|
|
9
|
+
import { literal } from './sql';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDefault`. Declared here
|
|
13
|
+
* rather than in `entity-shape.ts` so `defaultExpression` can name it without an import cycle,
|
|
14
|
+
* exactly as `IndexMethod` lives in `index-method.ts` and is read from the mirror.
|
|
15
|
+
*
|
|
16
|
+
* `uuid-v7` is what `entity()` stamps on a generated uuid key. It renders `gen_random_uuid()` —
|
|
17
|
+
* a v4 — because that is the only server-side generator Postgres 17 ships and it is what this
|
|
18
|
+
* generator has always emitted for such a key. The v7 is minted in JS on the write path; the
|
|
19
|
+
* column default is the fallback for a row nothing in this framework inserted.
|
|
20
|
+
*/
|
|
21
|
+
export type ColumnDefaultLike =
|
|
22
|
+
| { readonly kind: 'value'; readonly value: string | number | boolean | null }
|
|
23
|
+
| { readonly kind: 'generated'; readonly by: 'uuid-v7' | 'now' };
|
|
24
|
+
|
|
25
|
+
/** The parts of a column description this file reads — nothing else. */
|
|
26
|
+
interface DefaultedColumn {
|
|
27
|
+
readonly column: string;
|
|
28
|
+
readonly property: string;
|
|
29
|
+
readonly kind: string;
|
|
30
|
+
readonly primaryKey: boolean;
|
|
31
|
+
readonly hasDefault: boolean;
|
|
32
|
+
readonly default?: ColumnDefaultLike | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A literal reaches the statement TEXT — `create table` takes no parameters — so a string is
|
|
37
|
+
* quoted through the one escape this package has (`literal`), and a number this build cannot
|
|
38
|
+
* write down is refused rather than emitted as `NaN`, which is a syntax error whose first reader
|
|
39
|
+
* would be `ROLE=migrate`.
|
|
40
|
+
*/
|
|
41
|
+
function literalSql(column: DefaultedColumn, value: string | number | boolean | null): string {
|
|
42
|
+
if (value === null) return 'null';
|
|
43
|
+
if (typeof value === 'string') return literal(value).text;
|
|
44
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
45
|
+
assert(
|
|
46
|
+
Number.isFinite(value),
|
|
47
|
+
`column "${column.column}" declares a default of ${String(value)}, which is not a number Postgres can hold`,
|
|
48
|
+
`.default(0) # give "${column.property}" a finite number, or drop the default`,
|
|
49
|
+
);
|
|
50
|
+
return String(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The `default …` a column clause carries, or `null` for a column that has none this generator can
|
|
55
|
+
* write. A DECLARED default wins over an inferred one; the two inferences below stay as the
|
|
56
|
+
* fallback for a description whose producer does not yet project the expression — see
|
|
57
|
+
* `unrenderedDefault`, which is what stops that fallback from being a silent loss.
|
|
58
|
+
*/
|
|
59
|
+
export function defaultExpression(column: DefaultedColumn): string | null {
|
|
60
|
+
if (!column.hasDefault) return null;
|
|
61
|
+
const declared = column.default;
|
|
62
|
+
if (declared !== undefined) {
|
|
63
|
+
return declared.kind === 'value'
|
|
64
|
+
? literalSql(column, declared.value)
|
|
65
|
+
: declared.by === 'now'
|
|
66
|
+
? 'now()'
|
|
67
|
+
: 'gen_random_uuid()';
|
|
68
|
+
}
|
|
69
|
+
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
|
|
70
|
+
if (column.kind === 'timestamptz') return 'now()';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether this column claims a default the generated SQL does not carry. Exactly the condition
|
|
76
|
+
* that lost nine columns' defaults in silence: `hasDefault: true` with nothing to render it from.
|
|
77
|
+
*/
|
|
78
|
+
export function hasUnrenderedDefault(column: DefaultedColumn): boolean {
|
|
79
|
+
return column.hasDefault && defaultExpression(column) === null;
|
|
80
|
+
}
|
package/src/destructive.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { stripSqlNoise } from './sql-noise';
|
|
7
7
|
import { noiseAt } from './sql-scan';
|
|
8
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
8
9
|
import { statementsOf } from './statement-split';
|
|
9
10
|
|
|
10
11
|
/** The line a migration carries to declare that applying it destroys data. */
|
|
@@ -90,20 +91,6 @@ const RULES: readonly (readonly [DestructiveKind, RegExp])[] = [
|
|
|
90
91
|
['retype-column', /\balter\s+column\b[\s\S]*?\btype\b/],
|
|
91
92
|
];
|
|
92
93
|
|
|
93
|
-
/**
|
|
94
|
-
* One capped line — an error prints this, not a whole script. Only the comments *preceding* the
|
|
95
|
-
* statement come off, the ones `statementsOf` carries in from the file header; the SQL itself stays
|
|
96
|
-
* verbatim, because `stripSqlNoise` blanks quoted identifiers and `drop table ""` names nothing an
|
|
97
|
-
* author can act on. Blanking is for deciding, never for reporting.
|
|
98
|
-
*/
|
|
99
|
-
function excerpt(statement: string): string {
|
|
100
|
-
const line = statement
|
|
101
|
-
.replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
|
|
102
|
-
.replace(/\s+/g, ' ')
|
|
103
|
-
.trim();
|
|
104
|
-
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
94
|
/**
|
|
108
95
|
* Every destructive statement in `up`, in apply order.
|
|
109
96
|
*
|
|
@@ -117,7 +104,7 @@ export function destructiveStatements(up: string): readonly DestructiveStatement
|
|
|
117
104
|
for (const statement of statementsOf(up)) {
|
|
118
105
|
const bare = stripSqlNoise(statement).toLowerCase();
|
|
119
106
|
const rule = RULES.find(([, pattern]) => pattern.test(bare));
|
|
120
|
-
if (rule !== undefined) found.push({ kind: rule[0], statement:
|
|
107
|
+
if (rule !== undefined) found.push({ kind: rule[0], statement: statementExcerpt(statement) });
|
|
121
108
|
}
|
|
122
109
|
return found;
|
|
123
110
|
}
|
package/src/drift.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { baseClient, type DbClient } from './client';
|
|
7
7
|
import { DbError } from './errors';
|
|
8
|
-
import {
|
|
8
|
+
import { foreignKeyTarget, onDeleteRule, rebuildForeignKey } from './foreign-key';
|
|
9
9
|
import { indexMethodOf } from './index-method';
|
|
10
10
|
import {
|
|
11
11
|
type ForeignKeyDescription,
|
|
@@ -192,9 +192,7 @@ function changedForeignKey(
|
|
|
192
192
|
`"${declared.referencedTable}" ` +
|
|
193
193
|
`${rule === null ? 'declares no on delete rule' : `is on delete ${rule}`}, not what ` +
|
|
194
194
|
'migrations declare',
|
|
195
|
-
fix:
|
|
196
|
-
`${dropForeignKey(table, held.name)} ${addForeignKey(table, declared)}` +
|
|
197
|
-
' # in a new migration',
|
|
195
|
+
fix: `${rebuildForeignKey(table, declared, held)} # in a new migration`,
|
|
198
196
|
};
|
|
199
197
|
}
|
|
200
198
|
|
package/src/entity-shape.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// tier 1 and may never import `entity` (tier 2), so a snapshot arrives as a parameter and every
|
|
3
3
|
// part of it — a column's `on delete` rule included — crosses the seam by shape or not at all.
|
|
4
4
|
|
|
5
|
+
import type { ColumnDefaultLike } from './column-default';
|
|
5
6
|
import type { IndexMethod } from './index-method';
|
|
6
7
|
|
|
7
8
|
/** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
|
|
@@ -32,6 +33,45 @@ export interface ColumnDescriptionLike {
|
|
|
32
33
|
* was a `23502`, because nothing computed it.
|
|
33
34
|
*/
|
|
34
35
|
readonly generated?: string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* What the column defaults to, when the declaration carries the value and not only the flag.
|
|
38
|
+
* Optional for the reason `onDelete` and `generated` are: this package cannot import
|
|
39
|
+
* `@ultimat3/entity`, so a field that is not on the projection reaches no DDL at all.
|
|
40
|
+
*
|
|
41
|
+
* `hasDefault` beside it is NOT redundant and is not being replaced. It is the older, narrower
|
|
42
|
+
* fact — "this column defaults to something" — and it is what `generatedClause` reads to refuse
|
|
43
|
+
* a column that is both generated and defaulted. Where `hasDefault` is true and this is absent,
|
|
44
|
+
* `defaultExpression` falls back to the two defaults inferable from the kind and everything else
|
|
45
|
+
* is REPORTED as unrendered rather than dropped (`unrendered.ts`).
|
|
46
|
+
*/
|
|
47
|
+
readonly default?: ColumnDefaultLike | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Structurally assignment-compatible with `@ultimat3/entity`'s `InvariantDescription`.
|
|
52
|
+
*
|
|
53
|
+
* An invariant is written once and enforced twice — in the app on every write, and in Postgres as
|
|
54
|
+
* a CHECK or a unique index. The second half reached no SQL at all until 2026-08-25, because this
|
|
55
|
+
* mirror had no `invariants` field: a regenerated migration silently held none of them, including
|
|
56
|
+
* the composite UNIQUE that `upsertAll`'s `on conflict` is inferred against, so a replay-safe
|
|
57
|
+
* write became a duplicate row on a database the framework itself generated.
|
|
58
|
+
*/
|
|
59
|
+
export interface InvariantDescriptionLike {
|
|
60
|
+
/** The rule's own name. `<table>_<name>_check` / `_key` is the constraint it becomes. */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** `assert` is a rule only the app can run — no SQL, and nothing for a migration to emit. */
|
|
63
|
+
readonly kind: 'check' | 'unique' | 'assert';
|
|
64
|
+
readonly message: string;
|
|
65
|
+
/** The predicate for a `check`, the column list for a `unique`, `null` for an `assert`. */
|
|
66
|
+
readonly sql: string | null;
|
|
67
|
+
/** Partial-constraint predicate, e.g. `deleted_at is null`. `null` covers every row. */
|
|
68
|
+
readonly where: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* The physical columns a `unique` names, when the declaration carries them. Optional, and
|
|
71
|
+
* `uniqueColumns()` falls back to splitting `sql` when it is absent — see the argument in
|
|
72
|
+
* `invariant-ddl.ts` for why that fallback is a validated re-read and not a name parsed back.
|
|
73
|
+
*/
|
|
74
|
+
readonly columns?: readonly string[] | undefined;
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
/**
|
|
@@ -66,4 +106,10 @@ export interface EntityDescriptionLike {
|
|
|
66
106
|
readonly primaryKey: readonly string[];
|
|
67
107
|
readonly columns: readonly ColumnDescriptionLike[];
|
|
68
108
|
readonly indexes: readonly IndexDescriptionLike[];
|
|
109
|
+
/**
|
|
110
|
+
* The domain rules the database must hold too. Optional so no existing description changes
|
|
111
|
+
* shape, exactly as `onDelete`, `generated` and `using` are — and absent reads as "declares
|
|
112
|
+
* none", which is what every hand-built description in this package's own tests is.
|
|
113
|
+
*/
|
|
114
|
+
readonly invariants?: readonly InvariantDescriptionLike[] | undefined;
|
|
69
115
|
}
|
package/src/foreign-key-plan.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { EntityDescriptionLike } from './entity-shape';
|
|
6
6
|
import { addForeignKey, dropForeignKey, foreignKeyTarget, onDeleteRule } from './foreign-key';
|
|
7
7
|
import type { ForeignKeyDescription, TableDescription } from './introspect';
|
|
8
|
+
import { identifier } from './sql';
|
|
8
9
|
|
|
9
10
|
/** The two directions of one migration, pushed in `up` order; `down` is reversed at assembly. */
|
|
10
11
|
export interface Plan {
|
|
@@ -137,10 +138,17 @@ export interface ConstraintPlans {
|
|
|
137
138
|
readonly doomed: ReadonlySet<string>;
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A key whose target is being dropped: gone on the way up, a note on the way back.
|
|
143
|
+
*
|
|
144
|
+
* The note goes through `identifier` too. A `--` comment ends at the first newline, so a name
|
|
145
|
+
* holding one is a second command on the line after it — the same escape `columnClause` closed,
|
|
146
|
+
* one quoting rule short of the statement above it.
|
|
147
|
+
*/
|
|
141
148
|
function unrestorableDrop(table: string, constraint: string, target: string, preDrops: Plan): void {
|
|
142
149
|
preDrops.up.push(dropForeignKey(table, constraint));
|
|
143
150
|
preDrops.down.push(
|
|
144
|
-
`-- constraint
|
|
151
|
+
`-- constraint ${identifier(constraint).text} on ${identifier(table).text} ` +
|
|
152
|
+
`cannot be restored; ${identifier(target).text} is gone`,
|
|
145
153
|
);
|
|
146
154
|
}
|