registry/internal/database/migrate.go
adam jones 6f0d43320a
fix: use path.Join instead of filepath.Join for embedded migrations (#338)
Fixes Windows compatibility issue where filepath.Join uses backslashes
but embed.FS always expects forward slashes for embedded paths.

Fixes #337

Also see https://github.com/golang/go/issues/44305

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
2025-09-02 14:51:23 +01:00

192 lines
4.9 KiB
Go

package database
import (
"context"
"embed"
"errors"
"fmt"
"log"
"path"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
// Migration represents a database migration
type Migration struct {
Version int
Name string
SQL string
}
// Migrator handles database migrations
type Migrator struct {
conn *pgx.Conn
}
// NewMigrator creates a new migrator instance
func NewMigrator(conn *pgx.Conn) *Migrator {
return &Migrator{conn: conn}
}
// ensureMigrationsTable creates the migrations tracking table if it doesn't exist
func (m *Migrator) ensureMigrationsTable(ctx context.Context) error {
query := `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`
_, err := m.conn.Exec(ctx, query)
return err
}
// getAppliedMigrations returns a map of already applied migration versions
func (m *Migrator) getAppliedMigrations(ctx context.Context) (map[int]bool, error) {
query := "SELECT version FROM schema_migrations ORDER BY version"
rows, err := m.conn.Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query applied migrations: %w", err)
}
defer rows.Close()
applied := make(map[int]bool)
for rows.Next() {
var version int
if err := rows.Scan(&version); err != nil {
return nil, fmt.Errorf("failed to scan migration version: %w", err)
}
applied[version] = true
}
return applied, rows.Err()
}
// loadMigrations loads all migration files from the embedded filesystem
func (m *Migrator) loadMigrations() ([]Migration, error) {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return nil, fmt.Errorf("failed to read migrations directory: %w", err)
}
var migrations []Migration
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
// Parse version from filename (e.g., "001_initial_schema.sql" -> version 1)
name := entry.Name()
parts := strings.SplitN(name, "_", 2)
if len(parts) != 2 {
log.Printf("Skipping migration file with invalid name format: %s", name)
continue
}
version, err := strconv.Atoi(parts[0])
if err != nil {
log.Printf("Skipping migration file with invalid version: %s", name)
continue
}
// Read the migration SQL
content, err := migrationFiles.ReadFile(path.Join("migrations", name))
if err != nil {
return nil, fmt.Errorf("failed to read migration file %s: %w", name, err)
}
migrations = append(migrations, Migration{
Version: version,
Name: strings.TrimSuffix(name, ".sql"),
SQL: string(content),
})
}
// Sort migrations by version
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version
})
return migrations, nil
}
// Migrate runs all pending migrations
func (m *Migrator) Migrate(ctx context.Context) error {
// Ensure the migrations table exists
if err := m.ensureMigrationsTable(ctx); err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Get applied migrations
applied, err := m.getAppliedMigrations(ctx)
if err != nil {
return fmt.Errorf("failed to get applied migrations: %w", err)
}
// Load all migration files
migrations, err := m.loadMigrations()
if err != nil {
return fmt.Errorf("failed to load migrations: %w", err)
}
// Find pending migrations
var pending []Migration
for _, migration := range migrations {
if !applied[migration.Version] {
pending = append(pending, migration)
}
}
if len(pending) == 0 {
log.Println("No pending migrations")
return nil
}
log.Printf("Applying %d pending migrations", len(pending))
// Apply each pending migration in a transaction
for _, migration := range pending {
if err := m.applyMigration(ctx, migration); err != nil {
return fmt.Errorf("failed to apply migration %d (%s): %w", migration.Version, migration.Name, err)
}
log.Printf("Applied migration %d: %s", migration.Version, migration.Name)
}
log.Println("All migrations applied successfully")
return nil
}
// applyMigration applies a single migration in a transaction
func (m *Migrator) applyMigration(ctx context.Context, migration Migration) error {
tx, err := m.conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Printf("Failed to rollback migration transaction: %v", err)
}
}()
// Execute the migration SQL
_, err = tx.Exec(ctx, migration.SQL)
if err != nil {
return fmt.Errorf("failed to execute migration SQL: %w", err)
}
// Record the migration as applied
_, err = tx.Exec(ctx,
"INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
migration.Version, migration.Name)
if err != nil {
return fmt.Errorf("failed to record migration: %w", err)
}
return tx.Commit(ctx)
}