Files
DiunDashboard/.planning/phases/02-backend-refactor/02-02-PLAN.md

25 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-backend-refactor 02 execute 2
02-01
pkg/diunwebhook/diunwebhook.go
pkg/diunwebhook/export_test.go
pkg/diunwebhook/diunwebhook_test.go
cmd/diunwebhook/main.go
true
REFAC-01
REFAC-02
REFAC-03
truths artifacts key_links
All 33 existing tests pass with zero behavior change after the refactor
HTTP handlers contain no SQL -- all persistence goes through Store method calls
Package-level globals db, mu, and webhookSecret no longer exist
main.go constructs SQLiteStore, runs migrations, builds Server, and registers routes
Each test gets its own in-memory database via NewTestServer (no shared global state)
path provides exports
pkg/diunwebhook/diunwebhook.go Server struct with handler methods, types, maxBodyBytes constant
Server
NewServer
DiunEvent
UpdateEntry
Tag
path provides exports
pkg/diunwebhook/export_test.go NewTestServer helper for tests
NewTestServer
path provides
cmd/diunwebhook/main.go Wiring: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration
from to via pattern
pkg/diunwebhook/diunwebhook.go pkg/diunwebhook/store.go Server.store field of type Store s.store.
from to via pattern
cmd/diunwebhook/main.go pkg/diunwebhook/sqlite_store.go diun.NewSQLiteStore(db) NewSQLiteStore
from to via pattern
cmd/diunwebhook/main.go pkg/diunwebhook/migrate.go diun.RunMigrations(db) RunMigrations
from to via pattern
pkg/diunwebhook/diunwebhook_test.go pkg/diunwebhook/export_test.go diun.NewTestServer() NewTestServer
Convert all handlers from package-level functions to Server struct methods, remove global state, rewrite tests to use per-test in-memory databases, and update main.go to wire everything together.

Purpose: Complete the refactor so handlers use the Store interface (no SQL in handlers), globals are eliminated, and each test is isolated with its own database. This is the "big flip" that makes the codebase ready for PostgreSQL support. Output: Refactored diunwebhook.go, rewritten export_test.go + test file, updated main.go. All existing tests pass.

<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.md @.planning/phases/02-backend-refactor/02-RESEARCH.md @.planning/phases/02-backend-refactor/02-01-SUMMARY.md

From pkg/diunwebhook/store.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/sqlite_store.go:

type SQLiteStore struct { db *sql.DB; mu sync.Mutex }
func NewSQLiteStore(db *sql.DB) *SQLiteStore

From pkg/diunwebhook/migrate.go:

