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.
90 lines
3.1 KiB
Go
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
|
|
}
|