Follow-up to #302 / #303, which fixed self-referential FKs (a self-loop is not a cycle and no longer evicts its component from the dependency order).
What still fails
A genuine cross-table cycle — a.b_id → b and b.a_id → a — cannot be dependency-ordered at all. ferro_migrate::order_by_dependencies falls through and appends the remaining tables in input order, and whichever table is emitted first carries an inline REFERENCES to a relation that does not exist yet:
CREATE TABLE "a" (..., "b_id" uuid REFERENCES "b" ("id")); -- ERROR: relation "b" does not exist
CREATE TABLE "b" (..., "a_id" uuid REFERENCES "a" ("id"));
SQLite tolerates forward FK references at DDL time, so the fallback only bites on Postgres — the same asymmetry that hid #302.
Proposed fix
For tables that remain unordered after the dependency sort, strip the cycle-forming FKs from the CREATE TABLE emission and add them back after all creates via named post-create constraints:
ALTER TABLE "a" ADD CONSTRAINT "fk_a_b_id_b" FOREIGN KEY ("b_id") REFERENCES "b" ("id");
The pieces already exist: FK constraint names are single-sourced in ferro_ddl_lowering::fk_name (AGENTS.md I-1 §6), and the ALTER-path emitter already renders named ADD CONSTRAINT FKs (see emit_sql_with_ir_add_column_fk_postgres). Cross-emitter parity needs a check that the Alembic bridge names these identically (SQLAlchemy handles cycles with use_alter, which produces the same post-create ALTER shape).
Until then the boundary is: self-loops order correctly (#302); genuine cycles are emitted in input order and fail loudly on Postgres at CREATE time.
Follow-up to #302 / #303, which fixed self-referential FKs (a self-loop is not a cycle and no longer evicts its component from the dependency order).
What still fails
A genuine cross-table cycle —
a.b_id → bandb.a_id → a— cannot be dependency-ordered at all.ferro_migrate::order_by_dependenciesfalls through and appends the remaining tables in input order, and whichever table is emitted first carries an inlineREFERENCESto a relation that does not exist yet:SQLite tolerates forward FK references at DDL time, so the fallback only bites on Postgres — the same asymmetry that hid #302.
Proposed fix
For tables that remain unordered after the dependency sort, strip the cycle-forming FKs from the
CREATE TABLEemission and add them back after all creates via named post-create constraints:The pieces already exist: FK constraint names are single-sourced in
ferro_ddl_lowering::fk_name(AGENTS.md I-1 §6), and the ALTER-path emitter already renders namedADD CONSTRAINTFKs (seeemit_sql_with_ir_add_column_fk_postgres). Cross-emitter parity needs a check that the Alembic bridge names these identically (SQLAlchemy handles cycles withuse_alter, which produces the same post-create ALTER shape).Until then the boundary is: self-loops order correctly (#302); genuine cycles are emitted in input order and fail loudly on Postgres at CREATE time.