// Package db wraps sqlx and runs embedded SQL migrations on startup. // // Migrations are flat files in migrations/, named NNN_*.sql. They run in // lexical order. Each is executed in its own transaction; a missing // schema_migrations row indicates "not yet applied". This is a // deliberately simple migration runner — for this project's size + team // size, pulling in golang-migrate or atlas adds complexity without // payback. If migration count grows past ~20, revisit. package db import ( "context" "database/sql" "embed" "fmt" "sort" "strings" "time" _ "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" ) //go:embed migrations/*.sql var migrationsFS embed.FS func Open(ctx context.Context, dsn string) (*sqlx.DB, error) { d, err := sqlx.ConnectContext(ctx, "mysql", dsn) if err != nil { return nil, fmt.Errorf("connect mysql: %w", err) } d.SetMaxOpenConns(25) d.SetMaxIdleConns(5) d.SetConnMaxLifetime(5 * time.Minute) return d, nil } // RunMigrations applies any migrations that aren't yet present in the // schema_migrations table. Idempotent — safe to call on every startup. func RunMigrations(ctx context.Context, d *sqlx.DB) error { // Bootstrap the tracker table itself. if _, err := d.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_migrations ( name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); err != nil { return fmt.Errorf("ensure schema_migrations: %w", err) } entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("list migrations: %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) for _, name := range files { var found string err := d.GetContext(ctx, &found, `SELECT name FROM schema_migrations WHERE name = ?`, name) if err == nil { continue // already applied } if err != sql.ErrNoRows { return fmt.Errorf("check migration %s: %w", name, err) } content, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } // MySQL doesn't support multi-statement in a single Exec by default // — split on ';' boundaries and run each individually. Comments are // passed through (server-side parser handles). statements := splitSQL(string(content)) tx, err := d.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("tx for %s: %w", name, err) } for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if _, err := tx.ExecContext(ctx, stmt); err != nil { _ = tx.Rollback() return fmt.Errorf("apply %s [statement: %q]: %w", name, firstLine(stmt), err) } } if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil { _ = tx.Rollback() return fmt.Errorf("record %s: %w", name, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit %s: %w", name, err) } } return nil } func splitSQL(s string) []string { // Crude but adequate for our migrations (no string-literal semicolons). // If we ever need to embed semicolons inside strings, switch to a // proper SQL tokenizer. return strings.Split(s, ";") } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }