rung

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 12 Imported by: 0

README

rung

CI Go Reference

Versioned SQL migrations for Go services, across PostgreSQL, MySQL, MariaDB and SQLite.

Migrations are applied and rolled back one version at a time. Each applied version is recorded in a ledger table inside the database itself, so the schema carries its own history.

m := rung.New(dialect, migrations.FS(), rung.WithReporter(render.NewConsole(nil)))

if err := m.Up(ctx, db, 0); err != nil {
    return err
}

Design

rung treats schema migrations as a step in the deployment process rather than as a runtime responsibility of the service.

Migrations are applied by a database account holding DDL privileges. The running service connects using a separate account that does not hold them. Separating the two means a defect in a request handler cannot alter the schema, and a rolling restart or a crash loop cannot modify the database beneath a running instance. A service using rung reports that its schema is out of date; it does not bring it up to date itself.

The remainder of the design follows from that decision: a command-line interface built for deployment pipelines, a confirmation prompt that fails rather than assumes an answer when no terminal is attached, and a status format intended to be read in a CI log.

Features

  • Four databases, one file set per dialect. PostgreSQL, MySQL, MariaDB and SQLite, behind a Dialect interface that confines every database-specific difference to a single place.
  • Isolated driver dependencies. The root package imports only the standard library. Each driver is confined to its dialect subpackage, so a PostgreSQL-only service never compiles the MySQL driver into its binary.
  • Embeddable migration files. Migrations are read from an io/fs.FS, so //go:embed places them in the binary that expects them, leaving no directory to distribute and nothing that can drift out of step.
  • Transactional application. Each migration commits together with its ledger row, so a file that fails partway through leaves neither a partially applied schema nor a ledger entry recording success.
  • Predictable without a terminal. A command that requires confirmation fails when given neither --force nor an attached terminal, rather than assuming an answer.
  • A ready-made command-line interface. clicmd provides up, down, status and init in roughly twenty lines of wiring.

Install

As a library:

go get github.com/gruberchris/rung

As a standalone tool:

go install github.com/gruberchris/rung/cmd/rung@latest

Or download a binary from Releases.

Migration files

Files are named NNNNNN_name.up.sql and NNNNNN_name.down.sql, in a directory per dialect:

migrations/
├── embed.go
├── postgres/
│   ├── 000001_initial_schema.up.sql
│   └── 000001_initial_schema.down.sql
├── mysql/                      # MariaDB runs this set too
│   ├── 000001_initial_schema.up.sql
│   └── 000001_initial_schema.down.sql
└── sqlite/
    ├── 000001_initial_schema.up.sql
    └── 000001_initial_schema.down.sql

A version with only one half is skipped: applying an up with no matching down would create a state the tool cannot reverse. Any file that does not parse as a migration name is ignored, so a README.md alongside them is harmless.

Applied versions are recorded in a _migrations table:

column
id serial
version integer unique
name text
applied_at timestamp UTC

Using it

Embed the files
// migrations/embed.go
package migrations

import (
    "embed"
    "io/fs"
)

//go:embed postgres mysql
var files embed.FS

func FS() fs.FS { return files }
Build a migrate binary
package main

import (
    "os"

    "github.com/gruberchris/rung/clicmd"

    _ "github.com/gruberchris/rung/dialect/mysql"    // mysql, mariadb
    _ "github.com/gruberchris/rung/dialect/postgres" // postgres, postgresql, pgx
    _ "github.com/gruberchris/rung/dialect/sqlite"   // sqlite, sqlite3

    "github.com/example/service/migrations"
)

func main() {
    cmd := clicmd.New(clicmd.Options{
        Use:       "migrate",
        Short:     "example database migration tool",
        EnvPrefix: "EXAMPLE",
        FS:        migrations.FS(),
    })
    if err := cmd.Execute(); err != nil {
        os.Exit(1)
    }
}

The driver and DSN are read from --driver / --database-uri, then EXAMPLE_DATABASE_DRIVER / EXAMPLE_DATABASE_URI, then an optional Options.Config callback.

Report drift from your server

The service does not apply migrations. It reports when its schema is out of date:

pending, err := m.Pending(ctx, db)
switch {
case err != nil:
    log.Warn("could not determine migration status", "error", err)
case len(pending) > 0:
    log.Warn("database schema is out of date; run `migrate up` before serving traffic",
        "pending_versions", pending)
default:
    log.Info("database schema is up to date")
}

Pending never creates the ledger table, so a read stays a read.

Use the engine directly
d, err := rung.For(cfg.Driver)          // "postgres", "mysql", "mariadb", …
db, err := d.OpenForMigrations(cfg.DSN) // multi-statement; never for serving traffic
m := rung.New(d, migrations.FS())

