Help us shape the future of Go! Take the 2026 Go Developer Survey

migrate

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 15 Imported by: 0

README

arkoren.dev/core/service/database/migrate

Ordered schema migrations for arkoren.dev/core/service/database, recorded in a bookkeeping table (arkoren_migrations by default).

When a feature needs a new table or a backfill, record the change as a migration so every database applies it in the same order. The example below registers a table change with an existing db database.

Install arkoren.dev/core with go get arkoren.dev/core. Import arkoren.dev/core/service/database/migrate for the migrator, arkoren.dev/foundation/query/schema for schema operations, and arkoren.dev/foundation/query for SQL fragments. Here ctx is your deployment context; focused snippets expose error results, which your caller must check before continuing.

Declare migrations with NewMigration(id, configure...). Each callback edits the same *MigrationBuilder once, synchronously in order, and returns no value. Settings return that draft. The published Definition is immutable: owned step and dialect slices are copied on input and publication, so retained drafts do not change it. Step functions and their captures remain shared, and are not run during construction. Nil callback entries panic; expanded nil or empty slices make no edits. Application panics propagate. Construction retains the first domain error; missing Up is checked only after all callbacks, and Add rejects invalid definitions before SQL runs. Exec and Schema also copy their statement and operation slices immediately, without running migration work. Replacing an input slice entry cannot change the published step; arbitrary statement arguments still retain their own ownership rules.

migrator := migrate.New(db, func(migrator *migrate.Builder) {
	migrator.OutOfOrder(migrate.OutOfOrderError)
})

err := migrator.Add(
	migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
		migration.Up(migrate.Schema(schema.Create("users", func(table *schema.Table) {
			table.ID()
			table.String("email", 255).Unique()
		})))
		migration.Down(migrate.Schema(schema.Drop("users")))
	}),
)
err = migrator.Up(ctx) // apply pending migrations as one batch
statuses, err := migrator.Status(ctx)

To undo an applied change, use Rollback. These calls are alternatives: the first reverts the last batch, while the second reverts the last two migrations. Down steps may delete tables and their data; review them before rollback.

err := migrator.Rollback(ctx)
err = migrator.Rollback(ctx, migrate.Steps(2))

Rollback selects the newest batch first and reverses Add order within each batch, even if timestamps tie or the wall clock moves backward. Preserve the relative Add order of applied migrations. An unknown recorded id takes precedence over known ids in its batch and blocks rollback; restore its original definition before retrying.

  • Add order is run order. The list is append-only.
  • An unrecognized nonempty OutOfOrder label makes Up, Rollback, and Status return ErrInvalidMigration before session acquisition or SQL. An empty label still selects the default warning policy.
  • Ids need no number or date prefix: order comes from Add order. Name the change (create-users, add-users-email-index). A package writes short ids and its group prefixes them (acme.users/create-users).
  • Ids are stable. Renaming an applied migration runs it again.
  • MySQL ledger IDs use VARBINARY(191) NOT NULL with a full, single-column primary key. Existing unsupported layouts are rejected before migration callbacks; the migrator never converts them automatically. See below before upgrading an existing MySQL ledger.
  • Migrations run in a transaction on PostgreSQL and SQLite. MySQL runs without one. WithoutTransaction migrations must be idempotent.
  • A step runs with the migration's transaction in its context. A WithTransaction inside a step is a nested transaction.
  • Every operation retains one physical session for its lock, planning, bookkeeping, and migration work. Pass the supplied context to every facade operation; root-driver calls or fresh contexts bypass protection and can deadlock a one-connection pool. Session loss stops work without replay.
  • A lock serializes concurrent migrators (see below).
  • Packages export Migrations() []migrate.Definition, wrapped in migrate.NewGroup(name).Migrations(...), which prefixes each id with the group name and a slash (a short id containing / is ErrInvalidMigration, as is a combined id over 191 bytes); nothing self-registers. Out-of-order detection applies within each group, so a package release that appends a migration never counts as out of order against the application's own.

Dialects

A migration runs on every dialect unless Only restricts it. Add returns migrate.ErrInvalidMigration when the database uses another dialect; it never skips silently. For logic that differs per dialect, switch on db.Dialect() inside a migration step (migrate.Func) instead.

migrate.NewMigration("create-cache", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE UNLOGGED TABLE cache (id INTEGER)")))
	migration.Down(migrate.Schema(schema.Drop("cache")))
	migration.Only(query.Postgres)
})
Existing MySQL ledgers

Up, Rollback, and Status verify the actual id column and primary key with SHOW FULL COLUMNS and SHOW INDEX on the lock-owning session. This happens before history reads, any Up/Down callback, or ledger-row mutation. A confirmed unsupported layout returns ErrInvalidMigration, names its actual metadata, and requests explicit operator review. No migration event represents an unstarted step. Missing/hidden metadata and SQL inspection errors fail closed; SQL errors preserve their original cause and do not prescribe a conversion.

