Files
DiunDashboard/.planning/phases/03-postgresql-support/03-RESEARCH.md

29 KiB

Phase 3: PostgreSQL Support - Research

Researched: 2026-03-24 Domain: Go database/sql with pgx/v5 + golang-migrate PostgreSQL dialect Confidence: HIGH

Summary

Phase 3 adds PostgreSQL as an alternative backend alongside SQLite. The Store interface and all HTTP handlers are already dialect-neutral (Phase 2 delivered this). The work is entirely in three areas: (1) a new PostgresStore struct that implements the existing Store interface using PostgreSQL SQL syntax, (2) a separate migration runner for PostgreSQL using golang-migrate's dedicated pgx/v5 database driver, and (3) wiring in main.go to branch on DATABASE_URL.

The critical dialect difference is CreateTag: PostgreSQL does not support LastInsertId() via pgx/stdlib. The PostgresStore.CreateTag method must use QueryRow with RETURNING id instead of Exec + LastInsertId. Every other SQL translation is mechanical (positional params, NOW(), SERIAL, ON CONFLICT ... DO UPDATE instead of INSERT OR REPLACE).

The golang-migrate ecosystem ships a dedicated database/pgx/v5 sub-package that wraps a *sql.DB opened via pgx/v5/stdlib. This fits the established pattern in migrate.go exactly — a new RunPostgresMigrations(db *sql.DB) error function using the same iofs source with an embedded migrations/postgres directory.

Primary recommendation: Follow the locked decisions in CONTEXT.md verbatim. The implementation is a straightforward port of SQLiteStore with dialect adjustments; the only non-obvious trap is the LastInsertId incompatibility in CreateTag.


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

D-01: Use pgx/v5/stdlib as the database/sql adapter — matches SQLiteStore's *sql.DB pattern so PostgresStore has the same constructor signature (*sql.DB in, Store out) D-02: Do NOT use pgx native interface directly — keeping both stores on database/sql means the Store interface stays unchanged and NewServer(store Store, ...) works identically D-03: Each store implementation has its own raw SQL — no runtime dialect switching, no query builder, no shared SQL templates D-04: PostgreSQL-specific syntax differences handled in PostgresStore methods:

  • SERIAL instead of INTEGER PRIMARY KEY AUTOINCREMENT for tags.id
  • $1, $2, $3 positional params instead of ? placeholders
  • NOW() or CURRENT_TIMESTAMP instead of datetime('now') for acknowledged_at
  • ON CONFLICT ... DO UPDATE SET syntax is compatible (PostgreSQL 9.5+)
  • INSERT ... ON CONFLICT DO UPDATE for UPSERT (same pattern, different param style)
  • INSERT ... ON CONFLICT for tag assignments instead of INSERT OR REPLACE D-05: PostgresStore does NOT use a mutex — PostgreSQL handles concurrent writes natively D-06: Use database/sql default pool settings with sensible overrides: MaxOpenConns(25), MaxIdleConns(5), ConnMaxLifetime(5 * time.Minute) D-07: DATABASE_URL env var present → PostgreSQL; absent → SQLite with DB_PATH D-08: No separate DB_DRIVER variable — the presence of DATABASE_URL is the switch D-09: Startup log clearly indicates which backend is active: "Using PostgreSQL database" vs "Using SQLite database at {path}" D-10: Separate migration directories: migrations/sqlite/ (exists) and migrations/postgres/ (new) D-11: PostgreSQL baseline migration 0001_initial_schema.up.sql creates the same 3 tables with PostgreSQL-native types D-12: RunMigrations becomes dialect-aware or split into RunSQLiteMigrations/RunPostgresMigrations — researcher should determine best approach (see Architecture Patterns below) D-13: PostgreSQL migrations embedded via separate //go:embed migrations/postgres directive D-14: Use Docker Compose profiles — docker compose --profile postgres up activates the postgres service D-15: Default compose (no profile) remains SQLite-only for simple deploys D-16: Compose file includes a postgres service with health check, and the app service gets DATABASE_URL when the profile is active D-17: PostgresStore integration tests use a //go:build postgres build tag — they only run when a PostgreSQL instance is available D-18: CI can optionally run -tags postgres with a postgres service container; SQLite tests always run D-19: Test helper NewTestPostgresServer() creates a test database and runs migrations, similar to NewTestServer() for SQLite

