SurrealDB schema migrations one contract, three runtimes

lib-surrealdb-migrate

A migration engine that treats your schema as a ledger, not a hope.

Diesel-like up/down migrations for SurrealDB — transaction-safe, drift-detecting, version-ordered. The same tracking schema and byte-for-byte hashing, implemented three times, so a database migrated by any runtime is readable by the others.

Rust cargo · path dep Reference implementation · P1–P7
TypeScript @servicecute/surrealdb-migrate Published to JSR · P1–P7
Gleam gleam · path dep P1–P6 · baseline commands in progress

The failure modes

What a hand-rolled runner gets wrong

Every backend eventually grows its own migration.ts loop. Each one re-learns the same lessons the hard way. This engine encodes the fixes so you don't.

Corruption

Partial apply poisons the ledger

A file fails halfway. The schema is half-changed, but no tracking row was written — so the next up re-runs the successful prefix and double-applies it.

Every up.surql/down.surql runs inside BEGIN … COMMIT. A mid-file failure rolls back atomically. Opt out per file with a -- no-transaction pragma.

Drift

Someone edits an applied migration

A .surql that already ran in production gets "just a small fix." Now every fresh database builds a different schema than the one that's live — silently.

A SHA-256 of every applied file lives in __migration_files. up refuses to build on drifted history; verify fails CI; accept-drift is the deliberate override.

Ordering

Out-of-order branch merges

Two branches each scaffold a migration. The one with the older timestamp merges after the newer one already applied — ordering by applied_at would hide it.

Apply order is lexicographic on the version timestamp, stop-on-first-failure. up --strict refuses a pending migration older than one already applied.

Bootstrap

A fresh v3 server won't connect

SurrealDB v3 stopped auto-creating the namespace and database on USE. Your app can't even reach the DB to run its own migrations — chicken and egg.

bootstrap <env> <db> creates the namespace, database, and tracking table in one root session, before the app connects. Idempotent.

Adoption

A live DB with no history

Schema was built by hand, or by another tool, and there's no migration ledger. Running up would try to re-create tables that already exist.

stamp marks migrations applied without running them. squash distills the live schema into an idempotent baseline; reinit converges any DB onto it.

Audit

Nobody knows who ran what

A migration went out at 2am and something broke. Which one, applied by whom, and how long did it take? The ledger has a version and nothing else.

applied_by and duration_ms ride every row. log (alias history) is git log for the schema, in version order.

Parity

Rust and TypeScript touch one DB

A Rust service and a TypeScript script both migrate the same database. Two runners with two tracking formats means neither trusts the other's state.

The tracking tables and the SHA-256 of raw file bytes are byte-compatible across all three runtimes. Any implementation reads another's ledger.

Safety

Destruction is one keystroke away

Rollback and drop are a typo from data loss, and in a script they run without a second thought.

Destructive verbs require a --sudo elevation and an interactive confirmation, previewing the SQL before it runs.

The command surface

When to reach for each command

The verbs are grouped by where they land in the lifecycle of a schema. The signature is identical across runtimes; only the way you invoke it differs.

01

Set up & author

once per DB, then per change
bootstrap <env> <db>
The very first thing on a fresh SurrealDB v3 server. Creates the namespace, database, and tracking table at root — run it before your app connects. env ∈ {production, staging, development}.
init
Create the __migrations / __migration_files tracking tables. Reach for it when the database already exists but the ledger doesn't. Idempotent.
create <name>
Scaffold <timestamp>_<name>/{up,down}.surql. Start every schema change here so the version timestamp is unique and sortable.
02

Apply forward

the everyday loop
up
Apply every pending migration in version order, each transaction-wrapped, stopping on the first failure. The default forward command. Refuses if any prior file has drifted.
up --to <v>
Apply only through version <v> (timestamp prefix or full name). For staged rollouts, or bisecting which migration introduced a problem.
up --strict --allow-out-of-order
Refuse — or deliberately permit — a pending migration older than one already applied. Guards the out-of-order branch-merge hazard.
up --dry-run · plan
Preview exactly what up would apply, in order, with the SQL — without touching the database.
redo --sudosudo
Roll back the head migration and re-apply exactly it. The tight "iterate on the migration I'm writing" dev loop.
03

Inspect & verify