New ledgers are created with binary ID storage. CREATE TABLE IF NOT EXISTS does not alter an existing ledger. Character columns, even those using a _bin or NO PAD collation, are not accepted as the canonical layout. Fixed BINARY is not accepted either because it pads values. Other columns and the configured table/group/ID identities remain unchanged. Additional unique indexes are rejected because they can impose narrower prefix/expression equality even when the primary key itself is byte-exact; nonunique secondary indexes do not change that uniqueness invariant.

An unqualified MySQL table is resolved against the retained session's current database once. Qualified names are quoted component by component. All metadata, history, inserts, and deletes then use that frozen target, even if a callback changes the current database. IDs bind as their original UTF-8 bytes only at the MySQL ledger seam; they are not normalized, hashed, or renamed.

Do not run a blanket ALTER or charset conversion to bypass the guard. Before an operator explicitly converts an existing ledger:

  1. Stop all migrators, ledger writers, and conflicting DDL. Back up the schema and every (HEX(id), batch, ran_at) tuple, including unregistered history. The migration lock coordinates cooperating migrators, not arbitrary admin DDL.
  2. Prove every stored ID's bytes equal the intended Go UTF-8 identity, satisfy the unchanged 1–191-byte printable/no-whitespace definition, and fit 191 bytes without truncation. Do not infer byte identity from the charset label alone.
  3. Review non-UTF-8 data explicitly. For example, a latin1 character can have different stored bytes from its Go UTF-8 identity. If preserving stored bytes and preserving application identity conflict, stop for an owner decision; the library will not silently transcode or reinterpret history.
  4. Inspect indexes, foreign keys, triggers, generated expressions, and required column attributes. Use strict SQL mode. Change only the approved ID storage and preserve the complete primary key and all other history fields.
  5. Reconcile previously failed nontransactional migrations against the actual application schema. Their steps might already have committed despite a failed ledger insert; conversion does not make replay safe or mark them done.
  6. Verify the canonical column/index metadata and exact equality of all saved history tuples before resuming. Keep table names, group prefixes and ID bytes.

The migration role needs inspection access but no new ALTER privilege. Operator conversion is separate from migration execution. Native charset, permission and metadata behavior must be verified against your supported MySQL version; a fake SQL protocol test is not backend evidence.

Horizontal scaling

Up, Rollback, and Status hold the lock named by Builder.LockName while they work.

Dialect Lock Scope
PostgreSQL session advisory lock every instance on the database
MySQL GET_LOCK every instance on the server
SQLite in-process lock, plus flock on <dbfile>.<hash>.lock one host; Linux, macOS, and BSDs only

SQLite in memory, on other operating systems, or on a network file system has no cross-process lock, and is not a horizontal-scaling database.

PgBouncer. Transaction pooling breaks session advisory locks, because the lock and unlock can reach different server connections. Run migrations over a direct connection or a session-pooled one.

Lock timeout. Builder.LockTimeout(d) bounds the wait for the lock, including pool acquisition, not the migrations. Zero uses 5 minutes; a negative value waits until the context ends. A timeout fails with an error that wraps migrate.ErrLockTimeout and context.DeadlineExceeded.

When the caller context ends first, the error preserves that context's cause without ErrLockTimeout. Cleanup has its own bounded context. A confirmed migration commit followed by cleanup failure keeps its success event, returns the failure, and stops before the next migration; inspect status before retrying.

Idempotent migrations

A migration without a transaction (WithoutTransaction, and every MySQL migration) may stop half way and run again. Make each step safe to repeat, and prefer schema.CreateIfNotExists and schema.DropIfExists:

migrate.NewMigration("create-audit", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Schema(schema.CreateIfNotExists("audit", func(table *schema.Table) {
		table.ID()
		table.String("action", 64)
	})))
	migration.Down(migrate.Schema(schema.DropIfExists("audit")))
	migration.WithoutTransaction()
})

Rolling deploys

Old and new code run together during a rolling deploy. Use expand and contract: release additive migrations first (new tables, nullable columns, indexes), deploy code that uses them, and remove unused schema in a later release. Do not rename or drop in the release that stops using the old name.

In production, run migrations as a Job, an init container, or a dedicated command that exits when done, not from compose.Startup. With startup migrations, every instance competes for the lock and a failed migration takes instances down. Registering migrations with compose.Startup is suitable for development and single instances.

See the migration guide and API reference.

Documentation

Overview

Package migrate applies ordered schema migrations to a database.Database and records them in a bookkeeping table.

migrator := migrate.New(db, func(migrator *migrate.Builder) {
	migrator.OutOfOrder(migrate.OutOfOrderError)
})

err := migrator.Add(
	migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
		migration.Up(migrate.Schema(schema.Create("users", func(table *schema.Table) {
			table.ID()
			table.String("email", 255).Unique()
		})))
		migration.Down(migrate.Schema(schema.Drop("users")))
	}),
)
if err != nil {
	return err
}
if err := migrator.Up(ctx); err != nil {
	return err
}

To revert separately, migrator.Rollback(ctx, migrate.Steps(1)) reverts the most recently applied migration. Check its error before continuing.

Order and ids