Claude's Discretion

  • Exact PostgreSQL connection pool tuning beyond the defaults in D-06
  • Whether to split RunMigrations into two functions or use a dialect parameter
  • Error message formatting for PostgreSQL connection failures
  • Whether to add a health check endpoint that verifies database connectivity

Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope. </user_constraints>


<phase_requirements>

Phase Requirements

ID Description Research Support
DB-01 PostgreSQL is supported as an alternative to SQLite via pgx v5 driver pgx/v5/stdlib confirmed pure-Go, *sql.DB compatible; PostgresStore implements all 9 Store methods
DB-02 Database backend is selected via DATABASE_URL env var (present = PostgreSQL, absent = SQLite with DB_PATH) main.go branching pattern documented; driver registration names confirmed: "sqlite" and "pgx"
DB-03 Existing SQLite users can upgrade without data loss (baseline migration represents current schema) SQLite migration already uses CREATE TABLE IF NOT EXISTS; PostgreSQL migration is a fresh baseline for new deployments; no cross-dialect migration needed
</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
github.com/jackc/pgx/v5 v5.9.1 (Mar 22 2026) PostgreSQL driver + database/sql adapter via pgx/v5/stdlib De-facto standard Go PostgreSQL driver; pure Go (no CGO); actively maintained; 8,394 packages import it
github.com/golang-migrate/migrate/v4/database/pgx/v5 v4.19.1 (same module as existing golang-migrate) golang-migrate database driver for pgx v5 Already in project; dedicated pgx/v5 sub-package fits existing migrate.go pattern exactly

Supporting

Library Version Purpose When to Use
github.com/golang-migrate/migrate/v4/source/iofs v4.19.1 (already imported) Serve embedded FS migration files Reuse existing pattern from migrate.go

Alternatives Considered

Instead of Could Use Tradeoff
pgx/v5/stdlib (database/sql) pgx native interface Native pgx is faster but breaks Store interface — rejected by D-02
golang-migrate database/pgx/v5 golang-migrate database/postgres database/postgres uses lib/pq internally; database/pgx/v5 uses pgx consistently — use pgx/v5 sub-package
Two separate RunMigrations functions Single function with dialect param Two functions is simpler, avoids string-switch, each can be go:embed-scoped independently — use two functions (see Architecture)

Installation:

go get github.com/jackc/pgx/v5@v5.9.1
go get github.com/golang-migrate/migrate/v4/database/pgx/v5

Note: golang-migrate/migrate/v4 is already in go.mod at v4.19.1. Adding the database/pgx/v5 sub-package pulls from the same module version — no module version conflict.

Version verification (current as of 2026-03-24):

  • pgx/v5: v5.9.1 — verified via pkg.go.dev versions tab
  • golang-migrate/v4: v4.19.1 — already in go.mod

Architecture Patterns

pkg/diunwebhook/
├── store.go                         # Store interface (unchanged)
├── sqlite_store.go                  # SQLiteStore (unchanged)
├── postgres_store.go                # PostgresStore (new)
├── migrate.go                       # Split: RunSQLiteMigrations + RunPostgresMigrations
├── migrations/
│   ├── sqlite/
│   │   ├── 0001_initial_schema.up.sql   (exists)
│   │   └── 0001_initial_schema.down.sql (exists)
│   └── postgres/
│       ├── 0001_initial_schema.up.sql   (new)
│       └── 0001_initial_schema.down.sql (new)
├── diunwebhook.go                   (unchanged)
└── export_test.go                   # Add NewTestPostgresServer (build-tagged)
cmd/diunwebhook/
└── main.go                          # Add DATABASE_URL branching
compose.yml                          # Add postgres profile
compose.dev.yml                      # Add postgres profile

