schema migrations #
pub-search runs Turso schema migrations via zug, a small Zig 0.16 SQLite migration runner. Migrations are ordered, recorded in a zug_migrations table, run-once-and-checksummed, and halt on dirty failures.
the system at a glance #
backend/src/db/
├── migrations.zig # the ordered Migration list — single source of truth
├── schema.zig # bootstrap + zug.sqlite.run dispatch
├── zug_conn.zig # adapter: *Client → zug-shaped connection trait
└── Client.zig # Turso HTTP client (now with runtime-SQL escape hatch)
db.initSchema() (from backend/src/db.zig) runs in the initServices background thread on every backend boot. It calls schema.init(allocator, client) which:
- Bootstraps
zug_migrationsif needed (existing-DB-first-deploy case) — see below - Calls
zug.sqlite.run(allocator, &conn, &migrations, …)with ourMigrationConnadapter - zug iterates migrations, skipping any whose
idis already inzug_migrationswith a matching checksum
On steady state (post-bootstrap, no new migrations added), step 3 is a single SELECT id, checksum, dirty FROM zug_migrations plus in-memory checksum compare. No turso work. Boot is fast.
the bootstrap — why and how #
why #
We adopted zug after the schema had already grown ~51 idempotent ALTER/UPDATE statements that re-ran on every boot. Many were full-table scans against turso (UPDATE documents SET platform = ... WHERE platform IS NULL over 15k rows). The cumulative cost was ~5 minutes per boot, during which:
- the local SQLite replica wasn't open yet (gated behind
initSchemafinishing) - handlers fell back to turso
- turso was busy with the migrations
- requests piled up, fly proxy timed out at 30s
That had bitten us repeatedly (notably 2026-04-04 and 2026-05-04). Each cold start meant 5+ minutes of degraded service.
how #
schema.zig:bootstrapIfNeeded handles three states:
| state | how detected | what happens |
|---|---|---|
| fresh DB | documents table doesn't exist |
skip bootstrap; let zug.sqlite.run create everything from migration 001 onward |
| already-bootstrapped | baselineComplete() returns true (every baseline id present with right checksum and dirty=0) |
skip bootstrap; zug.sqlite.run is a no-op for the baseline |
| pre-zug DB | documents exists but baseline not complete |
seed the baseline as already-applied |
The seed loop uses INSERT OR IGNORE so a partial previous attempt resumes: rows already in the table stay, missing ones get filled. After seeding, baselineComplete() runs again as a verification gate. If it still fails, init() halts with error.BootstrapIncomplete — better to refuse to start than silently let zug re-run history.
what counts as "baseline" #
migrations.zig exports BOOTSTRAP_BASELINE_COUNT: usize = 10. This number is frozen at the count that existed when zug was adopted. Migrations appended after adoption (011, 012, …) are NOT folded into the baseline — they need to actually run against turso. If a fresh-but-pre-zug DB ever bootstraps (e.g. a restored backup), only baseline entries are pre-marked applied; everything past BOOTSTRAP_BASELINE_COUNT runs through zug.sqlite.run normally.
A test asserts BOOTSTRAP_BASELINE_COUNT <= migrations.len, so accidentally shrinking the array fails the build.
adding a new migration #
- Append a new entry to
migrations.zig. Use the next 3-digit prefix (e.g.011_*) — the test enforces this. - Do NOT change
BOOTSTRAP_BASELINE_COUNT. It stays at 10 forever. - Do NOT edit any existing migration. zug stores the checksum (Wyhash of
id,name,class,sql, has-callback flag); a content change tripsChecksumMismatchand zug refuses to run. - Run
zig build testto verify. The test suite includes id-uniqueness and 3-digit-prefix assertions. - Deploy. zug applies the new migration on next backend boot, recording it in
zug_migrations.
transactions #
All migrations are transactional: false. The Turso HTTP client closes the connection at the end of each pipeline request, so BEGIN / COMMIT cannot span multiple conn.exec calls — wrapping in a transaction would not work.
If a migration fails partway through its statement list, zug marks the row dirty=1 and refuses to run anything else until repaired. Repair: fix the underlying issue and complete and verify every statement in the migration manually, then UPDATE zug_migrations SET dirty = 0 WHERE id = '...' to unblock.
the adapter (zug_conn.zig) #
zug's validateConn requires a connection with:
exec(sql: []const u8, args: anytype) !void
rows(sql: []const u8, args: anytype) !Rows
Rows.next() ?Row
Row.text(i) []const u8
Row.int(i) i64
MigrationConn wraps a *Client to satisfy this. The tuple → typed-value conversion happens inline at compile time, accepting []const u8, *const [N:0]u8 (for string literals and @tagName(enum)), and i64 — the exact shapes zug binds. Anything else is a compile error.
The Hrana protocol's value shape requires per-arg type tags (text, integer, null). The runtime-typed path on Client.zig (execRuntime / queryRuntime taking []const RuntimeValue) emits the right tags; the adapter feeds it. Existing comptime-SQL handlers (Client.exec / Client.query) are untouched.
ops cheatsheet #
# Inspect migration state on prod (requires turso CLI auth):
turso db shell leaf "SELECT id, checksum, dirty, applied_at FROM zug_migrations ORDER BY id"
# Repair a dirty migration:
# 1. fix the root cause and verify every migration statement completed
# 2. UPDATE zug_migrations SET dirty = 0 WHERE id = 'XXX_offending_migration'
# 3. redeploy — zug treats the repaired migration as applied; it does not rerun it
# Force a re-bootstrap (DANGER — only after verifying schema is intact):
# DROP TABLE zug_migrations;
# restart backend
related #
- docs/turso-hrana.md — the HTTP protocol the Client speaks
- zug repo — the migration runner library
backend/src/db/migrations.zig— the actual migration list (read this when in doubt)