Add order is run order, and the list is append-only: add new migrations after existing ones and never reorder or remove them. An id is stable. It is the key in the bookkeeping table, so renaming a migration makes it pending again and it runs a second time, while the old id shows up as Status.Missing. Out-of-order detection applies within each group; see Packaging. Rollback uses newest batch first and reverse Add order within each batch, not wall-clock timestamps. Preserve the relative Add order of applied ids. Unknown ids in a target batch block rollback before known ids in that batch; restore their definitions rather than guessing their original order.

Id matching in Go is byte-exact. MySQL uses VARBINARY(191) NOT NULL with a full single-column primary key for new ledgers and verifies actual metadata on every operation before user steps or history mutations. Unsupported layouts fail with ErrInvalidMigration; unreadable/hidden metadata fails closed without assuming that conversion is needed. Existing ledgers are never automatically altered. See MySQL ledger compatibility below.

Because order comes from Add order, an id needs no number or date prefix. Name a migration for the change it makes, in lowercase words joined by hyphens: "create-users", "add-users-email-index", "backfill-users-email". Ids are unique across every group. A package writes short ids and its MigrationGroup prefixes them with the group name and a slash, so "create-users" in group "acme.users" is stored as "acme.users/create-users"; see Packaging.

Transactions

By default a migration runs in a transaction together with its bookkeeping row, when the dialect rolls back DDL (query.Dialect.TransactionalDDL): PostgreSQL and SQLite. MySQL implicitly commits around DDL, so migrations run there without a transaction. A migration marked MigrationBuilder.WithoutTransaction never starts one. Such a migration must be idempotent: a failure can leave it partly applied and unrecorded, and the next Migrator.Up runs it again.

A step receives the migrator database and a context that carries the retained session and, when applicable, its transaction. Its statements join both. Calling database.Database.WithTransaction inside a transactional migration creates a nested transaction, which fails with database.ErrNestedTransaction when the driver cannot nest. There is no operation-wide transaction: earlier migration commits survive a later failure. Run deployments with an ordinary context. If ctx already carries a transaction, the migrator joins it instead, so that outermost transaction controls final commit and the migration lock's release. Even WithoutTransaction cannot escape a transaction the caller already started.

Dialects

A migration runs on every dialect unless MigrationBuilder.Only restricts it. Use Only for a shipped migration that is meant for one dialect, such as a PostgreSQL-only cache table:

migrate.NewMigration("create-cache", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE UNLOGGED TABLE cache (id INTEGER)")))
	migration.Down(migrate.Schema(schema.Drop("cache")))
	migration.Only(query.Postgres)
})

Migrator.Add rejects such a migration with ErrInvalidMigration when the database uses another dialect, before any SQL runs. It is never skipped silently. A migration whose logic differs per dialect should stay unrestricted and switch on database.Database.Dialect inside its Func.

MySQL ledger compatibility

Up, Rollback, and Status use target-addressed SHOW FULL COLUMNS and SHOW INDEX under the retained session and lock. Character columns (including _bin and NO PAD collations), fixed BINARY, nullable/generated columns, and composite or prefix primary keys are not canonical ID storage. Additional unique indexes are rejected rather than allowing narrower prefix/expression equality to collide after user steps. SQL inspection failures preserve their cause; unavailable metadata does not trigger a repair attempt. No per-migration event is emitted for work that never started.

A configured MySQL table is frozen to an actual schema/table before steps: unqualified names use the current session database; qualified names retain their components. Subsequent USE statements cannot redirect bookkeeping. ID insert/delete arguments are the original UTF-8 bytes, not character strings subject to connection conversion. Public ID and group values stay unchanged. Non-MySQL preparation and bindings retain their existing behavior.

An operator must explicitly authorize conversion of a legacy ledger. First quiesce all migrators/writers and conflicting DDL, back up the schema and every (HEX(id), batch, ran_at) tuple, and prove the stored bytes match the intended UTF-8 identities within 191 bytes without truncation. Non-UTF-8 storage can require an owner decision: physical bytes and application identity cannot both be preserved by blindly changing a charset. Do not normalize, trim, rename, or discard history. Inspect dependent constraints, triggers, generated values, and column attributes; use strict SQL mode and verify all history tuples and canonical metadata after the approved change. Reconcile earlier failed nontransactional steps before allowing replay: those changes might already be final. The library never runs ALTER for this conversion and the migration role needs no new ALTER privilege. Its lock coordinates cooperating migrators, not arbitrary administrator DDL.

Locking

Migrator.Up, Migrator.Rollback, and Migrator.Status hold the database lock named by Builder.LockName while they work. Each attempt enters database.Database.WithSession and tries the lock once. A refused attempt ends that scope before waiting to retry, releasing newly acquired pool capacity. A successful attempt retains one physical connection for the lock, table preparation, history, planning, all migration transactions and SQL, and bookkeeping. A lost session fails closed: the operation never reconnects or replays its plan through another pool connection. The guarantee depends on the dialect:

  • PostgreSQL: session advisory locks span every process and instance that uses the same database. A crashed holder loses the lock when its connection drops. Safe to run on many instances.
  • MySQL: GET_LOCK spans every process and instance that uses the same server, and ends with the session. Safe to run on many instances, but see Transactions: MySQL DDL is not transactional.
  • SQLite: an in-process lock covers all drivers in one process. For a file-backed database on Linux, macOS, and the BSDs, an flock on a sibling "<dbfile>.<hash>.lock" file also covers other processes on the same host. In-memory databases, other operating systems, and database files on network file systems get no cross-process lock. SQLite is not a horizontal-scaling database.

