8000 feat(cli): add reylo migrate by colinhacks · Pull Request #179 · colinhacks/reylo · GitHub
[go: up one dir, main page]

Skip to content

feat(cli): add reylo migrate - #179

Open
colinhacks wants to merge 40 commits into
mainfrom
reylo-migrate
Open

colinhacks wants to merge 40 commits into
mainfrom
reylo-migrate

Conversation

@colinhacks
@colinhacks colinhacks commented Nov 4, 2026 •
Copy link
Copy Markdown
Owner

Adds migrations to Reylo: reylo migrate, in the native binary, for Postgres, MySQL and SQLite. The schema is declared in reylo/schema.sql; reylo migrate create <name> writes each change as a plain .sql migration, tried on a shadow database before it is written; reylo migrate dev runs them in development, and reylo migrate prod in production. The docs page is site/content/docs/migrate.mdx (/migrate).

Commands

reylo migrate init             start from an existing database
reylo migrate create <name>    write the migration that takes the migrations to schema.sql
  --drop <names>               accept what it drops, or changes so values are lost
  --rename <old>=<new>         say a table or column was renamed
reylo migrate dev              take this database to the migrations, and write the lock
reylo migrate prod             run what the lock lists; builds no shadow database
reylo migrate sync [--watch]   take this database to schema.sql, unrecorded
reylo migrate check [--offline]  check the migrations, schema.sql and this database; runs nothing
reylo migrate reset            drop everything in the database
reylo migrate wipe             delete every row
reylo migrate pull             write the migrations a database ran back into files
reylo migrate sync-history     rewrite the history to what the schema has; runs nothing
reylo migrate lock | unlock    keep the lock, or stop keeping it

Each is one operation, with no flag that makes it another. dev, sync, reset and wipe refuse to run where CI is set.

