docs(03-postgresql-support): create phase plan

This commit is contained in:
2026-03-24 08:59:02 +01:00
parent 535061453b
commit e8e0731adc
3 changed files with 834 additions and 3 deletions

View File

@@ -58,8 +58,11 @@ Plans:
2. A fresh PostgreSQL deployment receives all schema tables via automatic migration on startup 2. A fresh PostgreSQL deployment receives all schema tables via automatic migration on startup
3. An existing SQLite user can upgrade to the new binary without any data loss or manual schema changes 3. An existing SQLite user can upgrade to the new binary without any data loss or manual schema changes
4. The app can be run with Docker Compose using an optional postgres service profile 4. The app can be run with Docker Compose using an optional postgres service profile
**Plans**: TBD **Plans**: 2 plans
**UI hint**: no
Plans:
- [ ] 03-01-PLAN.md — Create PostgresStore (9 Store methods), PostgreSQL migration files, rename RunMigrations to RunSQLiteMigrations, add RunPostgresMigrations
- [ ] 03-02-PLAN.md — Wire DATABASE_URL branching in main.go, fix cross-dialect UNIQUE detection, add Docker Compose postgres profiles, create build-tagged test helper
### Phase 4: UX Improvements ### Phase 4: UX Improvements
**Goal**: Users can manage a large list of updates efficiently — dismissing many at once, finding specific images quickly, and seeing new arrivals without manual refreshes **Goal**: Users can manage a large list of updates efficiently — dismissing many at once, finding specific images quickly, and seeing new arrivals without manual refreshes
@@ -84,5 +87,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Data Integrity | 0/2 | Not started | - | | 1. Data Integrity | 0/2 | Not started | - |
| 2. Backend Refactor | 2/2 | Complete | 2026-03-24 | | 2. Backend Refactor | 2/2 | Complete | 2026-03-24 |
| 3. PostgreSQL Support | 0/? | Not started | - | | 3. PostgreSQL Support | 0/2 | Not started | - |
| 4. UX Improvements | 0/? | Not started | - | | 4. UX Improvements | 0/? | Not started | - |

View File