PgBouncer in transaction pooling mode breaks PostgreSQL session advisory locks: the lock and the unlock can run on different server connections, so the lock is not held, or never released. Run migrations over a direct connection or a session-pooled one.

Steps must pass their supplied context to every facade call, not use a fresh context or the raw database.Database.Driver. Those paths bypass the pinned session and can wait forever for another connection in a one-connection pool. Close streaming cursors before subsequent statements or transaction boundaries. Managed named locks are not reentrant: never acquire the migration's lock separately and then call the migrator. Other root-level held locks also reserve pool connections and can prevent session acquisition when the pool is full.

An existing session or transaction is borrowed, not released between failed attempts; its owner still reserves its connection. Prefer an ordinary context for deployments so contention can release all newly acquired capacity.

Lock timeout

Builder.LockTimeout bounds session/pool acquisition and lock contention. Its timer stops on acquisition, before table preparation, history, or migrations. Caller cancellation still applies throughout the work. Zero uses five minutes and a negative value waits until the context ends. When acquisition times out, the error wraps both ErrLockTimeout and context.DeadlineExceeded. If the caller's own context ends first, the error wraps that context's error instead. Unlock uses a bounded cleanup context independent of caller cancellation. Cleanup errors are joined with the original error; a failed session is never retried by the operation. A callback panic is re-raised after cleanup.

Idempotent migrations

A migration that runs without a transaction, through MigrationBuilder.WithoutTransaction or on MySQL, can stop half way and run again on the next Migrator.Up. Write each step so a second run is safe: prefer schema.CreateIfNotExists and schema.DropIfExists.

migrate.NewMigration("create-audit", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Schema(
		schema.CreateIfNotExists("audit", func(table *schema.Table) {
			table.ID()
			table.String("action", 64)
		}),
	))
	migration.Down(migrate.Schema(schema.DropIfExists("audit")))
	migration.WithoutTransaction()
})

Rolling deploys

During a rolling deploy, old and new code run against the same schema. Use expand and contract: first release a migration that only adds (tables, nullable columns, indexes) and keeps old code working; deploy the code that uses the new schema; and in a later release remove what nothing uses any more. Never rename or drop in the same release that stops using the old name.

In production, run migrations once per deploy as a Job, an init container, or a dedicated command that exits when done, before the new instances take traffic. Registering Migrator.Up with Composer.Startup makes every instance compete for the lock, delays startup, and ties migration failures to instance health. The lock keeps that safe, but it is best for development and single-instance setups.

Packaging

Nothing registers itself. A package that needs tables exports a function that returns its definitions, and the application adds them in the order it chooses:

func Migrations() []migrate.Definition {
	return migrate.NewGroup("acme.users").Migrations(
		migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
			migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
			migration.Down(migrate.Schema(schema.Drop("users")))
		}),
	)
}

err := migrator.Add(users.Migrations()...)

A package wraps its definitions in a NewGroup and gives them short ids. MigrationGroup.Migrations prefixes each id with the group name and a slash, so the stored id, Definition.ID, Status.ID, and MigrationEvent.ID are "acme.users/create-users". A short id may not contain a slash, which also rejects ids a package prefixed by hand, and the combined id must fit in 191 bytes; both are ErrInvalidMigration at Migrator.Add. Ungrouped ids may contain slashes. Any valid group name works. Arkoren's own packages name groups "arkoren.dev:<module>:<package>", plus ":<instance>" when a driver can run several instances in one database; that is a repository convention, not a rule of this package.

A group is its own append-only sequence: out-of-order detection compares a migration only with migrations of the same group, so a package release that appends a migration runs it on the next Migrator.Up without an out-of-order warning or ErrOutOfOrder, wherever the application added the package's definitions. Migrations without a group, usually the application's own, form the default group. Add order stays run order across groups: add a package's migrations before application migrations that refer to its tables.

Validation

NewMigration invokes its callbacks directly once, synchronously in order on one MigrationBuilder. Settings return that draft. Nil callbacks panic and application panics propagate; expanded nil or empty slices supply no edits. Callbacks still run when domain validation fails. The published Definition detaches owned step and dialect slices, so retaining a draft cannot change it. Step functions and their closure captures are not copied or run at construction. Exec and Schema do copy their own statement and operation slices immediately; replacing an entry in the caller's slice cannot replace a published step.

The draft retains the first domain error. Required Up is checked after all callbacks; omission is invalid even when no callbacks were supplied. Migrator.Add reports construction errors as ErrInvalidMigration. An unknown nonempty OutOfOrder policy is rejected with ErrInvalidMigration by Up, Rollback, and Status before session acquisition or SQL; empty still selects OutOfOrderWarn. A Func is opaque, so Exec with no statements or a nil statement, or Schema with no operations, is detected when the step runs, before it executes anything, not at Add.

Events

