package database import ( "fmt" "log" "os" "path/filepath" "runtime" "sort" "strings" ) // schemaVersion tracks the latest applied migration. var schemaVersion string = "none" // SchemaVersion returns the current schema version string. func SchemaVersion() string { return schemaVersion } // Migrate runs all pending migrations. It creates the schema_migrations // tracking table if it doesn't exist, then applies each .sql file that // hasn't been applied yet, in order. func Migrate() error { if DB == nil { return fmt.Errorf("database not connected") } // Ensure tracking table exists _, err := DB.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW() ) `) if err != nil { return fmt.Errorf("create migrations table: %w", err) } // Find migration files migrationsDir := findMigrationsDir() if migrationsDir == "" { log.Println("⚠ No migrations directory found — skipping schema migration") return nil } entries, err := os.ReadDir(migrationsDir) if err != nil { return fmt.Errorf("read migrations dir: %w", err) } // Collect and sort .sql files var files []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { files = append(files, e.Name()) } } sort.Strings(files) if len(files) == 0 { log.Println(" No migration files found") return nil } // Compat: rename old numeric-only version entries to full filenames. // Earlier extractVersion used regex ^(\d+), recording "001" instead of // "001_v09_schema.sql". Fix them in-place so CI's db-migrate.sh matches. for _, file := range files { prefix := strings.SplitN(file, "_", 2)[0] // "001" DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix) } // Apply pending migrations applied := 0 for _, file := range files { version := extractVersion(file) if version == "" { continue } // Check if already applied var exists bool DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists) if exists { schemaVersion = version continue } // Read and execute path := filepath.Join(migrationsDir, file) sql, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read %s: %w", file, err) } log.Printf(" Applying migration %s...", file) if _, err := DB.Exec(string(sql)); err != nil { return fmt.Errorf("apply %s: %w", file, err) } // Record if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil { return fmt.Errorf("record %s: %w", file, err) } schemaVersion = version applied++ } if applied > 0 { log.Printf(" ✅ Applied %d migration(s), schema at %s", applied, schemaVersion) } else { log.Printf(" Schema up to date at %s", schemaVersion) } return nil } // extractVersion returns the filename as the version key if it's a valid // migration file (starts with digit, ends with .sql). // "001_v09_schema.sql" → "001_v09_schema.sql" (matches db-migrate.sh convention) func extractVersion(filename string) string { // Use full filename as version to match db-migrate.sh convention if !strings.HasSuffix(filename, ".sql") { return "" } // Must start with a digit (e.g., 001_v09_schema.sql) if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' { return "" } return filename } // findMigrationsDir locates the migrations directory. // Checks relative to the binary, then relative to the source file. func findMigrationsDir() string { candidates := []string{ "database/migrations", "server/database/migrations", "../database/migrations", } // Also check relative to this source file (for tests) _, thisFile, _, ok := runtime.Caller(0) if ok { dir := filepath.Dir(thisFile) candidates = append(candidates, filepath.Join(dir, "migrations")) } for _, c := range candidates { if info, err := os.Stat(c); err == nil && info.IsDir() { return c } } return "" }