err = m.Up(ctx, db, 0)   // 0 applies everything; N stops at version N
err = m.Down(ctx, db)    // rolls back the newest applied migration

The CLI

migrate up                  # Apply every pending migration
migrate up --target 5       # Apply up to and including version 5
migrate up --dry-run        # Report what would be applied
migrate down                # Roll back the most recent migration
migrate down --steps 2      # Roll back the last two
migrate down --all          # Roll back everything
migrate status              # Show applied and pending migrations
migrate status --format json
migrate init                # Drop every table and re-apply (destructive)
$ migrate up --force
📊 Checking migration status...
Migration Status:
================
Version 1: initial_schema [Applied] 2026-01-02 01:46:55
Version 2: create_indexes [Pending]

⚡ Running migrations...
  applying      000002_create_indexes
  applied       000002_create_indexes
✅ All migrations completed successfully!

Colour is dropped automatically when the output is not a terminal, so a CI log gets the same text without escape sequences. --no-emoji and --no-color force it.

In a deploy pipeline

# --force is REQUIRED. Without a terminal there is nobody to answer the
# confirmation, and rung exits non-zero rather than assuming an answer.
#
# Note the DSN: this runs as the owner role, which holds DDL privileges. The
# service connects as a different account that does not.
docker run --rm \
  --network "${DOCKER_NETWORK}" \
  -e EXAMPLE_DATABASE_DRIVER=postgres \
  -e EXAMPLE_DATABASE_URI="${MIGRATION_DATABASE_URI}" \
  --entrypoint /app/bin/migrate \
  "${IMAGE}" \
  up --force

The separation relies on two database roles:

-- Applies migrations. Never used by the service.
CREATE ROLE example_owner LOGIN PASSWORD '…';

-- Used by the service. Can read and write rows; cannot create, alter or drop.
CREATE ROLE example_app LOGIN PASSWORD '…';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO example_app;

Supported databases

Database Driver names Directory Driver
PostgreSQL 12+ postgres, postgresql, pgx postgres/ jackc/pgx/v5
MySQL 8.0+ mysql mysql/ go-sql-driver/mysql
MariaDB 10.5+ mariadb mysql/ go-sql-driver/mysql
SQLite 3 sqlite, sqlite3 sqlite/ modernc.org/sqlite

MariaDB is served by the MySQL dialect — same wire protocol, same SQL — but it is a fork rather than a version, so CI verifies it separately.

SQLite's driver is a pure-Go translation rather than a cgo binding, so it needs no C toolchain and does not break CGO_ENABLED=0 builds or cross-compilation. Its DSN is a file path, :memory:, or a file: URI, and the pool is capped at one connection because SQLite takes a single writer — a pool turns what database/sql would have queued into SQLITE_BUSY.

Adding a dialect

Implement rung.Dialect and register it:

func init() { rung.Register(Dialect{}, "sqlite", "sqlite3") }

The interface has six methods: Name, MigrationsDir, OpenForMigrations, Rebind, LedgerDDL and LedgerExistsQuery. Implement the optional reset.Resetter as well to support the init command.

A note on MySQL and DDL

MySQL commits implicitly on most DDL, so a migration that fails partway through several CREATE TABLEs cannot be fully rolled back there. The ledger row is still correct — it is only written on success — so a failed migration is re-attempted rather than skipped. Write MySQL migrations with IF NOT EXISTS.

PostgreSQL has transactional DDL and does not have this caveat.

Contributing

See CONTRIBUTING.md. Issues and pull requests are welcome.

License

MIT © Christopher Gruber

Documentation

Overview

Package rung applies versioned SQL migrations to PostgreSQL, MySQL and MariaDB.

A migration is a pair of files, NNNNNN_name.up.sql and NNNNNN_name.down.sql. A ledger table named _migrations records which versions have been applied. Files are read from an io/fs.FS, so they can be embedded in the binary that expects them:

//go:embed postgres mysql
var files embed.FS

The model

Migrations are a deploy step, not something a server does to itself. The account that applies them holds DDL privileges; the account the running service connects as does not. Keeping the two apart means a bug in a request handler cannot alter the schema, and a rolling restart or a crash loop cannot change the database underneath a running instance.

A server should therefore report drift rather than fix it:

pending, err := m.Pending(ctx, db)
if len(pending) > 0 {
    log.Warn("database schema is out of date; run `migrate up` before serving traffic",
        "pending_versions", pending)
}

Dialects

What differs between databases lives behind Dialect and nowhere else. Importing a dialect package registers it:

import (
    _ "github.com/gruberchris/rung/dialect/mysql"    // mysql, mariadb
    _ "github.com/gruberchris/rung/dialect/postgres" // postgres, postgresql, pgx
    _ "github.com/gruberchris/rung/dialect/sqlite"   // sqlite, sqlite3
)

d, err := rung.For(cfg.Driver)

