package admin import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path/filepath" "sort" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Migration struct { Version string Path string Checksum string } func RunMigrations(ctx context.Context, db *pgxpool.Pool, dir string) error { if db == nil { return errors.New("database is required") } migrations, err := LoadMigrationFiles(dir) if err != nil { return err } tx, err := db.Begin(ctx) if err != nil { return fmt.Errorf("begin migration transaction: %w", err) } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, ` CREATE TABLE IF NOT EXISTS admin_schema_migrations ( version TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() )`); err != nil { return fmt.Errorf("ensure migration table: %w", err) } for _, migration := range migrations { if err := applyMigration(ctx, tx, migration); err != nil { return err } } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit migrations: %w", err) } return nil } func LoadMigrationFiles(dir string) ([]Migration, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("read migrations dir: %w", err) } var migrations []Migration for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { continue } path := filepath.Join(dir, entry.Name()) content, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read migration %s: %w", entry.Name(), err) } migrations = append(migrations, Migration{ Version: entry.Name(), Path: path, Checksum: checksum(content), }) } sort.Slice(migrations, func(i, j int) bool { return migrations[i].Version < migrations[j].Version }) return migrations, nil } func applyMigration(ctx context.Context, tx pgx.Tx, migration Migration) error { var appliedChecksum string err := tx.QueryRow(ctx, ` SELECT checksum FROM admin_schema_migrations WHERE version = $1`, migration.Version).Scan(&appliedChecksum) if err == nil { if appliedChecksum != migration.Checksum { return fmt.Errorf("migration %s checksum changed", migration.Version) } return nil } if !errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("check migration %s: %w", migration.Version, err) } content, err := os.ReadFile(migration.Path) if err != nil { return fmt.Errorf("read migration %s: %w", migration.Version, err) } if _, err := tx.Exec(ctx, string(content)); err != nil { return fmt.Errorf("apply migration %s: %w", migration.Version, err) } if _, err := tx.Exec(ctx, ` INSERT INTO admin_schema_migrations (version, checksum) VALUES ($1, $2)`, migration.Version, migration.Checksum); err != nil { return fmt.Errorf("record migration %s: %w", migration.Version, err) } return nil } func checksum(content []byte) string { sum := sha256.Sum256(content) return hex.EncodeToString(sum[:]) }