Skip to content

fix(migrate): auto-migrate pass ownership — column-before-index sequencing and FK on_delete reconciliation#326

Merged
0x054 merged 4 commits into
mainfrom
fix-324-325-automigrate-pass-ownership
Jul 19, 2026
Merged

fix(migrate): auto-migrate pass ownership — column-before-index sequencing and FK on_delete reconciliation#326
0x054 merged 4 commits into
mainfrom
fix-324-325-automigrate-pass-ownership

Conversation

@0x054

@0x054 0x054 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #324. Closes #325.

Both issues are auto-migrate gaps found by Pinch's M7 CP0 scratch-verification spike. One PR, one commit per slice, per the agreed plan (grilled and confirmed with the owner; decisions recorded in ADR-0010 and CONTEXT.md).

#324 — create pass fired index DDL at existing tables

The reported "index before column" crash was not in the reconcile plan (which already orders ADD COLUMN before CREATE INDEX): internal_migrate first runs the create pass, which executed every model's index DDL even when CREATE TABLE IF NOT EXISTS no-opped on an existing table. A new composite unique referencing not-yet-added columns died right there — before the reconciliation pass could add them. On SQLite it was worse than a crash: the double-quoted-string fallback silently built the unique index over a string constant.

Fix: the create pass introspects live table names once and skips existing tables entirely, enforcing the already-documented contract ("existing tables are left untouched unless migrate_updates is set"). Index reconciliation on existing tables stays where it already lives, correctly ordered, in migrate_updates. Deliberate behavior change (ADR-0010): plain auto_migrate no longer creates new indexes on live tables — that undocumented convenience was the crash vector and had two competing owners of index creation.

#325 — FK on_delete drift was silently ignored

Live FK constraints were never introspected at all (live_columns_to_schema_ir hardcoded foreign_keys: vec![]), so CASCADE → SET NULL on an existing model changed nothing and warned about nothing — and Pinch's "sever, never destroy" disconnect would cascade-delete user history on upgraded installs.

Fix: migrate_updates now introspects live FKs (pg_constraint / PRAGMA foreign_key_list) into the old IR and diffs each declared FK against the live constraint on its column:

  • Postgres: a ferro-owned (fk_-named) constraint with definition drift (on_delete, target) is rebuilt — DROP CONSTRAINT + ADD CONSTRAINT under the canonical name, inside the existing per-table transaction. A declared FK missing entirely from an existing column is added. A drifting constraint ferro does not own is warned about, never altered (mirroring the idx_*/uq_* index-ownership policy).
  • SQLite: constraints cannot be altered in place; every FK drift warns loudly — silence was the only unacceptable outcome.

Verification

  • Both Pinch scratch shapes are regression tests at the connect() seam (tests/test_auto_migrate.py), plus plan-level DDL goldens (tests/test_migrate_plan.py) and Rust diff/emission tests (crates/ferro-migrate/src/tests.rs).
  • Full suite green on both backends: 1688 passed (sqlite + postgres matrix, local PG 18), cargo test --no-default-features --features testing 203 passed, just check clean.
  • Cross-emitter parity (I-1): rebuilt constraints keep the canonical fk_<table>_<col>_<to_table> name; test_alembic_autogen_after_migrate_updates_is_idempotent stays green.

Docs

CONTEXT.md gains four terms (create pass, reconciliation pass, ferro-owned artifact, constraint rebuild); ADR-0010 records the pass-ownership decision and its rejected alternatives; the migrations guide, architecture page, FAQ, and connect()/create_tables()/migrate() docstrings document the new coverage per backend.

0x054 added 4 commits July 19, 2026 11:40
…n pass

One auto-migrate pass that both adds columns to an existing table and
declares a composite unique over them crashed at boot: the create pass
fired the model's index DDL at the already-existing table before the
reconciliation pass could add the referenced columns (Postgres: 'column
does not exist'; SQLite silently built the index over a string constant
via its double-quote fallback).

The create pass now introspects live table names once and skips existing
tables entirely — enforcing the documented contract that plain
auto_migrate leaves existing tables untouched. Index reconciliation on
existing tables already lives, correctly ordered, in migrate_updates.

See ADR-0010; part of #324.
The live-side IR previously hardcoded foreign_keys to empty: no FK
introspection existed, so a declared FK change on an existing column was
invisible to the diff. Introduce LiveForeignKey and per-backend readers —
pg_constraint on Postgres (constraint name, columns, target, confdeltype
mapped to the declared action vocabulary) and PRAGMA foreign_key_list on
SQLite (which exposes no constraint names) — and populate the old IR's
foreign_keys from them. Multi-column live FKs are user-owned by
construction (ferro only emits single-column FKs) and are not surfaced.

No diff behavior change yet; the FK diff step lands next. Part of #325.
Changing a ForeignKey's on_delete on an existing model was silently
ignored — the dangerous kind of miss: the model declares SET NULL, the
database keeps CASCADE, and the first parent delete destroys children an
operation documented as non-destructive.

migrate_updates now diffs each declared FK against the live constraint on
the same column. On Postgres, a ferro-owned (fk_-named) constraint whose
definition drifts (on_delete, target) is rebuilt — DROP CONSTRAINT + ADD
CONSTRAINT under the canonical name, inside the per-table transaction —
and a declared FK missing entirely from an existing column is added. A
drifting constraint ferro does not own is left untouched but warned
about. On SQLite, which cannot alter table constraints, every FK drift
warns loudly instead — never silence.

New MigrationOp variants AddForeignKey / RebuildForeignKey; ownership
predicate is_ferro_fk_name single-sourced in ferro-ddl-lowering.
…DR-0010

Document the auto-migrate pass-ownership decision (create pass owns only
missing tables; the reconciliation pass is the sole authority for DDL
against existing tables) as ADR-0010, with the new CONTEXT.md terms
(create pass, reconciliation pass, ferro-owned artifact, constraint
rebuild). Extend the migrate_updates coverage docs — guide table,
architecture, FAQ, and the connect()/create_tables()/migrate()
docstrings — with FK reconciliation semantics per backend.
@0x054
0x054 merged commit 3451b1d into main Jul 19, 2026
7 checks passed
@0x054
0x054 deleted the fix-324-325-automigrate-pass-ownership branch July 19, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant