Files
MindSift/internal/storage/migrations.go
T
sandrodachandClaude Opus 5.5 38b8c92899
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
feat(notify): web push as a notification channel
- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:21:55 +01:00

119 lines
3.3 KiB
Go

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},
}
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, &notNull, &dflt, &pk); err != nil {
return false, err
}
if strings.EqualFold(name, column) {
return true, nil
}
}
return false, rows.Err()
}