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:
SERIALinstead ofINTEGER PRIMARY KEY AUTOINCREMENTfor tags.id$1, $2, $3positional params instead of?placeholdersNOW()orCURRENT_TIMESTAMPinstead ofdatetime('now')for acknowledged_atON CONFLICT ... DO UPDATE SETsyntax is compatible (PostgreSQL 9.5+)INSERT ... ON CONFLICT DO UPDATEfor UPSERT (same pattern, different param style)INSERT ... ON CONFLICTfor tag assignments instead ofINSERT OR REPLACED-05: PostgresStore does NOT use a mutex — PostgreSQL handles concurrent writes natively D-06: Usedatabase/sqldefault pool settings with sensible overrides:MaxOpenConns(25),MaxIdleConns(5),ConnMaxLifetime(5 * time.Minute)D-07:DATABASE_URLenv var present → PostgreSQL; absent → SQLite withDB_PATHD-08: No separateDB_DRIVERvariable — the presence ofDATABASE_URLis 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) andmigrations/postgres/(new) D-11: PostgreSQL baseline migration0001_initial_schema.up.sqlcreates the same 3 tables with PostgreSQL-native types D-12:RunMigrationsbecomes dialect-aware or split intoRunSQLiteMigrations/RunPostgresMigrations— researcher should determine best approach (see Architecture Patterns below) D-13: PostgreSQL migrations embedded via separate//go:embed migrations/postgresdirective D-14: Use Docker Compose profiles —docker compose --profile postgres upactivates the postgres service D-15: Default compose (no profile) remains SQLite-only for simple deploys D-16: Compose file includes apostgresservice with health check, and the app service getsDATABASE_URLwhen the profile is active D-17: PostgresStore integration tests use a//go:build postgresbuild tag — they only run when a PostgreSQL instance is available D-18: CI can optionally run-tags postgreswith a postgres service container; SQLite tests always run D-19: Test helperNewTestPostgresServer()creates a test database and runs migrations, similar toNewTestServer()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 tabgolang-migrate/v4: v4.19.1 — already in go.mod
Architecture Patterns
Recommended Project Structure
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()afterdb.Exec: pgx does not implement this — returns an error at runtime. UseQueryRow(...).Scan(&id)withRETURNING idinstead. - 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. UseINSERT ... ON CONFLICT ... DO UPDATE SET. - Using
datetime('now'): SQLite function — not valid in PostgreSQL. UseNOW()orCURRENT_TIMESTAMP. - Using
?placeholders: Not valid in PostgreSQL. Use$1,$2, etc. - Using
INTEGER PRIMARY KEY AUTOINCREMENT: Not valid in PostgreSQL. UseSERIALorBIGSERIAL. - Forgetting
//go:build postgreson test files: Without the build tag, the test file will be compiled for all builds —pgx/v5/stdlibimport will fail on SQLite-only CI runs. - Calling
RunSQLiteMigrationson 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 KEYreplacesINTEGER PRIMARY KEY AUTOINCREMENT- All other columns are identical (
TEXTtype used throughout) ON DELETE CASCADEis the same — PostgreSQL enforces FK constraints by default (no equivalent ofPRAGMA foreign_keys = ONneeded)
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
-
Rename
RunMigrationstoRunSQLiteMigrations- What we know:
RunMigrationsis only called inmain.goandexport_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.
- What we know:
-
depends_on.required: falseDocker Compose version requirement- What we know:
required: falseunderdepends_onwas 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.
- What we know:
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=0in Dockerfile Stage 2.pgx/v5is pure Go — this constraint is satisfied. Verify that addingpgx/v5does not transitively pull in any CGO package. - Pure Go SQLite driver:
modernc.org/sqlitemust 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/sqlabstraction: Both stores use*sql.DB. No pgx native interface in handlers.net/httponly, no router framework: No impact from this phase.gofmtenforced: All new.gofiles must begofmt-clean.- Naming conventions: New file
postgres_store.go, new typePostgresStore, new constructorNewPostgresStore. Test helperNewTestPostgresServer. FunctionsRunSQLiteMigrations/RunPostgresMigrations. - Error handling:
http.Error(w, ..., status)with lowercase messages. Not directly affected — PostgresStore is storage-layer only.log.Fatalfinmain.gofor connection/migration failures (matches existing pattern). - No global state:
PostgresStoreholds*sql.DBas 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 asdiun "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,LastInsertIdnot 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 —
LastInsertIdnot 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: falseversion 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: falseversion boundary
Research date: 2026-03-24 Valid until: 2026-05-24 (stable ecosystem; pgx and golang-migrate release infrequently)