Skip to content

Migrations

The autodetector diffs ModelMeta snapshots. The executor applies JSON files through siderite_orm::Db. Call the commands from the application binary so makemigrations sees compiled metadata — AppCli is the usual entry point. See CLI.

Terminal window
cargo run -p blog_postgres -- makemigrations
cargo run -p blog_postgres -- migrate
cargo run -p blog_postgres -- showmigrations
Command What it does
makemigrations [--name SLUG] [--empty] [--dry-run] Diff compiled models against the graph and write migrations/NNNN_slug.json. Never connects to a database
migrate [TARGET] [--dry-run] Apply unapplied migrations up to TARGET
rollback --steps N [--dry-run] Unapply the last N applied migrations
rollback TARGET [--dry-run] Unapply everything after TARGET (TARGET stays applied)
showmigrations [X] applied / [ ] pending, in graph order
squashmigrations FROM TO [--name SLUG] Collapse a contiguous range into one migration with replaces

The standalone siderite binary runs migrate, rollback, showmigrations, and squashmigrations from JSON files plus --database-url. It cannot see your models, so it cannot run makemigrations. --dry-run prints SQL (or operations) and executes nothing. migrate and rollback take a backend lock before re-reading history (PostgreSQL pg_advisory_lock, MySQL GET_LOCK, SQLite BEGIN IMMEDIATE) so concurrent replicas cannot double-apply. Waiting is bounded by --lock-timeout-ms (default 30 s); on MigrationError::LockTimeout the command prints the holding session (Migrator::lock_holder()). A run whose lock release cannot be confirmed fails with MigrationError::LockReleaseUncertain, listing the migrations it applied or unapplied.

MongoDB and Redis return MigrationError::UnsupportedBackend before I/O.

Pretty-printed JSON, one file per migration, named {id}.json:

{
"id": "0001_initial",
"dependencies": [],
"operations": [
{ "op": "create_model", "model": { "name": "Author", "table": "authors", "fields": [] } }
],
"atomic": true,
"checksum": "cbf29ce484222325"
}
  • id — zero-padded sequence (at least four digits) plus a slug.
  • dependencies — ids that must be applied first. The graph must be a single-headed DAG: missing deps, cycles, and multiple leaves are errors.
  • checksum — 16-digit hex FNV-1a of the canonical JSON of id, dependencies, operations, atomic, and replaces. An applied migration whose file no longer matches the history row is refused.
  • replaces — set by squash. The loader hides replaced ids and treats the squash as applied when every replaced id is in the history table.
  • atomic — wrap the migration in db.transaction when the backend supports transactional DDL (PostgreSQL and SQLite). MySQL does not; see below. false on PostgreSQL runs it without a transaction (for CREATE INDEX CONCURRENTLY), which also makes it resumable — see PostgreSQL.

RunRust stores a registered name, not a function. Register implementations at runtime with MigrationRegistry before Migrator::migrate.

Operation Reverse Notes
CreateModel DeleteModel Fields, named indexes, table constraints. Auto M2M join tables are ordinary models
DeleteModel CreateModel from the before-state Never assumed empty
AddField RemoveField
RemoveField AddField from the before-state
AlterField AlterField with the old snapshot
RenameField swapped names Only when hinted (RenameHints::rename_field). An unhinted same-shape remove+add is refused; RenameHints::allow_drop_field approves an intentional drop
RenameModel swapped names Only when hinted (RenameHints::rename_model). An unhinted same-shape delete+create is refused; RenameHints::allow_drop_model approves an intentional drop
CreateIndex / DeleteIndex each other
AddConstraint / DeleteConstraint each other
RunSQL RunSQL of reverse_sql Irreversible if reverse_sql is omitted
RunRust the backwards registered name Irreversible if backwards is omitted

Rollback refuses irreversible migrations instead of guessing. Creates are topological by foreign-key dependencies (cyclic FKs are deferred as AddField). Deletes run in reverse dependency order. Unmanaged models (managed: false) are ignored.

Identifiers are double-quoted on PostgreSQL and SQLite (embedded quotes doubled) and backtick-quoted on MySQL. Bind parameters are used for history-table DML; developer-authored RunSQL / CHECK expressions are copied as written.

Family PostgreSQL SQLite
Auto i64 PK BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY INTEGER PRIMARY KEY AUTOINCREMENT
Auto i32 PK INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY INTEGER PRIMARY KEY AUTOINCREMENT
VARCHAR(n) when max_length is set same
NUMERIC(p,s) when max_digits / decimal_places are set same
JSON jsonb TEXT
UUID UUID TEXT
Timestamp TIMESTAMPTZ TEXT
Date / time DATE / TIME TEXT
Bytes BYTEA BLOB
Bool BOOLEAN INTEGER
Duration BIGINT BIGINT
IP address TEXT TEXT

ON DELETE: CASCADE, RESTRICT (Protect), SET NULL, SET DEFAULT, NO ACTION (DoNothing).

DbDefault::Now → CURRENT_TIMESTAMP on PostgreSQL; on SQLite (strftime('%Y-%m-%dT%H:%M:%fZ','now')).

PostgreSQL DDL is transactional, so a migration runs in db.transaction by default and a failure rolls the whole migration back.

