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
- Variables
- func Command(open func(ctx context.Context, allowOutOfOrder bool) (*Migrator, error)) *cli.Command
- func Create(dir, name string) (string, string, error)
- func Merge(sources ...fs.FS) (fs.FS, error)
- type Config
- type Dialect
- type Migration
- type Migrator
- func (m *Migrator) Down(ctx context.Context) error
- func (m *Migrator) DownTo(ctx context.Context, version int64) error
- func (m *Migrator) Status(ctx context.Context) ([]Migration, error)
- func (m *Migrator) Up(ctx context.Context) error
- func (m *Migrator) UpByOne(ctx context.Context) error
- func (m *Migrator) UpTo(ctx context.Context, version int64) error
- func (m *Migrator) Version(ctx context.Context) (int64, error)
Constants ¶
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 ¶
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 ¶
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 ¶
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
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.
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 ¶
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) DownTo ¶
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 ¶
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.