fix(db): a migration aimed at a table a database no longer holds must not kill the startup
The 0031 clublog ALTER hit split settings databases (their qso table moved to the logbook years of QSOs ago) and the whole open failed — silently, because the error went to a println the GUI subsystem discards. The app then ran with no settings store: every panel showed defaults, 'db not initialized' in Preferences, and operators read it as their database being lost. Nothing was ever touched: the failed migration rolled back on every attempt. The SQLite migration path now tolerates what the MySQL path always has — plus the one case it never meets: ALTER/CREATE INDEX/DROP on a table this database legitimately does not hold. And a failed open is written to the rotating log, where the next such morning can actually be diagnosed.
This commit is contained in:
@@ -240,6 +240,25 @@ func backupBeforeRewrite(conn *sql.DB, dbPath, migration string) {
|
||||
logf("db: backed up %d QSO(s) to %s in %s before %s", n, dest, time.Since(start).Round(time.Millisecond), migration)
|
||||
}
|
||||
|
||||
// isIgnorableSQLiteDDLError reports a benign DDL failure: the change is
|
||||
// already there, or the statement shapes a table this database does not hold.
|
||||
// Scoped to shaping statements only — a CREATE TABLE or data statement that
|
||||
// fails must still fail the migration.
|
||||
func isIgnorableSQLiteDDLError(err error, stmt string) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "duplicate column name") || strings.Contains(msg, "already exists") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "no such table") {
|
||||
head := strings.ToLower(strings.TrimSpace(stmt))
|
||||
return strings.HasPrefix(head, "alter table") ||
|
||||
strings.HasPrefix(head, "create index") ||
|
||||
strings.HasPrefix(head, "create unique index") ||
|
||||
strings.HasPrefix(head, "drop ")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
@@ -352,6 +371,16 @@ func migrate(conn *sql.DB, translate func(string) string, dbPath, label string,
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
// Same self-healing as the MySQL path, plus one case it never
|
||||
// meets: a table-shaping statement aimed at a table this
|
||||
// database legitimately does not have. A split settings
|
||||
// database dropped its qso table when the QSOs moved to the
|
||||
// logbook, but its role is still RoleAll — so a later
|
||||
// "ALTER TABLE qso ADD COLUMN" must be a no-op there, not a
|
||||
// failure that silently kills the whole startup (v0.27.4+).
|
||||
if isIgnorableSQLiteDDLError(err, stmt) {
|
||||
continue
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user