Data migrations (RunSql)
Hand-authored raw-SQL migrations that change rows, not the schema.
Most migrations are schema migrations - umbral auto-detects them from a model change (CreateTable, AddColumn, …). A data migration changes rows, not the schema: backfill a new column, normalise existing values, seed lookup rows. umbral ships this as Operation::RunSql, a hand-authored raw-SQL op.
A data migration is never auto-generated - makemigrations only emits schema diffs. You write it by hand, and because it has no schema effect, it never disturbs the model-snapshot chain (its snapshot_after equals the previous snapshot, so the next makemigrations still detects no change).
Authoring one: makemigrations --empty
Generate an empty migration for a plugin, then edit it to add a RunSql op:
Write the empty stub
cargo run -- makemigrations --empty app# Wrote migrations/app/0002_empty.json (empty)This writes the current snapshot with an empty operations list.
Add a RunSql op
Open the file and replace the empty operations array:
{ "id": "0002_empty", "plugin": "app", "operations": [ { "kind": "RunSql", "sql": "UPDATE post SET status = 'draft' WHERE status IS NULL", "reverse_sql": "UPDATE post SET status = NULL WHERE status = 'draft'" } ], "snapshot_after": { /* unchanged - carried forward verbatim */ }}sql is the forward statement, executed verbatim on both backends (you own portability). reverse_sql is optional - null means irreversible.
Apply it
cargo run -- migrateThe op runs inside the same per-migration transaction as the tracking-table insert, so it's atomic and applied exactly once. A re-run is a no-op (the tracking table guards it).
Under multitenancy: per-tenant-schema application
Under schema-per-tenant, a RunSql in a tenant app runs once per tenant schema, under search_path = "<schema>", public. That lets the data migration read shared public lookup tables while writing tenant rows - a boundary-spanning backfill:
{ "kind": "RunSql", "sql": "INSERT INTO subscription (plan_code, amount_cents) \ SELECT code, price_cents FROM public.plan", "reverse_sql": "DELETE FROM subscription"}subscription (bare) resolves to the current tenant schema; public.plan is the shared lookup. Roll it out with migrate_schemas. A RunSql in a shared app runs once in public via the normal migrate.
See also
- Managed migrations - the auto-detected schema loop.
- Multitenancy - boundary-spanning data migrations.
crates/umbral-core/src/migrate.rs(theOperation::RunSqlvariant) anddocs/specs/06-migration-engine.mdfor the design.