With Builder.Events, Migrator.Up publishes Applied ("database.migration.applied") after each migration, Migrator.Rollback publishes RolledBack ("database.migration.rolled-back"), and either publishes Failed ("database.migration.failed") for the migration that failed before completion. If a migration transaction commits its schema and bookkeeping but cleanup or an after-commit hook then fails, it publishes the success event with nil Err and returns the error without running later migrations. An error returned by a step remains a failure even when it matches database.ErrAfterCommit. An outer session cleanup error does not change events for migrations that already completed. Events inside a caller-owned outer transaction remain provisional until that transaction commits.

migrator := migrate.New(db, func(migrator *migrate.Builder) {
	migrator.Events(app.Events())
})

New configures the migrator component through Builder policies; declaration policies remain the separate MigrationBuilder role. Component policies run directly once in order on one draft before defaults are resolved. Nil entries panic; expanded nil/empty slices supply no edits. Publication detaches settings, not the database, logger or event bus. Construction performs no migration, bookkeeping, session or lock I/O.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidMigration reports an invalid definition or argument.
	ErrInvalidMigration = errors.New("invalid migration")

	// ErrDuplicateID reports a migration id that is already added.
	ErrDuplicateID = errors.New("duplicate migration id")

	// ErrOutOfOrder reports pending migrations that precede applied ones.
	ErrOutOfOrder = errors.New("out-of-order migrations")

	// ErrIrreversible reports a migration without a Down step.
	ErrIrreversible = errors.New("irreversible migration")

	// ErrLockTimeout reports that session acquisition or lock contention used
	// the [Builder.LockTimeout] budget before protected work began. The error
	// also wraps [context.DeadlineExceeded]. Caller-owned cancellation and
	// deadlines are returned without this marker; work is never replayed after
	// successful acquisition, even if its session or cleanup fails.
	ErrLockTimeout = errors.New("migration lock timeout")

	// ErrUnknownMigration reports a recorded migration that is not added.
	ErrUnknownMigration = errors.New("unknown migration")
)
View Source
var (
	// Applied is published after [Migrator.Up] applies and records one
	// migration.
	Applied = event.NewTopic[MigrationEvent]("database.migration.applied")

	// RolledBack is published after [Migrator.Rollback] reverts one
	// migration and deletes its record.
	RolledBack = event.NewTopic[MigrationEvent]("database.migration.rolled-back")

	// Failed is published when applying or reverting a migration fails; the
	// run stops there.
	Failed = event.NewTopic[MigrationEvent]("database.migration.failed")
)

Functions

This section is empty.

Types

type Builder

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

Builder configures New before publication. Callbacks run directly once, synchronously in order on one fresh draft. Nil callback entries panic; expanded nil or empty callback slices supply no edits. Retaining the draft cannot change published settings. Methods return the same draft.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.Table("application_migrations")
})

func (*Builder) Events

func (migrator *Builder) Events(bus *event.Bus) *Builder

Events selects the shared bus receiving Applied, RolledBack and Failed after each migration outcome. Nil publishes nothing; listeners cannot change the outcome or take ownership of migration execution.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.Events(nil)
})

func (*Builder) LockName

func (migrator *Builder) LockName(name string) *Builder

LockName selects the database lock that serializes migration work. Empty resolves to arkoren_migrations after all callbacks. Construction acquires no lock and does not change session ownership or release timing.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.LockName("application_migrations")
})

func (*Builder) LockTimeout

func (migrator *Builder) LockTimeout(timeout time.Duration) *Builder

LockTimeout sets the combined session-acquisition and lock-wait budget. Once the lock is acquired the budget no longer limits preparation or work; the caller's context still does. Zero uses five minutes, and a negative duration disables this budget without disabling caller cancellation.

Example:

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.LockTimeout(time.Minute)
})

func (*Builder) Logger

func (migrator *Builder) Logger(logger *slog.Logger) *Builder

Logger selects the shared migration log destination. Nil resolves slog.Default after all construction callbacks; no logs are emitted here.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.Logger(nil)
})

func (*Builder) OutOfOrder

func (migrator *Builder) OutOfOrder(mode OutOfOrder) *Builder

OutOfOrder selects how Up handles pending migrations older than completed migrations in the same group. The zero value warns and runs them; allow and error modes retain their existing execution-time behavior. Unknown nonempty labels remain on the draft but Up, Rollback, and Status reject them with ErrInvalidMigration before session acquisition or SQL.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.OutOfOrder(migrate.OutOfOrderError)
})

func (*Builder) Table

func (migrator *Builder) Table(table string) *Builder

Table selects the bookkeeping table. Empty resolves to arkoren_migrations after all callbacks. Construction neither creates nor inspects that table; the existing migration execution path owns bookkeeping I/O. On MySQL, an unqualified name is resolved against the retained session's current database before steps; qualified names preserve their components. The operation then uses that frozen target for metadata and all history SQL. Existing MySQL storage must pass the package's binary-ledger verification; Table does not authorize conversion or alter an existing ledger.

This setting replaces the previous value on the construction draft. Services and loggers remain shared; publication detaches owned settings.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.Table("application_migrations")
})

type BuilderFunc

