diff --git a/cmd/migrate.go b/cmd/cmd.go similarity index 100% rename from cmd/migrate.go rename to cmd/cmd.go diff --git a/migrate.go b/migrate.go index 8cdba97..46ab3a4 100644 --- a/migrate.go +++ b/migrate.go @@ -1,5 +1,26 @@ 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 { + // 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" @@ -13,3 +34,228 @@ const ( // 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() error { + _, err := ApplyPendingMigrations(context.Background(), -1) + return err + }, + Depends: []string{ModuleMigrationsName}, + }) + + // ModuleMigrateBlank resets the database to a blank state, removing all data. + ModuleMigrateBlank = app.NewModule(ModuleMigrateBlankName, app.ModuleOpts{ + Setup: func() error { + _, err := MigrateToBlank() + return err + }, + Depends: []string{ModuleMigrationsName}, + }) + + // 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.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 +} + +// 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: AutoMigrate, + Depends: []string{ModuleMigrationsName}, + }) + + return autoMigrateModule +} + +// setupMigrations initializes the goose migration provider. +func setupMigrations() error { + var err error + + if err := goose.SetDialect("mysql"); err != nil { + slog.Error("Couldn't set database dialect for goose", "err", err) + return err + } + + // Initialize the goose migration provider + Migration, err = goose.NewProvider(goose.DialectMySQL, dbx.SQLO(), migrationsConfig.FS) + if err != nil { + slog.Error("Couldn't initialize goose migration provider", "err", err) + return err + } + + 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(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 + } + slog.Info("Database versions", "current", current, "target", target) + + var count int64 + for current > 0 { + res, err := Migration.Down(ctx) + if err := handleMigrationResults(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 { + slog.Error("Couldn't check for pending migrations", "err", err) + return err + } + + migrationFields := []any{ + "current", migrationCurrent, + "target", migrationTarget, + } + + if migrationCurrent >= migrationTarget { + slog.Info("No pending migrations", "version", migrationCurrent) + } else if !autoMigrateEnabled { + slog.Error( + "Pending migrations detected, but auto-migration is disabled. Please run `acrm migrate up` to apply them.", + migrationFields..., + ) + return err + } else { + slog.Info("Pending migrations detected, applying them...", migrationFields...) + now := time.Now() + count, err := ApplyPendingMigrations(migrationCtx, migrationTarget-migrationCurrent) + if err != nil { + slog.Error("Couldn't apply pending migrations", "current", migrationCurrent+count, "target", migrationTarget, "err", err) + return err + } + slog.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(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) + slog.Error("Couldn't apply migration", fields...) + return res.Error + } else if res.Empty { + slog.Warn("Applied empty migration", fields...) + } else { + slog.Info("Applied migration", fields...) + } + + return nil +} diff --git a/migrations.go b/migrations.go deleted file mode 100644 index 69edfc4..0000000 --- a/migrations.go +++ /dev/null @@ -1,247 +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 { - // 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 -} - -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() error { - _, err := ApplyPendingMigrations(context.Background(), -1) - return err - }, - Depends: []string{ModuleMigrationsName}, - }) - - // ModuleMigrateBlank resets the database to a blank state, removing all data. - ModuleMigrateBlank = app.NewModule(ModuleMigrateBlankName, app.ModuleOpts{ - Setup: func() error { - _, err := MigrateToBlank() - return err - }, - Depends: []string{ModuleMigrationsName}, - }) - - // 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.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 -} - -// 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: AutoMigrate, - Depends: []string{ModuleMigrationsName}, - }) - - return autoMigrateModule -} - -// setupMigrations initializes the goose migration provider. -func setupMigrations() error { - var err error - - if err := goose.SetDialect("mysql"); err != nil { - slog.Error("Couldn't set database dialect for goose", "err", err) - return err - } - - // Initialize the goose migration provider - Migration, err = goose.NewProvider(goose.DialectMySQL, dbx.SQLO(), migrationsConfig.FS) - if err != nil { - slog.Error("Couldn't initialize goose migration provider", "err", err) - return err - } - - 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(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 - } - slog.Info("Database versions", "current", current, "target", target) - - var count int64 - for current > 0 { - res, err := Migration.Down(ctx) - if err := handleMigrationResults(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 { - slog.Error("Couldn't check for pending migrations", "err", err) - return err - } - - migrationFields := []any{ - "current", migrationCurrent, - "target", migrationTarget, - } - - if migrationCurrent >= migrationTarget { - slog.Info("No pending migrations", "version", migrationCurrent) - } else if !autoMigrateEnabled { - slog.Error( - "Pending migrations detected, but auto-migration is disabled. Please run `acrm migrate up` to apply them.", - migrationFields..., - ) - return err - } else { - slog.Info("Pending migrations detected, applying them...", migrationFields...) - now := time.Now() - count, err := ApplyPendingMigrations(migrationCtx, migrationTarget-migrationCurrent) - if err != nil { - slog.Error("Couldn't apply pending migrations", "current", migrationCurrent+count, "target", migrationTarget, "err", err) - return err - } - slog.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(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) - slog.Error("Couldn't apply migration", fields...) - return res.Error - } else if res.Empty { - slog.Warn("Applied empty migration", fields...) - } else { - slog.Info("Applied migration", fields...) - } - - return nil -}