package migrations import ( "context" "database/sql" "fmt" "os" "path/filepath" "sort" "strings" ) const migrationTableSQL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() )` // Apply executes numbered SQL files in lexical order. A transaction-level // advisory lock serializes concurrent API/worker starts, while each migration // is committed together with its schema_migrations marker so a failed // migration can be retried safely. func Apply(ctx context.Context, db *sql.DB, directory string) error { if db == nil || strings.TrimSpace(directory) == "" { return fmt.Errorf("database and migration directory are required") } paths, err := filepath.Glob(filepath.Join(directory, "*.sql")) if err != nil { return fmt.Errorf("find migrations: %w", err) } sort.Slice(paths, func(i, j int) bool { return filepath.Base(paths[i]) < filepath.Base(paths[j]) }) if len(paths) == 0 { return fmt.Errorf("no migrations found in %s", directory) } if _, err := db.ExecContext(ctx, migrationTableSQL); err != nil { return fmt.Errorf("create migration table: %w", err) } for _, path := range paths { version := filepath.Base(path) sqlBytes, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read migration %s: %w", version, err) } tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration %s: %w", version, err) } committed := false defer func() { if !committed { _ = tx.Rollback() } }() if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('cosmic-clash:migrations'))`); err != nil { return fmt.Errorf("lock migration %s: %w", version, err) } var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil { return fmt.Errorf("check migration %s: %w", version, err) } if !applied { if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil { return fmt.Errorf("apply migration %s: %w", version, err) } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { return fmt.Errorf("record migration %s: %w", version, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit migration %s: %w", version, err) } committed = true } return nil } // Rollback reverses the `steps` most recently applied migrations, newest // first, by running each one's down file from the `down/` subdirectory of // `directory` (e.g. `down/0006_match_allocation_claims.sql` undoes // `0006_match_allocation_claims.sql`) and deleting its schema_migrations // marker. Each rollback is committed in its own transaction, same as Apply, // so a failure partway through leaves the schema at a consistent, resumable // state rather than a half-applied one. A missing down file for a migration // being rolled back is a hard error — better a stuck rollback than a schema // silently left half-reversed. func Rollback(ctx context.Context, db *sql.DB, directory string, steps int) error { if db == nil || strings.TrimSpace(directory) == "" || steps <= 0 { return fmt.Errorf("database, migration directory and a positive step count are required") } if _, err := db.ExecContext(ctx, migrationTableSQL); err != nil { return fmt.Errorf("create migration table: %w", err) } rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations ORDER BY version DESC LIMIT $1`, steps) if err != nil { return fmt.Errorf("list applied migrations: %w", err) } var versions []string for rows.Next() { var version string if err := rows.Scan(&version); err != nil { rows.Close() return fmt.Errorf("scan applied migration: %w", err) } versions = append(versions, version) } if err := rows.Err(); err != nil { return fmt.Errorf("list applied migrations: %w", err) } if err := rows.Close(); err != nil { return fmt.Errorf("list applied migrations: %w", err) } for _, version := range versions { sqlBytes, err := os.ReadFile(filepath.Join(directory, "down", version)) if err != nil { return fmt.Errorf("read down migration for %s: %w", version, err) } tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin rollback %s: %w", version, err) } committed := false defer func() { if !committed { _ = tx.Rollback() } }() if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext('cosmic-clash:migrations'))`); err != nil { return fmt.Errorf("lock rollback %s: %w", version, err) } var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil { return fmt.Errorf("check rollback %s: %w", version, err) } if applied { if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil { return fmt.Errorf("apply down migration %s: %w", version, err) } if _, err := tx.ExecContext(ctx, `DELETE FROM schema_migrations WHERE version = $1`, version); err != nil { return fmt.Errorf("unrecord migration %s: %w", version, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit rollback %s: %w", version, err) } committed = true } return nil }