Files
member-console/internal/db/migrations.go

181 lines
5.0 KiB
Go

package db
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
// MigrationSource pairs a module name with its embedded migration filesystem.
// Modules are registered in dependency order (e.g., identity before organization).
type MigrationSource struct {
Name string // Module name (e.g., "db", "identity", "organization")
Migrations fs.FS // Embedded FS containing migration SQL files
Dir string // Directory path within the FS (e.g., "migrations")
}
// assembleMigrations collects migrations from all sources in order, writes them
// to a temporary directory with stable version prefixes, and returns the temp
// directory path. The caller is responsible for removing the temp directory.
//
// Each module gets a fixed numeric namespace based on its position in the
// sources slice: module at index i gets versions (i+1)*1000 + intra-module seq.
// This means adding a new migration to one module never changes the version
// numbers of any other module's migrations. Each module can have up to 999
// migrations.
func assembleMigrations(sources []MigrationSource) (string, error) {
tmpDir, err := os.MkdirTemp("", "migrations-*")
if err != nil {
return "", fmt.Errorf("failed to create temp migration dir: %w", err)
}
for srcIdx, src := range sources {
moduleBase := (srcIdx + 1) * 1000
// Read migration files from the embedded FS
entries, err := fs.ReadDir(src.Migrations, src.Dir)
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("failed to read migrations for module %s: %w", src.Name, err)
}
// Sort entries to preserve intra-module ordering
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
intraSeq := 1
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
content, err := fs.ReadFile(src.Migrations, filepath.Join(src.Dir, entry.Name()))
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("failed to read migration %s/%s: %w", src.Name, entry.Name(), err)
}
// Use stable version: moduleBase + intra-module sequence
// e.g., billing (index 3) migration 00002 → 04002_billing_seed_products.sql
baseName := stripNumericPrefix(entry.Name())
version := moduleBase + intraSeq
newName := fmt.Sprintf("%05d_%s_%s", version, src.Name, baseName)
intraSeq++
if err := os.WriteFile(filepath.Join(tmpDir, newName), content, 0644); err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("failed to write assembled migration %s: %w", newName, err)
}
}
}
return tmpDir, nil
}
// stripNumericPrefix removes a leading numeric prefix and underscore from a filename.
// e.g., "00001_init.sql" → "init.sql", "00002_add_roles.sql" → "add_roles.sql"
func stripNumericPrefix(name string) string {
idx := strings.Index(name, "_")
if idx < 0 {
return name
}
prefix := name[:idx]
// Check if the prefix is all digits
allDigits := true
for _, c := range prefix {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if allDigits {
return name[idx+1:]
}
return name
}
// RunMigrations runs all pending migrations from the given ordered sources.
func RunMigrations(database *sql.DB, sources []MigrationSource) error {
tmpDir, err := assembleMigrations(sources)
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
goose.SetBaseFS(nil) // Use real filesystem for the temp directory
if err := goose.SetDialect("postgres"); err != nil {
return err
}
// Allow out-of-order migrations. New migrations added to earlier modules
// (e.g., integration 8002) must be applicable even when later modules
// (e.g., fwmod 9001) are already applied.
if err := goose.Up(database, tmpDir, goose.WithAllowMissing()); err != nil {
return err
}
return nil
}
// RollbackMigration rolls back the last migration using the given ordered sources.
func RollbackMigration(database *sql.DB, sources []MigrationSource) error {
tmpDir, err := assembleMigrations(sources)
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
if err := goose.Down(database, tmpDir); err != nil {
return err
}
return nil
}
// MigrationStatus shows the status of all migrations using the given ordered sources.
func MigrationStatus(database *sql.DB, sources []MigrationSource) error {
tmpDir, err := assembleMigrations(sources)
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
if err := goose.Status(database, tmpDir); err != nil {
return err
}
return nil
}
// BaseSources returns the db package's own migration source.
// This is the foundation — identity and organization modules are appended by callers.
func BaseSources() []MigrationSource {
return []MigrationSource{
{Name: "db", Migrations: embedMigrations, Dir: "migrations"},
}
}