package database import ( "errors" "fmt" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" ) // RunMigrations applies all pending database migrations from the given path. // dbURL should be a database connection string (PostgreSQL or SQLite). // migrationsPath should be a file path to the migrations directory (e.g. "migrations"). // // Returns nil if all migrations were applied successfully, or an error if: // - The migration source cannot be opened // - The database connection fails // - A migration fails to apply // - The database is in a dirty state (a previous migration partially failed) func RunMigrations(dbURL string, migrationsPath string) error { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, ) if err != nil { return fmt.Errorf("failed to create migrate instance: %w", err) } defer m.Close() if err := m.Up(); err != nil { if errors.Is(err, migrate.ErrNoChange) { // No pending migrations — this is not an error return nil } // Check for dirty state version, dirty, dirtyErr := m.Version() if dirtyErr == nil && dirty { return fmt.Errorf("database is in dirty state at version %d; run 'migrate force %d' to fix before retrying: %w", version, version, err) } return fmt.Errorf("migration failed: %w", err) } return nil } // MigrateSteps applies N migration steps (positive = up, negative = down). func MigrateSteps(dbURL string, migrationsPath string, steps int) error { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, ) if err != nil { return fmt.Errorf("failed to create migrate instance: %w", err) } defer m.Close() if err := m.Steps(steps); err != nil { if errors.Is(err, migrate.ErrNoChange) { return nil } return fmt.Errorf("migration steps failed: %w", err) } return nil } // RollbackMigrations rolls back all migrations (drops all tables). func RollbackMigrations(dbURL string, migrationsPath string) error { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, ) if err != nil { return fmt.Errorf("failed to create migrate instance: %w", err) } defer m.Close() if err := m.Down(); err != nil { if errors.Is(err, migrate.ErrNoChange) { return nil } return fmt.Errorf("rollback failed: %w", err) } return nil } // ForceVersion sets the migration version to a specific number, clearing dirty state. // Use this to recover from a partially-applied migration. func ForceVersion(dbURL string, migrationsPath string, version int) error { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, ) if err != nil { return fmt.Errorf("failed to create migrate instance: %w", err) } defer m.Close() if err := m.Force(version); err != nil { return fmt.Errorf("force version failed: %w", err) } return nil } // CurrentVersion returns the current migration version and whether the DB is in a dirty state. // When no migrations have been applied (version 0), returns (0, false, nil) instead of an error. func CurrentVersion(dbURL string, migrationsPath string) (uint, bool, error) { m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), dbURL, ) if err != nil { return 0, false, fmt.Errorf("failed to create migrate instance: %w", err) } defer m.Close() version, dirty, err := m.Version() if err != nil { if errors.Is(err, migrate.ErrNilVersion) { // No migrations applied yet — version 0, clean state return 0, false, nil } return 0, false, fmt.Errorf("failed to get version: %w", err) } return version, dirty, nil }