read-only · CI-safe
status --json
Classify every migration: applied / pending / pending-out-of-order / drifted / orphaned. The "where am I" command; --json for machine output.
log · history
Applied migrations in version order with actor and duration. git log for the schema.
verify --backfill
Re-hash every applied file; non-zero exit on drift or missing-on-disk. Run this in CI. --backfill adopts current files as the baseline for a pre-tracking deployment.
diff --baseline <f>
Compare the live schema against a baseline; non-zero on divergence. Catches out-of-band DEFINE/REMOVE — a schema-level drift gate for CI.
show <version>
Print the up.surql and down.surql for a single migration, by prefix or full name.
list --all
Tables and row counts. Hides internal tracking tables unless --all.
04

Roll back & repair

elevated · reversible with care
down --sudo --to <v> --yessudo
Roll back the last migration — or, with --to, every migration newer than <v> in reverse order (the target is kept). Interactive per-step preview unless --yes.
accept-drift <version> --sudosudo
"I edited this on purpose." Overwrite the recorded hash from the current on-disk files, clearing the drift flag.
05

Baseline & adopt

collapse history · onboard a live DB
stamp <version> --sudosudo
Mark migrations up to <version> applied without running them — adopt a database whose schema already exists out-of-band. Rows are flagged stamped.
squash --out <f>
Generate an idempotent OVERWRITE schema baseline from the live schema. Read-only; never touches migration history. Warns if any migration carried data statements.
reinit --baseline <f> --sudosudo
Apply the baseline to converge this DB's schema — create-or-overwrite, preserving rows.
06

Data & inventory

operational escape hatches
backup <file> --tables a,b
JSON dump of schema and data, optionally scoped to named tables.
restore <file>
Load a JSON dump back into the database.
drop <t1,t2> · drop-alldestructive
Remove tables. Confirms interactively unless --yes.

Three runtimes

Wire it up in your language

Pick your stack. The migration files, the ledger, and the hashes are identical — only the dependency and the host glue change.

Distribution cargo path dependency Parity · P1–P7 (reference) Driver surrealdb crate

1 · Add to the service

# srv-*/Cargo.toml
[dependencies]
lib-surrealdb-migrate = { path = "../lib-surrealdb-migrate" }

2 · Wire into your api_cli

use lib_surrealdb_migrate::{bootstrap, connect_surrealdb, MigrationRunner, MigrationCommands};

// bootstrap is special-cased: it runs at root, before init_db() USEs the ns/db.
if let MigrationCommands::Bootstrap { namespace, database } = &migrate_cmd {
    let db = connect_surrealdb(&surreal_url).await?;
    db.signin(Root { username: "root", password: "root" }).await?;
    bootstrap(&db, namespace, database).await?;
    return Ok(());
}

// normal path: init_db() connects + USEs the configured ns/db.
let db = init_db().await?;
let runner = MigrationRunner::new(db, "migrations");
runner.handle_command(migrate_cmd).await?;

3 · Run

cargo run -- migrate bootstrap development linkit
cargo run -- migrate up
cargo run -- migrate status

The Rust crate is the reference: the TypeScript and Gleam ports mirror its task IDs, exit-code taxonomy, and tracking schema.

Feature parity

Capability by runtime

Rust and TypeScript are at full parity. Gleam has landed everything through preview and state visibility; only the schema-baseline family is outstanding.

CapabilityRustTypeScriptGleam
Setup bootstrap · init · create
P1 correctness tx-wrap · version order · stop-on-fail
config-dir --migrations-dir → env → default
P2 drift verify · accept-drift · SHA-256
P4 state status --json · log/history · stamped · actor
P5 targeted up --to · down --to · redo
P6 preview plan · up --dry-run · show
P7 baseline stamp · squash · reinit · diff◑ in progress
Inventory & data list · drop · backup · restore
Distributioncargo pathJSRgleam path

The shared contract

What every runtime agrees on

These three details are byte-compatible across Rust, TypeScript, and Gleam — the reason a database migrated by one is safe to read with another.

Exit-code taxonomy

0Success.
1User error — bad input, gate refusal, not-found, or schema-diff divergence.
2Runtime error — database, I/O, or a migration step that failed.
3Drift refused — up or verify hit an edited applied file.

Tracking ledger

__migrations
version · name · applied_at
applied_by · duration_ms · stamped

__migration_files
version · kind · sha256
byte_len · recorded_at

Versioning rule

Each migration is a directory:
<YYYYMMDDHHMMSS>_<name>/
holding up.surql + down.surql.

Order is lexicographic on the 14-digit timestamp prefix, so every migration needs a unique timestamp.