moon_schema_plan

    Deterministic schema diff, migration planning, and destructive-change gates for MoonBit

    database
    schema
    migration
    sqlite
    postgresql
    ci
    Download zip
    Author
    Version
    0.4.0
    License
    Apache-2.0
    Last updated
    21 hours ago
    Downloads
    13

    Dependencies

    #moon_schema_plan

    CI License mooncakes

    Deterministic database schema diffing, migration planning and destructive-change gates for MoonBit. The library is transport-independent and never connects to a database. You give it two explicit schema descriptions; it gives you an auditable plan, a risk verdict, and SQLite or PostgreSQL SQL.

    # Fail the build when a migration would destroy data. moon run cmd/main -- verify postgresql \ --before schema/v1.json --after schema/v2.json # exit 2: migration policy violated: --max-risk is review # step-5 [destructive] drop column users.legacy_code: drops all values stored in the column

    #Why this exists

    MoonBit already has database drivers, ORMs and SQL parsers. They answer "how do I talk to this database?". moon_schema_plan answers a different question: "may this schema change run, and what exactly will it do?" It is the piece you put in CI, between a schema definition and a production database.

    The rules are deliberately conservative:

    • output is deterministic, so a plan can be reviewed in a pull request;
    • renames require explicit hints and are never guessed from similar names;
    • destructive changes are classified and refused unless approved in the command;
    • an invalid schema returns every issue it has, never partial SQL;
    • SQLite changes that ALTER TABLE cannot express become one auditable, transactional table rebuild;
    • identifiers are quoted, and raw SQL fragments reject statement delimiters.

    #Installation

    moon add kath61105/moon_schema_plan

    The library itself has no third-party runtime dependencies. The bundled CLI additionally uses moonbitlang/x for file access and exit codes, so the library stays pure and portable while the executable can talk to a real shell.

    For local development, clone the repository and run moon check.

    #Using it in CI

    verify is the command a pipeline runs. It builds the plan, reports it and then exits with a status a build system can act on. It never emits SQL.

    moon run cmd/main -- verify postgresql \ --before schema/v1.json \ --after schema/v2.json \ --hints schema/renames.json \ --max-risk review

    Exit codeMeaning
    0The plan satisfies --max-risk.
    1Usage, file, JSON or schema-validation error. Nothing was planned.
    2The plan is valid but contains a step above --max-risk.

    --max-risk accepts safe, review or destructive and defaults to review, so dropping a table or a column fails the build until someone says otherwise.

    To attach the plan to a pull request, ask for Markdown:

    moon run cmd/main -- verify postgresql \ --before schema/v1.json --after schema/v2.json \ --format markdown --out migration-plan.md

    #Generating SQL

    plan renders the SQL, under the same policy.

    # Blocked: exits 2 and prints the offending steps, with no SQL. moon run cmd/main -- plan postgresql \ --before examples/schema_v1.json \ --after examples/schema_v2.json \ --hints examples/rename_hints.json # Approved: renders the migration. moon run cmd/main -- plan postgresql \ --before examples/schema_v1.json \ --after examples/schema_v2.json \ --hints examples/rename_hints.json \ --allow-destructive

    Apply a SQLite migration with a client that stops at the first error:

    moon run cmd/main -- plan sqlite --before v1.json --after v2.json --allow-destructive --out migration.sql sqlite3 -bail app.db < migration.sql

    A rebuild checks referential integrity before committing, but SQL cannot make a COMMIT conditional on a query result: the check raises an error, and it is the client stopping on that error that leaves the transaction to be rolled back. Without -bail, the error is reported and the migration commits anyway.

    --format selects sql (default), json for a stable machine-readable report, or markdown for review. --out <path> writes to a file. Schemas may also be passed inline with --before-json and --after-json.

    The JSON accepted and produced by every one of these is documented in docs/schema-format.md, including why a rename needs an explicit hint and how to write a default expression.

    Run moon run cmd/main -- demo for a complete, executable SQLite example, and moon run cmd/main -- help for the full option list.

    #Library example

    let result = @moon_schema_plan.build_plan(before, after, @moon_schema_plan.PostgreSQL)

    match result {
    // Every invalid path at once, rather than the first one found.
    Err(issues) => report(issues)
    Ok(plan) =>
    if plan.max_risk().severity() > @moon_schema_plan.Review.severity() {
    reject(plan.steps_above(@moon_schema_plan.Review))
    } else {
    match @moon_schema_plan.render_plan(plan) {
    Err(blocked) => report(blocked)
    Ok(rendered) => println(rendered.sql.join("\n"))
    }
    }
    }

    Plan::to_markdown and RenderedPlan::to_markdown produce the same review document the CLI prints, so a tool built on the library can post it without shelling out.

    #Safety model

    Safe means the planner found no evidence of data loss. It does not mean the operation is free of locks or performance cost. Review asks for operator attention. Destructive means existing data or key semantics can be lost or converted.

    A plan is not a substitute for backups, a staging rehearsal or database-specific operational review. It makes the decision explicit; a human still makes it.

    #Scope

    The IR models tables, columns, indexes, foreign keys, check constraints, triggers and views. Planning covers creating, dropping and renaming tables and columns, altering columns, index, foreign-key, check, trigger and view changes, and SQLite table rebuilds, for PostgreSQL and SQLite.

    The invariant is not which objects are modelled but that the planner never parses SQL. A type, a default, a check expression, a view body and a trigger action are all carried verbatim and validated only for what can be checked without parsing them. That is why adding triggers and views did not require a SQL parser, and why it does not pretend to portability it lacks: a trigger action is dialect-specific, because SQLite inlines statements while PostgreSQL executes a function.

    Generated columns and partial-index predicates remain unmodelled. One consequence shows up in the output: a SQLite column drop always becomes a rebuild rather than a native DROP COLUMN, because some of SQLite's conditions for refusing that statement involve things the schema does not describe. docs/design-decisions.md works through it.

    This release deliberately does not connect to a live database, parse arbitrary DDL, infer renames, migrate business data, or support MySQL. Introspection belongs in optional adapters; the planner stays a pure, cross-target function. Dialect fragments come from trusted configuration — this is a planner, not a SQL firewall.

    #Development

    moon fmt --check moon info moon check --deny-warn moon test --deny-warn sh scripts/cli_smoke.sh # every documented CLI invocation and exit code sh scripts/sqlite_e2e.sh # applies generated SQL to a real sqlite3 database sh scripts/postgres_e2e.sh # the same against a real PostgreSQL server

    postgres_e2e.sh reads the standard libpq environment variables (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE), so it runs against any server you point it at, including the one CI starts as a service container.

    CI runs all of the above and the test suite on the wasm, wasm-gc, js and native backends. The native backend compiles through C, so it needs a system C compiler; the dev container provides one:

    docker build -t moon-schema-plan-dev -f .devcontainer/Dockerfile . docker run --rm -v "$PWD:/workspace" moon-schema-plan-dev moon test --deny-warn

    #Work completed in this period

    This project was built for the 2026 MoonBit September Hackathon. Recording the boundary explicitly, because the rules ask entrants to distinguish new work from pre-existing work:

    The repository's first commit, chore: import initial moon_schema_planimplementation, captures the code as it stood before version control was set up on 19 September 2026 — the schema IR, validation, the diff engine, risk classification, the SQLite and PostgreSQL renderers, a demo CLI, and 23 tests, totalling 1,970 lines of MoonBit. Nothing was ever published from that state: there was no repository, no CI, no release and no registry entry.

    Every commit after it — 30 so far, changing 44 files by +6,000/-300 lines — was written during this period. That work is:

    • the risk-policy layer (Risk::severity/parse, Plan::summary, Plan::max_risk, Plan::steps_above) and the Markdown reporting layer (Change::describe, Plan::to_markdown, RenderedPlan::to_markdown) — a new report.mbt;
    • the CLI rewrite: file inputs, the verify command, --max-risk, --format, --out, and distinct exit codes for a policy violation and a real error;
    • CHECK constraints in the schema IR, which a SQLite rebuild used to drop in silence;
    • the test suite going from 23 to 159 tests, library coverage to 996/998 lines and the CLI from none to 67/196, including four property-based checks of the determinism, gate and identity claims;
    • real database execution for both dialects, which is how every renderer defect so far was found; two of those tests are regressions for defects found by executing generated SQL against a real database rather than by reading it;
    • scripts/cli_smoke.sh, asserting all 21 documented CLI invocations, and a rewritten scripts/sqlite_e2e.sh that migrates the example schemas against a real database;
    • GitHub Actions across four backends, a dev container, this README, docs/schema-format.md, and the 0.2.0 release to GitHub and mooncakes.

    The CI history is part of that record: the first run failed because a fresh runner has no MoonBit registry index, which local development had masked.

    #Documentation

    #License

    Apache-2.0. See LICENSE.

    Change

    pub(all) enum Change {
    AddTable(Table)
    DropTable(Table)
    RenameTable(String, String)
    AddColumn(String, Column)
    DropColumn(String, Column)
    RenameColumn(String, String, String)
    AlterColumn(String, Column, Column)
    AddIndex(String, Index)
    DropIndex(String, Index)
    AddForeignKey(String, ForeignKey)
    DropForeignKey(String, ForeignKey)
    AddCheck(String, CheckConstraint)
    DropCheck(String, CheckConstraint)
    AddTrigger(String, Trigger)
    DropTrigger(String, Trigger)
    AddView(View)
    DropView(View)
    RebuildTable(Table, Table, Array[(String, String)])
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Change::describe

    fn Change::describe(self : Change) -> String

    A short, stable description of a change, suitable for a report table or a commit message. It never includes SQL.

    CheckConstraint

    pub(all) struct CheckConstraint {
    name : String
    expression : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A table-level CHECK constraint. The expression is a dialect SQL fragment held under the same rules as a column default: validated for statement delimiters, emitted verbatim, never parsed.

    Column

    pub(all) struct Column {
    name : String
    data_type : String
    nullable : Bool
    default_value : String?
    primary_key : Bool
    unique : Bool
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A portable column description. data_type is intentionally retained as a dialect-level string: the planner compares it deterministically without pretending that PostgreSQL and SQLite type systems are interchangeable.

    Dialect

    pub(all) enum Dialect {
    SQLite
    PostgreSQL
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    The SQL dialect used when a migration plan is rendered.

    Dialect::label

    fn Dialect::label(self : Dialect) -> String

    The stable lowercase spelling of a dialect.

    Dialect::parse

    fn Dialect::parse(text : String) -> Dialect?

    Parse a dialect name. postgres is accepted as an alias for postgresql because both spellings are common in tooling.

    ForeignKey

    pub(all) struct ForeignKey {
    name : String
    columns : Array[String]
    referenced_table : String
    referenced_columns : Array[String]
    on_delete : String?
    on_update : String?
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Index

    pub(all) struct Index {
    name : String
    columns : Array[String]
    unique : Bool
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    MigrationStep

    pub(all) struct MigrationStep {
    id : String
    risk : Risk
    reason : String
    change : Change
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Plan

    pub(all) struct Plan {
    from_version : String
    to_version : String
    dialect : Dialect
    steps : Array[MigrationStep]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Plan::has_destructive_changes

    fn Plan::has_destructive_changes(self : Plan) -> Bool

    Plan::max_risk

    fn Plan::max_risk(self : Plan) -> Risk

    The highest risk present in the plan. An empty plan is Safe.

    Plan::steps_above

    fn Plan::steps_above(self : Plan, max_risk : Risk) -> Array[MigrationStep]

    Every step whose risk exceeds max_risk. An empty result means the plan satisfies the policy.

    Plan::summary

    fn Plan::summary(self : Plan) -> PlanSummary

    Plan::to_markdown

    fn Plan::to_markdown(self : Plan) -> String

    Render a plan as a deterministic Markdown review document. The output is intended to be attached to a pull request so that a human approves the migration before any SQL runs.

    PlanSummary

    pub(all) struct PlanSummary {
    total : Int
    safe : Int
    review : Int
    destructive : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Step counts per risk level, used for policy decisions and for the headline of a review report.

    RenameHints

    pub(all) struct RenameHints {
    tables : Array[(String, String)]
    columns : Array[(String, String, String)]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Explicit rename hints prevent the planner from making unsafe guesses.

    RenameHints::empty

    fn RenameHints::empty() -> RenameHints

    RenderIssue

    pub(all) struct RenderIssue {
    step_id : String
    message : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    RenderedPlan

    pub(all) struct RenderedPlan {
    plan : Plan
    sql : Array[String]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    RenderedPlan::to_markdown

    fn RenderedPlan::to_markdown(self : RenderedPlan) -> String

    Render a plan together with the SQL that was approved for it.

    Risk

    pub(all) enum Risk {
    Safe
    Review
    Destructive
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A conservative risk classification for a migration step.

    Risk::label

    fn Risk::label(self : Risk) -> String

    The stable lowercase spelling used in SQL comments, reports and the CLI.

    Risk::parse

    fn Risk::parse(text : String) -> Risk?

    Parse the spelling produced by Risk::label. Unknown input returns None rather than silently selecting a weaker policy.

    Risk::severity

    fn Risk::severity(self : Risk) -> Int

    Severity order used when a caller compares risks or configures a policy threshold. Safe is 0, Review is 1, Destructive is 2.

    Schema

    pub(all) struct Schema {
    version : String
    tables : Array[Table]
    views : Array[View]
    } derive(Eq, ToJson,
    Debug
    )

    impl FromJson for Schema

    Table

    pub(all) struct Table {
    name : String
    columns : Array[Column]
    indexes : Array[Index]
    foreign_keys : Array[ForeignKey]
    checks : Array[CheckConstraint]
    triggers : Array[Trigger]
    } derive(Eq, ToJson,
    Debug
    )

    impl FromJson for Table

    Trigger

    pub(all) struct Trigger {
    name : String
    timing : String
    event : String
    action : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A trigger on a table.

    timing and event are validated against a fixed set because they are the portable part. action is everything after ON <table> and is a dialect-specific fragment: SQLite inlines statements between BEGIN and END, while PostgreSQL executes a function. It is the one fragment the delimiter check does not apply to, because a SQLite trigger body legitimately contains semicolons.

    ValidationIssue

    pub(all) struct ValidationIssue {
    path : String
    message : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    View

    pub(all) struct View {
    name : String
    definition : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A view. definition is the body after AS, held opaquely like a check expression.

    build_plan

    fn build_plan(before : Schema, after : Schema, dialect : Dialect, hints? : RenameHints) -> Result[Plan, Array[ValidationIssue]]

    Build a deterministic, auditable migration plan. Invalid schemas and stale rename hints are returned together instead of producing partial SQL.

    plan_to_json

    fn plan_to_json(plan : Plan) -> Json

    render_plan

    fn render_plan(plan : Plan, allow_destructive? : Bool) -> Result[RenderedPlan, Array[RenderIssue]]

    Render a plan into SQL. Destructive plans are blocked unless the caller explicitly opts in, making the safe path the default for both library and CLI.

    rendered_plan_to_json

    fn rendered_plan_to_json(plan : RenderedPlan) -> Json

    schema_from_json

    Parse a schema from the stable JSON representation used by the CLI.

    schema_to_json

    fn schema_to_json(schema : Schema) -> Json

    validate_schema

    fn validate_schema(schema : Schema) -> Array[ValidationIssue]

    Validate structural invariants before planning. The function returns every issue it can find so callers can fix a schema in one pass.