Files
paliad/internal/db/migrate.go
m 95817fe78c fix(db): use paliad_schema_migrations tracker to avoid public.schema_migrations collision
Production crash when DATABASE_URL was first set on the shared Supabase:

  pq: column "dirty" does not exist at column 17 (42703)
  in line 0: SELECT version, dirty FROM "public"."schema_migrations"

Root cause: the Supabase instance already had a differently-shaped
public.schema_migrations (version-only, no dirty column) from another app
or earlier tool. golang-migrate's default tracking table is called
"schema_migrations" and lives in current_schema() (public, since paliad
didn't exist yet at migrator startup). The driver tried to read its own
schema from the foreign table and blew up.

Fix:
1. Set postgres.Config.MigrationsTable = "paliad_schema_migrations" — a
   uniquely-named tracker that cannot collide with another app's table.
2. Pre-create the paliad schema before invoking golang-migrate so
   subsequent migrations target it cleanly. Idempotent via IF NOT EXISTS.
3. Leave the tracker in `public` (default SchemaName). Rationale: the
   first migration's down-step is DROP SCHEMA IF EXISTS paliad CASCADE,
   which would take a paliad.schema_migrations tracker with it and break
   any subsequent migrate.Up(). Keeping it in public makes down-cycles
   safe.

Verified locally:
- Reproduced the collision by creating a public.schema_migrations with
  only a version column (matching the production shape) and running the
  fixed migrator against it.
- Pre-existing public.schema_migrations untouched (version=42 preserved).
- New public.paliad_schema_migrations created at version=11.
- All 15 paliad.* tables created.
- Idempotent: second migrator run reports ErrNoChange, no double-apply,
  seed data unchanged.
- Live tests (TEST_DATABASE_URL) still pass against the collision DB.
2026-04-16 15:02:35 +02:00

90 lines
3.1 KiB
Go

// 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
}