Sign in

    migration_lens

    Offline PostgreSQL index migration review with explicit execution context

    postgresql
    migration
    lint
    sql
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    10

    #MigrationLens

    CI

    Offline PostgreSQL 18 migration review for MoonBit migration-runner and CI tool authors. MigrationLens combines SQL with the runner's dispatch, transaction and timeout context, then reports actionable findings with original source ranges. Unsupported input stays visible and makes the review incomplete.

    The source is Apache-2.0. The library coordinate is cn-xjr/migration_lens@0.1.0.

    From your own MoonBit module, add the dependency:

    moon add cn-xjr/migration_lens@0.1.0

    Then import "cn-xjr/migration_lens" @migration_lens in your moon.pkg. See the consumer guide for the complete integration and validation evidence for observed release checks.

    #Try a real migration

    Use MoonBit stable 0.10.16+b1a0e846e (the release pinned in CI). JavaScript execution also needs Node.js; native execution needs a C compiler.

    git clone https://github.com/cn-xjr/MigrationLens.git cd MigrationLens moon update moon run cmd/main -- fixtures/postgres-migrations-concurrent.sql separate

    This unchanged upstream migration-runner test fixture returns a complete review and exit 0. Its source and MIT notice are in THIRD_PARTY.md. The CLI reads files and prints a report; it does not execute the SQL.

    moon run cmd/main -- fixtures/batch-indexes.sql simple --json moon run cmd/main -- fixtures/river-006-bulk-unique.sql separate moon run scripts/e2e.mbtx wasm

    The first command returns 1: two concurrent index statements in one simple-query message conflict with its implicit transaction. The second returns 2: a real River migration contains unsupported function/ALTER SQL, so its covered index findings cannot make the whole file pass. The final command checks these expected exits, saved reports, baselines and malformed input, then exits 0 when the complete workflow passes. No database or private service is needed.

    #Integrate the library

    Supply the runner's actual dispatch behavior; disabling its explicit transaction wrapper does not establish that statements are sent separately.

    ///|
    test "quickstart distinguishes runner dispatch" {
    let sql = "SET lock_timeout=100; CREATE INDEX CONCURRENTLY orders_idx ON orders(id);"
    let separate = @migration_lens.review_sql(
    sql,
    execution=SeparateStatements,
    max_lock_timeout_ms=100,
    )
    let whole_file = @migration_lens.review_sql(
    sql,
    execution=SimpleQuery,
    max_lock_timeout_ms=100,
    )
    assert_eq(separate.exit_code(), 0)
    assert_eq(whole_file.exit_code(), 1)
    assert_eq(whole_file.diagnostics[0].code, "ML101")
    }

    ExistingTransaction means separate dispatch inside a caller's transaction. For whole-file dispatch inside that transaction, use SimpleQuery together with initial_transaction=true. Unknown cannot produce a complete review. Timeout policy is opt-in: require_lock_timeout requires an effective finite value; max_lock_timeout_ms adds an inclusive upper bound. A caller-assured initial_lock_timeout_ms supplies a connection's session setting at entry.

    For CI, persist a report and compare it with a trusted baseline from the same project/rule version. Batch archives retain per-file execution and policy facts. For automatic project/version/context compatibility checks, use the new version-2 review archive. V1 APIs below remain compatible.

    ///|
    test "quickstart restores a batch and gates new findings" {
    let batch = @migration_lens.review_migrations(
    [
    @migration_lens.Migration::new(
    "migrations/001.sql",
    "DROP INDEX old_idx;",
    execution=SeparateStatements,
    ),
    ][:],
    )
    let restored = @migration_lens.BatchReport::parse_json(
    batch.to_json().stringify(),
    ).unwrap()
    assert_eq(batch.exit_code(), 1)
    assert_eq(
    @migration_lens.compare_batches(batch, restored).unwrap().exit_code(),
    0,
    )
    assert_true(restored.to_sarif() is Ok(_))
    }

    A baseline gate accepts retained findings; the full current report still has its own absolute exit code. Invalid or incomplete comparison input is rejected. No baseline is updated automatically. Single-report baselines additionally need the caller to verify matching execution/policy context.

    Run moon run scripts/consumer.mbtx wasm to compile, test and execute a separate module against a freshly packaged local ZIP. It checks source/licenses and cache exclusion, then resolves the library through a local workspace. It does not upload a package. See downstream integration for all four targets and adapting the example to your tool.

    #Supported review boundary

    AreaImplemented workflow
    Index rolloutCREATE/DROP INDEX, single-index REINDEX, rename and SET TABLESPACE
    Execution stateExplicit/simple-query transactions, chaining, savepoints, rollback and release
    Lock waitingSession/local lock_timeout, resets, caller entry facts and maximum policy
    MaintenanceExplicit table locks, ANALYZE, VACUUM, CLUSTER and materialized-view refresh
    Staged constraintsForeign keys with NOT VALID, validation, UNIQUE/PRIMARY KEY USING INDEX, SET NOT NULL
    Tool integrationText, v1/v2 JSON archives, SARIF 2.1.0, batch review and trusted baseline comparison

    Each SQL family has a closed supported grammar, documented in the detailed reference. Other DDL/DML, templates, psql commands, COPY data and unsupported clauses produce incomplete diagnostics. Input is bounded to 1,000,000 UTF-16 code units; file input must be valid UTF-8. SQL source is retained verbatim; no SQL rewrite or lossy reserialization occurs.

    complete=true means coverage of this rule set. Catalog facts, historical data, permissions, relation kinds and concurrent workload still require verification. MigrationLens does not certify deployment success or predict measured lock time.

    #Commands and documentation

    All commands run from the checkout root and return 0 (no findings), 1 (covered findings), or 2 (incomplete/invalid input). Baseline commands return 1 only for new findings; their full reports preserve accepted findings.

    Entry pointTask
    cmd/mainReview a UTF-8 SQL file with explicit execution mode
    cmd/ciSave full artifacts, check provenance and apply an optional baseline gate
    cmd/batchReview a strict JSON manifest with independent per-file context
    cmd/report, cmd/report_batchRestore saved archives and render text/JSON/SARIF
    cmd/compare, cmd/compare_batchCompare trusted complete baseline/current archives

    #Develop and verify

    moon fmt --check moon info git diff --exit-code -- pkg.generated.mbti cmd docs/pkg.generated.mbti moon check --target all --deny-warn moon test --target wasm --deny-warn moon test --target wasm-gc --deny-warn moon test --target js --deny-warn moon test --target native --deny-warn

    CI executes portable tests, CLI and packaged-consumer workflows on Linux, macOS and Windows; Linux/macOS also execute native workflows. A separate PostgreSQL 18 job checks actual transaction, timeout and lock semantics in a disposable database. Windows native runtime support has not been verified. See the validation matrix for observed results and prerequisites.

    ArchiveDelta

    pub struct ArchiveDelta {
    baseline : ReviewArchive
    current : ReviewArchive
    files : Array[FileDelta]
    }

    Checked baseline comparison retaining origin and complete source snapshots.

    ArchiveDelta::exit_code

    fn ArchiveDelta::exit_code(self : ArchiveDelta) -> Int

    ArchiveDelta::to_json

    fn ArchiveDelta::to_json(self : ArchiveDelta) -> Json

    ArchiveDelta::to_text

    fn ArchiveDelta::to_text(self : ArchiveDelta) -> String

    BatchDelta

    pub struct BatchDelta {
    baseline : BatchReport
    current : BatchReport
    files : Array[FileDelta]
    }

    Current-file order followed by unmatched baseline files in baseline order.

    BatchDelta::exit_code

    fn BatchDelta::exit_code(self : BatchDelta) -> Int

    Baseline gate only: retained findings remain in the full current snapshot.

    BatchDelta::to_json

    fn BatchDelta::to_json(self : BatchDelta) -> Json

    BatchDelta::to_text

    fn BatchDelta::to_text(self : BatchDelta) -> String

    BatchReport

    pub struct BatchReport {
    files : Array[FileReport]
    initial_lock_timeout_ms : Int?
    }

    Input order and duplicate path labels are retained. Each file has its own run.

    BatchReport::exit_code

    fn BatchReport::exit_code(self : BatchReport) -> Int

    Highest per-file exit code: incomplete (2) wins over findings (1) and clean (0).

    BatchReport::parse_json

    fn BatchReport::parse_json(source : String) -> Result[BatchReport, String]

    Restore a version-1 batch without reading SQL files or rerunning analysis. Unknown fields, duplicate keys and inconsistent contexts/exit codes are rejected. Invalid-policy reports are retained with their configuration error.

    test {
    let original = @migration_lens.review_migrations(
    [][:],
    initial_lock_timeout_ms=100,
    )
    let loaded = @migration_lens.BatchReport::parse_json(
    original.to_json().stringify(),
    ).unwrap()
    assert_eq(loaded.to_json(), original.to_json())
    }

    BatchReport::to_json

    fn BatchReport::to_json(self : BatchReport) -> Json

    BatchReport::to_sarif

    fn BatchReport::to_sarif(self : BatchReport) -> Result[Json, String]

    Each input is a separate SARIF run, retaining its source and completeness. One invalid file report/path rejects the export; no partial log is returned.

    BatchReport::to_text

    fn BatchReport::to_text(self : BatchReport) -> String

    Diagnostic

    pub(all) struct Diagnostic {
    code : String
    severity : Severity
    span : Span
    message : String
    help : String
    } derive(Eq, ToJson,
    Debug
    )

    Diagnostic::equal

    fn Diagnostic::equal(Diagnostic, Diagnostic) -> Bool

    Diagnostic::not_equal

    fn Diagnostic::not_equal(x : Diagnostic, y : Diagnostic) -> Bool

    Diagnostic::to_json

    fn Diagnostic::to_json(Diagnostic) -> Json

    Execution

    pub(all) enum Execution {
    SeparateStatements
    SimpleQuery
    ExistingTransaction
    Unknown
    } derive(Eq, ToJson,
    Debug
    )

    How the caller sends this input. ExistingTransaction is shorthand for separate dispatch in an existing transaction. For simple-query dispatch in an existing transaction, use SimpleQuery with initial_transaction=true. Unknown never establishes a passing review.

    Execution::equal

    fn Execution::equal(Execution, Execution) -> Bool

    Execution::not_equal

    fn Execution::not_equal(x : Execution, y : Execution) -> Bool

    Execution::to_json

    fn Execution::to_json(Execution) -> Json

    FileDelta

    pub struct FileDelta {
    baseline_index : Int?
    current_index : Int?
    introduced : Array[Diagnostic]
    resolved : Array[Diagnostic]
    retained : Int
    }

    Indices are zero-based into BatchDelta.baseline/current; None marks absence. Findings use current spans for introduced items and baseline spans for resolved.

    FileReport

    pub struct FileReport {
    migration : Migration
    report : Report
    }

    Migration

    pub(all) struct Migration {
    path : String
    source : String
    execution : Execution
    initial_transaction : Bool
    require_lock_timeout : Bool
    max_lock_timeout_ms : Int?
    }

    One independent input. No transaction or setting state flows between inputs.

    Migration::new

    fn Migration::new(path : String, source : String, execution~ : Execution, initial_transaction? : Bool, require_lock_timeout? : Bool, max_lock_timeout_ms? : Int) -> Migration

    Report

    pub(all) struct Report {
    source : String
    statements : Int
    complete : Bool
    diagnostics : Array[Diagnostic]
    } derive(Eq,
    Debug
    )

    Source is retained verbatim. Complete means coverage of this rule set only.

    Report::equal

    fn Report::equal(Report, Report) -> Bool

    Report::exit_code

    fn Report::exit_code(self : Report) -> Int

    0: no findings; 1: covered input has findings; 2: analysis incomplete.

    Report::not_equal

    fn Report::not_equal(x : Report, y : Report) -> Bool

    Report::parse_json

    fn Report::parse_json(source : String) -> Result[Report, String]

    Decode the version-1 single-report JSON contract without rescanning SQL. Unknown/missing fields, duplicate decoded keys, invalid Unicode, unsupported versions, invalid ranges and inconsistent completion are rejected. Original SQL, diagnostic order and every supported value are preserved. Numeric fields require plain nonnegative decimal integer tokens: fractions, exponents and negative zero are rejected before floating-point rounding.

    Report::to_json

    fn Report::to_json(self : Report) -> Json

    Report::to_repr

    Report::to_sarif

    fn Report::to_sarif(self : Report, artifact_path : String) -> Result[Json, String]

    Export every finding and the original source as SARIF 2.1.0. Paths must be repository-relative; Windows separators are normalized and UTF-8 bytes are percent-encoded. Invalid paths, Unicode, diagnostic metadata or spans return Err instead of dropping findings or repairing externally constructed reports.

    Report::to_text

    fn Report::to_text(self : Report) -> String

    ReportDelta

    pub struct ReportDelta {
    baseline : Report
    current : Report
    introduced : Array[Diagnostic]
    resolved : Array[Diagnostic]
    retained : Int
    }

    Exact multiset comparison of one caller-assured logical artifact.

    ReportDelta::exit_code

    fn ReportDelta::exit_code(self : ReportDelta) -> Int

    Baseline gate: 1 for introduced findings, otherwise 0. Retained errors remain visible in current; this result is not an absolute migration safety check.

    ReportDelta::to_json

    fn ReportDelta::to_json(self : ReportDelta) -> Json

    ReportDelta::to_text

    fn ReportDelta::to_text(self : ReportDelta) -> String

    ReviewArchive

    pub struct ReviewArchive {
    origin : ReviewOrigin
    batch : BatchReport
    }

    Version-2 durable review. One file is represented as a one-entry batch. All execution/policy facts are retained in batch; legacy v1 APIs are unchanged.

    ReviewArchive::exit_code

    fn ReviewArchive::exit_code(self : ReviewArchive) -> Int

    ReviewArchive::parse_json

    fn ReviewArchive::parse_json(source : String) -> Result[ReviewArchive, String]

    Strict restoration without reanalysis. Unknown origins may be viewed but comparison requires a compatible PostgreSQL origin. v1 is never upgraded by guessing: use Report/BatchReport::parse_json to view it, or regenerate it.

    ReviewArchive::review

    fn ReviewArchive::review(project : String, inputs : ArrayView[Migration], initial_lock_timeout_ms? : Int) -> Result[ReviewArchive, String]

    Analyze inputs now; never stamp provenance onto an old unverified report. Project is a stable caller-assigned identifier, 1..256 UTF-16 units.

    ReviewArchive::to_json

    fn ReviewArchive::to_json(self : ReviewArchive) -> Json

    ReviewArchive::to_sarif

    fn ReviewArchive::to_sarif(self : ReviewArchive) -> Result[Json, String]

    ReviewArchive::to_text

    fn ReviewArchive::to_text(self : ReviewArchive) -> String

    ReviewOrigin

    pub struct ReviewOrigin {
    project : String
    tool_version : String
    rules_version : String
    database : String
    }

    Declared provenance, not authentication. Baselines must still be trusted.

    Severity

    pub(all) enum Severity {
    Error
    Warning
    Incomplete
    } derive(Eq, ToJson,
    Debug
    )

    Severity::equal

    fn Severity::equal(Severity, Severity) -> Bool

    Severity::not_equal

    fn Severity::not_equal(x : Severity, y : Severity) -> Bool

    Severity::to_json

    fn Severity::to_json(Severity) -> Json

    Severity::to_repr

    Span

    pub(all) struct Span {
    start : Int
    end : Int
    } derive(Eq, ToJson,
    Debug
    )

    Half-open UTF-16 code-unit offsets into the unchanged input string.

    Span::equal

    fn Span::equal(Span, Span) -> Bool

    Span::not_equal

    fn Span::not_equal(x : Span, y : Span) -> Bool

    Span::to_json

    fn Span::to_json(Span) -> Json

    Span::to_repr

    compare_archives

    fn compare_archives(baseline : ReviewArchive, current : ReviewArchive) -> Result[ArchiveDelta, String]

    Reject project/tool/rule/database differences and changed context on every common path occurrence before applying the legacy exact multiset algorithm. File additions/removals/reordering remain supported. Duplicate paths pair by occurrence; no rename or semantic-equivalence inference is performed.

    compare_batches

    fn compare_batches(baseline : BatchReport, current : BatchReport) -> Result[BatchDelta, String]

    Match exact path/context keys, then duplicate occurrences in source order. Context includes shared initial timeout. Changed paths/policies are removals and additions; SQL source itself is compared using compare_reports. The caller must select trusted reports from the same project and rule version.

    test {
    let inputs = [
    @migration_lens.Migration::new(
    "a.sql",
    "DROP INDEX a;",
    execution=SeparateStatements,
    ),
    ]
    let batch = @migration_lens.review_migrations(inputs[:])
    let delta = @migration_lens.compare_batches(batch, batch).unwrap()
    assert_eq(delta.files[0].retained, 1)
    assert_eq(delta.exit_code(), 0)
    }

    compare_reports

    fn compare_reports(baseline : Report, current : Report) -> Result[ReportDelta, String]

    Compare complete reports from the same artifact, rule version and caller context. Identity is exact code/severity/message/help plus source-span text; only position shifts are ignored. Duplicate multiplicity is preserved. Incomplete or malformed input returns Err, never a passing partial delta.

    test {
    let before = @migration_lens.review_sql(
    "DROP INDEX a;",
    execution=SeparateStatements,
    )
    let after = @migration_lens.review_sql(
    "-- shifted\nDROP INDEX a;",
    execution=SeparateStatements,
    )
    let delta = @migration_lens.compare_reports(before, after).unwrap()
    assert_eq(delta.retained, 1)
    assert_eq(delta.exit_code(), 0)
    }

    review_migrations

    fn review_migrations(inputs : ArrayView[Migration], initial_lock_timeout_ms? : Int) -> BatchReport

    Review every input even if earlier inputs are incomplete or have findings. An optional initial_lock_timeout_ms starts every independent review with the same caller-assured session value; it never flows from an earlier input.

    review_sql

    fn review_sql(source : String, execution~ : Execution, initial_transaction? : Bool, require_lock_timeout? : Bool, max_lock_timeout_ms? : Int, initial_lock_timeout_ms? : Int) -> Report

    Analyze PostgreSQL 18 index statements, maintenance, locks and transactions. The caller must describe dispatch; comments never override the execution mode. A positive max_lock_timeout_ms also enables timeout checking. Invalid limits yield ML011 and an incomplete report without analyzing statements. initial_lock_timeout_ms is a caller-assured session value with no unmodeled SET LOCAL override. Existing-transaction rollback has an unknown earlier baseline. Negative initial values yield ML012 without analyzing statements.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io