Pattern 1: PostgresStore Constructor (no mutex, pool config)

What: Constructor opens pool, sets sensible limits, no mutex (PostgreSQL serializes writes natively). When to use: Called from main.go when DATABASE_URL is present.

// Source: CONTEXT.md D-05, D-06 + established SQLiteStore pattern in sqlite_store.go
package diunwebhook

import (
    "database/sql"
    "time"
)

type PostgresStore struct {
    db *sql.DB
}

func NewPostgresStore(db *sql.DB) *PostgresStore {
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)
    return &PostgresStore{db: db}
}

Pattern 2: RunPostgresMigrations (separate function, separate embed)

What: A dedicated migration runner for PostgreSQL using golang-migrate's database/pgx/v5 driver. Mirrors RunMigrations (which becomes RunSQLiteMigrations) exactly. When to use: Called from main.go after sql.Open("pgx", databaseURL) when DATABASE_URL is set.

Decision D-12 leaves the split-vs-param choice to researcher. Recommendation: two separate functions (RunSQLiteMigrations and RunPostgresMigrations). Rationale: each function has its own //go:embed scope, there's no shared logic to deduplicate, and a string-switch approach adds a code path that can fail at runtime. Rename the existing RunMigrations to RunSQLiteMigrations for symmetry.

// Source: migrate.go (existing pattern) + golang-migrate pgx/v5 docs
//go:embed migrations/postgres
var postgresMigrations embed.FS

func RunPostgresMigrations(db *sql.DB) error {
    src, err := iofs.New(postgresMigrations, "migrations/postgres")
    if err != nil {
        return err
    }
    driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
    if err != nil {
        return err
    }
    m, err := migrate.NewWithInstance("iofs", src, "pgx5", driver)
    if err != nil {
        return err
    }
    if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return err
    }
    return nil
}

Import alias: pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5". Driver name string for NewWithInstance is "pgx5" (matches the registration name in the pgx/v5 driver).

Pattern 3: CreateTag — RETURNING id (CRITICAL)

What: PostgreSQL's pgx driver does not support LastInsertId(). CreateTag must use QueryRow with RETURNING id. When to use: In every PostgresStore.CreateTag implementation — this is the most error-prone difference from SQLiteStore.

// Source: pgx issue #1483 + pkg.go.dev pgx/v5/stdlib docs
func (s *PostgresStore) CreateTag(name string) (Tag, error) {
    var id int
    err := s.db.QueryRow(
        `INSERT INTO tags (name) VALUES ($1) RETURNING id`, name,
    ).Scan(&id)
    if err != nil {
        return Tag{}, err
    }
    return Tag{ID: id, Name: name}, nil
}

Pattern 4: AssignTag — ON CONFLICT DO UPDATE (replaces INSERT OR REPLACE)

What: PostgreSQL does not have INSERT OR REPLACE. Use INSERT ... ON CONFLICT (image) DO UPDATE SET tag_id = EXCLUDED.tag_id. When to use: PostgresStore.AssignTag.

// Source: CONTEXT.md D-04
_, err := s.db.Exec(
    `INSERT INTO tag_assignments (image, tag_id) VALUES ($1, $2)
     ON CONFLICT (image) DO UPDATE SET tag_id = EXCLUDED.tag_id`,
    image, tagID,
)

Pattern 5: main.go DATABASE_URL branching

// Source: CONTEXT.md D-07, D-08, D-09
databaseURL := os.Getenv("DATABASE_URL")
var store diun.Store
if databaseURL != "" {
    db, err := sql.Open("pgx", databaseURL)
    if err != nil {
        log.Fatalf("sql.Open postgres: %v", err)
    }
    if err := diun.RunPostgresMigrations(db); err != nil {
        log.Fatalf("RunPostgresMigrations: %v", err)
    }
    store = diun.NewPostgresStore(db)
    log.Println("Using PostgreSQL database")
} else {
    dbPath := os.Getenv("DB_PATH")
    if dbPath == "" {
        dbPath = "./diun.db"
    }
    db, err := sql.Open("sqlite", dbPath)
    if err != nil {
        log.Fatalf("sql.Open sqlite: %v", err)
    }
    if err := diun.RunSQLiteMigrations(db); err != nil {
        log.Fatalf("RunSQLiteMigrations: %v", err)
    }
    store = diun.NewSQLiteStore(db)
    log.Printf("Using SQLite database at %s", dbPath)
}

