Files
MindSift/internal/storage/migrations.go
T
sandrodachandClaude Opus 5.5 dc44f8250c fix(db): numbered migrations that run once and fail loudly (FLAW-13)
schema_migrations records every applied step; the former migrate() is
step 3, the baseline that builds the v0.4.1 schema and repairs older
databases. Columns are only added when missing, so every other error now
stops the start with a clear message instead of being swallowed, and the
cleanup statements no longer run on each start. migrations/001_init.sql
was never read and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:49:35 +01:00

113 lines
3.0 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},
}
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()
}