package database import ( "context" "database/sql" "embed" "fmt" "path" "github.com/blang/semver/v4" "git.nakama.town/fmartingr/gotoolkit/model" ) // Version is a type alias for semver.Version for convenience type Version = semver.Version // ParseVersion parses a version string func ParseVersion(v string) (Version, error) { return semver.Parse(v) } // Migration represents a database schema migration type Migration struct { FromVersion Version ToVersion Version MigrationFunc func(db *sql.DB) error } // txFn is a function that runs in a transaction. type txFn func(tx *sql.Tx) error // runInTransaction runs the given function in a transaction. func runInTransaction(db *sql.DB, fn txFn) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("failed to start transaction: %w", err) } defer tx.Rollback() if err := fn(tx); err != nil { return fmt.Errorf("failed to run transaction: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // NewFuncMigration creates a new migration from a function. func NewFuncMigration(fromVersion, toVersion string, migrationFunc func(db *sql.DB) error) (Migration, error) { from, err := semver.Parse(fromVersion) if err != nil { return Migration{}, fmt.Errorf("invalid from version: %w", err) } to, err := semver.Parse(toVersion) if err != nil { return Migration{}, fmt.Errorf("invalid to version: %w", err) } return Migration{ FromVersion: from, ToVersion: to, MigrationFunc: migrationFunc, }, nil } // NewFileMigration creates a new migration from an embedded file system. func NewFileMigration(fromVersion, toVersion, filename string, migrationFiles embed.FS) (Migration, error) { migration, err := NewFuncMigration(fromVersion, toVersion, func(db *sql.DB) error { return runInTransaction(db, func(tx *sql.Tx) error { migrationSQL, err := migrationFiles.ReadFile(path.Join("migrations", filename+".up.sql")) if err != nil { return fmt.Errorf("failed to read migration file: %w", err) } if _, err := tx.Exec(string(migrationSQL)); err != nil { return fmt.Errorf("failed to execute migration %s to %s: %w", fromVersion, toVersion, err) } return nil }) }) if err != nil { return Migration{}, err } return migration, nil } // RunMigrations runs the given migrations on the database. func RunMigrations(ctx context.Context, db model.DB, migrations []Migration) error { currentVersion := semver.Version{} // Get current database version dbVersion, err := db.GetDatabaseSchemaVersion(ctx) if err == nil && dbVersion != "" { currentVersion, err = semver.Parse(dbVersion) if err != nil { return fmt.Errorf("failed to parse current database version %s: %w", dbVersion, err) } } for _, migration := range migrations { if !currentVersion.EQ(migration.FromVersion) { continue } if err := migration.MigrationFunc(db.WriterDB()); err != nil { return fmt.Errorf("failed to run migration from %s to %s: %w", migration.FromVersion, migration.ToVersion, err) } currentVersion = migration.ToVersion if err := db.SetDatabaseSchemaVersion(ctx, currentVersion.String()); err != nil { return fmt.Errorf("failed to store database version %s: %w", currentVersion.String(), err) } } return nil }