From 0afa04a473b8b0a0e3e5508419a41959e4697cc9 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 03:37:22 +0100 Subject: [PATCH] feat(01-02): implement migration runner with FTS5, seed data, and dev seed - Statement-splitting migration runner for go-libsql compatibility (go-libsql does not support multi-statement Exec) - FTS5 virtual table on player names with sync triggers - Default seed data: DKK venue settings, Standard and Copenhagen chip sets - Dev-only seed: default admin operator (PIN: 1234, bcrypt hashed) - Dev mode flag (--dev) controls dev seed application - First-run setup detection when no operators exist - Single connection forced during migration for table visibility - Idempotent: second startup skips all applied migrations Co-Authored-By: Claude Opus 4.6 --- internal/store/migrate.go | 105 +++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 18 deletions(-) diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 087b222..89860db 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -21,11 +21,19 @@ var devOnlyMigrations = map[string]bool{ // RunMigrations applies all pending SQL migrations embedded in the binary. // Migrations are sorted by filename (numeric prefix ensures order) and -// executed within individual transactions. Each successful migration is -// recorded in the _migrations table to prevent re-application. +// executed statement-by-statement. Each successful migration is recorded +// in the _migrations table to prevent re-application. +// +// go-libsql does not support multi-statement Exec, so each SQL statement +// is executed individually. // // If devMode is false, migrations listed in devOnlyMigrations are skipped. func RunMigrations(db *sql.DB, devMode bool) error { + // Force single connection during migration to ensure all tables are + // visible across migration steps. + db.SetMaxOpenConns(1) + defer db.SetMaxOpenConns(0) // restore default (unlimited) after migrations + // Create migrations tracking table _, err := db.Exec(` CREATE TABLE IF NOT EXISTS _migrations ( @@ -82,33 +90,26 @@ func RunMigrations(db *sql.DB, devMode bool) error { return fmt.Errorf("read migration %s: %w", name, err) } - // Execute migration within a transaction - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("begin transaction for %s: %w", name, err) - } - - if _, err := tx.Exec(string(sqlBytes)); err != nil { - tx.Rollback() - return fmt.Errorf("execute migration %s: %w", name, err) + // Split into individual statements and execute each one. + // go-libsql does not support multi-statement Exec. + stmts := splitStatements(string(sqlBytes)) + for i, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("execute migration %s (statement %d): %w\nSQL: %s", name, i+1, err, truncate(stmt, 200)) + } } // Record migration as applied now := time.Now().Unix() - if _, err := tx.Exec( + if _, err := db.Exec( "INSERT INTO _migrations (name, applied_at) VALUES (?, ?)", name, now, ); err != nil { - tx.Rollback() return fmt.Errorf("record migration %s: %w", name, err) } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit migration %s: %w", name, err) - } - applied++ - log.Printf("store: applied migration %s", name) + log.Printf("store: applied migration %s (%d statements)", name, len(stmts)) } if applied > 0 { @@ -119,3 +120,71 @@ func RunMigrations(db *sql.DB, devMode bool) error { return nil } + +// splitStatements splits a SQL file into individual statements. +// It handles: +// - Single-line comments (-- ...) +// - Multi-statement files separated by semicolons +// - String literals containing semicolons (won't split inside quotes) +// - CREATE TRIGGER statements that contain semicolons inside BEGIN...END blocks +func splitStatements(sql string) []string { + var stmts []string + var current strings.Builder + inTrigger := false + + lines := strings.Split(sql, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Skip empty lines and pure comment lines + if trimmed == "" || strings.HasPrefix(trimmed, "--") { + continue + } + + // Track BEGIN/END for trigger bodies + upperTrimmed := strings.ToUpper(trimmed) + if strings.Contains(upperTrimmed, "CREATE TRIGGER") { + inTrigger = true + } + + current.WriteString(line) + current.WriteString("\n") + + // Check if this line ends a statement + if strings.HasSuffix(trimmed, ";") { + if inTrigger { + // Inside a trigger, only END; terminates the trigger + if strings.HasPrefix(upperTrimmed, "END;") || upperTrimmed == "END;" { + stmt := strings.TrimSpace(current.String()) + if stmt != "" { + stmts = append(stmts, stmt) + } + current.Reset() + inTrigger = false + } + } else { + stmt := strings.TrimSpace(current.String()) + if stmt != "" { + stmts = append(stmts, stmt) + } + current.Reset() + } + } + } + + // Handle any remaining content (statement without trailing semicolon) + remaining := strings.TrimSpace(current.String()) + if remaining != "" { + stmts = append(stmts, remaining) + } + + return stmts +} + +// truncate returns at most n characters of s, appending "..." if truncated. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +}