.offrails/guides/migrations.md
Migrations (db/migrate → migration/ crate)
TL;DR
sea-orm-cli migrate generate <verb_noun>; write the migration and the entity together;cargo run -- db migrate.- Committed migrations are append-only (CI enforces it): change the schema with a new migration; a repair migration's
downrestores the predecessor's exact schema.- Ids and foreign keys are 64-bit:
big_pk_auto,big_integer(_null),i64in the entity. Neverinteger/pk_autofor anidor*_id(offrails-check[ids]).- Every migration has a working
down(prove rollback, then migrate, on both backends); every FK has an index and an expliciton_delete; NOT NULL by default.- Stick to SQLite-safe schema changes (rebuild tables for what SQLite's
ALTERcan't do); data backfills go insrc/tasks/.Read the rest when writing or repairing a migration, or changing a framework table.
Migrations are Rust, written with sea-orm-migration's schema DSL, which emits correct DDL for SQLite and Postgres. They live in the migration/ workspace crate (the standard SeaORM layout) and are embedded in the app binary, so deploying the binary deploys the migrations.
Commands
| Task | Command |
|---|---|
| New migration | sea-orm-cli migrate generate create_posts creates migration/src/m<timestamp>_create_posts.rs (its body is todo!(); replace it) and registers it in migration/src/lib.rs. Registration needs the multi-line vec![ … ] shape in lib.rs: after a generate, check that a Box::new(m…::Migration), line was added (new.md step 3) |
| Apply (any env) | cargo run -- db migrate (dev) / app db migrate (prod; also runs in the Docker entrypoint) |
| Status | app db status |
| Roll back last | app db rollback (--steps N) |
| Prove reversibility | app db rollback && app db migrate |
| Against a remote DB | DATABASE_URL=postgres://… cargo run -- db migrate (see deployment.md for each platform's one-liner) |
Template
Verified with sea-orm-migration 2.0.4 on SQLite and Postgres 18. The generated file names the migration with impl MigrationName for Migration { fn name(&self) -> &str { "m…" } }; #[derive(DeriveMigrationName)] is equivalent. Keep whichever the CLI wrote.
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait] // `async_trait` comes from sea_orm_migration's prelude; don't add the crate
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.create_table(
Table::create()
.table(Posts::Table)
.if_not_exists()
.col(big_pk_auto(Posts::Id))
.col(big_integer(Posts::AuthorId))
.col(string(Posts::Title))
.col(text(Posts::Body))
.col(string(Posts::Status).default("draft")
.check(Expr::col(Posts::Status).is_in(["draft", "published"])))
.col(timestamp_with_time_zone_null(Posts::PublishedAt))
.col(timestamp_with_time_zone(Posts::CreatedAt))
.col(timestamp_with_time_zone(Posts::UpdatedAt))
.foreign_key(
ForeignKey::create()
.from(Posts::Table, Posts::AuthorId)
.to(Users::Table, Users::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
).await?;
m.create_index(
Index::create().name("posts_author_id_idx").table(Posts::Table).col(Posts::AuthorId).to_owned(),
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.drop_table(Table::drop().table(Posts::Table).to_owned()).await
}
}
#[derive(DeriveIden)]
enum Posts { Table, Id, AuthorId, Title, Body, Status, PublishedAt, CreatedAt, UpdatedAt }
#[derive(DeriveIden)]
enum Users { Table, Id }Helper names come from sea_orm_migration::schema:
big_pk_auto,string,text,big_integer,integer,small_integer,boolean,json,uuid,date,timestamp_with_time_zone, …;*_nullfor nullable and*_uniqfor unique. Helpers areNOT NULLunless suffixed_null.
Chain modifiers onto them: string(X).default("draft").check(Expr::col(X).is_in([...])), json(X).default("{}"), string(X).primary_key() for natural keys.
Primary keys: big_pk_auto(T::Id). Verified DDL:
| Backend | id column |
|---|---|
| SQLite | "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT |
| Postgres | bigint generated by default as identity |
Entities use i64 for it.
Ids are 64-bit
Every id is big_pk_auto, and every *_id is big_integer / big_integer_null, with i64 (or Option<i64>) in the entity. Never integer, small_integer, pk_auto or .integer() for an id. SQLite's INTEGER is 64-bit whatever you write, so a SQLite-only suite passes; on Postgres the column is INT4, and every insert or query that returns an id fails to decode into i64: a 500 on every write. A peer's Kanban shipped exactly that, "green", because its tests never ran on Postgres. offrails-check fails the build on it ([ids]: a 32-bit id/*_id column in migration/src, or an i32 id field in src/), and on a tests/app/support.rs that never reads TEST_DATABASE_URL ([postgres]).
Repairing an applied migration (rule 1 below: never edit it): a new migration widen_ids alters each column to BIGINT on Postgres (SERIAL's sequence too) and does nothing on SQLite, whose INTEGER already holds an i64. Its down restores INTEGER. Then add // offrails-check: ids widened in m<timestamp>_widen_ids to each repaired migration file (a comment, so the file's behavior doesn't change); the check then skips that file and fails if the named migration doesn't exist.
// migration/src/m<timestamp>_widen_ids.rs: one (table, column) per 32-bit id; foreign keys first, then ids.
const COLUMNS: &[(&str, &str)] = &[("cards", "column_id"), ("cards", "board_id"), ("cards", "id"), ("boards", "id")];
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
if m.get_database_backend() != DbBackend::Postgres {
return Ok(()); // SQLite: INTEGER is already 64-bit
}
for (table, column) in COLUMNS {
m.alter_table(Table::alter().table(Alias::new(*table))
.modify_column(ColumnDef::new(Alias::new(*column)).big_integer()).to_owned()).await?;
}
// pk_auto made SERIAL columns: their sequences are INT4 too (portable SQL, Postgres branch only).
for table in ["cards", "boards"] {
m.get_connection().execute_unprepared(&format!("ALTER SEQUENCE IF EXISTS {table}_id_seq AS bigint")).await?;
}
Ok(())
}
// down: the same loops with .integer() and `AS integer`, also Postgres only.modify_column changes only the type (sea-query 1.0.2 emits ALTER COLUMN … TYPE bigint and nothing else); NOT NULL, defaults and foreign keys stay. Verified on Postgres 18 (the SQL, on serial tables with a foreign key and rows): types and constraints kept, and the next id after setval(…, 3000000000) inserts. The down fails once any id passes 2,147,483,647, which is correct. Prove it on Postgres: db rollback --steps 1 && db migrate, then the whole suite with TEST_DATABASE_URL. Entities change to i64 in the same slice, and the compiler lists every caller.
Verified portable:
jsoncolumns arejson_texton SQLite andjsonon Postgres.timestamp_with_time_zoneis text on SQLite andtimestamptzon Postgres. Both round-tripDateTimeUtc.- A unique index on a nullable column allows many
NULLs on both backends.
Rules
- Committed migrations are append-only. Never edit a migration that is on the main branch, has been deployed, or was run against any database you don't throw away (including other agents' and the human's dev databases): those databases never re-run it, so the edit silently never applies and the code then queries columns that don't exist ("no such column"). Change the schema with a new migration (
alter_table, per the table below). Only a migration that exists solely in your own uncommitted work may be edited. CI enforces it:git diff --name-only --diff-filter=M origin/main... -- 'migration/src/m*.rs'must print nothing. - Every migration has a working
down. Run rollback then migrate before you're done.- Repairing an earlier migration (rule 1 forbids editing it): write a new migration whose
upfixes the schema, and whosedownrestores the predecessor's exact schema, not "drop everything". Example: an earlycreate_mailbox_messageswas a stub, and a laterfix_mailbox_schemadrops and recreates the table with the right columns. Itsdownmust recreate the stub table (same columns, same indexes), so the nextdown(the stub's own) still finds a table to drop. If bothdowns drop the same table, a full rollback aborts midway ("table does not exist"), which breaks the DoD's rollback gate. - Test it:
db rollback --steps all, thendb migrate, on both backends (CI does).
- Repairing an earlier migration (rule 1 forbids editing it): write a new migration whose
- Write the migration and the entity in the same step. They must match.
- Every FK gets an index and an explicit
on_delete(Cascadefor owned children,Restrictfor references,SetNullfor optional). NOT NULLby default. A column is nullable only if the spec says the value can be absent.- Name indexes
<table>_<cols>_idx, and unique ones<table>_<cols>_key. - Case-insensitive uniqueness (emails): store lowercased in Rust and put a plain unique index on the column. It's portable, unlike
lower()expression indexes. - Data backfills go in
src/tasks/, not in migrations, unless trivial.
SQLite-safe schema changes (portability)
SQLite's ALTER TABLE is limited. To keep every migration portable:
| Change | Do this |
|---|---|
| Add column | alter_table with one add_column per statement. Nullable, or NOT NULL with a default |
| Drop column | Allowed (SQLite ≥ 3.35). One per statement. Drop its indexes first |
| Rename column | rename_column. One per statement |
| Change type / nullability / add FK to an existing table | Rebuild: create <table>_new, copy rows with INSERT … SELECT (via m.get_connection().execute_unprepared, the only sanctioned raw SQL in migrations, which must be portable SQL), drop the old table, rename the new one, recreate indexes. Do it in one migration |
| Concurrent index on a big Postgres table | Backend branch: if m.get_database_backend() == DbBackend::Postgres { … CONCURRENTLY … } else { normal create_index }. Log it in decisions |
Framework tables
Generators create these. Never hand-roll alternatives: users, sessions, password_resets (auth), jobs, job_queues, job_nodes, recurring_executions (jobs), mailbox_messages (dev mailbox), cache_entries (cache), broadcasts (realtime), blobs (storage).