From 45f46d1fd5a19264714f83d953f4bb949a868fc2 Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 1 Apr 2026 15:00:55 +0200 Subject: [PATCH] fix: make migration runner resilient to duplicate column errors The migration runner now splits multi-statement SQL files and executes each statement individually. "duplicate column name" errors are skipped since the column already exists from a prior partial run. This fixes a crash on startup when migration 004_voice_optimization.sql re-ran against a database that already had the columns. --- Server/db/migrate.go | 95 +++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/Server/db/migrate.go b/Server/db/migrate.go index 9ad42c5c..baf6b56e 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -176,36 +176,85 @@ func MigrateFS(database *DB, fsys fs.FS) error { continue } - tx, txErr := database.sqlDB.Begin() - if txErr != nil { - return fmt.Errorf("begin tx for %s: %w", name, txErr) - } - raw, readErr := fs.ReadFile(fsys, name) if readErr != nil { - _ = tx.Rollback() // error ignored: already handling the triggering error return fmt.Errorf("reading migration %s: %w", name, readErr) } - if _, execErr := tx.Exec(string(raw)); execErr != nil { - _ = tx.Rollback() // error ignored: already handling the triggering error - return fmt.Errorf("executing migration %s: %w", name, execErr) - } - - // Record the migration inside the same transaction so the migration - // and its tracking record are atomic. A crash between commit and - // record would otherwise cause re-application on next startup. - if _, execErr := tx.Exec( - "INSERT INTO schema_versions (version) VALUES (?)", name, - ); execErr != nil { - _ = tx.Rollback() - return fmt.Errorf("recording migration %s: %w", name, execErr) - } - - if commitErr := tx.Commit(); commitErr != nil { - return fmt.Errorf("commit migration %s: %w", name, commitErr) + if err := applyMigration(database, name, string(raw)); err != nil { + return err } } return nil } + +// applyMigration executes a single migration and records it. If the +// migration contains multiple statements (e.g. several ALTER TABLE ADD +// COLUMN), each is executed individually so that "duplicate column" errors +// from a prior partial run can be skipped — the column already exists and +// the intent is satisfied. +func applyMigration(database *DB, name, rawSQL string) error { + stmts := splitStatements(rawSQL) + + tx, txErr := database.sqlDB.Begin() + if txErr != nil { + return fmt.Errorf("begin tx for %s: %w", name, txErr) + } + + for _, stmt := range stmts { + if _, execErr := tx.Exec(stmt); execErr != nil { + if isDuplicateColumn(execErr) { + continue // column already exists — skip + } + _ = tx.Rollback() + return fmt.Errorf("executing migration %s: %w", name, execErr) + } + } + + // Record the migration inside the same transaction so the migration + // and its tracking record are atomic. + if _, execErr := tx.Exec( + "INSERT INTO schema_versions (version) VALUES (?)", name, + ); execErr != nil { + _ = tx.Rollback() + return fmt.Errorf("recording migration %s: %w", name, execErr) + } + + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf("commit migration %s: %w", name, commitErr) + } + return nil +} + +// splitStatements splits raw SQL into individual statements on semicolons. +// Empty/comment-only fragments are discarded. +func splitStatements(raw string) []string { + parts := strings.Split(raw, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s == "" || isCommentOnly(s) { + continue + } + out = append(out, s) + } + return out +} + +// isCommentOnly returns true if every line is a SQL comment or blank. +func isCommentOnly(s string) bool { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "--") { + return false + } + } + return true +} + +// isDuplicateColumn reports whether a SQLite error indicates a duplicate +// column name from an ALTER TABLE ADD COLUMN statement. +func isDuplicateColumn(err error) bool { + return err != nil && strings.Contains(err.Error(), "duplicate column name") +}