Add _ "github.com/jackc/pgx/v5/stdlib" import to main.go (blank import registers the "pgx" driver name).

Pattern 6: Docker Compose postgres profile

# compose.yml — adds postgres profile without breaking default SQLite deploy
services:
  app:
    image: gitea.jeanlucmakiola.de/makiolaj/diundashboard:latest
    ports:
      - "8080:8080"
    environment:
      - WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
      - PORT=${PORT:-8080}
      - DB_PATH=/data/diun.db
      - DATABASE_URL=${DATABASE_URL:-}
    volumes:
      - diun-data:/data
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
        required: false   # only enforced when postgres profile is active

  postgres:
    image: postgres:17-alpine
    profiles:
      - postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-diun}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-diun}
      POSTGRES_DB: ${POSTGRES_DB:-diundashboard}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-diun}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped

volumes:
  diun-data:
  postgres-data:
    profiles:
      - postgres

Activate with: docker compose --profile postgres up -d

Pattern 7: Build-tagged PostgreSQL integration tests

// Source: CONTEXT.md D-17, D-19 + export_test.go pattern
//go:build postgres

package diunwebhook

import (
    "database/sql"
    "os"
    _ "github.com/jackc/pgx/v5/stdlib"
)

func NewTestPostgresServer() (*Server, error) {
    databaseURL := os.Getenv("TEST_DATABASE_URL")
    if databaseURL == "" {
        databaseURL = "postgres://diun:diun@localhost:5432/diundashboard_test?sslmode=disable"
    }
    db, err := sql.Open("pgx", databaseURL)
    if err != nil {
        return nil, err
    }
    if err := RunPostgresMigrations(db); err != nil {
        return nil, err
    }
    store := NewPostgresStore(db)
    return NewServer(store, ""), nil
}

Anti-Patterns to Avoid

  • Using res.LastInsertId() after db.Exec: pgx does not implement this — returns an error at runtime. Use QueryRow(...).Scan(&id) with RETURNING id instead.
  • Sharing the mutex with PostgresStore: PostgreSQL handles concurrent writes; adding a mutex is unnecessary and hurts performance.
  • Using INSERT OR REPLACE: Not valid PostgreSQL syntax. Use INSERT ... ON CONFLICT ... DO UPDATE SET.
  • Using datetime('now'): SQLite function — not valid in PostgreSQL. Use NOW() or CURRENT_TIMESTAMP.
  • Using ? placeholders: Not valid in PostgreSQL. Use $1, $2, etc.
  • Using INTEGER PRIMARY KEY AUTOINCREMENT: Not valid in PostgreSQL. Use SERIAL or BIGSERIAL.
  • Forgetting //go:build postgres on test files: Without the build tag, the test file will be compiled for all builds — pgx/v5/stdlib import will fail on SQLite-only CI runs.
  • Calling RunSQLiteMigrations on a PostgreSQL connection: The sqlite migration driver will fail to initialize against a PostgreSQL database.

Don't Hand-Roll

Problem Don't Build Use Instead Why
PostgreSQL migration tracking Custom schema_version table golang-migrate/v4/database/pgx/v5 Handles dirty state, locking, version history, rollbacks — all already solved
Connection pooling Custom pool implementation database/sql built-in pool + pgx/v5/stdlib database/sql pool is production-grade; pgx stdlib wraps it correctly
Connection string parsing Custom URL parser Pass DATABASE_URL directly to sql.Open("pgx", url) pgx parses standard PostgreSQL URI format natively
Dialect detection at runtime Inspect driver name at query time Separate store structs with their own SQL Runtime dialect switching creates test surface, runtime failures; two structs is simpler

