Skip to content

feat: add rowx migrate command - #160

Merged
mplemay merged 2 commits into
mainfrom
feat/145-migrate-command
Nov 1, 2026
Merged

mplemay merged 2 commits into
mainfrom
feat/145-migrate-command

Conversation

@mplemay

@mplemay mplemay commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Closes #145

What

rowx migrate add | run | revert | info and the [tool.rowx.migrate] config.

  • check/config.py: [tool.rowx.migrate] parsed into Config.migrate (MigrateConfig: dir, table, create_schemas, versioning, kind). None when the table is absent, and an empty table counts as configured. Unknown keys get the existing did-you-mean error, and bad versioning or kind values are config errors with a suggestion (exit 2). A new database_url(config, url) helper is the --database-url/database_url_env lookup, now shared with check.
  • check/migrate.py (new): parsers and handlers.
    • add writes sqlx's placeholder content (-- Add migration script here, -- Add up migration script here, -- Add down migration script here), creates the directory, and infers versioning (sqlx's rule; sequential is {:04}) and kind (latest migration) unless a flag or the config says otherwise. Timestamps are UTC YYYYMMDDHHMMSS. It creates files exclusively (open("x")) and refuses an existing version before writing anything.
    • run, revert and info use Migrator over rowx.postgres.connect. They print Applied|Reverted|Would apply|Would revert <version>/<migrate|revert> <description> (<time>), and info prints <version>/<state> <description>. Every error is error: ... on stderr with the DSN and password redacted (same redact as check), exit 2.
    • --dry-run calls Migrator.run/undo with dry_run=True: the Rust plan runs under the lock as for a real call, but nothing is executed and no schema or history table is created. Errors are the real run's, from the same code.
    • revert is a single Migrator.undo call, so the versions are chosen and reverted under one lock. Without --target-version it passes target=None, which reverts only the latest applied migration (chosen under that lock). Each Reverted line's time is the status's execution_time, which undo now sets to how long the revert took.
    • add rejects a simple migration whose description ends in .up or .down (the file would read back as half of a reversible pair and break the directory), a description with a NUL byte, and reports a directory it can't create or write as error: ... (exit 2).
  • check/cli.py: registers the migrate group and dispatches it with the same exit-2 handling as check.

Changes to the merged Migrator (wiring edits)

  • Migrator.run(..., dry_run=False) and Migrator.undo(..., dry_run=False), as api.md specifies. Rust: rowx_postgres::migrate::{run_dry, undo_dry} list the history rows under the lock (creating nothing), run the core plan, and return the statuses the call would produce (applied for run, pending for undo, no installed_on/execution_time). The binding's Op::Run/Op::Undo carry the flag, and a dry run never bumps the pool epoch or clears the statement cache.
  • Migrator.undo(target=None): target stays a required keyword but may be None, meaning "only the latest applied migration". rowx_core::migrate::latest_undo_target (pure, unit-tested) picks it inside the executor's lock. rowx_postgres::migrate::undo/undo_dry take Option<i64>. Design change to propose: api.md types undo's target as int; this widens it to int | None so the CLI's targetless revert is one claim.
  • undo now returns each reverted status with execution_time = how long its revert took (the version has no history row afterwards, so the field was always None before). Design change to propose: document this in api.md/migrations.md.
  • _core/postgres.pyi, rowx/postgres/_migrate.py and the typing cases updated for both.

