From cd5d8b503b994ba89cadfe00776f2b0349e4d9b7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 31 Aug 2026 11:17:45 +0200 Subject: [PATCH] fix(db): a migration aimed at a table a database no longer holds must not kill the startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 4 +++- changelog.json | 6 ++++-- internal/db/db.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app.go b/app.go index f6ffbb9..9926ded 100644 --- a/app.go +++ b/app.go @@ -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 diff --git a/changelog.json b/changelog.json index d1a91d0..ae06f7d 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/internal/db/db.go b/internal/db/db.go index ae98046..87ab1c6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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) }