docs(07): create phase plan

This commit is contained in:
Arthur Belleville 2026-05-15 18:06:08 +02:00
parent dbe9d493be
commit 8ae83f6c50
No known key found for this signature in database
7 changed files with 637 additions and 52 deletions

View file

@ -177,8 +177,13 @@ Plans:
**Plans:** 3 plans
Plans:
**Wave 1**
- [ ] 07-01-PLAN.md — Wave 1: go:embed assets + goose RunMigrations + /healthz liveness split + /readyz readiness (DEPLOY-03, DEPLOY-04)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 07-02-PLAN.md — Wave 2: multi-stage Dockerfile (assets + builder + distroless) + .env.example update (DEPLOY-01, DEPLOY-02)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 07-03-PLAN.md — Wave 3: docker-compose.prod.yaml + deploy/Caddyfile + README runbook (DEPLOY-02..DEPLOY-05)
---

View file

@ -3,13 +3,13 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: ready_to_plan
last_updated: "2026-05-15T14:38:52.742Z"
last_updated: "2026-05-15T16:06:01.313Z"
progress:
total_phases: 7
completed_phases: 5
total_plans: 25
completed_plans: 24
percent: 96
completed_phases: 6
total_plans: 28
completed_plans: 25
percent: 89
---
# STATE

View file