func RunMigrations(db *sql.DB) error
Task 1: Convert diunwebhook.go to Server struct and update main.go pkg/diunwebhook/diunwebhook.go, cmd/diunwebhook/main.go - pkg/diunwebhook/diunwebhook.go (full current file -- handlers to convert) - pkg/diunwebhook/store.go (Store interface from Plan 01) - pkg/diunwebhook/sqlite_store.go (SQLiteStore from Plan 01) - pkg/diunwebhook/migrate.go (RunMigrations from Plan 01) - cmd/diunwebhook/main.go (current wiring to replace) - .planning/phases/02-backend-refactor/02-RESEARCH.md (Server struct pattern, handler method pattern) **Refactor `pkg/diunwebhook/diunwebhook.go`:**
  1. Remove all package-level globals -- delete these 3 lines entirely:

    var (
        mu            sync.Mutex
        db            *sql.DB
        webhookSecret string
    )
    
  2. Remove SetWebhookSecret function -- delete entirely (replaced by NewServer constructor).

  3. Remove InitDB function -- delete entirely (replaced by RunMigrations + NewSQLiteStore in main.go).

  4. Remove UpdateEvent function -- delete entirely (moved to SQLiteStore.UpsertEvent in sqlite_store.go).

  5. Remove GetUpdates function -- delete entirely (moved to SQLiteStore.GetUpdates in sqlite_store.go).

  6. Add Server struct and constructor:

    type Server struct {
        store         Store
        webhookSecret string
    }
    
    func NewServer(store Store, webhookSecret string) *Server {
        return &Server{store: store, webhookSecret: webhookSecret}
    }
    
  7. Convert all 6 handler functions to methods on *Server:

    • func WebhookHandler(w, r) becomes func (s *Server) WebhookHandler(w, r)
    • func UpdatesHandler(w, r) becomes func (s *Server) UpdatesHandler(w, r)
    • func DismissHandler(w, r) becomes func (s *Server) DismissHandler(w, r)
    • func TagsHandler(w, r) becomes func (s *Server) TagsHandler(w, r)
    • func TagByIDHandler(w, r) becomes func (s *Server) TagByIDHandler(w, r)
    • func TagAssignmentHandler(w, r) becomes func (s *Server) TagAssignmentHandler(w, r)
  8. Replace all inline SQL in handlers with Store method calls:

    In WebhookHandler: replace UpdateEvent(event) with s.store.UpsertEvent(event). Keep all auth checks, method checks, MaxBytesReader, and JSON decode logic. Keep exact same error messages and status codes.

    In UpdatesHandler: replace GetUpdates() with s.store.GetUpdates(). Keep JSON encoding logic.

    In DismissHandler: replace the mu.Lock(); db.Exec(UPDATE...); mu.Unlock() block with:

    found, err := s.store.AcknowledgeUpdate(image)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if !found {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    

    In TagsHandler GET case: replace db.Query(SELECT...) block with:

    tags, err := s.store.ListTags()
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(tags)
    

    In TagsHandler POST case: replace mu.Lock(); db.Exec(INSERT...) block with:

    tag, err := s.store.CreateTag(req.Name)
    if err != nil {
        if strings.Contains(err.Error(), "UNIQUE") {
            http.Error(w, "conflict: tag name already exists", http.StatusConflict)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(tag)
    

    In TagByIDHandler: replace mu.Lock(); db.Exec(DELETE...) block with:

    found, err := s.store.DeleteTag(id)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    if !found {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    

    In TagAssignmentHandler PUT case: replace tag-exists check + INSERT with:

    exists, err := s.store.TagExists(req.TagID)
    if err != nil || !exists {
        http.Error(w, "not found: tag does not exist", http.StatusNotFound)
        return
    }
    if err := s.store.AssignTag(req.Image, req.TagID); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    

    In TagAssignmentHandler DELETE case: replace mu.Lock(); db.Exec(DELETE...) with:

    if err := s.store.UnassignTag(req.Image); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    
  9. Keep in diunwebhook.go: The 3 type definitions (DiunEvent, Tag, UpdateEntry), the maxBodyBytes constant. Remove imports that are no longer needed (database/sql, sync, time if unused). Add time back only if still needed. The crypto/subtle import stays for webhook auth.

  10. Update diunwebhook.go imports -- remove: database/sql, sync, time (if no longer used after removing UpdateEvent/GetUpdates). Keep: crypto/subtle, encoding/json, errors, log, net/http, strconv, strings. Remove the blank import _ "modernc.org/sqlite" (it moves to migrate.go or sqlite_store.go).

Update cmd/diunwebhook/main.go:

Replace the current InitDB + SetWebhookSecret + package-level handler registration with:

package main

import (
    "context"
    "database/sql"
    "errors"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    diun "awesomeProject/pkg/diunwebhook"
    _ "modernc.org/sqlite"
)

func main() {
    dbPath := os.Getenv("DB_PATH")
    if dbPath == "" {
        dbPath = "./diun.db"
    }

    db, err := sql.Open("sqlite", dbPath)
    if err != nil {
        log.Fatalf("sql.Open: %v", err)
    }

    if err := diun.RunMigrations(db); err != nil {
        log.Fatalf("RunMigrations: %v", err)
    }

    store := diun.NewSQLiteStore(db)

    secret := os.Getenv("WEBHOOK_SECRET")
    if secret == "" {
        log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
    } else {
        log.Println("Webhook endpoint protected with token authentication")
    }

    srv := diun.NewServer(store, secret)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/webhook", srv.WebhookHandler)
    mux.HandleFunc("/api/updates/", srv.DismissHandler)
    mux.HandleFunc("/api/updates", srv.UpdatesHandler)
    mux.HandleFunc("/api/tags", srv.TagsHandler)
    mux.HandleFunc("/api/tags/", srv.TagByIDHandler)
    mux.HandleFunc("/api/tag-assignments", srv.TagAssignmentHandler)
    mux.Handle("/", http.FileServer(http.Dir("./frontend/dist")))

    httpSrv := &http.Server{
        Addr:         ":" + port,
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        log.Printf("Listening on :%s", port)
        if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("ListenAndServe: %v", err)
        }
    }()

    <-stop

    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    if err := httpSrv.Shutdown(ctx); err != nil {
        log.Printf("Shutdown error: %v", err)
    } else {
        log.Println("Server stopped cleanly")
    }
}

Key changes in main.go:

  • sql.Open called directly (not via InitDB)
  • diun.RunMigrations(db) called before store creation
  • diun.NewSQLiteStore(db) creates the store (sets PRAGMA, MaxOpenConns internally)
  • diun.NewServer(store, secret) creates the server
  • Route registration uses srv.WebhookHandler (method) instead of diun.WebhookHandler (package function)
  • _ "modernc.org/sqlite" blank import is in main.go (driver registration) cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./cmd/diunwebhook/ && go build ./pkg/diunwebhook/ && go vet ./... && echo "BUILD+VET OK" <acceptance_criteria>
    • pkg/diunwebhook/diunwebhook.go contains type Server struct {
    • pkg/diunwebhook/diunwebhook.go contains func NewServer(store Store, webhookSecret string) *Server
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) WebhookHandler(
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) UpdatesHandler(
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) DismissHandler(
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) TagsHandler(
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) TagByIDHandler(
    • pkg/diunwebhook/diunwebhook.go contains func (s *Server) TagAssignmentHandler(
    • pkg/diunwebhook/diunwebhook.go contains s.store.UpsertEvent (handler calls store, not direct SQL)
    • pkg/diunwebhook/diunwebhook.go does NOT contain var db *sql.DB (global removed)
    • pkg/diunwebhook/diunwebhook.go does NOT contain var mu sync.Mutex (global removed)
    • pkg/diunwebhook/diunwebhook.go does NOT contain var webhookSecret string (global removed)
    • pkg/diunwebhook/diunwebhook.go does NOT contain func InitDB( (removed)
    • pkg/diunwebhook/diunwebhook.go does NOT contain func SetWebhookSecret( (removed)
    • pkg/diunwebhook/diunwebhook.go does NOT contain db.Exec( or db.Query( (no SQL in handlers)
    • cmd/diunwebhook/main.go contains diun.RunMigrations(db)
    • cmd/diunwebhook/main.go contains diun.NewSQLiteStore(db)
    • cmd/diunwebhook/main.go contains diun.NewServer(store, secret)
    • cmd/diunwebhook/main.go contains srv.WebhookHandler (method reference, not package function)
    • go build ./cmd/diunwebhook/ exits 0
    • go vet ./... exits 0 </acceptance_criteria> Handlers are methods on Server calling s.store.X(); no package-level globals remain; main.go wires sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> routes; both packages compile and pass go vet
Task 2: Rewrite export_test.go and update all tests for Server/Store pkg/diunwebhook/export_test.go, pkg/diunwebhook/diunwebhook_test.go - pkg/diunwebhook/diunwebhook_test.go (all 33 existing tests to convert) - pkg/diunwebhook/export_test.go (current helpers to replace) - pkg/diunwebhook/diunwebhook.go (refactored Server/handler signatures from Task 1) - pkg/diunwebhook/store.go (Store interface) - pkg/diunwebhook/sqlite_store.go (NewSQLiteStore) - pkg/diunwebhook/migrate.go (RunMigrations) - .planning/phases/02-backend-refactor/02-RESEARCH.md (export_test.go redesign pattern) **Rewrite `pkg/diunwebhook/export_test.go`:**

Replace the entire file. The old helpers (UpdatesReset, GetUpdatesMap, ResetTags, ResetWebhookSecret) relied on package-level globals that no longer exist.

New content:

package diunwebhook

import "database/sql"

// NewTestServer constructs a Server with a fresh in-memory SQLite database.
// Each call returns an isolated server -- tests do not share state.
func NewTestServer() (*Server, error) {
    db, err := sql.Open("sqlite", ":memory:")
    if err != nil {
        return nil, err
    }
    if err := RunMigrations(db); err != nil {
        return nil, err
    }
    store := NewSQLiteStore(db)
    return NewServer(store, ""), nil
}

// NewTestServerWithSecret constructs a Server with webhook authentication enabled.
func NewTestServerWithSecret(secret string) (*Server, error) {
    db, err := sql.Open("sqlite", ":memory:")
    if err != nil {
        return nil, err
    }
    if err := RunMigrations(db); err != nil {
        return nil, err
    }
    store := NewSQLiteStore(db)
    return NewServer(store, secret), nil
}

Rewrite pkg/diunwebhook/diunwebhook_test.go:

The test file is package diunwebhook_test (external test package). Every test that previously called diun.UpdatesReset() to get a clean global DB must now call diun.NewTestServer() to get its own isolated server.

Conversion pattern for every test:

OLD:

func TestFoo(t *testing.T) {
    diun.UpdatesReset()
    // ... uses diun.WebhookHandler, diun.UpdateEvent, diun.GetUpdatesMap, etc.
}

NEW:

func TestFoo(t *testing.T) {
    srv, err := diun.NewTestServer()
    if err != nil {
        t.Fatalf("NewTestServer: %v", err)
    }
    // ... uses srv.WebhookHandler, srv.Store().UpsertEvent, etc.
}

But wait: srv.Store() does not exist -- the store field is unexported. Tests need a way to call UpsertEvent and GetUpdates directly. Two options:

Option A: Add a Store() accessor method to Server (exported, for tests). Option B: Add test-helper functions in export_test.go that access s.store directly (since export_test.go is in the internal package).

Use Option B -- add these helpers in export_test.go:

// TestUpsertEvent calls UpsertEvent on the server's store (for test setup).
func (s *Server) TestUpsertEvent(event DiunEvent) error {
    return s.store.UpsertEvent(event)
}

// TestGetUpdates calls GetUpdates on the server's store (for test assertions).
func (s *Server) TestGetUpdates() (map[string]UpdateEntry, error) {
    return s.store.GetUpdates()
}

// TestGetUpdatesMap is a convenience wrapper that returns the map without error.
func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry {
    m, _ := s.store.GetUpdates()
    return m
}

Now convert each test function. Here are the specific conversions for ALL tests:

  1. Remove TestMain -- it only called diun.UpdatesReset() which is no longer needed since each test creates its own server.

  2. TestUpdateEventAndGetUpdates -- replace diun.UpdatesReset() with srv, err := diun.NewTestServer(). Replace diun.UpdateEvent(event) with srv.TestUpsertEvent(event). Replace diun.GetUpdates() with srv.TestGetUpdates().

  3. TestWebhookHandler -- replace diun.UpdatesReset() with srv, err := diun.NewTestServer(). Replace diun.WebhookHandler(rec, req) with srv.WebhookHandler(rec, req). Replace diun.GetUpdatesMap() with srv.TestGetUpdatesMap().

  4. TestWebhookHandler_Unauthorized -- replace with srv, err := diun.NewTestServerWithSecret("my-secret"). Remove defer diun.ResetWebhookSecret(). Replace diun.WebhookHandler with srv.WebhookHandler.

  5. TestWebhookHandler_WrongToken -- same as Unauthorized: use NewTestServerWithSecret("my-secret").

  6. TestWebhookHandler_ValidToken -- use NewTestServerWithSecret("my-secret").

  7. TestWebhookHandler_NoSecretConfigured -- use diun.NewTestServer() (no secret = open webhook).

  8. TestWebhookHandler_BadRequest -- use diun.NewTestServer(). (Note: the old test did NOT call UpdatesReset, but it should use a server now.) Replace diun.WebhookHandler with srv.WebhookHandler.

  9. TestUpdatesHandler -- use diun.NewTestServer(). Replace diun.UpdateEvent(event) with srv.TestUpsertEvent(event). Replace diun.UpdatesHandler with srv.UpdatesHandler.

  10. TestUpdatesHandler_EncodeError -- use diun.NewTestServer(). Replace diun.UpdatesHandler with srv.UpdatesHandler.

  11. TestWebhookHandler_MethodNotAllowed -- use diun.NewTestServer(). Replace all diun.WebhookHandler with srv.WebhookHandler.

  12. TestWebhookHandler_EmptyImage -- use diun.NewTestServer(). Replace handler + GetUpdatesMap calls.

  13. TestConcurrentUpdateEvent -- use diun.NewTestServer(). Replace diun.UpdateEvent(...) with srv.TestUpsertEvent(...). Replace diun.GetUpdatesMap() with srv.TestGetUpdatesMap(). Note: t.Fatalf cannot be called from goroutines. This is a pre-existing issue in the test. Change to t.Errorf inside goroutines (or use a channel/error collection pattern). The current code already has this bug -- preserve the existing behavior for now but change t.Fatalf to t.Errorf inside the goroutine.

  14. TestMainHandlerIntegration -- use diun.NewTestServer(). Replace the inline handler router to use srv.WebhookHandler and srv.UpdatesHandler in the httptest.NewServer setup.

  15. TestDismissHandler_Success -- use diun.NewTestServer(). Replace diun.UpdateEvent -> srv.TestUpsertEvent. Replace diun.DismissHandler -> srv.DismissHandler. Replace diun.GetUpdatesMap -> srv.TestGetUpdatesMap.

  16. TestDismissHandler_NotFound -- use diun.NewTestServer(). Replace handler call.

  17. TestDismissHandler_EmptyImage -- use diun.NewTestServer(). Replace handler call.

  18. TestDismissHandler_SlashInImageName -- use diun.NewTestServer(). Replace all calls.

  19. TestDismissHandler_ReappearsAfterNewWebhook -- use diun.NewTestServer(). Replace all calls. The diun.UpdateEvent(...) call without error check becomes srv.TestUpsertEvent(...) -- add an error check.

  20. Helper functions postTag and postTagAndGetID -- these need the server as a parameter. Change signatures:

    func postTag(t *testing.T, srv *diun.Server, name string) (int, int)
    func postTagAndGetID(t *testing.T, srv *diun.Server, name string) int
    

    Replace diun.TagsHandler(rec, req) with srv.TagsHandler(rec, req).

  21. All tag tests (TestCreateTagHandler_Success, TestCreateTagHandler_DuplicateName, TestCreateTagHandler_EmptyName, TestGetTagsHandler_Empty, TestGetTagsHandler_WithTags, TestDeleteTagHandler_Success, TestDeleteTagHandler_NotFound, TestDeleteTagHandler_CascadesAssignment) -- use diun.NewTestServer(). Replace all handler calls. Pass srv to helper functions.

  22. All tag assignment tests (TestTagAssignmentHandler_Assign, TestTagAssignmentHandler_Reassign, TestTagAssignmentHandler_Unassign, TestGetUpdates_IncludesTag) -- use diun.NewTestServer(). Replace all calls.

  23. Oversized body tests (TestWebhookHandler_OversizedBody, TestTagsHandler_OversizedBody, TestTagAssignmentHandler_OversizedBody) -- use diun.NewTestServer(). Replace handler calls.

  24. TestUpdateEvent_PreservesTagOnUpsert -- use diun.NewTestServer(). Replace diun.UpdateEvent -> srv.TestUpsertEvent. Replace handler calls. Replace diun.GetUpdatesMap -> srv.TestGetUpdatesMap.

Remove these imports from test file (no longer needed):

  • os (was for TestMain's os.Exit)

Verify all HTTP status codes, error messages, and assertion logic remain IDENTICAL to the original tests. The only change is the source of the handler function (method on srv instead of package function) and the source of test data (srv.TestUpsertEvent instead of diun.UpdateEvent). cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -40 <acceptance_criteria> - pkg/diunwebhook/export_test.go contains func NewTestServer() (*Server, error) - pkg/diunwebhook/export_test.go contains func NewTestServerWithSecret(secret string) (*Server, error) - pkg/diunwebhook/export_test.go contains func (s *Server) TestUpsertEvent(event DiunEvent) error - pkg/diunwebhook/export_test.go contains func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry - pkg/diunwebhook/export_test.go does NOT contain func UpdatesReset() (old helper removed) - pkg/diunwebhook/export_test.go does NOT contain func ResetWebhookSecret() (old helper removed) - pkg/diunwebhook/diunwebhook_test.go does NOT contain diun.UpdatesReset() (replaced with NewTestServer) - pkg/diunwebhook/diunwebhook_test.go does NOT contain diun.SetWebhookSecret( (replaced with NewTestServerWithSecret) - pkg/diunwebhook/diunwebhook_test.go contains diun.NewTestServer() (new pattern) - pkg/diunwebhook/diunwebhook_test.go contains srv.WebhookHandler( (method call, not package function) - pkg/diunwebhook/diunwebhook_test.go contains srv.TestUpsertEvent( (test helper) - pkg/diunwebhook/diunwebhook_test.go contains srv.TestGetUpdatesMap() (test helper) - pkg/diunwebhook/diunwebhook_test.go does NOT contain func TestMain( (removed, no longer needed) - go test -v -count=1 ./pkg/diunwebhook/ exits 0 with all tests passing - go test -v -count=1 ./pkg/diunwebhook/ output contains PASS </acceptance_criteria> All existing tests pass against the new Server/Store architecture; each test has its own in-memory database; no shared global state; test output shows PASS with 0 failures

- `go test -v -count=1 ./pkg/diunwebhook/` -- ALL tests pass (same test count as before the refactor) - `go build ./cmd/diunwebhook/` -- binary compiles - `go vet ./...` -- no issues - `grep -r 'var db \|var mu \|var webhookSecret' pkg/diunwebhook/diunwebhook.go` -- returns empty (globals removed) - `grep -r 'db\.Exec\|db\.Query\|db\.QueryRow' pkg/diunwebhook/diunwebhook.go` -- returns empty (no SQL in handlers) - `grep 's\.store\.' pkg/diunwebhook/diunwebhook.go` -- returns multiple matches (handlers use Store interface) - `grep 'diun\.UpdatesReset' pkg/diunwebhook/diunwebhook_test.go` -- returns empty (old pattern gone)

<success_criteria>

  • All existing tests pass with zero behavior change (same HTTP status codes, same error messages, same data semantics)
  • HTTP handlers contain no SQL -- every persistence call goes through s.store.X()
  • Package-level globals db, mu, webhookSecret are deleted from diunwebhook.go
  • main.go wires: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration
  • Each test creates its own in-memory database via NewTestServer() (parallel-safe)
  • go vet passes on all packages </success_criteria>
After completion, create `.planning/phases/02-backend-refactor/02-02-SUMMARY.md`