diff --git a/internal/db/db.go b/internal/db/db.go index 587aab69..dc63596c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,33 +1,90 @@ package db import ( + "context" "embed" "errors" + "fmt" + "io/fs" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql var migrationsFS embed.FS -// MigrationsFS exposes the embedded migration files. The concrete migration -// runner (goose) will be wired in M2 when Postgres work begins; this package -// intentionally keeps the embed + a placeholder surface so the file layout -// and `go:embed` directive are established from day one. -func MigrationsFS() embed.FS { - return migrationsFS -} - -// ErrNotConfigured is returned by Migrate until the real runner is wired. -var ErrNotConfigured = errors.New("db: migrations not yet configured (wired in M2)") - -// Migrate is a placeholder. It validates that the embedded migrations load -// and then returns ErrNotConfigured so callers can detect the stub. -func Migrate() error { - entries, err := migrationsFS.ReadDir("migrations") +// MigrationsFS exposes the embedded migration set for callers that need +// raw access (e.g. tests that construct their own migration runner). +func MigrationsFS() fs.FS { + sub, err := fs.Sub(migrationsFS, "migrations") if err != nil { - return err + panic(fmt.Sprintf("db: sub fs: %v", err)) } - if len(entries) == 0 { - return errors.New("db: no migration files found") - } - return ErrNotConfigured + return sub +} + +// Open establishes a pgx connection pool. The caller owns the returned pool +// and must Close() it on shutdown. +func Open(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + if dsn == "" { + return nil, errors.New("db: database URL is empty") + } + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("db: create pool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("db: ping: %w", err) + } + return pool, nil +} + +// Migrate applies any pending migrations embedded in this package. It is safe +// to call concurrently with application traffic only if the database is idle; +// in practice this runs once at startup before the HTTP server binds. +func Migrate(dsn string, logger *slog.Logger) error { + if dsn == "" { + return errors.New("db: database URL is empty") + } + + src, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("db: iofs source: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://"+trimScheme(dsn)) + if err != nil { + return fmt.Errorf("db: migrate init: %w", err) + } + defer func() { + if srcErr, dbErr := m.Close(); srcErr != nil || dbErr != nil { + logger.Warn("migrate close", "src_err", srcErr, "db_err", dbErr) + } + }() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("db: migrate up: %w", err) + } + version, dirty, verr := m.Version() + if verr != nil && !errors.Is(verr, migrate.ErrNilVersion) { + return fmt.Errorf("db: migrate version: %w", verr) + } + logger.Info("migrations applied", "version", version, "dirty", dirty) + return nil +} + +// trimScheme rewrites a postgres://... or postgresql://... DSN to the bare +// form expected by the migrate pgx driver (it registers its own scheme). +func trimScheme(dsn string) string { + for _, prefix := range []string{"postgres://", "postgresql://"} { + if len(dsn) > len(prefix) && dsn[:len(prefix)] == prefix { + return dsn[len(prefix):] + } + } + return dsn }