@ -5,7 +5,7 @@ type: execute
wave: 1
depends_on: []
files_modified:
- backend/assets/assets.go
- backend/embed.go
- backend/internal/db/migrate.go
- backend/internal/web/handlers.go
- backend/internal/web/handlers_test.go
@ -18,13 +18,13 @@ requirements:
must_haves:
truths:
- "GET /healthz returns 200 with no DB connection (pure liveness)"
- "GET /readyz returns 200 when DB is reachable and 503 when DB is down"
- "Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
- "goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
- "D-12: GET /healthz returns 200 with no DB connection (pure liveness)"
- "D-13: GET /readyz returns 200 when DB is reachable and 503 when DB is down"
- "D-09: Static assets (tailwind.css, htmx.min.js, sortable.min.js) are served from embedded FS, not disk"
- "D-10: goose.Up() is called in cmd/web/main.go startup, before router construction, using embedded migrations"
- "Full Go test suite passes: go test ./..."
artifacts:
- path: "backend/assets/assets.go"
- path: "backend/embed.go"
provides: "go:embed declarations for static/ and migrations/ directories"
contains: "//go:embed"
- path: "backend/internal/db/migrate.go"
@ -42,8 +42,8 @@ must_haves:
via: "db.RunMigrations(ctx, pool, assets.Migrations)"
pattern: "RunMigrations"
- from: "backend/cmd/web/main.go"
to: "backend/assets/assets.go"
via: "assets.Static passed to web.NewRouter"
to: "backend/embed.go"
via: "assets.Static passed to web.NewRouter (import assets \"backend\")"
pattern: "assets\\.Static"
- from: "backend/internal/web/router.go"
to: "ReadyzHandler"
@ -58,7 +58,7 @@ must_haves:
<objective>
Deliver all Go code changes required before the Dockerfile can be built successfully:
1. Create `backend/assets/assets.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
1. Create `backend/embed.go` with `//go:embed` for both `static/` and `migrations/` directories — this is the embed anchor for the entire phase (D-09, D-10).
2. Create `backend/internal/db/migrate.go` with `RunMigrations(ctx, pool, migrationsFS)` using the pgx/v5/stdlib bridge to goose.Up() (D-10, DEPLOY-03).
3. Refactor `backend/internal/web/handlers.go`: rename existing `HealthzHandler``ReadyzHandler`; add new no-pinger `HealthzHandler` (D-12, D-13, DEPLOY-04).
4. Update `backend/internal/web/router.go`: change `staticDir string``staticFS fs.FS`, register `/readyz` route with `ReadyzHandler`, keep `/healthz` with new liveness `HealthzHandler` (D-09, D-12, D-13).
@ -125,7 +125,7 @@ for _, v := range res.Versions {
<task type="auto" tdd="true">
<name>Task 1: go:embed anchor, goose RunMigrations, and /healthz + /readyz handler split</name>
<files>
backend/assets/assets.go
backend/embed.go
backend/internal/db/migrate.go
backend/internal/web/handlers.go
backend/internal/web/handlers_test.go
@ -146,29 +146,24 @@ for _, v := range res.Versions {
- TestReadyz_Down: `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` returns 503 with body containing `"status":"degraded"` and `"db":"down"`
</behavior>
<action>
Create `backend/assets/assets.go` in package `assets`. The file must contain two `//go:embed` declarations: one for `static` (var Static embed.FS) and one for `migrations` (var Migrations embed.FS). IMPORTANT: the `//go:embed` path is relative to the .go file. Since `backend/assets/assets.go` is at `backend/assets/` and `static/` and `migrations/` are at `backend/`, the paths `../static` and `../migrations` would be invalid (go:embed cannot use `..`). Instead, create the file at `backend/embed.go` in package `main` — no, this conflicts with cmd/web. The correct approach per PATTERNS.md "Alternative (embed.go at module root)" is: create `backend/embed.go` as a standalone file in a new `package assets` would require a separate directory. The cleanest verified approach: place `assets.go` at `backend/assets/assets.go` and rely on the Go toolchain's embed path resolution from that file location. The `static/` directory is at `backend/static/`. From `backend/assets/assets.go`, the embed directive `//go:embed ../static` is invalid. Therefore use the RESEARCH-recommended alternative: create `backend/embed.go` (a file at the backend module root) in a new package. Since `backend/cmd/web/main.go` is in `package main`, `backend/embed.go` cannot also be `package main`. Create `backend/assets/assets.go` and symlink `static` and `migrations` inside `backend/assets/` — no symlinks in Docker builds.
**Verified approach (no ambiguity — read RESEARCH.md Open Questions (RESOLVED) before starting):**
**Correct approach (verified per RESEARCH Pitfall 1 and PATTERNS.md "Path verification required"):** Place the embed file at `backend/internal/assets/assets.go` — this does NOT help with `..` restriction either. The only valid approach without moving directories: place the `//go:embed` directive in a file that IS in the `backend/` directory. Create `backend/assets.go` as a file directly at the `backend/` module root, in a package named `assets`. But Go requires all .go files in the same directory to share a package name — and `backend/cmd/web/main.go` is in a subdirectory, not in `backend/` directly. Files in `backend/` like `backend/go.mod` don't constrain the package name. Check if any .go files exist directly in `backend/`; if not, a new package at `backend/assets/assets.go` with `//go:embed static migrations` is valid IF `static` and `migrations` are moved inside `backend/assets/` — which we cannot do without changing many paths.
Create `backend/embed.go` in `package assets`. This file lives at the module root (`backend/`), making `static/` and `migrations/` siblings — so `//go:embed` directives resolve without any `..` paths. Module is `module backend` (verified in `go.mod`). No other .go files exist at `backend/` root, so no package name conflict. Declare two exported vars:
- `//go:embed all:static``var Static embed.FS` (use `all:` prefix because `backend/static/.gitkeep` exists)
- `//go:embed migrations``var Migrations embed.FS` (no hidden files in migrations/)
Import in `cmd/web/main.go` as `import assets "backend"` — aliasing the import path to the declared package name `assets`.
**Final resolution:** Create the embed file as `backend/embed.go` in a dedicated package. Scan what `.go` files exist directly in `backend/` with `ls backend/*.go`. If none exist, create `backend/embed.go` with `package assets` — this is a new Go package at the module root. Import it in `cmd/web/main.go` as `"backend/assets"` (where the module is `module backend` per go.mod). This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`, so `//go:embed static` and `//go:embed migrations` resolve correctly.
Create `backend/internal/db/migrate.go` in `package db`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer sqlDB.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(sqlDB, "migrations")` and returns the error. Required imports: `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"embed"`, `"context"`. Do NOT set a custom table name — default `goose_db_version` is correct.
Actually re-read RESEARCH: "the embed directive file must also be at `backend/` — either as `backend/embed.go`". Confirm module name by reading `backend/go.mod` first line. Create `backend/embed.go` with `package assets` containing:
- `//go:embed static``var Static embed.FS`
- `//go:embed migrations``var Migrations embed.FS`
In `backend/internal/web/handlers.go`: rename existing `HealthzHandler(pinger Pinger) http.HandlerFunc``ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s context timeout, `db.Ping()`, 503 + `{"status":"degraded","db":"down"}` on error, 200 + `{"status":"ok","db":"ok"}` on success). Add new `HealthzHandler() http.HandlerFunc` that returns 200 + `{"status":"ok"}` immediately with no DB ping and no pinger argument.
If `backend/static/` contains files starting with `.` (check with `ls -la backend/static/`), use `//go:embed all:static` and `//go:embed all:migrations`.
Create `backend/internal/db/migrate.go` in `package db`. Import `database/sql`, `_ "github.com/jackc/pgx/v5/stdlib"`, `"github.com/pressly/goose/v3"`, `"github.com/jackc/pgx/v5/pgxpool"`, and `"embed"`. Implement `RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error` that: (1) extracts DSN via `pool.Config().ConnConfig.ConnString()`, (2) opens `sql.Open("pgx/v5", dsn)` + `defer db.Close()`, (3) calls `goose.SetBaseFS(migrationsFS)`, (4) calls `goose.SetDialect("postgres")`, (5) calls `goose.Up(db, "migrations")` and returns the error. Do NOT call `goose.SetTableName` — the production table uses the default `goose_db_version`.
In `backend/internal/web/handlers.go`: rename current `HealthzHandler(pinger Pinger) http.HandlerFunc``ReadyzHandler(pinger Pinger) http.HandlerFunc` (logic unchanged: 2s timeout, DB ping, 503 on error with `{"status":"degraded","db":"down"}`, 200 with `{"status":"ok","db":"ok"}`). Add new `HealthzHandler() http.HandlerFunc` that returns 200 immediately with `{"status":"ok"}` only (no DB ping, no context, no time import needed in this handler — but keep the imports that ReadyzHandler still needs).
In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args; remove the `"db":"ok"` assertion (new liveness handler does not return db field); delete `TestHealthz_Down`; add `TestReadyz_OK` that calls `ReadyzHandler(stubPinger{err: nil})` and asserts 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` that calls `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` and asserts 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct at the top of the file is unchanged.
In `backend/internal/web/handlers_test.go`: update `TestHealthz_OK` to call `HealthzHandler()` with no args and assert 200 + body contains `"status":"ok"` but NOT `"db":"ok"`; delete `TestHealthz_Down`; add `TestReadyz_OK` calling `ReadyzHandler(stubPinger{err: nil})` → 200 + `"status":"ok"` + `"db":"ok"`; add `TestReadyz_Down` calling `ReadyzHandler(stubPinger{err: errors.New("conn refused")})` → 503 + `"status":"degraded"` + `"db":"down"`. The `stubPinger` struct is unchanged.
</action>
<verify>
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/... -run "TestHealthz|TestReadyz" -v -count=1</automated>
</verify>
<done>
TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. backend/embed.go exists with //go:embed directives for both static and migrations. backend/internal/db/migrate.go exports RunMigrations.
TestHealthz_OK passes (200, no pinger arg, body has "status":"ok" but NOT "db":"ok"). TestReadyz_OK passes (200, DB ok, body has both "status":"ok" and "db":"ok"). TestReadyz_Down passes (503, body has "status":"degraded" and "db":"down"). TestHealthz_Down is deleted. `backend/embed.go` exists in `package assets` with `//go:embed all:static` and `//go:embed migrations`. backend/internal/db/migrate.go exports RunMigrations.
</done>
</task>
@ -190,9 +185,9 @@ for _, v := range res.Versions {
<action>
In `backend/internal/web/router.go`: change the `NewRouter` function signature — replace the `staticDir string` parameter with `staticFS fs.FS` (add `"io/fs"` to imports, remove no-longer-needed static path logic). Replace the static file serving lines (currently `fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))` and `r.Get("/static/*", fs.ServeHTTP)`) with the embed.FS pattern: `sub, err := fs.Sub(staticFS, "static"); if err != nil { panic(...) }; fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub))); r.Get("/static/*", fileServer.ServeHTTP)`. Change the `/healthz` route from `r.Get("/healthz", HealthzHandler(pinger))` to `r.Get("/healthz", HealthzHandler())` (liveness, no pinger, per D-12). Add `r.Get("/readyz", ReadyzHandler(pinger))` immediately after (per D-13). Variable name conflict: the local variable `fs` used for the file server will conflict with the `"io/fs"` package import alias. Rename the local variable (e.g., `fileHandler`) to avoid the shadowing.
In `backend/cmd/web/main.go`: add import for the embed package containing `assets.Static` and `assets.Migrations` — check the package path from `backend/go.mod` module declaration (likely `backend` module, so the import is `"backend"` if `embed.go` uses `package assets` but lives in the `backend/` directory... re-check: Go package = the `package` declaration in the .go file, not the directory. If `backend/embed.go` has `package assets`, then `cmd/web/main.go` imports `"backend"` with an alias — no, the import path is the directory path relative to module root. The `backend/embed.go` file lives in the directory `backend/`, and the module is `module backend` (check go.mod). So the import path for files in `backend/` directly is `"backend"`. But the package name would be `assets` (or whatever `embed.go` declares). You would import: `import assets "backend"` or `import "backend"` and refer to `backend.Static`. To avoid ambiguity, use `package main` for `backend/embed.go` is not possible (cmd/web is also main). The cleanest solution: name the package in `backend/embed.go` as `assets` and import it in cmd/web as `import "backend"` with local name `assets`. Actually: `import assets "backend"` does NOT work — you cannot rename a package import to differ from the last element of the path like that conventionally. The actual import path for a file in the `backend/` directory with `module backend` is just `"backend"`, and the package name is whatever the file declares. So `import "backend"` makes the identifier `assets.Static` available as `backend.Static` (using the package declaration name, not the import path). To make this ergonomic: declare `package assets` in `backend/embed.go` and import with alias: `import assets "backend"` — this IS valid Go; you can alias any import path. Then use `assets.Static` and `assets.Migrations` in main.go.
In `backend/cmd/web/main.go`: add `import assets "backend"` — this aliases the import path `"backend"` (module root files) to the local identifier `assets`, which matches the `package assets` declaration in `backend/embed.go`. This is valid Go aliased import syntax.
Add to `cmd/web/main.go` after `pool, err := db.NewPool(ctx, dsn)` (after the nil-check block at line 64-69) and before `q := sqlc.New(pool)` (line 71): call `db.RunMigrations(ctx, pool, assets.Migrations)` with the same slog.Error + os.Exit(1) pattern used for other startup failures. Also log each applied migration with `slog.Info("goose migration applied")` if you want to mirror the worker, but the minimum is just error handling. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.
After `pool, err := db.NewPool(ctx, dsn)` and its nil-check block, before `q := sqlc.New(pool)`: add a call to `db.RunMigrations(ctx, pool, assets.Migrations)`. Use the same `slog.Error(...) + os.Exit(1)` error-handling pattern already used for `db.NewPool`. Change `web.NewRouter(pool, "./static", ...)` to `web.NewRouter(pool, assets.Static, ...)`.
In `backend/internal/web/handlers_test.go`: find all call sites that pass `"./static"` as the second argument to `NewRouter` (lines 69, 84, 107 based on file inspection) and change them to `os.DirFS("./static")`. Add `"os"` to the test imports if not already present.
</action>
@ -243,7 +238,7 @@ cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go
2. `cd backend && go build ./cmd/web/...` exits 0 (binary compiles with embedded assets and migrations)
3. `grep -r "ReadyzHandler" backend/internal/web/router.go` returns a match
4. `grep -r "RunMigrations" backend/cmd/web/main.go` returns a match
5. `backend/embed.go` exists and contains `//go:embed static` and `//go:embed migrations`
5. `backend/embed.go` exists in `package assets`, contains `//go:embed all:static` and `//go:embed migrations`
6. `backend/internal/db/migrate.go` exists and exports `RunMigrations`
7. TestHealthz_OK passes without a pinger argument; TestHealthz_Down is deleted; TestReadyz_OK and TestReadyz_Down exist and pass
</success_criteria>

View file

@ -15,11 +15,11 @@ requirements:
must_haves:
truths:
- "A single Dockerfile builds both /app/web and /app/worker binaries in one image"
- "D-07: A single Dockerfile builds both /app/web and /app/worker binaries in one image"
- "The builder stage runs templ generate before go build (gitignored *_templ.go files)"
- "The final runtime image is distroless/static-debian12:nonroot with CGO_ENABLED=0 binaries"
- "The image has no CMD — docker-compose overrides command: per service"
- ".env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
- "D-08: The image has no CMD — docker-compose overrides command: per service"
- "D-05/D-06: .env.example documents all production env vars including S3/R2, DOMAIN, and MAX_UPLOAD_SIZE_MB"
artifacts:
- path: "backend/Dockerfile"
provides: "3-stage multi-stage Docker build: assets, builder, runtime"

View file

@ -18,12 +18,12 @@ requirements:
must_haves:
truths:
- "docker-compose.prod.yaml defines postgres, web, worker, and caddy services using the single image"
- "The web service has command: /app/web and depends_on postgres with service_healthy condition"
- "D-01/D-02/D-03: docker-compose.prod.yaml defines postgres, web, worker, and caddy services for Hetzner VM"
- "D-08: The web service has command: /app/web and depends_on postgres with service_healthy condition"
- "The worker service has command: /app/worker with the same depends_on condition"
- "Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes"
- "D-04: Caddy service mounts deploy/Caddyfile and has persistent caddy_data and caddy_config named volumes"
- "Postgres service has no ports: directive (internal-only, per RESEARCH Pitfall 5)"
- "backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections"
- "D-11: backend/README.md documents first-time deploy, routine deploy, rollback, and incident triage sections"
- "backend/deploy/Caddyfile uses {$DOMAIN} env var interpolation for the site block"
artifacts:
- path: "backend/docker-compose.prod.yaml"

View file

@ -0,0 +1,590 @@
# Phase 7: Deploy v1 - Pattern Map
**Mapped:** 2026-05-15
**Files analyzed:** 8
**Analogs found:** 6 / 8
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/Dockerfile` | config | batch (build pipeline) | `apps/api/Dockerfile` (TS monorepo) | partial — different language, same multi-stage concept |
| `backend/docker-compose.prod.yaml` | config | request-response | `backend/compose.yaml` | exact — same compose v2 syntax, same postgres service |
| `backend/deploy/Caddyfile` | config | request-response | none in codebase | no analog |
| `backend/cmd/web/main.go` (modify) | config | request-response | `backend/cmd/worker/main.go` | exact — rivermigrate startup is the model for goose.Up() |
| `backend/internal/web/handlers.go` (modify) | handler | request-response | `backend/internal/web/handlers.go` itself | self — refactor existing HealthzHandler, add ReadyzHandler |
| `backend/internal/web/handlers_test.go` (modify) | test | request-response | `backend/internal/web/handlers_test.go` itself | self — extend existing TestHealthz_* tests, add TestReadyz_* |
| `backend/internal/web/router.go` (modify) | config | request-response | `backend/internal/web/router.go` itself | self — add /readyz route, update /healthz, change staticDir to fs.FS |
| `backend/assets/assets.go` (new) | utility | batch (embed) | none in codebase | no analog — first go:embed usage in this codebase |
| `backend/internal/db/migrate.go` (new) | utility | batch (migration) | `backend/cmd/worker/main.go` lines 5468 | role-match — rivermigrate startup pattern |
| `backend/README.md` (modify) | config | — | `backend/README.md` itself | self — extend existing structure |
| `backend/.env.example` (modify) | config | — | `backend/.env.example` itself | self — add R2/prod vars |
## Pattern Assignments
---
### `backend/docker-compose.prod.yaml` (config, request-response)
**Analog:** `backend/compose.yaml`
**Postgres service pattern** (compose.yaml lines 118):
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: xtablo-backend-postgres
restart: unless-stopped
environment:
POSTGRES_DB: xtablo
POSTGRES_USER: xtablo
POSTGRES_PASSWORD: xtablo
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
interval: 5s
timeout: 5s
retries: 10
```
**Key differences in prod compose vs dev compose:**
- No `ports:` on postgres (internal network only — Pitfall 5 in RESEARCH)
- No minio / minio-init services (replaced by R2 env vars)
- Add `web` service: `image: ${IMAGE}:${TAG}`, `command: /app/web`, `env_file: .env.prod`, `depends_on: postgres: condition: service_healthy`, `expose: ["8080"]`
- Add `worker` service: same image, `command: /app/worker`, same env_file and depends_on
- Add `caddy` service: `image: caddy:2-alpine`, `ports: ["80:80","443:443","443:443/udp"]`, volumes for Caddyfile bind-mount + named caddy_data + caddy_config
- Volumes block: `postgres_data`, `caddy_data`, `caddy_config` (minio_data removed)
- Healthcheck interval: change to `interval: 10s`, `retries: 10` (slower startup tolerance in prod vs dev's 5s)
---
### `backend/cmd/web/main.go` — add goose.Up() (modify, batch/startup)
**Analog:** `backend/cmd/worker/main.go` lines 5468 — rivermigrate startup pattern
**rivermigrate pattern to mirror** (cmd/worker/main.go lines 5468):
```go
// Step 2: Run river migrations before constructing the client (D-02).
// rivermigrate is idempotent — already-applied versions are skipped.
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
if err != nil {
slog.Error("rivermigrate init failed", "err", err)
os.Exit(1)
}
res, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil)
if err != nil {
slog.Error("river migration failed", "err", err)
os.Exit(1)
}
for _, v := range res.Versions {
slog.Info("river migration applied", "version", v.Version)
}
```
**goose.Up() equivalent to insert after pool creation** (after cmd/web/main.go line 64 `pool, err := db.NewPool`):
```go
// Step N: Run goose migrations at startup (D-10). db.RunMigrations is
// idempotent — already-applied versions are skipped via goose_db_version table.
if err := db.RunMigrations(ctx, pool); err != nil {
slog.Error("migrations failed", "err", err)
os.Exit(1)
}
```
**Ordering constraint:** Insert the goose.Up() call AFTER `pool, err := db.NewPool(ctx, dsn)` (line 64) and BEFORE `q := sqlc.New(pool)` (line 71). Matches the worker's pattern of running migrations before constructing any higher-level clients.
**staticDir → fs.FS change** (cmd/web/main.go line 116):
```go
// BEFORE:
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
// AFTER (once assets package exists):
import "backend/assets"
router := web.NewRouter(pool, assets.Static, deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)
```
---
### `backend/internal/db/migrate.go` (new, utility/batch)
**Analog:** `backend/cmd/worker/main.go` — rivermigrate pattern (role-match: different library, same idiom of programmatic migration before server start)
**Full file pattern** (new file, no existing analog — use RESEARCH Pattern 2 exactly):
```go
package db
import (
"context"
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" // registers "pgx/v5" driver with database/sql
"github.com/pressly/goose/v3"
)
// RunMigrations opens a *sql.DB from the pool's connection string and runs all
// pending goose migrations embedded via the assets package. Idempotent:
// already-applied versions are skipped via the goose_db_version table.
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error {
dsn := pool.Config().ConnConfig.ConnString()
db, err := sql.Open("pgx/v5", dsn)
if err != nil {
return err
}
defer db.Close()
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.Up(db, "migrations")
}
```
**Embed path note (RESEARCH Open Question 1):** The `//go:embed migrations` directive cannot live in `backend/internal/db/migrate.go` because `backend/migrations/` is 2 levels up and `go:embed` cannot use `..`. The embed directive MUST live in `backend/assets/assets.go` (or `cmd/web/main.go`). Pass the `embed.FS` into `RunMigrations` as a parameter, or expose it via the assets package.
**Preferred signature** (accepts embed.FS as parameter so the function itself does not carry an embed directive):
```go
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsFS embed.FS) error
```
Called from `cmd/web/main.go` as:
```go
db.RunMigrations(ctx, pool, assets.Migrations)
```
---
### `backend/assets/assets.go` (new, utility/embed)
**Analog:** None in this codebase — first `go:embed` usage. Pattern sourced from RESEARCH Pattern 1.
**Full file pattern:**
```go
// Package assets embeds the static web assets and SQL migration files into the
// binary at build time (D-09, D-10). No runtime file dependencies.
package assets
import "embed"
// Static holds the compiled CSS, HTMX JS, and Sortable.js served at /static/*.
// The embed directive must be relative to this file; backend/static/ is a sibling
// of backend/assets/ at the module root level.
//go:embed static
var Static embed.FS
// Migrations holds the goose SQL migration files embedded for programmatic
// execution at startup (D-10).
//go:embed migrations
var Migrations embed.FS
```
**Path verification required:** This file sits at `backend/assets/assets.go`. The `//go:embed static` path resolves to `backend/assets/static` — but `static/` is at `backend/static/`. The directory must either be moved inside `backend/assets/`, symlinked, or the embed directive must be placed in a file at `backend/` itself (e.g., `backend/embed.go`). See RESEARCH Pitfall 1 and Open Question 1.
**Alternative (embed.go at module root):**
```go
// backend/embed.go
package main // or a dedicated package
//go:embed static
var Static embed.FS
//go:embed migrations
var Migrations embed.FS
```
This works because `backend/embed.go` is a sibling of `backend/static/` and `backend/migrations/`. The planner should prefer this approach over an `assets/` package if the path constraint cannot be resolved cleanly.
---
### `backend/internal/web/handlers.go` (modify, handler/request-response)
**Analog:** `backend/internal/web/handlers.go` itself — refactor existing HealthzHandler
**Existing HealthzHandler pattern** (handlers.go lines 1635) — this becomes ReadyzHandler:
```go
func HealthzHandler(pinger Pinger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/json")
if err := pinger.Ping(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "degraded",
"db": "down",
})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"db": "ok",
})
}
}
```
**Required changes:**
1. Rename existing `HealthzHandler(pinger Pinger)``ReadyzHandler(pinger Pinger)` (keep all logic identical)
2. Add new `HealthzHandler()` with no pinger dependency:
```go
// HealthzHandler is a pure liveness check (D-12). Returns 200 immediately
// as long as the server process is up. No DB ping.
func HealthzHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
}
```
**Imports:** Remove `"context"` and `"time"` from `HealthzHandler` imports (they move to `ReadyzHandler`). Both handlers remain in `handlers.go`.
---
### `backend/internal/web/router.go` (modify, config/request-response)
**Analog:** `backend/internal/web/router.go` itself
**Existing static file serving pattern** (router.go line 120121):
```go
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
r.Get("/static/*", fs.ServeHTTP)
```
**Replacement pattern using embed.FS** (RESEARCH Pattern 1 / Code Examples):
```go
import "io/fs"
// In NewRouter signature: change `staticDir string``staticFS fs.FS`
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("static embed sub failed: " + err.Error())
}
fileServer := http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
r.Get("/static/*", fileServer.ServeHTTP)
```
**Signature change** (router.go line 47):
```go
// BEFORE:
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, ...) http.Handler
// AFTER:
func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, ...) http.Handler
```
**Route changes** (router.go line 117):
```go
// BEFORE:
r.Get("/healthz", HealthzHandler(pinger))
// AFTER:
r.Get("/healthz", HealthzHandler()) // D-12: liveness, no pinger
r.Get("/readyz", ReadyzHandler(pinger)) // D-13: readiness, DB ping
```
**Test compatibility note:** All existing tests that call `NewRouter(stubPinger{}, "./static", ...)` must change their second argument. Tests can pass `os.DirFS("./static")` to avoid embedding during unit test runs.
---
### `backend/internal/web/handlers_test.go` (modify, test/request-response)
**Analog:** `backend/internal/web/handlers_test.go` itself
**Existing tests to update** (handlers_test.go lines 2461):
`TestHealthz_OK` (lines 2443) — change to test the new no-pinger liveness handler:
```go
func TestHealthz_OK(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
// BEFORE: HealthzHandler(stubPinger{err: nil})
// AFTER: HealthzHandler() — no pinger dependency
HealthzHandler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
// Remove db:ok assertion — new liveness handler only returns {"status":"ok"}
if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Errorf("body missing status:ok; got: %s", rec.Body.String())
}
}
```
`TestHealthz_Down` (lines 4561) — delete this test (the new HealthzHandler has no failure mode).
**New tests to add** (mirror old TestHealthz structure, now for ReadyzHandler):
```go
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)
}
body := rec.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("body missing status:ok; got: %s", body)
}
if !strings.Contains(body, `"db":"ok"`) {
t.Errorf("body missing db:ok; got: %s", body)
}
}
func TestReadyz_Down(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
ReadyzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d; want 503", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `"status":"degraded"`) {
t.Errorf("body missing status:degraded; got: %s", body)
}
if !strings.Contains(body, `"db":"down"`) {
t.Errorf("body missing db:down; got: %s", body)
}
}
```
**stubPinger reuse:** The existing `stubPinger` struct (handlers_test.go lines 1822) is unchanged — it satisfies `Pinger` and is used by the new ReadyzHandler tests identically.
**TestIndex_UnauthRedirects and other router-level tests** (handlers_test.go lines 68121) — all pass `"./static"` as second arg to `NewRouter`. Change to `os.DirFS("./static")` after the signature changes. Requires adding `"os"` to imports.
---
### `backend/Dockerfile` (new, config/batch)
**Analog:** No Go Dockerfile in this codebase. Pattern sourced from RESEARCH Pattern 3.
**Key decisions from justfile** (justfile lines 2081 — pinned versions to match):
- `templ_version := "v0.3.1020"` — use this exact version in `go install github.com/a-h/templ/cmd/templ@v0.3.1020`
- `tailwind_version := "v4.0.0"` — Tailwind standalone CLI download
- `htmx_version := "2"` — unpkg.com HTMX download
- `sortable_version := "1.15.7"` — CDN Sortable.js download
- Builder base: `golang:1.26-alpine` (matches go.mod `go 1.26.1`)
- Runtime base: `gcr.io/distroless/static-debian12:nonroot`
**Asset generation step** (mirrors justfile `bootstrap` + `generate` recipes):
```dockerfile
# Stage 1: Generate static assets (CSS + JS)
FROM node:20-alpine AS assets
WORKDIR /build
RUN apk add --no-cache curl
# Download Tailwind standalone CLI (pinned version from justfile)
RUN curl -sSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.0/tailwindcss-linux-x64" \
&& chmod +x /usr/local/bin/tailwindcss
# Download HTMX and Sortable.js (versions pinned in justfile)
RUN curl -sSL -o static/htmx.min.js "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
&& curl -sSL -o static/sortable.min.js \
"https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"
COPY tailwind.input.css .
COPY templates/ templates/
RUN tailwindcss -i tailwind.input.css -o static/tailwind.css --minify
```
**Go build stage** (CGO_ENABLED=0 is mandatory for distroless/static — RESEARCH Pitfall 7):
```dockerfile
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=assets /build/static ./static
# templ generate BEFORE go build — *_templ.go files are gitignored (RESEARCH Pitfall 6)
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" -trimpath -o /app/web ./cmd/web
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux \
go build -ldflags="-s -w" -trimpath -o /app/worker ./cmd/worker
```
**Runtime stage** (D-08: no CMD — compose overrides with `command: /app/web` or `/app/worker`):
```dockerfile
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/web /app/web
COPY --from=builder /app/worker /app/worker
EXPOSE 8080
# No CMD — docker-compose.prod.yaml sets `command:` per service (D-08)
```
---
### `backend/deploy/Caddyfile` (new, config/request-response)
**Analog:** None in codebase.
**Pattern** (RESEARCH Pattern 6 — simple single-site reverse proxy):
```caddyfile
{$DOMAIN} {
reverse_proxy web:8080
}
```
`{$DOMAIN}` is Caddy's env var interpolation syntax. The host `.env.prod` must set `DOMAIN=app.yourdomain.com`. Caddy automatically handles HTTP→HTTPS redirect and Let's Encrypt certificate issuance when a domain (not IP) is specified.
**Persistent volume requirement:** The caddy service in docker-compose.prod.yaml MUST mount named volumes at `/data` and `/config` — without them, certificates are re-issued on every restart and hit Let's Encrypt rate limits (RESEARCH Pitfall 4).
---
### `backend/.env.example` (modify, config)
**Analog:** `backend/.env.example` itself
**Existing vars** (current .env.example):
```bash
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
SESSION_SECRET=
PORT=8080
ENV=development
```
**Add these vars** (R2 / production vars per D-05, D-06):
```bash
# S3-compatible object storage (Cloudflare R2 in prod; MinIO in local dev via compose.yaml)
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=xtablo-dev
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_USE_PATH_STYLE=true # true for MinIO; false for R2
# Maximum upload size in megabytes (default: 25)
MAX_UPLOAD_SIZE_MB=25
# Production domain for Caddy TLS (only needed in docker-compose.prod.yaml context)
# DOMAIN=app.yourdomain.com
```
**Note on TEST_DATABASE_URL:** The comment in CONTEXT.md and RESEARCH.md says to remove the `TEST_DATABASE_URL` note. Keep the var but update the comment to clarify it's dev/test-only and should not be set in the prod `.env.prod` file.
---
### `backend/README.md` (modify, documentation)
**Analog:** `backend/README.md` itself (lines 1240 exist; add sections after line 240)
**Sections to add** (append to existing README after the "What Phase 1 ships" section):
The existing README structure uses H2 headers (`##`) for top-level sections. New sections follow the same pattern:
```markdown
## Deploy
Production runs on a Hetzner VM via plain Docker Compose. No PaaS, no Kubernetes (D-01, D-02).
### First-time setup
[SSH, docker install, image build, .env.prod creation, docker compose up steps]
### Deploying a new version
[docker build, tag, compose pull/up steps]
## Rollback
Normal rollback = change image tag in .env.prod + `docker compose up -d` (D-11).
[exact commands]
## Incident runbook
### /readyz returns 503
### Database unreachable
### Caddy TLS certificate errors
[break-glass steps including goose down documentation]
```
---
## Shared Patterns
### slog structured logging
**Source:** `backend/cmd/web/main.go` lines 4141 and `backend/internal/web/slog.go`
**Apply to:** All new Go files that log at startup or on error
```go
slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout)))
// Then use slog.Error / slog.Info / slog.Warn directly
slog.Error("migrations failed", "err", err)
os.Exit(1)
```
### os.Exit(1) on startup failure
**Source:** `backend/cmd/web/main.go` lines 4347, `backend/cmd/worker/main.go` lines 3336
**Apply to:** `db.RunMigrations` call in `cmd/web/main.go`
```go
if err := someStartupStep(...); err != nil {
slog.Error("step failed", "err", err)
os.Exit(1)
}
```
### httptest handler testing
**Source:** `backend/internal/web/handlers_test.go` lines 2443
**Apply to:** TestReadyz_OK, TestReadyz_Down, updated TestHealthz_OK
```go
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/route", nil)
MyHandler(args...).ServeHTTP(rec, req)
if rec.Code != http.StatusOK { t.Fatalf(...) }
```
### pgxpool DSN extraction for sql.DB bridge
**Source:** RESEARCH Pattern 2 (no existing codebase usage — first occurrence)
**Apply to:** `backend/internal/db/migrate.go`
```go
dsn := pool.Config().ConnConfig.ConnString()
db, err := sql.Open("pgx/v5", dsn)
// Import _ "github.com/jackc/pgx/v5/stdlib" to register the driver
```
---
## No Analog Found
Files with no close match in the codebase (planner should use RESEARCH.md patterns instead):
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `backend/deploy/Caddyfile` | config | request-response | No reverse proxy config exists in codebase; use RESEARCH Pattern 6 exactly |
| `backend/assets/assets.go` | utility | embed | No `go:embed` usage exists anywhere in this codebase; use RESEARCH Pattern 1; verify embed path resolution before committing |
| `backend/Dockerfile` | config | batch | No Go Dockerfile in this repo; TS `apps/api/Dockerfile` exists but is Node-based and not a useful pattern source; use RESEARCH Pattern 3 |
---
## Critical Implementation Warnings
1. **go:embed path constraint (RESEARCH Pitfall 1):** The `//go:embed` path must be relative to the .go file and cannot use `..`. Since `static/` and `migrations/` are at `backend/`, the embed directive file must also be at `backend/` — either as `backend/embed.go` (a file in `package main` or a new package) or inside `backend/assets/` if the directories are moved there. Verify before implementing.
2. **templ generate in Docker (RESEARCH Pitfall 6):** `*_templ.go` files are gitignored. The Dockerfile builder stage MUST run `templ generate` at the pinned version (`v0.3.1020` from justfile) before `go build`, or the build will fail with undefined template functions.
3. **goose needs *sql.DB not *pgxpool.Pool (RESEARCH Pitfall 2):** Must import `_ "github.com/jackc/pgx/v5/stdlib"` and bridge via `sql.Open("pgx/v5", connStr)`. Close the `*sql.DB` with `defer db.Close()` after migrations — the pool remains open for app use.
4. **Postgres ports in prod compose:** Do NOT add `ports:` to the postgres service in `docker-compose.prod.yaml`. The postgres service is internal-only. The dev `compose.yaml` exposes `5432:5432` for local tooling — this must not be replicated in prod.
5. **caddy_data volume:** Must be a named volume in `docker-compose.prod.yaml`. Without it, Caddy re-issues certificates on every restart and will hit Let's Encrypt rate limits (RESEARCH Pitfall 4).
6. **web service depends_on with health condition:** `depends_on: postgres: condition: service_healthy` is required so `goose.Up()` does not race Postgres startup. The dev `compose.yaml` postgres service already has a `healthcheck:` — mirror it in the prod compose.
## Metadata
**Analog search scope:** `backend/cmd/`, `backend/internal/`, `backend/compose.yaml`, `backend/.env.example`, `backend/README.md`, `backend/justfile`
**Files scanned:** 14
**Pattern extraction date:** 2026-05-15

View file

@ -546,22 +546,17 @@ func RunMigrations(pool *pgxpool.Pool, migrationsFS embed.FS) error {
| A3 | Tailwind standalone binary download in Docker builder is reliable during CI/CD | Pattern 3 (Dockerfile) | If the external download is flaky, bake the Tailwind binary into the builder image or add it to the repo as a committed artifact |
| A4 | `pool.Config().ConnConfig.ConnString()` reconstructs a DSN compatible with `sql.Open("pgx/v5", ...)` | Pattern 2 (goose bridge) | If ConnString() omits sslmode or other params, pass the original DATABASE_URL env var directly to sql.Open instead |
## Open Questions
## Open Questions (RESOLVED)
1. **embed.FS path for migrations/ in internal/db/migrate.go**
- What we know: `//go:embed` paths are relative to the Go source file and cannot use `..` to go above the module root.
- What's unclear: Whether `backend/internal/db/migrate.go` can embed `../../migrations/*.sql` — the `migrations/` directory is at `backend/migrations/`, and `internal/db/` is 2 levels deep.
- Recommendation: Test during Wave 1 implementation. If `../../migrations` is rejected, move the embed directive to an `assets` package at `backend/assets/` or to `cmd/web/main.go` itself and pass the `embed.FS` into `RunMigrations`.
1. **embed.FS path for migrations/ — RESOLVED: use `backend/embed.go` at module root**
- Resolution: No `.go` files exist in `backend/` root (verified: `ls backend/*.go` → no matches). Module is `module backend` (verified: `backend/go.mod` line 1). Create `backend/embed.go` with `package assets` — the embed directives `//go:embed all:static` and `//go:embed migrations` resolve correctly because `static/` and `migrations/` are siblings of the file. Import in `cmd/web/main.go` as `import assets "backend"`.
- The `../../migrations` path from `internal/db/migrate.go` is invalid (go:embed cannot use `..`). The `assets` package approach is the correct solution.
2. **templ generate in Docker build**
- What we know: `*_templ.go` files are gitignored (STATE.md); the build fails without them.
- What's unclear: Whether `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` in the builder stage is fast enough or needs caching.
- Recommendation: Use `--mount=type=cache,target=/root/.cache/go-build` on the `go build` steps; the templ generate step is fast (it's pure Go → Go codegen, no compilation).
2. **templ generate in Docker build — RESOLVED: add to builder stage with go build cache**
- Resolution: `RUN go install github.com/a-h/templ/cmd/templ@v0.3.1020 && templ generate` runs in the builder stage before `go build`. Use `--mount=type=cache,target=/root/.cache/go-build` on `go build` steps to keep the build fast. templ generate is pure Go codegen — fast and deterministic.
3. **go:embed and files starting with `.` or `_`**
- What we know: By default, `//go:embed` excludes files/dirs starting with `.` or `_`.
- What's unclear: Whether `static/` contains any such files (e.g., `.gitkeep`).
- Recommendation: Check `ls -la backend/static/` during implementation. If such files exist, use `//go:embed all:static`.
3. **go:embed and `.gitkeep` in static/ — RESOLVED: use `//go:embed all:static`**
- Resolution: `backend/static/` contains `.gitkeep` (verified: `ls -la backend/static/` confirms `.gitkeep` exists). The `all:` prefix is required so `.gitkeep` is included. Use `//go:embed all:static` for Static and `//go:embed migrations` for Migrations (migrations/ has no hidden files).
## Environment Availability