Files
baby-nas/internal/db/db.go
T
darroyo 9a9d4cc753 fix: implement migration tracking and make 0002 idempotent
- Add schema_migrations table to track applied migrations
- Migrate() now checks if a migration was already applied before running
- 0002 rewritten to use SAVEPOINT+ROLLBACK per column, making it safe
  to run even if some columns were partially added previously
- Each migration runs in its own transaction; INSERT into schema_migrations
  only happens if the SQL executes without error
- Bump VERSION 0.1.8 -> 0.1.9
2026-07-05 23:54:14 -04:00

125 lines
2.5 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 {
tx, err := d.conn.Begin()
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(content); err != nil {
return fmt.Errorf("execute: %w", err)
}
if _, err := tx.Exec(
"INSERT INTO schema_migrations (name) VALUES (?)",
name,
); err != nil {
return fmt.Errorf("record migration: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}