Migrations That Survive a Team: the Wheels 4.0 Migrator #3215
bpamiri
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Next in the post-GA how-to series. We've done the model side, the AI surface, and the extension model — this one takes the migrator and answers the question that comes up the moment a second person clones the repo: how do you write migrations that don't fall apart on someone else's checkout against a shared dev database?
Read: https://blog.wheels.dev/posts/migrations-that-survive-a-team
This is a broad, worked how-to rather than a release post — write a migration, drive the table builder, seed data, run the CLI, reconcile drift. It covers:
Migration.cfcunderapp/migrator/migrations/withup()/down(), the 3–14-digit version filename regex (and the fact that non-matching files are silently ignored — no error, no listing, no run).createTable()returns an in-memoryTableDefinition. Chainingt.string(...),t.references(...),t.timestamps(...)runs no DDL. TheCREATE TABLEonly fires on the terminalt.create()(ort.change()for thechangeTable()path). Forget the terminal call and the migration "succeeds" with no table and still records itself applied — the single most common silent footgun in the whole subsystem.columnNames(plural), and most accept a comma-list (t.string(columnNames="title,slug")) to add several columns at once. The singularcolumnNameis an alias.primaryKey()is the deliberate exception: it never splits on commas, so a composite key means calling it once per column.timestamps()adds THREE columns — createdAt, updatedAt, AND deletedAt (the soft-delete marker), named from the framework's timestamp/soft-delete settings. The post is explicit about not hand-rolling those three.references()and the suffix flag — builds the FK column plus a constraint to the pluralized table's id, but the column name (authoridvsauthor_id) is governed byuseUnderscoreReferenceColumns, read at runtime per migration. Framework default isfalse;wheels newapps default it totrue. Same migration code, different column names across apps — worth knowing when you copy a migration between projects.execute()takes ONLY asqlstring. There is noparametersargument; a binding array would just be ignored. Use inline SQL withNOW()for portable dates, andaddRecord()/updateRecord()when you genuinely need parameterized inserts.up()/down()runs in its own transaction. On failure, that one rolls back and the loop stops, so you're never left half-applied with a tracking table that lies. "I ranmigrate latestand only some applied" almost always means an earlier one failed.migrate infoflags orphan tracking rows (a version with no matching file — the classic shared-DB case) with[?];migrate doctoris a pure-read health report (orphans, pending, applied, a one-line summary,healthyiff zero orphans and zero pending); andmigrate forget/pretend --yesreconcile, each with a guard that refuses the wrong move (forgetwon't run if a matching file exists — usemigrate downfor that;pretendwon't run if there's no file or it's already applied).The shared-database reconciliation story has its own deep treatment in the 4.0.2 release notes, so this post deliberately treats it as one section and points there for the full arc — here it's the get-started-and-stay-out-of-trouble version.
Discussion
A couple of things I'd genuinely like input on:
.create()) the right ergonomic tradeoff, or would an auto-commit-on-scope-exit feel safer for newcomers? The current design buys you one assembled statement with all columns and FKs, but the mandatory terminal call clearly surprises people.doctor/forget/pretendworkflow matched how your team actually hits drift, or are there scenarios it doesn't cover yet?Feedback on the post — what's unclear, what's missing, what you'd want a future migrator post to dig into — welcome in this thread.
Beta Was this translation helpful? Give feedback.
All reactions