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:
Josh Creek
2026-09-01 12:41:30 +01:00
parent 7e5cfdeceb
commit 67609d71c0
10 changed files with 191 additions and 0 deletions
+61
View File
@@ -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")
}
}