docs(03-postgresql-support): create phase plan
This commit is contained in:
426
.planning/phases/03-postgresql-support/03-01-PLAN.md
Normal file
426
.planning/phases/03-postgresql-support/03-01-PLAN.md
Normal 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>
|
||||
402
.planning/phases/03-postgresql-support/03-02-PLAN.md
Normal file
402
.planning/phases/03-postgresql-support/03-02-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user