mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): add down migrations and a rollback runner
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Down migration for 0002_assignments.sql.
|
||||
DROP TABLE IF EXISTS assignments;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Down migration for 0003_queue_probe_metadata.sql.
|
||||
ALTER TABLE queue_tickets DROP COLUMN IF EXISTS predicted_rtt;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Down migration for 0004_allocator_registry.sql.
|
||||
DROP TABLE IF EXISTS allocations;
|
||||
DROP TABLE IF EXISTS game_servers;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user