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 for the current dialect. // Migration files live in migrations// subdirectories. // Falls back to the root migrations/ directory if no subdirectory exists // (backward compatible with pre-v0.17.1 postgres-only layouts). func Migrate() error { if DB == nil { return fmt.Errorf("database not connected") } // Create tracking table — syntax compatible with both drivers. createMigrations := `CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')) )` if IsPostgres() { createMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ DEFAULT NOW() )` } if _, err := DB.Exec(createMigrations); err != nil { return fmt.Errorf("create migrations table: %w", err) } // Locate migration files for current dialect. 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) } 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. for _, file := range files { prefix := strings.SplitN(file, "_", 2)[0] DB.Exec("UPDATE schema_migrations SET version = ? WHERE version = ? AND version != ?", file, prefix, file) if IsPostgres() { 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 } var exists bool if IsPostgres() { DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists) } else { DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", version).Scan(&exists) } if exists { schemaVersion = version continue } path := filepath.Join(migrationsDir, file) sqlBytes, 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(sqlBytes)); err != nil { return fmt.Errorf("apply %s: %w", file, err) } if IsPostgres() { if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil { return fmt.Errorf("record %s: %w", file, err) } } else { if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES (?)", 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). func extractVersion(filename string) string { if !strings.HasSuffix(filename, ".sql") { return "" } if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' { return "" } return filename } // findMigrationsDir locates the dialect-specific migrations directory. // Looks for migrations// first, then falls back to migrations/. func findMigrationsDir() string { dialectDir := CurrentDialect.String() // "postgres" or "sqlite" // Candidates: dialect-specific first, then generic. bases := []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) bases = append(bases, filepath.Join(dir, "migrations")) } // Try dialect-specific subdir first. for _, base := range bases { candidate := filepath.Join(base, dialectDir) if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } } // Fall back to root migrations/ (backward compat for postgres). if IsPostgres() { for _, base := range bases { if info, err := os.Stat(base); err == nil && info.IsDir() { return base } } } return "" }