package storage import ( "database/sql" "fmt" "log/slog" "regexp" "strings" "github.com/sandrodach/mindsift/internal/domain" ) // Schema migrations (FLAW-13). Every step runs exactly once per database and is recorded in // schema_migrations; a failing step stops the start with its error instead of being ignored. // New schema changes are appended as new steps (next free id), never edited into old ones. // PRAGMA user_version mirrors the highest applied id. Ids 1 and 2 were used by earlier releases // (database wipes on start), so the first step here is 3. type migration struct { id int name string run func(s *Store) error } var migrations = []migration{ {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, {4, "Sitzungen", (*Store).migrateSessions}, {5, "Medien", (*Store).migrateMedia}, {6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, {7, "Benachrichtigungen", (*Store).migrateNotifications}, {8, "Journal", (*Store).migrateJournal}, {9, "Web-Push", (*Store).migratePush}, {10, "Registrierung und Einladungslinks", (*Store).migrateRegistration}, } func (s *Store) migrate() error { if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL )`); err != nil { return err } applied := map[int]bool{} rows, err := s.db.Query(`SELECT id FROM schema_migrations`) if err != nil { return err } for rows.Next() { var id int if rows.Scan(&id) == nil { applied[id] = true } } _ = rows.Close() last := 0 for _, m := range migrations { last = m.id if applied[m.id] { continue } slog.Info("Datenbank-Migration", "id", m.id, "name", m.name) if err := m.run(s); err != nil { return fmt.Errorf("Migration %d (%s): %w", m.id, m.name, err) } if _, err := s.db.Exec(`INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)`, m.id, m.name, domain.NowISO()); err != nil { return err } } _, err = s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, last)) return err } var addColumnRe = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\s+(.+?);?\s*$`) // execSchemaStatement runs one schema statement; "ALTER TABLE … ADD COLUMN" only when the column is // missing, so that a real error is never mistaken for "already there". func (s *Store) execSchemaStatement(q string) error { if m := addColumnRe.FindStringSubmatch(q); m != nil { return s.addColumnIfMissing(m[1], m[2], m[3]) } if _, err := s.db.Exec(q); err != nil { return fmt.Errorf("%w (%s)", err, strings.TrimSpace(q)) } return nil } func (s *Store) addColumnIfMissing(table, column, definition string) error { exists, err := s.columnExists(table, column) if err != nil || exists { return err } if _, err := s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, definition)); err != nil { return fmt.Errorf("Spalte %s.%s anlegen: %w", table, column, err) } return nil } func (s *Store) columnExists(table, column string) (bool, error) { rows, err := s.db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table)) if err != nil { return false, err } defer rows.Close() for rows.Next() { var cid, notNull, pk int var name, typ string var dflt sql.NullString if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { return false, err } if strings.EqualFold(name, column) { return true, nil } } return false, rows.Err() }