|
| 1 | +# Existing-table CHECK evolution (Postgres) — Design |
| 2 | + |
| 3 | +_Date: 2026-05-31. Status: **Design (approved in brainstorm; not yet implemented).**_ |
| 4 | + |
| 5 | +## 1. Problem |
| 6 | + |
| 7 | +CHECK constraints (enum-derived + validator-derived) are **create-time-inline only**: |
| 8 | +they ride on `CREATE TABLE`, and the diff produces no `add-check`/`drop-check` (Plan |
| 9 | +6 made it inline-only to fix a non-idempotency bug). So adding/removing/changing a |
| 10 | +constraint on an **already-existing** table generates no migration. For the common |
| 11 | +real workflow — "I changed a validator on a deployed table" — nothing happens. |
| 12 | + |
| 13 | +The blocker was idempotency: the introspectors hardcode `actual.checks = []`, so a |
| 14 | +naive check-diff re-proposes every check on every run. This design closes that for |
| 15 | +Postgres. |
| 16 | + |
| 17 | +## 2. Goals / non-goals |
| 18 | + |
| 19 | +**Goal:** evolve CHECK constraints on existing Postgres tables — generate |
| 20 | +`ALTER TABLE ADD/DROP CONSTRAINT` when a check is added, removed, or its expression |
| 21 | +changes — idempotently, across both the offline (snapshot) and `--from-db` |
| 22 | +(introspect) paths. |
| 23 | + |
| 24 | +**Non-goals:** SQLite check-only evolution (see §6 — SQLite evolves checks via |
| 25 | +recreate-and-copy on any column change; a check-only SQLite change is a follow-on); |
| 26 | +multi-column checks; free-form `@check`. |
| 27 | + |
| 28 | +## 3. Two paths, two idempotency stories |
| 29 | + |
| 30 | +The diff is `diff(expected, actual)`; `actual` differs by path: |
| 31 | + |
| 32 | +- **Offline (default):** `actual` = the committed snapshot, which **already carries |
| 33 | + our checks** (we wrote them with our exact expressions). So a check-diff is |
| 34 | + idempotent and change-detecting **with no introspection** — the snapshot remembers |
| 35 | + the prior checks. A changed validator bound → expected expression differs from the |
| 36 | + snapshot's → drop+re-add. This is the primary win and needs no DB. |
| 37 | +- **`--from-db` / `verify --replay`:** `actual` = the live DB introspected. Here the |
| 38 | + introspector must actually **read existing checks** (else `[]` → every check |
| 39 | + re-proposed). Postgres rewrites stored expressions (`price >= 0` → |
| 40 | + `(price >= 0)`), so comparison needs normalization (§5). |
| 41 | + |
| 42 | +Both paths use the same `diffTableChecks`; only the `actual.checks` source differs. |
| 43 | + |
| 44 | +## 4. Postgres check introspection |
| 45 | + |
| 46 | +Add `readPgChecks(k, schema, table)` to `src/introspect/postgres.ts`, replacing the |
| 47 | +`checks: []` placeholder. Query the catalog for CHECK constraints: |
| 48 | + |
| 49 | +```sql |
| 50 | +SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS def |
| 51 | +FROM pg_constraint con |
| 52 | +JOIN pg_class rel ON rel.oid = con.conrelid |
| 53 | +JOIN pg_namespace ns ON ns.oid = rel.relnamespace |
| 54 | +WHERE con.contype = 'c' AND rel.relname = $table AND ns.nspname = $schema |
| 55 | +``` |
| 56 | + |
| 57 | +`pg_get_constraintdef` returns `CHECK (<expr>)`; strip the `CHECK (` … `)` wrapper to |
| 58 | +get the expression. Returns `CheckDescriptor[]` `{ name, expression }`. |
| 59 | + |
| 60 | +**pg-mem gap (accepted, documented like the FK/index path):** pg-mem (the unit-test |
| 61 | +PG) does not support `pg_constraint`. `readPgChecks` catches the failure and returns |
| 62 | +`[]` on pg-mem — so unit tests see no checks; the real introspection is covered by |
| 63 | +the `MIGRATE_TS_PG_URL`-gated integration tests (the existing pattern for FK/index |
| 64 | +introspection). SQLite introspection stays `checks: []` for now (§6). |
| 65 | + |
| 66 | +## 5. Expression normalization + the check diff |
| 67 | + |
| 68 | +Add `src/check-expr-compare.ts` with `normalizeCheckExpr(expr)` and |
| 69 | +`checkExprEquals(a, b)`, mirroring `view-sql-compare.ts`: |
| 70 | + |
| 71 | +``` |
| 72 | +normalizeCheckExpr: strip ALL parentheses, collapse whitespace to single spaces, |
| 73 | +trim, lower-case. |
| 74 | +``` |
| 75 | + |
| 76 | +This is reliable here because **every check expression we generate is |
| 77 | +machine-derived with a known simple shape** (`col >= 0 AND col <= 100`, |
| 78 | +`col IN ('a','b')`, `length(col) >= 3`, `col ~ 'p'`). PG's parenthesized rewrite |
| 79 | +(`(col >= 0) AND (col <= 100)`) normalizes to the same canonical string. The |
| 80 | +fragility of normalizing arbitrary author SQL does not apply — we have no author |
| 81 | +SQL. |
| 82 | + |
| 83 | +Re-introduce `diffTableChecks(expected, actual, changes)` (removed in Plan 6), |
| 84 | +called in **pass-2 (existing tables) only** — never pass-1, where checks are inlined |
| 85 | +in `CREATE TABLE`. This split mirrors how the create-table path already inlines |
| 86 | +checks while index/fk diffing happens in pass-2; it avoids the duplicate-on-new-table |
| 87 | +bug. Logic (by constraint name, mirroring `diffTableIndexes`): |
| 88 | +- expected name absent in actual → `add-check`. |
| 89 | +- both present, `!checkExprEquals(expected.expr, actual.expr)` → `drop-check` + |
| 90 | + `add-check` (re-create; a CHECK has no in-place ALTER). |
| 91 | +- actual name absent in expected → `drop-check`. |
| 92 | + |
| 93 | +**Dialect gating:** `diffTableChecks` runs **only for Postgres**. Thread an optional |
| 94 | +`dialect?: Dialect` into `DiffArgs`; when it is not `"postgres"`, skip |
| 95 | +`diffTableChecks` entirely (so SQLite never emits `add-check`/`drop-check`, whose |
| 96 | +SQLite emit arms throw — SQLite evolves checks via recreate, §6). Existing diff |
| 97 | +callers that pass no `dialect` keep current behavior (no check evolution). The CLI |
| 98 | +threads its `dialect` into the offline and `--from-db` diff calls. |
| 99 | + |
| 100 | +## 6. SQLite (out of scope this design — evolves via recreate) |
| 101 | + |
| 102 | +SQLite checks are inlined in `CREATE TABLE` and `renderCreateTable` re-inlines them |
| 103 | +on a recreate-and-copy. So when any column change triggers a SQLite table recreate, |
| 104 | +the rebuilt table carries the **current** checks from the expected schema — checks |
| 105 | +evolve for free as a side effect. The only gap is a **check-only** SQLite change (no |
| 106 | +column change → no recreate → no update); that requires routing a check delta into |
| 107 | +the recreate bundler and is a documented follow-on. This design does not produce |
| 108 | +`add-check`/`drop-check` on SQLite (the dialect gate in §5). |
| 109 | + |
| 110 | +## 7. Down-migration (reversibility) |
| 111 | + |
| 112 | +`drop-check` becomes a produced change, so it needs a real `down`. Add |
| 113 | +`restore?: CheckDescriptor` to the `drop-check` Change kind (mirroring Plan 4's |
| 114 | +`restore` on the other drop-* kinds); `diffTableChecks` sets it from the actual-side |
| 115 | +descriptor. Postgres `renderDown` for `drop-check`: when `restore` is present → |
| 116 | +`ALTER TABLE ADD CONSTRAINT <name> CHECK (<restore.expression>)`; else keep the |
| 117 | +existing WARNING stub. `add-check` down is already `DROP CONSTRAINT` (Plan 6 arm). |
| 118 | + |
| 119 | +## 8. Destructive gating |
| 120 | + |
| 121 | +`drop-check` removes a constraint (a column could later accept values the constraint |
| 122 | +forbade) — mildly destructive. Gate it behind a new `allow.dropCheck` flag (mirroring |
| 123 | +`allow.dropIndex`/`allow.dropFk`): without it, a `drop-check` is `blocked` and the |
| 124 | +migration aborts with guidance. `add-check` is additive → `ALLOWED`. (A |
| 125 | +change-expression emits drop+add, so it needs `dropCheck` too — correct: changing a |
| 126 | +constraint is a destructive-then-additive pair.) |
| 127 | + |
| 128 | +## 9. Testing |
| 129 | + |
| 130 | +- **Offline (no DB):** snapshot has a check; new metadata adds a second validator → |
| 131 | + `add-check`; removes a validator → `drop-check` (gated by `allow.dropCheck`); |
| 132 | + changes a bound → `drop-check`+`add-check`. A re-diff after applying (snapshot |
| 133 | + advanced) → zero check changes (idempotent). |
| 134 | +- **`normalizeCheckExpr`:** `(col >= 0) AND (col <= 100)` ≡ `col >= 0 AND col <= 100`; |
| 135 | + case/whitespace-insensitive; distinct expressions ≠. |
| 136 | +- **PG introspection (`MIGRATE_TS_PG_URL`-gated):** create a table with a CHECK, run |
| 137 | + `readPgChecks` → name + normalized expression match; a full introspect→diff against |
| 138 | + the matching expected → zero check drift (the real-DB idempotency proof). |
| 139 | +- **down:** `drop-check` with `restore` → down re-adds the constraint. |
| 140 | +- **dialect gate:** sqlite diff produces no `add-check`/`drop-check` even when |
| 141 | + expected/actual checks differ. |
| 142 | +- **no double-emit on new table:** a create-table still inlines its checks and emits |
| 143 | + no separate `add-check`. |
| 144 | + |
| 145 | +## 10. Out of scope / follow-ons |
| 146 | + |
| 147 | +- SQLite check-only evolution (route into recreate-and-copy). |
| 148 | +- Multi-column / free-form checks. |
| 149 | +- Only-drop-managed-checks policy on `--from-db` (today a hand-added DB CHECK absent |
| 150 | + from metadata classifies as `drop-check`/unmanaged via the Plan-3 drift classifier; |
| 151 | + the `allow.dropCheck` gate already protects it from silent removal). |
0 commit comments