Key insight: The existing Store interface already separates the concern — PostgresStore is just another implementation. There is nothing to invent.


Common Pitfalls

Pitfall 1: LastInsertId on PostgreSQL

What goes wrong: CreateTag calls res.LastInsertId() — pgx returns ErrNoLastInsertId at runtime, not compile time. Why it happens: The database/sql Result interface defines LastInsertId() but pgx does not support it. SQLite does. How to avoid: In PostgresStore.CreateTag, use QueryRow(...RETURNING id...).Scan(&id) instead of Exec + LastInsertId. Warning signs: Test passes compile, panics or returns error at runtime on tag creation.

Pitfall 2: golang-migrate driver name mismatch

What goes wrong: Passing the wrong database name string to migrate.NewWithInstance causes "unknown driver" errors. Why it happens: The golang-migrate/database/pgx/v5 driver registers as "pgx5", not "pgx" or "postgres". How to avoid: Use "pgx5" as the database name arg to migrate.NewWithInstance("iofs", src, "pgx5", driver). Warning signs: migrate.NewWithInstance returns an error mentioning an unknown driver.

Pitfall 3: pgx/v5/stdlib import not registered

What goes wrong: sql.Open("pgx", url) fails with "unknown driver pgx". Why it happens: The "pgx" driver is only registered when pgx/v5/stdlib is imported (blank import side effect). How to avoid: Add _ "github.com/jackc/pgx/v5/stdlib" to main.go and to any test files that open a "pgx" connection. Warning signs: Runtime error "unknown driver pgx" despite pgx being in go.mod.

Pitfall 4: SQLite migrate.go import conflict

What goes wrong: Adding the pgx/v5 migrate driver import to migrate.go introduces pgx as a dependency of the SQLite migration path. Why it happens: Go imports are file-scoped; putting both drivers in one file compiles both. How to avoid: Put RunSQLiteMigrations and RunPostgresMigrations in separate files, or at minimum keep the blank driver import for pgx only in the PostgreSQL branch. Alternatively, keep both in migrate.go — both drivers are compiled into the binary regardless; this is a binary size trade-off, not a correctness issue. Warning signs: modernc.org/sqlite and pgx both appear in a file that should only need one.

Pitfall 5: Docker Compose required: false on depends_on

What goes wrong: app service fails to start when postgres profile is inactive because depends_on.postgres is unconditional. Why it happens: depends_on without required: false makes the dependency mandatory even when the postgres profile is not active. How to avoid: Use depends_on.postgres.required: false so the health check dependency is only enforced when the postgres service is actually started. Requires Docker Compose v2.20+. Warning signs: docker compose up (no profile) fails with "service postgres not found".

Pitfall 6: GetUpdates timestamp scanning differences

What goes wrong: GetUpdates scans received_at and created as strings (createdStr, receivedStr) and then calls time.Parse(time.RFC3339, ...). In the PostgreSQL schema these columns are TEXT (by design), so scanning behaves the same. If someone types them as TIMESTAMPTZ instead, scanning into a string breaks. Why it happens: The SQLiteStore scans timestamps as strings because SQLite stores them as TEXT. If the PostgreSQL migration uses TEXT for these columns (matching the SQLite schema), the existing scan logic works unchanged in PostgresStore. How to avoid: Use TEXT NOT NULL for received_at, acknowledged_at, and created in the PostgreSQL migration, mirroring the SQLite schema exactly. Do not use TIMESTAMPTZ unless you also update the scan/format logic. Warning signs: sql: Scan error ... converting driver.Value type time.Time into *string.


Code Examples

PostgreSQL baseline migration (0001_initial_schema.up.sql)

