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 <noreply@anthropic.com>
This commit is contained in:
parent
af13732b2b
commit
0afa04a473
1 changed files with 87 additions and 18 deletions
|
|
@ -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] + "..."
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue