Files
baby-nas/internal/db/db.go
T
darroyo 63e0b5146a fix: tolerate duplicate column errors in migration runner
The previous SAVEPOINT approach failed because Go's tx.Exec() stops at
the first error and never reaches the ROLLBACK TO statements. This caused
every subsequent startup to re-run migration 0002, fail on the first
ALTER (column already exists from a prior partial run), and crash.

Fix runMigration() to:
- Execute migrations without a wrapping transaction (DDL in SQLite is
  auto-commit anyway)
- Treat "duplicate column name" / "already exists" errors as success
  and record the migration anyway, covering cases where a previous
  failed attempt already partially modified the schema

Also simplify 0002_nfs_structured.sql back to plain ALTER TABLE
statements (no SAVEPOINT needed with the new Go-level tolerance).

Bump VERSION 0.1.9 -> 0.1.10
2026-07-06 00:05:02 -04:00

129 lines
2.7 KiB
Go

package db
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type DB struct {
conn *sql.DB
}
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := conn.Exec(`PRAGMA foreign_keys = ON`); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
return &DB{conn: conn}, nil
}
func (d *DB) Close() error {
return d.conn.Close()
}
func (d *DB) Conn() *sql.DB {
return d.conn
}
func (d *DB) Migrate() error {
if err := d.createMigrationsTable(); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
name := entry.Name()
applied, err := d.isMigrationApplied(name)
if err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
if err := d.runMigration(name, string(content)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
}
return nil
}
func (d *DB) createMigrationsTable() error {
_, err := d.conn.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
return err
}
func (d *DB) isMigrationApplied(name string) (bool, error) {
var count int
err := d.conn.QueryRow(
"SELECT COUNT(*) FROM schema_migrations WHERE name = ?",
name,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *DB) runMigration(name, content string) error {
if _, err := d.conn.Exec(content); err != nil {
if isIgnorableMigrationError(err) {
// Schema is already in target state (e.g. columns already added
// by a previous partial run). Continue to record the migration.
} else {
return fmt.Errorf("execute: %w", err)
}
}
if _, err := d.conn.Exec(
"INSERT INTO schema_migrations (name) VALUES (?)",
name,
); err != nil {
return fmt.Errorf("record migration: %w", err)
}
return nil
}
func isIgnorableMigrationError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate column name") ||
strings.Contains(msg, "already exists")
}