// Package migrate applies the embedded forward-only SQL migrations // (migrations/*.up.sql). It is shared infrastructure: the oikos postgres // adapter runs it at pool startup, and nomos's session tests use it to // build throwaway databases — nomos must not import the adapters, so the // runner lives here, one level above both (ADR 0016 rule 3). package migrate import ( "context" "fmt" "io/fs" "log/slog" "sort" "strings" "github.com/dtoro/oikos/migrations" "github.com/jackc/pgx/v5/pgxpool" ) // migrationLockKey is the advisory-lock key serializing migration runs — // two concurrent migrators must not interleave DDL. const migrationLockKey = 0x01c05e5 // PgxPool is the surface Apply needs: acquire a dedicated connection for // the lock-held run. type PgxPool interface { Acquire(ctx context.Context) (*pgxpool.Conn, error) } // Apply runs all embedded forward migrations in order, on one connection // holding a session advisory lock. Uses the schema_migrations table to // track applied versions. func Apply(ctx context.Context, pool PgxPool) error { conn, err := pool.Acquire(ctx) if err != nil { return fmt.Errorf("acquire migration conn: %w", err) } defer conn.Release() if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil { return fmt.Errorf("acquire migration lock: %w", err) } defer func() { if _, err := conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil { slog.Warn("migrate: release lock failed", "error", err) } }() // Create tracking table if not exists _, err = conn.Exec(ctx, ` CREATE TABLE IF NOT EXISTS schema_migrations ( version INT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `) if err != nil { return fmt.Errorf("create schema_migrations: %w", err) } // List migration files entries, err := fs.ReadDir(migrations.FS, ".") if err != nil { return fmt.Errorf("read migration fs: %w", err) } var files []string for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { files = append(files, e.Name()) } } sort.Strings(files) for _, fname := range files { // Extract version number (001, 002, etc.) var version int if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil { continue } // Check if already applied var applied int err := conn.QueryRow(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied) if err != nil { return fmt.Errorf("check migration %d: %w", version, err) } if applied > 0 { continue } // Read and execute migration — split into individual statements // because TimescaleDB CAGGs and some DDL can't run inside a transaction, // and pgx's multi-statement Exec wraps them implicitly. content, err := migrations.FS.ReadFile(fname) if err != nil { return fmt.Errorf("read %s: %w", fname, err) } stmts := SplitSQL(string(content)) for i, stmt := range stmts { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } _, err := conn.Exec(ctx, stmt) if err != nil { return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err) } } _, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version) if err != nil { return fmt.Errorf("record migration %d: %w", version, err) } slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts)) } return nil }