Skip to content

refactor(migrate): one step-class entry point; the generator names each carried step's move (architecture pass on #636) - #641

Merged
0x054 merged 3 commits into
feat/pinch-migration-needsfrom
refactor/deepen-step-class-and-moves
Oct 12, 2026
Merged

0x054 merged 3 commits into
feat/pinch-migration-needsfrom
refactor/deepen-step-class-and-moves

Conversation

@0x054

@0x054 0x054 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Part of #623 (deepening pass on PR #636; behaviour unchanged)

Two decisions that were spread across callers now each live in one module. Every refusal text, generated file, recorded class and printed line is the same as before.

Card 1: one step-class entry point

Before. run_plan/step_class.rs exported seven ways to get a class: op_class, step_class, schema_step_class, index_step_class, constraint_step_class, hand_sql_class and DATA_STEP_CLASS. At each step it laid out, the generator chose one of them and worked out the constrains flag on its own. It did this through a stages_on map built from staged_constraints(...).is_empty(), and for the contract it added || !layout.demands.is_empty(). index_step_class repeated op_class's AddIndex / RedefineIndex rule. The table test called op_class directly, so it would still pass if a call site picked the wrong sibling or passed the wrong constrains.

After. The interface is one function, step_class(&LaidOutStep) -> StepClass. The generator describes the step it laid out and the module decides the class. LaidOutStep has these variants:

  • Labels
  • Schema(SchemaStep { ops, sides, dialect })
  • Index { op, target }
  • Constraints { headers }
  • Contract { ops, sides, expand: SchemaStep, demands }
  • Data
  • HandSql { headers }

How the old pieces map onto it:

  • Constraint staging. The module works out from the step's own ops whether the step adds a constraint NOT VALID, using staging::stages_not_valid. staged_constraints now filters by that same predicate, so the class and the staged list cannot disagree.
  • The contract. It is contract when the expand it finishes staged a constraint, or when it holds a demanded column to its values.
  • Index steps. index_step_class became the private kind_class, which op_class also uses.
  • Apply time. migration_classes keeps its three inputs (regenerated text checksum, file checksum, headers). For a data step and for a hand-written step it now goes through step_class.
  • storage_facts. The IR crate has no DDL-bearing projection to derive it from, so it stays a single list. Its comment now states the rule it follows.

step_class, LaidOutStep, SchemaStep and Sides are now pub(crate); nothing outside the crate used them. To let the module name Demand and stages_not_valid, generate::backfill and generate::staging are now pub(crate).

Interface tests. These now go through step_class:

  • every_op_kind_has_its_class_under_a_quiet_verdict: every op kind from ADR-0054's table, each alone in a schema step, on both dialects. The verdict overrides, the SQLite rebuild row and the demanded column all stay. A demanded column is now asserted expand in the schema step and contract in the contract, which the old op_class table could not tell apart.
  • every_op_reads_its_class_off_its_verdict_and_kind: the whole plan carried by one contract step.
  • a_hand_written_sql_step_is_contract_unless_it_says_expand.
  • New: a_data_step_is_expand, a_constraint_step_is_contract_wherever_it_runs_a_statement, an_index_step_is_contract_only_when_it_builds_a_unique_index, and a_schema_step_staging_a_constraint_not_valid_is_contract_and_so_is_its_contract. The last one is a nullable FK column added to an existing table: contract on Postgres, expand on SQLite, and the same for its contract.

The classes() golden table (with the C1 row an_added_label_beside_a_retired_one_is_expand), the F1 test a_generated_step_edited_by_hand_is_classed_by_its_file, the unclassifiable test and tests/test_step_class.py are unchanged. The only change is that the classes() helper reads every step's class from the generator, data steps included, instead of using DATA_STEP_CLASS. No test was deleted: the tests that exercised the removed siblings were rewritten against the entry point.

Changed test. One generator test in generate/mod.rs compared against DATA_STEP_CLASS, which no longer exists. It now compares against StepClass::Expand.

RED. The rewritten tests came first and failed to compile against the old module: 24 errors, because LaidOutStep and SchemaStep did not exist and step_class took three arguments. GREEN: cargo test -p ferro-migrate went from 431 to 435 passing.

Card 4: the generator names each carried step's move

Before. Carried sent the whole carried step (name, kind, body, down) back through the JSON. _with_moves then matched carried steps to generated steps again by (name, kind) in order, which repeated a placement carried_step had already decided.

After. Carried is { carry: usize, over_namesake: bool }, which serializes as {"carry": N, "over_namesake": …}. carry is the step's position in GenerateOptions.carry. On the Python side:

  • GeneratedMigration.from_generated(..., carry=carry) writes a carried step's files from the carry entry it names.
  • GeneratedStep.carry (the index, or None) replaces the boolean carried.
  • _with_moves looks up carry[step.carry]["old"] for the old files and renders the per-file lines. The order-matching code is gone.

The FFI result is still a JSON string, so neither src/migrations_ffi.rs nor _core.pyi changed.

Interface tests. tests/test_migrate_replace.py pins the printed lines (kept, renumbered, "carried, review it", and the namesake line) and passes without changes.

Changed tests. Three generator tests asserted Carried.step == options.carry[i], the old echoed body. That was testing past the interface, so they now assert the index. a_carried_step_wins_over_… also pins the JSON shape the Python side reads.

RED. The updated tests failed to compile (4 × no field carry on Carried). GREEN after the change.

Verify

Run as written on the committed state (FERRO_POSTGRES_URL set, UV_FROZEN=1, set -o pipefail):

