feat: add PostgreSQL migration runner

This commit is contained in:
Josh Creek
2026-09-01 09:21:26 +01:00
parent 4d83ec1525
commit 18888ed520
6 changed files with 133 additions and 13 deletions
+74
View File
@@ -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
}
+15
View File
@@ -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")
}
}