type BuilderFunc = func(*Builder)

BuilderFunc configures a Builder.

type Definition

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

Definition describes one immutable migration, published by NewMigration. Its accessors never expose owned containers. Construction errors are retained for Definition.Err and rejected by Migrator.Add before SQL runs.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
})
if err := definition.Err(); err != nil {
	return err
}

func NewMigration

func NewMigration(id string, configure ...MigrationBuilderFunc) Definition

NewMigration publishes a definition with a stable bookkeeping id of 1-191 printable UTF-8 bytes without whitespace. Callbacks run directly once, synchronously in order on one draft, even if domain validation fails. Nil callbacks panic; expanded nil or empty callback slices make no edits. Callback panics propagate. With no Up, the result has the normal required-Up error. Owned step and dialect slices are detached on setting and publication.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
})
if err := definition.Err(); err != nil {
	return err
}

func (Definition) DescriptionValue

func (definition Definition) DescriptionValue() string

DescriptionValue returns the description text.

func (Definition) Dialects

func (definition Definition) Dialects() []query.Dialect

Dialects returns a copy of the dialects set by MigrationBuilder.Only, in the order given and without duplicates. Nil means the migration runs on every dialect. Changing the result does not affect the definition.

Example:

definition := migrate.NewMigration("create-cache", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE cache (id INTEGER)")))
	migration.Only(query.Postgres)
})
dialects := definition.Dialects() // An independent, one-element slice.

func (Definition) Err

func (definition Definition) Err() error

Err returns the first error found while building the definition, or a missing-Up error.

func (Definition) Group

func (definition Definition) Group() string

Group returns the name of the group set by MigrationGroup.Migrations, or the empty string for the default group.

func (Definition) ID

func (definition Definition) ID() string

ID returns the migration id.

func (Definition) Reversible

func (definition Definition) Reversible() bool

Reversible reports whether the migration has Down steps.

func (Definition) Transactional

func (definition Definition) Transactional() bool

Transactional reports whether the migration asks for a transaction. The dialect can still run it without one; see the package documentation.

type Func

type Func = func(ctx context.Context, db *database.Database) error

Func performs one migration step using the supplied database and context. The context routes every facade operation onto the lock-owning physical session and, for transactional migrations, its current transaction. Always forward this context to database calls; a fresh context or direct Driver call bypasses the scope and can deadlock a one-connection pool. The context expires when its enclosing session or transaction callback ends; do not retain it or use it concurrently.

Example:

step := func(ctx context.Context, db *database.Database) error {
	_, err := db.Exec(ctx, query.Raw("UPDATE users SET active = TRUE"))
	return err
}

func Exec

func Exec(statements ...query.Statementer) Func

Exec returns a step that runs each statement in order with database.Database.Exec. A failure is wrapped as "statement N: ..." with N starting at 1. Because a Func is opaque, calling Exec with no statements or a nil statement is detected when the step runs, before it executes anything, not when the migration is added. The statement slice is copied immediately; statement implementations and arbitrary argument values retain their existing ownership rules.

Example:

definition := migrate.NewMigration("backfill-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("UPDATE users SET active = TRUE")))
})
if err := migrator.Add(definition); err != nil {
	return err
}

func Schema

func Schema(operations ...schema.Operation) Func

Schema returns a step that runs each schema operation in order. For every operation it first checks schema.Operation.Err, then runs its statements in order with database.Database.Exec, compiled for the database's dialect. A failure is wrapped as "operation N statement M: ..." with both counted from 1; an operation that failed to construct reports "operation N: ..." before anything runs. As with Exec, an empty list is detected when the step runs, before it executes anything, and fails with ErrInvalidMigration.

Statements run one at a time, so on a dialect without transactional DDL (query.Dialect.TransactionalDDL) a failure part way leaves the earlier statements applied. The operation slice is copied immediately without rendering or executing it; later replacement of a caller's slice entry cannot change the step.

Example:

migrate.NewMigration("20261017_create_users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Schema(
		schema.Create("users", func(table *schema.Table) {
			table.ID()
			table.String("email", 255).Unique()
		}),
	))
	migration.Down(migrate.Schema(schema.Drop("users")))
})

type MigrationBuilder

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

MigrationBuilder configures a migration during NewMigration. Settings return the same draft and retain the first error. A retained draft cannot change a published Definition; step functions and their captures are shared. Missing Up is checked on the published definition, after all callbacks.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
	migration.Description("Create the users table")
})
if err := definition.Err(); err != nil {
	return err
}

func (*MigrationBuilder) Description

func (migration *MigrationBuilder) Description(text string) *MigrationBuilder

Description replaces the human-readable description, with empty text clearing it. It returns the same draft and does not change any retained error.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
	migration.Description("Create the users table")
})
fmt.Println(definition.DescriptionValue()) // Create the users table

func (*MigrationBuilder) Down

func (migration *MigrationBuilder) Down(steps ...Func) *MigrationBuilder

Down sets the steps that revert the migration. Calling it twice, with no steps, or with a nil step retains an ErrInvalidMigration error. The input slice is copied without invoking its functions. Omit Down for an irreversible migration. Down returns the same draft, including when it is invalid.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
	migration.Down(migrate.Schema(schema.Drop("users")))
})
if err := definition.Err(); err != nil {
	return err
}

