// Package db owns the Paliad Postgres connection and embedded schema migrations. // // Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and // live in the migrations/ subdirectory, embedded into the binary so a single // artifact ships with its schema. The server applies pending migrations at // startup before binding the HTTP listener. package db import ( "database/sql" "embed" "errors" "fmt" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" _ "github.com/lib/pq" ) //go:embed migrations/*.sql var migrationFS embed.FS // migrationsTable is the name of the golang-migrate tracking table. We use a // uniquely-named table (not the default "schema_migrations") because the // production Supabase instance hosts multiple apps in the `public` schema, // and a differently-shaped `public.schema_migrations` already exists there. // Using "paliad_schema_migrations" prevents collision at startup. // // The table lives in the `public` schema (golang-migrate's default) rather // than `paliad`. Rationale: migration 001's down-step is // DROP SCHEMA IF EXISTS paliad CASCADE // which would take the tracking table with it — breaking any subsequent // migrate.Up() call. Keeping the tracker in `public` makes the down-path // safe and idempotent. const migrationsTable = "paliad_schema_migrations" // ApplyMigrations runs all pending up-migrations against the given database // URL. Returns nil if no migrations were pending. Safe to call repeatedly. // // Pre-creates the `paliad` schema before invoking golang-migrate because the // first migration creates it and golang-migrate's tracking table would // otherwise be created in whatever `current_schema()` happens to be. func ApplyMigrations(databaseURL string) error { if databaseURL == "" { return errors.New("database URL is empty") } conn, err := sql.Open("postgres", databaseURL) if err != nil { return fmt.Errorf("open database: %w", err) } defer conn.Close() if err := conn.Ping(); err != nil { return fmt.Errorf("ping database: %w", err) } // Bootstrap the paliad schema so later migrations can target it cleanly. // This duplicates migration 001, but is idempotent via IF NOT EXISTS and // ensures the schema exists before golang-migrate touches the DB. if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil { return fmt.Errorf("ensure paliad schema: %w", err) } source, err := iofs.New(migrationFS, "migrations") if err != nil { return fmt.Errorf("open migration source: %w", err) } driver, err := postgres.WithInstance(conn, &postgres.Config{ // Unique tracking-table name avoids collision with pre-existing // public.schema_migrations owned by other apps on this Postgres. MigrationsTable: migrationsTable, }) if err != nil { return fmt.Errorf("create migration driver: %w", err) } m, err := migrate.NewWithInstance("iofs", source, "postgres", driver) if err != nil { return fmt.Errorf("create migrator: %w", err) } if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { return fmt.Errorf("apply migrations: %w", err) } return nil }