package db import ( "context" "database/sql" "embed" "fmt" "log/slog" "sort" "strings" ) //go:embed migrations/*.sql var migrationsFS embed.FS // migration is a single schema change with its forward (up) and reverse (down) // SQL. id is the "{timestamp}_{desc}" derived from the file name, which is also // the unique key recorded in schema_migrations. type migration struct { id string upSQL string downSQL string } // createMigrationsTable is a parameter-free DDL statement (no placeholders). It // is executed with ExecContext to create the bookkeeping table on first use. const createMigrationsTable = ` CREATE TABLE IF NOT EXISTS schema_migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) )` // MigrateUp applies every embedded migration that has not yet been recorded in // schema_migrations. Migrations run in ascending timestamp order, each inside its // own transaction; the schema_migrations row is inserted in that same // transaction, so a failing migration leaves no half-applied state. Already // applied migrations are skipped, making MigrateUp idempotent. // // The entire content of a migration's .up.sql is executed as one ExecContext // inside the transaction. Keep migrations small and use several files for a // larger schema change rather than packing many statements into one file. func MigrateUp(ctx context.Context, db *sql.DB, log *slog.Logger) error { if err := ensureMigrationTable(ctx, db); err != nil { return err } applied, err := appliedMigrationIDs(ctx, db) if err != nil { return err } migs, err := loadMigrations() if err != nil { return err } for _, m := range migs { if applied[m.id] { continue } if err := applyUp(ctx, db, m, discardLogger(log)); err != nil { return err } } return nil } // MigrateDown rolls back the most recent steps migrations, in descending // timestamp order. Each rollback runs the migration's .down.sql and deletes its // schema_migrations row in the same transaction. steps must be >= 1; if steps // exceeds the number of applied migrations, all applied migrations are rolled // back. func MigrateDown(ctx context.Context, db *sql.DB, steps int, log *slog.Logger) error { if steps < 1 { return fmt.Errorf("db: down: %w", ErrInvalidSteps) } log = discardLogger(log) if err := ensureMigrationTable(ctx, db); err != nil { return err } applied, err := appliedMigrationIDs(ctx, db) if err != nil { return err } migs, err := loadMigrations() if err != nil { return err } toRollback := make([]migration, 0, len(applied)) for _, m := range migs { if applied[m.id] { toRollback = append(toRollback, m) } } // Reverse to descending order, then keep only the most recent `steps`. for i, j := 0, len(toRollback)-1; i < j; i, j = i+1, j-1 { toRollback[i], toRollback[j] = toRollback[j], toRollback[i] } if len(toRollback) > steps { toRollback = toRollback[:steps] } for _, m := range toRollback { if err := applyDown(ctx, db, m, log); err != nil { return err } } return nil } // ensureMigrationTable creates the schema_migrations bookkeeping table if it does // not already exist. func ensureMigrationTable(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, createMigrationsTable); err != nil { return fmt.Errorf("db: ensure schema_migrations: %w", err) } return nil } // appliedMigrationIDs returns the set of migration ids already recorded in // schema_migrations. func appliedMigrationIDs(ctx context.Context, db *sql.DB) (map[string]bool, error) { rows, err := db.QueryContext(ctx, "SELECT id FROM schema_migrations") if err != nil { return nil, fmt.Errorf("db: list applied migrations: %w", err) } defer rows.Close() applied := make(map[string]bool) for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("db: scan migration id: %w", err) } applied[id] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("db: iterate applied migrations: %w", err) } return applied, nil } // applyUp runs a single migration's up SQL in a transaction and records it. func applyUp(ctx context.Context, db *sql.DB, m migration, log *slog.Logger) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("db: begin up %s: %w", m.id, err) } defer tx.Rollback() if _, err := tx.ExecContext(ctx, m.upSQL); err != nil { return fmt.Errorf("db: apply up %s: %w", m.id, err) } if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (id) VALUES (?)", m.id); err != nil { return fmt.Errorf("db: record up %s: %w", m.id, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("db: commit up %s: %w", m.id, err) } log.Info("migration applied", "migration", m.id) return nil } // applyDown runs a single migration's down SQL in a transaction and unrecords it. func applyDown(ctx context.Context, db *sql.DB, m migration, log *slog.Logger) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("db: begin down %s: %w", m.id, err) } defer tx.Rollback() if _, err := tx.ExecContext(ctx, m.downSQL); err != nil { return fmt.Errorf("db: apply down %s: %w", m.id, err) } if _, err := tx.ExecContext(ctx, "DELETE FROM schema_migrations WHERE id = ?", m.id); err != nil { return fmt.Errorf("db: unrecord down %s: %w", m.id, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("db: commit down %s: %w", m.id, err) } log.Info("migration rolled back", "migration", m.id) return nil } // loadMigrations reads all embedded .up.sql files, pairs each with its matching // .down.sql, and returns them sorted ascending by id (which places them in // timestamp order). A .up.sql without a matching .down.sql is an error. func loadMigrations() ([]migration, error) { entries, err := migrationsFS.ReadDir("migrations") if err != nil { return nil, fmt.Errorf("db: read embedded migrations: %w", err) } migs := make([]migration, 0, len(entries)/2) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") { continue } id := strings.TrimSuffix(e.Name(), ".up.sql") upSQL, err := migrationsFS.ReadFile("migrations/" + e.Name()) if err != nil { return nil, fmt.Errorf("db: read %s: %w", e.Name(), err) } downName := id + ".down.sql" downSQL, err := migrationsFS.ReadFile("migrations/" + downName) if err != nil { return nil, fmt.Errorf("db: %w: %s", ErrDownMissing, downName) } migs = append(migs, migration{id: id, upSQL: string(upSQL), downSQL: string(downSQL)}) } if len(migs) == 0 { return nil, ErrNoEmbedded } sort.Slice(migs, func(i, j int) bool { return migs[i].id < migs[j].id }) return migs, nil }