Repository navigation
feat(migrations): ferro migrate new --replace NNNN_<name> regenerates the newest migration carrying hand-written steps (#629) - #635
Conversation
…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
left a comment
There was a problem hiding this comment.
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,--urlrefused with an unreachable URL, the applied-then-replaced recipe on sqlite and postgres (backend_matrix), the in-place snapshot-mismatch,check/new/upduplicate-number lines (test_migrate_check.py,test_migrate_new.py,test_migrate_up.py), and the guide'scheck/--replaceblocks 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, .. }>, PythonGeneratedMigration.moveswithMoved { KEPT, RENUMBERED, CARRIED },ferro.migrations.generate.replace(database, name, target, *, sql_step, data_step, data_only, no_backfill) -> Path, CLInew --replace: every named item exists.CarriedStep.down,GeneratedStep.carried: Option<Carried { step, over_namesake }>andAppliedMissing.replacedare 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
newputs 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 isStepKind::PortableSql(NN_<name>.up.sql/.down.sql; CONTEXT.md "step class", directory.rsStepKind), and dialect-suffixed files areStepKind::Ddl, so treating them as regenerated loses no file the ticket means. - ADR-0023:
prepare(..., replace=)reads only the directory and the models.--urlhitsnew's existing refusal ("never a database; drop --url"), tested with no database reachable. - Refusal texts:
SnapshotMismatchandAppliedMissinggain 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>), thenup" clause.AppliedMissingstays byte for byte when nothing is missing at or below the head, so the ahead-only / expand-only texts are unchanged. Every otherRunRefusalarm is untouched in the diff.--to 0000for0001is accepted bydown(clidown --tohelp). - Docs: "Two branches, one number" exists. The Headers table has the
-- ferro: expandrow (meaning,contractby 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-contractanchor resolves). No generated rendering gains a step-class header (ADR-0054). A carried-- ferro: expandhand 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 ofGeneratedMigration),backfill.rs/staging.rs(carried: Noneon existing constructors),tests/test_migrate_rerecord.py(pins the changed snapshot-mismatch text),docs/pages/reference/cli.md(the--replacerow). - Minor · src/ferro/cli/migrate.py:267 at c078e69 · Missing ·
_print_replacementprints 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 adropped (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 coveris unreachable, becauseprepare(..., replace=)raises before it can returnNone. It is a second, untested refusal text for the same condition. Anassert migration is not None(or typingprepareper 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".
…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
Closes #629
What
Two branches each generated
0007. After the rebase the directory holds0007_add_xand0007_add_y, andchecknames both with the command that fixes it:ferro migrate new add_x --replace 0007_add_xregeneratesadd_xon 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:A database that already applied the old
0007_add_xnow 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), thenup". The migrations guide has a new "Two branches, one number" section and a-- ferro: expandrow in its Headers table.Verify
Ran the ticket's Verify block on
c078e69: passed (cargo test -p ferro-migrate416/416; pytest 181 passed, 6 skipped, on sqlite and postgres;prek run --all-filesclean).TDD: RED
cargo test -p ferro-migrate --lib directoryfailed as expected (no associated function named read_replacing), and REDpytest tests/test_migrate_replace.pyfailed (Unknown option: --replace). GREEN: the same commands passed (18/18 directory tests; 13/13 replace tests).Verify block corrected: no
Test scenarios
0003_add_xchained from0002_add_y, old directory gone, the per-file lines print,checkexits 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 extralands beside a carried SQL step, which gets a new number.--replace 0002works when the number is unique and is refused naming both directories when it is shared;--replace 0009_nothingis refused naming the directory.--replacecombines with--data-stepand with--data-only(intest_migrate_new.py).--url→new's refusal, with no database reachable.0002, replace it,uprefuses with "or 0002 was replaced …". Following the recipe (old files,down --to 0001, new files,up) leavesstatusat the new head. Replacing in place produces the snapshot-mismatch text with the same clause.checkandupon a duplicate number print both--replacelines.checkand--replaceexamples and compares them line for line with what ferro prints.Notes for the reviewer
MigrationsDir::read_replacing(path, target) -> Result<(MigrationsDir, Migration), DirectoryError>.DirectoryErrorvariants:AmbiguousNumber { number, names }, plusNoSuchMigration { target }andLaterMigration { 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.GeneratedMigration.moves: tuple[(old, new, Moved)]withMoved { KEPT, RENUMBERED, CARRIED }.ferro.migrations.generate.replace(...) -> Path, andprepare(..., replace=).RunRefusal::AppliedMissinggainsreplaced: 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 indeploying.mdare unchanged.newputs 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.src/migrations_ffi.rs:_read_migrations_dirgainsreplacing=, so no second entry point.src/ferro/_core.pyi: the stub for that signature.src/ferro/migrations/layout.py: home ofGeneratedMigration,Movedandread_replacing.generate/backfill.rsandgenerate/staging.rs:carried: Noneadded 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--replacerow.expandamong the known headers.🤖 Generated with Claude Code
https://claude.ai/code/session_014ERLk8yFhJy7rdDyU7rGdR