feat: establish Orbit models, migrations and development baseline
This commit is contained in:
207
internal/storage/mysql/migrate.go
Normal file
207
internal/storage/mysql/migrate.go
Normal file
@@ -0,0 +1,207 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user