Skip to content

therox/gomigr

Repository files navigation

gomigr

Russian / Русский

gomigr is a PostgreSQL DSL migrator written in Go. It applies and reverts migrations in topological order based on depends-on declarations rather than plain filename sorting. It ships both as a CLI binary (cmd/gomigr) and as a Go library (root package github.com/therox/gomigr).

Installation

go install github.com/therox/gomigr/cmd/gomigr@latest

The binary needs Go 1.21+. The only runtime dependencies are github.com/jackc/pgx/v5/stdlib and gopkg.in/yaml.v3.

Configuration

Sources are merged in this order (later sources override earlier ones):

  1. Built-in defaults: migrations_dir = "./migrations". There is no default DSN — it must be provided.
  2. YAML file at --config <path> (default ./configs/config.yaml). If the file is missing, this step is silently skipped.
  3. Environment variables: GOMIGR_DSN, GOMIGR_MIGRATIONS_DIR.
  4. CLI flags: --dsn, --migrations-dir.

GOMIGR_USER (read by the CLI as one of the applied_by sources) and GOMIGR_LOG_LEVEL (logger level) live outside this merge — they are not overridden by YAML and have no CLI-flag equivalents in the merge chain. --to, --steps, --all, --yes are per-command arguments for up/down, not persistent settings.

The state table name (schema_migrations) is not configurable — it is hard-coded.

Example YAML:

dsn: "postgres://user:pass@localhost:5432/app?sslmode=disable"
migrations_dir: "./migrations"

Migration file format

File name: <version>_<snake_case_description>.sql, where <version> is a sortable identifier (integer or date such as 20260601). <version> is the migration's unique identifier (UNIQUE constraint in schema_migrations); the primary key of the table is the surrogate id column described below.

-- depends-on: 20260701
-- depends-on: 20260702, 20260703

-- +migrate Up
CREATE TABLE users (...);

-- +migrate Down
DROP TABLE users;

Parsing rules:

  • -- depends-on: comments may appear 0..N times anywhere before -- +migrate Up. A single line may list several versions separated by commas.
  • Markers -- +migrate Up and -- +migrate Down are mandatory.
  • Everything between them is the Up section; everything after -- +migrate Down is the Down section. An empty Down section is allowed (logged as a warning).
  • Sections are passed to pgx as-is. Semicolons inside string literals and $$ ... $$ blocks are not split — multi-statement bodies execute within one transaction.
  • Checksum is sha256 of the normalized file content.

CLI commands

