mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
67609d71c0
Add migrations.Rollback(ctx, db, dir, steps): reverses the N most recently applied migrations, newest first, each in its own committed transaction under the same advisory lock Apply uses. Down SQL lives in migrations/down/<version>.sql (a subdirectory, so Apply's *.sql glob over the main directory is untouched); a missing down file for a migration being rolled back is a hard error rather than a silent partial reversal. Wire it into cmd/migrate as --rollback=N. Add down files for all six existing migrations, each dropping objects in FK-safe reverse dependency order. Adversarial review: could not run the new integration test (TestPostgreSQLMigrationsRollBackAndReapplyCleanly, gated behind COSMIC_CLASH_POSTGRES_DSN / scripts/run_postgres_integration.sh) against a real database in this sandbox - Docker Desktop's own overlayfs ran out of space pulling postgres:17-alpine, unrelated to this change. Verified instead by hand-tracing every DROP against its forward migration's FK graph, confirming Apply's directory glob does not pick up the down/ subdirectory, and a clean go build/vet/test -tags integration. Worth an explicit real run before this is trusted in CI.
148 lines
5.2 KiB
Go
148 lines
5.2 KiB
Go
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
|
|
}
|