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:
@@ -1132,7 +1132,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
conn, err := db.Open(a.dbPath)
|
||||
if err != nil {
|
||||
a.startupErr = "cannot open db: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
// In the rotating log too: the GUI subsystem discards stdout, and this
|
||||
// exact failure once hid for a whole morning behind a println.
|
||||
applog.Printf("startup: %s", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.db = conn
|
||||
|
||||
+4
-2
@@ -4,11 +4,13 @@
|
||||
"date": "",
|
||||
"en": [
|
||||
"QSL Manager: Club Log confirmations — downloads your log matches (getmatches API) and stamps two new columns, ClubLog match status and match date, available everywhere: table columns, filters, bulk edit and the QSO editor.",
|
||||
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP."
|
||||
"Super Check Partial: option to merge Club Log’s weekly call list (~180k calls heard on the air in the last 3 years) with MASTER.SCP.",
|
||||
"Fixed: opening the app could silently stop at startup (settings showing as default, “db not initialized”) when a database migration targeted a table that database no longer holds — migrations now skip what does not apply, and a startup failure is written to the log."
|
||||
],
|
||||
"fr": [
|
||||
"QSL Manager : confirmations Club Log — télécharge vos matches de log (API getmatches) et remplit deux nouvelles colonnes, statut et date de match ClubLog, disponibles partout : colonnes du tableau, filtres, édition groupée et éditeur de QSO.",
|
||||
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP."
|
||||
"Super Check Partial : option pour fusionner la liste hebdomadaire de Club Log (~180k indicatifs entendus sur l’air ces 3 dernières années) avec MASTER.SCP.",
|
||||
"Corrigé : l’application pouvait se figer silencieusement au démarrage (réglages par défaut, « db not initialized ») quand une migration visait une table absente de cette base — les migrations ignorent désormais ce qui ne s’applique pas, et un échec de démarrage est écrit dans le log."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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