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.
cargo run -p blog_postgres -- makemigrationscargo run -p blog_postgres -- migratecargo 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.
File format
Section titled “File format”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, andreplaces. 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.transactionwhen the backend supports transactional DDL (PostgreSQL and SQLite). MySQL does not; see below.falseon PostgreSQL runs it without a transaction (forCREATE 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.
Operations and reversibility
Section titled “Operations and reversibility”| 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
Section titled “PostgreSQL”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), somigrateagain resumes at the first statement that did not commit instead of replaying the committed ones — including inside one operation, since aCreateModelwith indexes renders several statements. The error names the statement that failed (2 of 3, …), or the operation when aRunRuststep 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 smallatomic: falsemigrations to one large one. - The progress row stores the migration checksum: if the file changed after a
partial run,
migraterefuses 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, orop_index + 1, stmt_index = 0for aRunRust) and re-run. KeepRunRustcode idempotent. rollbackis 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 table rebuild
Section titled “SQLite table rebuild”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+.
DDL is not transactional
Section titled “DDL is not transactional”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 runningmigrateagain resumes at the first statement that did not commit instead of replaying the ones that did — including inside one operation, since aCreateModelwith indexes renders several statements. The error names the statement that failed (2 of 3, …), or the operation when aRunRuststep fails after committed statements (earlier DDL, or earlier data written by anotherRunRust). - 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-runshows the SQL first. - The progress row stores the migration checksum: if the file changed after
a partial run,
migraterefuses 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, orop_index + 1, stmt_index = 0for aRunRust) and re-run. KeepRunRustcode idempotent on MySQL. Live tests kill the process right after DDL commits on MySQL and on non-atomic PostgreSQL: the migration stays unapplied,migratestops with the database’s “already exists” error (orUncertainSqlStepfor aRunSQLscript) instead of replaying, and dropping the unrecorded object (plus clearing a verified script intent) lets the nextmigratefinish. Atomic PostgreSQL migrations roll back entirely in the same crash. RunSQLstatements are not rolled back either.
Keyed TEXT needs max_length
Section titled “Keyed TEXT needs max_length”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.
ON DELETE SET DEFAULT is unsupported
Section titled “ON DELETE SET DEFAULT is unsupported”InnoDB rejects it. Use SET NULL, CASCADE, RESTRICT, or NO ACTION.
Other differences
Section titled “Other differences”| 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 |
History table
Section titled “History table”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.