This package itself imports only the standard library; the database drivers come with the dialect packages, so a PostgreSQL-only program never compiles the MySQL driver into its binary.

Reporting

A Migrator narrates through a Reporter rather than through a logger, so the caller decides whether a migration starting is a line of prose, a structured log record, or nothing at all. See the render package for ready implementations, and clicmd for a complete cobra command tree.

Transactions

Each migration runs in one transaction together with its ledger row, so a file that fails halfway leaves no partially-built schema and no ledger row claiming it succeeded. MySQL is the caveat worth knowing: it commits implicitly on most DDL, so a migration that fails partway through several CREATE TABLEs cannot be fully rolled back there. The ledger row is still correct, because it is only written on success, so a failed migration is re-attempted rather than skipped -- which is why migration files are best written with IF NOT EXISTS.

Index

Constants

View Source
const LedgerTable = "_migrations"

LedgerTable is the name of the table recording which migrations have been applied. It is fixed rather than configurable: it is a schema contract with every database this package has already migrated.

Variables

View Source
var ErrNothingToRollback = errors.New("no migrations to roll back")

ErrNothingToRollback reports an exhausted ledger.

It is an error rather than a silent success so that a caller rolling back repeatedly -- "undo two more", "undo everything" -- has a way to know it has finished. Treating an empty ledger as success gives such a loop no termination condition.

Functions

func Names

func Names() []string

Names lists every registered driver name, including aliases, sorted. It is intended for error messages and help text.

func RebindDollar

func RebindDollar(query string) string

RebindDollar converts ? placeholders into PostgreSQL's numbered $1, $2, … form. It is exported so that third-party dialects can reuse it.

It does not parse SQL: a literal question mark inside a string literal is rewritten too. Queries with such literals should be written in the target dialect's own syntax rather than passed through Rebind.

func RebindQuestion

func RebindQuestion(query string) string

RebindQuestion returns the query unchanged, for dialects that already use ? placeholders. It exists so that every Dialect implementation states its placeholder syntax explicitly rather than by omission.

func Register

func Register(d Dialect, names ...string)

Register makes a Dialect available under one or more driver names.

It is intended to be called from a dialect package's init function, so that importing that package is what makes its names resolvable:

func init() { rung.Register(Dialect{}, "postgres", "postgresql", "pgx") }

Register panics if d is nil, if no names are given, or if a name is already registered, all of which are programming errors detectable at startup.

Types

type Dialect

type Dialect interface {
	// Name is the canonical driver name, such as "postgres" or "mysql".
	Name() string

	// MigrationsDir is the directory within the file set holding this
	// dialect's migrations. It is a single path element, not a path.
	MigrationsDir() string

	// OpenForMigrations returns a database handle able to execute a file
	// containing several statements.
	//
	// This is deliberately separate from however an application opens its own
	// pool. Both supported drivers refuse multi-statement execution by
	// default, in different ways and for different reasons, and that default
	// is what lets a driver reject an injected statement. Relaxing it is
	// appropriate for a migration tool and never for serving traffic.
	OpenForMigrations(dsn string) (*sql.DB, error)

	// Rebind converts a query written with ? placeholders into this dialect's
	// syntax. Queries are written once, with ?, and translated here.
	Rebind(query string) string

	// LedgerDDL creates the _migrations table if it does not already exist.
	LedgerDDL() string

	// LedgerExistsQuery reports whether the _migrations table exists, as a
	// single boolean column, scoped to the connected database.
	//
	// The read-only paths use this because they must not create the table they
	// claim only to inspect: a database with no ledger has simply had nothing
	// applied.
	LedgerExistsQuery() string
}

Dialect is everything that differs between the supported databases.

The rule this interface exists to enforce: nothing else may branch on which database is in use. Migration files stay in each dialect's own directory, and what genuinely diverges -- connection handling, placeholder syntax, the ledger's DDL -- lives here, selected once from a configured driver name.

Implementations must be safe for concurrent use and are expected to be stateless value types.

func For

func For(name string) (Dialect, error)

For returns the Dialect registered under a driver name.

Matching ignores case and surrounding space, and dialects register generous aliases: "postgresql" is what a deployment is likely to call it, "pgx" is the driver, and "mariadb" is what somebody running MariaDB will write even though the MySQL dialect serves it.

type Migration

type Migration struct {
	Version  int
	Name     string
	UpFile   string
	DownFile string
}

Migration is one migration, as a pair of files.

func (Migration) String

func (m Migration) String() string

String renders a migration as its zero-padded version and name, the form used in file names.

type Migrator

type Migrator struct {
	// contains filtered or unexported fields
}

Migrator applies one dialect's migrations from one file set.

A Migrator holds no database handle: the handle is passed to each call, so one Migrator can serve a short-lived command and a long-running server alike. It is safe for concurrent use.

