migrationx

package
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Sep 27, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

migrationx

Your schema's history as ordered SQL files — applied in transactions, verified by checksum, locked against concurrent deploys.

Status: v0, released — ships inside the cli module (tagged cli/vX.Y.Z). Database drivers never appear here — the engine runs on your *sql.DB (sqlite and mysql dialects).

TL;DR

go get github.com/Wigata-Intech/w-tools/cli
  • Migrations are <unix-timestamp>_<name>.up.sql / .down.sql pairs, minted by Create (timestamps, not sequence numbers — two engineers can't collide on merge), shipped via embed.FS or a directory
  • Fail-closed on every run: checksum tampering, orphaned history, and late-merged out-of-order migrations all abort; out-of-order applies only with explicit permission
  • Concurrent deploys are serialized by a database-side lock, and an already-applied migration is skipped, never re-executed (-- migrationx:no-transaction migrations excepted — deploy those serially)
  • On mysql, a no-transaction migration that fails partway leaves its history row dirty; Up/Down refuse to run again until an operator resolves it by hand, Status reports it inline via Migration.Dirty instead of failing
  • Rollbacks are audited: warn-level log lines with version, host, and operator — the database keeps no trace of a down by design
  • Trigger/procedure bodies keep their semicolons inside -- migrationx:statement begin / end
  • Zero third-party dependencies

How to use with cli

One call wires the whole migrate tree into your command root:

root.Commands = append(root.Commands, migrationx.Command(
	func(ctx context.Context, allowOutOfOrder bool) (*migrationx.Migrator, error) {
		db, err := sql.Open("your-driver", cfg.DSN) // your driver, your DSN
		if err != nil {
			return nil, err
		}
		sub, _ := fs.Sub(migrations, "migrations") // //go:embed migrations/*.sql
		return migrationx.New(db, sub, migrationx.Config{
			Dialect:         migrationx.DialectSQLite,
			AllowOutOfOrder: allowOutOfOrder,
		})
	},
))
app migrate create add_users        # scaffold <unix>_add_users.{up,down}.sql
app migrate up                      # apply all pending
app migrate up -one | -to <version>
app migrate up -allow-out-of-order  # accept a late-merged branch
app migrate down                    # roll back the newest; -all | -to <version>
app migrate status                  # ✓ applied, pending, out-of-order

How to use standalone

The engine is plain database/sql — no cli required:

m, err := migrationx.New(db, migrationsFS, migrationx.Config{Dialect: migrationx.DialectMySQL})
if err != nil {
	return err // a stray file, bad name, or unparseable SQL aborts here, never at apply time
}
if err := m.Up(ctx); err != nil {
	return err
}

Up, UpByOne, UpTo, Down, DownTo, Status, Version — every mutating call verifies first (checksums, orphans, ordering) and each migration runs in its own transaction with the history write, commit or rollback as one unit. On mysql, DDL auto-commits mid-migration — single-DDL-statement migrations are the recommended practice there.

A -- migrationx:no-transaction migration carries that same exposure by design: its statements run outside any transaction, so a failure partway through can leave the schema changed with no history row to show it. The history table's dirty column tracks that case — Up/Down refuse to run again while any version is dirty, and Status surfaces it via Migration.Dirty instead of failing. Resolving it is a manual operator step: verify or repair the schema by hand, then either UPDATE <table> SET dirty = 0 WHERE version = ... or delete the row if the migration never really landed, before rerunning. New heals a history table created before this column existed, adding it automatically.

Library-owned migrations

A shared library can ship the DDL for its own tables as an embedded fs.FS. Merge combines it with the service's migrations into one timeline, one history table, ordered by version:

merged, err := migrationx.Merge(appMigrations, somelib.Migrations)
if err != nil {
	return err // a nil source, an unlistable source, or the same file name in two sources
}
m, err := migrationx.New(db, merged, migrationx.Config{Dialect: migrationx.DialectMySQL})

From each source's root, directories and dot-files (a .gitkeep keeping the //go:embed pattern valid) are skipped; every other file passes through, so New still rejects a stray or mistyped file such as 100_a.up.sq. The same file name in two sources fails Merge with ErrDuplicateFile; the same version under two names fails New as it always has. Checksums are computed over each source's own bytes, and Create still writes to one directory — the service's.

What it costs

Fake-driver numbers — the engine's own work with the database costing nothing. Measured on a MacBook Pro — Apple M2 Pro (10 cores), 16 GB RAM, macOS 27.0, go1.26.8.

cd cli/migrationx && go test -run='^$' -bench=. -benchmem .
Raw output
goos: darwin
goarch: arm64
pkg: github.com/Wigata-Intech/w-tools/cli/migrationx
cpu: Apple M2 Pro
BenchmarkParseScript-10    	   31846	     36977 ns/op	   42320 B/op	     509 allocs/op
BenchmarkLoad-10           	    7239	    175905 ns/op	  130221 B/op	    3443 allocs/op
BenchmarkUpTen-10          	   25144	     46353 ns/op	   40046 B/op	     562 allocs/op
PASS
ok  	github.com/Wigata-Intech/w-tools/cli/migrationx	4.637s
Measure Result
Scanning a 100-statement file (BenchmarkParseScript) ~36 µs
Loading + checksumming 100 migration pairs (BenchmarkLoad) ~176 µs
Applying ten migrations — transactions, probes, history (BenchmarkUpTen) ~47 µs of engine overhead

Migrations run once per deploy; real cost is your SQL, not the engine. Fuzzing covers the two own-parsers — the statement scanner (mirror-oracle invariants) and the filename parser (round-trip invariants):

Fuzzing — commands and raw output
$ go test -run='^$' -fuzz=FuzzParseScript -fuzztime=10s .
$ go test -run='^$' -fuzz=FuzzParseFilename -fuzztime=10s .

The full story — module overview, precedence, entrypoints: the cli README.

Documentation

Overview

Package migrationx is a SQL migration engine on the standard library: ordered SQL files, a history table with checksums, transactions where the database supports them, and a migration lock — with database drivers staying entirely on the consumer's side.

Migrations are pairs of files named <unix-timestamp>_<name>.up.sql and .down.sql, minted by Create and shipped via embed.FS or a directory. Merge combines several sources — a service's own migrations and those its libraries embed — into one timeline for New. The engine runs on the consumer's *sql.DB against sqlite or mysql.

Every mutating run verifies before it acts, and ambiguity aborts: an applied migration whose file changed (checksum mismatch) or disappeared (orphan) stops the run; pending migrations older than the newest applied one — the late-merged branch — are refused unless Config.AllowOutOfOrder acknowledges them.

Concurrent runners are serialized by a database-side lock, and a migration another runner already applied is skipped, never re-executed. The one exception is a no-transaction migration: its statements run outside any transaction, so the concurrent-runner guarantee does not cover it — deploy those serially.

On mysql, a no-transaction migration's statements can fail partway through with no transaction to roll back, leaving the schema changed but the history row absent or stale. The history table's dirty column tracks this: the row is marked dirty before the statements run and cleared on success, so a version a failed run left dirty stays visible afterward. Up and Down refuse to run again while any version is dirty; Status and Version report it (Migration.Dirty) without failing — they take no lock, so a legitimate run in progress must not look like a failure to either. Resolving a dirty version is an operator step this package cannot automate: inspect and repair whatever the failed statements left half-done, then clear the row — UPDATE the history table setting dirty to 0, or delete the row if the migration never really applied — before rerunning.

A migration engine bug is a data incident, not a bug report: when in doubt, this package stops.

Index

Constants

View Source
const (
	// DefaultTable is the history table name when Config.Table is empty.
	DefaultTable = "migration_histories"

	// DefaultLockTimeout bounds the wait for the migration lock when
	// Config.LockTimeout is zero.
	DefaultLockTimeout = 30 * time.Second
)

Variables

View Source
var ErrDuplicateFile = errors.New("migrationx: duplicate migration file")

ErrDuplicateFile reports that two sources passed to Merge carry a file with the same name.

Functions

func Command

func Command(open func(ctx context.Context, allowOutOfOrder bool) (*Migrator, error)) *cli.Command

Command builds the migrate command tree — create, up, down, status — for registration in a cli root. open constructs the Migrator after flags resolve: the DSN, dialect, and filesystem live in the caller's own configuration; allowOutOfOrder carries the --allow-out-of-order flag into Config.

The grammar: the bare verb does the safe default (up applies everything, down rolls back one), --one/--all flip the quantity, --to targets a version, and combining a quantity flag with --to is an error.

func Create

func Create(dir, name string) (string, string, error)

Create scaffolds a timestamped migration pair in dir — <unix>_<name>.up.sql and .down.sql — and returns both paths. The version is the current unix second: tool-minted timestamps are what keep two engineers' migrations from colliding on merge. The name must be lowercase [a-z0-9_].

func Merge added in v0.2.1

func Merge(sources ...fs.FS) (fs.FS, error)

Merge combines several migration sources — a service's own directory plus the embedded migrations of the libraries it uses — into one flat filesystem for New, so they share one ordered timeline and one history table.

From each source's root, Merge skips directories and dot-files (names starting with "."), such as a .gitkeep that keeps a //go:embed pattern valid. Every other root entry is carried into the merged view unchanged, so New still rejects anything that is not a well-formed migration file, exactly as it does for a single source.

The same file name in two sources is an error wrapping ErrDuplicateFile; the same version under two different names passes Merge and is rejected by New. A nil source or a source whose root cannot be listed is an error. Merge with no sources returns an empty filesystem.

The file listing is taken when Merge runs; opening a file reads it from the source that owns it, so its bytes — and the checksum New records — are the source's own.

Types

type Config

type Config struct {
	// Dialect selects the database: DialectSQLite or DialectMySQL.
	Dialect Dialect

	// Table is the history table name. Default DefaultTable.
	Table string

	// Log receives progress and the down-audit lines. Nil means
	// slog.Default().
	Log *slog.Logger

	// AllowOutOfOrder applies pending migrations older than the newest
	// applied one — the late-merged branch — instead of refusing. A
	// deploy-time human decision: never enable it unattended.
	AllowOutOfOrder bool

	// LockTimeout bounds the wait for the migration lock. Default
	// DefaultLockTimeout.
	LockTimeout time.Duration
}

Config configures New. Dialect is required; every other field has a safe default.

type Dialect

type Dialect int

Dialect selects the database-specific behavior: history-table DDL, the migration lock, and transaction control. Config.Dialect is required.

const (
	DialectSQLite Dialect = iota + 1
	DialectMySQL
)

The supported dialects. The zero value is invalid — choosing a database is an explicit decision.

type Migration

type Migration struct {
	Version    int64
	Name       string
	Applied    bool
	AppliedAt  time.Time // zero when not applied, or when the stored value was unreadable
	OutOfOrder bool      // pending and older than the newest applied migration still present on the filesystem
	Orphaned   bool      // applied but its migration file is missing from the filesystem
	Dirty      bool      // applied but left dirty by a previous no-transaction run; resolve manually, see doc.go
}

Migration is one row of Status: a version known to the filesystem, the history table, or both.

type Migrator

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

Migrator applies, rolls back, and reports migrations. Create one with New; it is safe for a single caller — migrations are a deploy step, not a concurrent workload.

func New

func New(db *sql.DB, fsys fs.FS, cfg Config) (*Migrator, error)

New validates the configuration, loads and validates every migration file, and creates the history table if it does not exist — the first deploy needs no manual bootstrap.

func (*Migrator) Down

func (m *Migrator) Down(ctx context.Context) error

Down rolls back the applied migration with the highest version.

func (*Migrator) DownTo

func (m *Migrator) DownTo(ctx context.Context, version int64) error

DownTo rolls back every applied migration with a version greater than version, highest first; DownTo(ctx, 0) rolls back everything. Negative versions are treated as 0.

func (*Migrator) Status

func (m *Migrator) Status(ctx context.Context) ([]Migration, error)

Status reports every migration the filesystem or the history table knows, in version order. An applied migration whose file is missing is reported with Orphaned set instead of failing the call — Up, Down, and Version still fail closed on the same condition via verify. A checksum mismatch still fails the call, though: the row exists on both sides, so there is no honest single Migration to render for it, and drift between what ran and what's on disk is the more dangerous state — orphans are reported inline, checksum mismatches fail closed. It takes no lock. A version left dirty by a previous no-transaction run is reported with Dirty set, never as an error — Up and Down are the calls that refuse dirty state.

func (*Migrator) Up

func (m *Migrator) Up(ctx context.Context) error

Up applies every pending migration in version order.

func (*Migrator) UpByOne

func (m *Migrator) UpByOne(ctx context.Context) error

UpByOne applies exactly the oldest pending migration.

func (*Migrator) UpTo

func (m *Migrator) UpTo(ctx context.Context, version int64) error

UpTo applies pending migrations up to and including version.

func (*Migrator) Version

func (m *Migrator) Version(ctx context.Context) (int64, error)

Version reports the newest applied version, 0 when none. Like Status, it takes no lock and does not fail on dirty state — Up and Down are the calls that refuse it.

Jump to

Keyboard shortcuts

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