Migrations are where a lot of Rust web projects quietly fall back to hand-written SQL. Umbral treats the managed loop as the product, not an add-on.
The loop
- Declare or change a model. An autodetected migration is generated.
migrateapplies all pending migrations to the database.- Update or delete a model. The diff produces the right
ALTER/DROP.
# 1. you changed a model — autodetect and write the migration
# 2. review the generated file (it's plain, readable JSON), then apply
Autodetection
The autodetector diffs your current models against the last migration snapshot and emits ordered, reversible operations: create/alter/drop table, add/alter/drop column. The common cases — a new model, a dropped model, an added or removed field — work on day one. The genuinely hard cases (rename vs. drop+add disambiguation, data-preserving alters) get surfaced rather than guessed.
Existing rows are the test, not an obstacle
A rule we hold hard: never wipe the database to bypass a migration. If a UNIQUE addition trips a duplicate, or a new NOT NULL column needs a backfill default, that failure is the bug you want to find. Deleting the database to "get a clean run" just hides it until production.
umbral makemigrations: rename detected (column-shape match): `body` → `content`
Porting an existing database
Already have a schema? inspectdb introspects it and generates models that feed straight back into the same managed loop:
From there you're in the normal declare → migrate cycle, with the full audit trail of how each column got its shape. Migration history is the schema's record — we never delete entries to "regenerate cleanly," because that makes older deploys un-migratable.
Cross-plugin foreign keys
Each plugin owns its own migrations. migrate walks every registered plugin, collects their migrations, orders them by a dependency graph — cross-plugin foreign keys included — and runs only those not yet recorded in the tracking table. The built-in auth, sessions, and tasks tables are created this exact way. Nothing is special-cased.