Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

DialectCLI aliasNotes
MySQLmysqlDefault, full ALTER COLUMN support
PostgreSQLpostgresUses $1 parameter placeholders
SQLitesqliteUsed in tests/dev; limited ALTER COLUMN
DoltdoltMySQL 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 a MigrationPlan with 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:

LanguageException TypeCode FieldMessage Format
RustMigrationsErrorN/A (enum variant)thiserror Display messages
PythonRuntimeErrorN/AStructured JSON error parsing from stderr
.NETMigrationExceptionErrorCode int"sb-migrate exited with code N" or parsed message
TypeScriptMigrationErrorcode? optional intParsed 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:

  1. $SB_MIGRATE_BIN environment variable
  2. Binary on $PATH (shutil.which / Process.Start / spawn)
  3. 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:

PillarPurposePrimary Surface
Schema DefinitionDeclarative DDL revision control (this document)sb-migrate Rust crate + SDK wrappers
Query RuntimeTyped query execution with policy enforcementgRPC server, MirrorProvider, ExecutorService
Audit LoggingStructured audit trail for all queries and mutationsCoR handlers, event pipeline
Billing & QuotasAAU metering, rate limiting, quota enforcementQuotaError, 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