Migration Management — Declarative DDL Revision Control for SCHEMABOUND
SCHEMABOUND ships a cross-language migration library that generates and applies DDL changes via a single Rust binary called sb-migrate. Every SDK (Rust, Python, .NET, TypeScript/Node.js) wraps this binary as a subprocess — no external migration tooling or database-specific engines required.
Why Declarative Migrations?
Traditional migrations are imperative: you write the exact SQL to apply and reverse each change by hand. This works until your schema has grown into dozens of tables with interdependent indexes, foreign keys, and constraints. At that point:
- Manual reversal becomes error-prone.
- Adding a column in one environment but forgetting to update it elsewhere causes drift.
- Schema diffs between environments are hard to audit or reproduce.
Declarative migrations flip the model: you describe what schema state you want, and sb-migrate computes the how. The same algorithm runs on every SDK — Python generates exactly the same SQL as .NET for identical snapshots.
Supported Dialects
| Dialect | CLI alias | Notes |
|---|---|---|
| MySQL | mysql | Default, full ALTER COLUMN support |
| PostgreSQL | postgres | Uses $1 parameter placeholders |
| SQLite | sqlite | Used in tests/dev; limited ALTER COLUMN |
| Dolt | dolt | MySQL wire protocol compatibility layer |
The Binary: sb-migrate
The binary is built from the schemabound-migrations Rust crate. It accepts two invocation modes:
Clap Subcommands (Direct Use)
# Initialize the migration tracking table
./target/debug/sb-migrate init --dialect mysql
# Compute a diff between two schema files
./target/debug/sb-migrate diff --from current.json --to desired.json
# Apply a migration plan from JSON
./target/debug/sb-migrate apply --sql-file plan.json
JSON Stdin Protocol (SDK Use)
Each SDK encodes commands as JSON and writes them to stdin. The binary returns structured JSON on stdout:
echo '{"command":"init","dialect":"mysql"}' | sb-migrate
# → {"status":"success","code":0,"data":{"sql":"CREATE TABLE ..."}}
Invalid input or runtime errors produce a structured {"status":"error","code":N,"message":"..."} response with exit code 1.
Architecture
┌──────────────┐ JSON stdin ┌─────────────┐
│ Python SDK │ ──────────────────▶ │ sb-migrate │
│ .NET SDK │ │ (Rust crate) │
│ TypeScript │ │ │
│ Rust │ ◀────────────────── │ diff_snapshots()
│ │ JSON stdout │ plan_to_sql()
└──────────────┘ └─────────────┘
The binary is a thin orchestration layer. All schema analysis runs in the schemabound-migrations library:
- Dialect registry — pluggable type mappers, parameter styles, and SQL generators per dialect.
- Diff engine — compares
TableSnapshots to produce aMigrationPlanwith typed steps (CreateTable, DropColumn, RenameColumn, etc.). - Reverser — inverts plans for down migrations; falls back gracefully when reversal requires schema history.
- SQL generator — renders each step into dialect-specific SQL using the registered type mapper.
Migration Tracking Table
Each database that SCHEMABOUND manages carries a single tracking table (default schemabound_migrations) with columns:
CREATE TABLE IF NOT EXISTS schemabound_migrations (
revision VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL DEFAULT '',
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
rollback_sql TEXT,
checksum VARCHAR(64) NOT NULL DEFAULT ''
);
The table name is configurable per dialect via --table-name-override (Rust/CLI) or the equivalent SDK parameter. The tracking table records applied revisions and their associated rollback SQL so down migrations can be replayed safely.
Data Model
All four SDKs share an identical type model matching the Rust crate:
TableSnapshot ──┬── columns: Vec<ColumnDef>
name │ name, sql_type, nullable, default, unique
primary_key └── indexes: Vec<IndexDef>
index.name, index.columns, index.unique
MigrationPlan ── id, direction ("Up" | "Down"), steps
MigrationStep ── kind (CreateTable | DropTable | AddColumn | ...)
data (step-specific payload)
Cross-Language Quick Start
Rust
See sdk/rust.md. The sb-migrate crate is published as part of the SCHEMABOUND family.
Python
from schemabound_sdk import MigrationEngine, table_snapshot, ColumnDef
engine = MigrationEngine(dialect="mysql")
# Initialize tracking table
result = engine.init()
print(result["data"]["sql"]) # CREATE TABLE IF NOT EXISTS ...
# Generate diff between current and desired schema
diff_result = engine.diff(
to_schema=[
table_snapshot(
name="users",
columns=[
ColumnDef(name="id", sql_type="BIGINT", nullable=False, unique=True),
ColumnDef(name="email", sql_type="VARCHAR(255)", nullable=False),
],
primary_key=["id"],
),
]
)
# Apply the plan
from schemabound_sdk import MigrationPlan
plan = MigrationPlan(id=diff_result["data"]["id"], direction="Up")
engine.apply(plan)
.NET
using Schemabound.Migrations;
var engine = new MigrationEngine("mysql");
var initResult = engine.Init();
Console.WriteLine(initResult["sql"]); // CREATE TABLE IF NOT EXISTS ...
var diffResult = await engine.Diff(toSchema: new List<TableSnapshot> {
new TableSnapshot {
Name = "users",
Columns = new List<ColumnDef> {
new ColumnDef { Name = "id", SqlType = "BIGINT", Nullable = false, Unique = true },
new ColumnDef { Name = "email", SqlType = "VARCHAR(255)", Nullable = false }
},
PrimaryKey = new List<string> { "id" }
}
});
var plan = new MigrationPlan { Id = (string)diffResult["id"], Direction = "Up" };
await engine.Apply(plan);
TypeScript / Node.js
import { MigrationEngine, TableSnapshot, ColumnDef } from "schemabound-ts";
const engine = new MigrationEngine({ dialect: "mysql" });
const initResult = await engine.init();
console.log(initResult.sql); // CREATE TABLE IF NOT EXISTS ...
const diffResult = await engine.diff({
toSchema: [{
name: "users",
columns: [
{ name: "id", sql_type: "BIGINT", nullable: false, unique: true },
{ name: "email", sql_type: "VARCHAR(255)", nullable: false }
],
primary_key: ["id"]
}] as TableSnapshot[]
});
const plan = { id: diffResult.id, direction: "Up" };
await engine.apply(plan);
Error Handling
All SDKs surface errors via typed exceptions that include the sb-migrate error code and a human-readable message:
| Language | Exception Type | Code Field | Message Format |
|---|---|---|---|
| Rust | MigrationsError | N/A (enum variant) | thiserror Display messages |
| Python | RuntimeError | N/A | Structured JSON error parsing from stderr |
| .NET | MigrationException | ErrorCode int | "sb-migrate exited with code N" or parsed message |
| TypeScript | MigrationError | code? optional int | Parsed JSON or fallback string |
Common error codes: 10 (diff failed), 20 (apply failed), 30 (not initialized), 40 (connection failed), 50 (already applied), 90 (invalid snapshot).
Configuration
The SDKs look for the sb-migrate binary in this order:
$SB_MIGRATE_BINenvironment variable- Binary on
$PATH(shutil.which/Process.Start/spawn) - Project-relative paths (
bin/sb-migrate,<repo>/target/debug/sb-migrate, etc.)
For production deployments, set SB_MIGRATE_BIN to a known absolute path to avoid runtime lookup failures.
Where This Fits in the SCHEMABOUND Stack
Migration management is one of four pillars of SCHEMABOUND:
| Pillar | Purpose | Primary Surface |
|---|---|---|
| Schema Definition | Declarative DDL revision control (this document) | sb-migrate Rust crate + SDK wrappers |
| Query Runtime | Typed query execution with policy enforcement | gRPC server, MirrorProvider, ExecutorService |
| Audit Logging | Structured audit trail for all queries and mutations | CoR handlers, event pipeline |
| Billing & Quotas | AAU metering, rate limiting, quota enforcement | QuotaError, policy engine |
The migration library is independent of the gRPC runtime — you can use it standalone to manage schema evolution in any project that ships a Rust binary.
Contributing
See Contribution Workflow for the full TDD contract. The schemabound-migrations crate has its own test suite (39 tests) covering diff, reversal, SQL generation across dialects, and runner integration.
# Run all migration crate tests
cd libraries/schemabound-migrations && cargo test --tests