Migrations as Data
The problem
Schemas change after they ship. The discipline every mature ORM
converges on: each change is a versioned migration with an up and a
down, the list is append-only, and the database records which version
it is at. The interesting engineering question is where the logic lives,
because “compute what to run” tends to get tangled into “run it”, and
then nothing is testable without a database.
The pattern
- The history is data: an ordered array of
{version, up, down}. Released entries are never edited, because someone’s database already ran them. - The planner is a pure function:
plan(current, target)returns the steps to execute, in order. Upgrades walk forward; downgrades walk the affected range newest-first. - Executing the plan is a separate, boring loop that belongs to the repo.
const std = @import("std");
const expect = std.testing.expect;
const Migration = struct {
version: u32,
up: []const u8,
down: []const u8,
};
// The whole history, in order. Append-only: released migrations are
// never edited, because someone's database already ran them.
const history = [_]Migration{
.{
.version = 1,
.up = "CREATE TABLE users (id INTEGER, name TEXT)",
.down = "DROP TABLE users",
},
.{
.version = 2,
.up = "CREATE TABLE posts (id INTEGER, title TEXT)",
.down = "DROP TABLE posts",
},
.{
.version = 3,
.up = "ALTER TABLE users ADD COLUMN active INTEGER",
.down = "ALTER TABLE users DROP COLUMN active",
},
};
const Step = struct { version: u32, sql: []const u8 };
// The planner is a pure function from (current, target) to steps. All
// the subtle cases (no-op, partial upgrade, rollback ordering) are
// testable without a database anywhere in sight.
fn plan(current: u32, target: u32, buf: []Step) []Step {
var n: usize = 0;
if (target > current) {
for (history) |m| {
if (m.version > current and m.version <= target) {
buf[n] = .{ .version = m.version, .sql = m.up };
n += 1;
}
}
} else {
// Downgrades run the down steps newest-first.
var i = history.len;
while (i > 0) {
i -= 1;
const m = history[i];
if (m.version <= current and m.version > target) {
buf[n] = .{ .version = m.version, .sql = m.down };
n += 1;
}
}
}
return buf[0..n];
}
test "a fresh database applies everything in order" {
var buf: [8]Step = undefined;
const steps = plan(0, 3, &buf);
try expect(steps.len == 3);
try expect(steps[0].version == 1);
try expect(steps[2].version == 3);
try expect(std.mem.startsWith(u8, steps[0].sql, "CREATE TABLE users"));
}
test "partial upgrade takes only the gap" {
var buf: [8]Step = undefined;
const steps = plan(1, 3, &buf);
try expect(steps.len == 2);
try expect(steps[0].version == 2);
try expect(steps[1].version == 3);
}
test "rollback runs down steps newest-first" {
var buf: [8]Step = undefined;
const steps = plan(3, 1, &buf);
try expect(steps.len == 2);
try expect(steps[0].version == 3);
try expect(std.mem.startsWith(u8, steps[0].sql, "ALTER TABLE users DROP"));
try expect(steps[1].version == 2);
}
test "already there is a no-op" {
var buf: [8]Step = undefined;
try expect(plan(3, 3, &buf).len == 0);
}Purity is what makes the edge cases cheap
The four tests cover the cases that go wrong in real systems: the fresh database, the partial upgrade, the rollback (in reverse order; forward order corrupts), and the no-op. Each is an assertion about a function from two integers to a slice. No fixtures, no containers, no cleanup. When the planner and the executor are one function, every one of those tests needs a live database and most never get written.
The split also buys --dry-run for free: print the plan instead of
executing it. Any operation your library can plan without performing is
one users can review before trusting.
Append-only is a social contract, not a technical one
Nothing in the code stops someone editing migration 2 after release; the guarantee is convention plus review. A library can help enforce it: the draft stores a checksum of each applied migration and refuses to proceed when history was rewritten under it. That check is, again, pure logic over data, testable the same way.
In a full ORM
The draft’s migrations are Zig files discovered by the build (the
timestamped files in sample-migrations/), each exposing up and
down, with a CLI that creates, applies, and rolls back. The applied
version lives in a schema_migrations table; reading it and executing
the plan are the only parts that touch the database.