Skip to content

feat(migrations): ferro migrate new --replace NNNN_<name> regenerates the newest migration carrying hand-written steps (#629) - #635

Merged
0x054 merged 7 commits into
feat/pinch-migration-needsfrom
sdd/629-migrate-new-replace
Oct 11, 2026
Merged

0x054 merged 7 commits into
feat/pinch-migration-needsfrom
sdd/629-migrate-new-replace

Conversation

@0x054

@0x054 0x054 commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Closes #629

What

Two branches each generated 0007. After the rebase the directory holds 0007_add_x and 0007_add_y, and check names both with the command that fixes it:

duplicate_number: two migrations are numbered 0007: 0007_add_x and 0007_add_y. Keep the one that is applied anywhere and regenerate the other on top of it:
  ferro migrate new add_x --replace 0007_add_x
  ferro migrate new add_y --replace 0007_add_y
(each line regenerates the migration it names)

ferro migrate new add_x --replace 0007_add_x regenerates add_x on top of the newest snapshot below it. The DDL is re-planned by the one planner, and every data step and hand-written SQL step comes across byte for byte. The command never opens a database:

migrations/0008_add_x/  (replaces 0007_add_x, now deleted)
  01_schema.up.postgres.sql  moved (was 0007_add_x/01_schema.up.postgres.sql)
  01_schema.down.postgres.sql  moved (was 0007_add_x/01_schema.down.postgres.sql)
  02_fix_rows.up.sql  carried, review it (was 0007_add_x/02_fix_rows.up.sql)
  02_fix_rows.down.sql  carried, review it (was 0007_add_x/02_fix_rows.down.sql)
  ir.json  new snapshot, chained from 0007_add_y
changed models: Author

A database that already applied the old 0007_add_x now gets a second cause in its refusal: "or 0007 was replaced: revert it with the files that were applied (check out the commit that applied it, ferro migrate down --to 0006), then up". The migrations guide has a new "Two branches, one number" section and a -- ferro: expand row in its Headers table.

Verify

Ran the ticket's Verify block on c078e69: passed (cargo test -p ferro-migrate 416/416; pytest 181 passed, 6 skipped, on sqlite and postgres; prek run --all-files clean).
TDD: RED cargo test -p ferro-migrate --lib directory failed as expected (no associated function named read_replacing), and RED pytest tests/test_migrate_replace.py failed (Unknown option: --replace). GREEN: the same commands passed (18/18 directory tests; 13/13 replace tests).
Verify block corrected: no

Test scenarios

  • Happy path: same parent → 0003_add_x chained from 0002_add_y, old directory gone, the per-file lines print, check exits 0. New parent: chained from the newest snapshot below (0003_more), never the stale one. Free number: regenerated in place, and a filled-in backfill stays byte-identical ("carried, review it … wins over the regenerated step of the same name"). --sql-step extra lands beside a carried SQL step, which gets a new number.
  • Edge cases: --replace 0002 works when the number is unique and is refused naming both directories when it is shared; --replace 0009_nothing is refused naming the directory. --replace combines with --data-step and with --data-only (in test_migrate_new.py).
  • Error paths: a later migration chains from the target → "replace the later one first, or delete it", nothing written. Empty regeneration → "delete the directory", nothing written. --url → new's refusal, with no database reachable.
  • Integration (sqlite and postgres): apply the old 0002, replace it, up refuses with "or 0002 was replaced …". Following the recipe (old files, down --to 0001, new files, up) leaves status at the new head. Replacing in place produces the snapshot-mismatch text with the same clause. check and up on a duplicate number print both --replace lines.
  • Docs: a test reruns the guide's check and --replace examples and compares them line for line with what ferro prints.

Notes for the reviewer

  • Interfaces as produced:
    • MigrationsDir::read_replacing(path, target) -> Result<(MigrationsDir, Migration), DirectoryError>.
    • New DirectoryError variants: AmbiguousNumber { number, names }, plus NoSuchMigration { target } and LaterMigration { replaced, later } for the other two refusals.
    • GenerateOptions.carry: Vec<CarriedStep { name, body, kind, down }>. down: Option<String> was added because a hand-written SQL step has two files.
    • GeneratedStep.carried: Option<Carried { step, over_namesake }>, so the output can say when a carried step won over a rescaffolded one.
    • Python GeneratedMigration.moves: tuple[(old, new, Moved)] with Moved { KEPT, RENUMBERED, CARRIED }.
    • ferro.migrations.generate.replace(...) -> Path, and prepare(..., replace=).
    • RunRefusal::AppliedMissing gains replaced: Option<u16>. The added clause only shows when the directory lacks a migration at or below its head, so the rolling-deploy (ahead-only) texts in deploying.md are unchanged.
  • Placement rule (the ticket left it open): a carried step takes the place of the first regenerated step with the same name and kind, and wins over it. A carried step with no such match goes where new puts a person's step of its kind: data steps go before the contract (ADR-0025), and SQL steps go right before the data steps, or last. Dialect-suffixed SQL counts as generated DDL, so it is regenerated, not carried.
  • Outside Files owned:
    • src/migrations_ffi.rs: _read_migrations_dir gains replacing=, so no second entry point.
    • src/ferro/_core.pyi: the stub for that signature.
    • src/ferro/migrations/layout.py: home of GeneratedMigration, Moved and read_replacing.
    • generate/backfill.rs and generate/staging.rs: carried: None added to their step constructors.
    • tests/test_migrate_rerecord.py: it pins the snapshot-mismatch text, which gained the clause.
    • docs/pages/reference/cli.md: the --replace row.
  • Small extra: the unparseable-header refusal now lists expand among the known headers.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR

0x054 and others added 7 commits October 11, 2026 13:51
…e at the replaced number

The duplicate-number refusal prints one `ferro migrate new <name> --replace NNNN_<name>` line per directory.

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

Each gains a second cause and its fix: or NNNN was replaced, revert it with the files that were applied (check out the commit that applied it, `ferro migrate down --to <parent>`), then `up`.

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

GenerateOptions.carry places each carried data step or hand-written SQL step in the place of its rescaffolded namesake, which it wins over, or where a person's step of its kind goes; _read_migrations_dir takes replacing=<target>.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
…migration carrying hand-written steps

replace() and new --replace read the directory admitting the duplicate at the replaced number, regenerate on top of the newest snapshot below it, carry every data step and hand-written SQL step across byte for byte, and print one line per file (kept, moved, renumbered, carried, review it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR
The guide gains a "Two branches, one number" section (pinned against what check and new --replace print) and its Headers table the -- ferro: expand line; the CLI reference gains --replace.

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

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

  • Every acceptance criterion has a test that fails without the change: same parent / new parent / free number / carried backfill and --sql-step (tests/test_migrate_replace.py), target forms and the three refusals with the tree compared byte for byte before/after, --url refused with an unreachable URL, the applied-then-replaced recipe on sqlite and postgres (backend_matrix), the in-place snapshot-mismatch, check / new / up duplicate-number lines (test_migrate_check.py, test_migrate_new.py, test_migrate_up.py), and the guide's check / --replace blocks compared line for line with ferro's output.
  • Interfaces: MigrationsDir::read_replacing(path, target) -> Result<(MigrationsDir, Migration), DirectoryError>, DirectoryError::AmbiguousNumber { number, names }, GenerateOptions.carry: Vec<CarriedStep { name, body, kind, .. }>, Python GeneratedMigration.moves with Moved { KEPT, RENUMBERED, CARRIED }, ferro.migrations.generate.replace(database, name, target, *, sql_step, data_step, data_only, no_backfill) -> Path, CLI new --replace: every named item exists. CarriedStep.down, GeneratedStep.carried: Option<Carried { step, over_namesake }> and AppliedMissing.replaced are additive; no ticket consumes them. The per-file kept / moved / renumbered / "carried, review it" lines and the namesake line are delivered and pinned.
  • Placement: an unmatched carried step goes where new puts a person's step of its kind (data before the contract, ADR-0025; SQL before the data steps or last). In the domain model a hand-written SQL step is StepKind::PortableSql (NN_<name>.up.sql / .down.sql; CONTEXT.md "step class", directory.rs StepKind), and dialect-suffixed files are StepKind::Ddl, so treating them as regenerated loses no file the ticket means.
  • ADR-0023: prepare(..., replace=) reads only the directory and the models. --url hits new's existing refusal ("never a database; drop --url"), tested with no database reachable.
  • Refusal texts: SnapshotMismatch and AppliedMissing gain the "or NNNN was replaced: revert it with the files that were applied (check out the commit that applied it, ferro migrate down --to <parent>), then up" clause. AppliedMissing stays byte for byte when nothing is missing at or below the head, so the ahead-only / expand-only texts are unchanged. Every other RunRefusal arm is untouched in the diff. --to 0000 for 0001 is accepted by down (cli down --to help).
  • Docs: "Two branches, one number" exists. The Headers table has the -- ferro: expand row (meaning, contract by default, older ferro refuses it), and it matches the deploying guide's step-class text ("a write the previous release makes", the #rolling-deploys-and-the-two-release-contract anchor resolves). No generated rendering gains a step-class header (ADR-0054). A carried -- ferro: expand hand step keeps its body byte for byte (test_a_carried_sql_step_keeps_its_body_and_moves_to_its_new_place).
  • Files outside Files owned, declared and accepted: src/migrations_ffi.rs + _core.pyi (one FFI entry point, replacing=), layout.py (home of GeneratedMigration), backfill.rs / staging.rs (carried: None on existing constructors), tests/test_migrate_rerecord.py (pins the changed snapshot-mismatch text), docs/pages/reference/cli.md (the --replace row).
  • Minor · src/ferro/cli/migrate.py:267 at c078e69 · Missing · _print_replacement prints a line only for files of the new migration. A file of the replaced migration with no counterpart (a generated step the regeneration no longer needs, e.g. 02_idx_author_bio.*) disappears without a line. The ticket asks for "one line per file (kept, moved, renumbered)" of the replaced migration's files, so a dropped (was …) line would complete that account.

Cannot verify: none. Checked separately: PR #634 touches none of this PR's files, adds no GeneratedStep constructor (so the new carried field cannot break it), and a local unpushed trial merge of #634's head into c078e69 passed cargo check --all-targets.

Quality: PASS

  • Minor · src/ferro/migrations/generate.py:236 at c078e69 · if migration is None: raise … # pragma: no cover is unreachable, because prepare(..., replace=) raises before it can return None. It is a second, untested refusal text for the same condition. An assert migration is not None (or typing prepare per overload) states the contract without the dead message.

Checks run: read_replacing admits the duplicate only at the replaced number and still refuses one elsewhere (unit test). LaterMigration compares only later snapshots' parent links, and an unreadable later snapshot still fails in assemble. Carried bodies are read with read_bytes().decode and written with write_bytes(text.encode()), so they are byte-exact with no newline translation. The in-place write sets the old directory aside and restores it on failure. No new compiler warnings in the changed files (cargo check --all-targets: every warning is in untouched files).

Merge preview: the integration tip 682c30b is already an ancestor of c078e69, so git merge --no-commit origin/feat/pinch-migration-needs was "Already up to date" and cargo check --all-targets passed on that tree.

Verify: passed on c078e69. uv run maturin develop exit 0. cargo test -p ferro-migrate: 416 passed, exit 0. uv run pytest --db-backends=sqlite,postgres tests/test_migrate_replace.py tests/test_migrate_new.py tests/test_migrate_check.py tests/test_migrate_up.py tests/test_docs_migrations.py: 181 passed, 6 skipped (dialect-only tests), exit 0. uv run prek run --all-files: exit 0. The worker's sdd-verify note on HEAD says "verified 2026-10-11T18:22:50Z".

@0x054
0x054 merged commit cee6a19 into feat/pinch-migration-needs Oct 11, 2026
6 checks passed
@0x054
0x054 deleted the sdd/629-migrate-new-replace branch October 11, 2026 18:32
0x054 added a commit that referenced this pull request Oct 12, 2026
…migration carrying hand-written steps (#629) (#635)

`ferro migrate new <name> --replace NNNN_<name>` regenerates the newest migration against the current newest snapshot below it: a new snapshot, regenerated DDL steps, and every data step and hand-written SQL step carried across by name with its body intact, renumbered when the regenerated steps move it. The number is kept when free and the next taken when not. The directory loader admits the duplicate at the replaced number for this verb only; `check` and the loader name both duplicates in full and print one `--replace` line each; the run's snapshot-mismatch and applied-missing refusals name a replaced migration and its revert recipe. The migrations guide gains "Two branches, one number" and documents the `-- ferro: expand` header.

Closes #629

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