Setting "atomic": false opts out — which is what CREATE INDEX CONCURRENTLY needs, since PostgreSQL refuses to run it inside a transaction block. Outside a transaction every statement autocommits, so a failure leaves its earlier statements applied and no history row, exactly like MySQL:

  • The executor records progress per statement (siderite_migration_progress), so migrate again resumes at the first statement that did not commit instead of replaying the committed ones — including inside one operation, since a CreateModel with indexes renders several statements. The error names the statement that failed (2 of 3, …), or the operation when a RunRust step fails after earlier statements committed.
  • Repair by hand, then re-run: fix what the failed statement left behind (for example drop a half-created table, or free a taken index name) and the resume starts at the statement that failed. To restart from scratch instead, roll the applied part back by hand and delete its progress row (DELETE FROM siderite_migration_progress WHERE migration_id = '…'). Prefer several small atomic: false migrations to one large one.
  • The progress row stores the migration checksum: if the file changed after a partial run, migrate refuses to resume.
  • Progress is written right after each statement commits, and no savepoint spans the two. If the process dies in between, the re-run replays that statement and fails loudly. Confirm its effect is present, advance the row by hand (stmt_index + 1, or op_index + 1, stmt_index = 0 for a RunRust) and re-run. Keep RunRust code idempotent.
  • rollback is the same in reverse.

The flag changes nothing on SQLite (the run is the lock’s transaction) or on MySQL (which never wraps DDL regardless).

SQLite cannot DROP COLUMN portably, cannot ALTER COLUMN, and cannot add or drop table constraints. For AlterField, RemoveField, AddConstraint, and DeleteConstraint the editor rebuilds the table (create __siderite_new, copy, drop, rename, recreate indexes).

PRAGMA foreign_keys is a no-op inside a transaction, so the migrator turns it off on the dedicated connection before BEGIN, runs PRAGMA foreign_key_check before commit, and restores the previous value afterwards. Child rows with ON DELETE CASCADE survive a rebuild of the parent table. The check fails only on violations the migration introduced (diffed row by row against a baseline taken at open, so repairing one violation while adding another still fails), so pre-existing violations in unrelated tables do not block it; when foreign keys were already off the check is skipped.

RunRust data code on SQLite runs on that same connection with foreign keys off: ON DELETE CASCADE does nothing there, and orphan rows can be written. Keep data migrations FK-clean by hand (delete children before parents, insert parents first) — the pre-commit check rejects newly created orphans and names the offending table and row. Simple AddField uses ALTER TABLE ... ADD COLUMN. Adding UNIQUE / PRIMARY KEY on SQLite also rebuilds.

A migrate / rollback run on SQLite is a single transaction (BEGIN IMMEDIATE doubles as the concurrency lock), so it is all-or-nothing: a failing migration rolls back the earlier ones in the same run. The per-migration atomic flag has no effect on SQLite, so statements SQLite refuses inside a transaction (VACUUM, PRAGMA journal_mode) cannot appear in a SQLite migration. migrate and rollback also refuse a SQLite Db that is already inside a transaction.

The editor targets MySQL 8.0.31+.

Every DDL statement in MySQL commits implicitly, so the executor does not wrap MySQL migrations in a transaction, whatever atomic says.

  • A migration that fails half way leaves its earlier statements applied, and no row is written to the history table. The executor records progress per statement (siderite_migration_progress), so running migrate again resumes at the first statement that did not commit instead of replaying the ones that did — including inside one operation, since a CreateModel with indexes renders several statements. The error names the statement that failed (2 of 3, …), or the operation when a RunRust step fails after committed statements (earlier DDL, or earlier data written by another RunRust).
  • Repair by hand, then re-run: fix what the failed statement left behind (for example drop a half-created table, or free a taken index name) and the resume starts at the statement that failed. To restart a migration from scratch instead, roll its applied part back by hand and delete its progress row (DELETE FROM siderite_migration_progress WHERE migration_id = '…'). Keep MySQL migrations small — ideally one schema change each. --dry-run shows the SQL first.
  • The progress row stores the migration checksum: if the file changed after a partial run, migrate refuses to resume. Restore the original file, or reconcile the schema by hand and delete the progress row.
  • Progress is written right after each statement commits; MySQL cannot make the two atomic. If the process dies in between, the re-run replays that statement and fails loudly. Confirm its effect is present, advance the row by hand (stmt_index + 1, or op_index + 1, stmt_index = 0 for a RunRust) and re-run. Keep RunRust code idempotent on MySQL. Live tests kill the process right after DDL commits on MySQL and on non-atomic PostgreSQL: the migration stays unapplied, migrate stops with the database’s “already exists” error (or UncertainSqlStep for a RunSQL script) instead of replaying, and dropping the unrecorded object (plus clearing a verified script intent) lets the next migrate finish. Atomic PostgreSQL migrations roll back entirely in the same crash.
  • RunSQL statements are not rolled back either.

TEXT, BLOB, and JSON cannot be a primary key, UNIQUE, or indexed without a prefix length, and the editor never guesses one. A String field that is unique, indexed, or a primary key must set max_length:

#[field(max_length = 191, unique)]
pub email: String,

VARCHAR(191) keeps a utf8mb4 unique index within older key-length limits.

InnoDB rejects it. Use SET NULL, CASCADE, RESTRICT, or NO ACTION.

Topic MySQL
Quoting backticks
Auto primary key BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY
Column types TINYINT(1) bool, DATETIME(6) timestamps, TIME(6), CHAR(36) UUID, JSON, DOUBLE; a decimal without precision is DECIMAL(38,10)
DbDefault::Now CURRENT_TIMESTAMP(6)
Foreign keys table-level CONSTRAINT .. FOREIGN KEY, named <table>_<column>_fk
AlterField MODIFY COLUMN
Dropping DROP INDEX name ON table; unique constraints drop as indexes
CREATE TABLE IF NOT EXISTS "siderite_migrations" (
"id" TEXT PRIMARY KEY,
"checksum" TEXT NOT NULL,
"applied_at" TEXT NOT NULL
)

applied_at is UTC text (%Y-%m-%dT%H:%M:%S%.6fZ).

Automatic rename detection, MongoDB/Redis migrations, and data migrations beyond RunSQL / RunRust are not implemented.