From 9a441c71a632cdc1542d396b01e01ecac9ba5f9d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 15 Sep 2026 15:03:07 +0700 Subject: [PATCH] fix: tolerate already-applied migrations, heal poisoned prod DB on boot --- backend/internal/store/db.go | 62 ++++++++++++++----- .../internal/store/migrations/0002_scope.sql | 5 +- .../store/migrations/0004_scope_repair.sql | 7 +++ 3 files changed, 53 insertions(+), 21 deletions(-) create mode 100644 backend/internal/store/migrations/0004_scope_repair.sql diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go index b435c1e..1c5ff23 100644 --- a/backend/internal/store/db.go +++ b/backend/internal/store/db.go @@ -8,6 +8,7 @@ import ( "embed" "fmt" "sort" + "strings" _ "modernc.org/sqlite" ) @@ -59,29 +60,56 @@ func (db *DB) migrate() error { if applied > 0 { continue } - b, err := migrationsFS.ReadFile(n) - if err != nil { - return err - } - tx, err := db.Begin() - if err != nil { - return err - } - if _, err := tx.Exec(string(b)); err != nil { - _ = tx.Rollback() - return fmt.Errorf("store: migration %s: %w", n, err) - } - if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, n); err != nil { - _ = tx.Rollback() - return err - } - if err := tx.Commit(); err != nil { + if err := db.applyMigration(n); err != nil { return err } } return nil } +// applyMigration runs one migration file. A statement that fails only because +// its effect already exists (duplicate column / object already exists) is +// tolerated: the DB is treated as already containing that change. Any other +// error aborts the migration. +func (db *DB) applyMigration(n string) error { + b, err := migrationsFS.ReadFile(n) + if err != nil { + return err + } + tx, err := db.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(string(b)); err != nil { + _ = tx.Rollback() + if isAlreadyExistsErr(err) { + if _, err := db.Exec(`INSERT OR IGNORE INTO schema_migrations(name) VALUES(?)`, n); err != nil { + return err + } + return nil + } + return fmt.Errorf("store: migration %s: %w", n, err) + } + if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, n); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +// isAlreadyExistsErr reports SQLite "already exists" errors: duplicate column +// or duplicate table/index. Matched on message text because modernc sqlite +// surfaces them as generic error code 1. +func isAlreadyExistsErr(err error) bool { + msg := err.Error() + for _, s := range []string{"duplicate column name", "already exists"} { + if strings.Contains(msg, s) { + return true + } + } + return false +} + // Meta helpers persist incremental cursors (quarterly_since, news_since, // filings_since) and dedup markers so restarts resume incrementally. diff --git a/backend/internal/store/migrations/0002_scope.sql b/backend/internal/store/migrations/0002_scope.sql index 2684c28..24e9b12 100644 --- a/backend/internal/store/migrations/0002_scope.sql +++ b/backend/internal/store/migrations/0002_scope.sql @@ -1,8 +1,5 @@ -- FlowSight schema v2: holder attribution + user scoping for history reads. +-- Idempotent: safe to re-run on DBs created before schema_migrations existed. ALTER TABLE filings ADD COLUMN holder_name TEXT NOT NULL DEFAULT ''; ALTER TABLE routine_runs ADD COLUMN user_key TEXT NOT NULL DEFAULT ''; ALTER TABLE alert_events ADD COLUMN user_key TEXT NOT NULL DEFAULT ''; -CREATE INDEX IF NOT EXISTS idx_routine_runs_user ON routine_runs(user_key, routine_id); -CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_key, ticker); -UPDATE routine_runs SET user_key=(SELECT user_key FROM routines WHERE routines.id=routine_runs.routine_id) WHERE user_key=''; -UPDATE alert_events SET user_key=(SELECT user_key FROM alerts WHERE alerts.id=alert_events.alert_id) WHERE user_key=''; diff --git a/backend/internal/store/migrations/0004_scope_repair.sql b/backend/internal/store/migrations/0004_scope_repair.sql new file mode 100644 index 0000000..01e1202 --- /dev/null +++ b/backend/internal/store/migrations/0004_scope_repair.sql @@ -0,0 +1,7 @@ +-- FlowSight schema v4: repair marker for DBs poisoned by the pre-journal +-- 0002 run (v2 columns applied but never recorded in schema_migrations). +-- Backfills user scoping data dropped from 0002 when it was made idempotent. +CREATE INDEX IF NOT EXISTS idx_routine_runs_user ON routine_runs(user_key, routine_id); +CREATE INDEX IF NOT EXISTS idx_alert_events_user ON alert_events(user_key, ticker); +UPDATE routine_runs SET user_key=(SELECT user_key FROM routines WHERE routines.id=routine_runs.routine_id) WHERE user_key=''; +UPDATE alert_events SET user_key=(SELECT user_key FROM alerts WHERE alerts.id=alert_events.alert_id) WHERE user_key='';