* H-130: reject empty rollback migration path * H-130: lock Captain inbox assistant uniqueness --------- Co-authored-by: Rogee <rogee@ipao.vip>
184 lines
5.2 KiB
Go
184 lines
5.2 KiB
Go
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 {
|
|
if migrationsPath == "" {
|
|
return errors.New("migrations path is required")
|
|
}
|
|
|
|
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
|
|
}
|