Runtime Migrations
Cadmus has two distinct migration pipelines:
- Schema migrations — plain
.sqlfiles incrates/core/migrations/, applied by SQLx’smigrate!macro at startup. Use these forCREATE TABLE,ALTER TABLE, and similar DDL changes. - Runtime migrations — Rust
async fnblocks declared with themigration!macro. Use these for one-time data operations: backfilling columns, importing legacy files, cleaning up obsolete rows, or any procedural work that goes beyond SQL DDL.
How runtime migrations work
flowchart TD
ctor["#[ctor] runs at process start"]
registry["Global REGISTRY HashMap<br>(migration id → async fn)"]
startup["Database::migrate() called on startup"]
schema["sqlx::migrate!() — applies .sql files"]
runner["MigrationRunner::run_all()"]
table["_cadmus_migrations table<br>(id, executed_at, status)"]
pending["Filter: id NOT IN already-succeeded rows"]
exec["Execute each pending migration in id order"]
record["Record success or failure in _cadmus_migrations"]
ctor --> registry
startup --> schema
schema --> runner
runner --> table
table --> pending
pending --> exec
exec --> record
At process start the #[ctor] attribute runs for every migration! call and
inserts the migration function into a global HashMap. When Database::migrate
is called during application startup, it first applies all pending SQL schema
migrations, then calls MigrationRunner::run_all(), which:
- Reads
_cadmus_migrationsand collects IDs that already succeeded. - Skips those; runs the remaining ones sorted by ID.
- Records each result (
successorfailed) before moving on. - Continues past failures so one broken migration does not block others.
A failed migration can be retried by deleting its tracking row (see Re-running a migration).
The migration! macro
cadmus_core::migration! takes a
stable string ID and an async fn definition:
#![allow(unused)]
fn main() {
cadmus_core::migration!(
/// One-line doc comment forwarded to rustdoc.
"v1_my_migration",
async fn my_migration(pool: &SqlitePool) {
sqlx::query!("UPDATE books SET title = TRIM(title)")
.execute(pool)
.await?;
Ok(())
}
);
}
The macro:
- Generates the
async fnwith the provided body. - Creates a public submodule named after the function that exposes a
MIGRATION_IDconstant — useful for tests and cross-references. - Registers the function in the global
REGISTRYvia#[ctor]so it runs automatically without any manual wiring. - Forwards doc comments onto the generated items so rustdoc picks them up.
- Appends the migration ID and a re-run SQL snippet to the generated docs.
Where to put migration code
Co-locate migrations with the feature they belong to. The library subsystem’s
migrations live in crates/core/src/library/migrations.rs; a hypothetical
reader subsystem would put its migrations in
crates/core/src/reader/migrations.rs.
The module only needs to be declared once so the #[ctor] registration runs.
There is no central registry file to update.
Writing a migration step by step
1. Choose a stable ID
The ID is the primary key in _cadmus_migrations. Once a migration has been
deployed it must never be renamed, because existing installations track it by
this string.
Convention: v<N>_<short_description>, for example v1_backfill_book_language.
2. Create the migration file (or add to an existing one)
#![allow(unused)]
fn main() {
// crates/core/src/my_feature/migrations.rs
use sqlx::SqlitePool;
cadmus_core::migration!(
/// Backfills the `language` column for books that were imported before
/// language detection was added.
"v1_backfill_book_language",
async fn backfill_book_language(pool: &SqlitePool) {
sqlx::query!(
"UPDATE books SET language = 'en' WHERE language = '' OR language IS NULL"
)
.execute(pool)
.await?;
Ok(())
}
);
}
3. Use SQLx typed macros
All SQL inside a migration must use the typed macros for compile-time verification (see SQLite & SQLx):
#![allow(unused)]
fn main() {
// ✅ Good — compile-time checked
sqlx::query!("DELETE FROM books WHERE file_path = ?", path)
.execute(pool)
.await?;
// ❌ Bad — untyped, bypasses verification
sqlx::query("DELETE FROM books WHERE file_path = ?")
.bind(path)
.execute(pool)
.await?;
}
4. Make it idempotent
Use INSERT OR IGNORE, ON CONFLICT DO NOTHING, or guard with a WHERE clause
so the migration is safe to re-run without corrupting data.
5. Regenerate .sqlx metadata
After adding or changing any query in the migration, regenerate the compile-time metadata:
cargo sqlx prepare --all --workspace
Commit the updated .sqlx/ files alongside your migration code.
Re-running a migration
Delete the tracking row, then restart the application:
DELETE FROM _cadmus_migrations WHERE id = 'v1_my_migration';
The next startup will treat the migration as pending and run it again.
API reference
cadmus_core::migration!— macro for declaring runtime migrationscadmus_core::db::migrations::MigrationRunner— executes all pending registered migrationscadmus_core::db::Database— owns the connection pool and orchestrates both migration stages