From 4ca353dd7e1329a63b0a3421a241aaf02be6346f Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 6 Oct 2026 00:08:57 -0700 Subject: [PATCH] fix: apply pending migrations without version arithmetic --- README.md | 16 +++ go.mod | 9 ++ go.sum | 33 +++++ operations.go | 59 +++++--- operations_test.go | 334 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 430 insertions(+), 21 deletions(-) create mode 100644 operations_test.go diff --git a/README.md b/README.md index 866ca3a..c52c25f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod index 817bb2c..4489444 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 422f093..c3b283b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/operations.go b/operations.go index bf7f4fb..c832048 100644 --- a/operations.go +++ b/operations.go @@ -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 { diff --git a/operations_test.go b/operations_test.go new file mode 100644 index 0000000..072e386 --- /dev/null +++ b/operations_test.go @@ -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) + }) + } +} -- 2.54.0