181 lines
5.0 KiB
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"},
|
|
}
|
|
}
|