Files
DiunDashboard/.planning/phases/03-postgresql-support/03-02-PLAN.md

15 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-postgresql-support 02 execute 2
03-01
cmd/diunwebhook/main.go
pkg/diunwebhook/diunwebhook.go
pkg/diunwebhook/postgres_test.go
pkg/diunwebhook/export_test.go
compose.yml
compose.dev.yml
true
DB-01
DB-02
DB-03
truths artifacts key_links
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
path provides contains
cmd/diunwebhook/main.go DATABASE_URL branching logic DATABASE_URL
path provides contains
compose.yml Production compose with postgres profile profiles:
path provides contains
compose.dev.yml Dev compose with postgres profile profiles:
path provides contains
pkg/diunwebhook/postgres_test.go Build-tagged PostgreSQL integration test helper go:build postgres
path provides contains
pkg/diunwebhook/diunwebhook.go Case-insensitive UNIQUE constraint detection strings.ToLower
from to via pattern
cmd/diunwebhook/main.go pkg/diunwebhook/postgres_store.go diun.NewPostgresStore(db) NewPostgresStore
from to via pattern
cmd/diunwebhook/main.go pkg/diunwebhook/migrate.go diun.RunPostgresMigrations(db) RunPostgresMigrations
from to via pattern
cmd/diunwebhook/main.go pgx/v5/stdlib blank import for driver registration _ "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. Output: Updated main.go with DATABASE_URL branching, compose files with postgres profiles, build-tagged test helper, cross-dialect error handling fix.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

func RunSQLiteMigrations(db *sql.DB) error
func RunPostgresMigrations(db *sql.DB) error

From pkg/diunwebhook/store.go:

type Store interface { ... } // 9 methods

From pkg/diunwebhook/diunwebhook.go:

func NewServer(store Store, webhookSecret string) *Server
Task 1: Wire DATABASE_URL branching in main.go and fix cross-dialect UNIQUE detection - cmd/diunwebhook/main.go (current SQLite-only startup to add branching) - pkg/diunwebhook/diunwebhook.go (TagsHandler line 172 - UNIQUE detection to fix) - 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 **1. Update `cmd/diunwebhook/main.go`** to branch on `DATABASE_URL` per D-07, D-08, D-09.

Replace the current database setup block (lines 18-33) with DATABASE_URL branching. The full main function should:

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 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. Fix cross-dialect UNIQUE constraint detection in pkg/diunwebhook/diunwebhook.go.

In the TagsHandler method, line 172, change:

if strings.Contains(err.Error(), "UNIQUE") {

to:

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 <acceptance_criteria> - 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/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 -count=1 ./pkg/diunwebhook/ exits 0 </acceptance_criteria> 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. 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:
# 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:

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: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 ./... && docker compose config --quiet 2>&1; echo "exit: $?" <acceptance_criteria> - 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 -count=1 ./pkg/diunwebhook/ exits 0 (SQLite tests still pass, postgres_test.go skipped) </acceptance_criteria> 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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/03-postgresql-support/03-02-SUMMARY.md`