--- phase: 03-postgresql-support plan: 02 type: execute wave: 2 depends_on: [03-01] files_modified: - cmd/diunwebhook/main.go - pkg/diunwebhook/diunwebhook.go - pkg/diunwebhook/postgres_test.go - pkg/diunwebhook/export_test.go - compose.yml - compose.dev.yml autonomous: true requirements: [DB-01, DB-02, DB-03] must_haves: truths: - "Setting DATABASE_URL starts the app using PostgreSQL; omitting it falls back to SQLite with DB_PATH" - "Startup log clearly indicates which backend is active" - "Docker Compose with --profile postgres activates a PostgreSQL service" - "Default docker compose (no profile) remains SQLite-only" - "Duplicate tag creation returns 409 on both SQLite and PostgreSQL" - "Existing SQLite users can upgrade to this version with zero configuration changes and no data loss" artifacts: - path: "cmd/diunwebhook/main.go" provides: "DATABASE_URL branching logic" contains: "DATABASE_URL" - path: "compose.yml" provides: "Production compose with postgres profile" contains: "profiles:" - path: "compose.dev.yml" provides: "Dev compose with postgres profile" contains: "profiles:" - path: "pkg/diunwebhook/postgres_test.go" provides: "Build-tagged PostgreSQL integration test helper" contains: "go:build postgres" - path: "pkg/diunwebhook/diunwebhook.go" provides: "Case-insensitive UNIQUE constraint detection" contains: "strings.ToLower" key_links: - from: "cmd/diunwebhook/main.go" to: "pkg/diunwebhook/postgres_store.go" via: "diun.NewPostgresStore(db)" pattern: "NewPostgresStore" - from: "cmd/diunwebhook/main.go" to: "pkg/diunwebhook/migrate.go" via: "diun.RunPostgresMigrations(db)" pattern: "RunPostgresMigrations" - from: "cmd/diunwebhook/main.go" to: "pgx/v5/stdlib" via: "blank import for driver registration" pattern: '_ "github.com/jackc/pgx/v5/stdlib"' --- Wire PostgresStore into the application and deployment infrastructure. Purpose: Connects the PostgresStore (built in Plan 01) to the startup path, adds Docker Compose profiles for PostgreSQL deployments, creates build-tagged integration test helpers, and fixes the UNIQUE constraint detection to work across both database backends. Also updates all call sites that still reference the old `RunMigrations` name (renamed to `RunSQLiteMigrations` in Plan 01). Output: Updated main.go with DATABASE_URL branching, compose files with postgres profiles, build-tagged test helper, cross-dialect error handling fix. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-postgresql-support/03-CONTEXT.md @.planning/phases/03-postgresql-support/03-RESEARCH.md @.planning/phases/03-postgresql-support/03-01-SUMMARY.md @cmd/diunwebhook/main.go @pkg/diunwebhook/diunwebhook.go @pkg/diunwebhook/export_test.go @compose.yml @compose.dev.yml From pkg/diunwebhook/postgres_store.go: ```go func NewPostgresStore(db *sql.DB) *PostgresStore ``` From pkg/diunwebhook/migrate.go: ```go func RunSQLiteMigrations(db *sql.DB) error func RunPostgresMigrations(db *sql.DB) error ``` From pkg/diunwebhook/store.go: ```go type Store interface { ... } // 9 methods ``` From pkg/diunwebhook/diunwebhook.go: ```go func NewServer(store Store, webhookSecret string) *Server ``` Task 1: Wire DATABASE_URL branching in main.go, update call sites, and fix cross-dialect UNIQUE detection - cmd/diunwebhook/main.go (current SQLite-only startup to rewrite with branching) - pkg/diunwebhook/diunwebhook.go (TagsHandler - UNIQUE detection to fix) - pkg/diunwebhook/export_test.go (calls RunMigrations - must rename to RunSQLiteMigrations) - pkg/diunwebhook/postgres_store.go (verify NewPostgresStore exists from Plan 01) - pkg/diunwebhook/migrate.go (verify RunSQLiteMigrations and RunPostgresMigrations exist from Plan 01) cmd/diunwebhook/main.go, pkg/diunwebhook/diunwebhook.go, pkg/diunwebhook/export_test.go **1. Rewrite `cmd/diunwebhook/main.go`** with DATABASE_URL branching per D-07, D-08, D-09. Replace the current database setup block with DATABASE_URL branching. The full main function should: ```go package main import ( "context" "database/sql" "errors" "log" "net/http" "os" "os/signal" "syscall" "time" diun "awesomeProject/pkg/diunwebhook" _ "github.com/jackc/pgx/v5/stdlib" _ "modernc.org/sqlite" ) func main() { 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) } // ... rest of main unchanged (secret, server, mux, httpSrv, graceful shutdown) } ``` Key changes: - Add blank import `_ "github.com/jackc/pgx/v5/stdlib"` to register "pgx" driver name - `DATABASE_URL` present -> `sql.Open("pgx", databaseURL)` -> `RunPostgresMigrations` -> `NewPostgresStore` - `DATABASE_URL` absent -> existing SQLite path with `RunSQLiteMigrations` (renamed from `RunMigrations` in Plan 01) - Log `"Using PostgreSQL database"` or `"Using SQLite database at %s"` per D-09 - Keep all existing code after the store setup unchanged (secret, server, mux, httpSrv, shutdown) **2. Update `pkg/diunwebhook/export_test.go`** to use the renamed function. Change all occurrences of `RunMigrations(db)` to `RunSQLiteMigrations(db)` in export_test.go. This completes the rename that Plan 01 started in migrate.go. **3. Fix cross-dialect UNIQUE constraint detection in `pkg/diunwebhook/diunwebhook.go`.** In the `TagsHandler` method, change: ```go if strings.Contains(err.Error(), "UNIQUE") { ``` to: ```go if strings.Contains(strings.ToLower(err.Error()), "unique") { ``` Why: SQLite errors contain uppercase "UNIQUE" (e.g., `UNIQUE constraint failed: tags.name`). PostgreSQL/pgx errors contain lowercase "unique" (e.g., `duplicate key value violates unique constraint "tags_name_key"`). Case-insensitive matching ensures 409 Conflict is returned for both backends. cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -5 - cmd/diunwebhook/main.go contains `databaseURL := os.Getenv("DATABASE_URL")` - cmd/diunwebhook/main.go contains `sql.Open("pgx", databaseURL)` - cmd/diunwebhook/main.go contains `diun.RunPostgresMigrations(db)` - cmd/diunwebhook/main.go contains `diun.NewPostgresStore(db)` - cmd/diunwebhook/main.go contains `log.Println("Using PostgreSQL database")` - cmd/diunwebhook/main.go contains `log.Printf("Using SQLite database at %s", dbPath)` - cmd/diunwebhook/main.go contains `_ "github.com/jackc/pgx/v5/stdlib"` - cmd/diunwebhook/main.go contains `diun.RunSQLiteMigrations(db)` (not RunMigrations) - pkg/diunwebhook/export_test.go contains `RunSQLiteMigrations` (not RunMigrations) - pkg/diunwebhook/diunwebhook.go contains `strings.Contains(strings.ToLower(err.Error()), "unique")` - pkg/diunwebhook/diunwebhook.go does NOT contain `strings.Contains(err.Error(), "UNIQUE")` (old pattern removed) - `go build ./...` exits 0 - `go test -v -count=1 ./pkg/diunwebhook/` exits 0 (full test suite passes) main.go branches on DATABASE_URL to select PostgreSQL or SQLite. pgx/v5/stdlib is blank-imported to register the driver. Startup log identifies the active backend. export_test.go updated with RunSQLiteMigrations. UNIQUE detection is case-insensitive for cross-dialect compatibility. All existing tests pass. Task 2: Add Docker Compose postgres profiles and build-tagged test helper - compose.yml (current production compose to add postgres profile) - compose.dev.yml (current dev compose to add postgres profile) - pkg/diunwebhook/export_test.go (pattern for NewTestPostgresServer) - Dockerfile (verify no changes needed -- pgx/v5 is pure Go, CGO_ENABLED=0 is fine) compose.yml, compose.dev.yml, pkg/diunwebhook/postgres_test.go **1. Update `compose.yml`** (production) to add postgres profile per D-14, D-15, D-16: ```yaml # Minimum Docker Compose v2.20 required for depends_on.required 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 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: ``` Default `docker compose up` still uses SQLite (DATABASE_URL is empty string, app falls back to DB_PATH). `docker compose --profile postgres up` starts the postgres service; user sets `DATABASE_URL=postgres://diun:diun@postgres:5432/diundashboard?sslmode=disable` in .env. **2. Update `compose.dev.yml`** to add postgres profile for local development: ```yaml services: app: build: . ports: - "8080:8080" environment: - WEBHOOK_SECRET=${WEBHOOK_SECRET:-} - DATABASE_URL=${DATABASE_URL:-} restart: unless-stopped depends_on: postgres: condition: service_healthy required: false postgres: image: postgres:17-alpine profiles: - postgres ports: - "5432:5432" 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: postgres-data: ``` Dev compose exposes port 5432 on host for direct psql access during development. **3. Create `pkg/diunwebhook/postgres_test.go`** with build tag per D-17, D-19: ```go //go:build postgres package diunwebhook import ( "database/sql" "os" _ "github.com/jackc/pgx/v5/stdlib" ) // NewTestPostgresServer constructs a Server backed by a PostgreSQL database. // Requires a running PostgreSQL instance. Set TEST_DATABASE_URL to override // the default connection string. 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 } ``` This file is in the `diunwebhook` package (internal, same as export_test.go pattern). The `//go:build postgres` tag ensures it only compiles when explicitly requested with `go test -tags postgres`. Without the tag, `go test ./pkg/diunwebhook/` skips this file entirely -- no pgx import, no PostgreSQL dependency. cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -5 - compose.yml contains `profiles:` under the postgres service - compose.yml contains `- postgres` (profile name) - compose.yml contains `postgres:17-alpine` - compose.yml contains `pg_isready` - compose.yml contains `required: false` (conditional depends_on) - compose.yml contains `DATABASE_URL=${DATABASE_URL:-}` - compose.yml contains `postgres-data:` in volumes - compose.dev.yml contains `profiles:` under the postgres service - compose.dev.yml contains `- postgres` (profile name) - compose.dev.yml contains `"5432:5432"` (exposed for dev) - compose.dev.yml contains `required: false` - compose.dev.yml contains `DATABASE_URL=${DATABASE_URL:-}` - pkg/diunwebhook/postgres_test.go contains `//go:build postgres` - pkg/diunwebhook/postgres_test.go contains `func NewTestPostgresServer()` - pkg/diunwebhook/postgres_test.go contains `sql.Open("pgx", databaseURL)` - pkg/diunwebhook/postgres_test.go contains `RunPostgresMigrations(db)` - pkg/diunwebhook/postgres_test.go contains `NewPostgresStore(db)` - pkg/diunwebhook/postgres_test.go contains `TEST_DATABASE_URL` - `go build ./...` exits 0 (postgres_test.go is not compiled without build tag) - `go test -v -count=1 ./pkg/diunwebhook/` exits 0 (full SQLite test suite passes, postgres_test.go skipped) Docker Compose files support optional PostgreSQL via profiles. Default deploy remains SQLite-only. Build-tagged test helper exists for PostgreSQL integration testing. Dockerfile needs no changes (pgx/v5 is pure Go). 1. `go build ./...` succeeds 2. `go test -v -count=1 ./pkg/diunwebhook/` passes (all existing SQLite tests) 3. `docker compose config` validates without errors 4. `docker compose --profile postgres config` shows postgres service 5. `grep -c "DATABASE_URL" cmd/diunwebhook/main.go` returns at least 1 6. `grep "strings.ToLower" pkg/diunwebhook/diunwebhook.go` shows case-insensitive UNIQUE check - DATABASE_URL present: app opens pgx connection, runs PostgreSQL migrations, creates PostgresStore, logs "Using PostgreSQL database" - DATABASE_URL absent: app opens sqlite connection, runs SQLite migrations, creates SQLiteStore, logs "Using SQLite database at {path}" - `docker compose up` (no profile) works with SQLite only - `docker compose --profile postgres up` starts PostgreSQL service with health check - Build-tagged test helper available for PostgreSQL integration tests - UNIQUE constraint detection works for both SQLite and PostgreSQL error messages - All existing SQLite tests continue to pass After completion, create `.planning/phases/03-postgresql-support/03-02-SUMMARY.md`