gomigr up      [--config path] [--dsn ...] [--migrations-dir path] [--user name] [--to V | --steps N]
gomigr down    [--config path] [--dsn ...] [--migrations-dir path] [--user name] (--to V | --steps N | --all [--yes])
gomigr status  [--config path] [--dsn ...] [--migrations-dir path]
gomigr create  [--config path] [--migrations-dir path] <name>
  • up without flags applies all pending migrations in topological order. --to V restricts the batch to versions <= V plus their transitive pending dependencies; transitive dependencies with version greater than V are still applied and logged as warnings. --to and --steps are mutually exclusive.

  • up --steps N applies only the first N pending migrations in topological order. Topo order guarantees dependencies come before dependents, so the first-N prefix is self-contained — no migration in the prefix can have a dependency that was cut off. N must be a positive integer; the CLI rejects an explicit --steps 0 and negative values (the library-level UpOptions{Steps: 0} still means "no limit", for symmetry with empty To). If N >= len(pending), all pending are applied.

  • down requires an explicit scope — running gomigr down without any of --to, --steps, --all exits with code 1 and the message specify --to <version>, --steps <N> or --all. The three flags are mutually exclusive: combining any two prints --to, --steps, --all are mutually exclusive and exits with code 1.

  • down --to V reverts all applied migrations whose version is > V in reverse topological order (a dependent migration is reverted before the one it depends on).

  • down --steps N reverts the N most recently applied migrations, ordered by id in schema_migrations (chronological apply order, not lexicographic version order). N must be a positive integer; the CLI rejects an explicit --steps 0 and negative values with --steps must be a positive integer. Cascade is followed: if any of the starting N has applied dependents that fall outside the starting set, those dependents are pulled in and a WARN is logged for each extra migration. The final batch is executed in reverse topological order.

  • down --all reverts every applied migration. In an interactive terminal it prints This will revert ALL applied migrations. Type 'yes' to continue: to stderr and reads a line from stdin; the operation proceeds only when the trimmed, lowercased input equals yes. Any other input aborts with exit code 1 and aborted by user. In a non-interactive context (pipe, CI), --all without --yes exits with code 1 and --all requires --yes in non-interactive mode. Passing --yes skips the prompt entirely. --yes only makes sense alongside --all; with --to or --steps it is silently ignored.

  • status prints a table of currently applied migrations in chronological apply order (sorted by id) with columns ID, VERSION, NAME, STATUS, APPLIED_BY, APPLIED_AT. ID is the sequential number of the row in schema_migrations (BIGSERIAL, hands out values in the order migrations were applied) — it does not coincide with VERSION when depends-on reordered the natural lexicographic sequence. APPLIED_AT is rendered as RFC 3339 in UTC. Example output:

    ID  VERSION   NAME                STATUS             APPLIED_BY  APPLIED_AT
    1   20260101  create_users        applied            sergey      2026-05-15T18:42:01Z
    2   20260201  create_posts        applied            sergey      2026-05-15T18:42:01Z
    3   20260301  add_audit_trigger   applied            sergey      2026-05-15T18:42:02Z
    4   20260401  seed_demo_data      checksum_mismatch  sergey      2026-05-15T18:42:02Z
    
  • create <name> writes <UTC timestamp>_<name>.sql into the migrations directory with an empty Up/Down template. <name> must match ^[a-z0-9][a-z0-9_]*$. This command does not touch the database.

applied_by resolution order:

  1. --user <name> flag
  2. GOMIGR_USER environment variable
  3. Current OS user via os/user.Current()
  4. fallback "unknown"

depends-on semantics

  • Natural order is lexicographic by version.
  • A depends-on: B declaration in migration A forces B to be applied before A, even if version(B) > version(A).
  • Cycles are reported as an error listing the cycle vertices. gomigr does not attempt to break them.
  • A dangling dependency (version listed in depends-on: but not present anywhere on disk or in schema_migrations):
    • Detected before the batch starts: gomigr prints missing dependency: <V> required by <A> to stdout, logs an error and exits with code 2 without applying anything depending on the missing one.
    • Detected mid-batch: the current migration is not applied, the same line is printed to stdout, and previously committed migrations remain in the database (no global rollback). Exit code 2.

State storage

