package database import ( "errors" "fmt" "os" "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" ) var unsupportedPQEnvironmentKeys = []string{"PGSERVICE", "PGSERVICEFILE", "PGREALM"} // SanitizePostgresEnvironment removes libpq service-file settings that are // incompatible with GoChat's explicit database configuration and can make // both pgx and lib/pq ignore or reject the configured host/user/database. func SanitizePostgresEnvironment() { for _, key := range unsupportedPQEnvironmentKeys { _ = os.Unsetenv(key) } } // 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 { return withSanitizedPQEnvironment(func() error { return runMigrations(dbURL, migrationsPath) }) } 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 } // withSanitizedPQEnvironment prevents lib/pq from panicking when PostgreSQL // service-file variables are inherited from the user's shell. GoChat passes a // complete connection URL to golang-migrate, and lib/pq does not support these // libpq service variables. Restore them after the migration operation so this // workaround remains scoped to the legacy migration driver. func withSanitizedPQEnvironment(fn func() error) error { type savedValue struct { value string set bool } saved := make(map[string]savedValue, len(unsupportedPQEnvironmentKeys)) for _, key := range unsupportedPQEnvironmentKeys { value, set := os.LookupEnv(key) saved[key] = savedValue{value: value, set: set} _ = os.Unsetenv(key) } defer func() { for _, key := range unsupportedPQEnvironmentKeys { previous := saved[key] if previous.set { _ = os.Setenv(key, previous.value) } else { _ = os.Unsetenv(key) } } }() return fn() } // 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 }