Command Exit Result
uv run maturin develop 0
cargo check --all-targets 0
cargo test -p ferro-migrate 0 435 passed
cargo test --no-default-features --features testing 0 354 passed
uv run pytest --db-backends=sqlite,postgres tests/test_step_class.py tests/test_migrations_api.py tests/test_migrate_up.py tests/test_migrate_status.py tests/test_migrate_rerecord.py tests/test_migrate_baseline.py tests/test_migrate_down.py tests/test_migrate_replace.py tests/test_migrate_new.py tests/test_migrate_check.py tests/test_generate_columns.py tests/test_cross_emitter_parity.py tests/test_docs_migrations.py 0 943 passed, 13 skipped, 1 xfailed
uv run prek run --all-files 0

Outside Files owned

None. This PR does not touch src/migrations_ffi.rs, _core.pyi, runner.py or any of the other worker's files.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

0x054 and others added 2 commits October 11, 2026 23:27
The generator hands `step_class` the step it lays out (`LaidOutStep`: its
phase, its planned ops with their verdicts, the schemas on either side, its
rendering's headers) and the module decides the class; the seven exported
entry points become one. `index_step_class` folds into the op-kind rule it
repeated, and whether a step adds a constraint `NOT VALID` is read inside the
module from the step's ops through `staging::stages_not_valid`, the predicate
`staged_constraints` now filters by. The apply-time path classifies data and
hand-written steps through the same entry point. Behaviour is unchanged.

Part of #623

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…ntry

`Carried` names the position of the step it carries in
`GenerateOptions.carry` instead of echoing the step's body back through the
JSON, beside whether it won over a namesake. `--replace`'s Python side maps
that index to the files it read and renders the per-file lines; the
re-matching of carried steps to generated steps by name and kind in order
is gone. The printed lines are unchanged.

Part of #623

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

@0x054 0x054 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec: PASS

Spec: the PR body's Card 1 and Card 4; the invariant is that behaviour does not change. Closes #N is waived because this PR is Part of #623.

Checks run against 0f96c19:

  • Schema step. The old stages_on was computed over labels+schema ops. It now comes from stages(SchemaStep{placed.schema}). The two are the same because labels ops are only AddEnumLabel (commits_alone, lib.rs:504), and stages_not_valid returns false for those. Sides (before/expanded) and the demanded-column relaxing are unchanged.
  • Contract. stages(expand) || !demands.is_empty() is the old validates || !layout.demands.is_empty(), built over the same ops and sides.
  • stages_not_valid vs the old filter. It is the old guard of staged_constraints lifted out unchanged: Postgres only, the table on both sides, and the same four op kinds plus the riders of AddColumn. The old staged list was non-empty exactly when some op passes this predicate. staged_constraints now filters by it (staging.rs:257, 321).
  • Index steps. index_ops only emits AddIndex, DropIndex and RedefineIndex (stages.rs:293-358). kind_class gives each the same class as the old index_step_class. The RebuildIndex difference cannot be reached.
  • Other step kinds. Labels, constraint/validate (not_applicable), data, guard and carried-data steps, and hand SQL with or without -- ferro: expand all map one-to-one.
  • Apply time (F1 rule). migration_classes still gates on the checksum and falls back to declared_class. Only the data arm and the hand arm changed, and both now go through step_class.
  • Tests kept. C1, F1 and the classes() golden tests are present and pass.
  • Card 4. carry is the index into the same list that _carried_steps builds and the generator receives. The files written come from that entry's body and down, which hold the same bytes the old echo returned. The order-matching code is gone. test_migrate_replace.py passes unchanged.
  • Generated bytes. No rendering path changed (the class is never written to a file). Pin (a) in test_cross_emitter_parity.py passes.

Cannot verify: none.

Quality: PASS

  • Minor · crates/ferro-migrate/src/run_plan/step_class.rs:344 at 0f96c19 · The rule in the storage_facts comment is now "changing it alone cannot refuse a write". The rule it replaces was "renders no storage". The listed fields satisfy both, but a future editor could read the new rule as permission to drop a field that renders storage.
  • Minor · src/ferro/migrations/layout.py:31 at 0f96c19 · collections.abc is imported after pathlib, which is out of stdlib order.

Other checks: I-1 holds (the class is decided once, in the core). I-3: the change adds no unwrap. The pub(crate) widening is needed for Demand and stages_not_valid. The four changed tests now assert through step_class or the JSON shape. Nothing outside the five files changed.

Merge previews:

  • Against origin/feat/pinch-migration-needs: no-op.
  • Against #640 (dc93200): merges cleanly with no conflicts, including src/migrations_ffi.rs. cargo check --all-targets exits 0.

Verify: passed on 0f96c19. All six commands exited 0: maturin; cargo check; cargo test -p ferro-migrate 435 passed; cargo test --no-default-features --features testing 354 passed; pytest 943 passed, 13 skipped, 1 xfailed; prek.

Part of #623

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
@0x054
0x054 marked this pull request as ready for review October 12, 2026 10:41
@0x054
0x054 merged commit b38f358 into feat/pinch-migration-needs Oct 12, 2026
7 checks passed
@0x054
0x054 deleted the refactor/deepen-step-class-and-moves branch October 12, 2026 10:41
0x054 added a commit that referenced this pull request Oct 12, 2026
…ch carried step's move (architecture pass on #636) (#641)

Architecture pass on PR #636, cards 1 and 4; behaviour unchanged. The step class has one entry point: the generator describes each laid-out step and the run planner's module decides, deriving the `constrains` flag from the step's own ops through the same function the staging code uses; the duplicated unique-index rule is gone; the apply-time path classes data and hand steps through the same entry point. `Carried` names which carry entry a step came from, so the Python side maps files by index instead of re-matching steps by name.

Part of #623

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant