9.4 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-lab-advisor | 01 | execute | 1 |
|
true |
|
|
Purpose: ADV-03 requires conversation history to persist across browser sessions. SQLite is already used for other local state but PostgreSQL is the designated store here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env).
Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods, integration tests.
<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.mdModule path: git.georgsen.dk/hwlab
PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env):
postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby
pgx/v5 driver: github.com/jackc/pgx/v5
Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn
Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly
(pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3)
Task 1: Add pgx/v5 dep and create Store with RunMigrations internal/store/store.go, internal/store/migrations.go, go.mod, go.sum - NewStore("invalid-dsn") returns non-nil error - NewStore(validDSN) returns *Store with open pool, no error - Store.Close() does not panic - RunMigrations(ctx, pool) on empty DB creates conversations and messages tables - RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables) - conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT '' - messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() Run `go get github.com/jackc/pgx/v5` to add the dependency.Create internal/store/store.go:
- Package store
- Import pgxpool "github.com/jackc/pgx/v5/pgxpool"
- Store struct holds *pgxpool.Pool
- NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure
- Close() — calls pool.Close()
- Expose Pool() *pgxpool.Pool accessor for use by repository methods
Create internal/store/migrations.go:
- RunMigrations(ctx context.Context, pool *pgxpool.Pool) error
- Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec
- conversations first (messages references it)
- Return first non-nil error encountered
Write tests first (RED), confirm they fail, then implement (GREEN). cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod
Task 2: Conversation and message CRUD methods internal/store/conversations.go, internal/store/store_test.go - CreateConversation(ctx, model string) returns (conversationID string, err error) - AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error) - AddMessage with invalid role returns error (CHECK constraint violation from DB) - GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message - GetConversation on unknown ID returns nil, ErrNotFound (sentinel) - ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count) - Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order Create internal/store/conversations.go:Types:
- Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time }
- Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message }
- ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int }
- var ErrNotFound = errors.New("store: not found")
Methods on *Store:
- CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id
- AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id
- GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound
- ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC
Write tests in internal/store/store_test.go using build tag //go:build integration
so they run only with -tags integration. Tests connect to the real PostgreSQL.
Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1).
Write tests first (RED), confirm compile error or test failure, then implement (GREEN). cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| App → PostgreSQL | DSN from env; pool executes parameterized queries |
| Messages table | role column has CHECK constraint; content is free text |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL |
| T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file |
| T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error |
| T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard |
| </threat_model> |
<success_criteria>
- pgx/v5 added to go.mod
- internal/store package compiles with no errors
- RunMigrations creates both tables idempotently
- Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests
- ErrNotFound returned for unknown conversation ID </success_criteria>