New tests

  • unit/check/test_config.py: test_migrate_table_is_absent_by_default, test_empty_migrate_table_is_configured_with_defaults, test_migrate_table_parses_every_key, test_migrate_unknown_key_suggests, test_migrate_unknown_key_without_suggestion, test_misspelled_migrate_table_suggests, test_migrate_bad_values (bad enums, empty and non-string values), test_migrate_must_be_a_table.
  • unit/test_cli.py: test_add_creates_the_directory_with_sqlx_placeholder_content, test_add_replaces_spaces_in_the_description, test_add_timestamp_is_utc_to_the_second, test_add_with_no_migrations_infers_a_timestamp, test_add_infers_versioning_by_sqlxs_rule (parametrized), test_add_reversible_pairs_use_the_up_version_for_the_rule, test_add_flags_override_inference, test_add_reversible_writes_a_pair, test_add_kind_is_inferred_from_the_latest_migration, test_add_simple_flag_beats_a_reversible_history, test_add_refuses_to_overwrite, test_add_refuses_a_version_that_exists_before_writing_a_pair, test_add_reads_dir_versioning_and_kind_from_config, test_add_source_is_relative_to_the_current_directory, test_add_without_a_pyproject_defaults_to_migrations, test_add_rejects_a_bad_description, test_add_reports_a_bad_existing_directory, test_add_reports_a_bad_config, test_add_conflicting_flags_are_a_usage_error, test_database_commands_need_a_url, test_database_url_env_comes_from_the_config, test_an_unreachable_database_exits_two_without_the_password, test_a_bad_migration_directory_exits_two, test_a_missing_config_file_exits_two, test_format_duration.
  • Rust: rowx-core the_latest_undo_target_reverts_only_the_latest_applied_version; rowx-postgres/tests/migrate.rs undo_without_a_target_reverts_only_the_latest_and_times_each_revert, a_dry_run_plans_like_the_real_one_and_changes_nothing.
  • integration/postgres/test__migrate.py: test_undo_without_a_target_reverts_the_latest_only_and_times_it, test_a_dry_run_returns_the_planned_statuses_and_changes_nothing (including create_schemas never being created).
  • unit/typing/cases.py: dry_run and target=None reveals, plus negative_migrator_arguments.
  • unit/test_cli.py (review fixes): test_add_rejects_a_simple_description_that_reads_back_as_half_of_a_pair, test_add_a_reversible_description_may_end_in_up, test_add_reports_a_directory_it_cannot_create, and a NUL case in test_add_rejects_a_bad_description.
  • integration/check/test_migrate_cli.py (db, goldens in integration/check/golden/migrate_*.txt, elapsed times normalised): test_run_info_revert_info_match_goldens, test_run_dry_run_changes_nothing, test_revert_dry_run_changes_nothing, test_revert_dry_run_on_a_fresh_database_creates_nothing, test_dry_run_reports_the_same_errors_as_a_real_run, test_target_version, test_revert_to_zero_reverts_everything, test_revert_without_a_down_file_exits_two_and_reverts_nothing, test_out_of_order_exits_two_unless_allowed, test_missing_migrations_exit_two_unless_ignored, test_info_shows_both_checksums_of_a_modified_migration, test_a_failing_migration_exits_two_with_the_server_message, test_config_table_and_create_schemas_are_used, test_a_password_is_never_printed (a role with a random password, covering success, a failing migration and a refused login).

Wiring edits

  • check/cli.py: migrate subcommand registration, dispatch, and use of the shared database_url helper.
  • check/config.py: "migrate" added to the [tool.rowx] known keys.
  • Migrator, executor and bindings: dry_run, undo(target=None) and the revert execution_time (listed under "Changes to the merged Migrator").

Spec ambiguities

  • dry_run on Migrator. api.md shows dry_run on run/undo but feat: add rowx.postgres.migrator #159 didn't add it; it is added here (see above) so the plan stays in rowx-core.
  • info and applied checksums. MigrationStatus has no applied checksum, so for a modified row info reads version, checksum from the history table with an {table:ident} query (schema-qualified names split on the last .). The line is N/modified <description> (local abcd1234, applied ef567890), short hex being the first 4 bytes. A missing row prints no description.
  • Output format. Only the run line is given in the design. info prints <version>/<state> <description> (<time>), with the time only for applied rows that have one.
  • Per-line elapsed time for revert. Resolved by undo returning the revert's execution_time (see above); revert is one call under one lock.
  • Output after a partial failure. Migrator.run returns nothing on error, so run prints no Applied lines for migrations that committed before a failing one (the same holds for revert). The error is on stderr and the earlier ones stay applied. Fixing it needs MigrationFailedError (and the connection-error path) to carry the versions done so far, which is a new public error attribute the design doesn't have; proposed as a design change rather than done here.
  • Kind inference. Design says "kind of the latest migration"; sqlx-cli uses the first one. I followed the design. A --reversible or --simple flag always wins, whereas sqlx ignores --reversible once migrations exist.
  • Default dir. Without the table, dir is migrations relative to the directory of the found pyproject.toml (or the current directory when there is none). --source is relative to the current directory.
  • Version 0. sqlx's "the only version is 0 or 1" rule is implemented, but the filename grammar requires a positive version, so only 1 can occur here.
  • Existing version. add refuses when the new version already exists in the directory, which can only happen with a timestamp collision in the same second; it reports this as "refusing to overwrite".
  • Description. Only spaces become _ (as sqlx), and an empty description or one containing a path separator is an error (exit 2). --database-url is not accepted by add, which needs no database.
  • Argparse errors (for example --reversible --simple) exit 2 through argparse's usage error.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T01:36:46.708015Z ffd98a3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffd98a395d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/rowx/check/migrate.py Resolved
Comment thread src/rowx/check/migrate.py Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mplemay
mplemay merged commit e3dbd82 into main Nov 1, 2026
3 checks passed
@mplemay
mplemay deleted the feat/145-migrate-command branch November 1, 2026 01:54
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.

feat: add rowx migrate command

1 participant