-- Source: sqlite/0001_initial_schema.up.sql translated to PostgreSQL dialect
CREATE TABLE IF NOT EXISTS updates (
    image           TEXT PRIMARY KEY,
    diun_version    TEXT NOT NULL DEFAULT '',
    hostname        TEXT NOT NULL DEFAULT '',
    status          TEXT NOT NULL DEFAULT '',
    provider        TEXT NOT NULL DEFAULT '',
    hub_link        TEXT NOT NULL DEFAULT '',
    mime_type       TEXT NOT NULL DEFAULT '',
    digest          TEXT NOT NULL DEFAULT '',
    created         TEXT NOT NULL DEFAULT '',
    platform        TEXT NOT NULL DEFAULT '',
    ctn_name        TEXT NOT NULL DEFAULT '',
    ctn_id          TEXT NOT NULL DEFAULT '',
    ctn_state       TEXT NOT NULL DEFAULT '',
    ctn_status      TEXT NOT NULL DEFAULT '',
    received_at     TEXT NOT NULL,
    acknowledged_at TEXT
);

CREATE TABLE IF NOT EXISTS tags (
    id   SERIAL PRIMARY KEY,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS tag_assignments (
    image  TEXT PRIMARY KEY,
    tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
);

Key differences from SQLite version:

  • SERIAL PRIMARY KEY replaces INTEGER PRIMARY KEY AUTOINCREMENT
  • All other columns are identical (TEXT type used throughout)
  • ON DELETE CASCADE is the same — PostgreSQL enforces FK constraints by default (no equivalent of PRAGMA foreign_keys = ON needed)

PostgreSQL down migration (0001_initial_schema.down.sql)

DROP TABLE IF EXISTS tag_assignments;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS updates;

Identical to SQLite version.

UpsertEvent (PostgreSQL)

// Positional params $1..$15, acknowledged_at reset to NULL on conflict
_, err := s.db.Exec(`
    INSERT INTO updates (
        image, diun_version, hostname, status, provider,
        hub_link, mime_type, digest, created, platform,
        ctn_name, ctn_id, ctn_state, ctn_status,
        received_at, acknowledged_at
    ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NULL)
    ON CONFLICT(image) DO UPDATE SET
        diun_version    = EXCLUDED.diun_version,
        hostname        = EXCLUDED.hostname,
        status          = EXCLUDED.status,
        provider        = EXCLUDED.provider,
        hub_link        = EXCLUDED.hub_link,
        mime_type       = EXCLUDED.mime_type,
        digest          = EXCLUDED.digest,
        created         = EXCLUDED.created,
        platform        = EXCLUDED.platform,
        ctn_name        = EXCLUDED.ctn_name,
        ctn_id          = EXCLUDED.ctn_id,
        ctn_state       = EXCLUDED.ctn_state,
        ctn_status      = EXCLUDED.ctn_status,
        received_at     = EXCLUDED.received_at,
        acknowledged_at = NULL`,
    event.Image, event.DiunVersion, ...
)

AcknowledgeUpdate (PostgreSQL)

// NOW() replaces datetime('now'), $1 replaces ?
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE image = $1`, image)

State of the Art

Old Approach Current Approach When Changed Impact
lib/pq (archived) pgx/v5/stdlib pgx v4→v5, lib/pq archived ~2023 pgx is now the consensus standard Go PostgreSQL driver
golang-migrate database/postgres (uses lib/pq) golang-migrate database/pgx/v5 golang-migrate added pgx/v5 sub-package Use the pgx-native driver to avoid a lib/pq dependency
Single global RunMigrations Separate RunSQLiteMigrations / RunPostgresMigrations This phase Each function owns its embed directive and driver import

Open Questions

  1. Rename RunMigrations to RunSQLiteMigrations

    • What we know: RunMigrations is only called in main.go and export_test.go. Renaming breaks two call sites.
    • What's unclear: Whether to rename (consistency) or keep old name and add a new RunPostgresMigrations (backward compatible for hypothetical external callers).
    • Recommendation: Rename to RunSQLiteMigrations — this is internal-only code and symmetry aids comprehension. Update the two call sites.
  2. depends_on.required: false Docker Compose version requirement

    • What we know: required: false under depends_on was added in Docker Compose v2.20.
    • What's unclear: Whether the target deployment environment has Compose v2.20+. Docker 29.0.0 (confirmed present) ships with Compose v2.29+ — this is not a concern for the dev machine. Production deployments depend on the user's Docker version.
    • Recommendation: Use required: false; document minimum Docker Compose v2.20 in compose.yml comment.

Environment Availability

Dependency Required By Available Version Fallback
Docker Compose postgres profile, integration tests 29.0.0
PostgreSQL server Integration test execution (-tags postgres) Tests skip via build tag; Docker Compose spins up postgres for CI
pg_isready / psql client Health check inside postgres container ✗ (host) pg_isready is inside the postgres:17-alpine image — not needed on host
Go 1.26 Build Not directly measurable from this shell go.mod specifies 1.26

Missing dependencies with no fallback:

  • None that block development. PostgreSQL integration tests require a live server but are gated behind //go:build postgres.

Missing dependencies with fallback:

  • PostgreSQL server (host): not installed, but not required — tests use build tags, Docker Compose provides the server for integration runs.

Project Constraints (from CLAUDE.md)

Directives the planner must verify compliance with:

  • No CGO: CGO_ENABLED=0 in Dockerfile Stage 2. pgx/v5 is pure Go — this constraint is satisfied. Verify that adding pgx/v5 does not transitively pull in any CGO package.
  • Pure Go SQLite driver: modernc.org/sqlite must remain. Adding pgx does not replace it — both coexist.
  • Database must support both SQLite and PostgreSQL: This is exactly what Phase 3 delivers via the Store interface.
  • database/sql abstraction: Both stores use *sql.DB. No pgx native interface in handlers.
  • net/http only, no router framework: No impact from this phase.
  • gofmt enforced: All new .go files must be gofmt-clean.
  • Naming conventions: New file postgres_store.go, new type PostgresStore, new constructor NewPostgresStore. Test helper NewTestPostgresServer. Functions RunSQLiteMigrations / RunPostgresMigrations.
  • Error handling: http.Error(w, ..., status) with lowercase messages. Not directly affected — PostgresStore is storage-layer only. log.Fatalf in main.go for connection/migration failures (matches existing pattern).
  • No global state: PostgresStore holds *sql.DB as struct field, no package-level vars — consistent with Phase 2 refactor.
  • GSD workflow: Do not make direct edits outside a GSD phase.
  • Module name: awesomeProject (in go.mod). Import as diun "awesomeProject/pkg/diunwebhook" in main.go.

Sources

Primary (HIGH confidence)

  • pkg.go.dev/github.com/jackc/pgx/v5 — version confirmed v5.9.1 (Mar 22 2026), stdlib package import path, driver name "pgx", pure Go confirmed
  • pkg.go.dev/github.com/jackc/pgx/v5/stdlib — sql.Open("pgx", url) pattern, LastInsertId not supported
  • pkg.go.dev/github.com/golang-migrate/migrate/v4/database/pgx/v5 — WithInstance(*sql.DB, *Config), driver registers as "pgx5", v4.19.1
  • github.com/golang-migrate/migrate/blob/master/database/pgx/v5/pgx.go — confirmed database.Register("pgx5", &db) registration name
  • Existing codebase: store.go, sqlite_store.go, migrate.go, export_test.go, main.go — all read directly

Secondary (MEDIUM confidence)

  • github.com/jackc/pgx/issues/1483 — LastInsertId not supported by pgx, confirmed by multiple sources
  • Docker Compose docs (docs.docker.com/reference/compose-file/services/) — profiles syntax, depends_on with required: false

Tertiary (LOW confidence)

  • WebSearch results re: Docker Compose required: false version requirement — states Compose v2.20; not independently verified against official changelog. However, Docker 29.0.0 (installed) ships Compose v2.29+, so this is moot for the dev machine.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — versions verified via pkg.go.dev on 2026-03-24
  • Architecture: HIGH — based on existing codebase patterns + confirmed library APIs
  • Pitfalls: HIGH for LastInsertId, driver name, import registration (all verified via official sources); MEDIUM for Docker Compose required: false version boundary

Research date: 2026-03-24 Valid until: 2026-05-24 (stable ecosystem; pgx and golang-migrate release infrequently)