From 77e37cb21b024133bc8f08846dddb1b81d602f53 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 18:14:26 +0200 Subject: [PATCH 1/3] feat(07-01): embed.go + RunMigrations + HealthzHandler()/ReadyzHandler() split - backend/embed.go: package assets with //go:embed all:static and //go:embed migrations - backend/internal/db/migrate.go: RunMigrations using pgx/v5/stdlib bridge to goose.Up() - backend/internal/web/handlers.go: HealthzHandler() no-arg liveness + ReadyzHandler(pinger) readiness - backend/internal/web/handlers_test.go: TestHealthz_OK (no pinger), TestReadyz_OK, TestReadyz_Down added; TestHealthz_Down deleted --- backend/embed.go | 22 ++++++++++++++ backend/internal/db/migrate.go | 44 +++++++++++++++++++++++++++ backend/internal/web/handlers.go | 19 ++++++++++-- backend/internal/web/handlers_test.go | 40 +++++++++++++++++++----- 4 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 backend/embed.go create mode 100644 backend/internal/db/migrate.go diff --git a/backend/embed.go b/backend/embed.go new file mode 100644 index 0000000..2570bf5 --- /dev/null +++ b/backend/embed.go @@ -0,0 +1,22 @@ +// Package assets exposes the embedded static assets and SQL migrations that are +// baked into the binary at build time. Importing packages reference these via: +// +// import assets "backend" +// +// and then use assets.Static (for file serving) and assets.Migrations (for goose). +package assets + +import "embed" + +// Static holds all files under the static/ directory, embedded at build time. +// The all: prefix includes files whose names begin with . or _ (e.g. .gitkeep). +// +//go:embed all:static +var Static embed.FS + +// Migrations holds all .sql files under the migrations/ directory, embedded at +// build time. goose.SetBaseFS(Migrations) + goose.Up(sqlDB, "migrations") uses +// this FS so the binary has zero runtime file dependencies. +// +//go:embed migrations +var Migrations embed.FS diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go new file mode 100644 index 0000000..34e077f --- /dev/null +++ b/backend/internal/db/migrate.go @@ -0,0 +1,44 @@ +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" // pgx/v5 driver for database/sql + "github.com/pressly/goose/v3" +) + +// RunMigrations applies all pending goose migrations from the embedded FS. +// It uses the pgx/v5/stdlib bridge so that goose's database/sql interface +// can speak to the same Postgres as the rest of the application. +// +// Call RunMigrations once at startup, before constructing the router. +// goose.Up is idempotent — already-applied migrations are skipped. +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error { + // Extract the DSN from the pool's connection config. + connConfig := pool.Config().ConnConfig + dsn := connConfig.ConnString() + + // Open a database/sql connection for goose. The pgx/v5 stdlib driver is + // registered by the blank import above. + sqlDB, err := sql.Open("pgx/v5", dsn) + if err != nil { + return fmt.Errorf("migrate: open sql.DB: %w", err) + } + defer sqlDB.Close() + + // Point goose at the embedded FS so no on-disk files are needed. + goose.SetBaseFS(migrationsFS) + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("migrate: set dialect: %w", err) + } + + if err := goose.Up(sqlDB, "migrations"); err != nil { + return fmt.Errorf("migrate: goose up: %w", err) + } + + return nil +} diff --git a/backend/internal/web/handlers.go b/backend/internal/web/handlers.go index bad9dca..391ca0a 100644 --- a/backend/internal/web/handlers.go +++ b/backend/internal/web/handlers.go @@ -9,11 +9,24 @@ import ( "backend/templates" ) -// HealthzHandler returns an HTTP handler that probes the supplied Pinger -// with a 2-second timeout and responds per CONTEXT D-20: 200 + JSON +// HealthzHandler returns a pure liveness probe handler (D-12). It responds +// immediately with 200 + `{"status":"ok"}` and never contacts the database. +// Use /readyz for a DB-aware readiness probe. +func HealthzHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) + } +} + +// ReadyzHandler returns an HTTP handler that probes the supplied Pinger with a +// 2-second timeout and responds per CONTEXT D-13: 200 + JSON // `{"status":"ok","db":"ok"}` when reachable, 503 + JSON // `{"status":"degraded","db":"down"}` otherwise. -func HealthzHandler(pinger Pinger) http.HandlerFunc { +func ReadyzHandler(pinger Pinger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index 54cf20d..e3676f8 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "os" "regexp" "strings" "testing" @@ -25,7 +26,32 @@ func TestHealthz_OK(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - HealthzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) + // HealthzHandler takes no args — pure liveness, no DB ping (D-12). + HealthzHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q; want application/json", ct) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Errorf("body missing status:ok; got: %s", body) + } + // Liveness endpoint must NOT expose db field (T-07-01: no internal info in responses). + if strings.Contains(body, `"db"`) { + t.Errorf("liveness body must not contain db field; got: %s", body) + } +} + +// TestHealthz_Down is deleted — new HealthzHandler has no failure mode (D-12). + +func TestReadyz_OK(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + + ReadyzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d; want 200", rec.Code) @@ -42,11 +68,11 @@ func TestHealthz_OK(t *testing.T) { } } -func TestHealthz_Down(t *testing.T) { +func TestReadyz_Down(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) - HealthzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) + ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d; want 503", rec.Code) @@ -66,7 +92,7 @@ func TestHealthz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -81,7 +107,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) @@ -104,7 +130,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, os.DirFS("./static"), AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) From bdd3cba3146ba25415216d1e3354207489bae686 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 18:14:33 +0200 Subject: [PATCH 2/3] feat(07-01): wire embed.FS into NewRouter and RunMigrations into cmd/web/main.go - backend/internal/web/router.go: staticDir string -> staticFS fs.FS; /healthz uses HealthzHandler(); /readyz registered with ReadyzHandler(pinger); embedded FS served via fs.Sub() - backend/cmd/web/main.go: import assets "backend"; db.RunMigrations(ctx, pool, assets.Migrations) before router; web.NewRouter now receives assets.Static - All *_test.go NewRouter call sites updated from "./static" to os.DirFS("./static"); "os" import added where missing --- backend/cmd/web/main.go | 17 +++++++++--- backend/internal/web/csrf_test.go | 2 +- backend/internal/web/handlers_auth_test.go | 5 ++-- backend/internal/web/handlers_files_test.go | 5 ++-- backend/internal/web/handlers_tablos_test.go | 3 ++- backend/internal/web/handlers_tasks_test.go | 3 ++- backend/internal/web/router.go | 27 ++++++++++++++------ 7 files changed, 43 insertions(+), 19 deletions(-) diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 0d003c5..5d20fc7 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -1,7 +1,7 @@ // Command web is the Phase 1 walking-skeleton HTTP server. It loads env, -// builds a slog handler, opens a pgxpool, mounts the chi router, and serves -// /, /healthz, /demo/time, and /static/* with graceful shutdown on -// SIGINT/SIGTERM (CONTEXT D-19). +// builds a slog handler, opens a pgxpool, runs goose migrations, mounts the +// chi router, and serves /, /healthz, /readyz, /demo/time, and /static/* with +// graceful shutdown on SIGINT/SIGTERM (CONTEXT D-19, D-10, DEPLOY-03/04). // // No .env parser lives here — `.env` is exported into the process // environment by `just dev`; production injects real env vars (D-15). @@ -18,6 +18,7 @@ import ( "syscall" "time" + assets "backend" "backend/internal/auth" "backend/internal/db" "backend/internal/db/sqlc" @@ -68,6 +69,13 @@ func main() { os.Exit(1) } + // D-10: run goose migrations from the embedded FS before constructing the + // router. goose.Up is idempotent — already-applied migrations are skipped. + if err := db.RunMigrations(ctx, pool, assets.Migrations); err != nil { + slog.Error("migrations failed", "err", err) + os.Exit(1) + } + q := sqlc.New(pool) store := auth.NewStore(q) secure := env != "development" && env != "dev" @@ -113,7 +121,8 @@ func main() { fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} - router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) + // D-09: pass the embedded static FS — binary has zero runtime file dependencies. + router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) srv := &http.Server{ Addr: ":" + port, diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index 184974f..01604de 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") } // extractCSRFToken performs a GET request and extracts the _csrf token from the diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 87e2e50..93656b5 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" "time" @@ -33,14 +34,14 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // newTestRouterWithLimiter builds a router with an injected LimiterStore, // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // getCSRFToken performs a GET request to path and extracts the CSRF token diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index daa5980..18e43d7 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -18,6 +18,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -59,7 +60,7 @@ func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileS tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") } // ---- TestFileUpload (FILE-01, FILE-02) ---- @@ -164,7 +165,7 @@ func TestFileUploadTooLarge(t *testing.T) { tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} - router := NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + router := NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12") tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 0e86425..db16eca 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -26,7 +27,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // loginUser signs up a user and returns the session cookie set after signup. diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 989f49f..09733d7 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -15,6 +15,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -29,7 +30,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, os.DirFS("./static"), authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // ---- TestTasksKanbanRenders (TASK-01) ---- diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 4baa004..76e3f40 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -2,6 +2,7 @@ package web import ( "context" + "io/fs" "log/slog" "net/http" "time" @@ -12,7 +13,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" ) -// Pinger is the contract /healthz uses to probe the data plane. *pgxpool.Pool +// Pinger is the contract /readyz uses to probe the data plane. *pgxpool.Pool // satisfies this interface out of the box, which is why cmd/web passes the // pool directly to NewRouter (no adapter required). type Pinger interface { @@ -29,10 +30,10 @@ type Pinger interface { // 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24 // 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7 // -// Routes: GET / · GET /healthz · GET /demo/time · GET /static/* +// Routes: GET / · GET /healthz (liveness) · GET /readyz (readiness) · GET /demo/time · GET /static/* // GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup. -// staticDir is the on-disk path served at /static/*; path traversal is -// blocked by http.Dir's default behavior (T-01-08). +// staticFS is the embedded FS (or os.DirFS in tests) served at /static/*; the +// embedded FS pattern blocks path traversal at the http.FS layer (T-01-08). // // deps.Store may be nil during unit tests for Phase 1 routes (those routes // never exercise session resolution). ResolveSession guards against nil Store. @@ -44,7 +45,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -114,11 +115,21 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps)) }) - r.Get("/healthz", HealthzHandler(pinger)) + // Liveness probe (D-12): always 200, no DB contact. + r.Get("/healthz", HealthzHandler()) + // Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded. + r.Get("/readyz", ReadyzHandler(pinger)) r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) - fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))) - r.Get("/static/*", fs.ServeHTTP) + // Serve embedded static assets. Sub to strip the "static/" prefix from the + // embedded path so /static/tailwind.css maps to static/tailwind.css inside + // the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import. + sub, err := fs.Sub(staticFS, "static") + if err != nil { + panic("router: failed to sub static FS: " + err.Error()) + } + fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub))) + r.Get("/static/*", fileHandler.ServeHTTP) return r } From 735106f797b3fa2bc96d25d34e3f071df26e9dd8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 18:16:05 +0200 Subject: [PATCH 3/3] =?UTF-8?q?docs(07-01):=20complete=20plan=20summary=20?= =?UTF-8?q?=E2=80=94=20embed=20anchor,=20RunMigrations,=20health=20endpoin?= =?UTF-8?q?t=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../phases/07-deploy-v1/07-01-SUMMARY.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 .planning/phases/07-deploy-v1/07-01-SUMMARY.md diff --git a/.planning/phases/07-deploy-v1/07-01-SUMMARY.md b/.planning/phases/07-deploy-v1/07-01-SUMMARY.md new file mode 100644 index 0000000..f63af3c --- /dev/null +++ b/.planning/phases/07-deploy-v1/07-01-SUMMARY.md @@ -0,0 +1,142 @@ +--- +phase: 07-deploy-v1 +plan: "01" +subsystem: backend-deploy +tags: [go, embed, goose, health-endpoints, deploy] +dependency_graph: + requires: + - 06-02 (cmd/worker wiring — same pool/db patterns mirrored) + provides: + - embed.FS anchor for static/ and migrations/ (consumed by Dockerfile in plan 07-02) + - RunMigrations for use in cmd/web startup (D-10) + - /healthz liveness and /readyz readiness endpoints (D-12, D-13) + affects: + - backend/cmd/web/main.go (startup sequence) + - backend/internal/web/router.go (NewRouter signature change) +tech_stack: + added: + - go:embed (stdlib) — static asset and migration embedding + - github.com/pressly/goose/v3 — SQL migration runner via embedded FS + - github.com/jackc/pgx/v5/stdlib — pgx/v5 database/sql bridge for goose + patterns: + - embed.FS anchor in package root with //go:embed directives + - fs.FS parameter for static serving (replaces string path) + - goose.SetBaseFS + goose.Up for idempotent embedded migrations +key_files: + created: + - backend/embed.go + - backend/internal/db/migrate.go + modified: + - backend/internal/web/handlers.go + - backend/internal/web/handlers_test.go + - backend/internal/web/router.go + - backend/cmd/web/main.go + - backend/internal/web/csrf_test.go + - backend/internal/web/handlers_auth_test.go + - backend/internal/web/handlers_files_test.go + - backend/internal/web/handlers_tablos_test.go + - backend/internal/web/handlers_tasks_test.go +decisions: + - "embed.go in package assets at backend/ root — module root makes static/ and migrations/ siblings, no ../ paths needed" + - "import assets \"backend\" aliased import — package name assets matches package declaration in embed.go" + - "pool.Config().ConnConfig.ConnString() for DSN extraction — avoids storing DSN separately" + - "goose default table name goose_db_version — no custom table needed (T-07-02 accepted)" + - "fs.Sub() strips static/ prefix from embedded FS — avoids double-prefix in served URLs" + - "Variable renamed fileHandler (not fs) — prevents shadowing io/fs import in router.go" +metrics: + duration: "~6 minutes" + completed: "2026-05-15" + tasks: 2 + files: 11 +--- + +# Phase 7 Plan 1: Go embed anchor, goose RunMigrations, and /healthz+/readyz handler split Summary + +## What Was Built + +go:embed anchor for zero-runtime-file-dependency binary, goose RunMigrations using pgx/v5/stdlib bridge, and liveness/readiness health endpoint split (HealthzHandler no-arg + ReadyzHandler with pinger). + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | go:embed anchor, goose RunMigrations, handler split | 77e37cb | backend/embed.go, backend/internal/db/migrate.go, backend/internal/web/handlers.go, backend/internal/web/handlers_test.go | +| 2 | Wire embed.FS into NewRouter and RunMigrations into main.go | bdd3cba | backend/internal/web/router.go, backend/cmd/web/main.go, 5 test files | + +## Decisions Made + +1. `embed.go` placed at `backend/` root in `package assets` — the module root makes `static/` and `migrations/` natural siblings so `//go:embed` directives resolve without `../` paths. Module is `module backend`; consumers import with `import assets "backend"` which aliases the import path to the package identifier. + +2. `pool.Config().ConnConfig.ConnString()` used to extract DSN for goose's database/sql bridge — avoids threading DSN as a separate parameter through the call stack. + +3. `goose.SetBaseFS` + `goose.Up(sqlDB, "migrations")` — the embedded FS subdirectory path `"migrations"` matches the actual directory name in the module root; no path remapping needed. + +4. `fs.Sub(staticFS, "static")` strips the `static/` prefix from the embedded FS before passing to `http.FileServer(http.FS(...))` — this ensures `/static/tailwind.css` maps to the correct embedded entry without double-prefix. + +5. Local variable renamed `fileHandler` (was `fs`) in `router.go` to prevent shadowing the `"io/fs"` package import. + +6. All 5 test helper files updated from `"./static"` string to `os.DirFS("./static")` to match the new `fs.FS` parameter type in `NewRouter`. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] router.go and all *_test.go call sites updated in Task 1** +- **Found during:** Task 1 GREEN phase (handlers.go updated but router.go still called HealthzHandler(pinger)) +- **Issue:** Changing HealthzHandler's signature from `(pinger Pinger)` to `()` caused compilation failure in router.go which prevented the handler tests from compiling at all. +- **Fix:** Updated router.go, cmd/web/main.go, and all 5 test helper files as part of the GREEN implementation. These are all interdependent changes that cannot compile in isolation. Task 2 changes were merged into a single GREEN commit after the RED test commit. +- **Files modified:** backend/internal/web/router.go, backend/cmd/web/main.go, 5 *_test.go files +- **Commit:** bdd3cba + +**2. [Rule 3 - Blocking] Generated files needed in worktree (sqlc + templ)** +- **Found during:** First test run in worktree context +- **Issue:** The worktree starts clean — sqlc-generated .go files and templ-generated *_templ.go files are gitignored and must be regenerated per DEVELOPMENT.md. +- **Fix:** Ran `templ generate` (via `just generate` partial) and `sqlc generate` in the worktree backend directory before running tests. +- **Files modified:** None (generated files are gitignored; not committed) + +## Verification + +``` +cd backend && go test ./... -count=1 +``` + +All packages pass: +- `backend/internal/auth` — ok +- `backend/internal/db` — ok +- `backend/internal/files` — ok +- `backend/internal/jobs` — ok +- `backend/internal/web` — ok (TestHealthz_OK, TestReadyz_OK, TestReadyz_Down all pass) +- `backend/internal/web/ui` — ok +- `backend/templates` — ok + +``` +go build -o /tmp/xtablo-web ./cmd/web && ls -la /tmp/xtablo-web +``` +Binary compiles at ~22.7 MB with embedded assets and migrations. + +## Success Criteria Check + +1. `go test ./... -count=1` exits 0 — PASS +2. `go build ./cmd/web/...` exits 0 — PASS +3. `grep ReadyzHandler backend/internal/web/router.go` — PASS (r.Get("/readyz", ReadyzHandler(pinger))) +4. `grep RunMigrations backend/cmd/web/main.go` — PASS (db.RunMigrations(ctx, pool, assets.Migrations)) +5. `backend/embed.go` in `package assets` with `//go:embed all:static` and `//go:embed migrations` — PASS +6. `backend/internal/db/migrate.go` exports `RunMigrations` — PASS +7. TestHealthz_OK passes without pinger; TestHealthz_Down deleted; TestReadyz_OK and TestReadyz_Down pass — PASS + +## Known Stubs + +None. + +## Threat Flags + +No new threat surface introduced beyond plan's threat_model. Health endpoints return only `{"status":"ok"}` or `{"status":"degraded","db":"down"}` — no version strings, stack traces, or DSN fragments (T-07-01 mitigated). + +## Self-Check: PASSED + +- backend/embed.go: EXISTS +- backend/internal/db/migrate.go: EXISTS +- backend/internal/web/handlers.go: EXISTS (contains ReadyzHandler) +- backend/internal/web/router.go: EXISTS (contains /readyz route) +- backend/cmd/web/main.go: EXISTS (contains RunMigrations call) +- Commits 77e37cb and bdd3cba: VERIFIED in git log