mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 13:22:03 +00:00
feat: add PostgreSQL migration runner
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
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")
|
||||
flag.Parse()
|
||||
if *dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "migrate: --dsn or COSMIC_CLASH_POSTGRES_DSN is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
db, err := sql.Open("pgx", *dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "migrate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := migrations.Apply(ctx, db, *directory); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "migrate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("migrations applied")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyRejectsMissingDatabaseOrDirectory(t *testing.T) {
|
||||
if err := Apply(context.Background(), nil, "."); err == nil {
|
||||
t.Fatal("nil database accepted")
|
||||
}
|
||||
if err := Apply(context.Background(), nil, ""); err == nil {
|
||||
t.Fatal("empty directory accepted")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
"github.com/cosmic-clash/cosmic-clash/server/migrations"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
@@ -40,18 +41,11 @@ func openIntegrationPostgres(t *testing.T) *sql.DB {
|
||||
|
||||
func applyIntegrationMigrations(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(), `DROP TABLE IF EXISTS assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
if _, err := db.ExecContext(context.Background(), `DROP TABLE IF EXISTS schema_migrations, assignments, audit_events, outbox, result_receipts, ranked_season_rollovers, penalties, seasons, ratings, match_participants, matches, proposal_participants, proposals, queue_tickets, idempotency_keys, sessions, identities CASCADE`); err != nil {
|
||||
t.Fatalf("reset PostgreSQL schema: %v", err)
|
||||
}
|
||||
for _, name := range []string{"0001_initial.sql", "0002_assignments.sql"} {
|
||||
path := filepath.Join("..", "migrations", name)
|
||||
sqlBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(context.Background(), string(sqlBytes)); err != nil {
|
||||
t.Fatalf("apply %s: %v", name, err)
|
||||
}
|
||||
if err := migrations.Apply(context.Background(), db, filepath.Join("..", "migrations")); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user