func (*MigrationBuilder) Only

func (migration *MigrationBuilder) Only(dialects ...query.Dialect) *MigrationBuilder

Only restricts the migration to the listed dialects. Migrator.Add rejects the migration with ErrInvalidMigration when the migrator's database uses another dialect: it never skips a migration silently, and fails before any SQL runs.

Use it for a shipped migration that only makes sense on one dialect, such as a PostgreSQL-only cache table:

migrate.NewMigration("create-cache", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE UNLOGGED TABLE cache (id INTEGER)")))
	migration.Down(migrate.Schema(schema.Drop("cache")))
	migration.Only(query.Postgres)
})

A migration whose logic differs per dialect should not use Only; switch on database.Database.Dialect inside a Func instead.

Calling Only again replaces the earlier list, like MigrationBuilder.Description. Duplicate dialects are removed. Calling it with no dialects is an error. Without Only the migration runs on every dialect. The input list is copied; Only returns the same draft, including when it is invalid.

func (*MigrationBuilder) Up

func (migration *MigrationBuilder) Up(steps ...Func) *MigrationBuilder

Up sets the steps that apply the migration. Calling it twice, with no steps, or with a nil step retains an ErrInvalidMigration error. The input slice is copied; functions are stored for execution, not invoked here. Up returns the same draft, including when it is invalid.

Example:

definition := migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
})
if err := definition.Err(); err != nil {
	return err
}

func (*MigrationBuilder) WithoutTransaction

func (migration *MigrationBuilder) WithoutTransaction() *MigrationBuilder

WithoutTransaction configures the draft not to start a per-migration transaction. Its steps still use the lock-owning session, and still join a transaction the caller already supplied. Without an enclosing transaction, failure can leave partial schema changes without a bookkeeping row, so the migration must be safe to run again after deliberate recovery. The setting is idempotent and returns the same draft.

Example:

definition := migrate.NewMigration("create-audit", func(migration *migrate.MigrationBuilder) {
	migration.Up(migrate.Exec(query.Raw("CREATE TABLE IF NOT EXISTS audit (id INTEGER)")))
	migration.WithoutTransaction()
})

type MigrationBuilderFunc

type MigrationBuilderFunc = func(*MigrationBuilder)

MigrationBuilderFunc configures a MigrationBuilder.

type MigrationEvent

type MigrationEvent struct {
	// ID is the migration identifier.
	ID string

	// Group is the migration group; empty for the application's own.
	Group string

	// Batch is the batch the migration was applied in.
	Batch int

	// Rollback reports whether the migration was being reverted.
	Rollback bool

	// Duration is how long the migration ran.
	Duration time.Duration

	// Err is the error that failed the migration; nil for [Applied] and
	// [RolledBack].
	Err error
}

MigrationEvent is the payload of Applied, RolledBack, and Failed.

type MigrationGroup

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

MigrationGroup names an ordered sequence of migrations that a package ships. Out-of-order detection compares migrations only within one group, so a package release that adds a migration does not count as out of order against the application's own migrations. Create one with NewGroup.

func NewGroup

func NewGroup(name string) MigrationGroup

NewGroup returns a MigrationGroup called name. The name has the same rules as a migration id: 1-191 bytes, all printable and none white space. Any valid name works; Arkoren's own packages follow the repository's "arkoren.dev:<module>:<package>" convention, which this package does not enforce.

Example:

func Migrations() []migrate.Definition {
	return migrate.NewGroup("acme.users").Migrations(
		migrate.NewMigration("create-users", func(migration *migrate.MigrationBuilder) {
			migration.Up(migrate.Exec(query.Raw("CREATE TABLE users (id INTEGER)")))
			migration.Down(migrate.Schema(schema.Drop("users")))
		}),
	)
}

func (MigrationGroup) Migrations

func (group MigrationGroup) Migrations(definitions ...Definition) []Definition

Migrations returns copies of definitions that belong to the group, in the same order. Each copy's id becomes the group name, a slash, and the short id the package gave it, so a package writes stable step names ("create-users") and the group decides the full id ("acme.users/create-users") that Definition.ID, Migrator.Status, and MigrationEvent report.

A definition becomes invalid, and Migrator.Add reports it as ErrInvalidMigration, when the group name is invalid, when it already belongs to a group, when its short id contains a slash (which also rejects ids prefixed by hand), or when the combined id is longer than 191 bytes. Ungrouped ids may contain slashes.

type Migrator

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

Migrator applies migrations to a database. Create one with New.

func New

func New(db *database.Database, configure ...BuilderFunc) *Migrator

New creates a Migrator for db. It performs no I/O.

Construction callbacks run directly once, synchronously in order on one fresh draft. Nil entries panic; expanded nil or empty slices omit edits. Publication detaches owned settings, not live dependencies or captures.

Example:

migrate.New(database, func(migrator *migrate.Builder) {
	migrator.LockTimeout(time.Minute)
})

func (*Migrator) Add

func (migrator *Migrator) Add(migrations ...Definition) error

