363 lines
15 KiB
Markdown
363 lines
15 KiB
Markdown
---
|
|
phase: 02-backend-refactor
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- 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
|
|
- go.mod
|
|
- go.sum
|
|
autonomous: true
|
|
requirements: [REFAC-01, REFAC-03]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "A Store interface defines all 9 persistence operations with no SQL or *sql.DB in the contract"
|
|
- "SQLiteStore implements every Store method using raw SQL and a sync.Mutex"
|
|
- "RunMigrations applies embedded SQL files via golang-migrate and tolerates ErrNoChange"
|
|
- "Migration 0001 creates the full current schema including acknowledged_at using CREATE TABLE IF NOT EXISTS"
|
|
- "PRAGMA foreign_keys = ON is set in NewSQLiteStore before any queries"
|
|
artifacts:
|
|
- path: "pkg/diunwebhook/store.go"
|
|
provides: "Store interface with 9 methods"
|
|
exports: ["Store"]
|
|
- path: "pkg/diunwebhook/sqlite_store.go"
|
|
provides: "SQLiteStore struct implementing Store"
|
|
exports: ["SQLiteStore", "NewSQLiteStore"]
|
|
- path: "pkg/diunwebhook/migrate.go"
|
|
provides: "RunMigrations function using golang-migrate + embed.FS"
|
|
exports: ["RunMigrations"]
|
|
- path: "pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql"
|
|
provides: "Baseline schema DDL"
|
|
contains: "CREATE TABLE IF NOT EXISTS updates"
|
|
key_links:
|
|
- from: "pkg/diunwebhook/sqlite_store.go"
|
|
to: "pkg/diunwebhook/store.go"
|
|
via: "interface implementation"
|
|
pattern: "func \\(s \\*SQLiteStore\\)"
|
|
- from: "pkg/diunwebhook/migrate.go"
|
|
to: "pkg/diunwebhook/migrations/sqlite/"
|
|
via: "embed.FS"
|
|
pattern: "go:embed migrations/sqlite"
|
|
---
|
|
|
|
<objective>
|
|
Create the Store interface, SQLiteStore implementation, and golang-migrate migration infrastructure as new files alongside the existing code.
|
|
|
|
Purpose: Establish the persistence abstraction layer and migration system that Plan 02 will wire into the Server struct and handlers. These are additive-only changes -- nothing existing breaks.
|
|
Output: store.go, sqlite_store.go, migrate.go, migration SQL files, golang-migrate dependency installed.
|
|
</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/02-backend-refactor/02-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Current types from diunwebhook.go that Store interface methods must use -->
|
|
|
|
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"`
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create Store interface and SQLiteStore implementation</name>
|
|
<files>pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go</files>
|
|
<read_first>
|
|
- pkg/diunwebhook/diunwebhook.go (current SQL operations to extract)
|
|
- .planning/phases/02-backend-refactor/02-RESEARCH.md (Store interface design, SQL operations inventory)
|
|
</read_first>
|
|
<action>
|
|
**Install golang-migrate dependency first:**
|
|
```bash
|
|
cd /home/jean-luc-makiola/Development/projects/DiunDashboard
|
|
go get github.com/golang-migrate/migrate/v4@v4.19.1
|
|
go get github.com/golang-migrate/migrate/v4/database/sqlite
|
|
go get github.com/golang-migrate/migrate/v4/source/iofs
|
|
```
|
|
|
|
**Create `pkg/diunwebhook/store.go`** with exactly this interface (per REFAC-01):
|
|
```go
|
|
package diunwebhook
|
|
|
|
// Store defines all persistence operations. Implementations must be safe
|
|
// for concurrent use from HTTP handlers.
|
|
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)
|
|
}
|
|
```
|
|
|
|
**Create `pkg/diunwebhook/sqlite_store.go`** with `SQLiteStore` struct implementing all 9 Store methods:
|
|
|
|
```go
|
|
package diunwebhook
|
|
|
|
import (
|
|
"database/sql"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type SQLiteStore struct {
|
|
db *sql.DB
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
|
|
return &SQLiteStore{db: db}
|
|
}
|
|
```
|
|
|
|
Move all SQL from current handlers/functions into Store methods:
|
|
|
|
1. **UpsertEvent** -- move the INSERT...ON CONFLICT from current `UpdateEvent()` function. Keep exact same SQL including `ON CONFLICT(image) DO UPDATE SET` with all 14 columns and `acknowledged_at = NULL`. Use `time.Now().Format(time.RFC3339)` for received_at. Acquire `s.mu.Lock()`.
|
|
|
|
2. **GetUpdates** -- move the SELECT...LEFT JOIN from current `GetUpdates()` function. Exact same query: `SELECT u.image, u.diun_version, ...` with LEFT JOIN on tag_assignments and tags. Same row scanning logic with `sql.NullInt64`/`sql.NullString` for tag fields. No mutex needed (read-only).
|
|
|
|
3. **AcknowledgeUpdate** -- move SQL from `DismissHandler`: `UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`. Return `(found bool, err error)` where found = RowsAffected() > 0. Acquire `s.mu.Lock()`.
|
|
|
|
4. **ListTags** -- move SQL from `TagsHandler` GET case: `SELECT id, name FROM tags ORDER BY name`. Return `([]Tag, error)`. No mutex.
|
|
|
|
5. **CreateTag** -- move SQL from `TagsHandler` POST case: `INSERT INTO tags (name) VALUES (?)`. Return `(Tag{ID: int(lastInsertId), Name: name}, error)`. Acquire `s.mu.Lock()`.
|
|
|
|
6. **DeleteTag** -- move SQL from `TagByIDHandler`: `DELETE FROM tags WHERE id = ?`. Return `(found bool, err error)` where found = RowsAffected() > 0. Acquire `s.mu.Lock()`.
|
|
|
|
7. **AssignTag** -- move SQL from `TagAssignmentHandler` PUT case: `INSERT OR REPLACE INTO tag_assignments (image, tag_id) VALUES (?, ?)`. Keep `INSERT OR REPLACE` (correct for SQLite, per research Pitfall 6). Acquire `s.mu.Lock()`.
|
|
|
|
8. **UnassignTag** -- move SQL from `TagAssignmentHandler` DELETE case: `DELETE FROM tag_assignments WHERE image = ?`. Acquire `s.mu.Lock()`.
|
|
|
|
9. **TagExists** -- move SQL from `TagAssignmentHandler` PUT check: `SELECT COUNT(*) FROM tags WHERE id = ?`. Return `(bool, error)` where bool = count > 0. No mutex (read-only).
|
|
|
|
**CRITICAL:** `NewSQLiteStore` must run `PRAGMA foreign_keys = ON` on the db connection and `db.SetMaxOpenConns(1)` -- these currently live in `InitDB` and must NOT be lost. Specifically:
|
|
```go
|
|
func NewSQLiteStore(db *sql.DB) *SQLiteStore {
|
|
db.SetMaxOpenConns(1)
|
|
// PRAGMA foreign_keys must be set per-connection; with MaxOpenConns(1) this covers all queries
|
|
db.Exec("PRAGMA foreign_keys = ON")
|
|
return &SQLiteStore{db: db}
|
|
}
|
|
```
|
|
|
|
**rows.Close() pattern:** Use `defer rows.Close()` directly (not the verbose closure pattern from the current code). The error from Close() is safe to ignore in read paths.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && echo "BUILD OK"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- pkg/diunwebhook/store.go contains `type Store interface {`
|
|
- pkg/diunwebhook/store.go contains exactly these 9 method signatures: UpsertEvent, GetUpdates, AcknowledgeUpdate, ListTags, CreateTag, DeleteTag, AssignTag, UnassignTag, TagExists
|
|
- pkg/diunwebhook/sqlite_store.go contains `type SQLiteStore struct {`
|
|
- pkg/diunwebhook/sqlite_store.go contains `func NewSQLiteStore(db *sql.DB) *SQLiteStore`
|
|
- pkg/diunwebhook/sqlite_store.go contains `db.SetMaxOpenConns(1)`
|
|
- pkg/diunwebhook/sqlite_store.go contains `PRAGMA foreign_keys = ON`
|
|
- pkg/diunwebhook/sqlite_store.go contains `func (s *SQLiteStore) UpsertEvent(event DiunEvent) error`
|
|
- pkg/diunwebhook/sqlite_store.go contains `s.mu.Lock()` (mutex usage in write methods)
|
|
- pkg/diunwebhook/sqlite_store.go contains `INSERT OR REPLACE INTO tag_assignments` (not ON CONFLICT for this table)
|
|
- pkg/diunwebhook/sqlite_store.go contains `ON CONFLICT(image) DO UPDATE SET` (UPSERT for updates table)
|
|
- `go build ./pkg/diunwebhook/` exits 0
|
|
</acceptance_criteria>
|
|
<done>Store interface defines 9 methods; SQLiteStore implements all 9 with exact SQL from current handlers; package compiles with no errors</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create migration infrastructure and SQL files</name>
|
|
<files>pkg/diunwebhook/migrate.go, pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql, pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql</files>
|
|
<read_first>
|
|
- pkg/diunwebhook/diunwebhook.go (current DDL in InitDB to extract)
|
|
- .planning/phases/02-backend-refactor/02-RESEARCH.md (RunMigrations pattern, migration file design, Pitfall 2 and 4)
|
|
</read_first>
|
|
<action>
|
|
**Create migration SQL files:**
|
|
|
|
Create directory `pkg/diunwebhook/migrations/sqlite/`.
|
|
|
|
**`0001_initial_schema.up.sql`** -- Full current schema as a single baseline migration. Use `CREATE TABLE IF NOT EXISTS` for backward compatibility with existing databases (per research recommendation):
|
|
|
|
```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 INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
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
|
|
);
|
|
```
|
|
|
|
**`0001_initial_schema.down.sql`** -- Reverse of up migration:
|
|
|
|
```sql
|
|
DROP TABLE IF EXISTS tag_assignments;
|
|
DROP TABLE IF EXISTS tags;
|
|
DROP TABLE IF EXISTS updates;
|
|
```
|
|
|
|
**Create `pkg/diunwebhook/migrate.go`:**
|
|
|
|
```go
|
|
package diunwebhook
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"errors"
|
|
|
|
"github.com/golang-migrate/migrate/v4"
|
|
sqlitemigrate "github.com/golang-migrate/migrate/v4/database/sqlite"
|
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
//go:embed migrations/sqlite
|
|
var sqliteMigrations embed.FS
|
|
|
|
// RunMigrations applies all pending schema migrations to the given SQLite database.
|
|
// Returns nil if all migrations applied successfully or if database is already up to date.
|
|
func RunMigrations(db *sql.DB) error {
|
|
src, err := iofs.New(sqliteMigrations, "migrations/sqlite")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
driver, err := sqlitemigrate.WithInstance(db, &sqlitemigrate.Config{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
```
|
|
|
|
**CRITICAL imports:**
|
|
- Use `database/sqlite` (NOT `database/sqlite3`) -- the sqlite3 variant requires CGO which is forbidden
|
|
- Import alias `sqlitemigrate` for `github.com/golang-migrate/migrate/v4/database/sqlite` to avoid collision with the blank import of `modernc.org/sqlite`
|
|
- The `_ "modernc.org/sqlite"` blank import must be present so the "sqlite" driver is registered for `sql.Open`
|
|
|
|
**After creating files, run:**
|
|
```bash
|
|
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go mod tidy
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./pkg/diunwebhook/ && go vet ./pkg/diunwebhook/ && echo "BUILD+VET OK"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- pkg/diunwebhook/migrate.go contains `//go:embed migrations/sqlite`
|
|
- pkg/diunwebhook/migrate.go contains `func RunMigrations(db *sql.DB) error`
|
|
- pkg/diunwebhook/migrate.go contains `!errors.Is(err, migrate.ErrNoChange)` (Pitfall 2 guard)
|
|
- pkg/diunwebhook/migrate.go contains `database/sqlite` import (NOT `database/sqlite3`)
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS updates`
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tags`
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `CREATE TABLE IF NOT EXISTS tag_assignments`
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `acknowledged_at TEXT` (included in baseline, not a separate migration)
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.up.sql contains `ON DELETE CASCADE`
|
|
- pkg/diunwebhook/migrations/sqlite/0001_initial_schema.down.sql contains `DROP TABLE IF EXISTS`
|
|
- `go build ./pkg/diunwebhook/` exits 0
|
|
- `go vet ./pkg/diunwebhook/` exits 0
|
|
- go.mod contains `github.com/golang-migrate/migrate/v4`
|
|
</acceptance_criteria>
|
|
<done>Migration files exist with full current schema as baseline; RunMigrations function compiles and handles ErrNoChange; golang-migrate v4.19.1 in go.mod; go vet passes</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `go build ./pkg/diunwebhook/` compiles without errors (new files coexist with existing code)
|
|
- `go vet ./pkg/diunwebhook/` reports no issues
|
|
- `go test ./pkg/diunwebhook/` still passes (existing tests unchanged, new files are additive only)
|
|
- go.mod contains golang-migrate v4 dependency
|
|
- No CGO: `go mod graph | grep sqlite3` returns empty (no mattn/go-sqlite3 pulled in)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Store interface with 9 methods exists in store.go
|
|
- SQLiteStore implements all 9 methods in sqlite_store.go with exact SQL semantics from current handlers
|
|
- NewSQLiteStore sets PRAGMA foreign_keys = ON and MaxOpenConns(1)
|
|
- RunMigrations in migrate.go uses golang-migrate + embed.FS + iofs, handles ErrNoChange
|
|
- Migration 0001 contains full current schema with CREATE TABLE IF NOT EXISTS
|
|
- All existing tests still pass (no existing code modified)
|
|
- No CGO dependency introduced
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-backend-refactor/02-01-SUMMARY.md`
|
|
</output>
|