--- phase: 03-postgresql-support plan: "01" subsystem: persistence tags: [postgresql, store, migration, pgx] dependency_graph: requires: [] provides: [PostgresStore, RunPostgresMigrations, RunSQLiteMigrations] affects: [pkg/diunwebhook/migrate.go, pkg/diunwebhook/postgres_store.go] tech_stack: added: [github.com/jackc/pgx/v5 v5.9.1, golang-migrate pgx/v5 driver] patterns: [Store interface implementation, golang-migrate embedded migrations, pgx/v5 stdlib adapter] key_files: created: - pkg/diunwebhook/postgres_store.go - pkg/diunwebhook/migrations/postgres/0001_initial_schema.up.sql - pkg/diunwebhook/migrations/postgres/0001_initial_schema.down.sql modified: - pkg/diunwebhook/migrate.go - pkg/diunwebhook/export_test.go - go.mod - go.sum decisions: - "PostgresStore uses *sql.DB via pgx/v5/stdlib adapter — no native pgx pool, consistent with SQLiteStore pattern" - "No mutex in PostgresStore — PostgreSQL handles concurrent writes natively (unlike SQLite)" - "Timestamps stored as TEXT in PostgreSQL schema — matches SQLite scan logic, avoids TIMESTAMPTZ type divergence" - "CreateTag uses RETURNING id instead of LastInsertId — pgx driver does not support LastInsertId" - "AssignTag uses ON CONFLICT (image) DO UPDATE instead of INSERT OR REPLACE — standard PostgreSQL upsert" - "Both migration runners compiled into same binary — no build tags needed (both drivers always present)" metrics: duration: "~2.5 minutes" completed: "2026-03-24T08:09:42Z" tasks_completed: 2 files_changed: 7 --- # Phase 03 Plan 01: PostgreSQL Store and Migration Infrastructure Summary PostgresStore implementing all 9 Store interface methods using pgx/v5 stdlib adapter, plus PostgreSQL migration infrastructure with RunPostgresMigrations and renamed RunSQLiteMigrations. ## What Was Built ### PostgresStore (pkg/diunwebhook/postgres_store.go) Full implementation of the Store interface for PostgreSQL: - `NewPostgresStore` constructor with connection pool: `MaxOpenConns(25)`, `MaxIdleConns(5)`, `ConnMaxLifetime(5m)` - All 9 methods: `UpsertEvent`, `GetUpdates`, `AcknowledgeUpdate`, `ListTags`, `CreateTag`, `DeleteTag`, `AssignTag`, `UnassignTag`, `TagExists` - PostgreSQL-native SQL: `$1..$15` positional params, `NOW()`, `RETURNING id`, `ON CONFLICT DO UPDATE` - No `sync.Mutex` — PostgreSQL handles concurrent writes natively ### PostgreSQL Migrations (pkg/diunwebhook/migrations/postgres/) - `0001_initial_schema.up.sql`: Creates same 3 tables as SQLite (`updates`, `tags`, `tag_assignments`); uses `SERIAL PRIMARY KEY` for `tags.id`; timestamps remain `TEXT` to match scan logic - `0001_initial_schema.down.sql`: Drops all 3 tables in dependency order ### Updated migrate.go - `RunMigrations` renamed to `RunSQLiteMigrations` - `RunPostgresMigrations` added using `pgxmigrate` driver with `"pgx5"` database name - Second `//go:embed migrations/postgres` directive added for `postgresMigrations` ## Decisions Made | Decision | Rationale | |----------|-----------| | TEXT timestamps in PostgreSQL schema | Avoids scan divergence with SQLiteStore; both stores parse RFC3339 strings identically | | RETURNING id in CreateTag | pgx driver does not implement `LastInsertId`; `RETURNING` is the PostgreSQL-idiomatic approach | | ON CONFLICT (image) DO UPDATE in AssignTag | Replaces SQLite's `INSERT OR REPLACE`; functionally equivalent upsert in standard SQL | | No mutex in PostgresStore | PostgreSQL connection pool + MVCC handles concurrency; mutex would serialize unnecessarily | | Both drivers compiled into binary | Simpler than build tags; binary size cost acceptable for a server binary | ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Updated export_test.go to use renamed function** - **Found during:** Task 1 verification - **Issue:** `go vet ./pkg/diunwebhook/` failed because `export_test.go` still referenced `RunMigrations` (renamed to `RunSQLiteMigrations`). The plan's acceptance criteria requires `go vet` to exit 0, which takes precedence over the instruction to defer export_test.go changes to Plan 02. - **Fix:** Updated both `NewTestServer` and `NewTestServerWithSecret` in `export_test.go` to call `RunSQLiteMigrations` - **Files modified:** `pkg/diunwebhook/export_test.go` - **Commit:** 95b64b4 ## Verification Results - `go build ./pkg/diunwebhook/` exits 0 - `go vet ./pkg/diunwebhook/` exits 0 - PostgreSQL migration UP contains `SERIAL PRIMARY KEY`, all 3 tables - PostgreSQL migration DOWN contains `DROP TABLE IF EXISTS` for all 3 tables - `go.mod` contains `github.com/jackc/pgx/v5 v5.9.1` - `migrate.go` exports both `RunSQLiteMigrations` and `RunPostgresMigrations` ## Known Stubs None — this plan creates implementation code, not UI stubs. ## Self-Check: PASSED