diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7970236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage.out +coverage.html +*.test diff --git a/README.md b/README.md index 71fd175..866ca3a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,45 @@ # migrate -migrate provides several [app.Module](https://gitea.auvem.com/go-toolkit/app) configurations to handle basic boilerplate. Built around the [goose](https://github.com/pressly/goose) database migration tool. +Goose migrations as [app](https://gitea.auvem.com/go-toolkit/app) modules. -See migrate/cmd for ready-made [Cobra](https://github.com/spf13/cobra) commands. +## Install + +```bash +go get gitea.auvem.com/go-toolkit/migrate +``` + +## Quick start + +```go +//go:embed migrations/*.sql +var migrations embed.FS + +mod, err := migrate.ModuleMigrations(&migrate.MigrationOpts{ + SQLO: dbx.SQLO, + Dialect: goose.DialectMySQL, + FS: migrations, + BasePath: "migrations", +}) +``` + +Register `mod` and optionally `migrate.ModuleAutoMigrate(true)` after the database module. + +## CLI + +Use [migrate/cli](cli) with `appcli` — not the removed `migrate/cmd` package. + +## Production notes + +- Require a goose zero-version migration for empty databases +- Do not run `ModuleMigrateBlank` in production +- When auto-migration is disabled, pending migrations return `ErrPendingMigrations` + +## Breaking changes (V1) + +| Before | After | +|--------|-------| +| `migrate.Migration` global | `migrate.Provider()` | +| `ModuleMigrations(cfg) *Module` (panic) | `ModuleMigrations(cfg) (*Module, error)` + `MustModuleMigrations` | +| `ModuleMigrateUp` var | `ModuleMigrateUp()` factory | +| `MigrationsConfig() *MigrationOpts` | `MigrationsConfig() MigrationOpts` (copy) | +| `migrate/cmd` | removed — use `migrate/cli` | diff --git a/cli/cli.go b/cli/cli.go index ccbe07c..82967e0 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -5,14 +5,13 @@ import ( "fmt" "gitea.auvem.com/go-toolkit/appcli" - "gitea.auvem.com/go-toolkit/dbx" "gitea.auvem.com/go-toolkit/migrate" "github.com/pressly/goose/v3" "github.com/urfave/cli/v3" ) // MigrateCmd returns the main migrate command. -func MigrateCmd(sqlo dbx.SQLOFunc, directDeps appcli.DepFn, childDeps appcli.DepFn) *cli.Command { +func MigrateCmd(sqlo migrate.SQLOFunc, directDeps appcli.DepFn, childDeps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "migrate", Usage: "Migrate the database", @@ -21,7 +20,7 @@ func MigrateCmd(sqlo dbx.SQLOFunc, directDeps appcli.DepFn, childDeps appcli.Dep } // AllSubcommands returns all subcommands of the migrate command. -func AllSubcommands(sqlo dbx.SQLOFunc, deps appcli.DepFn) []*cli.Command { +func AllSubcommands(sqlo migrate.SQLOFunc, deps appcli.DepFn) []*cli.Command { return []*cli.Command{ MigrateStatusCmd(sqlo, deps), MigrateCreateCmd(sqlo, deps), @@ -33,23 +32,27 @@ func AllSubcommands(sqlo dbx.SQLOFunc, deps appcli.DepFn) []*cli.Command { } } +func withProvider(ctx context.Context, fn func(context.Context, *goose.Provider) error) error { + p, err := migrate.Provider() + if err != nil { + return err + } + return fn(ctx, p) +} + // MigrateStatusCmd returns a command to get database migration status. -func MigrateStatusCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +func MigrateStatusCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "status", Usage: "Get database migration status", Action: func(ctx context.Context, cmd *cli.Command) error { - if err := goose.Status(sqlo(), migrate.MigrationsConfig().BasePath); err != nil { - return fmt.Errorf("couldn't get migration status: %v", err) - } - - return nil + return migrate.PrintMigrationStatus(ctx) }, }, deps) } -// MigrateCreateCmd returns a command to create a new migration. -func MigrateCreateCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +// MigrateCreateCmd returns a command to create a new migration file. +func MigrateCreateCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "create", Usage: "Create a new migration", @@ -65,36 +68,38 @@ func MigrateCreateCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - sequential := cmd.Bool("sequential") - if sequential { - goose.SetSequential(sequential) + if cmd.Bool("sequential") { + goose.SetSequential(true) } - - if err := goose.Create(sqlo(), "migrations", cmd.StringArg("name"), cmd.StringArg("type")); err != nil { - return fmt.Errorf("couldn't create migration: %v", err) + cfg := migrate.MigrationsConfig() + dir := cfg.BasePath + if dir == "." || dir == "" { + dir = "migrations" + } + if err := goose.Create(sqlo(), dir, cmd.StringArg("name"), cmd.StringArg("type")); err != nil { + return fmt.Errorf("couldn't create migration: %w", err) } - return nil }, }, deps) } // MigrateUpCmd returns a command to apply all available database migrations. -func MigrateUpCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +func MigrateUpCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "up", Usage: "Apply all available database migrations", Action: func(ctx context.Context, cmd *cli.Command) error { - if err := goose.Up(sqlo(), migrate.MigrationsConfig().BasePath); err != nil { - return fmt.Errorf("couldn't apply migrations: %v", err) - } - return nil + return withProvider(ctx, func(ctx context.Context, p *goose.Provider) error { + _, err := p.Up(ctx) + return err + }) }, }, deps) } -// MigrateUpToCmd returns a command to apply all available database migrations up to a specific version. -func MigrateUpToCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +// MigrateUpToCmd returns a command to apply migrations up to a specific version. +func MigrateUpToCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "up-to", Usage: "Apply all available database migrations up to a specific version", @@ -106,30 +111,30 @@ func MigrateUpToCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { version := cmd.Int64("version") - if err := goose.UpTo(sqlo(), migrate.MigrationsConfig().BasePath, version); err != nil { - return fmt.Errorf("couldn't apply migrations to target version %d: %v", version, err) - } - return nil + return withProvider(ctx, func(ctx context.Context, p *goose.Provider) error { + _, err := p.UpTo(ctx, version) + return err + }) }, }, deps) } // MigrateDownCmd returns a command to rollback the most recent database migration. -func MigrateDownCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +func MigrateDownCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "down", Usage: "Rollback the most recent database migration", Action: func(ctx context.Context, cmd *cli.Command) error { - if err := goose.Down(sqlo(), migrate.MigrationsConfig().BasePath); err != nil { - return fmt.Errorf("couldn't rollback migration: %v", err) - } - return nil + return withProvider(ctx, func(ctx context.Context, p *goose.Provider) error { + _, err := p.Down(ctx) + return err + }) }, }, deps) } -// MigrateDownToCmd returns a command to rollback all database migrations down to a specific version. -func MigrateDownToCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +// MigrateDownToCmd returns a command to rollback migrations down to a specific version. +func MigrateDownToCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "down-to", Usage: "Rollback all database migrations down to a specific version", @@ -141,24 +146,27 @@ func MigrateDownToCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { version := cmd.Int64("version") - if err := goose.DownTo(sqlo(), migrate.MigrationsConfig().BasePath, version); err != nil { - return fmt.Errorf("couldn't rollback migrations to target version %d: %v", version, err) - } - return nil + return withProvider(ctx, func(ctx context.Context, p *goose.Provider) error { + _, err := p.DownTo(ctx, version) + return err + }) }, }, deps) } -// MigrateRedoCmd returns a command to rollback the most recent database migration and reapply it. -func MigrateRedoCmd(sqlo dbx.SQLOFunc, deps appcli.DepFn) *cli.Command { +// MigrateRedoCmd returns a command to rollback and reapply the most recent migration. +func MigrateRedoCmd(sqlo migrate.SQLOFunc, deps appcli.DepFn) *cli.Command { return appcli.NewCommand(&cli.Command{ Name: "redo", Usage: "Rollback the most recent database migration and reapply it", Action: func(ctx context.Context, cmd *cli.Command) error { - if err := goose.Redo(sqlo(), migrate.MigrationsConfig().BasePath); err != nil { - return fmt.Errorf("couldn't redo migration: %v", err) - } - return nil + return withProvider(ctx, func(ctx context.Context, p *goose.Provider) error { + if _, err := p.Down(ctx); err != nil { + return err + } + _, err := p.UpByOne(ctx) + return err + }) }, }, deps) } diff --git a/cli/doc.go b/cli/doc.go new file mode 100644 index 0000000..928a1a5 --- /dev/null +++ b/cli/doc.go @@ -0,0 +1,5 @@ +// Package migratecli provides urfave/cli v3 commands for go-toolkit migrations. +// +// Commands load the migrations [app.Module] via [gitea.auvem.com/go-toolkit/appcli.DepFn] +// before invoking the shared goose [Provider] API. +package migratecli diff --git a/cmd/cmd.go b/cmd/cmd.go deleted file mode 100644 index 7dd1779..0000000 --- a/cmd/cmd.go +++ /dev/null @@ -1,165 +0,0 @@ -package migratecmd - -import ( - "database/sql" - "fmt" - "strconv" - - "gitea.auvem.com/go-toolkit/migrate" - "github.com/pressly/goose/v3" - "github.com/spf13/cobra" -) - -// MigrateCmd returns the main migrate command. -func MigrateCmd(sqlo *sql.DB) *cobra.Command { - cmd := &cobra.Command{ - Use: "migrate", - Short: "Migrate the database", - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() - }, - } - - cmd.AddCommand(AllSubcommands(sqlo)...) - - return cmd -} - -// AllSubcommands returns all subcommands of the migrate command. -func AllSubcommands(sqlo *sql.DB) []*cobra.Command { - return []*cobra.Command{ - MigrateStatusCmd(sqlo), - MigrateCreateCmd(sqlo), - MigrateUpCmd(sqlo), - MigrateUpToCmd(sqlo), - MigrateDownCmd(sqlo), - MigrateDownToCmd(sqlo), - MigrateRedoCmd(sqlo), - } -} - -// MigrateStatusCmd returns a command to get database migration status. -func MigrateStatusCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Get database migration status", - Run: func(cmd *cobra.Command, args []string) { - if err := goose.Status(sqlo, migrate.MigrationsConfig().BasePath); err != nil { - fmt.Printf("Error: Couldn't get migration status: %v\n", err) - return - } - }, - } -} - -// MigrateCreateCmd returns a command to create a new migration. -func MigrateCreateCmd(sqlo *sql.DB) *cobra.Command { - cmd := &cobra.Command{ - Use: "create [NAME] [TYPE]", - Short: "Create a new migration", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - sequential, err := cmd.Flags().GetBool("sequential") - if err != nil { - fmt.Printf("Error: Failed to get 'sequential' flag: %v\n", err) - return - } - - if sequential { - goose.SetSequential(sequential) - } - - if err := goose.Create(sqlo, "migrations", args[0], args[1]); err != nil { - fmt.Printf("Error: Couldn't create migration: %v\n", err) - return - } - }, - } - - cmd.Flags().BoolP("sequential", "s", false, "Create a sequential migration") - return cmd -} - -// MigrateUpCmd returns a command to apply all available database migrations. -func MigrateUpCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "up", - Short: "Apply all available database migrations", - Run: func(cmd *cobra.Command, args []string) { - if err := goose.Up(sqlo, migrate.MigrationsConfig().BasePath); err != nil { - fmt.Printf("Error: Couldn't apply migrations: %v\n", err) - return - } - }, - } -} - -// MigrateUpToCmd returns a command to apply all available database migrations up to a specific version. -func MigrateUpToCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "up-to [VERSION]", - Short: "Apply all available database migrations up to a specific version", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - version, err := strconv.ParseInt(args[0], 10, 0) - if err != nil { - fmt.Printf("Error: Couldn't parse version: %v\n", err) - return - } - - if err := goose.UpTo(sqlo, migrate.MigrationsConfig().BasePath, version); err != nil { - fmt.Printf("Error: Couldn't apply migrations to target version %d: %v\n", version, err) - return - } - }, - } -} - -// MigrateDownCmd returns a command to rollback the most recent database migration. -func MigrateDownCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "down", - Short: "Rollback the most recent database migration", - Run: func(cmd *cobra.Command, args []string) { - if err := goose.Down(sqlo, migrate.MigrationsConfig().BasePath); err != nil { - fmt.Printf("Error: Couldn't rollback migration: %v\n", err) - return - } - }, - } -} - -// MigrateDownToCmd returns a command to rollback all database migrations down to a specific version. -func MigrateDownToCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "down-to [VERSION]", - Short: "Rollback all database migrations down to a specific version", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - version, err := strconv.ParseInt(args[0], 10, 0) - if err != nil { - fmt.Printf("Error: Couldn't parse version: %v\n", err) - return - } - - if err := goose.DownTo(sqlo, migrate.MigrationsConfig().BasePath, version); err != nil { - fmt.Printf("Error: Couldn't rollback migrations to target version %d: %v\n", version, err) - return - } - }, - } -} - -// MigrateRedoCmd returns a command to rollback the most recent database migration and reapply it. -func MigrateRedoCmd(sqlo *sql.DB) *cobra.Command { - return &cobra.Command{ - Use: "redo", - Short: "Rollback the most recent database migration and reapply it", - Run: func(cmd *cobra.Command, args []string) { - if err := goose.Redo(sqlo, migrate.MigrationsConfig().BasePath); err != nil { - fmt.Printf("Error: Couldn't redo migration: %v\n", err) - return - } - }, - } -} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..9fa8b8a --- /dev/null +++ b/doc.go @@ -0,0 +1,17 @@ +// Package migrate integrates pressly/goose with go-toolkit [app.Module] lifecycles. +// +// # Setup +// +// Embed migration SQL, configure [MigrationOpts] with SQLO, Dialect, FS, and optional +// BasePath, then register [ModuleMigrations] before dependent modules such as +// [ModuleAutoMigrate] or CLI commands via [gitea.auvem.com/go-toolkit/migrate/cli]. +// +// # BasePath +// +// When BasePath is not ".", migration files are read from fs.Sub(FS, BasePath) for +// the goose Provider. Keep CLI create paths aligned with BasePath. +// +// # Zero version +// +// Goose requires a version-0 migration when bootstrapping a fresh database. +package migrate diff --git a/go.mod b/go.mod index 1661c04..817bb2c 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,32 @@ module gitea.auvem.com/go-toolkit/migrate -go 1.24.0 +go 1.25.0 require ( - gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9 + gitea.auvem.com/go-toolkit/app v0.0.0-20250530181559-231561c92698 gitea.auvem.com/go-toolkit/appcli v0.0.0-20250604221759-6b4196dbda59 gitea.auvem.com/go-toolkit/dbx v0.0.0-20250530232843-55cc3ffd8364 github.com/pressly/goose/v3 v3.24.3 - github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.3.3 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/go-jet/jet/v2 v2.13.0 // indirect - github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/ksuid v1.0.4 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/sync v0.19.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace ( + gitea.auvem.com/go-toolkit/app => ../app + gitea.auvem.com/go-toolkit/appcli => ../appcli + gitea.auvem.com/go-toolkit/dbx => ../dbx +) diff --git a/go.sum b/go.sum index 31caffd..422f093 100644 --- a/go.sum +++ b/go.sum @@ -1,42 +1,21 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9 h1:MYOI+bB4IBAqoL1tyIUFnu0S+NSq0OX88J3K/PUR7lI= -gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9/go.mod h1:a7ENpOxndUdONE6oZ9MZAvG1ba2uq01x/LtcnDkpOj8= -gitea.auvem.com/go-toolkit/appcli v0.0.0-20250604221759-6b4196dbda59 h1:fKH/G5QUbqUeA20UlxqSOVZvFPjt2JIaGgaLg4QRSEs= -gitea.auvem.com/go-toolkit/appcli v0.0.0-20250604221759-6b4196dbda59/go.mod h1:kutQ69eyFMhs7l8siqdZcHRQ9BLc32ftLs0crnm+dys= -gitea.auvem.com/go-toolkit/dbx v0.0.0-20250530232843-55cc3ffd8364 h1:xadjfyFYYoyy8d6AtqiNSv8OtMtav53vMpg3mVRM+10= -gitea.auvem.com/go-toolkit/dbx v0.0.0-20250530232843-55cc3ffd8364/go.mod h1:3CYeto5wVq0fcABgssDYycXGbR7ibNiN66p1HpxBdds= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= -github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -45,38 +24,32 @@ github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwW github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 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-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= -golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/migrate.go b/migrate.go deleted file mode 100644 index ded11a4..0000000 --- a/migrate.go +++ /dev/null @@ -1,287 +0,0 @@ -package migrate - -import ( - "context" - "io/fs" - "log/slog" - "time" - - "gitea.auvem.com/go-toolkit/app" - "gitea.auvem.com/go-toolkit/dbx" - "github.com/pressly/goose/v3" -) - -// MigrationsOpts define the options for the migrations module. -type MigrationOpts struct { - // SQLO is the SQL database handle getter used for migrations. REQUIRED. - SQLO dbx.SQLOFunc - - // Dialect is the database dialect used for migrations (e.g., "mysql", "postgres"). - // REQUIRED. Must match the dialect used in dbx. - Dialect goose.Dialect - - // FS is the filesystem where migration files are stored. - FS fs.FS - - // BasePath is the directory path where migration files are located. - // Defaults to "." if not set. - BasePath string -} - -const ( - // ModuleMigrationsName is the name of the migrations module. - ModuleMigrationsName = "migrations" - - // ModuleMigrateUpName is the name of the migrate up module. - ModuleMigrateUpName = "migrate up" - - // ModuleMigrateBlankName is the name of the migrate blank module. - ModuleMigrateBlankName = "migrate down to blank" - - // ModuleAutoMigrateName is the name of the auto-migrate module. - ModuleAutoMigrateName = "auto migrate" -) - -var ( - // Migration stores the goose migration provider. - Migration *goose.Provider - - // migrationsConfig stores the configuration for the migrations module. - migrationsConfig *MigrationOpts - - // migrationsModule is the singleton module instance for the migrations subsystem. - migrationsModule *app.Module - - // ModuleMigrateUp applies any pending migrations. - ModuleMigrateUp = app.NewModule(ModuleMigrateUpName, app.ModuleOpts{ - Setup: func(_ *app.Module) error { - _, err := ApplyPendingMigrations(context.Background(), -1) - return err - }, - Depends: []string{ModuleMigrationsName}, - }) - - // moduleMigrateBlank resets the database to a blank state, removing all data. - moduleMigrateBlank *app.Module - - // autoMigrateEnabled controls whether auto-migration is enabled. - autoMigrateEnabled bool - - // autoMigrateModule is the singleton module instance for the auto-migration subsystem. - autoMigrateModule *app.Module -) - -// MigrationsConfig returns the current configuration for the migrations module. -func MigrationsConfig() *MigrationOpts { - migrationsModule.RequireLoaded() // ensure the migrations module is loaded - return migrationsConfig -} - -// ModuleMigrations returns the migrations module with the provided configuration. -func ModuleMigrations(cfg *MigrationOpts) *app.Module { - if migrationsModule != nil { - panic("ModuleMigrations initialized multiple times") - } - - if cfg.SQLO == nil { - panic("Migration SQL handle (SQLO) must be set in the configuration") - } - if cfg.BasePath == "" { - cfg.BasePath = "." // default base path if not set - } - if cfg.FS == nil { - panic("Migration filesystem (FS) must be set in the configuration") - } - migrationsConfig = cfg // store configuration at package level - - migrationsModule = app.NewModule(ModuleMigrationsName, app.ModuleOpts{ - Setup: setupMigrations, - Depends: []string{dbx.ModuleDBName}, - }) - - return migrationsModule -} - -// ModuleMigrateBlank returns the migrate blank module that resets the database to a blank state. -func ModuleMigrateBlank() *app.Module { - if moduleMigrateBlank != nil { - panic("ModuleMigrateBlank initialized multiple times") - } - - moduleMigrateBlank = app.NewModule(ModuleMigrateBlankName, app.ModuleOpts{ - Setup: func(_ *app.Module) error { - _, err := MigrateToBlank() - return err - }, - Depends: []string{ModuleMigrationsName}, - }) - - return moduleMigrateBlank -} - -// ModuleAutoMigrate returns the auto-migration module with the provided configuration. -func ModuleAutoMigrate(enabled bool) *app.Module { - if autoMigrateModule != nil { - panic("ModuleAutoMigrate initialized multiple times") - } - - autoMigrateEnabled = enabled // store auto-migration state at package level - - autoMigrateModule = app.NewModule(ModuleAutoMigrateName, app.ModuleOpts{ - Setup: func(_ *app.Module) error { - return AutoMigrate() - }, - Depends: []string{ModuleMigrationsName}, - }) - - return autoMigrateModule -} - -// setupMigrations initializes the goose migration provider. -func setupMigrations(mod *app.Module) error { - cfg := migrationsConfig - var err error - if err := goose.SetDialect(string(cfg.Dialect)); err != nil { - migrationsModule.Logger().Error("Couldn't set database dialect for goose", "err", err) - return err - } - - // Set base filesystem for goose migrations - goose.SetBaseFS(cfg.FS) - - // Initialize the goose migration provider - Migration, err = goose.NewProvider(cfg.Dialect, cfg.SQLO(), cfg.FS) - if err != nil { - migrationsModule.Logger().Error("Couldn't initialize goose migration provider", "err", err) - return err - } - mod.Logger().Info("Goose migration provider initialized", "dialect", cfg.Dialect, "basePath", cfg.BasePath, "fs", cfg.FS) - - return nil -} - -// ApplyPendingMigrations applies all pending migrations. Returns the number of -// migrations applied and an error if any occurred. The number of migrations that -// will be applied are specified by `pendingCount`. If `pendingCount` is 0, no migrations -// are applied. If `pendingCount` is negative, the number of pending migrations -// is fetched from the database. -func ApplyPendingMigrations(ctx context.Context, pendingCount int64) (int64, error) { - migrationsModule.RequireLoaded() // ensure the migrations module is loaded - - if pendingCount == 0 { - return 0, nil - } - - if pendingCount < 0 { - curr, target, err := Migration.GetVersions(ctx) - if err != nil { - return 0, err - } - pendingCount = target - curr - } - - var count int64 - for range pendingCount { - res, err := Migration.UpByOne(ctx) - if err := handleMigrationResults(migrationsModule.Logger(), res, err); err != nil { - return count, err - } - - if res.Error == nil { - count++ - } - } - return count, nil -} - -// MigrateToBlank resets the database to a blank state, removing all data and -// running all down migrations. Returns number of migrations applied and an error -// if any occurred. -func MigrateToBlank() (int64, error) { - migrationsModule.RequireLoaded() // ensure the migrations module is loaded - - ctx := context.Background() - current, target, err := Migration.GetVersions(ctx) - if err != nil { - return 0, err - } - moduleMigrateBlank.Logger().Info("Database versions", "current", current, "target", target) - - var count int64 - for current > 0 { - res, err := Migration.Down(ctx) - if err := handleMigrationResults(moduleMigrateBlank.Logger(), res, err); err != nil { - return count, err - } - - count++ - current-- - } - return count, nil -} - -// AutoMigrate applies any pending migrations if auto-migration is enabled. -func AutoMigrate() error { - migrationsModule.RequireLoaded() // ensure the migrations module is loaded - - // Check if there are any pending migrations - migrationCtx := context.Background() - migrationCurrent, migrationTarget, err := Migration.GetVersions(migrationCtx) - if err != nil { - migrationsModule.Logger().Error("Couldn't check for pending migrations", "err", err) - return err - } - - migrationFields := []any{ - "current", migrationCurrent, - "target", migrationTarget, - } - - if migrationCurrent >= migrationTarget { - migrationsModule.Logger().Info("No pending migrations", "version", migrationCurrent) - } else if !autoMigrateEnabled { - migrationsModule.Logger().Error( - "Pending migrations detected, but auto-migration is disabled. Please run `acrm migrate up` to apply them.", - migrationFields..., - ) - return err - } else { - migrationsModule.Logger().Info("Pending migrations detected, applying them...", migrationFields...) - now := time.Now() - count, err := ApplyPendingMigrations(migrationCtx, migrationTarget-migrationCurrent) - if err != nil { - migrationsModule.Logger().Error("Couldn't apply pending migrations", "current", migrationCurrent+count, "target", migrationTarget, "err", err) - return err - } - migrationsModule.Logger().Info("Applied pending migrations", "current", migrationTarget, "appliedCount", count, "duration", time.Since(now)) - } - - return nil -} - -// handleMigrationResults is a helper function that prints various responses -// based on a *goose.MigrationResult. -func handleMigrationResults(logger *slog.Logger, res *goose.MigrationResult, err error) error { - if err != nil { - return err - } - - fields := []any{ - "dir", res.Direction, - "version", res.Source.Version, - "source", res.Source.Path, - "duration", res.Duration, - } - - if res.Error != nil { - fields = append(fields, "err", res.Error) - logger.Error("Couldn't apply migration", fields...) - return res.Error - } else if res.Empty { - logger.Warn("Applied empty migration", fields...) - } else { - logger.Info("Applied migration", fields...) - } - - return nil -} diff --git a/migrate_test.go b/migrate_test.go new file mode 100644 index 0000000..eb9058f --- /dev/null +++ b/migrate_test.go @@ -0,0 +1,18 @@ +package migrate_test + +import ( + "testing" + + "gitea.auvem.com/go-toolkit/migrate" + "github.com/stretchr/testify/assert" +) + +func TestModuleMigrationsRequiresConfig(t *testing.T) { + _, err := migrate.ModuleMigrations(nil) + assert.Error(t, err) + assert.ErrorIs(t, err, migrate.ErrInvalidMigrationCfg) +} + +func TestErrPendingMigrations(t *testing.T) { + assert.Error(t, migrate.ErrPendingMigrations) +} diff --git a/module.go b/module.go new file mode 100644 index 0000000..0f99400 --- /dev/null +++ b/module.go @@ -0,0 +1,166 @@ +package migrate + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io/fs" + "path" + + "gitea.auvem.com/go-toolkit/app" + "gitea.auvem.com/go-toolkit/dbx" + "github.com/pressly/goose/v3" +) + +// SQLOFunc returns the SQL database handle for migrations. +type SQLOFunc func() *sql.DB + +var ( + ErrNotInitialized = errors.New("migrations module not initialized") + ErrPendingMigrations = errors.New("pending migrations detected but auto-migration is disabled") + ErrInvalidMigrationCfg = errors.New("invalid migration configuration") +) + +// MigrationOpts defines options for the migrations module. +type MigrationOpts struct { + // SQLO is the SQL database handle getter used for migrations. REQUIRED. + SQLO SQLOFunc + + // Dialect is the database dialect used for migrations (e.g., "mysql", "postgres"). + // REQUIRED. Must match the dialect used in dbx. + Dialect goose.Dialect + + // FS is the filesystem where migration files are stored. + FS fs.FS + + // BasePath is the directory within FS where migration files are located. + // Defaults to "." if not set. + BasePath string +} + +const ( + ModuleMigrationsName = "migrations" + ModuleMigrateUpName = "migrate up" + ModuleMigrateBlankName = "migrate down to blank" + ModuleAutoMigrateName = "auto migrate" +) + +var ( + migrationProvider *goose.Provider + migrationsConfig MigrationOpts + migrationsModule *app.Module + moduleMigrateBlank *app.Module + moduleMigrateUp *app.Module + autoMigrateEnabled bool + autoMigrateModule *app.Module +) + +// MigrationsConfig returns a copy of the active migration configuration. +func MigrationsConfig() MigrationOpts { + migrationsModule.RequireLoaded() + return migrationsConfig +} + +// ModuleMigrations returns the migrations module with the provided configuration. +func ModuleMigrations(cfg *MigrationOpts) (*app.Module, error) { + if migrationsModule != nil { + return nil, fmt.Errorf("ModuleMigrations initialized multiple times") + } + if err := validateMigrationOpts(cfg); err != nil { + return nil, err + } + migrationsConfig = *cfg + migrationsModule = app.NewModule(ModuleMigrationsName, app.ModuleOpts{ + Setup: setupMigrations, + Depends: []string{dbx.ModuleDBName}, + }) + return migrationsModule, nil +} + +// MustModuleMigrations is like [ModuleMigrations] but panics on error. +func MustModuleMigrations(cfg *MigrationOpts) *app.Module { + mod, err := ModuleMigrations(cfg) + if err != nil { + panic(err) + } + return mod +} + +// ModuleMigrateUp returns a module that applies pending migrations on setup. +func ModuleMigrateUp() *app.Module { + if moduleMigrateUp != nil { + panic("ModuleMigrateUp initialized multiple times") + } + moduleMigrateUp = app.NewModule(ModuleMigrateUpName, app.ModuleOpts{ + Setup: func(_ *app.Module) error { + _, err := ApplyPendingMigrations(context.Background(), -1) + return err + }, + Depends: []string{ModuleMigrationsName}, + }) + return moduleMigrateUp +} + +// ModuleMigrateBlank returns a module that rolls back all migrations. +func ModuleMigrateBlank() *app.Module { + if moduleMigrateBlank != nil { + panic("ModuleMigrateBlank initialized multiple times") + } + moduleMigrateBlank = app.NewModule(ModuleMigrateBlankName, app.ModuleOpts{ + Setup: func(_ *app.Module) error { + _, err := MigrateToBlank(context.Background()) + return err + }, + Depends: []string{ModuleMigrationsName}, + }) + return moduleMigrateBlank +} + +// ModuleAutoMigrate returns a module that auto-applies pending migrations when enabled. +func ModuleAutoMigrate(enabled bool) *app.Module { + if autoMigrateModule != nil { + panic("ModuleAutoMigrate initialized multiple times") + } + autoMigrateEnabled = enabled + autoMigrateModule = app.NewModule(ModuleAutoMigrateName, app.ModuleOpts{ + Setup: func(_ *app.Module) error { + return AutoMigrate(context.Background()) + }, + Depends: []string{ModuleMigrationsName}, + }) + return autoMigrateModule +} + +func validateMigrationOpts(cfg *MigrationOpts) error { + if cfg == nil { + return fmt.Errorf("%w: config is nil", ErrInvalidMigrationCfg) + } + if cfg.SQLO == nil { + return fmt.Errorf("%w: SQLO is required", ErrInvalidMigrationCfg) + } + if cfg.FS == nil { + return fmt.Errorf("%w: FS is required", ErrInvalidMigrationCfg) + } + if cfg.Dialect == "" { + return fmt.Errorf("%w: Dialect is required", ErrInvalidMigrationCfg) + } + if cfg.BasePath == "" { + cfg.BasePath = "." + } + return nil +} + +func migrationFS(cfg MigrationOpts) (fs.FS, error) { + if cfg.BasePath == "." || cfg.BasePath == "" { + return cfg.FS, nil + } + return fs.Sub(cfg.FS, cfg.BasePath) +} + +func normalizeCreateDir(basePath string) string { + if basePath == "." || basePath == "" { + return "migrations" + } + return path.Clean(basePath) +} diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..bf7f4fb --- /dev/null +++ b/operations.go @@ -0,0 +1,156 @@ +package migrate + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/pressly/goose/v3" +) + +// ApplyPendingMigrations applies pending migrations. pendingCount: 0 none, <0 auto-detect, >0 cap. +func ApplyPendingMigrations(ctx context.Context, pendingCount int64) (int64, error) { + migrationsModule.RequireLoaded() + p, err := Provider() + if err != nil { + return 0, err + } + + if pendingCount == 0 { + return 0, nil + } + + if pendingCount < 0 { + curr, target, err := p.GetVersions(ctx) + if err != nil { + return 0, err + } + pendingCount = target - curr + } + + var count int64 + for range pendingCount { + res, err := p.UpByOne(ctx) + if err := handleMigrationResults(migrationsModule.Logger(), res, err); err != nil { + return count, err + } + if res != nil && res.Error == nil { + count++ + } + } + return count, nil +} + +// MigrateToBlank rolls back all applied migrations. +func MigrateToBlank(ctx context.Context) (int64, error) { + migrationsModule.RequireLoaded() + p, err := Provider() + if err != nil { + return 0, err + } + + current, target, err := p.GetVersions(ctx) + if err != nil { + return 0, err + } + moduleMigrateBlank.Logger().Info("Database versions", "current", current, "target", target) + + if current == 0 { + return 0, nil + } + + results, err := p.DownTo(ctx, 0) + if err != nil { + return 0, err + } + for _, res := range results { + if err := handleMigrationResults(moduleMigrateBlank.Logger(), res, nil); err != nil { + return int64(len(results)), err + } + } + return int64(len(results)), nil +} + +// AutoMigrate applies pending migrations when auto-migration is enabled. +func AutoMigrate(ctx context.Context) error { + migrationsModule.RequireLoaded() + p, err := Provider() + if err != nil { + return err + } + + current, target, err := p.GetVersions(ctx) + if err != nil { + migrationsModule.Logger().Error("Couldn't check for pending migrations", "err", err) + return err + } + + fields := []any{"current", current, "target", target} + + if current >= target { + migrationsModule.Logger().Info("No pending migrations", "version", current) + return nil + } + if !autoMigrateEnabled { + migrationsModule.Logger().Error( + "Pending migrations detected, but auto-migration is disabled. Run `migrate up` to apply them.", + fields..., + ) + return ErrPendingMigrations + } + + migrationsModule.Logger().Info("Pending migrations detected, applying them...", fields...) + now := time.Now() + count, err := ApplyPendingMigrations(ctx, target-current) + if err != nil { + migrationsModule.Logger().Error("Couldn't apply pending migrations", "current", current+count, "target", target, "err", err) + return err + } + migrationsModule.Logger().Info("Applied pending migrations", "current", target, "appliedCount", count, "duration", time.Since(now)) + return nil +} + +func handleMigrationResults(logger *slog.Logger, res *goose.MigrationResult, err error) error { + if err != nil { + return err + } + if res == nil { + return nil + } + + fields := []any{ + "dir", res.Direction, + "version", res.Source.Version, + "source", res.Source.Path, + "duration", res.Duration, + } + + if res.Error != nil { + fields = append(fields, "err", res.Error) + logger.Error("Couldn't apply migration", fields...) + return res.Error + } + if res.Empty { + logger.Warn("Applied empty migration", fields...) + } else { + logger.Info("Applied migration", fields...) + } + return nil +} + +// PrintMigrationStatus prints migration status to stdout via the provider. +func PrintMigrationStatus(ctx context.Context) error { + p, err := Provider() + if err != nil { + return err + } + statuses, err := p.Status(ctx) + if err != nil { + return fmt.Errorf("migration status: %w", err) + } + for _, s := range statuses { + fmt.Printf("%s\t%s\n", s.State, s.Source.Path) + } + return nil +} diff --git a/provider.go b/provider.go new file mode 100644 index 0000000..2dc9879 --- /dev/null +++ b/provider.go @@ -0,0 +1,36 @@ +package migrate + +import ( + "fmt" + + "gitea.auvem.com/go-toolkit/app" + "github.com/pressly/goose/v3" +) + +func setupMigrations(mod *app.Module) error { + cfg := migrationsConfig + mfs, err := migrationFS(cfg) + if err != nil { + return fmt.Errorf("migration filesystem: %w", err) + } + + provider, err := goose.NewProvider(cfg.Dialect, cfg.SQLO(), mfs) + if err != nil { + migrationsModule.Logger().Error("Couldn't initialize goose migration provider", "err", err) + return err + } + migrationProvider = provider + mod.Logger().Info("Goose migration provider initialized", + "dialect", cfg.Dialect, + "basePath", cfg.BasePath, + ) + return nil +} + +// Provider returns the initialized goose provider. +func Provider() (*goose.Provider, error) { + if migrationProvider == nil { + return nil, ErrNotInitialized + } + return migrationProvider, nil +} diff --git a/testdata/migrations/00001_init.sql b/testdata/migrations/00001_init.sql new file mode 100644 index 0000000..013e54a --- /dev/null +++ b/testdata/migrations/00001_init.sql @@ -0,0 +1,5 @@ +-- +goose Up +SELECT 1; + +-- +goose Down +SELECT 1;