// Package postgres owns the persistence layer of the catalog. // // It exposes a *pgxpool.Pool plus a small set of repository helpers used by // internal/catalog. Migrations are applied at startup from the SQL files in // the migrations/ subdirectory. package postgres import ( "context" "embed" "fmt" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql var migrationsFS embed.FS // Config captures the connection parameters. URL takes precedence over the // individual fields if non-empty. type Config struct { URL string Host string Port int User string Password string Database string SSLMode string MaxConns int32 MinConns int32 ConnectTimeout time.Duration } // Open establishes a pgxpool, pings the database and returns it. The // returned pool must be closed by the caller. func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) { dsn, err := cfg.DSN() if err != nil { return nil, err } pcfg, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("parse dsn: %w", err) } if cfg.MaxConns > 0 { pcfg.MaxConns = cfg.MaxConns } if cfg.MinConns > 0 { pcfg.MinConns = cfg.MinConns } if cfg.ConnectTimeout > 0 { pcfg.ConnConfig.ConnectTimeout = cfg.ConnectTimeout } pool, err := pgxpool.NewWithConfig(ctx, pcfg) if err != nil { return nil, fmt.Errorf("connect: %w", err) } pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := pool.Ping(pingCtx); err != nil { pool.Close() return nil, fmt.Errorf("ping: %w", err) } return pool, nil } // DSN builds the postgres connection string. func (c Config) DSN() (string, error) { if c.URL != "" { return c.URL, nil } if c.Host == "" { return "", fmt.Errorf("postgres: host required") } port := c.Port if port == 0 { port = 5432 } ssl := c.SSLMode if ssl == "" { ssl = "disable" } return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", c.User, c.Password, c.Host, port, c.Database, ssl), nil } // Migrate applies any pending SQL migrations from the embedded migrations/ // directory. Migrations are tracked in the schema_migrations table and run // in lexical filename order. Each migration runs in its own transaction. func Migrate(ctx context.Context, pool *pgxpool.Pool) error { if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() )`); err != nil { return fmt.Errorf("create schema_migrations: %w", err) } entries, err := migrationsFS.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) } names := make([]string, 0, len(entries)) for _, e := range entries { if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { names = append(names, e.Name()) } } sort.Strings(names) applied := map[string]struct{}{} rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`) if err != nil { return fmt.Errorf("list applied migrations: %w", err) } for rows.Next() { var v string if err := rows.Scan(&v); err != nil { rows.Close() return err } applied[v] = struct{}{} } rows.Close() for _, name := range names { if _, ok := applied[name]; ok { continue } body, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read %s: %w", name, err) } if err := applyMigration(ctx, pool, name, string(body)); err != nil { return fmt.Errorf("apply %s: %w", name, err) } } return nil } func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, body string) error { tx, err := pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err } defer tx.Rollback(ctx) // pgx's simple-protocol Exec() rejects multi-statement strings. Split // the body on ";\n" boundaries and run each statement individually. for i, stmt := range splitStatements(body) { if _, err := tx.Exec(ctx, stmt); err != nil { preview := stmt if len(preview) > 120 { preview = preview[:120] + "..." } return fmt.Errorf("statement %d failed: %w; sql=%q", i+1, err, preview) } } if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil { return err } return tx.Commit(ctx) } // splitStatements splits a SQL script on top-level ";" boundaries. It // is deliberately simple: it ignores ";" inside string literals and // comments, which is fine for our migration files (no literal ";" // in any INSERT). func splitStatements(body string) []string { var ( out []string buf strings.Builder inStr bool escNext bool ) for i := 0; i < len(body); i++ { c := body[i] if escNext { buf.WriteByte(c) escNext = false continue } if c == '\\' && inStr { buf.WriteByte(c) escNext = true continue } if c == '\'' { inStr = !inStr buf.WriteByte(c) continue } if c == '-' && !inStr && i+1 < len(body) && body[i+1] == '-' { // line comment until \n for i < len(body) && body[i] != '\n' { buf.WriteByte(body[i]) i++ } if i < len(body) { buf.WriteByte('\n') } continue } if c == ';' && !inStr { s := strings.TrimSpace(buf.String()) if s != "" { out = append(out, s) } buf.Reset() continue } buf.WriteByte(c) } if rest := strings.TrimSpace(buf.String()); rest != "" { out = append(out, rest) } return out }