Add registers migrations in order. Add order is run order. It adds all of them or none. It returns ErrInvalidMigration for an invalid definition and ErrDuplicateID for an id that is already added. A definition restricted by MigrationBuilder.Only to dialects that exclude the database's dialect is invalid: Add reports it as ErrInvalidMigration naming the id, the allowed dialects, and the database dialect, before anything is registered or run.

func (*Migrator) Database

func (migrator *Migrator) Database() *database.Database

Database returns the database the migrator runs on.

func (*Migrator) Rollback

func (migrator *Migrator) Rollback(ctx context.Context, opts ...RollbackOption) error

Rollback reverts the last batch, or the last n migrations with Steps, in reverse application order under the migration lock. It validates every target before running anything: ErrUnknownMigration for a recorded migration that is not added, ErrIrreversible for one without Down steps. History, planning, steps, and bookkeeping share the lock-owning session; a lost session stops the run without replay. Earlier Down commits remain final unless ctx carries a surrounding transaction. Batches are reverted newest first; within a batch the registered Add order is reversed, independent of timestamp precision or clock changes. Preserve the original relative Add order of applied definitions. Unknown records in a batch take precedence over known targets and fail with ErrUnknownMigration: re-register their definitions rather than guessing their application order. MySQL validates actual binary ledger metadata before any Down callback or deletion, and never converts an existing unsupported ledger automatically.

Example:

err := migrator.Rollback(ctx, migrate.Steps(1))
if err != nil {
	return err
}

func (*Migrator) Status

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

Status lists the registered migrations in Add order, then recorded migrations that are not registered, marked Missing. Like Migrator.Up, it takes the migration lock and creates the bookkeeping table when it does not exist, so it writes to a fresh database. Both preparation and history reads use the lock-owning session. Acquisition, session loss, or cleanup failures return a nil result rather than a snapshot whose protected scope did not complete successfully. On MySQL this also verifies canonical binary ledger storage. An unsupported or unverifiable existing table returns nil and an error, never an ALTER.

Example:

statuses, err := migrator.Status(ctx)
if err != nil {
	return err
}
for _, status := range statuses {
	fmt.Println(status.ID, status.Ran)
}

func (*Migrator) Up

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

Up applies pending migrations in Add order while retaining the lock-owning physical session. It stops at the first failure without reconnecting or replaying work; earlier migration commits stay recorded. Each transactional migration commits separately unless ctx already carries an outer transaction. See Builder.OutOfOrder for pending migrations preceding applied ones. MySQL verifies canonical binary ledger storage before history or callbacks; unsupported layouts return ErrInvalidMigration without automatic conversion.

Example:

err := migrator.Up(ctx)
if err != nil {
	return err // Inspect the cause; never blindly replay nontransactional work.
}

type OutOfOrder

type OutOfOrder string

OutOfOrder says what Migrator.Up does with a pending migration that was added before a migration of the same group that already ran. Empty construction input selects OutOfOrderWarn. The string values are stable configuration labels, not an ordered set. Other nonempty labels are invalid; Up, Rollback, and Status reject them before acquiring a session.

Example: _ = migrate.New(db, func(migrator *migrate.Builder) {
	migrator.OutOfOrder(migrate.OutOfOrderError)
})
const (
	// OutOfOrderWarn logs a warning and runs the migration. It is the default.
	//
	// Example: _ = migrate.New(db, func(migrator *migrate.Builder) {
	//	migrator.OutOfOrder(migrate.OutOfOrderWarn)
	// })
	OutOfOrderWarn OutOfOrder = "warn"

	// OutOfOrderAllow runs the migration without a warning.
	//
	// Example: _ = migrate.New(db, func(migrator *migrate.Builder) {
	//	migrator.OutOfOrder(migrate.OutOfOrderAllow)
	// })
	OutOfOrderAllow OutOfOrder = "allow"

	// OutOfOrderError fails with [ErrOutOfOrder] before any migration runs.
	//
	// Example: _ = migrate.New(db, func(migrator *migrate.Builder) {
	//	migrator.OutOfOrder(migrate.OutOfOrderError)
	// })
	OutOfOrderError OutOfOrder = "error"
)

type RollbackOption

type RollbackOption func(*rollbackConfig)

RollbackOption changes how Migrator.Rollback chooses migrations.

func Steps

func Steps(n int) RollbackOption

Steps reverts the last n applied migrations instead of the last batch. An n below one makes Rollback fail with ErrInvalidMigration.

type Status

type Status struct {
	// ID is the migration id.
	ID string

	// Group is the migration's group, or the empty string for the default
	// group and for a Missing migration.
	Group string

	// Ran reports whether the migration is recorded as applied.
	Ran bool

	// Batch is the batch that applied the migration, or zero.
	Batch int

	// RanAt is when the migration was applied, in UTC, or zero.
	RanAt time.Time

	// OutOfOrder marks a pending migration that precedes an applied one of
	// the same group.
	OutOfOrder bool

	// Missing marks a recorded migration that is not added to the migrator.
	Missing bool
}

Status describes one migration for Migrator.Status.

Jump to

Keyboard shortcuts

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