store: introduce versioned migrations with ordered runner + atomic apply
The old migrate() helper only knew how to re-run a fixed slab of CREATE
TABLE IF NOT EXISTS plus per-column ensureColumnExists calls. That worked
while every schema change was a benign additive column; it falls apart
as soon as we need a data backfill, an index, a rename, or anything that
has to happen exactly once in a known order.
Replaces it with a schema_migrations table + ordered []migration slice.
Each migration has a unique id, a human-readable name, and a func(*Tx)
body; the runner opens a transaction per migration so DDL and any data
changes either both land and get recorded or both roll back together,
leaving the DB in a state where retrying on next Open() reapplies from
the same point.
Migration 1 ("baseline") collapses the current schema into one entry:
fresh databases apply it in one shot; existing dev databases see
idempotent `CREATE TABLE IF NOT EXISTS` + `ALTER TABLE … ADD COLUMN`
statements that succeed as no-ops, and the only net effect is the
schema_migrations row that brings them into the versioned system.
Tests cover fresh apply, idempotent re-open, skipping already-applied
ids, rollback on body error (the transient table the migration created
must not survive), and duplicate-id rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b930c51990
commit
34dd7644d8
3 changed files with 354 additions and 78 deletions
156
internal/store/migrations_test.go
Normal file
156
internal/store/migrations_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// openRawDB opens a SQLite DB at a fresh tempfile without running any
|
||||
// migrations, so tests can observe migration-runner behaviour directly.
|
||||
func openRawDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
dsn, err := sqliteDSN(path)
|
||||
if err != nil {
|
||||
t.Fatalf("sqliteDSN: %v", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRunMigrationsAppliesBaselineOnFreshDB(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
// All declared migrations must be recorded.
|
||||
for _, m := range migrations {
|
||||
var got string
|
||||
if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = ?", m.id).Scan(&got); err != nil {
|
||||
t.Fatalf("migration %d not recorded: %v", m.id, err)
|
||||
}
|
||||
if got != m.name {
|
||||
t.Errorf("migration %d name = %q, want %q", m.id, got, m.name)
|
||||
}
|
||||
}
|
||||
// Baseline must have created the real tables.
|
||||
for _, table := range []string{"images", "vms"} {
|
||||
var name string
|
||||
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name); err != nil {
|
||||
t.Fatalf("table %s missing: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationsIsIdempotent(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations first pass: %v", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations second pass: %v", err)
|
||||
}
|
||||
// One row per migration, no duplicates.
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != len(migrations) {
|
||||
t.Errorf("schema_migrations rows = %d, want %d", count, len(migrations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationsSkipsAlreadyApplied(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
|
||||
// Swap in a test-only migration whose body would error if invoked,
|
||||
// pre-insert its id into schema_migrations, and confirm the runner
|
||||
// recognises the marker and skips the body entirely.
|
||||
orig := migrations
|
||||
t.Cleanup(func() { migrations = orig })
|
||||
migrations = []migration{
|
||||
{id: 1, name: "baseline", up: migrateBaseline},
|
||||
{id: 99, name: "explodes-if-run", up: func(*sql.Tx) error {
|
||||
return errors.New("must not execute")
|
||||
}},
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed schema_migrations table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
|
||||
99, "explodes-if-run", "2026-04-20T00:00:00Z",
|
||||
); err != nil {
|
||||
t.Fatalf("seed applied row: %v", err)
|
||||
}
|
||||
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMigrationRollsBackOnBodyError(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatalf("seed schema_migrations: %v", err)
|
||||
}
|
||||
|
||||
err := applyMigration(db, migration{
|
||||
id: 7,
|
||||
name: "creates-then-fails",
|
||||
up: func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec("CREATE TABLE transient (x INTEGER)"); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("synthetic failure")
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected applyMigration to surface body error")
|
||||
}
|
||||
|
||||
// The transient table must NOT survive the failed migration.
|
||||
var name string
|
||||
if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='transient'").Scan(&name); err == nil {
|
||||
t.Fatal("transient table survived rollback")
|
||||
}
|
||||
// And no schema_migrations row for id=7.
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id=7").Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("schema_migrations recorded failed migration: count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationsRejectsDuplicateID(t *testing.T) {
|
||||
db := openRawDB(t)
|
||||
orig := migrations
|
||||
t.Cleanup(func() { migrations = orig })
|
||||
migrations = []migration{
|
||||
{id: 1, name: "first", up: func(*sql.Tx) error { return nil }},
|
||||
{id: 1, name: "dupe", up: func(*sql.Tx) error { return nil }},
|
||||
}
|
||||
err := runMigrations(db)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate migration id")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue