fix: apply pending migrations without version arithmetic #1

Merged
end merged 1 commits from fix/pending-migration-identifiers into main 2026-10-06 00:14:36 -07:00
5 changed files with 430 additions and 21 deletions
Showing only changes of commit 4ca353dd7e - Show all commits

No files matched your search

+16
View File
@@ -24,6 +24,22 @@ mod, err := migrate.ModuleMigrations(&migrate.MigrationOpts{
Register `mod` and optionally `migrate.ModuleAutoMigrate(true)` after the database module.
## Applying pending migrations
`ApplyPendingMigrations(ctx, pendingCount)` returns the number of migrations
successfully applied, including empty migrations. A count of `0` applies none,
a negative count applies all pending migrations, and a positive count applies
at most that many. Reaching the end of pending migrations is successful, even
when fewer migrations remain than the positive cap. A failure returns the
successful count so far and the migration/provider error.
Migration versions are identifiers: contiguous numbers, gaps, timestamps, and
mixed numbering all work without treating a version difference as a count.
`AutoMigrate` applies all pending migrations when enabled, retains per-migration
and completion logs with actual version identifiers and successful counts, and
returns `ErrPendingMigrations` when disabled and newer migrations exist.
Already-current databases are a no-op in either mode.
## CLI
Use [migrate/cli](cli) with `appcli` — not the removed `migrate/cmd` package.
+9
View File
@@ -9,20 +9,29 @@ require (
github.com/pressly/goose/v3 v3.24.3
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v3 v3.3.3
modernc.org/sqlite v1.37.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jet/jet/v2 v2.13.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
)
replace (
+33
View File
@@ -6,6 +6,8 @@ github.com/go-jet/jet/v2 v2.13.0 h1:DcD2IJRGos+4X40IQRV6S6q9onoOfZY/GPdvU6ImZcQ=
github.com/go-jet/jet/v2 v2.13.0/go.mod h1:YhT75U1FoYAxFOObbQliHmXVYQeffkBKWT7ZilZ3zPc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -14,6 +16,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
@@ -36,20 +40,49 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+38 -21
View File
@@ -2,6 +2,7 @@ package migrate
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
@@ -9,7 +10,11 @@ import (
"github.com/pressly/goose/v3"
)
// ApplyPendingMigrations applies pending migrations. pendingCount: 0 none, <0 auto-detect, >0 cap.
// ApplyPendingMigrations applies pending migrations and returns the number successfully applied.
// A zero pendingCount is a no-op, a negative value applies all pending migrations,
// and a positive value caps successful applications. Exhausting pending migrations
// is successful even when fewer than the requested cap remain. Versions are identifiers,
// not counts, and may contain gaps or timestamps.
func ApplyPendingMigrations(ctx context.Context, pendingCount int64) (int64, error) {
migrationsModule.RequireLoaded()
p, err := Provider()
@@ -17,29 +22,34 @@ func ApplyPendingMigrations(ctx context.Context, pendingCount int64) (int64, err
return 0, err
}
if pendingCount == 0 {
return 0, nil
}
count, _, err := applyPendingMigrations(ctx, p, migrationsModule.Logger(), pendingCount)
return count, err
}
if pendingCount < 0 {
curr, target, err := p.GetVersions(ctx)
if err != nil {
return 0, err
}
pendingCount = target - curr
}
type migrationStepper interface {
UpByOne(context.Context) (*goose.MigrationResult, error)
}
var count int64
for range pendingCount {
// The last successful version is used for summaries; it cannot be inferred from count.
func applyPendingMigrations(ctx context.Context, p migrationStepper, logger *slog.Logger, pendingCount int64) (count, lastVersion int64, err error) {
for pendingCount < 0 || count < pendingCount {
res, err := p.UpByOne(ctx)
if err := handleMigrationResults(migrationsModule.Logger(), res, err); err != nil {
return count, err
// A migration itself can fail with ErrNoNextVersion. Goose reports that
// through PartialError, which must not be mistaken for normal exhaustion.
var partial *goose.PartialError
if res == nil && errors.Is(err, goose.ErrNoNextVersion) && !errors.As(err, &partial) {
return count, lastVersion, nil
}
if res != nil && res.Error == nil {
count++
if err := handleMigrationResults(logger, res, err); err != nil {
return count, lastVersion, err
}
if res == nil {
return count, lastVersion, nil
}
count++
lastVersion = res.Source.Version
}
return count, nil
return count, lastVersion, nil
}
// MigrateToBlank rolls back all applied migrations.
@@ -102,17 +112,24 @@ func AutoMigrate(ctx context.Context) error {
migrationsModule.Logger().Info("Pending migrations detected, applying them...", fields...)
now := time.Now()
count, err := ApplyPendingMigrations(ctx, target-current)
count, lastVersion, err := applyPendingMigrations(ctx, p, migrationsModule.Logger(), -1)
if count > 0 {
current = lastVersion
}
if err != nil {
migrationsModule.Logger().Error("Couldn't apply pending migrations", "current", current+count, "target", target, "err", err)
migrationsModule.Logger().Error("Couldn't apply pending migrations", "current", current, "target", target, "appliedCount", count, "err", err)
return err
}
migrationsModule.Logger().Info("Applied pending migrations", "current", target, "appliedCount", count, "duration", time.Since(now))
migrationsModule.Logger().Info("Applied pending migrations", "current", current, "appliedCount", count, "duration", time.Since(now))
return nil
}
func handleMigrationResults(logger *slog.Logger, res *goose.MigrationResult, err error) error {
if err != nil {
var partial *goose.PartialError
if errors.As(err, &partial) && partial.Failed != nil {
_ = handleMigrationResults(logger, partial.Failed, nil)
}
return err
}
if res == nil {
+334
View File
@@ -0,0 +1,334 @@
package migrate
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"testing"
"testing/fstest"
"time"
"gitea.auvem.com/go-toolkit/app"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
)
// These tests own the package globals sequentially and use a fresh in-memory DB.
func operationsFixture(t *testing.T, versions []int64) (*goose.Provider, *sql.DB, *bytes.Buffer) {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
require.NoError(t, err)
db.SetMaxOpenConns(1)
t.Cleanup(func() { require.NoError(t, db.Close()) })
files := fstest.MapFS{}
for _, version := range versions {
name := fmt.Sprintf("%05d_migration.sql", version)
files[name] = &fstest.MapFile{Data: []byte(fmt.Sprintf("-- +goose Up\nCREATE TABLE migration_%d (id INTEGER);\n-- +goose Down\nDROP TABLE migration_%d;\n", version, version))}
}
p, err := goose.NewProvider(goose.DialectSQLite3, db, files)
require.NoError(t, err)
logs := installOperationsProvider(t, p)
return p, db, logs
}
func installOperationsProvider(t *testing.T, p *goose.Provider) *bytes.Buffer {
t.Helper()
oldProvider, oldModule, oldEnabled := migrationProvider, migrationsModule, autoMigrateEnabled
t.Cleanup(func() {
migrationProvider, migrationsModule, autoMigrateEnabled = oldProvider, oldModule, oldEnabled
})
logs := new(bytes.Buffer)
mod := app.NewModule(ModuleMigrationsName, app.ModuleOpts{})
lifecycle := app.NewLifecycle(mod).WithLogger(slog.New(slog.NewJSONHandler(logs, nil)))
require.NoError(t, lifecycle.Setup())
migrationProvider, migrationsModule, autoMigrateEnabled = p, mod, true
logs.Reset()
return logs
}
var identifierHistories = []struct {
name string
versions []int64
}{
{"contiguous", []int64{1, 2, 3}},
{"small_gaps", []int64{1, 4, 8}},
{"sequential_to_timestamp", []int64{28, 29, 20260929235340}},
{"timestamps", []int64{20260929235340, 20261001010203, 20261005120000}},
{"mixed", []int64{1, 2, 29, 20260929235340, 20261001010203}},
}
func seedHistory(t *testing.T, p *goose.Provider, count int) {
t.Helper()
for range count {
_, err := p.UpByOne(t.Context())
require.NoError(t, err)
}
}
func assertHistory(t *testing.T, p *goose.Provider, db *sql.DB, versions []int64, applied int) {
t.Helper()
current, target, err := p.GetVersions(t.Context())
require.NoError(t, err)
var expected int64
if applied > 0 {
expected = versions[applied-1]
}
require.Equal(t, expected, current)
require.Equal(t, versions[len(versions)-1], target)
var actual int
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM goose_db_version WHERE version_id > 0 AND is_applied = 1").Scan(&actual))
require.Equal(t, applied, actual)
}
func TestApplyPendingMigrationsIdentifiersAndCaps(t *testing.T) {
for _, history := range identifierHistories {
for _, initial := range []int{0, 1, len(history.versions)} {
for _, cap := range []int64{0, -1, -7, 1, 2, 100} {
t.Run(fmt.Sprintf("%s/initial_%d/cap_%d", history.name, initial, cap), func(t *testing.T) {
p, db, _ := operationsFixture(t, history.versions)
seedHistory(t, p, initial)
want := len(history.versions) - initial
if cap >= 0 && cap < int64(want) {
want = int(cap)
}
count, err := ApplyPendingMigrations(t.Context(), cap)
require.NoError(t, err)
require.Equal(t, int64(want), count)
assertHistory(t, p, db, history.versions, initial+want)
})
}
}
}
}
func TestAutoMigrateIdentifiersAndEnabled(t *testing.T) {
for _, history := range identifierHistories {
for _, initial := range []int{0, 1, len(history.versions)} {
for _, enabled := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/initial_%d/enabled_%t", history.name, initial, enabled), func(t *testing.T) {
p, db, logs := operationsFixture(t, history.versions)
seedHistory(t, p, initial)
autoMigrateEnabled = enabled
err := AutoMigrate(t.Context())
if !enabled && initial < len(history.versions) {
require.ErrorIs(t, err, ErrPendingMigrations)
assertHistory(t, p, db, history.versions, initial)
record := logWithMessage(t, logs, "Pending migrations detected, but auto-migration is disabled. Run `migrate up` to apply them.")
require.Equal(t, "ERROR", record["level"])
} else {
require.NoError(t, err)
assertHistory(t, p, db, history.versions, len(history.versions))
if initial == len(history.versions) {
record := logWithMessage(t, logs, "No pending migrations")
require.Equal(t, strconv.FormatInt(history.versions[initial-1], 10), record["version"].(json.Number).String())
} else {
record := logWithMessage(t, logs, "Applied pending migrations")
require.Equal(t, strconv.Itoa(len(history.versions)-initial), record["appliedCount"].(json.Number).String())
require.Equal(t, strconv.FormatInt(history.versions[len(history.versions)-1], 10), record["current"].(json.Number).String())
}
}
})
}
}
}
}
func logRecords(t *testing.T, logs *bytes.Buffer) []map[string]any {
t.Helper()
decoder := json.NewDecoder(bytes.NewReader(logs.Bytes()))
decoder.UseNumber()
var records []map[string]any
for {
var record map[string]any
err := decoder.Decode(&record)
if errors.Is(err, io.EOF) {
return records
}
require.NoError(t, err)
records = append(records, record)
}
}
func logWithMessage(t *testing.T, logs *bytes.Buffer, message string) map[string]any {
t.Helper()
for _, record := range logRecords(t, logs) {
if record["msg"] == message {
return record
}
}
t.Fatalf("missing log %q in %s", message, logs)
return nil
}
func TestAutoMigrateLogsRealVersionsAndEmptyMigration(t *testing.T) {
p, db, logs := operationsFixture(t, []int64{29})
seedHistory(t, p, 1)
files := fstest.MapFS{
"00029_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")},
"20260929235340_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")},
"20261001010203_empty.sql": {Data: []byte("-- +goose Up\n-- +goose Down\n")},
}
var err error
migrationProvider, err = goose.NewProvider(goose.DialectSQLite3, db, files)
require.NoError(t, err)
require.NoError(t, AutoMigrate(t.Context()))
status := logWithMessage(t, logs, "Pending migrations detected, applying them...")
require.Equal(t, json.Number("29"), status["current"])
require.Equal(t, json.Number("20261001010203"), status["target"])
for _, expected := range []struct{ message, version, source, level string }{
{"Applied migration", "20260929235340", "20260929235340_migration.sql", "INFO"},
{"Applied empty migration", "20261001010203", "20261001010203_empty.sql", "WARN"},
} {
record := logWithMessage(t, logs, expected.message)
require.Equal(t, json.Number(expected.version), record["version"])
require.Equal(t, expected.source, record["source"])
require.Equal(t, "up", record["dir"])
require.Equal(t, expected.level, record["level"])
require.Contains(t, record, "duration")
}
summary := logWithMessage(t, logs, "Applied pending migrations")
require.Equal(t, json.Number("20261001010203"), summary["current"])
require.Equal(t, json.Number("2"), summary["appliedCount"])
require.Contains(t, summary, "duration")
}
func TestMigrationFailureCountsAndLogs(t *testing.T) {
for _, auto := range []bool{false, true} {
for _, cause := range []error{errors.New("migration failed"), fmt.Errorf("migration failed: %w", goose.ErrNoNextVersion)} {
t.Run(fmt.Sprintf("auto_%t/%s", auto, cause), func(t *testing.T) {
_, db, logs := operationsFixture(t, []int64{29})
versions := []int64{29, 20260929235340, 20261001010203}
p, err := goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithGoMigrations(
goose.NewGoMigration(versions[0], &goose.GoFunc{}, nil),
goose.NewGoMigration(versions[1], &goose.GoFunc{}, nil),
goose.NewGoMigration(versions[2], &goose.GoFunc{RunTx: func(context.Context, *sql.Tx) error { return cause }}, nil),
))
require.NoError(t, err)
migrationProvider = p
seedHistory(t, p, 1)
if auto {
err = AutoMigrate(t.Context())
} else {
var count int64
count, err = ApplyPendingMigrations(t.Context(), -1)
require.Equal(t, int64(1), count)
}
require.ErrorIs(t, err, cause)
var partial *goose.PartialError
require.ErrorAs(t, err, &partial)
assertHistory(t, p, db, versions, 2)
failure := logWithMessage(t, logs, "Couldn't apply migration")
require.Equal(t, "ERROR", failure["level"])
require.Equal(t, json.Number("20261001010203"), failure["version"])
require.Equal(t, "up", failure["dir"])
require.Contains(t, failure, "source")
require.Contains(t, failure, "duration")
require.Contains(t, failure, "err")
if auto {
summary := logWithMessage(t, logs, "Couldn't apply pending migrations")
require.Equal(t, json.Number("20260929235340"), summary["current"])
require.Equal(t, json.Number("20261001010203"), summary["target"])
require.Equal(t, json.Number("1"), summary["appliedCount"])
}
})
}
}
}
func TestOperationsProviderAndLookupErrors(t *testing.T) {
p, db, logs := operationsFixture(t, []int64{29, 20260929235340})
migrationProvider = nil
count, err := ApplyPendingMigrations(t.Context(), -1)
require.Zero(t, count)
require.ErrorIs(t, err, ErrNotInitialized)
require.ErrorIs(t, AutoMigrate(t.Context()), ErrNotInitialized)
migrationProvider = p
require.NoError(t, db.Close())
count, err = ApplyPendingMigrations(t.Context(), 0)
require.NoError(t, err)
require.Zero(t, count)
for _, cap := range []int64{-1, 1} {
count, err = ApplyPendingMigrations(t.Context(), cap)
require.Error(t, err)
require.Zero(t, count)
}
require.Error(t, AutoMigrate(t.Context()))
failure := logWithMessage(t, logs, "Couldn't check for pending migrations")
require.Equal(t, "ERROR", failure["level"])
require.Contains(t, failure, "err")
}
func TestOperationsPropagateCancellation(t *testing.T) {
p, db, _ := operationsFixture(t, []int64{29, 20260929235340})
ctx, cancel := context.WithCancel(t.Context())
cancel()
count, err := ApplyPendingMigrations(ctx, -1)
require.ErrorIs(t, err, context.Canceled)
require.Zero(t, count)
require.ErrorIs(t, AutoMigrate(ctx), context.Canceled)
assertHistory(t, p, db, []int64{29, 20260929235340}, 0)
}
type stepResult struct {
result *goose.MigrationResult
err error
}
type scriptedStepper struct {
steps []stepResult
calls int
}
func (s *scriptedStepper) UpByOne(context.Context) (*goose.MigrationResult, error) {
if s.calls >= len(s.steps) {
panic("unexpected UpByOne call")
}
step := s.steps[s.calls]
s.calls++
return step.result, step.err
}
func TestPendingApplicationExhaustionAndErrors(t *testing.T) {
failure := errors.New("provider failed")
resultFailure := errors.New("result failed")
good := &goose.MigrationResult{Source: &goose.Source{Version: 20260929235340, Path: "migration.sql"}, Direction: "up", Duration: time.Millisecond}
bad := &goose.MigrationResult{Source: &goose.Source{Version: 20261001010203, Path: "bad.sql"}, Direction: "up", Error: resultFailure}
for _, test := range []struct {
name string
cap int64
steps []stepResult
wantCount, wantVersion int64
wantErr error
}{
{"zero", 0, nil, 0, 0, nil},
{"positive_cap", 1, []stepResult{{good, nil}}, 1, good.Source.Version, nil},
{"wrapped_exhaustion_unlimited", -1, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil},
{"wrapped_exhaustion_cap", 100, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil},
{"already_current", -1, []stepResult{{nil, goose.ErrNoNextVersion}}, 0, 0, nil},
{"provider_error", -1, []stepResult{{good, nil}, {nil, failure}}, 1, good.Source.Version, failure},
{"result_error", -1, []stepResult{{good, nil}, {bad, nil}}, 1, good.Source.Version, resultFailure},
{"error_with_result", -1, []stepResult{{good, failure}}, 0, 0, failure},
{"nil_result", -1, []stepResult{{nil, nil}}, 0, 0, nil},
} {
t.Run(test.name, func(t *testing.T) {
stepper := &scriptedStepper{steps: test.steps}
logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
count, version, err := applyPendingMigrations(t.Context(), stepper, logger, test.cap)
if test.wantErr != nil {
require.ErrorIs(t, err, test.wantErr)
} else {
require.NoError(t, err)
}
require.Equal(t, test.wantCount, count)
require.Equal(t, test.wantVersion, version)
require.Equal(t, len(test.steps), stepper.calls)
})
}
}