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 // freshInstallBaseline is the current schema without the historical foreign // key DDL. Existing databases keep using the immutable forward migration // chain; only an empty database takes this path. // //go:embed baseline.sql var freshInstallBaseline string const freshInstallBaselineVersion uint64 = 32 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 }) } }() var ledgerExists, businessTables int if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orbit_schema_migrations'`).Scan(&ledgerExists); err != nil { return err } if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_') AND TABLE_NAME<>'orbit_schema_migrations'`).Scan(&businessTables); err != nil { return err } if ledgerExists == 0 && businessTables > 0 { return errors.New("Orbit tables exist without migration ledger; refuse to guess schema history") } 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 } if len(applied) == 0 && businessTables > 0 { return errors.New("Orbit tables exist with an empty migration ledger; reconcile schema history before proceeding") } if len(applied) == 0 && businessTables == 0 { return applyFreshInstallBaseline(ctx, conn, migrations) } 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 := validateNoReferenceDDL(m.Version, statements); err != nil { return 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 } // validateNoReferenceDDL protects the post-removal migration line at runtime. // Versions 1-8 are immutable compatibility history: version 8 removes the // legacy constraints from databases that were created before the no-FK policy. // Every later migration must be incapable of creating or retaining a database // reference constraint, even if a future author forgets the static test. func validateNoReferenceDDL(version uint64, statements []string) error { if version <= 8 { return nil } for _, statement := range statements { upper := strings.ToUpper(statement) if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") { return fmt.Errorf("migration %d contains forbidden foreign-key DDL", version) } } return nil } // applyFreshInstallBaseline creates a new database directly at the current // schema version. It records every historical migration as dirty before DDL so // an interrupted baseline remains blocked for operator reconciliation, just // like an interrupted ordinary migration. The baseline is intentionally // limited to an empty database; it never rewrites or reinterprets an upgrade. func applyFreshInstallBaseline(ctx context.Context, conn *sql.Conn, migrations []Migration) error { if len(migrations) == 0 || migrations[len(migrations)-1].Version != freshInstallBaselineVersion { return errors.New("fresh-install baseline is out of date; regenerate it before release") } statements, err := splitSQL(freshInstallBaseline) if err != nil { return fmt.Errorf("fresh-install baseline: %w", err) } for _, statement := range statements { upper := strings.ToUpper(statement) if strings.Contains(upper, "FOREIGN KEY") || strings.Contains(upper, " REFERENCES ") { return errors.New("fresh-install baseline contains forbidden foreign-key DDL") } } for _, migration := range migrations { if _, err := conn.ExecContext(ctx, "INSERT INTO orbit_schema_migrations (version,name,checksum,dirty) VALUES (?,?,?,TRUE)", migration.Version, migration.Name, migration.Checksum); err != nil { return err } } for i, statement := range statements { if _, err := conn.ExecContext(ctx, statement); err != nil { return fmt.Errorf("fresh-install baseline statement %d failed (migration ledger remains dirty): %w", i+1, err) } } if _, err := conn.ExecContext(ctx, "UPDATE orbit_schema_migrations SET dirty=FALSE, applied_at=UTC_TIMESTAMP(6)"); 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 校验迁移记录并禁止外键,不执行 DDL;不代表已核验其他手工结构漂移。 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") } } var foreignKeys int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE() AND LEFT(TABLE_NAME,6) IN ('orbit_','pulse_','weave_')`).Scan(&foreignKeys); err != nil { return err } if foreignKeys != 0 { return errors.New("foreign keys are forbidden on Orbit tables; apply the supported schema before starting") } return nil }