Files
vibedns/internal/database/migrate.go
T
2026-08-16 21:18:45 -05:00

203 lines
5.4 KiB
Go

package database
import (
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Migration is one versioned schema change, embedded in the binary.
type Migration struct {
Version int
Name string
SQL string
}
// MigrationStatus reports whether a migration has been applied.
type MigrationStatus struct {
Version int `json:"version"`
Name string `json:"name"`
Applied bool `json:"applied"`
AppliedAt int64 `json:"applied_at,omitempty"`
Checksum string `json:"checksum"`
Drifted bool `json:"drifted"`
}
// loadMigrations reads and orders the embedded migration files. File names must
// look like "0001_description.sql".
func loadMigrations() ([]Migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read embedded migrations: %w", err)
}
var out []Migration
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
base := strings.TrimSuffix(e.Name(), ".sql")
parts := strings.SplitN(base, "_", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("migration %q: expected NNNN_name.sql", e.Name())
}
v, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("migration %q: bad version prefix: %w", e.Name(), err)
}
body, err := migrationFS.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, fmt.Errorf("read migration %q: %w", e.Name(), err)
}
out = append(out, Migration{Version: v, Name: parts[1], SQL: string(body)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
for i := 1; i < len(out); i++ {
if out[i].Version == out[i-1].Version {
return nil, fmt.Errorf("duplicate migration version %d", out[i].Version)
}
}
return out, nil
}
func checksum(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// ensureMigrationTable creates the migration bookkeeping table.
func (db *DB) ensureMigrationTable(ctx context.Context) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
return nil
}
type appliedMigration struct {
name string
checksum string
appliedAt int64
}
func (db *DB) appliedMigrations(ctx context.Context) (map[int]appliedMigration, error) {
rows, err := db.QueryContext(ctx, `SELECT version, name, checksum, applied_at FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
out := map[int]appliedMigration{}
for rows.Next() {
var v int
var a appliedMigration
if err := rows.Scan(&v, &a.name, &a.checksum, &a.appliedAt); err != nil {
return nil, err
}
out[v] = a
}
return out, rows.Err()
}
// Migrate applies every pending migration in version order. It returns the
// number of migrations that were applied.
func (db *DB) Migrate(ctx context.Context) (int, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return 0, err
}
migrations, err := loadMigrations()
if err != nil {
return 0, err
}
applied, err := db.appliedMigrations(ctx)
if err != nil {
return 0, err
}
count := 0
for _, m := range migrations {
sum := checksum(m.SQL)
if prev, ok := applied[m.Version]; ok {
if prev.checksum != sum {
return count, fmt.Errorf(
"migration %04d_%s was modified after being applied (expected checksum %s, found %s); "+
"roll the change into a new migration instead of editing history",
m.Version, m.Name, prev.checksum, sum)
}
continue
}
// Each migration is one transaction: a failure leaves no partial schema.
err := db.InTx(ctx, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
return fmt.Errorf("apply migration %04d_%s: %w", m.Version, m.Name, err)
}
_, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version, name, checksum) VALUES (?, ?, ?)`,
m.Version, m.Name, sum)
return err
})
if err != nil {
return count, err
}
count++
}
return count, nil
}
// SchemaVersion returns the highest applied migration version, or 0.
func (db *DB) SchemaVersion(ctx context.Context) (int, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return 0, err
}
var v int
err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&v)
if err != nil {
return 0, fmt.Errorf("read schema version: %w", err)
}
return v, nil
}
// MigrationStatuses lists every known migration and whether it is applied.
func (db *DB) MigrationStatuses(ctx context.Context) ([]MigrationStatus, error) {
if err := db.ensureMigrationTable(ctx); err != nil {
return nil, err
}
migrations, err := loadMigrations()
if err != nil {
return nil, err
}
applied, err := db.appliedMigrations(ctx)
if err != nil {
return nil, err
}
out := make([]MigrationStatus, 0, len(migrations))
for _, m := range migrations {
sum := checksum(m.SQL)
st := MigrationStatus{Version: m.Version, Name: m.Name, Checksum: sum[:12]}
if a, ok := applied[m.Version]; ok {
st.Applied = true
st.AppliedAt = a.appliedAt
st.Drifted = a.checksum != sum
}
out = append(out, st)
}
return out, nil
}