Files
member-console/internal/db/database.go
T
cgalo5758 b0072d8971 Consolidate domain tables into core schema
Squash the pre-production migration history into fresh core, fedwiki,
and stripe baselines and reduce the canonical source list to those
three streams.

Update sqlc configs, generated queries, raw SQL, tests, and docs while
keeping provider tables schema-qualified.

BREAKING: existing local database volumes must be wiped because goose
version history restarts from the new baselines.
2026-07-05 20:10:23 -05:00

145 lines
4.7 KiB
Go

package db
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// DBConfig holds database configuration.
type DBConfig struct {
DSN string // Data Source Name (PostgreSQL connection string)
MaxOpenConns int // Maximum number of open connections
MaxIdleConns int // Maximum number of idle connections
ConnMaxLifetime time.Duration // Maximum lifetime of connections
ConnMaxIdleTime time.Duration // Maximum idle time for connections
}
// DefaultDBConfig returns a DBConfig with sensible defaults for PostgreSQL.
func DefaultDBConfig(dsn string) *DBConfig {
return &DBConfig{
DSN: dsn,
MaxOpenConns: 25,
MaxIdleConns: 10,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
}
}
// ensureSearchPath appends search_path with the core schema to the DSN if
// not already present, so every connection in the pool resolves core-schema
// tables without qualification (Decision 113). Provider schemas (stripe,
// fedwiki) stay out of search_path and their queries stay schema-qualified.
func ensureSearchPath(dsn string) string {
const moduleSearchPath = "core,public"
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" {
// Key=value DSN format — append if not already set.
if !strings.Contains(dsn, "search_path") {
return dsn + " search_path=" + moduleSearchPath
}
return dsn
}
// URI format — append as query parameter.
q := u.Query()
if q.Get("search_path") == "" {
q.Set("search_path", moduleSearchPath)
u.RawQuery = q.Encode()
}
return u.String()
}
// openAndConfigureDB opens the database connection and configures the connection pool.
func openAndConfigureDB(config *DBConfig) (*sql.DB, error) {
db, err := sql.Open("pgx", ensureSearchPath(config.DSN))
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
return db, nil
}
// ConnectPlain opens a database connection without the custom search_path.
// Use this for migrations — the custom search_path causes a pgx/PG18
// interaction that silently drops tables from multi-statement DDL.
func ConnectPlain(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
db, err := sql.Open("pgx", config.DSN)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(config.MaxOpenConns)
db.SetMaxIdleConns(config.MaxIdleConns)
db.SetConnMaxLifetime(config.ConnMaxLifetime)
db.SetConnMaxIdleTime(config.ConnMaxIdleTime)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
logger.Info("database connection established (plain)",
slog.String("dsn", config.DSN))
return db, nil
}
// Connect initializes and returns a new database connection pool.
// This is the basic connection function without any automatic operations.
func Connect(ctx context.Context, logger *slog.Logger, config *DBConfig) (*sql.DB, error) {
db, err := openAndConfigureDB(config)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
logger.Info("database connection established",
slog.String("dsn", config.DSN),
slog.Int("max_open_conns", config.MaxOpenConns),
slog.Int("max_idle_conns", config.MaxIdleConns))
return db, nil
}
// ConnectAndMigrate initializes a database connection and automatically runs migrations.
// The sources parameter specifies migration sources in dependency order.
//
// Migrations run on a separate connection WITHOUT the custom search_path to
// avoid a pgx/PG18 interaction where setting the core schema in the search_path
// at connection time causes multi-statement DDL to silently lose tables. After
// migrations complete, the returned connection uses the full search_path for
// application queries.
func ConnectAndMigrate(ctx context.Context, logger *slog.Logger, config *DBConfig, sources []MigrationSource) (*sql.DB, error) {
// Run migrations on a plain connection (no custom search_path).
migDB, err := ConnectPlain(ctx, logger, config)
if err != nil {
return nil, err
}
if err := RunMigrations(migDB, sources); err != nil {
migDB.Close()
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
migDB.Close()
// Now open the application connection with full search_path.
db, err := Connect(ctx, logger, config)
if err != nil {
return nil, err
}
logger.Info("database migrations applied")
return db, nil
}