How it holds

  • The lock, reylo/reylo-lock.yaml, lists the migrations in the order they run, each with a checksum chained to the line before, and the checksum of schema.sql from the last time the migrations reached it. Two branches that add migrations conflict on it; a wrong "accept both" breaks the chain, offline. A file the lock lacks is a draft, which only dev runs. reylo migrate unlock turns it off (lock: false).
  • prod builds no shadow database. It stores a fingerprint of the schema after each run, refuses a database that no longer has it, and names check, which replays the history in shadow databases and says how it differs. A deploy runs check, then prod.
  • Nothing is recorded without running but by sync-history, which proves the schema first, and dev, where the database already has a migration's change.
  • Data loss is stated: create refuses a plan that drops a table or column, or changes a column's type so values are lost without failing, until --drop names it.
  • Renames: a table or column that goes while one identical but for its name comes is renamed, keeping its rows. One that also changes is refused, listing every such pair with one command for each way of settling them all. On Postgres what is named after a renamed table (keys, sequences, partitions' indexes) follows it.
  • A SQLite rebuild keeps every row on any runner. Where a table is rebuilt, the tables that reference it are rebuilt with it, so foreign keys on or off, no action fires.
  • Partitions made at run time (pg_partman's, TimescaleDB's chunks) are left out of every comparison and never dropped.

Also

  • Reylo's files live in a visible reylo/ directory (dir in .reylo.yaml); the type check's cache moves from .reylo/ to reylo/.cache/, which keeps itself out of git.
  • New .reylo.yaml keys: the top-level dir, and the migrate section (shadow_url, lock), which reylo migrate init adds.

Tests

  • engine/migrate/tests/scenarios.rs: 237 scenarios on Postgres 18, MySQL 8.4 and SQLite (branches, lock conflicts, drift, cut-off statements, renames, lossy types, partitions, watch).
  • The diff engine's corpora (engine/migrate/tests/diff*.rs), every crate's tests, the oracle, and the loader's tests with none skipped.

Migrations are checked against schema.sql in shadow databases before they run, and create writes the diff engine's plan, verified in a shadow, in schema.sql's style. history: linear keeps migrations/lock.yaml, which rebase resolves from git's index.
apply never records a migration without running it unless --record names one with no data statements, which shadows prove the database already has; an applied migration edited since is refused with both texts, and restore writes back the one that ran. check holds the migrations against schema.sql and runs nothing. Postgres plans convert columns by assignment, and read a table's access method. A SQLite database is locked for a run through a file in .reylo/.
create writes a migration only when --drop names every table and column it drops with their rows, and every name --drop gives is one it drops; --rename states a column's rename, which may change its type. The planner decides renames once, for every database's alterer: a column --drop names is never guessed renamed. Drafts not yet applied anywhere no longer stop create.
sync takes a development database to schema.sql through the migrations: its history is kept as far as it agrees with the files, the database is rewound past that, the migrations after it run for real, and what schema.sql has beyond them is made unrecorded. A migration with no data statements whose change the database already has is recorded instead. It refuses when CI is set. wipe deletes every row of the project's tables and runs the seed.
sync keeps what an earlier sync made unless a pending migration cannot
run on it, and checks each plan it runs before it commits. create
counts a replaced column or table as a drop, resolves --drop and
--rename names exactly, and repeats them in its suggestions. wipe
leaves SQLite's virtual tables' own tables alone. apply drops an index
a cut-off concurrent build left invalid, and --record takes the name
create was given.
A table that goes while one of its columns comes is renamed, or as
--rename states, with what Postgres names after it (keys, sequences,
partitions' indexes). sync's rewind undoes only what the rewound
migrations made, renaming tables back, and keeps what it records.
Every migration command is under reylo migrate. apply --dev takes a
development database to its migrations; sync, which refuses until it
is there, makes what schema.sql adds. sync-history replaces --record
and repair: it records the longest run of migrations the database's
schema already has, proven in a shadow. rebase is gone.
A project is locked unless history is loose. The lock lists each
migration with a checksum chained to the one before, and schema.sql's
checksum when the migrations reach it. apply runs only what it lists,
in its order; a file it lacks is a draft, which apply --dev runs and
locks. A conflicted lock is resolved by keeping main's lines and
running apply --dev. lock writes it from a database's history, unlock
removes it, and check --offline checks it without a database.
The project's files and caches live in the directory the top-level dir
names, reylo/ by default. apply replays nothing where nothing is
pending and the database is as its last run left it, and trusts a
run's result only where it matches the replay. create gates type
changes that lose values with --drop, and assumes renames where one
table or column goes and one like it comes. Partitions made at run
time are data. sync --watch syncs again on each change.
The cache moves from .reylo/ to the .cache/ of the directory dir names,
beside the migrations, and keeps itself out of git there.
A page for reylo migrate, the dir and migrate keys under
Configuration, the cache's new place, and the skill's Migrations.
Copilot AI balanced review requested due to automatic review settings November 4, 2026 16:31
@vercel
vercel Bot commented Nov 4, 2026 •
Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
reylo Ready Ready Preview Nov 6, 2026 3:50pm UTC

Request Review

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No blocking issues in the parts reviewed here, but parts of the migrate engine were not reviewed — see the coverage note below.

Reviewed changes

This run read the CLI, the project file, CI, the loader's cache move, lock.rs, lib.rs and the docs closely. Specialist passes on the rest of the migrate engine were started but stopped before they reported.

  • reylo migrate and reylo run: new subcommands in engine/cli/src/ma 8000 in.rs, with an argument parser of their own and an in-place edit of migrate.history in .reylo.yaml (with_history).
  • New crate reylo-migrate: the commands, the lock with chained checksums, shadow-database replays and their cache, a schema comparator, and diff engines for Postgres, MySQL and SQLite.
  • Project file: a new top-level dir key and a migrate section (url, shadow_url, history, strict_comments, post) in engine/core/src/config.rs.
  • Loader cache move: the cache moves from .reylo/ to <dir>/.cache/ through ResolvedProject.cache; nothing reads an old .reylo/.
  • CI: the engine job gets Postgres 18 and MySQL 8.4 services and -p reylo-migrate, and the scope filter treats engine/migrate/* as part of the binary.
  • Docs: a new migrations.mdx, the dir and migrate keys in 04-configuration.mdx, the FAQ and the skill.

ℹ️ Parts of the migrate engine were not reviewed

This is not an approval of the migration engine itself. Five questions went to specialists and none of them reported back: whether each diff engine (diff/postgres.rs, diff/mysql.rs, diff/sqlite.rs) can plan data loss that --drop does not catch, the rename heuristic among them; whether strict apply keeps its promises (commands.rs); the statement splitter (sql.rs); and whether the replay cache (cache.rs) can ever change an outcome. commands.rs (2986 lines) and diff/postgres.rs (1965 lines) are too large for the review's diff file and were only spot-checked. Those paths still need a reviewer.

ℹ️ binaries.yml doesn't run on changes to engine/migrate/

The reylo binary now links reylo-migrate, which brings in its own rusqlite, mysql_async and tokio-postgres. .github/workflows/binaries.yml still triggers only on engine/cli/** and engine/native/**, so a later change to engine/migrate/ alone never builds the binary on the eight release platforms before a release does.

Technical details
# binaries.yml path filter misses engine/migrate

## Affected sites
- .github/workflows/binaries.yml:17-18 — `paths` lists `engine/cli/**` and `engine/native/**` only
- AGENTS.md, "binaries" bullet under Checks — names the binary's program as `engine/cli/`, `engine/native/`

## Required outcome
- A pull request that changes only `engine/migrate/**` runs `binaries.yml`, and AGENTS.md says so.

ℹ️ Some .reylo/ mentions are still there

Four places outside the diff still name the old cache directory: lab/bundlers/.reylo.yaml:1, lab/bundlers/README.md:11, lab/jest/.reylo.yaml:1 and the comment at engine/native/src/sqlite.rs:81.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread src/project.ts Resolved
Comment thread engine/migrate/src/lock.rs Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new CI step installs pg_dump 18, but on the runner image pg_dump still runs version 16, so the migrations' init tests will still fail.

Reviewed changes

Since the last review, one commit changes the engine job: it now installs a newer pg_dump before cargo test.

  • Install pg_dump 18: adds pgdg's postgresql-client-18 to the engine job in .github/workflows/test.yml, so that reylo_migrate::init (engine/migrate/tests/scenarios.rs:479, :1442) can dump a Postgres 18 server.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread .github/workflows/test.yml Resolved
@colinhacks
Copy link
Copy Markdown
Owner Author

Answering the review's notes:

  • binaries.yml and engine/migrate/: fixed in d29e6ef. A change to engine/migrate/** now runs binaries.yml, and AGENTS.md's binaries bullet names it.
  • Old .reylo/ mentions: fixed in 991d15a (lab/bundlers/.reylo.yaml, lab/bundlers/README.md, lab/jest/.reylo.yaml and the comment in engine/native/src/sqlite.rs now name reylo/.cache/).
  • Parts of the migrate engine not reviewed: nothing changed for this note. The diff engines, strict apply, the statement splitter and the replay cache are covered by the scenario tests in engine/migrate/tests/ on Postgres 18, MySQL 8.4 and SQLite, which CI's engine job now runs with a pg_dump that matches its server (5a7551b). They still need a reviewer.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new commits fix what they set out to fix. One earlier thread is still open: the pg_dump 18 step at .github/workflows/test.yml:271. CI on 5a7551b failed in the way that thread predicted: init_writes_and_records_the_baseline::postgres and init_locks_the_baseline_in_a_locked_project::postgres failed with "init needs pg_dump 18 or newer". None of these commits change test.yml or dump_from's candidates, so the engine job will fail again. There is also one minor suggestion inline.

Reviewed changes

This run reviewed the four commits since 5a7551b.

  • Kept a lock's text after a backslash-escaped quote: canonical now reads each '…' and "…" string both ways, with and without backslash escapes. Where the two readings end the string in different places, it keeps everything after the string as written. The new tests cover MySQL's \', Postgres's E'…' and a double-quoted MySQL string.
  • Keyed sessions by their cache directory: project.cache is now part of both Session.for's key and the mapper's configIdentity, so a change to dir reaches a running process.
  • Built the binaries on engine/migrate/ changes: added engine/migrate/** to binaries.yml's paths, and updated AGENTS.md to match.
  • Renamed the old cache in the remaining places: replaced the leftover .reylo/ mentions in lab/bundlers, lab/jest and engine/native/src/sqlite.rs with reylo/.cache/.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/migrate/src/lock.rs Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues. The pg_dump 18 fix works as intended. One earlier minor thread is still open, on canonical at engine/migrate/src/lock.rs:88.

Reviewed changes

This run reviewed the one commit since 991d15a.

  • Put pg_dump 18 first on the engine job's path: the step now adds /usr/lib/postgresql/18/bin to $GITHUB_PATH and checks that binary's version. dump_from runs pg_dump by name, so cargo test now finds 18 before pg_wrapper, which would pick the runner's version 16. This resolves the earlier test.yml:271 thread. CI on this commit was still running when this review was written.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No blocking issues. Both commits do what they set out to do, and the earlier canonical thread is resolved. One minor gap of the same kind is noted inline.

Reviewed changes

This run reviewed the two commits since 507d64e.

  • Kept text in a lock that MySQL runs: canonical now keeps /*! … */ as written. It also keeps the rest of the text as written after a -- followed by anything other than a space or control character, and after a block comment that Postgres's nesting and MySQL's or SQLite's first */ end at different places. New tests cover each case.
  • Looked for pg_dump in Debian's versioned directory: dump_from now tries /usr/lib/postgresql/{major}/bin/pg_dump after Homebrew's keg and before PATH's pg_dump. This means a Debian or Ubuntu machine whose pg_wrapper picks an older local cluster still finds a matching pg_dump.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/migrate/src/lock.rs Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this commit, and the earlier # thread on canonical is resolved. This is not an approval, because the migrate engine as a whole still hasn't been reviewed (see below).

Reviewed changes

This run reviewed the one commit since 7866adc.

  • Kept a lock's text after a # or an unended comment: outside quoted text, canonical now keeps everything after a # as written. MySQL reads # as a comment and Postgres reads it as an operator. It also keeps everything after a /* that has no */ as written, where it used to drop it. I compiled the new canonical on its own and checked the cases in the new tests, plus '#' inside a string, Postgres's #>>, /*/ … */ and /*! … */. Each one gave the expected output.

ℹ️ The migrate engine still hasn't been reviewed in depth

The first review's note still applies. No reviewer has yet checked these parts of the migrate engine:

  • whether the diff engines (diff/postgres.rs, diff/mysql.rs, diff/sqlite.rs) can plan data loss that --drop doesn't catch, including the rename heuristic;
  • whether strict apply in commands.rs keeps its promises;
  • the statement splitter in sql.rs;
  • whether the replay cache in cache.rs can ever change an outcome.

Every incremental run since then has reviewed only its own delta.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

prod builds no shadow database: it trusts the fingerprint its last run
stored, and refuses a database that no longer has it. check is what
replays, and dev and reset are development's. The shadow flags, restore
and migrate.post are gone. A rename create guesses that changes more than
a name is refused, with one command for each way of settling them all.
Runs print one aligned row per migration.
The page leads with schema.sql, shows each change as a diff, gives every
migrate key with its default, and documents the CLI as help text with one
heading per command. The help fits one line per command, and suggested
commands print several flags one to a line.
A flag's value after an = is its value, and what a command doesn't take
is refused. A # in a password stays in the credentials, for shadow
databases and headings alike. reset refuses before it drops. dev stores
the fingerprint of the replay, and a failure's leftovers are seen by the
next prod. The fingerprint leaves out only a child's own lines. Commands
are quoted for the shell, and a child's column follows its parent's
rename. MySQL keeps its reserved words quoted.
@pullfrog pullfrog Bot left a comment •
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The split into prod and dev reads well, and the fixes in 740acc9 hold up. One path in dev drops data the user never asked to drop: a draft migration that is simply broken makes dev undo what sync made, rows and all, before it fails anyway. There are two smaller points below. They are in the body because GitHub can't anchor comments in engine/migrate/src/commands. 10BC8 rs, which is too large for its diff view.

Reviewed changes

This run reviewed the three commits since 75c83fb. It skipped what the two chore: merge main commits brought in.

  • Split apply into prod and dev: prod builds no shadow database and trusts the fingerprint it stored. dev, reset and wipe are separate commands now, each refused under CI. apply, --shadows/--no-shadows, restore and migrate.post are gone.
  • Refused flags a command doesn't take: the CLI checks the command name before parsing. It refuses any switch, --drop/--rename/--schema/--file or extra word that the command doesn't take, and it accepts --flag=value.
  • Made prod's fingerprint survive a partial failure: where the next migration could leave part of itself (on MySQL, or a statement that runs alone), the fingerprint is read before it runs. The fingerprint also now records whether comments were compared.
  • Changed how dev and reset record their state: dev now stores the fingerprint of a shadow replay of the migrations, not of the dev database. reset checks the lock against the history from before the wipe, then runs through dev_on.
  • Rewrote catalog::of_table: it now reads where each kind of line names its table, so a view or function that only mentions a table no longer counts as part of it.
  • Diff engine: child tables' columns follow a parent column's rename without a statement of their own, and quoted column names are kept as written.
  • URLs: with_database, database_of and shown_url now handle a password that holds # or @.
  • Docs: migrations.mdx is rewritten as migrate.mdx (/migrate), with the CLI help, the skill and AGENTS.md updated to match.

⚠️ dev drops what sync made when a draft migration is broken on its own

At commands.rs:2757-2796, runs is false whenever a pending migration fails on the probe shadow, including when the migration is just broken (a typo in a backfill). dev then runs take_to anyway, which drops every change no migration made, with its rows. The real run at 2806-2811 then fails on the same error. One typo in a draft costs the user their sync-made tables and the data in them, and nothing is gained.

Technical details
# dev undoes unrecorded changes even when the pending migration is broken on its own

## Affected sites
- engine/migrate/src/commands.rs:2757-2775 — the probe sets `runs = false` for any failure of `run(shadows.db(lane), m, false)`. That includes a migration that fails on any schema, not only one that conflicts with the extra changes.
- engine/migrate/src/commands.rs:2776-2796 — `take_to(db, …, &expect, …)` then drops everything outside `kept + records`, and `dropped_row` reports it after the fact.
- engine/migrate/src/commands.rs:2806-2811 — the real `run` of the same migration then fails, so the run ends with exit 1 and the data already gone.

## Required outcome
- `dev` undoes changes no migration made only when the pending migrations would run without them. Where they fail on a clean replay of `kept + records` too, it reports the migration's own error and changes nothing.
- A scenario test: a `sync`-made table holding rows, plus a draft whose data statement fails. `dev` exits 1, and the table and its rows are still there.

## Suggested approach (optional)
- Before the `take_to`, replay `kept + records + pending` on a fresh lane (`shadows.lines`). If that fails, `return fail("<migration> fails when replayed in a shadow database", e, log)` before touching the target.

ℹ️ prod's drift guard lapses whenever the stored fingerprint can't be compared

When there is no fingerprint to compare, prod (commands.rs:1097-1111) only logs a dim note. It then runs whatever is pending, and stores a fresh fingerprint over whatever drift exists. The tests make clear this is intended (prod_goes_on_where_it_has_no_fingerprint_to_compare). But it applies after any minor server upgrade, because server_version_num is part of the prefix how compares. It also applies after a change to catalog.rs, and after a run cut off between two migrations: insert_row writes fingerprint as NULL, and set_fingerprint only runs after the loop. migrate.mdx:310 says only that prod "refuses a database that no longer has it".

Technical details
# prod's drift guard lapses whenever the stored fingerprint can't be compared

## Affected sites
- engine/migrate/src/commands.rs:1097-1111 — `unchanged == None` logs, then runs or re-stores without any check
- engine/migrate/src/commands.rs:1150-1157 — the fingerprint is stored once, after every pending migration. A process killed after migration 1 of 3 leaves the last applied row with `fingerprint = NULL`, so the next run is unchecked.
- engine/migrate/src/commands.rs:761-762 — `how` compares `code_id:server_version:comments`, so a minor upgrade on a managed host (18.1 → 18.2) makes every stored fingerprint uncomparable
- site/content/docs/migrate.mdx:310 — states the guard without these exceptions

## Required outcome
- A cut-off run doesn't leave the next `prod` unchecked. For example, store the fingerprint on each migration's row as it finishes, or carry the previous one forward.
- The docs say when `prod` can't compare, what it does then, and that `check` is the way to verify after a Reylo or server upgrade.

## Open questions for the human
- After an upgrade with migrations pending, should `prod` refuse and point to `check`/`sync-history`, rather than run unchecked? That costs friction on every minor upgrade, which is presumably why it goes on now.

ℹ️ A migrations directory that doesn't exist makes dev rewind everything

At commands.rs:107, read_migrations returns no migrations for a directory that doesn't exist. dev then counts agree = 0 and rewinds every applied migration, dropping their tables and rows. reylo migrate dev --dir reylo-typo does this, and so does --dir --watch, where --dir takes --watch as its value. In a locked project the lock at the typo'd path is missing too, so dev_lock_problems returns early and nothing refuses. An empty directory can legitimately mean "no migrations on this branch". A directory that doesn't exist, on a database with applied history, is almost certainly a mistake, and dev (and reset) could refuse it.

dev undoes changes no migration made only where the pending migrations
would run without them. prod stores the fingerprint on each migration's
row as it finishes, and compares the server's major version alone. dev,
reset and sync-history refuse a migrations directory that isn't there.
reset drops everything in the database and runs nothing; dev runs the
migrations after it. pull writes each migration a database ran back into
its file, as it ran. create says what it wrote, and the help lists the
commands and the flags apart.
A terminal block holds a prompt, the command and what it printed,
coloured as reylo colours it. A diff's lines carry a + or - of their own.
@colinhacks
Copy link
Copy Markdown
Owner Author

Replying to the review of 769a371, all fixed in 864f37e:

  • dev dropping what sync made for a broken draft: before it undoes anything, dev now replays the kept migrations, the recorded ones and the pending ones on a clean shadow. If a migration fails there too, dev reports that error and changes nothing. Scenario: a_broken_draft_keeps_what_sync_made.
  • prod's check lapsing: the fingerprint is now stored on each migration's row as it finishes, so a run killed between migrations is still compared (a_run_cut_off_between_migrations_is_still_compared). It now records the server's major version only, so a minor upgrade keeps it comparable. The docs now say when it can't be compared. The open question stays as built: after a new version of Reylo or a new major version of the server, prod runs, says so, and stores a new fingerprint, with reylo migrate check as the way to verify.
  • A migrations directory that doesn't exist: dev and sync-history refuse it on a database that has run migrations (a_migrations_directory_that_isnt_there_is_refused). reset no longer reads migrations (e0e86f2), so it doesn't apply there. --dir --watch now fails with "--dir needs a value".

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The new commits address all three concerns from the last review. One new gap: reylo migrate pull is now the fix every lock refusal names, but pull treats a file as matching under a looser comparison than the lock uses. For a migration that was only reformatted, pull reports success, changes nothing, and the refusal stays. This finding is in the body because GitHub can't anchor comments in engine/migrate/src/commands.rs.

Reviewed changes

This run reviewed the three commits since 769a371. It skipped what chore: merge main brought in.

  • Kept what sync made when a draft is broken: dev first replays kept + records + pending on a clean shadow lane. If that fails, it refuses before it undoes anything. This resolves the earlier ⚠️.
  • Stored prod's fingerprint per migration: each row gets the fingerprint as its migration finishes. A run that is cut off, or a later migration that leaves part of itself, is now still compared. The version in the fingerprint is the server's major version, and the docs now say when no comparison is possible.
  • Refused a migrations directory that doesn't exist: dev and sync-history refuse a directory that isn't there when the database has applied history. The CLI refuses a flag value that starts with --, so --dir --watch no longer works as a typo.
  • Made reset only drop: reset now wipes the database, history included, and runs nothing. Every hint now says reylo migrate reset followed by reylo migrate dev.
  • Added reylo migrate pull: it writes each applied migration's text from the history table back into its file, and every Missing or Edited refusal now names it as the fix.
  • Docs: commands in migrate.mdx are shown with their output in a new ```terminal grammar (site/lib/terminal.ts), with +/- gutters for diffs. Migration file names are no longer analyzed as statements. The skill is updated for reset and pull.

⚠️ pull doesn't fix the "changed after it was locked" refusal that names it

The lock calls a file Edited when lock::canonical of its text differs from what was locked. canonical drops comments and collapses runs of whitespace, but it keeps whether whitespace is there at all: (id int) and ( id int ) read as different. It also keeps text after #, /*! and --x as written. pull skips any file where same_statements holds, and that compares tokens with all whitespace and comments removed. So if a formatter reflows a locked migration, check and prod refuse it with "Take back the text a database ran: reylo migrate pull". Running pull then prints ✓ … holds every migration this database ran, as it ran it, writes nothing, and the next check refuses again.

Technical details
# pull's "already the same" test is looser than the lock's Edited test

## Affected sites
- engine/migrate/src/commands.rs:1564 — `pull` skips a file when `same_statements(&f.sql, &r.sql, d)`, a token comparison with every comment and all whitespace removed (`sql.rs` `Statement.tokens`)
- engine/migrate/src/commands.rs:586-607 — `links` marks `Link::Edited` when `lock::checksum(prev, name, normalized(&f.sql))` differs, i.e. over `lock::canonical`, which keeps single spaces between tokens and keeps text as written after `#`, `/*!`, `--x`, ambiguous block comments and escaped quotes (engine/migrate/src/lock.rs:85-195)
- engine/migrate/src/commands.rs:638 and :2586 — the `Link::Edited` refusals that now name `reylo migrate pull` as the fix

## Required outcome
- After `pull` from a database that ran a locked migration, that migration's lock link is `Ok` whenever the database ran the locked text. In particular a whitespace-only reflow (`create table a (id int);` → `create table a (\n  id int\n);`) is written back.
- A scenario: lock a migration, reformat its file, see `check` refuse with `Edited`, run `pull`, and see `check` pass.

## Suggested approach (optional)
- In `pull`, leave a file as it is only when `lock::canonical(&normalized(&f.sql)) == lock::canonical(&normalized(&r.sql))`, the lock's own notion of "the same", rather than `same_statements`. A file that differs only in comments `canonical` drops is still left alone, as the doc comment says.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

init comes first, dev and prod share one heading, and so do lock and
unlock. The help follows that order, and paints its headings, commands
and flags, in a terminal and on the page.
@colinhacks
Copy link
Copy Markdown
Owner Author

Replying to the review of 95241c0: fixed in aa18bb6. pull now compares a file with what ran using the lock's own reading (lock::canonical), so a reformatted migration the lock refuses as edited is written back, and a comment-only change stays. Scenario: pull_takes_back_what_the_lock_refuses.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this commit, just one minor docs suggestion inline. The pull and lock-comparison gap from the last review is still open: this commit changes nothing in engine/migrate/src/commands.rs beyond the visibility of paint.

Reviewed changes

This run reviewed the one commit since 95241c0.

  • Reordered the help: MIGRATE_USAGE now lists the commands in the order they are used: init first, prod after dev, then reset and wipe after check. The docs' copy matches, and tests/cli.test.ts now looks for it in a ```terminal block.
  • Painted the help in a terminal: painted_usage() makes headings bold, commands cyan, flags yellow and <…> values dim. It uses commands::paint, which is now pub and has a bold style. main() decides whether to colour before dispatch, so piped output stays plain, and a unit test checks that the unpainted output equals MIGRATE_USAGE. The regex covers every line of the help, including -h, --help and --rename <old>=<new>.
  • Reordered the docs page: the sections in migrate.mdx follow the help's order. dev and prod are now one section, and so are lock and unlock, which also holds the lock's YAML example. Each section now opens with a one-line summary. No link anywhere in the repository points to the anchors that went away.
  • Extended the terminal grammar: site/lib/terminal.ts colours a help block from its Usage: line onward, and colours file trees' branches and their # notes.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

No migration was written.
```

### `migrate`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other heading on this page is a command or a pair of commands (`lock` and `unlock`). But migrate isn't a subcommand: reylo migrate migrate doesn't exist, and a reader looking for dev or prod in the page's outline won't find either. The pair form you already use for the lock would keep both names visible.

Suggested change
### `migrate`
### `dev` and `prod`

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Left as it is for now: this heading is deliberate, and what it should be depends on an open decision about whether the other commands stay under reylo migrate.

@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues. This commit fixes the gap between pull and the lock that the review of 95241c0 raised. One earlier minor thread is still open: the migrate heading in site/content/docs/migrate.mdx:207, which this commit doesn't change.

Reviewed changes

This run reviewed the one commit since c60a61a.

  • Made pull compare files the way the lock does: pull now leaves a file as it is only when lock::canonical(&normalized(…)) of the file and of the history row agree. links checksums that same reading of the file, so any file the lock marks Edited that the database ran is now written back. A reflow by a formatter gets written over, and a file that only gained a comment stays as it is. The doc comment says so, and the now-unused dialect() call is gone.
  • Added pull_takes_back_what_the_lock_refuses: in a strict project, the test reformats a locked migration. It checks that check refuses it and names reylo migrate pull, that pull writes the original text back, and that check then passes. It also checks that a file whose only change is a leading comment is left as it is and still passes.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

migrate.lock takes true or false, as reylo migrate lock and unlock set it,
in place of migrate.history. The database is the type check's connection,
so migrate.url is gone, and comments are always schema, so strict_comments
is too. init adds the section the other commands need, and on an empty
database writes only schema.sql. The page opens on what it does, then
Basic usage.
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The config cleanup holds together: Scope is gone everywhere it was used, old keys are now a parse error, and the docs and the skill match. One new path leaves a project stuck. If init fails after it has written its files, the migrate section is never added, so every command says to run init, and init refuses because schema.sql exists.

Reviewed changes

This run reviewed ce95354, the one commit since aa18bb6. It skipped what chore: merge main brought in.

  • Made the lock a boolean: migrate.lock: true | false replaces history: linear | loose. unlock writes lock: false and lock writes lock: true. with_lock now only sets or inserts the line, so the "empty section becomes migrate: {}" path is gone.
  • Dropped migrate.url and strict_comments: the database is --url, else the type check's direct_url, else DATABASE_URL. Comments are always compared. Scope is removed from the catalog, the replay cache key and the fingerprint, which is now code_id:version:digest. Fingerprints stored in the old format can't be compared, so the first prod after the upgrade runs unchecked, as for any change to catalog.rs.
  • Made init start a project: init runs without a migrate section, and on success the CLI appends migrate:\n lock: true to .reylo.yaml, creating the file if needed. On a database with no schema (holds_a_schema, now shared with check) it writes only an empty schema.sql, and the first migration is create's. Scenario and CLI tests cover both paths, on every dialect.
  • Docs: migrate.mdx now opens with a feature list and "Basic usage" starting from init. The configuration sections and the skill list the new keys.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/cli/src/main.rs Resolved
Dropping a table where foreign keys are on deletes its rows first, which
fires the actions of the tables that reference it. A rebuild now rebuilds
those tables too, at any depth, referencing the new one, so the file keeps
every row on whatever runs it, with no pragma of its own.
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

Rebuilding the tables that reference a parent along with it is the right fix, and it keeps the file free of pragmas. But the tables are copied and dropped in breadth-first order, which is not a dependency order. A common schema shape (users, posts(user_id), comments(user_id, post_id)) still fails with FOREIGN KEY constraint failed when foreign keys are on, so the module doc's promise ("keeps every row on whatever runs it, foreign keys on or off") doesn't hold yet. Details inline.

Reviewed changes

This run reviewed a61e6ce, the one commit since 750c421.

  • Rebuilt referencing tables with their parent: when SQLite rebuilds a table, alter_table now also rebuilds every table that references it, at any depth (children_of). Each one is rebuilt from its current text, with its REFERENCES pointing at the scratch tables. Rows are copied parents first, the old tables are dropped children first, and every table is then renamed back. So with foreign keys on, no drop fires a cascade or a set null on rows that still matter.
  • Ordered what a rebuild takes down across tables: each goes entry now carries its position in the schema. That lets the indexes, views and triggers of the whole family drop and come back in schema order, each once.
  • Gave the alterer both schemas: the new Alterer::schemas(from, to) hook is called before decide. The SQLite alterer keeps both schemas so a table's statements can reach the tables that reference it.
  • Wrote a self-reference to the scratch table: a self-referencing table's rebuild now points at new_<name>, which the rename turns back into its own name (sl26_self_reference_rebuild).
  • Tested it under foreign keys: a_rebuild_keeps_every_row_where_foreign_keys_are_on runs a generated file on a connection with foreign keys on, inside a transaction. It covers cascade, set null, no action, a second level and a self-reference, and checks row counts, foreign_key_check, the AUTOINCREMENT counter and a view.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Comment thread engine/migrate/src/diff/sqlite.rs Resolved
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues. This commit fixes the init dead end from the review of 750c421, and that thread is resolved. This review doesn't approve, because the earlier ⚠️ thread on children_of in engine/migrate/src/diff/sqlite.rs is still open. The SQLite family is still copied and dropped breadth-first, and this commit doesn't change that.

Reviewed changes

This run reviewed ffffc4f, the one commit since 083d158.

  • Added the migrate section whenever init wrote the files: the CLI now calls add_migrate_section whenever opts.schema exists after reylo_migrate::init returns, whatever the result. Before, it only did so on Ok(0). If sync_history fails after the files are written, the project now has its section, so reylo migrate sync-history can finish the job. add_migrate_section does nothing when a section is already there, so an init refused because schema.sql exists doesn't add a second migrate: key.
  • Tested a stranded project: tests/cli.test.ts writes an empty reylo/schema.sql with no section and checks that init exits 1 with "schema.sql exists already". It also checks that init still adds migrate:\n lock: true\n and that check --offline no longer says there's no migrate section.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

Each table is filled after, and dropped before, the tables it references,
so a table that references the rebuilt one and a sibling keeps its rows
with foreign keys on. Tables that reference each other have no such order,
and defer their keys to the commit.
@pullfrog pullfrog Bot left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found. This commit fixes the ordering of the SQLite rebuild that the review of a61e6ce raised, and that thread is resolved. This review isn't an approval only because the earlier thread on the ### \migrate`heading insite/content/docs/migrate.mdx` is still open, and the author has said it waits on a decision.

Reviewed changes

This run reviewed 629ad11, the one commit since ffffc4f.

  • Ordered the rebuilt family by its references: children_of still finds the tables that reference the rebuilt one breadth-first. It then places each table only once every family table it references is placed, so rows are copied after what they reference and old tables are dropped before it. A table's reference to itself is never one of its deps, so it can't block placement.
  • Deferred the keys of tables that reference each other: when no order exists, either among the children or through the rebuilt table itself, the file now adds pragma defer_foreign_keys = on with a comment. The pragma lasts until the transaction commits. Reylo's own runner wraps each SQLite migration in begin, and the replay cache's session key reads defer_foreign_keys only after the commit has reset it.
  • Tested both shapes with foreign keys on: a_links references the parent and also kids, a sibling that sorts after it. That is the shape the earlier thread reproduced. a_rebuild_keeps_tables_that_reference_each_other runs a two-table cycle with on delete cascade. It checks foreign_key_check, that both references survive, and that a later dev and dry run come out clean.

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

This branch has not been deployed

No deployments
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.

2 participants

0