Skip to content

Commit ba4aef4

Browse files
dmealingclaude
andcommitted
docs(migrate): design — existing-table CHECK evolution (postgres)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70be1ce commit ba4aef4

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)