func New

func New(d Dialect, fsys fs.FS, opts ...Option) *Migrator

New returns a Migrator reading d's migrations out of fsys.

Both d and fsys must be non-nil. The directory read is d.MigrationsDir() unless WithDir says otherwise.

func (*Migrator) Dialect

func (m *Migrator) Dialect() Dialect

Dialect returns the dialect this Migrator was built for.

func (*Migrator) Dir

func (m *Migrator) Dir() string

Dir returns the directory migrations are read from.

func (*Migrator) Down

func (m *Migrator) Down(ctx context.Context, db *sql.DB) error

Down rolls back the highest version recorded in the ledger.

It reports ErrNothingToRollback when the ledger is empty, so that a caller rolling back repeatedly can stop.

func (*Migrator) Expected

func (m *Migrator) Expected() (int, error)

Expected returns the highest version in the file set: the schema this build was written against. It is zero when the file set carries no migrations.

func (*Migrator) Load

func (m *Migrator) Load() ([]Migration, error)

Load reads the file set and returns its migrations in version order.

Files are named NNNNNN_name.up.sql and NNNNNN_name.down.sql. Anything that does not parse as that is ignored, so a README or a .gitkeep alongside the migrations is harmless.

A version with only one of its two halves is skipped rather than reported: applying an up with no matching down would create a state this package cannot reverse.

func (*Migrator) Pending

func (m *Migrator) Pending(ctx context.Context, db *sql.DB) ([]int, error)

Pending returns the versions in the file set that have not been applied, in ascending order. It does not create the ledger, which is what makes it safe for a server to call at startup to report drift.

func (*Migrator) Statuses

func (m *Migrator) Statuses(ctx context.Context, db *sql.DB) ([]Status, error)

Statuses lists every migration in the file set with its applied state, in version order. It does not create the ledger.

func (*Migrator) Up

func (m *Migrator) Up(ctx context.Context, db *sql.DB, target int) error

Up applies every migration not already recorded in the ledger, in version order.

A target above zero bounds the run: migrations up to and including that version are applied and the rest are left pending. Because migrations are ordered, the first version past the target ends the run rather than being skipped -- applying a later migration while leaving an earlier one pending would produce a schema that no sequence of migrations describes.

Up is idempotent. Applying an up-to-date database is a no-op.

type Option

type Option func(*Migrator)

Option configures a Migrator.

func WithDir

func WithDir(dir string) Option

WithDir overrides the directory migrations are read from, which defaults to the dialect's Dialect.MigrationsDir.

Use it for a file set that does not follow the convention -- a legacy layout naming the directory "postgresql", or "." for a flat directory holding a single dialect's files.

func WithReporter

func WithReporter(r Reporter) Option

WithReporter directs progress events to r. Without it a Migrator is silent.

type Reporter

type Reporter interface {
	// Applying is called immediately before a migration's up file runs.
	Applying(m Migration)
	// Applied is called after a migration and its ledger row have committed.
	Applied(m Migration)
	// Skipped is called for a migration already recorded in the ledger.
	Skipped(m Migration)
	// RollingBack is called immediately before a migration's down file runs.
	RollingBack(m Migration)
	// RolledBack is called after a rollback and its ledger deletion commit.
	RolledBack(m Migration)
	// StoppedAtTarget is called when Up halts at a version bound, reporting the
	// requested target and the version that was not applied.
	StoppedAtTarget(target, next int)
}

Reporter receives progress events as migrations are applied or rolled back.

A Migrator narrates through this rather than through a *slog.Logger, so the caller decides how progress appears: prose on a terminal, structured records in a service log, or nothing at all. A library that logs has already chosen its caller's output format.

Implementations must be safe to call with a zero-valued Migration and must not retain the value. A Migrator never calls a Reporter concurrently.

type Status

type Status struct {
	Version   int
	Name      string
	Applied   bool
	AppliedAt time.Time
}

Status reports whether a known migration has been applied.

Directories

Path Synopsis
Package clicmd builds a complete cobra command tree for applying migrations.
Package clicmd builds a complete cobra command tree for applying migrations.
cmd
rung command
Command rung applies versioned SQL migrations from a directory.
Command rung applies versioned SQL migrations from a directory.
dialect
mysql
Package mysql provides the MySQL dialect, which also serves MariaDB.
Package mysql provides the MySQL dialect, which also serves MariaDB.
postgres
Package postgres provides the PostgreSQL dialect.
Package postgres provides the PostgreSQL dialect.
sqlite
Package sqlite provides the SQLite dialect.
Package sqlite provides the SQLite dialect.
Package render turns migration progress and status into output.
Package render turns migration progress and status into output.
Package reset drops every table in a database, including the migration ledger.
Package reset drops every table in a database, including the migration ledger.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL