diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 0205bfb8..5956c620 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -15,6 +15,7 @@ import ( func main() { dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string") directory := flag.String("dir", "migrations", "directory containing numbered SQL migrations") + rollback := flag.Int("rollback", 0, "roll back this many of the most recently applied migrations instead of applying forward") flag.Parse() if *dsn == "" { fmt.Fprintln(os.Stderr, "migrate: --dsn or COSMIC_CLASH_POSTGRES_DSN is required") @@ -28,6 +29,14 @@ func main() { defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + if *rollback > 0 { + if err := migrations.Rollback(ctx, db, *directory, *rollback); err != nil { + fmt.Fprintln(os.Stderr, "migrate:", err) + os.Exit(1) + } + fmt.Printf("rolled back %d migration(s)\n", *rollback) + return + } if err := migrations.Apply(ctx, db, *directory); err != nil { fmt.Fprintln(os.Stderr, "migrate:", err) os.Exit(1) diff --git a/server/migrations/down/0001_initial.sql b/server/migrations/down/0001_initial.sql new file mode 100644 index 00000000..56f5cce7 --- /dev/null +++ b/server/migrations/down/0001_initial.sql @@ -0,0 +1,18 @@ +-- Down migration for 0001_initial.sql. Tables drop in FK-safe reverse +-- dependency order (a child table always drops before anything it +-- references); dropping a table drops its own indexes with it. +DROP TABLE IF EXISTS audit_events; +DROP TABLE IF EXISTS outbox; +DROP TABLE IF EXISTS result_receipts; +DROP TABLE IF EXISTS penalties; +DROP TABLE IF EXISTS ranked_season_rollovers; +DROP TABLE IF EXISTS seasons; +DROP TABLE IF EXISTS ratings; +DROP TABLE IF EXISTS match_participants; +DROP TABLE IF EXISTS matches; +DROP TABLE IF EXISTS proposal_participants; +DROP TABLE IF EXISTS proposals; +DROP TABLE IF EXISTS queue_tickets; +DROP TABLE IF EXISTS idempotency_keys; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS identities; diff --git a/server/migrations/down/0002_assignments.sql b/server/migrations/down/0002_assignments.sql new file mode 100644 index 00000000..2e30c080 --- /dev/null +++ b/server/migrations/down/0002_assignments.sql @@ -0,0 +1,2 @@ +-- Down migration for 0002_assignments.sql. +DROP TABLE IF EXISTS assignments; diff --git a/server/migrations/down/0003_queue_probe_metadata.sql b/server/migrations/down/0003_queue_probe_metadata.sql new file mode 100644 index 00000000..2ca1624e --- /dev/null +++ b/server/migrations/down/0003_queue_probe_metadata.sql @@ -0,0 +1,2 @@ +-- Down migration for 0003_queue_probe_metadata.sql. +ALTER TABLE queue_tickets DROP COLUMN IF EXISTS predicted_rtt; diff --git a/server/migrations/down/0004_allocator_registry.sql b/server/migrations/down/0004_allocator_registry.sql new file mode 100644 index 00000000..90f4661a --- /dev/null +++ b/server/migrations/down/0004_allocator_registry.sql @@ -0,0 +1,3 @@ +-- Down migration for 0004_allocator_registry.sql. +DROP TABLE IF EXISTS allocations; +DROP TABLE IF EXISTS game_servers; diff --git a/server/migrations/down/0005_proposal_match_plans.sql b/server/migrations/down/0005_proposal_match_plans.sql new file mode 100644 index 00000000..5c66e27f --- /dev/null +++ b/server/migrations/down/0005_proposal_match_plans.sql @@ -0,0 +1,8 @@ +-- Down migration for 0005_proposal_match_plans.sql. +DROP INDEX IF EXISTS proposal_participants_unique_slot; +ALTER TABLE proposal_participants + DROP COLUMN IF EXISTS slot, + DROP COLUMN IF EXISTS team; +ALTER TABLE proposals + DROP COLUMN IF EXISTS match_protocol, + DROP COLUMN IF EXISTS match_region; diff --git a/server/migrations/down/0006_match_allocation_claims.sql b/server/migrations/down/0006_match_allocation_claims.sql new file mode 100644 index 00000000..471a43af --- /dev/null +++ b/server/migrations/down/0006_match_allocation_claims.sql @@ -0,0 +1,6 @@ +-- Down migration for 0006_match_allocation_claims.sql. +DROP INDEX IF EXISTS matches_allocating_claimable; +ALTER TABLE matches + DROP CONSTRAINT IF EXISTS matches_allocation_claim_pair, + DROP COLUMN IF EXISTS allocation_claimed_at, + DROP COLUMN IF EXISTS allocation_id; diff --git a/server/migrations/runner.go b/server/migrations/runner.go index cf10b0cb..fd12ec33 100644 --- a/server/migrations/runner.go +++ b/server/migrations/runner.go @@ -72,3 +72,76 @@ func Apply(ctx context.Context, db *sql.DB, directory string) error { } 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 +} diff --git a/server/migrations/runner_test.go b/server/migrations/runner_test.go index 48327443..d08f3381 100644 --- a/server/migrations/runner_test.go +++ b/server/migrations/runner_test.go @@ -13,3 +13,12 @@ func TestApplyRejectsMissingDatabaseOrDirectory(t *testing.T) { t.Fatal("empty directory accepted") } } + +func TestRollbackRejectsMissingDatabaseDirectoryOrSteps(t *testing.T) { + if err := Rollback(context.Background(), nil, ".", 1); err == nil { + t.Fatal("nil database accepted") + } + if err := Rollback(context.Background(), nil, "", 1); err == nil { + t.Fatal("empty directory accepted") + } +} diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 9b2f3fdf..961ba55e 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -488,3 +488,64 @@ func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) { t.Fatal("assignments migration did not create its table") } } + +func TestPostgreSQLMigrationsRollBackAndReapplyCleanly(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + dir := filepath.Join("..", "migrations") + tableExists := func(table string) bool { + var count int + if err := db.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`, table).Scan(&count); err != nil { + t.Fatal(err) + } + return count == 1 + } + if !tableExists("assignments") || !tableExists("allocations") { + t.Fatal("expected forward-applied schema before rollback") + } + + // Roll back every migration one at a time, in reverse, checking each + // down file actually undoes what its forward file created — not just + // that Rollback returns nil. + if err := migrations.Rollback(context.Background(), db, dir, 1); err != nil { + t.Fatalf("rollback 0006: %v", err) + } + var hasAllocationClaimColumn bool + if err := db.QueryRow(`SELECT count(*) > 0 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'allocation_id'`).Scan(&hasAllocationClaimColumn); err != nil { + t.Fatal(err) + } + if hasAllocationClaimColumn { + t.Fatal("0006 rollback did not drop matches.allocation_id") + } + + if err := migrations.Rollback(context.Background(), db, dir, 4); err != nil { + t.Fatalf("rollback remaining down to 0001: %v", err) + } + if tableExists("assignments") || tableExists("allocations") || tableExists("game_servers") { + t.Fatal("rollback left later-migration tables behind") + } + + if err := migrations.Rollback(context.Background(), db, dir, 1); err != nil { + t.Fatalf("rollback 0001: %v", err) + } + if tableExists("identities") || tableExists("matches") { + t.Fatal("0001 rollback did not drop its own tables") + } + var remaining int + if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining != 0 { + t.Fatalf("expected schema_migrations empty after full rollback, got %d rows", remaining) + } + + // Reapplying from a fully rolled-back state must reach the same schema, + // proving down files don't leave orphaned state that trips a forward + // re-run (e.g. a constraint or index Apply then tries to recreate). + if err := migrations.Apply(context.Background(), db, dir); err != nil { + t.Fatalf("reapply after full rollback: %v", err) + } + if !tableExists("assignments") || !tableExists("allocations") { + t.Fatal("reapply after rollback did not recreate the schema") + } +}