package infra import ( "database/sql" "fmt" "sort" "strings" "time" ) // MigrationGetStatus crosses migration files on disk with records in _migrations, // returning a sorted list of MigrationStatus entries ordered by version ascending. // Migrations present in _migrations but not on disk are included with Applied=true // and marked as orphaned in their Name (suffix " (orphaned)"). // If _migrations does not exist yet, all file-based migrations are returned as pending. func MigrationGetStatus(db *sql.DB, dir string) ([]MigrationStatus, error) { // Load files from disk (non-fatal if dir is empty or missing) fileMigrations, err := loadMigrationsFromDir(dir) if err != nil && !strings.Contains(err.Error(), "cannot read migrations directory") { return nil, fmt.Errorf("migration_status: %w", err) } if err != nil { // Directory doesn't exist — treat as empty fileMigrations = nil } // Build a map from version to file migration fileMap := make(map[int]Migration, len(fileMigrations)) for _, m := range fileMigrations { fileMap[m.Version] = m } // Load applied migrations from DB (_migrations may not exist yet) dbMap, err := loadAppliedMigrations(db) if err != nil { return nil, fmt.Errorf("migration_status: %w", err) } // Merge: collect all known versions allVersions := make(map[int]struct{}) for v := range fileMap { allVersions[v] = struct{}{} } for v := range dbMap { allVersions[v] = struct{}{} } // Build status list var statuses []MigrationStatus for v := range allVersions { dbEntry, inDB := dbMap[v] fileEntry, inFile := fileMap[v] name := fileEntry.Name if !inFile && inDB { // Orphaned: applied but no file on disk name = dbEntry.Name + " (orphaned)" } statuses = append(statuses, MigrationStatus{ Version: v, Name: name, Applied: inDB, AppliedAt: dbEntry.AppliedAt, // zero value if not in DB }) } sort.Slice(statuses, func(i, j int) bool { return statuses[i].Version < statuses[j].Version }) return statuses, nil } // migrationDBRecord holds data from the _migrations table. type migrationDBRecord struct { Name string AppliedAt time.Time } // loadAppliedMigrations queries _migrations and returns a map of version -> record. // If the table does not exist, returns an empty map without error. func loadAppliedMigrations(db *sql.DB) (map[int]migrationDBRecord, error) { result := make(map[int]migrationDBRecord) rows, err := db.Query("SELECT version, name, applied_at FROM _migrations ORDER BY version ASC") if err != nil { // Table doesn't exist yet — return empty map if strings.Contains(err.Error(), "no such table") { return result, nil } return nil, fmt.Errorf("query _migrations: %w", err) } defer rows.Close() for rows.Next() { var version int var name, appliedAtStr string if err := rows.Scan(&version, &name, &appliedAtStr); err != nil { return nil, fmt.Errorf("scan _migrations row: %w", err) } appliedAt, _ := time.Parse("2006-01-02 15:04:05", appliedAtStr) result[version] = migrationDBRecord{Name: name, AppliedAt: appliedAt} } return result, rows.Err() }