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>
This commit is contained in:
1 parent
f57a9884d1
commit
dc44f8250c
10 files changed
+222
-155
No files matched your search
@@ -0,0 +1,112 @@
|
||||
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, ¬Null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.EqualFold(name, column) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
Reference in new issue
Block a user