Guide / Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9
How to make a DB migration system in your favorite language?
In this guide
We'll study how to make a DB migration system in your favorite language. In this video, we'll use MoonBit as an example to reproduce Reinhardt's migration ordering and execution rules. We build a small working core; the data structures and behavior checks below describe what to preserve when adapting it to your language.
Download the Rust and MoonBit experiments. Extract the ZIP and follow framework-internals/README.md. Tested with Rust 1.96.0, Moon CLI 0.1.20260827, moonc 0.10.11+6ff76a5f9, Node 22.22.1, Python 3.12 and a native C toolchain. Initial builds fetch dependencies. Both SQLite examples use separate in-memory databases; no server is needed.
cd framework-internals
python3 verify.pyFind the order and the failure boundary
You have two schema changes: users first, then posts that depend on users. Reinhardt finds that dependency order even when we supply the changes backwards, and MoonBit reproduces it. We will trace the real executor and rebuild its graph, transactions and history recording.
The investigation also exposes a boundary: in this release, schema changes commit before history is recorded. We test that ordering with a real database error, using Reinhardt zero point four, alpha thirteen.
A dependency is an ordering constraint
A migration describes a named change, its operations, and the earlier changes it depends on. Our Rust sample uses Reinhardt's Migration objects and gives posts a dependency on users. The graph must place each dependency before its dependent, regardless of the input list's order.
If users also depends on posts, no valid order exists; the cycle is rejected. The MoonBit reproduction walks dependencies before appending each change to the execution order. Its visiting set detects cycles, and a missing dependency is an error rather than a silently skipped prerequisite.
Excerpt from the executable source; the complete program is in the download.
let mut graph = MigrationGraph::new();
let user_key = MigrationKey::new("demo", "001_users");
let post_key = MigrationKey::new("demo", "002_posts");
graph.add_migration(post_key.clone(), vec![user_key.clone()]);
graph.add_migration(user_key.clone(), vec![]);
assert_eq!(
graph.topological_sort()?,
vec![user_key.clone(), post_key.clone()]
);Follow the actual executor into the database
The real Database Migration Executor resolves forward order and checks which migrations are already recorded. Recorded changes are skipped, while new changes go through the framework's operation planning and schema editor. Our bounded example uses trusted forward SQL and explicit reverse SQL through Reinhardt's Run SQL operation.
For these SQLite migrations, the schema editor can put one migration's operations in a transaction. This is a transaction per migration, so a later failure does not erase earlier completed migrations. Other backends and operation types require their own source and execution checks.
The SQL operations are trusted source code. Our example does not accept arbitrary user-provided migrations or infer a safe transaction policy from a file extension.
Excerpt from the executable source; the complete program is in the download.
fn migration(name: &str, sql: &str, reverse: &str) -> Migration {
Migration::new(name, "demo").add_operation(Operation::RunSQL {
sql: sql.into(),
reverse_sql: Some(reverse.into()),
})
}Verify order, retry and rollback
Run both implementations against separate in-memory databases. Although posts is supplied before users, both runs apply users first and posts second. A second application performs no new work because both changes have recorded history.
A third migration creates scratch, then deliberately writes to a missing table. That step fails and scratch disappears, while the earlier users and posts tables remain. For rollback we provide users then posts; the executor reverses that supplied list and drops posts before users.
Do not mistake reverse input order on rollback for another promise to sort arbitrary rollback graphs.
Rust / Reinhardt
input=posts,users executed=users,posts second_run=0
failed_step_rolled_back=true earlier_migrations_preserved=true
rollback=posts,users cycle=rejected
recorder_failure_schema_present=true history_present=false
PASS reinhardt-db=0.4.0-alpha.13; graph, executor and recorderMoonBit
input=posts,users executed=users,posts second_run=0
failed_step_rolled_back=true earlier_migrations_preserved=true
rollback=posts,users cycle=rejected
recorder_failure_schema_present=true history_present=false
PASS MoonBit migration graph, per-step transaction and post-commit recorderReproduce the recorder boundary faithfully
The MoonBit plan holds names, dependencies, and arrays of forward and reverse SQL. Its recorder table uses the application and migration name to identify an applied change. We execute one migration's operations inside the transaction helper, then insert its history record.
That ordering follows the pinned executor call sequence and history recorder: apply_migration completes before record_applied is called. Putting history inside the same transaction might be a redesign, but would change the implementation we are investigating. The reproduction therefore keeps the boundary explicit and gives it an executable failure check.
The history table tells the runner what it recorded, while inspecting the schema tells us what actually exists. Those are separate observations, and a failure can make them disagree.
Excerpt from the executable source; the complete program is in the download.
db.transaction(() => {
for sql in migration.forward {
db.batch(sql)
}
})
// Fidelity matters: alpha.13 records AFTER the schema transaction commits.
db.exec("INSERT INTO reinhardt_migrations(app, name) VALUES (?, ?)", [
"demo",
migration.name.to_json(),
])
applied.push(migration.name)Make the history write fail
We install a database trigger that rejects new rows in the migration history table. The next migration creates a fresh table successfully, then reaches that rejecting trigger when recording history. Both implementations report failure, but the new table exists and its applied-history row does not.
This observed result confirms that schema and history are not one atomic unit in the tested path. A retry must account for that state; a generic claim that every failure restores both would be inaccurate. This experiment concerns the pinned release's SQLite SQL-operation path, not every migration feature or future release.
Use the reproduction to ask a precise question
The download includes the graph checks, database failures and real Reinhardt commands. Read the executor and recorder side by side before proposing a different transaction boundary. Our reproduction omits schema autodetection, squashed replacements, locks and backend-specific online operations.
It also avoids adding a checksum or immutable-prefix rule that this recorder experiment did not establish. Keep the exact version and failure evidence with any report, so the finding can be reproduced and assessed. Our in-memory tests reuse the connection for the second run; they do not claim to have simulated a process crash or a server restart.
Reinhardt Press uses the MoonBit implementation to make that investigation easier to follow.
Take the mechanism to your language
In your language, define migration records, dependency lists, ordered operations and a history lookup, then connect them to a database driver's transaction API.
For a faithful Reinhardt reproduction, keep schema commit before history recording, and test errors on both sides. That ordering is this implementation's contract, not a universal migration rule.
Source map and limits
PORTING.md maps the pinned framework functions to the executable MoonBit implementation and names the omitted features. The reproduction uses different runtime representations and is a bounded investigation, not a complete port. Node 22 emits an experimental warning for its SQLite API.