package store import ( "database/sql" "fmt" "sort" "time" ) // migration is one ordered, atomic schema step. id must be unique and // strictly increasing across the slice. name is a human-readable label // stored alongside the id for debugging, and up receives a *sql.Tx so // DDL + data backfills land atomically — either the migration fully // applies and a schema_migrations row is written, or the whole thing // rolls back and gets retried on next Open(). type migration struct { id int name string up func(*sql.Tx) error } // migrations is the canonical ordered history. Append new migrations // at the bottom with the next id. Never edit or reorder existing // entries — installed DBs key off the id column. var migrations = []migration{ {id: 1, name: "baseline", up: migrateBaseline}, } // runMigrations ensures schema_migrations exists, then applies every // migration whose id hasn't been recorded yet, in id order. Existing // dev databases (schema set up by the pre-versioning inline migrate() // helper) see the baseline SQL as a no-op because every statement is // `CREATE TABLE IF NOT EXISTS`; the row that records id=1 is what // brings them into the new system. func runMigrations(db *sql.DB) error { 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 { return fmt.Errorf("create schema_migrations: %w", err) } applied, err := loadAppliedMigrations(db) if err != nil { return err } sorted := make([]migration, len(migrations)) copy(sorted, migrations) sort.Slice(sorted, func(i, j int) bool { return sorted[i].id < sorted[j].id }) seen := map[int]bool{} for _, m := range sorted { if seen[m.id] { return fmt.Errorf("duplicate migration id %d (%q)", m.id, m.name) } seen[m.id] = true } for _, m := range sorted { if _, ok := applied[m.id]; ok { continue } if err := applyMigration(db, m); err != nil { return fmt.Errorf("migration %d (%s): %w", m.id, m.name, err) } } return nil } func loadAppliedMigrations(db *sql.DB) (map[int]struct{}, error) { rows, err := db.Query("SELECT id FROM schema_migrations") if err != nil { return nil, fmt.Errorf("load schema_migrations: %w", err) } defer rows.Close() applied := map[int]struct{}{} for rows.Next() { var id int if err := rows.Scan(&id); err != nil { return nil, err } applied[id] = struct{}{} } return applied, rows.Err() } func applyMigration(db *sql.DB, m migration) error { tx, err := db.Begin() if err != nil { return err } if err := m.up(tx); err != nil { _ = tx.Rollback() return err } if _, err := tx.Exec( "INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)", m.id, m.name, time.Now().UTC().Format(time.RFC3339), ); err != nil { _ = tx.Rollback() return fmt.Errorf("record migration: %w", err) } return tx.Commit() } // migrateBaseline captures the schema as it stood when the versioned // migration system was introduced. Uses IF NOT EXISTS on every object // so existing dev databases — whose tables were set up by the old // inline migrate() — pass through cleanly and only the // schema_migrations row gets added. func migrateBaseline(tx *sql.Tx) error { stmts := []string{ `CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, managed INTEGER NOT NULL DEFAULT 0, artifact_dir TEXT, rootfs_path TEXT NOT NULL, work_seed_path TEXT, kernel_path TEXT NOT NULL, initrd_path TEXT, modules_dir TEXT, packages_path TEXT, build_size TEXT, seeded_ssh_public_key_fingerprint TEXT, docker INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );`, `CREATE TABLE IF NOT EXISTS vms ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, image_id TEXT NOT NULL, guest_ip TEXT NOT NULL UNIQUE, state TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_touched_at TEXT NOT NULL, spec_json TEXT NOT NULL, runtime_json TEXT NOT NULL, stats_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT );`, } for _, stmt := range stmts { if _, err := tx.Exec(stmt); err != nil { return err } } // Columns added to the images table across the pre-versioning // lifetime of the project. New installs get them from the CREATE // TABLE above; upgraders from an ancient snapshot (pre- // ensureColumnExists) pick them up here. Idempotent either way. for _, col := range []struct{ table, name, typ string }{ {"images", "work_seed_path", "TEXT"}, {"images", "seeded_ssh_public_key_fingerprint", "TEXT"}, } { if err := addColumnIfMissing(tx, col.table, col.name, col.typ); err != nil { return err } } return nil } // addColumnIfMissing is SQLite's "ALTER TABLE ADD COLUMN IF NOT EXISTS" // (which the dialect lacks) as a library function. Used inside // migrations when a column needs to survive a database that went // through some historical path where the column was added later. func addColumnIfMissing(tx *sql.Tx, table, column, columnType string) error { rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return err } defer rows.Close() for rows.Next() { var ( cid int name string valueType string notNull int defaultV sql.NullString pk int ) if err := rows.Scan(&cid, &name, &valueType, ¬Null, &defaultV, &pk); err != nil { return err } if name == column { return nil } } if err := rows.Err(); err != nil { return err } _, err = tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)) return err }