CREATE TABLE IF NOT EXISTS schema_migrations (
  id         BIGSERIAL PRIMARY KEY,
  version    TEXT NOT NULL UNIQUE,
  name       TEXT NOT NULL,
  checksum   TEXT NOT NULL,
  status     TEXT NOT NULL,
  applied_by TEXT NOT NULL,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
  • id is a BIGSERIAL surrogate key whose values reflect the chronological order in which migrations were applied.
  • version is the migration identifier from the file name. It is kept UNIQUE, which preserves the "one row per version" invariant that used to be enforced via PRIMARY KEY.
  • The status column stores applied or checksum_mismatch.
  • A pg_try_advisory_lock is held for the whole up/down run, preventing concurrent invocations against the same database (ErrLockBusy is reported if the lock is busy).
  • Each migration applies/reverts inside its own transaction together with the matching INSERT/DELETE on schema_migrations. There is no global rollback: a failure on the N-th migration leaves the first N-1 applied.
  • When the checksum of a file differs from the value stored in the table, gomigr does not stop. It logs an error and updates the row's status to checksum_mismatch, then continues with the rest of the batch.

Logging and exit codes

  • log/slog, text format. Level is taken from GOMIGR_LOG_LEVEL (debug|info|warn|error, default info).
  • Logged events: current DB version on start, applying/applied, reverting/reverted. If there is nothing to do, a single No migration line is emitted and the process exits with code 0.
  • Exit codes: 0 success, 1 user or configuration error, 2 migration execution error.

Library usage

The root package exposes a small facade. The library opens its own short-lived *sql.DB against pgx/v5/stdlib, performs the work and closes it on Close() — the host application's pool is never touched.

package main

import (
    "context"
    "log/slog"
    "os"

    "github.com/therox/gomigr"
)

func main() {
    ctx := context.Background()
    m, err := gomigr.New(ctx, gomigr.Options{
        DSN:           os.Getenv("DATABASE_URL"),
        MigrationsDir: "./migrations",
        Logger:        slog.New(slog.NewTextHandler(os.Stderr, nil)),
    })
    if err != nil {
        panic(err)
    }
    defer m.Close()
    if err := m.Up(ctx, gomigr.UpOptions{}); err != nil {
        panic(err)
    }
}

Migrator.Up and Migrator.Down take UpOptions / DownOptions structs rather than a single toVersion string. The library deliberately refuses to guess scope on Down — an empty DownOptions{} returns ErrDownScopeRequired instead of reverting everything.

type UpOptions struct {
    To    string // upper version bound (inclusive); "" means no bound
    Steps int    // limit pending applies; 0 means no limit; <0 is an error
}

type DownOptions struct {
    To    string // revert applied with version > To
    Steps int    // revert N most recently applied migrations (by id); >0
    All   bool   // revert every applied migration (explicit opt-in)
}

Exactly one of DownOptions.To, DownOptions.Steps, DownOptions.All must be set (To != "", Steps > 0, or All == true). Otherwise:

  • empty DownOptions{}ErrDownScopeRequired
  • two or three fields set → errors.New("gomigr: DownOptions fields To, Steps, All are mutually exclusive")
  • Steps < 0 (Up or Down) → validation error before any DB call

Three Down examples:

// Revert everything (matches the old behaviour of Down(ctx, "")).
err := m.Down(ctx, gomigr.DownOptions{All: true})

// Revert applied migrations with version > "20260301".
err := m.Down(ctx, gomigr.DownOptions{To: "20260301"})

// Revert the 3 most recently applied migrations (plus any applied
// dependents that get pulled in by cascade — those are logged at WARN).
err := m.Down(ctx, gomigr.DownOptions{Steps: 3})

UpOptions{Steps: 0} is intentionally equivalent to no limit (symmetric with To == ""); CLI-side validation rejects --steps 0 separately, because at the CLI layer the only reason to pass the flag is to set a positive cap.

Error sentinels exported from the package (match with errors.Is):

  • ErrLockBusy — another gomigr instance is holding the advisory lock for this database.
  • ErrDownScopeRequiredMigrator.Down was called with an empty DownOptions{}; the library refuses to default to "revert everything".

The library never reads environment variables, never parses YAML and never calls os.Exit. All input is passed through Options; all output goes to the supplied *slog.Logger (a nil logger turns into a discard handler).

Integration tests

Unit tests run with the default Go toolchain: go test ./.... They do not require Docker.

Integration tests live under the integration build tag and spin up a real PostgreSQL via testcontainers-go. Docker must be available locally:

go test -tags=integration ./...

The default go test ./... invocation stays green even when Docker is not installed.

Limitations

  • PostgreSQL only — no other databases.
  • No dry-run, no Go-coded migrations, no seed data, no golang-migrate compatibility layer. Use this tool only if you accept the format above.
  • Each migration is its own atomic unit; failing migration N leaves N-1 migrations applied. Manual cleanup is your responsibility.
  • down refuses to revert anything while there is an applied migration whose file is missing from migrations_dir: its depends-on is lost with the file, so cascade integrity cannot be verified. Restore the file or clean schema_migrations manually before retrying.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages