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 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 { panic(fmt.Sprintf("db: sub fs: %v", err)) } 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 }