Two fixes surfaced by sim e2e test (which otherwise passed full
lifecycle: created → signup_open → 3 signups → allocator → debating
→ arguments → verdict gate (409 early, 201 after debate_end_at) →
completed).
1) MySQL TIMESTAMP rejects RFC3339-with-Z strings — passing those as
sqlx parameters fails with "Incorrect datetime value". Changed
CreateTopicInput lifecycle fields from string to time.Time; the
handler parses+UTCs in validateLifecycleTimes (which now returns
the parsed array along with the validation result) and passes
time.Time to the store. The mysql driver formats correctly.
2) splitSQL was naive `strings.Split(s, ";")` which split inside
comments — the 001 migration had a few `--` lines containing `;`
("signup_close_at; immutable", etc) which broke. Migration text
tidied to not use `;` inside comments, AND splitSQL upgraded to
skip both `-- ...` and `/* ... */` comment regions before splitting.
Sim verified — clean apply on fresh MySQL.
143 lines
3.9 KiB
Go
143 lines
3.9 KiB
Go
// 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 {
|
|
// Comment-aware splitter: skip `;` inside `-- ...` line comments
|
|
// and `/* ... */` block comments. Doesn't handle string-literal
|
|
// semicolons (we don't put any) — if we ever need that, swap in a
|
|
// real SQL tokenizer.
|
|
var b strings.Builder
|
|
i := 0
|
|
for i < len(s) {
|
|
if i+1 < len(s) && s[i] == '-' && s[i+1] == '-' {
|
|
// single-line comment — strip through end of line
|
|
for i < len(s) && s[i] != '\n' {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' {
|
|
// block comment — strip through `*/`
|
|
i += 2
|
|
for i+1 < len(s) && !(s[i] == '*' && s[i+1] == '/') {
|
|
i++
|
|
}
|
|
i += 2
|
|
continue
|
|
}
|
|
b.WriteByte(s[i])
|
|
i++
|
|
}
|
|
return strings.Split(b.String(), ";")
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|