@@ -0,0 +1,426 @@
---
phase: 03-postgresql-support
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pkg/diunwebhook/postgres_store.go
- pkg/diunwebhook/migrate.go
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql
- go.mod
- go.sum
autonomous: true
requirements: [DB-01, DB-03]
must_haves:
truths:
- "PostgresStore implements all 9 Store interface methods with PostgreSQL SQL syntax"
- "PostgreSQL baseline migration creates the same 3 tables as SQLite (updates, tags, tag_assignments)"
- "RunMigrations is renamed to RunSQLiteMigrations for symmetry; RunPostgresMigrations exists for PostgreSQL"
- "Existing SQLite migration path is unchanged (backward compatible)"
artifacts:
- path: "pkg/diunwebhook/postgres_store.go"
provides: "PostgresStore struct implementing Store interface"
exports: ["PostgresStore", "NewPostgresStore"]
- path: "pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql"
provides: "PostgreSQL baseline schema"
contains: "CREATE TABLE IF NOT EXISTS updates"
- path: "pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql"
provides: "PostgreSQL rollback"
contains: "DROP TABLE IF EXISTS"
- path: "pkg/diunwebhook/migrate.go"
provides: "RunSQLiteMigrations and RunPostgresMigrations functions"
exports: ["RunSQLiteMigrations", "RunPostgresMigrations"]
key_links:
- from: "pkg/diunwebhook/postgres_store.go"
to: "pkg/diunwebhook/store.go"
via: "implements Store interface"
pattern: "func \\(s \\*PostgresStore\\)"
- from: "pkg/diunwebhook/migrate.go"
to: "pkg/diunwebhook/migrations/postgres/"
via: "go:embed directive"
pattern: "go:embed migrations/postgres"
---
<objective>
Create the PostgresStore implementation and PostgreSQL migration infrastructure.
Purpose: Delivers the core persistence layer for PostgreSQL — all 9 Store methods ported from SQLiteStore with PostgreSQL-native SQL, plus the migration runner and baseline schema. This is the foundation that Plan 02 wires into main.go.
Output: postgres_store.go, PostgreSQL migration files, updated migrate.go with both RunSQLiteMigrations and RunPostgresMigrations.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
@pkg/diunwebhook/store.go
@pkg/diunwebhook/sqlite_store.go
@pkg/diunwebhook/migrate.go
@pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql
@pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql
<interfaces>
<!-- Store interface that PostgresStore must implement -->
From pkg/diunwebhook/store.go:
```go
type Store interface {
UpsertEvent(event DiunEvent) error
GetUpdates() (map[string]UpdateEntry, error)
AcknowledgeUpdate(image string) (found bool, err error)
ListTags() ([]Tag, error)
CreateTag(name string) (Tag, error)
DeleteTag(id int) (found bool, err error)
AssignTag(image string, tagID int) error
UnassignTag(image string) error
TagExists(id int) (bool, error)
}
```
From pkg/diunwebhook/diunwebhook.go:
```go
type DiunEvent struct {
DiunVersion string `json:"diun_version"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Provider string `json:"provider"`
Image string `json:"image"`
HubLink string `json:"hub_link"`
MimeType string `json:"mime_type"`
Digest string `json:"digest"`
Created time.Time `json:"created"`
Platform string `json:"platform"`
Metadata struct {
ContainerName string `json:"ctn_names"`
ContainerID string `json:"ctn_id"`
State string `json:"ctn_state"`
Status string `json:"ctn_status"`
} `json:"metadata"`
}
type Tag struct {
ID int `json:"id"`
Name string `json:"name"`
}
type UpdateEntry struct {
Event DiunEvent `json:"event"`
ReceivedAt time.Time `json:"received_at"`
Acknowledged bool `json:"acknowledged"`
Tag *Tag `json:"tag"`
}
```
From pkg/diunwebhook/migrate.go:
```go
//go:embed migrations/sqlite
var sqliteMigrations embed.FS
func RunMigrations(db *sql.DB) error { ... }
```
Import alias: `sqlitemigrate "github.com/golang-migrate/migrate/v4/database/sqlite"`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add pgx dependency, create PostgreSQL migrations, update migrate.go</name>
<read_first>
- pkg/diunwebhook/migrate.go (current RunMigrations implementation to rename)
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql (schema to translate)
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql (down migration to copy)
- pkg/diunwebhook/export_test.go (calls RunMigrations - must update call site)
- cmd/diunwebhook/main.go (calls RunMigrations - must update call site)
- go.mod (current dependencies)
</read_first>
<files>
pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql,
pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql,
pkg/diunwebhook/migrate.go,
pkg/diunwebhook/export_test.go,
cmd/diunwebhook/main.go,
go.mod,
go.sum
</files>
<action>
1. Install dependencies:
```
go get github.com/jackc/pgx/v5@v5.9.1
go get github.com/golang-migrate/migrate/v4/database/pgx/v5
```
2. Create `pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql` with this exact content:
```sql
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 difference from SQLite: `SERIAL PRIMARY KEY` replaces `INTEGER PRIMARY KEY AUTOINCREMENT` for tags.id. All timestamp columns use TEXT (not TIMESTAMPTZ) to match SQLite scan logic per Pitfall 6 in RESEARCH.md.
3. Create `pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql` with this exact content:
```sql
DROP TABLE IF EXISTS tag_assignments;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS updates;
```
4. Rewrite `pkg/diunwebhook/migrate.go`:
- Rename `RunMigrations` to `RunSQLiteMigrations` (per RESEARCH.md recommendation)
- Add a second `//go:embed migrations/postgres` directive for `var postgresMigrations embed.FS`
- Add `RunPostgresMigrations(db *sql.DB) error` using `pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"` as the database driver
- The pgx migrate driver name string for `migrate.NewWithInstance` is `"pgx5"` (NOT "pgx" or "postgres" -- this is the registration name used by golang-migrate's pgx/v5 sub-package)
- Keep both functions in the same file (both drivers compile into the binary regardless per Pitfall 4 in RESEARCH.md)
- Full imports for the updated file:
```go
import (
"database/sql"
"embed"
"errors"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
sqlitemigrate "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
)
```
- RunPostgresMigrations body follows the exact same pattern as RunSQLiteMigrations but uses `postgresMigrations`, `"migrations/postgres"`, `pgxmigrate.WithInstance`, and `"pgx5"` as the database name
5. Update the two call sites that reference `RunMigrations`:
- `cmd/diunwebhook/main.go` line 29: change `diun.RunMigrations(db)` to `diun.RunSQLiteMigrations(db)`
- `pkg/diunwebhook/export_test.go` line 12 and line 24: change `RunMigrations(db)` to `RunSQLiteMigrations(db)`
6. Run `go mod tidy` to clean up go.sum.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && go test -v -count=1 ./pkg/diunwebhook/ -run TestWebhookHandler 2>&1 | head -30</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql contains `SERIAL PRIMARY KEY`
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS updates`
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tags`
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tag_assignments`
- pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql contains `DROP TABLE IF EXISTS`
- pkg/diunwebhook/migrate.go contains `func RunSQLiteMigrations(db *sql.DB) error`
- pkg/diunwebhook/migrate.go contains `func RunPostgresMigrations(db *sql.DB) error`
- pkg/diunwebhook/migrate.go contains `//go:embed migrations/postgres`
- pkg/diunwebhook/migrate.go contains `pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"`
- pkg/diunwebhook/migrate.go contains `"pgx5"` (driver name in NewWithInstance call)
- cmd/diunwebhook/main.go contains `RunSQLiteMigrations` (not RunMigrations)
- pkg/diunwebhook/export_test.go contains `RunSQLiteMigrations` (not RunMigrations)
- go.mod contains `github.com/jackc/pgx/v5`
- `go build ./...` exits 0
- `go test -count=1 ./pkg/diunwebhook/` exits 0 (all existing SQLite tests still pass)
</acceptance_criteria>
<done>PostgreSQL migration files exist with correct dialect. RunMigrations renamed to RunSQLiteMigrations. RunPostgresMigrations added. pgx/v5 dependency in go.mod. All existing tests pass unchanged.</done>
</task>
<task type="auto">
<name>Task 2: Create PostgresStore implementing all 9 Store methods</name>
<read_first>
- pkg/diunwebhook/store.go (interface contract to implement)
- pkg/diunwebhook/sqlite_store.go (reference implementation to port)
- pkg/diunwebhook/diunwebhook.go (DiunEvent, Tag, UpdateEntry type definitions)
</read_first>
<files>pkg/diunwebhook/postgres_store.go</files>
<action>
Create `pkg/diunwebhook/postgres_store.go` implementing all 9 Store interface methods.
Per D-01, D-02: Use `*sql.DB` (from `pgx/v5/stdlib`), not pgx native interface.
Per D-05: NO mutex -- PostgreSQL handles concurrent writes natively.
Per D-06: Pool config in constructor: `MaxOpenConns(25)`, `MaxIdleConns(5)`, `ConnMaxLifetime(5 * time.Minute)`.
Per D-03: Own raw SQL, no shared templates with SQLiteStore.
**Struct and constructor:**
```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}
}
```
**Method-by-method port from SQLiteStore with these dialect changes:**
1. **UpsertEvent** -- Replace `?` with `$1..$15`, same ON CONFLICT pattern:
```go
func (s *PostgresStore) UpsertEvent(event DiunEvent) error {
_, 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, event.Hostname, event.Status, event.Provider,
event.HubLink, event.MimeType, event.Digest,
event.Created.Format(time.RFC3339), event.Platform,
event.Metadata.ContainerName, event.Metadata.ContainerID,
event.Metadata.State, event.Metadata.Status,
time.Now().Format(time.RFC3339),
)
return err
}
```
2. **GetUpdates** -- Identical SQL to SQLiteStore (the SELECT query, JOINs, and COALESCE work in both dialects). Copy the full method body from sqlite_store.go verbatim -- the scan logic, time.Parse, and result building are all the same since timestamps are TEXT columns.
3. **AcknowledgeUpdate** -- Replace `datetime('now')` with `NOW()`, `?` with `$1`:
```go
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE image = $1`, image)
```
Return logic identical to SQLiteStore (check RowsAffected).
4. **ListTags** -- Identical SQL (`SELECT id, name FROM tags ORDER BY name`). Copy verbatim from SQLiteStore.
5. **CreateTag** -- CRITICAL: Do NOT use `Exec` + `LastInsertId` (pgx does not support LastInsertId). Use `QueryRow` with `RETURNING id`:
```go
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
}
```
6. **DeleteTag** -- Replace `?` with `$1`:
```go
res, err := s.db.Exec(`DELETE FROM tags WHERE id = $1`, id)
```
Return logic identical (check RowsAffected).
7. **AssignTag** -- Replace `INSERT OR REPLACE` with `INSERT ... ON CONFLICT DO UPDATE`:
```go
_, 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,
)
```
8. **UnassignTag** -- Replace `?` with `$1`:
```go
_, err := s.db.Exec(`DELETE FROM tag_assignments WHERE image = $1`, image)
```
9. **TagExists** -- Replace `?` with `$1`:
```go
err := s.db.QueryRow(`SELECT COUNT(*) FROM tags WHERE id = $1`, id).Scan(&count)
```
**IMPORTANT: No mutex.Lock/Unlock anywhere in PostgresStore** (per D-05). No `sync.Mutex` field in the struct.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && go vet ./pkg/diunwebhook/</automated>
</verify>
<acceptance_criteria>
- pkg/diunwebhook/postgres_store.go contains `type PostgresStore struct`
- pkg/diunwebhook/postgres_store.go contains `func NewPostgresStore(db *sql.DB) *PostgresStore`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) UpsertEvent(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) GetUpdates(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) AcknowledgeUpdate(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) ListTags(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) CreateTag(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) DeleteTag(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) AssignTag(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) UnassignTag(`
- pkg/diunwebhook/postgres_store.go contains `func (s *PostgresStore) TagExists(`
- pkg/diunwebhook/postgres_store.go contains `RETURNING id` (CreateTag uses QueryRow, not LastInsertId)
- pkg/diunwebhook/postgres_store.go contains `ON CONFLICT (image) DO UPDATE SET tag_id = EXCLUDED.tag_id` (AssignTag)
- pkg/diunwebhook/postgres_store.go contains `NOW()` (AcknowledgeUpdate)
- pkg/diunwebhook/postgres_store.go contains `SetMaxOpenConns(25)` (constructor pool config)
- pkg/diunwebhook/postgres_store.go does NOT contain `sync.Mutex` (no mutex for PostgreSQL)
- pkg/diunwebhook/postgres_store.go does NOT contain `mu.Lock` (no mutex)
- `go build ./...` exits 0
- `go vet ./pkg/diunwebhook/` exits 0
</acceptance_criteria>
<done>PostgresStore implements all 9 Store interface methods with PostgreSQL-native SQL. No mutex. Pool settings configured. CreateTag uses RETURNING id. AssignTag uses ON CONFLICT DO UPDATE. Code compiles and passes vet.</done>
</task>
</tasks>
<verification>
1. `go build ./...` succeeds (both stores compile, migrate.go compiles with both drivers)
2. `go test -v -count=1 ./pkg/diunwebhook/` passes (all existing SQLite tests unchanged)
3. `go vet ./pkg/diunwebhook/` clean
4. PostgresStore has all 9 methods matching Store interface (compiler enforces this)
5. Migration files exist in both `migrations/sqlite/` and `migrations/postgres/`
</verification>
<success_criteria>
- PostgresStore compiles and implements Store interface (go build succeeds)
- All existing SQLite tests pass (RunMigrations rename did not break anything)
- PostgreSQL migration creates identical table structure to SQLite (3 tables: updates, tags, tag_assignments)
- pgx/v5 is in go.mod as a direct dependency
</success_criteria>
<output>
After completion, create `.planning/phases/03-postgresql-support/03-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,402 @@
---
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"
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"'
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From Plan 01 outputs -->
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
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire DATABASE_URL branching in main.go and fix cross-dialect UNIQUE detection</name>
<read_first>
- 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)
</read_first>
<files>cmd/diunwebhook/main.go, pkg/diunwebhook/diunwebhook.go</files>
<action>
**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:
```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 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:
```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.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -5</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Add Docker Compose postgres profiles and build-tagged test helper</name>
<read_first>
- 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)
</read_first>
<files>compose.yml, compose.dev.yml, pkg/diunwebhook/postgres_test.go</files>
<action>
**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.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... && docker compose config --quiet 2>&1; echo "exit: $?"</automated>
</verify>
<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>
<done>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).</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/03-postgresql-support/03-02-SUMMARY.md`
</output>