๐ค Migrations
Your schema changes through numbered migrations that apply once each, at boot, recorded in a ledger. They are never re-run and never reversed.
This page is longer than most. Two of its rules will cost you data if you get them wrong, and both are easy to avoid once you have read them once.
The shape
Two *migrate.Set values live in internal/<app>/migrations.go, beside
models.go:
// Schema is your app's own migrations โ what generate and check diff
// against Models.
var Schema = migrate.MustFromFS(migrationFS, "notes")
// BootSchema is everything App() applies, in apply order.
var BootSchema = migrate.Merge(sessions.Schema, Schema)
migrate.Merge's argument order is apply order. That is how a package
which must run after another says so at the call site instead of in a
comment: auth after sessions, because auth's tables reference
sessions'.
Add a subsystem to BootSchema, never to Schema. Schema is what
migration check diffs against your models, so a subsystem's tables
there would look like tables your models do not know about, and check
would cheerfully propose dropping them.
App() applies the set once at boot:
if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
return nil, err
}
Changing a model
Edit the struct, then:
rastrillo migration generate
It diffs your models against your migrations and writes the delta as a
new numbered file. Read the SQL before you commit it. generate may
emit a full table rebuild instead of an ALTER TABLE, which is correct
โ SQLite's ALTER is limited โ but you want to have seen it.
Then keep rastrillo migration check in CI so the two cannot drift.
make ci in a scaffolded app already runs it.
Things generate cannot work out
A rename looks exactly like a drop plus an add to any tool comparing before and after. Write it yourself:
rastrillo migration new rename_title_to_heading
which gives you a numbered stub to fill in:
ALTER TABLE notes RENAME COLUMN title TO heading;
A change that drops data is refused unless you pass
--allow-destructive. The refusal prints the SQL it would have written,
so you can see what it thinks is destructive.
Never edit a shipped migration
Each ledger row records a checksum, and Apply refuses to boot when a
migration's SQL no longer matches what was recorded. Add a new migration
instead.
Reformatting is safe โ whitespace is ignored in that comparison. Changing what a migration does is not.
What Apply guarantees
Every migration runs in its own BEGIN IMMEDIATE transaction, with its
ledger row written inside that same transaction, on one pinned
connection.
Three useful properties follow, and they all matter on a platform that can SIGKILL a hibernating app at any moment. A wake killed mid-migration rolls back cleanly and the next wake retries from the same point. Progress survives across wakes, so a long set converges even if every wake is cut short. And two instances booting at once serialise onto the same migration: the loser blocks on the lock, re-checks the ledger, finds the row the winner just committed, and skips.
The single pinned connection is a correctness requirement rather than a
performance choice. PRAGMA foreign_keys is per-connection state, and
SQLite's twelve-step table rebuild has to toggle it outside the
transaction.
There is no Down
Migrations are forward-only. Production rollback for a CARLOS app is a
point-in-time restore of the SQLite file the activator replicates, so no
Down is offered.
Go migrations
migrate.Migration sets exactly one of SQL or Fn. Fn is the
escape hatch for a change SQL cannot express, and it runs on the same
pinned connection inside the same transaction, so a failure rolls its
writes back with the ledger row.
Do not reference your live model structs from a Go migration. A model changes over time, and would silently change the meaning of a migration that already ran. Copy the struct into the migration file.
The first deploy of a version with migrations must be schema-neutral
If your app is already deployed and you are adding migrations for the
first time, generate 0001_init from the models as already deployed and
ship it alone. Change a model only in a later release.
Otherwise boot refuses on the new column โ and baseline, the tool you
would reach for next, would strand that migration for good.
Recovering an old database
Boot refuses on a structural diff. You get there two ways: a real
database predates a subsystem's migrations, or a manifest resource's
generated gen/store/<name>/migrations.go got reshaped after first
boot, so its regenerated SQL no longer matches the ledger's checksum.
Either way the recovery is four steps, and their order is load-bearing:
- Note which migration the database already matches.
rastrillo migration baseline --db <path> --through <id>, stamping only up to that id.- Apply the missing migration by hand.
- Reboot. The remaining migrations run normally.
Why steps 2 and 3 are in that order
Do not tidy them the other way round. Stamp runs no DDL, so baseline
does not need the missing table to exist yet.
Here is what goes wrong if you create the table first. Between creating
it and stamping, the database structurally matches the full set with an
empty ledger โ and that is exactly the state Apply adopts, because
adoption is gated on the ledger being empty. On a hibernating platform
you do not choose when the app wakes. One inbound request in that window
adopts the schema, stamps the later migration without running it, and
strands every row it was supposed to backfill.
Stamping first makes the ledger non-empty, which closes that window for
good. The worst case then becomes a wake between steps 2 and 3, where
the later migration fails loudly with no such table and rolls back โ
and the reboot in step 4 still runs the backfill.
Bare baseline strands a migration
baseline with no --through stamps every migration, including the
ones that have not run. Any pending data migration is recorded as
applied without ever running, and the rows it would have backfilled are
stranded silently.
That is what bare baseline is for, and it is why the command is manual
and why the CLI will not apply migrations for you. Pass --through
unless you have specifically decided otherwise.
auth/store.go's package comment walks the whole thing with real
migration ids if you want to see it worked through.
Seeing what a database has applied
rastrillo migration status --db ./notes.db
Reports what the ledger records and any pending drift. A database that has never booted this version gets a plain "no ledger yet" line rather than an error.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.