package mysql import ( "context" "crypto/sha256" "database/sql" "database/sql/driver" "embed" "encoding/hex" "errors" "fmt" "io/fs" "sort" "strconv" "strings" "time" ) //go:embed migrations/*.up.sql var migrationFiles embed.FS type Migration struct { Version uint64 Name string Checksum string SQL string } type MigrationStatus struct { Version uint64 `json:"version"` Name string `json:"name"` Checksum string `json:"checksum"` Dirty bool `json:"dirty"` } func Migrations() ([]Migration, error) { names, err := fs.Glob(migrationFiles, "migrations/*.up.sql") if err != nil { return nil, err } result := make([]Migration, 0, len(names)) seen := map[uint64]bool{} for _, name := range names { base := strings.TrimPrefix(name, "migrations/") version, err := strconv.ParseUint(strings.SplitN(base, "_", 2)[0], 10, 64) if err != nil || version == 0 || seen[version] { return nil, fmt.Errorf("invalid or duplicate migration version: %s", base) } seen[version] = true b, err := migrationFiles.ReadFile(name) if err != nil { return nil, err } digest := sha256.Sum256(b) result = append(result, Migration{version, base, hex.EncodeToString(digest[:]), string(b)}) } sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version }) return result, nil } // Migrate applies explicit forward migrations while holding one connection-scoped // advisory lock. MySQL DDL commits implicitly: interruption leaves a dirty record // and stops further migrations until an operator reconciles the partial schema. func Migrate(ctx context.Context, db *sql.DB) error { migrations, err := Migrations() if err != nil { return err } conn, err := db.Conn(ctx) if err != nil { return err } defer conn.Close() var dbName, version string if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&dbName, &version); err != nil { return err } var major, minor int if _, err := fmt.Sscanf(version, "%d.%d", &major, &minor); err != nil || strings.Contains(strings.ToLower(version), "mariadb") || major < 8 || major == 8 && minor < 4 { return errors.New("migrations require MySQL 8.4 or newer") } digest := sha256.Sum256([]byte(dbName)) lockName := "orbit_migrate_" + hex.EncodeToString(digest[:20]) var acquired sql.NullInt64 if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 10)", lockName).Scan(&acquired); err != nil { return err } if !acquired.Valid || acquired.Int64 != 1 { return errors.New("migration lock unavailable") } defer func() { cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() var released sql.NullInt64 if err := conn.QueryRowContext(cleanup, "SELECT RELEASE_LOCK(?)", lockName).Scan(&released); err != nil || !released.Valid || released.Int64 != 1 { _ = conn.Raw(func(any) error { return driver.ErrBadConn }) } }() if _, err := conn.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS orbit_schema_migrations ( version BIGINT UNSIGNED PRIMARY KEY, name VARCHAR(191) NOT NULL, checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, dirty BOOLEAN NOT NULL, applied_at DATETIME(6) NULL ) ENGINE=InnoDB`); err != nil { return err } rows, err := conn.QueryContext(ctx, "SELECT version, name, checksum, dirty FROM orbit_schema_migrations ORDER BY version") if err != nil { return err } applied := map[uint64]MigrationStatus{} for rows.Next() { var m MigrationStatus if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil { rows.Close() return err } applied[m.Version] = m } err = rows.Err() rows.Close() if err != nil { return err } known := map[uint64]Migration{} for _, m := range migrations { known[m.Version] = m } for v, a := range applied { m, ok := known[v] if !ok { return fmt.Errorf("database migration %d is newer than this binary", v) } if a.Dirty { return fmt.Errorf("migration %d is dirty; reconcile partial DDL before proceeding", v) } if a.Checksum != m.Checksum || a.Name != m.Name { return fmt.Errorf("migration %d checksum/name mismatch", v) } } for _, m := range migrations { if _, ok := applied[m.Version]; ok { continue } for v := range applied { if v > m.Version { return errors.New("out-of-order migration requires operator review") } } statements, err := splitSQL(m.SQL) if err != nil { return fmt.Errorf("migration %d: %w", m.Version, err) } if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", m.Version, m.Name, m.Checksum); err != nil { return err } for i, statement := range statements { if _, err := conn.ExecContext(ctx, statement); err != nil { return fmt.Errorf("migration %d statement %d failed (marked dirty): %w", m.Version, i+1, err) } } if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6) WHERE version=? AND checksum=?", m.Version, m.Checksum); err != nil { return err } } return nil } func Status(ctx context.Context, db *sql.DB) ([]MigrationStatus, error) { rows, err := db.QueryContext(ctx, "SELECT version,name,checksum,dirty FROM orbit_schema_migrations ORDER BY version") if err != nil { return nil, err } defer rows.Close() result := []MigrationStatus{} for rows.Next() { var m MigrationStatus if err := rows.Scan(&m.Version, &m.Name, &m.Checksum, &m.Dirty); err != nil { return nil, err } result = append(result, m) } return result, rows.Err() } // CheckSchema rejects missing, modified, dirty and unexpected versions. It does // not execute DDL. A matched migration ledger is not proof against manual DDL drift. func CheckSchema(ctx context.Context, db *sql.DB) error { wanted, err := Migrations() if err != nil { return err } actual, err := Status(ctx, db) if err != nil { return errors.New("schema unavailable; run orbit-migrate up with migration credentials") } if len(actual) != len(wanted) { return errors.New("schema version mismatch") } for i, m := range wanted { a := actual[i] if a.Version != m.Version || a.Name != m.Name || a.Checksum != m.Checksum || a.Dirty { return errors.New("schema is dirty or does not match this binary") } } return nil }