docs(01): create Phase 1 foundation plans (4 plans, 3 waves)
This commit is contained in:
parent
4d745f82c3
commit
5f8af63e5e
6 changed files with 1520 additions and 0 deletions
|
|
@ -37,6 +37,13 @@
|
|||
|
||||
**User-in-loop:** Approve directory layout (`backend/cmd/web`, `backend/cmd/worker`, `backend/internal/...`) and pick the migration tool (`goose` vs `golang-migrate`).
|
||||
|
||||
**Plans:** 4 plans
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Project scaffold: Go module, compose, justfile, env, sqlc/air/tailwind config, bootstrap migration
|
||||
- [ ] 01-02-PLAN.md — RED gate: failing handler tests + `internal/web/ui` design-system package (Button/Card/Badge)
|
||||
- [ ] 01-03-PLAN.md — GREEN slice: pgxpool, chi router, middleware, templates, cmd/web + cmd/worker, end-to-end HTMX demo
|
||||
- [ ] 01-04-PLAN.md — README quickstart + clean-clone onboarding walkthrough (closes FOUND-05)
|
||||
|
||||
### Phase 2: Authentication
|
||||
**Goal:** A new user can sign up, log in with email + password, and stay logged in across requests using server-managed sessions.
|
||||
**Mode:** mvp
|
||||
|
|
|
|||
339
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
339
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
---
|
||||
phase: 01-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/go.mod
|
||||
- backend/go.sum
|
||||
- backend/.env.example
|
||||
- backend/.gitignore
|
||||
- backend/.air.toml
|
||||
- backend/compose.yaml
|
||||
- backend/justfile
|
||||
- backend/sqlc.yaml
|
||||
- backend/tailwind.input.css
|
||||
- backend/migrations/0001_init.sql
|
||||
- backend/internal/db/doc.go
|
||||
- backend/internal/session/doc.go
|
||||
- backend/internal/tablos/doc.go
|
||||
- backend/internal/tasks/doc.go
|
||||
- backend/internal/files/doc.go
|
||||
autonomous: false
|
||||
requirements:
|
||||
- FOUND-01
|
||||
- FOUND-02
|
||||
- FOUND-05
|
||||
tags:
|
||||
- go
|
||||
- scaffold
|
||||
- tooling
|
||||
- postgres
|
||||
- tailwind
|
||||
- htmx
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Running `just bootstrap` from a fresh clone installs goose, templ, sqlc, air CLIs and downloads the Tailwind standalone binary + htmx.min.js into backend/bin and backend/static"
|
||||
- "Running `just db-up` (or `podman compose up -d postgres`) starts a healthy Postgres 16 container reachable at localhost:5432"
|
||||
- "Running `just migrate up` against the running Postgres applies migration 0001_init.sql cleanly and `just migrate status` shows it applied"
|
||||
- "Running `just --list` shows recipes for at least: bootstrap, dev, db-up, db-down, migrate, generate, styles-watch, test, lint, build"
|
||||
- "Tailwind input CSS declares @source globs that include backend/templates/**/*.templ and backend/internal/web/**/*.{templ,go} so JIT does not purge classes used only in templ files"
|
||||
- ".env.example documents DATABASE_URL, PORT, ENV and .env is gitignored"
|
||||
artifacts:
|
||||
- path: backend/go.mod
|
||||
provides: Go module declaration with required deps (chi, templ, pgx, goose, uuid)
|
||||
contains: "module backend"
|
||||
- path: backend/compose.yaml
|
||||
provides: podman/docker compose service for postgres:16-alpine with pg_isready healthcheck
|
||||
contains: "postgres:16-alpine"
|
||||
- path: backend/justfile
|
||||
provides: task runner recipes (bootstrap, dev, db-up, db-down, migrate, generate, styles-watch, test, lint, build)
|
||||
contains: "bootstrap"
|
||||
- path: backend/migrations/0001_init.sql
|
||||
provides: goose-formatted no-op bootstrap migration exercising the migration pipeline
|
||||
contains: "+goose Up"
|
||||
- path: backend/sqlc.yaml
|
||||
provides: sqlc configuration pointing at migrations/ as schema source, internal/db/queries as queries source, emitting pgx/v5 code
|
||||
contains: "sql_package"
|
||||
- path: backend/tailwind.input.css
|
||||
provides: Tailwind v4 entry CSS with @source globs for templ files and @import lines for ui/*.css
|
||||
contains: "@source"
|
||||
- path: backend/.air.toml
|
||||
provides: air live-reload config for Go + templ
|
||||
contains: "templ generate"
|
||||
- path: backend/.env.example
|
||||
provides: Required env keys (DATABASE_URL, PORT, ENV)
|
||||
contains: "DATABASE_URL"
|
||||
- path: backend/.gitignore
|
||||
provides: Excludes bin/, tmp/, .env, static/tailwind.css, static/htmx.min.js, *_templ.go
|
||||
contains: ".env"
|
||||
- path: backend/internal/db/doc.go
|
||||
provides: db package marker (Phase 1 placeholder; pgxpool wiring lands in Plan 03)
|
||||
contains: "package db"
|
||||
- path: backend/internal/session/doc.go
|
||||
provides: session package placeholder (populated Phase 2)
|
||||
contains: "package session"
|
||||
- path: backend/internal/tablos/doc.go
|
||||
provides: tablos package placeholder (populated Phase 3)
|
||||
contains: "package tablos"
|
||||
- path: backend/internal/tasks/doc.go
|
||||
provides: tasks package placeholder (populated Phase 4)
|
||||
contains: "package tasks"
|
||||
- path: backend/internal/files/doc.go
|
||||
provides: files package placeholder (populated Phase 5)
|
||||
contains: "package files"
|
||||
key_links:
|
||||
- from: backend/justfile (bootstrap recipe)
|
||||
to: backend/bin/tailwindcss + backend/static/htmx.min.js
|
||||
via: curl download to pinned versions
|
||||
pattern: "curl.*tailwindcss"
|
||||
- from: backend/justfile (migrate recipe)
|
||||
to: backend/migrations/0001_init.sql
|
||||
via: goose CLI with GOOSE_MIGRATION_DIR=migrations
|
||||
pattern: "GOOSE_MIGRATION_DIR=migrations"
|
||||
- from: backend/sqlc.yaml
|
||||
to: backend/migrations/
|
||||
via: schema path so sqlc reads the same SQL files goose runs
|
||||
pattern: "schema:.*migrations"
|
||||
- from: backend/tailwind.input.css
|
||||
to: backend/templates/**/*.templ and backend/internal/web/**/*.{templ,go}
|
||||
via: "@source" directives
|
||||
pattern: "@source.*templ"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Lay down the entire `backend/` project skeleton: Go module, directory layout, local Postgres compose file, justfile, env example, gitignore, air config, sqlc config, Tailwind input CSS, the no-op goose bootstrap migration, and `doc.go` placeholders for every `internal/<domain>` package locked in CONTEXT D-01/D-02.
|
||||
|
||||
Purpose: Every subsequent plan in this phase (and the rest of v1) depends on a working `cd backend && just bootstrap && just db-up && just migrate up` loop. This plan delivers that loop without yet writing any Go application code or handlers — pure infra.
|
||||
|
||||
Output: `backend/` exists with a valid Go module, all directory placeholders, working `just` recipes, a healthy compose-managed Postgres, and one applied bootstrap migration.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation/01-UI-SPEC.md
|
||||
@.planning/phases/01-foundation/01-VALIDATION.md
|
||||
@.planning/phases/01-foundation/SKELETON.md
|
||||
|
||||
# Reference scaffolds — read for shape, do NOT copy wholesale (they use pnpm Tailwind which we are dropping)
|
||||
@go-backend/justfile
|
||||
@go-backend/compose.yaml
|
||||
@go-backend/sqlc.yaml
|
||||
|
||||
<interfaces>
|
||||
# Phase 1 has no upstream Go interfaces to honor. The shape this plan establishes:
|
||||
# - Module path: `backend`
|
||||
# - Tailwind input file path (relative to backend/): `tailwind.input.css`
|
||||
# - Tailwind output path: `static/tailwind.css`
|
||||
# - Goose migration dir: `migrations/`
|
||||
# - sqlc schema source: `migrations/`
|
||||
# - sqlc queries source: `internal/db/queries/` (empty in Phase 1)
|
||||
# - sqlc output: `internal/db/sqlc/` (empty in Phase 1)
|
||||
# - Air tmp dir: `tmp/`
|
||||
# - Local DB DSN (dev only, in justfile): `postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable`
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Initialize Go module and pin runtime dependencies</name>
|
||||
<files>backend/go.mod, backend/go.sum</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Standard Stack section — pinned versions are authoritative)
|
||||
- .planning/phases/01-foundation/SKELETON.md (locked versions table)
|
||||
</read_first>
|
||||
<action>From repo root, create `backend/`, then `cd backend && go mod init backend`. Pin the following runtime deps with `go get` at the exact versions from RESEARCH.md Standard Stack: `github.com/go-chi/chi/v5@v5.2.5`, `github.com/a-h/templ@v0.3.1020`, `github.com/jackc/pgx/v5@v5.9.2`, `github.com/pressly/goose/v3@v3.27.1`, `github.com/google/uuid@v1.6.0`. Do NOT use any version other than those listed. Run `go mod tidy` so `go.sum` is populated. The Go directive should match the developer's installed Go (1.22 minimum; existing `go-backend/go.mod` uses 1.26 — use the same `go` line if available, otherwise `1.22`). Do not import or fetch `air`, `sqlc`, `templ` CLI, `goose` CLI as Go module deps in `go.mod` — those are installed via `go install` from the `just bootstrap` recipe (Task 6).</action>
|
||||
<verify>
|
||||
<automated>cd backend && go mod verify && grep -q 'github.com/go-chi/chi/v5 v5.2.5' go.mod && grep -q 'github.com/a-h/templ v0.3.1020' go.mod && grep -q 'github.com/jackc/pgx/v5 v5.9.2' go.mod && grep -q 'github.com/pressly/goose/v3 v3.27.1' go.mod && grep -q 'github.com/google/uuid v1.6.0' go.mod</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `backend/go.mod` declares `module backend`
|
||||
- All five runtime deps pinned at the versions above (verified by grep, not just presence)
|
||||
- `go mod verify` exits 0
|
||||
- `go.sum` is populated and committed
|
||||
</acceptance_criteria>
|
||||
<done>`backend/go.mod` and `backend/go.sum` exist, module is `backend`, all five deps pinned at locked versions, `go mod verify` passes.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create directory skeleton and per-package doc.go placeholders</name>
|
||||
<files>backend/internal/db/doc.go, backend/internal/session/doc.go, backend/internal/tablos/doc.go, backend/internal/tasks/doc.go, backend/internal/files/doc.go, backend/internal/db/queries/.gitkeep, backend/internal/db/sqlc/.gitkeep, backend/templates/.gitkeep, backend/migrations/.gitkeep, backend/bin/.gitkeep, backend/static/.gitkeep</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-CONTEXT.md (D-01, D-02 — directory layout is non-negotiable)
|
||||
- .planning/phases/01-foundation/SKELETON.md (Directory layout section)
|
||||
</read_first>
|
||||
<action>Create the directory tree exactly as locked in D-01/D-02 and mirrored in SKELETON.md. For each placeholder Go package directory, write a minimal `doc.go` containing only a package clause and a one-line comment naming the phase that will populate it. Example shape (do NOT add anything else): a `// Package files is a placeholder; the upload/storage implementation lands in Phase 5.` line above `package files`. The five required doc.go files cover packages: `db`, `session`, `tablos`, `tasks`, `files` — per D-02 ("creates the directory skeleton for all internal/<domain> packages (empty doc.go is fine) so later phases drop files in without restructuring"). Use `.gitkeep` empty files for directories that have no Go files yet (`migrations/`, `templates/`, `bin/`, `static/`, `internal/db/queries/`, `internal/db/sqlc/`). Do NOT create `internal/web/` here — Plan 02 owns that package (handler tests + ui design-system). Do NOT create `cmd/web/` or `cmd/worker/` here — Plan 03 owns those entrypoints. This task strictly delivers the placeholder skeleton.</action>
|
||||
<verify>
|
||||
<automated>cd backend && for p in db session tablos tasks files; do test -f "internal/$p/doc.go" && grep -q "^package $p$" "internal/$p/doc.go" || { echo "missing or wrong package: $p"; exit 1; }; done && test -d migrations && test -d templates && test -d bin && test -d static && go build ./internal/...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Each of `internal/{db,session,tablos,tasks,files}/doc.go` exists and declares the correct package
|
||||
- `internal/db/queries/`, `internal/db/sqlc/`, `migrations/`, `templates/`, `bin/`, `static/` directories exist (via `.gitkeep`)
|
||||
- `go build ./internal/...` succeeds (empty packages compile)
|
||||
- `internal/web/`, `cmd/web/`, `cmd/worker/` are NOT created by this task
|
||||
</acceptance_criteria>
|
||||
<done>Directory skeleton matches D-01/D-02 verbatim; placeholder Go packages compile.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Compose file, .env.example, .gitignore, bootstrap goose migration</name>
|
||||
<files>backend/compose.yaml, backend/.env.example, backend/.gitignore, backend/migrations/0001_init.sql</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Code Examples section — compose.yaml and .env.example are verbatim references; D-15, D-20)
|
||||
- go-backend/compose.yaml (existing healthy reference; strip dev seed mounts as the research note instructs)
|
||||
</read_first>
|
||||
<action>Write `backend/compose.yaml` based on the verbatim block in RESEARCH.md Code Examples. Required: service name `postgres`, image `postgres:16-alpine`, container name `xtablo-backend-postgres`, env `POSTGRES_DB=xtablo`, `POSTGRES_USER=xtablo`, `POSTGRES_PASSWORD=xtablo`, port mapping `5432:5432`, named volume `postgres_data`, `pg_isready -U xtablo -d xtablo` healthcheck (interval 5s, timeout 5s, retries 10), `restart: unless-stopped`. Do NOT include the seed-mount volume from `go-backend/compose.yaml` — Phase 1 has nothing to seed. Write `backend/.env.example` with exactly the three keys locked in D-15: `DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable`, `PORT=8080`, `ENV=development`. Add a brief comment line above each key. Write `backend/.gitignore` covering: `bin/` (Tailwind binary + go-installed CLIs may live here), `tmp/` (air rebuild output), `.env`, `.env.local`, `static/tailwind.css` (generated), `static/htmx.min.js` (vendored on bootstrap, not committed), `*_templ.go` (templ-generated; per RESEARCH Pitfall 1 these are never committed), `internal/db/sqlc/*.go` (generated, except keep `.gitkeep`). Write `backend/migrations/0001_init.sql` exactly per RESEARCH.md Code Examples — goose annotations `-- +goose Up` and `-- +goose Down`, each section containing `SELECT 1;`. The file MUST start with `-- +goose Up` on the first non-comment line (goose parser requirement).</action>
|
||||
<verify>
|
||||
<automated>cd backend && grep -q 'postgres:16-alpine' compose.yaml && grep -q 'pg_isready' compose.yaml && grep -q '^DATABASE_URL=' .env.example && grep -q '^PORT=' .env.example && grep -q '^ENV=' .env.example && grep -q '^\.env$' .gitignore && grep -q 'tailwind.css' .gitignore && grep -q 'htmx.min.js' .gitignore && grep -q '_templ\.go' .gitignore && grep -q '\-\- +goose Up' migrations/0001_init.sql && grep -q '\-\- +goose Down' migrations/0001_init.sql</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `compose.yaml` declares postgres:16-alpine with a pg_isready healthcheck
|
||||
- `.env.example` contains exactly DATABASE_URL, PORT, ENV (per D-15)
|
||||
- `.gitignore` excludes bin/, tmp/, .env, generated CSS, vendored htmx, *_templ.go
|
||||
- `0001_init.sql` is a valid goose file with `-- +goose Up` / `-- +goose Down` markers and a no-op body
|
||||
- No seed-mount carryover from go-backend/compose.yaml
|
||||
</acceptance_criteria>
|
||||
<done>Local Postgres can be brought up via `podman compose up -d postgres` (verified in Task 7); the bootstrap migration file is goose-parseable.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: sqlc config, Tailwind input CSS, air config</name>
|
||||
<files>backend/sqlc.yaml, backend/tailwind.input.css, backend/.air.toml</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Pitfall 3, Pitfall 5, Pitfall 7, Code Examples for .air.toml)
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (Tailwind Configuration Contract — the @source globs and @import lines are exact)
|
||||
- go-backend/sqlc.yaml (reference shape only)
|
||||
</read_first>
|
||||
<action>Write `backend/sqlc.yaml` with `version: "2"`, one `sql` entry: `engine: postgresql`, `schema: "migrations"` (Pitfall 7 — point sqlc at the goose migrations dir so the schema sources match), `queries: "internal/db/queries"`, output `gen.go` with `package: "sqlc"`, `out: "internal/db/sqlc"`, `sql_package: "pgx/v5"`, `emit_json_tags: false`, `emit_interface: false`. Write `backend/tailwind.input.css` verbatim per UI-SPEC §Tailwind Configuration Contract: line 1 `@import "tailwindcss";`, then `@source "../templates/**/*.templ";`, `@source "../internal/web/**/*.templ";`, `@source "../internal/web/**/*.go";`, then `@import "../internal/web/ui/base.css";`, `@import "../internal/web/ui/button.css";`, `@import "../internal/web/ui/card.css";`, `@import "../internal/web/ui/badge.css";`. Note: the four `ui/*.css` files are created in Plan 02. They do not exist yet when this plan runs — that's fine, Tailwind v4 will error only when invoked. The CSS file pins the contract; the styles compile step is exercised by Plan 03's verify (after Plan 02 lands the ui CSS files). Write `backend/.air.toml` per RESEARCH.md Code Examples: `root = "."`, `tmp_dir = "tmp"`, `[build]` with `cmd = "templ generate && go build -o ./tmp/web ./cmd/web"`, `bin = "./tmp/web"`, `include_ext = ["go", "templ"]`, `exclude_dir = ["tmp", "bin", "static", ".git", "internal/db/sqlc"]`, `exclude_regex = [".*_templ\\.go$"]`, `delay = 200`, `stop_on_error = true`. Per RESEARCH Open Question 2 the recommendation is two-terminal workflow (Tailwind watch separate from air); do NOT add Tailwind to air's pre_cmd.</action>
|
||||
<verify>
|
||||
<automated>cd backend && grep -q '^version: "2"' sqlc.yaml && grep -q 'engine: postgresql' sqlc.yaml && grep -q 'schema: "migrations"' sqlc.yaml && grep -q 'sql_package: "pgx/v5"' sqlc.yaml && grep -q '^@import "tailwindcss";' tailwind.input.css && grep -c '@source' tailwind.input.css | awk '$1>=3{exit 0} {exit 1}' && grep -q 'internal/web/ui/button.css' tailwind.input.css && grep -q 'include_ext = \["go", "templ"\]' .air.toml && grep -q 'exclude_regex = \[".*_templ' .air.toml && grep -q 'templ generate' .air.toml</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- sqlc.yaml schema source points at `migrations/` (Pitfall 7)
|
||||
- tailwind.input.css declares ≥3 @source globs covering templ files and internal/web Go files (Pitfall 3)
|
||||
- tailwind.input.css @imports the four `internal/web/ui/*.css` files exactly as UI-SPEC requires
|
||||
- .air.toml watches `.go` + `.templ`, excludes generated `*_templ.go` (Pitfall 5), runs `templ generate` before build
|
||||
- Tailwind is NOT added to air's pre_cmd (RESEARCH Open Question 2 recommendation)
|
||||
</acceptance_criteria>
|
||||
<done>sqlc, Tailwind, and air configs match UI-SPEC + RESEARCH-mandated shapes; later plans can rely on these contracts.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: justfile with bootstrap, db, migrate, generate, dev, test, lint, build recipes</name>
|
||||
<files>backend/justfile</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Code Examples section — justfile recipes are the canonical reference; Environment Availability table for tool install commands and pinned versions)
|
||||
- .planning/phases/01-foundation/01-CONTEXT.md (D-05, D-11, D-12, D-13)
|
||||
- go-backend/justfile (shape only — strip pnpm Tailwind, retain podman + templ + sqlc + air pattern)
|
||||
</read_first>
|
||||
<action>Write `backend/justfile` modeled on the RESEARCH Code Examples block. Required at minimum, these recipes (names exact):
|
||||
|
||||
- `default` → `@just --list`
|
||||
- `bootstrap` → mkdir bin, then `go install` the four CLI tools at the exact RESEARCH-pinned versions: `github.com/pressly/goose/v3/cmd/goose@v3.27.1`, `github.com/a-h/templ/cmd/templ@v0.3.1020`, `github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1`, `github.com/air-verse/air@v1.65.1`. Then `curl -sSL` the Tailwind v4 standalone binary into `./bin/tailwindcss` (use the platform-detection pattern `tailwindcss-$(uname -s | tr A-Z a-z)-$(uname -m)`) and `chmod +x`. Then `curl -sSL` the HTMX v2.x `htmx.min.js` from `unpkg.com/htmx.org@2/dist/htmx.min.js` into `./static/htmx.min.js` (vendored once at bootstrap; subsequently committed-via-gitignore semantics — per CONTEXT D-10 we vendor, never CDN). Pin the Tailwind version in a recipe-local variable at the top of the justfile (e.g., `tailwind_version := "v4.0.0"` — planner: use the latest 4.x stable available at bootstrap time; document the version in a comment).
|
||||
- `db-up` → `podman compose up -d postgres`
|
||||
- `db-down` → `podman compose down`
|
||||
- `migrate cmd="status"` → invokes `goose` with `GOOSE_DRIVER=postgres`, `GOOSE_DBSTRING` set to the dev DSN, `GOOSE_MIGRATION_DIR=migrations`, passing `{{cmd}}` (so `just migrate up`, `just migrate down`, `just migrate status` all work)
|
||||
- `generate` → runs `templ generate`, then `sqlc generate`, then `./bin/tailwindcss -i tailwind.input.css -o static/tailwind.css`
|
||||
- `styles-watch` → `./bin/tailwindcss -i tailwind.input.css -o static/tailwind.css --watch`
|
||||
- `dev` → depends on `db-up`, runs `just generate` once, then exports `DATABASE_URL` for the dev DSN and runs `air -c .air.toml`. Document in a top-of-file comment that the developer is expected to run `just styles-watch` in a second terminal (RESEARCH Open Question 2 — two-terminal workflow).
|
||||
- `test` → runs `just generate` then `go test ./...`
|
||||
- `lint` → runs `go vet ./...` and `gofmt -l .` (failing if any file would be reformatted)
|
||||
- `build` → runs `just generate`, then `go build -o bin/web ./cmd/web`, then `go build -o bin/worker ./cmd/worker`
|
||||
|
||||
Document at top of justfile in a comment block: project name, podman/docker portability note (CONTEXT D-11 — `compose.yaml` works under either; README will spell out the alternative `docker compose` invocation), and the two-terminal `dev` + `styles-watch` workflow. Do NOT add a recipe to download `goose`/`templ`/`sqlc`/`air` outside of `bootstrap` — keep one install path.
|
||||
|
||||
Note: `build` will fail until Plan 03 ships `cmd/web/main.go` and `cmd/worker/main.go`. That is expected and acceptable for Plan 01's verification — we only assert the justfile parses and `just --list` enumerates the required recipes.</action>
|
||||
<verify>
|
||||
<automated>cd backend && just --list 2>/dev/null | grep -E '^\s+(bootstrap|db-up|db-down|migrate|generate|styles-watch|dev|test|lint|build)\b' | wc -l | awk '$1>=9{exit 0} {exit 1}'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `just --list` enumerates at least: bootstrap, db-up, db-down, migrate, generate, styles-watch, dev, test, lint, build
|
||||
- `bootstrap` recipe installs goose v3.27.1, templ v0.3.1020, sqlc v1.31.1, air v1.65.1 at exact pinned versions (verifiable by grep on the justfile)
|
||||
- `bootstrap` recipe downloads htmx.min.js into static/ from unpkg.com (NOT a CDN reference at runtime — D-10)
|
||||
- `migrate` recipe sets GOOSE_DRIVER, GOOSE_DBSTRING, GOOSE_MIGRATION_DIR
|
||||
- `dev` recipe runs `db-up`, `generate`, then `air`
|
||||
- No pnpm / npm / node references anywhere in the justfile (D-12)
|
||||
</acceptance_criteria>
|
||||
<done>justfile parses, exposes required recipes at the right names, and only references tools approved by CONTEXT/RESEARCH.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 6 (checkpoint): Bootstrap + bring up Postgres + apply migration end-to-end</name>
|
||||
<what-built>The full scaffold from Tasks 1–5: Go module, directory skeleton, compose.yaml, .env.example, .gitignore, sqlc.yaml, tailwind.input.css, .air.toml, justfile, bootstrap migration. After this checkpoint a developer can run the four-command bootstrap loop and see a healthy DB with one migration applied.</what-built>
|
||||
<files>backend/bin/tailwindcss, backend/static/htmx.min.js, postgres container, goose_db_version table</files>
|
||||
<action>Human verifies that the scaffold from Tasks 1–5 actually bootstraps end-to-end. The CLI work is automated in `just bootstrap`; the human gate confirms the tool downloads + compose health + first migration are real, not aspirational. Steps in how-to-verify are executed by the user; the agent does not run them autonomously because `just bootstrap` performs network downloads and `podman compose up -d postgres` mutates host state — both require explicit user authorization.</action>
|
||||
<how-to-verify>
|
||||
Run, from the repo root, in order:
|
||||
1. `cd backend && just bootstrap` — installs goose/templ/sqlc/air, downloads `bin/tailwindcss`, downloads `static/htmx.min.js`. Confirm all four `go install` commands succeed and both files exist (`test -x bin/tailwindcss && test -s static/htmx.min.js`).
|
||||
2. `just db-up` — starts the postgres container. Wait for `podman compose ps` to show the service as healthy (5–15s).
|
||||
3. `just migrate up` — exit 0; output mentions `0001_init`.
|
||||
4. `just migrate status` — shows `0001_init.sql` as Applied with a timestamp.
|
||||
5. `psql "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable" -c "SELECT version FROM goose_db_version ORDER BY id DESC LIMIT 1;"` — returns `1`.
|
||||
|
||||
Expected outcome: postgres container healthy, goose_db_version table contains version 1, no errors in any step.
|
||||
|
||||
If any step fails: investigate before approving. Common failure modes per RESEARCH Pitfalls 1, 2, 7.
|
||||
</how-to-verify>
|
||||
<verify>Human walks the five-step bootstrap loop and confirms each step exits successfully; `just migrate status` shows `0001_init.sql` Applied.</verify>
|
||||
<done>User responds "approved" after observing all five steps succeed on their machine.</done>
|
||||
<resume-signal>Type "approved" or describe issues (e.g., "Tailwind binary URL 404", "goose migration parse error").</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Internet → `just bootstrap` downloads | Tailwind binary + htmx.min.js are pulled from public URLs during bootstrap; integrity not yet pinned by hash in Phase 1 |
|
||||
| Local filesystem → goose / sqlc CLIs | Installed via `go install` from public proxy.golang.org |
|
||||
| Local Postgres container ↔ host | Bound to `localhost:5432` only via compose port mapping |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01-01 | T (Tampering) | `static/htmx.min.js` vendored via curl from unpkg | accept | Phase 1 scope: vendoring from a public CDN is an explicit decision (D-10 forbids runtime CDN, not bootstrap-time fetch). Subresource integrity hashes deferred to Phase 7 along with deploy hardening. Rationale: low-value local-dev attack surface; CSS/HTMX assets do not handle secrets in Phase 1. |
|
||||
| T-01-02 | T (Tampering) | `bin/tailwindcss` binary downloaded via curl | accept | Same rationale as T-01-01. Tailwind is local-dev tooling only; not shipped in any production image in Phase 1. Hash pinning revisited at Phase 7 (deploy) where reproducible CI bootstrap matters. |
|
||||
| T-01-03 | I (Information disclosure) | `.env` containing dev DSN | mitigate | `.gitignore` excludes `.env`; only `.env.example` (with placeholder/dev-only credentials) is committed. Dev DSN uses the literal `xtablo:xtablo` credential set, which is harmless for local-only bound containers. |
|
||||
| T-01-04 | I (Information disclosure) | `goose_db_version` table | accept | Standard goose metadata table; contains only migration version numbers and timestamps. No user data. |
|
||||
| T-01-05 | D (Denial of service) | postgres container port 5432 exposed to host | accept | Container binds to all interfaces by default in compose; mitigation deferred — developer machine is the trust zone. If the developer is on a hostile network, they can bind `127.0.0.1:5432:5432` in a follow-up; not in Phase 1 scope. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd backend && go mod verify` exits 0
|
||||
- `cd backend && go build ./internal/...` succeeds (placeholder packages compile)
|
||||
- `cd backend && just --list` enumerates all required recipes
|
||||
- `cd backend && just bootstrap && just db-up && just migrate up && just migrate status` runs cleanly end-to-end (checkpoint Task 6)
|
||||
- Manual: open `backend/compose.yaml`, `backend/.env.example`, `backend/.gitignore`, `backend/sqlc.yaml`, `backend/.air.toml`, `backend/tailwind.input.css`, `backend/justfile`, `backend/migrations/0001_init.sql` and verify each matches its acceptance criteria above
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `backend/` directory tree exactly matches the layout locked in CONTEXT D-01/D-02 and reproduced in SKELETON.md
|
||||
2. Go module declares `module backend` with all five runtime deps pinned at RESEARCH-Standard-Stack versions
|
||||
3. `just bootstrap` installs all four CLI tools at pinned versions and vendors `bin/tailwindcss` + `static/htmx.min.js`
|
||||
4. `just db-up` starts a healthy postgres:16-alpine container reachable at localhost:5432
|
||||
5. `just migrate up` applies the no-op bootstrap migration; `just migrate status` shows it as Applied
|
||||
6. No Node/npm/pnpm artifacts anywhere in `backend/` (D-12)
|
||||
7. No CDN references in any committed file (D-10)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` documenting:
|
||||
- Pinned versions actually installed (Tailwind 4.x patch, HTMX 2.x patch chosen at bootstrap time)
|
||||
- Any deviations from the RESEARCH-locked stack (there should be none)
|
||||
- Path forward for Plan 02 (Wave 2) — handler tests + ui design-system package
|
||||
</output>
|
||||
424
.planning/phases/01-foundation/01-02-PLAN.md
Normal file
424
.planning/phases/01-foundation/01-02-PLAN.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
---
|
||||
phase: 01-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["01-01"]
|
||||
files_modified:
|
||||
- backend/internal/web/ui/tokens.go
|
||||
- backend/internal/web/ui/variants.go
|
||||
- backend/internal/web/ui/helpers.go
|
||||
- backend/internal/web/ui/base.css
|
||||
- backend/internal/web/ui/button.templ
|
||||
- backend/internal/web/ui/button.css
|
||||
- backend/internal/web/ui/card.templ
|
||||
- backend/internal/web/ui/card.css
|
||||
- backend/internal/web/ui/badge.templ
|
||||
- backend/internal/web/ui/badge.css
|
||||
- backend/internal/web/ui/ui_test.go
|
||||
- backend/internal/web/handlers_test.go
|
||||
- backend/internal/db/pool_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- FOUND-01
|
||||
- FOUND-02
|
||||
- FOUND-03
|
||||
- FOUND-04
|
||||
tags:
|
||||
- go
|
||||
- testing
|
||||
- templ
|
||||
- design-system
|
||||
- htmx
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "After this plan lands, `go test ./internal/...` runs and the listed handler tests FAIL with 'undefined: <handler>' or equivalent — proving the test scaffold targets the right functions/signatures before Plan 03 implements them (RED step of MVP)"
|
||||
- "The `internal/web/ui` design-system package compiles standalone and its `ui_test.go` smoke tests PASS — each shipped variant of Button, Card, Badge renders without panicking and emits the expected root CSS class"
|
||||
- "The UI component package mirrors the enum surface of `go-backend/internal/web/ui/` for Size, ButtonVariant, ButtonTone, BadgeVariant — future phases extend these enums' CSS without redefining them"
|
||||
- "Test files declare the exact handler function signatures and templ component names that Plan 03 will implement, so Plan 03 is a fill-in not an exploration"
|
||||
artifacts:
|
||||
- path: backend/internal/web/ui/variants.go
|
||||
provides: Size, ButtonVariant, ButtonTone, BadgeVariant enums + class-builder functions + normalizers
|
||||
contains: "ButtonVariantDefault"
|
||||
- path: backend/internal/web/ui/tokens.go
|
||||
provides: Semantic token constants (e.g. TokenPrimary, TokenDanger) declared but minimally used in Phase 1
|
||||
contains: "package ui"
|
||||
- path: backend/internal/web/ui/button.templ
|
||||
provides: Button templ component accepting ButtonProps{Label, Variant, Tone, Size, Type, Attrs}
|
||||
contains: "ButtonProps"
|
||||
- path: backend/internal/web/ui/button.css
|
||||
provides: CSS rules for `.ui-button-solid-default-md` and the base `.ui-button` class
|
||||
contains: ".ui-button"
|
||||
- path: backend/internal/web/ui/card.templ
|
||||
provides: Card templ component accepting children (slate-50 panel + slate-200 border)
|
||||
contains: "templ Card"
|
||||
- path: backend/internal/web/ui/card.css
|
||||
provides: CSS rules for `.ui-card`
|
||||
contains: ".ui-card"
|
||||
- path: backend/internal/web/ui/badge.templ
|
||||
provides: Badge templ component accepting Label + Variant (info / success / warning / danger)
|
||||
contains: "BadgeProps"
|
||||
- path: backend/internal/web/ui/badge.css
|
||||
provides: CSS rules for `.ui-badge-{info,success,danger}` (warning declared in enum but no CSS in Phase 1)
|
||||
contains: ".ui-badge"
|
||||
- path: backend/internal/web/ui/base.css
|
||||
provides: Resets, focus-ring base, html/body defaults imported into tailwind.input.css
|
||||
contains: "html"
|
||||
- path: backend/internal/web/ui/ui_test.go
|
||||
provides: Smoke tests rendering each Phase-1 variant; asserts root CSS class appears in output
|
||||
contains: "TestButton"
|
||||
- path: backend/internal/web/handlers_test.go
|
||||
provides: Failing tests for healthz OK + down, index hx-get presence, demo/time fragment, RequestID header, slog handler switch
|
||||
contains: "TestHealthz_OK"
|
||||
- path: backend/internal/db/pool_test.go
|
||||
provides: pgxpool integration test that SKIPS when DATABASE_URL is unset
|
||||
contains: "t.Skip"
|
||||
key_links:
|
||||
- from: backend/internal/web/handlers_test.go
|
||||
to: backend/internal/web/{router.go,handlers.go,middleware.go} (Plan 03)
|
||||
via: tests reference exact function names — NewRouter, healthzHandler, indexHandler, demoTimeHandler, RequestIDMiddleware
|
||||
pattern: "NewRouter|healthzHandler|indexHandler|demoTimeHandler"
|
||||
- from: backend/internal/web/ui/button.templ
|
||||
to: backend/internal/web/ui/button.css
|
||||
via: emitted root class `ui-button-solid-default-md`
|
||||
pattern: "ui-button-solid-default-md"
|
||||
- from: backend/internal/web/ui/ui_test.go
|
||||
to: ButtonProps / CardProps / BadgeProps types declared in this plan
|
||||
via: direct constructor calls
|
||||
pattern: "ui.ButtonProps"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Land the RED step of the Walking Skeleton (failing handler + db tests targeting exact function signatures Plan 03 will implement) AND the `internal/web/ui` design-system package (Button / Card / Badge with their CSS files, enum surface, and passing smoke tests).
|
||||
|
||||
Purpose: Plan 03's job becomes "make the failing tests pass and render the index page via these components" rather than "design the package, then build the page, then write tests." Test-targeted signatures eliminate exploration; the UI package eliminates inline Tailwind in handlers.
|
||||
|
||||
Output: 13 files split between `internal/web/ui/` (10 files, all GREEN — package compiles, its own tests pass) and the failing-test scaffold (`internal/web/handlers_test.go`, `internal/db/pool_test.go`). The handler tests reference yet-unwritten code from Plan 03 — they are intentionally RED right now.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation/01-UI-SPEC.md
|
||||
@.planning/phases/01-foundation/01-VALIDATION.md
|
||||
@.planning/phases/01-foundation/SKELETON.md
|
||||
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
||||
|
||||
# Reference implementation for the ui package shape (READ carefully — DO NOT copy without re-keying class names)
|
||||
@go-backend/internal/web/ui/variants.go
|
||||
@go-backend/internal/web/ui/button.templ
|
||||
@go-backend/internal/web/ui/card.templ
|
||||
@go-backend/internal/web/ui/badge.templ
|
||||
|
||||
<interfaces>
|
||||
# Contracts Plan 03 will implement against. Defining them here, in tests, locks Plan 03's API surface.
|
||||
|
||||
## Handler signatures (backend/internal/web)
|
||||
|
||||
```go
|
||||
// NewRouter returns a fully configured chi.Router. The Pinger interface allows
|
||||
// test injection of a stub for /healthz coverage without spinning up Postgres.
|
||||
type Pinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewRouter(pinger Pinger, staticDir string) http.Handler
|
||||
|
||||
// Individual handlers exported for direct httptest invocation.
|
||||
func HealthzHandler(pinger Pinger) http.HandlerFunc
|
||||
func IndexHandler() http.HandlerFunc // renders templates.Index via templ
|
||||
func DemoTimeHandler(now func() time.Time) http.HandlerFunc // injected clock for deterministic tests
|
||||
|
||||
// Middleware
|
||||
func RequestIDMiddleware(next http.Handler) http.Handler
|
||||
func SlogLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler
|
||||
|
||||
// Slog handler selection (in package web — pure function, no globals)
|
||||
func NewSlogHandler(env string, w io.Writer) slog.Handler
|
||||
```
|
||||
|
||||
## DB package signatures (backend/internal/db)
|
||||
|
||||
```go
|
||||
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error)
|
||||
```
|
||||
|
||||
## UI package signatures (backend/internal/web/ui)
|
||||
|
||||
```go
|
||||
type Size string
|
||||
const (
|
||||
SizeSM Size = "sm"
|
||||
SizeMD Size = "md"
|
||||
SizeLG Size = "lg"
|
||||
)
|
||||
|
||||
type ButtonVariant string
|
||||
const (
|
||||
ButtonVariantDefault ButtonVariant = "default"
|
||||
ButtonVariantNeutral ButtonVariant = "neutral"
|
||||
ButtonVariantWarning ButtonVariant = "warning"
|
||||
ButtonVariantSuccess ButtonVariant = "success"
|
||||
ButtonVariantDanger ButtonVariant = "danger"
|
||||
)
|
||||
|
||||
type ButtonTone string
|
||||
const (
|
||||
ButtonToneSolid ButtonTone = "solid"
|
||||
ButtonToneSoft ButtonTone = "soft"
|
||||
)
|
||||
|
||||
type BadgeVariant string
|
||||
const (
|
||||
BadgeVariantInfo BadgeVariant = "info"
|
||||
BadgeVariantWarning BadgeVariant = "warning"
|
||||
BadgeVariantSuccess BadgeVariant = "success"
|
||||
BadgeVariantDanger BadgeVariant = "danger"
|
||||
)
|
||||
|
||||
type ButtonProps struct {
|
||||
Label string
|
||||
Variant ButtonVariant
|
||||
Tone ButtonTone
|
||||
Size Size
|
||||
Type string // "button" / "submit"; defaults to "button" if empty
|
||||
Attrs templ.Attributes // pass-through for hx-* etc.
|
||||
}
|
||||
|
||||
type BadgeProps struct {
|
||||
Label string
|
||||
Variant BadgeVariant
|
||||
}
|
||||
|
||||
// Templ components — match these names exactly
|
||||
templ Button(props ButtonProps)
|
||||
templ Card(attrs templ.Attributes) // children via templ children
|
||||
templ Badge(props BadgeProps)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: UI package enums, helpers, base CSS</name>
|
||||
<files>backend/internal/web/ui/tokens.go, backend/internal/web/ui/variants.go, backend/internal/web/ui/helpers.go, backend/internal/web/ui/base.css</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (Component Library Contract — enum names and normalizer pattern are non-negotiable)
|
||||
- go-backend/internal/web/ui/variants.go (reference shape; mirror the enum/normalizer surface)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Importing `backend/internal/web/ui` from a Go test compiles successfully
|
||||
- `ui.SizeMD`, `ui.ButtonVariantDefault`, `ui.ButtonToneSolid`, `ui.BadgeVariantInfo` are exported constants of the right types
|
||||
- `ui.NormalizedSize("")` returns `ui.SizeMD` (zero-value safe default)
|
||||
- `ui.NormalizedSize("md")` returns `ui.SizeMD`
|
||||
- `ui.NormalizedButtonVariant("")` returns `ui.ButtonVariantDefault`
|
||||
- `ui.NormalizedButtonTone("")` returns `ui.ButtonToneSolid`
|
||||
- `ui.NormalizedBadgeVariant("")` returns `ui.BadgeVariantInfo`
|
||||
- `ui.ButtonClass(ButtonVariantDefault, ButtonToneSolid, SizeMD)` returns `"ui-button ui-button-solid-default-md"`
|
||||
- `ui.BadgeClass(BadgeVariantInfo)` returns `"ui-badge ui-badge-info"`
|
||||
</behavior>
|
||||
<action>
|
||||
Write `tokens.go`: declare a minimal set of semantic-token string constants (`TokenPrimary`, `TokenNeutral`, `TokenWarning`, `TokenSuccess`, `TokenDanger`) — these mirror the reference package's surface but are not heavily consumed in Phase 1; they exist so later phases can extend without restructuring. Package clause: `package ui`.
|
||||
|
||||
Write `variants.go`: declare exactly the types and constants from the `<interfaces>` block above (Size, ButtonVariant, ButtonTone, BadgeVariant + their constants). Add `NormalizedSize`, `NormalizedButtonVariant`, `NormalizedButtonTone`, `NormalizedBadgeVariant` functions: each accepts the corresponding type and returns the safe default for zero-value input (per UI-SPEC: "normalizer pattern required for every enum"). Add class-builder functions `ButtonClass(variant ButtonVariant, tone ButtonTone, size Size) string` and `BadgeClass(variant BadgeVariant) string` returning class strings exactly as listed in the behavior block. Defaults applied via normalizers before string assembly.
|
||||
|
||||
Write `helpers.go`: a small `mergeAttrs(base, override templ.Attributes) templ.Attributes` helper (for use inside the templ components in Task 2) that copies base then overlays override keys. Keep it ~10 lines.
|
||||
|
||||
Write `base.css`: minimal global resets and a focus-ring base class. Per UI-SPEC §"Accessibility Floor": ensure `:focus-visible` has a visible ring. Use plain CSS (no `@apply`) so it works under Tailwind v4 standalone with `@source` scanning Go files. Include `html { -webkit-text-size-adjust: 100%; }` and an `*, *::before, *::after { box-sizing: border-box; }` reset. Keep under 30 lines.
|
||||
|
||||
Do NOT use `templ.Raw` anywhere. Do NOT import anything from `cmd/web` or other domain packages — `ui` has no upward dependencies.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && go build ./internal/web/ui/ && go vet ./internal/web/ui/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- All four files compile under `go build ./internal/web/ui/`
|
||||
- `variants.go` declares every enum constant listed in the `<interfaces>` block (verifiable: `grep -c 'ButtonVariant\(Default\|Neutral\|Warning\|Success\|Danger\)' variants.go` ≥ 5)
|
||||
- Each enum has a `Normalized*` function returning the safe default for zero value
|
||||
- `ButtonClass` produces exactly `"ui-button ui-button-solid-default-md"` for (Default, Solid, MD) — asserted by Task 4's tests
|
||||
- `helpers.go` exports `mergeAttrs` (used by Task 2)
|
||||
</acceptance_criteria>
|
||||
<done>UI enum surface and helpers compile; class strings match the contract in `<interfaces>`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: UI templ components — Button, Card, Badge — and their CSS</name>
|
||||
<files>backend/internal/web/ui/button.templ, backend/internal/web/ui/button.css, backend/internal/web/ui/card.templ, backend/internal/web/ui/card.css, backend/internal/web/ui/badge.templ, backend/internal/web/ui/badge.css</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (Component Library Contract — exact rendered HTML for Button shown in §HTMX Interaction Pattern; canonical Button usage block)
|
||||
- go-backend/internal/web/ui/button.templ (reference shape — do NOT copy class soup verbatim; UI-SPEC defines the Phase 1 class names)
|
||||
- go-backend/internal/web/ui/card.templ
|
||||
- go-backend/internal/web/ui/badge.templ
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `@ui.Button(ui.ButtonProps{Label: "x", Type: "button"})` rendered to a `bytes.Buffer` produces HTML containing class `ui-button-solid-default-md` and the label "x"
|
||||
- `@ui.Button(..., Attrs: templ.Attributes{"hx-get": "/demo/time"})` renders an `hx-get="/demo/time"` attribute on the `<button>` element
|
||||
- When `props.Type` is empty, the rendered button has `type="button"`
|
||||
- `@ui.Card(...) { <p>hello</p> }` renders HTML containing class `ui-card` and the literal `<p>hello</p>`
|
||||
- `@ui.Badge(ui.BadgeProps{Label: "OK", Variant: ui.BadgeVariantSuccess})` renders HTML containing `ui-badge-success` and the literal `OK`
|
||||
</behavior>
|
||||
<action>
|
||||
Write `button.templ`: declare `templ Button(props ButtonProps)`. Inside, compute `class` from `ui.ButtonClass(NormalizedButtonVariant(props.Variant), NormalizedButtonTone(props.Tone), NormalizedSize(props.Size))`. Render a `<button>` element with `type={props.Type or "button"}`, `class={class}`, and the pass-through `{props.Attrs...}` spread (templ attribute spread). The button body is `{props.Label}`. Do NOT inline Tailwind utility classes inside the `<button>` markup — the CSS rules live in `button.css`.
|
||||
|
||||
Write `button.css`: declare `.ui-button` as a base rule (display, font, focus-ring per UI-SPEC), and `.ui-button-solid-default-md` with the EXACT Tailwind-equivalent values from UI-SPEC §HTMX Interaction Pattern: `display: inline-flex; align-items: center; border-radius: 0.375rem; background-color: #2563eb; padding: 0.5rem 1rem; font-size: 1rem; font-weight: 600; color: #ffffff;` and `&:hover { background-color: #1d4ed8; }` and the focus-ring spec from UI-SPEC. Add the `htmx-request:opacity-60 htmx-request:pointer-events-none` behavior either via Tailwind variants (kept as utility classes on the `<button>`) or via plain CSS `.htmx-request { opacity: 0.6; pointer-events: none; }` — UI-SPEC's markup example uses Tailwind variants on the element, but since this contract centralizes classes in `button.css`, prefer the plain-CSS `.htmx-request` rule scoped under `.ui-button`. Document the choice in a one-line CSS comment.
|
||||
|
||||
Write `card.templ`: declare `templ Card(attrs templ.Attributes)` accepting children. Render `<section class="ui-card" {attrs...}>{ children... }</section>`.
|
||||
|
||||
Write `card.css`: `.ui-card` matches the slate-50 panel from UI-SPEC §HTMX Interaction Pattern (`border-radius: 0.5rem; border: 1px solid #e2e8f0; background-color: #f8fafc; padding: 1.5rem;`).
|
||||
|
||||
Write `badge.templ`: declare `templ Badge(props BadgeProps)`. Compute class from `ui.BadgeClass(NormalizedBadgeVariant(props.Variant))`. Render `<span class={class}>{props.Label}</span>`.
|
||||
|
||||
Write `badge.css`: `.ui-badge` base (inline-block, small padding, small font), plus variant rules for `.ui-badge-info` (blue-tinted), `.ui-badge-success` (green-600 fg), `.ui-badge-danger` (red-600 fg). `warning` variant: enum exists in variants.go (Task 1) but Phase 1 ships no CSS rule for it — document in a comment that warning lands when first needed.
|
||||
|
||||
All three `.css` files MUST be referenced from `tailwind.input.css` (Plan 01 already imports them). They are pulled in at CSS build time by the Tailwind standalone CLI.
|
||||
|
||||
Run `templ generate` after writing the `.templ` files so the `*_templ.go` artifacts exist for the test in Task 4 to import. Do NOT commit `*_templ.go` (gitignored).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && templ generate && go build ./internal/web/ui/ && grep -q '\.ui-button-solid-default-md' internal/web/ui/button.css && grep -q '\.ui-card' internal/web/ui/card.css && grep -q '\.ui-badge-info' internal/web/ui/badge.css && grep -q '\.ui-badge-success' internal/web/ui/badge.css && grep -q '\.ui-badge-danger' internal/web/ui/badge.css</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `templ generate` produces `button_templ.go`, `card_templ.go`, `badge_templ.go` with no errors
|
||||
- `go build ./internal/web/ui/` succeeds with the generated files present
|
||||
- Each CSS file declares exactly the variants enumerated above (info/success/danger for badge; warning intentionally absent)
|
||||
- Button component renders `type="button"` when `Type` field is empty
|
||||
- Card component accepts and passes through `templ.Attributes`
|
||||
- Pages MUST NOT need to know about the `.ui-*` class names — they consume the components only
|
||||
</acceptance_criteria>
|
||||
<done>UI primitives compile to Go, CSS files cover the Phase 1 variant set, and component HTML matches UI-SPEC's canonical render.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: UI package smoke tests (ui_test.go)</name>
|
||||
<files>backend/internal/web/ui/ui_test.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (Component Library Contract — `ui_test.go` requirements: render each Phase-1 variant, assert root class appears, no panic)
|
||||
- backend/internal/web/ui/button.templ (just written in Task 2)
|
||||
- backend/internal/web/ui/card.templ
|
||||
- backend/internal/web/ui/badge.templ
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `TestButton_DefaultSolidMD` renders `@ui.Button(ui.ButtonProps{Label: "Fetch server time"})` to a buffer, asserts the output contains `class="ui-button ui-button-solid-default-md"` and the literal "Fetch server time" and `type="button"`
|
||||
- `TestButton_PassesThroughAttrs` renders a Button with `Attrs: templ.Attributes{"hx-get": "/demo/time", "hx-target": "#demo-out"}`, asserts both attributes appear in output
|
||||
- `TestCard_RendersChildren` renders `@ui.Card(nil) { <p>x</p> }`, asserts output contains `<section class="ui-card"` and `<p>x</p>`
|
||||
- `TestBadge_InfoVariant` renders `@ui.Badge(BadgeProps{Label: "OK", Variant: BadgeVariantInfo})`, asserts output contains `class="ui-badge ui-badge-info"` and `OK`
|
||||
- `TestBadge_SuccessVariant` renders `BadgeVariantSuccess`, asserts `ui-badge-success`
|
||||
- `TestBadge_ZeroValueDefaultsToInfo` renders with empty Variant, asserts `ui-badge-info` (normalizer behavior)
|
||||
- `TestButtonClass_String` asserts `ui.ButtonClass(ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD) == "ui-button ui-button-solid-default-md"`
|
||||
</behavior>
|
||||
<action>
|
||||
Write `ui_test.go` with `package ui` (internal — so it can call unexported helpers if needed; but only public API is required by the behavior list). Use `bytes.Buffer` + `templ.Component.Render(ctx, &buf)` to capture output, then `strings.Contains` for assertions. Each test follows the standard table-driven Go pattern or a simple `t.Run` per case. Use `context.Background()` as the render context.
|
||||
|
||||
Required imports: `bytes`, `context`, `strings`, `testing`, `github.com/a-h/templ`.
|
||||
|
||||
Do NOT use any third-party test library — stdlib `testing` only (RESEARCH §Validation Architecture).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && templ generate && go test ./internal/web/ui/ -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test ./internal/web/ui/` exits 0 with all listed test names present in `-v` output
|
||||
- Each test asserts at minimum: (a) the expected root CSS class name appears in rendered HTML, (b) the expected literal label/content appears
|
||||
- `TestButtonClass_String` passes — proves the class-string contract Plan 03 relies on
|
||||
- Zero-value-defaults test passes — proves normalizers are wired correctly
|
||||
</acceptance_criteria>
|
||||
<done>The ui package is fully GREEN: it compiles, generates, and all smoke tests pass. This becomes the design-system foundation for Plan 03's page rendering and every later phase.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Failing handler tests (handlers_test.go) and pgxpool integration test</name>
|
||||
<files>backend/internal/web/handlers_test.go, backend/internal/db/pool_test.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Code Examples §`/healthz` handler; §Pattern 4 RequestID; §Pattern 3 slog handler switch)
|
||||
- .planning/phases/01-foundation/01-VALIDATION.md (Per-Task Verification Map — exact test names referenced)
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (HTMX Interaction Pattern — index page must contain `hx-get="/demo/time"`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
The following tests MUST be authored. They will FAIL during Plan 02's verify (because Plan 03's handlers don't exist yet). That failure is the RED step. Plan 03's GREEN step is making them pass.
|
||||
|
||||
In `internal/web/handlers_test.go` (package `web`):
|
||||
- `TestHealthz_OK`: builds a `httptest.NewRecorder` + `httptest.NewRequest("GET", "/healthz", nil)`, calls `web.HealthzHandler(stubPinger{err: nil})`. Asserts `200`, `Content-Type: application/json`, body contains `"status":"ok"` and `"db":"ok"`.
|
||||
- `TestHealthz_Down`: same shape but `stubPinger{err: errors.New("conn refused")}`. Asserts `503`, body contains `"status":"degraded"` and `"db":"down"`.
|
||||
- `TestIndex_RendersHxGet`: GET `/` through `web.NewRouter(stubPinger{}, "./static")`. Asserts status 200, `Content-Type: text/html`, body contains `hx-get="/demo/time"`, `hx-target="#demo-out"`, `ui-button-solid-default-md` (proving the page consumes the ui package), and the literal label "Fetch server time" (UI-SPEC copywriting contract).
|
||||
- `TestDemoTime_Fragment`: GET `/demo/time`. Asserts status 200, `Content-Type: text/html`, body matches ISO-8601 UTC pattern (regex `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`), body does NOT contain `<html` (it's a fragment, not a full page). Plan 03 wires the injected clock; for now this test just calls the production constructor and tolerates real wall-clock — Plan 03 will refine the deterministic-clock injection if needed.
|
||||
- `TestRequestID_HeaderSet`: GET `/healthz` through the full router, asserts response header `X-Request-ID` is non-empty AND matches a UUIDv4 regex (`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).
|
||||
- `TestSlog_HandlerSwitch`: calls `web.NewSlogHandler("production", &buf)` and asserts the returned `slog.Handler` writes JSON (the buf, after `logger.Info("x")`, parses as JSON with `"msg":"x"`). Then calls `web.NewSlogHandler("development", &buf2)` and asserts the buf is NOT valid JSON (`text` handler output).
|
||||
|
||||
Define a `stubPinger` struct local to the test file: a single `err error` field, `Ping(ctx context.Context) error { return s.err }`. This implements the `Pinger` interface Plan 03 declares.
|
||||
|
||||
In `internal/db/pool_test.go` (package `db`):
|
||||
- `TestPool_Connects`: reads `DATABASE_URL` from env. If empty: `t.Skip("DATABASE_URL not set — integration test skipped")`. Otherwise calls `db.NewPool(ctx, dsn)`, asserts no error, then `pool.Ping(ctx)` returns nil, then `pool.Close()`.
|
||||
</behavior>
|
||||
<action>
|
||||
Write both test files. Stub types live in the same file as the tests that use them (no separate helpers file in Phase 1).
|
||||
|
||||
Required imports (handlers_test.go): `bytes`, `context`, `encoding/json`, `errors`, `net/http`, `net/http/httptest`, `regexp`, `strings`, `testing`. Use Go's stdlib `regexp` for the UUID + timestamp patterns.
|
||||
|
||||
These tests reference `web.NewRouter`, `web.HealthzHandler`, `web.NewSlogHandler`, `web.Pinger` interface, all of which Plan 03 introduces. The test file's `package web` declaration ensures it compiles as part of the package — if those symbols don't exist, `go test ./internal/web/` will fail with `undefined: NewRouter` etc. That is the desired RED state at end of Plan 02.
|
||||
|
||||
Do NOT add `// +build` constraints. Do NOT skip the failing tests with `t.Skip` — they must remain RED until Plan 03.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && test -f internal/web/handlers_test.go && test -f internal/db/pool_test.go && grep -c '^func Test' internal/web/handlers_test.go | awk '$1>=6{exit 0} {exit 1}' && grep -q 'TestPool_Connects' internal/db/pool_test.go && grep -q 't.Skip' internal/db/pool_test.go && (cd internal/web && ! go test -count=1 -run 'TestHealthz_OK' . 2>&1 | grep -q '^ok')</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `handlers_test.go` contains ≥6 `Test*` functions covering the behaviors listed
|
||||
- `pool_test.go` contains `TestPool_Connects` with a `t.Skip` branch for missing `DATABASE_URL`
|
||||
- Running `go test ./internal/web/` exits NON-ZERO at end of Plan 02 (handlers don't exist yet — this is the RED gate)
|
||||
- Running `go test ./internal/db/` exits 0 (skips cleanly when `DATABASE_URL` unset) — the db package compiles because Plan 03 will add `pool.go`; until then this test file references `db.NewPool` which won't exist either. The verify command above only requires the FILE to exist with `t.Skip` content — Plan 03's verify proves the test runs green.
|
||||
- Failure messages are informative (`undefined: web.NewRouter`) — this exact error is what Plan 03's executor will see and fix
|
||||
</acceptance_criteria>
|
||||
<done>Failing tests are committed. Plan 03 inherits a concrete, behavior-specified target list — no exploration needed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test process ↔ filesystem | Tests run from local checkout only; no external network |
|
||||
| `DATABASE_URL` env ↔ pool_test.go | Test reads env var; skips if absent — does NOT default to a hardcoded DSN |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01-06 | I (Information disclosure) | `pool_test.go` logging | accept | Test logs use `t.Logf` only; never log full DSN. `t.Skip` message says "DATABASE_URL not set" — does not echo the value. |
|
||||
| T-01-07 | T (Tampering via test fixtures) | `stubPinger` and other in-test stubs | accept | Stubs are package-private to `_test.go` files; not reachable from production code (Go test files are excluded from non-test builds). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd backend && go build ./internal/web/ui/` succeeds
|
||||
- `cd backend && templ generate && go test ./internal/web/ui/ -count=1` exits 0 (all ui smoke tests GREEN)
|
||||
- `cd backend && go test ./internal/web/` exits NON-ZERO with `undefined: NewRouter` (or similar) — confirms RED gate is set for Plan 03
|
||||
- `cd backend && go test ./internal/db/` exits 0 (TestPool_Connects skips cleanly) OR exits non-zero with `undefined: db.NewPool` — either is acceptable here since Plan 03 lands `pool.go`
|
||||
- All ten files under `internal/web/ui/` exist
|
||||
- All three test files exist and contain the required `Test*` functions
|
||||
- `internal/web/ui/ui_test.go` covers Button (default+attrs), Card, Badge (info+success+zero-value)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `internal/web/ui/` package compiles, generates, and tests pass (GREEN)
|
||||
2. UI package mirrors the enum surface of `go-backend/internal/web/ui/` (Size, ButtonVariant, ButtonTone, BadgeVariant — all values present even if Phase 1 only renders a subset)
|
||||
3. UI package CSS files are pickable by Tailwind input CSS imports (already wired in Plan 01)
|
||||
4. `internal/web/handlers_test.go` declares the exact test names from VALIDATION.md and targets the exact handler signatures Plan 03 will implement
|
||||
5. `internal/db/pool_test.go` declares `TestPool_Connects` with a graceful skip when `DATABASE_URL` is unset
|
||||
6. RED gate established: `go test ./internal/web/` fails because production handlers don't exist yet — this is intentional and is the target Plan 03 fills in
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` documenting:
|
||||
- Final list of exported symbols in `internal/web/ui/` (enums, props types, components)
|
||||
- Final list of test names in `handlers_test.go` + `pool_test.go`
|
||||
- The exact compile/test error message currently produced by `go test ./internal/web/` (this is the RED gate Plan 03 turns GREEN)
|
||||
- Any deviations from UI-SPEC's Component Library Contract (there should be none)
|
||||
</output>
|
||||
406
.planning/phases/01-foundation/01-03-PLAN.md
Normal file
406
.planning/phases/01-foundation/01-03-PLAN.md
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
---
|
||||
phase: 01-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["01-01", "01-02"]
|
||||
files_modified:
|
||||
- backend/internal/db/pool.go
|
||||
- backend/internal/web/router.go
|
||||
- backend/internal/web/handlers.go
|
||||
- backend/internal/web/middleware.go
|
||||
- backend/internal/web/slog.go
|
||||
- backend/templates/layout.templ
|
||||
- backend/templates/index.templ
|
||||
- backend/templates/fragments.templ
|
||||
- backend/cmd/web/main.go
|
||||
- backend/cmd/worker/main.go
|
||||
autonomous: false
|
||||
requirements:
|
||||
- FOUND-01
|
||||
- FOUND-02
|
||||
- FOUND-03
|
||||
- FOUND-04
|
||||
tags:
|
||||
- go
|
||||
- chi
|
||||
- templ
|
||||
- htmx
|
||||
- pgx
|
||||
- slog
|
||||
- graceful-shutdown
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "After this plan lands, every test in `backend/internal/web/handlers_test.go` and `backend/internal/db/pool_test.go` is GREEN (the RED gate from Plan 02 is now turned over)"
|
||||
- "`go build ./cmd/web ./cmd/worker` succeeds — both binaries compile"
|
||||
- "Running `./tmp/web` (or `go run ./cmd/web`) with `DATABASE_URL` pointing at the compose-managed Postgres serves `/` (200, HTML), `/healthz` (200, JSON with db:ok), `/demo/time` (200, HTML fragment), and `/static/*` (file server)"
|
||||
- "The root page is rendered via `internal/web/ui` components — handlers do NOT inline Tailwind classes — and contains the canonical `hx-get` button declared in UI-SPEC"
|
||||
- "Sending SIGINT to the web binary triggers graceful shutdown: in-flight requests are allowed to finish (up to 10s), then the pgxpool is closed, then the process exits 0"
|
||||
- "`./tmp/worker` connects to Postgres, logs `worker ready` via slog, blocks on signal, and exits 0 on SIGINT (D-03)"
|
||||
artifacts:
|
||||
- path: backend/internal/db/pool.go
|
||||
provides: NewPool(ctx, dsn) (*pgxpool.Pool, error) per RESEARCH Pattern 1
|
||||
contains: "func NewPool"
|
||||
- path: backend/internal/web/router.go
|
||||
provides: NewRouter(pinger Pinger, staticDir string) http.Handler + Pinger interface declaration
|
||||
contains: "type Pinger interface"
|
||||
- path: backend/internal/web/handlers.go
|
||||
provides: HealthzHandler, IndexHandler, DemoTimeHandler
|
||||
contains: "func HealthzHandler"
|
||||
- path: backend/internal/web/middleware.go
|
||||
provides: RequestIDMiddleware, SlogLoggerMiddleware, request_id context key + helper
|
||||
contains: "func RequestIDMiddleware"
|
||||
- path: backend/internal/web/slog.go
|
||||
provides: NewSlogHandler(env, w) slog.Handler — JSON when env=production, text otherwise
|
||||
contains: "func NewSlogHandler"
|
||||
- path: backend/templates/layout.templ
|
||||
provides: Base templ layout per UI-SPEC §Base Layout Contract
|
||||
contains: "templ Layout"
|
||||
- path: backend/templates/index.templ
|
||||
provides: Root page rendering via ui.Card + ui.Button + the canonical hx-get demo
|
||||
contains: "templ Index"
|
||||
- path: backend/templates/fragments.templ
|
||||
provides: TimeFragment(t time.Time) templ component returning `<span>{ISO-8601-UTC}</span>`
|
||||
contains: "templ TimeFragment"
|
||||
- path: backend/cmd/web/main.go
|
||||
provides: Web entrypoint — loads env, builds slog handler, opens pgxpool, builds router, runs http.Server with graceful shutdown
|
||||
contains: "package main"
|
||||
- path: backend/cmd/worker/main.go
|
||||
provides: Worker skeleton — opens pgxpool, logs ready, blocks on signal, closes pool, exits (D-03)
|
||||
contains: "package main"
|
||||
key_links:
|
||||
- from: backend/cmd/web/main.go
|
||||
to: backend/internal/web/router.go (NewRouter)
|
||||
via: import + direct call
|
||||
pattern: "web.NewRouter"
|
||||
- from: backend/cmd/web/main.go
|
||||
to: backend/internal/db/pool.go (NewPool)
|
||||
via: import + direct call with DATABASE_URL
|
||||
pattern: "db.NewPool"
|
||||
- from: backend/templates/index.templ
|
||||
to: backend/internal/web/ui (Button, Card)
|
||||
via: templ component imports
|
||||
pattern: "ui.Button|ui.Card"
|
||||
- from: backend/internal/web/handlers.go (IndexHandler)
|
||||
to: backend/templates/index.templ
|
||||
via: templates.Index().Render(ctx, w)
|
||||
pattern: "templates\\.Index"
|
||||
- from: backend/internal/web/handlers.go (DemoTimeHandler)
|
||||
to: backend/templates/fragments.templ (TimeFragment)
|
||||
via: templates.TimeFragment(now()).Render(ctx, w)
|
||||
pattern: "templates\\.TimeFragment"
|
||||
- from: backend/internal/web/middleware.go (RequestIDMiddleware)
|
||||
to: backend/internal/web/slog.go
|
||||
via: ctx-stored UUID is read by SlogLoggerMiddleware and attached as a "request_id" slog attribute
|
||||
pattern: "request_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the thinnest end-to-end slice that turns Plan 02's RED tests GREEN: pgxpool wiring, chi router with middleware stack, /healthz / / /demo/time handlers, base + index + fragments templ templates (consuming `internal/web/ui`), and the two main entrypoints (`cmd/web` with full graceful shutdown, `cmd/worker` as a minimal skeleton per D-03).
|
||||
|
||||
Purpose: This is the GREEN step of the Walking Skeleton. After this plan, a developer can run `just dev`, open `http://localhost:8080/`, click the demo button, and see the HTMX round-trip work end-to-end against a real Postgres connection. Every FOUND-01..04 requirement is functionally satisfied here.
|
||||
|
||||
Output: 10 files; all Plan 02 tests pass; `go build ./cmd/web ./cmd/worker` produces both binaries.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation/01-UI-SPEC.md
|
||||
@.planning/phases/01-foundation/01-VALIDATION.md
|
||||
@.planning/phases/01-foundation/SKELETON.md
|
||||
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
||||
@.planning/phases/01-foundation/01-02-SUMMARY.md
|
||||
|
||||
# Tests this plan must turn GREEN — read carefully, do not modify
|
||||
@backend/internal/web/handlers_test.go
|
||||
@backend/internal/db/pool_test.go
|
||||
@backend/internal/web/ui/variants.go
|
||||
@backend/internal/web/ui/button.templ
|
||||
@backend/internal/web/ui/card.templ
|
||||
|
||||
<interfaces>
|
||||
# Interfaces from Plan 02's tests that this plan implements verbatim:
|
||||
|
||||
```go
|
||||
// internal/web
|
||||
type Pinger interface { Ping(ctx context.Context) error }
|
||||
|
||||
func NewRouter(pinger Pinger, staticDir string) http.Handler
|
||||
func HealthzHandler(pinger Pinger) http.HandlerFunc
|
||||
func IndexHandler() http.HandlerFunc
|
||||
func DemoTimeHandler(now func() time.Time) http.HandlerFunc
|
||||
func RequestIDMiddleware(next http.Handler) http.Handler
|
||||
func SlogLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler
|
||||
func NewSlogHandler(env string, w io.Writer) slog.Handler
|
||||
|
||||
// internal/db
|
||||
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error)
|
||||
|
||||
// pgxpool.Pool already satisfies the Pinger interface — used directly from cmd/web.
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: pgxpool wrapper + middleware (RequestID, slog) + slog handler switch</name>
|
||||
<files>backend/internal/db/pool.go, backend/internal/web/middleware.go, backend/internal/web/slog.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_test.go (TestRequestID_HeaderSet, TestSlog_HandlerSwitch — exact expectations)
|
||||
- backend/internal/db/pool_test.go (TestPool_Connects expectations)
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 1 pgxpool wiring; Pattern 3 slog handler switch; Pattern 4 RequestID → context → slog)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `db.NewPool(ctx, dsn)` returns a `*pgxpool.Pool` with `MaxConns=10`, `MinConns=1`, no eager Ping (lazy per RESEARCH Pitfall 2). Returns error for an unparseable DSN.
|
||||
- `web.NewSlogHandler("production", w)` returns a `*slog.JSONHandler` writing to `w`.
|
||||
- `web.NewSlogHandler("development", w)` (or any non-"production" string) returns a `*slog.TextHandler`.
|
||||
- `web.RequestIDMiddleware` generates a UUIDv4 via `github.com/google/uuid`, attaches it to the request context under the package-private `requestIDKey` ctx key, and sets the `X-Request-ID` response header.
|
||||
- `web.LoggerFromContext(ctx)` (helper) returns `slog.Default().With("request_id", id)` if a request ID is in context, else `slog.Default()`.
|
||||
- `web.SlogLoggerMiddleware(logger)` is a factory returning a `func(http.Handler) http.Handler` middleware that logs one structured line per request (method, path, status, duration, request_id) — uses `chi/v5/middleware.WrapResponseWriter` to capture status.
|
||||
</behavior>
|
||||
<action>
|
||||
Write `internal/db/pool.go` per RESEARCH Pattern 1 exactly. Use `github.com/jackc/pgx/v5/pgxpool`. The function MUST NOT call `pool.Ping` (Pitfall 2 — lazy is correct).
|
||||
|
||||
Write `internal/web/slog.go`: declare `NewSlogHandler(env string, w io.Writer) slog.Handler`. Use `slog.HandlerOptions{Level: slog.LevelInfo}`. Switch by `env == "production"` per RESEARCH Pattern 3. Do NOT call `slog.SetDefault` here — that's `cmd/web`'s job. The function returns the handler, callers decide what to do with it.
|
||||
|
||||
Write `internal/web/middleware.go`:
|
||||
- Declare `type ctxKey string; const requestIDKey ctxKey = "request_id"`.
|
||||
- `RequestIDMiddleware(next http.Handler) http.Handler` — per RESEARCH Pattern 4.
|
||||
- `LoggerFromContext(ctx context.Context) *slog.Logger` — per RESEARCH Pattern 4.
|
||||
- `SlogLoggerMiddleware(logger *slog.Logger) func(http.Handler) http.Handler`: wraps the handler, records start time, uses `github.com/go-chi/chi/v5/middleware.NewWrapResponseWriter(w, r.ProtoMajor)` to capture status, then logs `logger.With("request_id", id).Info("request", "method", r.Method, "path", r.URL.Path, "status", ww.Status(), "duration_ms", ms)`. Per RESEARCH Pitfall 6: do NOT also register chi's `middleware.Logger`.
|
||||
|
||||
All three files: package clause matches directory (`package db` for pool.go; `package web` for the other two).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && templ generate && go build ./internal/db/ ./internal/web/ && go test ./internal/web/ -run 'TestRequestID_HeaderSet|TestSlog_HandlerSwitch' -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go build ./internal/db/ ./internal/web/` succeeds (no missing symbols from Plan 02 tests)
|
||||
- `TestRequestID_HeaderSet` passes: response carries a UUIDv4 in `X-Request-ID`
|
||||
- `TestSlog_HandlerSwitch` passes: production handler emits JSON, dev handler emits text
|
||||
- `TestPool_Connects` either passes (with `DATABASE_URL` set) or skips cleanly
|
||||
- chi's built-in `middleware.Logger` is NOT imported anywhere (per Pitfall 6 — `grep -r 'chi/v5/middleware.Logger' internal/web/` returns empty)
|
||||
</acceptance_criteria>
|
||||
<done>The three infrastructure files are GREEN; two of the six handler tests now pass; the pgxpool wrapper satisfies the integration test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Templates (layout, index, fragments) + handlers + router</name>
|
||||
<files>backend/templates/layout.templ, backend/templates/index.templ, backend/templates/fragments.templ, backend/internal/web/handlers.go, backend/internal/web/router.go</files>
|
||||
<read_first>
|
||||
- backend/internal/web/handlers_test.go (TestHealthz_OK, TestHealthz_Down, TestIndex_RendersHxGet, TestDemoTime_Fragment)
|
||||
- .planning/phases/01-foundation/01-UI-SPEC.md (Base Layout Contract §, HTMX Interaction Pattern §, Copywriting Contract §, Component Library Contract § — canonical Button usage block is the exact templ source for the demo CTA)
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Code Examples §`/healthz` handler; Pattern 6 templ + chi; Pattern 7 hx-get demo)
|
||||
- backend/internal/web/ui/button.templ + card.templ (the components index.templ consumes)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `templates.Layout(title string)` renders the exact HTML shape from UI-SPEC §Base Layout Contract (system font stack via Tailwind defaults, `bg-white text-slate-900 antialiased`, header/main/footer regions, `/static/htmx.min.js` defer-loaded, `/static/tailwind.css` in `<head>`)
|
||||
- `templates.Index()` wraps `Layout("Xtablo — Foundation")` with the canonical UI-SPEC content: H1 "Xtablo", muted subtitle (exact copy from Copywriting Contract), then a `@ui.Card` containing the demo section (H2 "HTMX demo", helper paragraph, the `@ui.Button` with hx-get attrs, the loading indicator span, and `<div id="demo-out">No time fetched yet.</div>`)
|
||||
- `templates.TimeFragment(t time.Time)` renders `<span class="text-slate-900">{t.UTC().Format(time.RFC3339)}</span>` (exact format per UI-SPEC §HTMX Interaction Pattern: `2026-05-14T14:42:38Z`)
|
||||
- `HealthzHandler(pinger Pinger)` returns 200 JSON `{"status":"ok","db":"ok"}` when `pinger.Ping(ctx2sec)` is nil; 503 JSON `{"status":"degraded","db":"down"}` otherwise (RESEARCH Code Examples block — verbatim shape)
|
||||
- `IndexHandler()` returns `http.HandlerFunc` that sets `Content-Type: text/html; charset=utf-8` and calls `templates.Index().Render(r.Context(), w)`
|
||||
- `DemoTimeHandler(now func() time.Time)` returns `http.HandlerFunc` that sets `Content-Type: text/html; charset=utf-8` and calls `templates.TimeFragment(now()).Render(r.Context(), w)`
|
||||
- `NewRouter(pinger Pinger, staticDir string)` returns a chi router with middleware stack in this exact order per RESEARCH Pattern 2 + D-08:
|
||||
1. `chi/v5/middleware.RequestID` — NO, use OUR `RequestIDMiddleware` (Pitfall: chi's RequestID uses a base32 string not a UUID; UI-SPEC + RESEARCH say UUIDv4)
|
||||
2. `chi/v5/middleware.RealIP`
|
||||
3. `web.SlogLoggerMiddleware(slog.Default())`
|
||||
4. `chi/v5/middleware.Recoverer`
|
||||
Then routes: `GET /healthz` → HealthzHandler, `GET /` → IndexHandler, `GET /demo/time` → DemoTimeHandler with `time.Now` as the clock, `GET /static/*` → `http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))`.
|
||||
</behavior>
|
||||
<action>
|
||||
Write `templates/layout.templ` matching UI-SPEC §Base Layout Contract byte-for-byte for structural classes (`min-h-screen bg-white text-slate-900 antialiased`, `mx-auto max-w-5xl px-4 sm:px-6`, etc.). Footer text: `Phase 1 · Walking skeleton`. The layout takes a `title` parameter (`templ Layout(title string)`) and accepts children. The `<title>` element interpolates `{title}`. The `<head>` includes `<link rel="stylesheet" href="/static/tailwind.css">` and `<meta charset="utf-8">` + viewport. `<script src="/static/htmx.min.js" defer>` lives at the bottom of `<body>`.
|
||||
|
||||
Write `templates/index.templ` consuming the layout. Inside, place an `<h1>` styled per UI-SPEC §Typography Display row, a muted subtitle paragraph with the exact copy from Copywriting Contract ("Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases."), then `@ui.Card(nil)` containing the demo section. Inside the Card: an `<h2 class="text-xl font-semibold leading-snug">HTMX demo</h2>`, a `<p class="mt-2 text-base text-slate-600">` with the helper copy, then a flex row containing the `@ui.Button(...)` (use the exact ButtonProps from UI-SPEC §Component Library Contract canonical block: `Label: "Fetch server time"`, `Variant: ui.ButtonVariantDefault`, `Tone: ui.ButtonToneSolid`, `Size: ui.SizeMD`, `Type: "button"`, `Attrs: templ.Attributes{"hx-get": "/demo/time", "hx-target": "#demo-out", "hx-swap": "innerHTML", "hx-indicator": "#demo-spinner"}`) and a `<span id="demo-spinner" class="htmx-indicator text-sm text-slate-600">Loading…</span>`. Below the flex row: `<div id="demo-out" class="mt-4 text-base text-slate-600">No time fetched yet.</div>`. Imports: `backend/internal/web/ui`.
|
||||
|
||||
Write `templates/fragments.templ`: `templ TimeFragment(t time.Time) { <span class="text-slate-900">{ t.UTC().Format(time.RFC3339) }</span> }`. Import `time`.
|
||||
|
||||
Write `internal/web/handlers.go` declaring `HealthzHandler`, `IndexHandler`, `DemoTimeHandler` per the behavior block. Per RESEARCH Code Examples for HealthzHandler use a 2-second `context.WithTimeout` around `pinger.Ping`. Imports: standard, plus `backend/templates`.
|
||||
|
||||
Write `internal/web/router.go`: declare the `Pinger` interface and `NewRouter`. Middleware order per behavior block above. Routes registered with `chi.Router.Get`. Static file server: `r.Get("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))).ServeHTTP)`. The `time.Now` passed to `DemoTimeHandler` is wrapped in a `func() time.Time { return time.Now() }` to honor the injected-clock signature from `<interfaces>`.
|
||||
|
||||
Run `templ generate` and `go test ./internal/web/` to confirm all six handler tests pass.
|
||||
|
||||
Do NOT introduce ANY additional routes, middlewares, or template helpers beyond what is specified. Phase 1 is a Walking Skeleton — resist expansion.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && templ generate && go build ./internal/web/ ./templates/ && go test ./internal/web/... -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `templ generate` produces `layout_templ.go`, `index_templ.go`, `fragments_templ.go` without error
|
||||
- `go test ./internal/web/...` exits 0 — all six handler tests + the ui package tests pass
|
||||
- `TestIndex_RendersHxGet` confirms the index page renders the `ui-button-solid-default-md` class (proves it consumes the ui package, not raw Tailwind)
|
||||
- `TestDemoTime_Fragment` confirms the body matches the ISO-8601 UTC pattern and contains no `<html` tag
|
||||
- `index.templ` imports `backend/internal/web/ui` (verifiable: grep)
|
||||
- Router middleware order is RequestIDMiddleware → RealIP → SlogLoggerMiddleware → Recoverer (verifiable in `router.go` source order)
|
||||
- No raw `<button class="bg-blue-600 ...">` anywhere in templates/ (UI-SPEC: pages MUST NOT bypass the ui package)
|
||||
</acceptance_criteria>
|
||||
<done>Templates render via the ui package; all six failing tests from Plan 02 are now GREEN; the chi router serves the four endpoints in scope.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: cmd/web/main.go — env loading, slog setup, pgxpool, server lifecycle, graceful shutdown</name>
|
||||
<files>backend/cmd/web/main.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Pattern 5 graceful shutdown; Code Examples /healthz handler; Security Domain §Slow client → resource exhaustion — set ReadTimeout/WriteTimeout/IdleTimeout)
|
||||
- .planning/phases/01-foundation/01-CONTEXT.md (D-15, D-17, D-19, D-20)
|
||||
- backend/internal/web/router.go (just written — direct caller)
|
||||
- backend/internal/db/pool.go (just written — direct caller)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `backend/cmd/web/main.go` as the production entrypoint:
|
||||
|
||||
1. `package main` + `func main()`.
|
||||
2. Read env vars: `DATABASE_URL` (required — log fatal + exit 1 if empty), `PORT` (default "8080"), `ENV` (default "development"). Do NOT use any third-party env library — `os.Getenv` only (CONTEXT D-15 says `.env` is loaded by the dev wrapper, not by the binary; production injects real env). Note: per RESEARCH Open Question 1 and CONTEXT D-15 there's no in-binary `.env` parser — `just dev` exports `DATABASE_URL` for development.
|
||||
3. Build slog handler via `web.NewSlogHandler(env, os.Stdout)` and `slog.SetDefault(slog.New(handler))`.
|
||||
4. Create a background `context.Context` with `signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)` (Go 1.21+ idiom; cleaner than the manual `signal.Notify` channel pattern shown in RESEARCH Pattern 5 but functionally equivalent — use signal.NotifyContext).
|
||||
5. Open the pgxpool: `pool, err := db.NewPool(ctx, dsn)`. Log fatal on error.
|
||||
6. Build the router: `router := web.NewRouter(pool, "./static")`.
|
||||
7. Create `*http.Server` with: `Addr: ":" + port`, `Handler: router`, `ReadTimeout: 15 * time.Second`, `WriteTimeout: 15 * time.Second`, `IdleTimeout: 60 * time.Second` (per RESEARCH Security Domain entry on slow clients).
|
||||
8. Start the server in a goroutine: log on `ErrServerClosed` (info) vs other errors (fatal).
|
||||
9. Block on `<-ctx.Done()` (the signal-notify context).
|
||||
10. On wake: log "shutting down"; create a 10-second shutdown context `shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)`; call `srv.Shutdown(shutdownCtx)`; AFTER `Shutdown` returns, call `pool.Close()` explicitly per RESEARCH Pitfall 4 (NOT via `defer` — defer order during fatal-exit paths is unreliable). Log "shutdown complete" then return from main.
|
||||
|
||||
Imports needed: standard library (`context`, `log/slog`, `net/http`, `os`, `os/signal`, `syscall`, `time`), `backend/internal/db`, `backend/internal/web`.
|
||||
|
||||
Do NOT add any feature flags, dynamic config reload, or extra endpoints. Do NOT add a `/readyz` route (Phase 7 owns it per Deferred Ideas).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && go build ./cmd/web/ && grep -q 'signal.NotifyContext' cmd/web/main.go && grep -q 'srv.Shutdown' cmd/web/main.go && grep -q 'pool.Close' cmd/web/main.go && grep -q 'ReadTimeout' cmd/web/main.go && ! grep -q '/readyz' cmd/web/main.go</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go build ./cmd/web/` succeeds
|
||||
- `signal.NotifyContext` is used for signal handling
|
||||
- `srv.Shutdown` is called with a context bounded by 10s
|
||||
- `pool.Close()` is called explicitly AFTER `Shutdown` (not via defer) — Pitfall 4
|
||||
- HTTP server has explicit ReadTimeout, WriteTimeout, IdleTimeout
|
||||
- No `/readyz` route registered (Phase 7 scope)
|
||||
- On empty `DATABASE_URL`, the binary exits non-zero with a log line naming the missing env var (verifiable: `cd backend && DATABASE_URL='' go run ./cmd/web 2>&1 | grep -i DATABASE_URL` exits 0; manual or follow-up scripted test)
|
||||
</acceptance_criteria>
|
||||
<done>`cmd/web` binary builds, boots, accepts traffic, and shuts down gracefully.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: cmd/worker/main.go — Phase 1 skeleton (boot, log ready, wait on signal, close pool, exit)</name>
|
||||
<files>backend/cmd/worker/main.go</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-foundation/01-CONTEXT.md (D-03 — Phase 1 worker is a minimal skeleton; real job runtime is Phase 6)
|
||||
- backend/cmd/web/main.go (just written — mirror the slog + env + pool + signal-context pattern)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `backend/cmd/worker/main.go` as the Phase 1 worker skeleton per D-03 ("a minimal binary that boots, connects to Postgres, logs 'worker ready', and exits cleanly on signal. Real job runtime is Phase 6").
|
||||
|
||||
1. `package main` + `func main()`.
|
||||
2. Read `DATABASE_URL` (required) and `ENV` (default "development"). NO `PORT` — the worker doesn't serve HTTP.
|
||||
3. Build slog handler via `web.NewSlogHandler(env, os.Stdout)` and set as default. (Re-using `web.NewSlogHandler` is acceptable — it's a pure helper, not coupled to HTTP.)
|
||||
4. Open the pgxpool: `pool, err := db.NewPool(ctx, dsn)`. Log fatal on error.
|
||||
5. Log `slog.Info("worker ready")`. This is the load-bearing signal per D-03.
|
||||
6. Block on `signal.NotifyContext(ctx, SIGINT, SIGTERM)` until done.
|
||||
7. Log "shutting down", `pool.Close()`, log "shutdown complete", return.
|
||||
|
||||
Do NOT add a job registry, queue, or any handler logic. Phase 6 will replace this file in full — keep it ≤ 60 lines including comments.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && go build ./cmd/worker/ && grep -q '"worker ready"' cmd/worker/main.go && grep -q 'signal.NotifyContext' cmd/worker/main.go && grep -q 'pool.Close' cmd/worker/main.go && wc -l cmd/worker/main.go | awk '$1<=80{exit 0} {exit 1}'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go build ./cmd/worker/` succeeds
|
||||
- Binary logs `worker ready` after opening the pool (verifiable: `DATABASE_URL=... ./bin/worker &`, then `kill -INT $!`, expect log line)
|
||||
- Total file length ≤ 80 lines (signals "we did not implement Phase 6 by accident")
|
||||
- No job-queue libraries imported (`grep -E 'river|asynq|pg_notify' cmd/worker/main.go` returns empty)
|
||||
</acceptance_criteria>
|
||||
<done>Worker skeleton compiles, boots against the compose Postgres, logs its readiness, and shuts down cleanly. Phase 6 will replace this file in full.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 5 (checkpoint): Full Walking Skeleton run — browser + HTMX demo + graceful shutdown</name>
|
||||
<what-built>
|
||||
After Tasks 1–4, the full Walking Skeleton is functional: pgxpool wired, chi router with middleware stack, three endpoints (/, /healthz, /demo/time), static assets served, base+index+fragments templ pages consuming the ui design-system, both binaries building and running.
|
||||
</what-built>
|
||||
<files>backend/cmd/web (running binary), backend/cmd/worker (running binary), browser session, postgres container</files>
|
||||
<action>Human walks the live HTMX round-trip + graceful shutdown + worker boot. The agent does NOT execute these steps autonomously because they involve interactive browser interaction, live process supervision (Ctrl-C timing), and reversible filesystem edits to verify air's live-reload. The agent has already proven correctness via `go test ./...` in Tasks 1–4; this checkpoint is the end-to-end sanity that closes the Walking Skeleton.</action>
|
||||
<how-to-verify>
|
||||
From `backend/`:
|
||||
1. `just db-up` and confirm Postgres is healthy. Then `just migrate up`.
|
||||
2. In one terminal: `just styles-watch` (Tailwind builds `static/tailwind.css`). Confirm the file is non-empty after first compile.
|
||||
3. In a second terminal: `just dev` (air starts the web binary). Confirm logs show `pool open`, `listening on :8080`, no errors.
|
||||
4. `curl -s http://localhost:8080/healthz | jq` → returns `{"status":"ok","db":"ok"}` with HTTP 200.
|
||||
5. Stop the Postgres container (`just db-down`), wait ~10s, then `curl -is http://localhost:8080/healthz` → returns 503 with `{"status":"degraded","db":"down"}`. Bring DB back up.
|
||||
6. Open `http://localhost:8080/` in a browser. Verify:
|
||||
- Page title is "Xtablo — Foundation"
|
||||
- H1 "Xtablo" displays
|
||||
- Demo card shows "HTMX demo" heading and "Fetch server time" button
|
||||
- Click the button → HTML fragment with an ISO-8601 timestamp swaps into `#demo-out`
|
||||
- Browser DevTools console shows NO errors
|
||||
- Network panel shows the `/demo/time` request returned `Content-Type: text/html` and the response body is a `<span>` (NOT JSON, NOT a full page)
|
||||
- `htmx.min.js` is loaded from `/static/htmx.min.js` (NOT a CDN — verify via DevTools Network)
|
||||
7. Edit `internal/web/handlers.go` (add a no-op comment) → air rebuilds, browser refresh shows updated build. Edit `templates/index.templ` (change H1 to "Xtablo 2") → templ regenerates, air rebuilds, refresh shows the change. Revert both edits.
|
||||
8. In the `just dev` terminal: Ctrl-C. Confirm shutdown logs in order: `shutting down`, `shutdown complete`, and the process exits 0 within ~10s.
|
||||
9. Separately: `DATABASE_URL='postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable' go run ./cmd/worker &`, expect log `worker ready` within 1s, then `kill -INT $!`, expect `shutdown complete` and exit 0.
|
||||
10. `go test ./...` — all tests pass.
|
||||
|
||||
Expected outcome: every step succeeds without manual fixes. If any browser console error appears, or any test fails, or HTMX swap returns JSON instead of HTML, fix before approving.
|
||||
</how-to-verify>
|
||||
<verify>Human completes all 10 verification steps; observes HTMX swap, graceful shutdown logs, and `go test ./...` green.</verify>
|
||||
<done>User responds "approved" after all 10 steps pass without manual fixes.</done>
|
||||
<resume-signal>Type "approved" or describe issues encountered (specific failure step + observed behavior).</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Public HTTP → `/healthz` | Endpoint exposes db reachability boolean — accepted; no PII leaked |
|
||||
| Public HTTP → `/static/*` | Static file server rooted at `./static`; path traversal must be prevented |
|
||||
| Process → Postgres | DSN comes from env, never logged |
|
||||
| OS signal → process | SIGINT/SIGTERM trigger graceful shutdown path |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01-08 | T (Tampering) via path traversal | `/static/*` handler | mitigate | Use `http.FileServer(http.Dir(staticDir))` — Go's `http.Dir` rejects `..` traversal by default. Do NOT use raw `os.Open`. (RESEARCH Security Domain entry on file traversal.) |
|
||||
| T-01-09 | I (Information disclosure) | slog middleware logging request bodies / headers | mitigate | `SlogLoggerMiddleware` logs only: method, path, status, duration_ms, request_id. NEVER logs Authorization header, Cookie header, or request body. Allowlist-only (RESEARCH §Security Domain V7). |
|
||||
| T-01-10 | D (DoS via slow client) | `cmd/web` http.Server | mitigate | Explicit ReadTimeout=15s, WriteTimeout=15s, IdleTimeout=60s on `http.Server` (RESEARCH §Security Domain). |
|
||||
| T-01-11 | D (panic crashes process) | Any handler | mitigate | `chi/v5/middleware.Recoverer` is registered after the logging middleware so panics are logged with request_id then converted to 500. |
|
||||
| T-01-12 | I (DSN leak via slog fatal) | `cmd/web` startup | mitigate | On `db.NewPool` error, log the error type and message but DO NOT log the DSN. Sanitize: `slog.Error("db connect failed", "err", err)` without an `"dsn"` attribute. |
|
||||
| T-01-13 | T (XSS via `/demo/time` echo) | TimeFragment | mitigate | templ auto-escapes interpolated values by default (`{ t.UTC().Format(...) }`). No `templ.Raw` used anywhere. Fragment content is server-generated (time.Now), not user-controlled — defense-in-depth. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `cd backend && templ generate && go test ./... -count=1` exits 0
|
||||
- `cd backend && go build ./cmd/web ./cmd/worker` produces `bin/web` (if `just build`) or compiles cleanly via `go build`
|
||||
- Checkpoint Task 5 walks the full Walking Skeleton: dev loop, browser interaction, graceful shutdown, worker boot
|
||||
- No `chi/v5/middleware.Logger` import anywhere (Pitfall 6): `grep -r 'middleware.Logger' backend/` returns empty
|
||||
- No CDN reference anywhere: `grep -r 'unpkg.com\|cdn\.' backend/internal backend/templates backend/cmd` returns empty (justfile's bootstrap-time curl is the only allowed exception, lives in Plan 01)
|
||||
- No raw `<button class="bg-blue-`anywhere in templates/: `grep -r 'class="bg-blue-' backend/templates/` returns empty (pages consume `ui.Button`, never inline)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. All 6 handler tests from Plan 02 are GREEN — the RED gate is turned over
|
||||
2. `TestPool_Connects` passes when `DATABASE_URL` is set against compose Postgres
|
||||
3. Both `cmd/web` and `cmd/worker` build and run against the compose Postgres
|
||||
4. Browser end-to-end: load `/`, click "Fetch server time", see HTMX swap a server-rendered timestamp into `#demo-out` with no console errors
|
||||
5. Graceful shutdown closes pgxpool AFTER http.Server.Shutdown returns (Pitfall 4)
|
||||
6. All UI rendered through `internal/web/ui` components — no raw Tailwind utility-class soup leaks into `templates/`
|
||||
7. ALL FOUND-01..04 success criteria from ROADMAP are functionally exercised by the checkpoint
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` documenting:
|
||||
- Final middleware order in `NewRouter` (verifies it matches CONTEXT D-08)
|
||||
- Number of test functions now GREEN
|
||||
- Final binary sizes for `cmd/web` and `cmd/worker`
|
||||
- Any observations from the live-reload + HTMX browser interaction
|
||||
- README work remaining for Plan 04
|
||||
</output>
|
||||
227
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
227
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
---
|
||||
phase: 01-foundation
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["01-01", "01-02", "01-03"]
|
||||
files_modified:
|
||||
- backend/README.md
|
||||
autonomous: false
|
||||
requirements:
|
||||
- FOUND-05
|
||||
tags:
|
||||
- documentation
|
||||
- onboarding
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A new dev with Go ≥ 1.22, just, podman (or docker), and curl on their PATH can clone the repo, follow `backend/README.md`, and see the HTMX-driven root page within ~5 minutes"
|
||||
- "The README documents exactly the recipes shipped in Plan 01's justfile — no aspirational or yet-to-exist commands"
|
||||
- "The README spells out the docker compose fallback for contributors who don't use podman (D-11)"
|
||||
- "The README documents the two-terminal dev workflow (`just dev` + `just styles-watch`) per RESEARCH Open Question 2"
|
||||
- "The README documents the bootstrap order: `just bootstrap` → `just db-up` → `just migrate up` → `just dev` (in 1st terminal) + `just styles-watch` (in 2nd terminal) → open localhost:8080"
|
||||
artifacts:
|
||||
- path: backend/README.md
|
||||
provides: Five-minute onboarding doc covering prereqs, bootstrap, dev workflow, common commands, troubleshooting pointers
|
||||
contains: "Quickstart"
|
||||
key_links:
|
||||
- from: backend/README.md
|
||||
to: backend/justfile (recipes)
|
||||
via: documented command names match recipe names exactly
|
||||
pattern: "just bootstrap"
|
||||
- from: backend/README.md
|
||||
to: backend/compose.yaml (podman alternative)
|
||||
via: documented docker fallback
|
||||
pattern: "docker compose"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Ship the README that closes FOUND-05: a new dev can clone, follow it, and see the page in ~5 minutes. This is the final piece of the Walking Skeleton — without it, the success criterion "A new dev can clone the repo, run `compose up -d` + `just dev`, and see the page within ~5 minutes following `backend/README.md`" is unverified.
|
||||
|
||||
Purpose: Documentation IS the deliverable for FOUND-05. The checkpoint at the end of this plan is the manual onboarding walkthrough that closes the requirement.
|
||||
|
||||
Output: One file (`backend/README.md`) and a successful clean-clone walkthrough.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
||||
@.planning/phases/01-foundation/01-VALIDATION.md
|
||||
@.planning/phases/01-foundation/SKELETON.md
|
||||
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
||||
@.planning/phases/01-foundation/01-03-SUMMARY.md
|
||||
@backend/justfile
|
||||
@backend/compose.yaml
|
||||
@backend/.env.example
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Write backend/README.md — quickstart, prereqs, dev workflow, troubleshooting</name>
|
||||
<files>backend/README.md</files>
|
||||
<read_first>
|
||||
- backend/justfile (just-shipped — recipe names must match exactly)
|
||||
- backend/.env.example (env var names must match exactly)
|
||||
- backend/compose.yaml (service name + port must match)
|
||||
- .planning/phases/01-foundation/01-RESEARCH.md (Common Pitfalls section — surface the top 3 in a troubleshooting subsection)
|
||||
- .planning/phases/01-foundation/01-CONTEXT.md (D-11 — document docker fallback; deferred ideas — do NOT document /readyz or single-binary subcommands)
|
||||
</read_first>
|
||||
<action>
|
||||
Write `backend/README.md` with the following structure (use `##` headers, GitHub-flavored markdown, keep terse):
|
||||
|
||||
1. **Title + one-line description.** "Xtablo backend — Go + HTMX + Postgres. Phase 1: Walking Skeleton."
|
||||
|
||||
2. **Prerequisites.** Bulleted list of what must already be installed on the dev machine:
|
||||
- Go ≥ 1.22 (existing project uses 1.26)
|
||||
- `just` task runner
|
||||
- `podman` with `podman compose` (preferred per D-11) OR `docker` with `docker compose`
|
||||
- `curl`
|
||||
- Git
|
||||
Note: `goose`, `templ`, `sqlc`, `air`, the Tailwind CLI, and `htmx.min.js` are auto-installed/vendored by `just bootstrap` — do NOT list them as prereqs.
|
||||
|
||||
3. **Quickstart (5-minute clone-to-page).** Numbered list of commands, in order:
|
||||
```
|
||||
cd backend
|
||||
cp .env.example .env # adjust DATABASE_URL if Postgres is not on localhost:5432
|
||||
just bootstrap # installs goose/templ/sqlc/air; vendors tailwindcss + htmx.min.js
|
||||
just db-up # starts postgres via podman compose (or docker compose — see below)
|
||||
just migrate up # applies migrations from ./migrations
|
||||
just dev # in terminal 1: builds, runs air watcher on :8080
|
||||
# in a SECOND terminal:
|
||||
just styles-watch # rebuilds static/tailwind.css on .templ / .go change
|
||||
# open http://localhost:8080
|
||||
```
|
||||
State explicitly: clicking "Fetch server time" should swap an ISO-8601 timestamp into the page. If you see "No time fetched yet." and nothing happens on click, see Troubleshooting.
|
||||
|
||||
4. **docker compose fallback.** One paragraph: if you use docker instead of podman, replace `podman compose` with `docker compose` everywhere (the `compose.yaml` is portable). Reference `just db-up` calls `podman compose up -d postgres` — if you don't have podman, run `docker compose up -d postgres` directly instead of `just db-up`. (D-11)
|
||||
|
||||
5. **Project layout.** Reproduce the directory layout from SKELETON.md (or the abbreviated form from CONTEXT D-01). Brief annotations next to each top-level dir.
|
||||
|
||||
6. **Environment variables.** Table of the three keys from `.env.example`: `DATABASE_URL`, `PORT`, `ENV` — with one-line descriptions and default values. Note `.env` is gitignored; `.env.example` is committed.
|
||||
|
||||
7. **Common commands.** Table mapping recipe → what it does → when to use it:
|
||||
- `just bootstrap` — install dev tools + vendored assets (one-time per clone)
|
||||
- `just db-up` / `just db-down` — start/stop local Postgres
|
||||
- `just migrate up` / `migrate down` / `migrate status` — apply / revert / inspect migrations
|
||||
- `just generate` — runs `templ generate`, `sqlc generate`, Tailwind compile (one-shot)
|
||||
- `just styles-watch` — Tailwind in watch mode (run in a 2nd terminal alongside `just dev`)
|
||||
- `just dev` — Postgres up + initial generate + air live-reload on `:8080`
|
||||
- `just test` — `go test ./...` (after `templ generate`)
|
||||
- `just lint` — `go vet` + `gofmt` check
|
||||
- `just build` — compile both `bin/web` and `bin/worker`
|
||||
|
||||
8. **Worker (skeleton — Phase 1 only).** Note that `cmd/worker` in Phase 1 boots, logs "worker ready", and idles on signal. Real job runtime lands in Phase 6 (D-03). To run it manually: `DATABASE_URL=... go run ./cmd/worker` (Ctrl-C to exit).
|
||||
|
||||
9. **Troubleshooting.** Subsection covering the top 3 Pitfalls from RESEARCH:
|
||||
- "Fresh clone fails to build with `undefined: templates.Index`" → run `just generate` first; templ-generated files are not committed (Pitfall 1).
|
||||
- "First request to `/healthz` returns 503 right after `just db-up`" → wait ~5–10s for the container to become healthy; check `podman compose ps`. Subsequent calls succeed (Pitfall 2).
|
||||
- "Tailwind classes used in `.templ` files don't appear in the compiled CSS" → confirm `tailwind.input.css` has `@source "../templates/**/*.templ"` and re-run `just styles-watch` (Pitfall 3).
|
||||
|
||||
10. **What Phase 1 ships (and doesn't).** Two short lists.
|
||||
- Ships: project scaffold, local Postgres via compose, goose migration pipeline, chi router, slog logging, RequestID middleware, graceful shutdown, /healthz, templ + HTMX demo, ui design-system package (Button/Card/Badge), live-reload dev loop.
|
||||
- Does NOT ship (deferred to listed phase): auth (Phase 2), tablos (Phase 3), tasks (Phase 4), files (Phase 5), real worker jobs (Phase 6), production deploy / Dockerfile / `/readyz` (Phase 7).
|
||||
|
||||
Hard constraints:
|
||||
- Keep the file under ~300 lines. README is a quickstart, not an architecture doc.
|
||||
- Every command shown must match a recipe that actually exists in `backend/justfile` — no aspirational commands.
|
||||
- No emoji. No marketing voice (UI-SPEC tone rules — "friendly, declarative, no marketing").
|
||||
- No screenshots in Phase 1. Plain text + code blocks only.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f backend/README.md && grep -q '^## Prerequisites' backend/README.md && grep -q '^## Quickstart' backend/README.md && grep -q 'just bootstrap' backend/README.md && grep -q 'just db-up' backend/README.md && grep -q 'just migrate up' backend/README.md && grep -q 'just dev' backend/README.md && grep -q 'just styles-watch' backend/README.md && grep -q 'docker compose' backend/README.md && grep -q 'DATABASE_URL' backend/README.md && grep -q 'PORT' backend/README.md && grep -q 'ENV' backend/README.md && grep -q 'Troubleshooting' backend/README.md && wc -l backend/README.md | awk '$1<=350{exit 0} {exit 1}'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- README has ≥10 distinct `##` sections per the action plan
|
||||
- Every `just ...` command mentioned matches a recipe in `backend/justfile` (verifiable by cross-grep)
|
||||
- docker compose fallback is documented (D-11)
|
||||
- Two-terminal workflow (`just dev` + `just styles-watch`) is explicitly called out
|
||||
- Troubleshooting surfaces ≥3 Pitfalls from RESEARCH.md
|
||||
- Total length ≤ 350 lines
|
||||
- No emoji (`grep -P '[\x{1F300}-\x{1F9FF}]' backend/README.md` returns empty)
|
||||
- No references to features deferred to Phases 2–7 except in the explicit "What Phase 1 ships (and doesn't)" section
|
||||
</acceptance_criteria>
|
||||
<done>README exists and matches the structure above; quickstart is internally consistent with the actual justfile.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2 (checkpoint): Clean-clone onboarding walkthrough — closes FOUND-05</name>
|
||||
<what-built>
|
||||
Task 1 wrote `backend/README.md`. This checkpoint exercises FOUND-05's success criterion 5 ("A new dev can clone the repo, run `compose up -d` + `just dev`, and see the page within ~5 minutes following `backend/README.md`") via a real walkthrough.
|
||||
</what-built>
|
||||
<files>backend/README.md (read by the developer being onboarded), fresh clone directory (Method A) or cleaned local backend/ (Method B)</files>
|
||||
<action>Human runs the onboarding walkthrough — agent cannot do this autonomously because the test is "does a human reading the README succeed?" The agent's job ends with Task 1 (writing the README); this checkpoint is the literal contract test for FOUND-05 success criterion 5.</action>
|
||||
<how-to-verify>
|
||||
Choose ONE of the two methods below:
|
||||
|
||||
**Method A — Fresh checkout in a sibling directory (preferred):**
|
||||
1. `cd ..` to the parent of this repo.
|
||||
2. `git clone <this repo path> xtablo-source-onboarding-test` (or shallow clone if disk space is a concern).
|
||||
3. `cd xtablo-source-onboarding-test/backend`.
|
||||
4. Open `README.md` and follow the Quickstart numbered list LITERALLY. Do not skip steps. Do not read other docs. Start a stopwatch.
|
||||
5. Confirm `http://localhost:8080/` loads, the page renders, the "Fetch server time" button works.
|
||||
6. Note total elapsed time. Target: ≤ 5 minutes wall-clock (including `just bootstrap` downloads — which is the slowest step).
|
||||
7. Note any step in the README that was ambiguous, missing, or wrong.
|
||||
8. Stop here. Do not commit to the sibling clone. `cd ..` and `rm -rf xtablo-source-onboarding-test`.
|
||||
|
||||
**Method B — In-place dry run (acceptable fallback):**
|
||||
1. From a clean working tree in `backend/`, run `just clean` (or manually delete `bin/`, `tmp/`, `static/tailwind.css`, `static/htmx.min.js`, `*_templ.go`).
|
||||
2. Open `README.md` and follow the Quickstart literally.
|
||||
3. Same time + ambiguity checks as Method A.
|
||||
|
||||
Acceptance:
|
||||
- Page renders within 5 minutes from step 1.
|
||||
- No step in the README requires guessing or out-of-band knowledge.
|
||||
- HTMX demo button works.
|
||||
- `/healthz` returns 200 with `{"status":"ok","db":"ok"}`.
|
||||
|
||||
If any step fails or is ambiguous: edit README, re-run.
|
||||
</how-to-verify>
|
||||
<verify>Human follows the README from a clean state; the running page is reached within 5 minutes; HTMX demo works; /healthz returns 200.</verify>
|
||||
<done>User responds "approved" with elapsed time noted; any README edits made during the walkthrough are committed.</done>
|
||||
<resume-signal>Type "approved" (with elapsed time and any minor README tweaks made) or describe blocking issues.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| README ↔ developer | README is the contract — wrong commands cause silent failure |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-01-14 | I (Information disclosure) | README example DSN | accept | The example DSN uses the dev-only `xtablo:xtablo` credentials documented in compose.yaml. README explicitly states "adjust DATABASE_URL if Postgres is not on localhost:5432" but does NOT instruct devs to commit secrets. `.env` remains gitignored. |
|
||||
| T-01-15 | (process) | Onboarding ambiguity → wasted time | mitigate | Checkpoint Task 2 is the contractual test — the README must produce a working page in one pass with no out-of-band knowledge. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `backend/README.md` exists and passes Task 1 automated checks
|
||||
- Task 2 checkpoint completes (clean-clone walkthrough succeeds, time ≤ 5 minutes)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. README documents every required step of the Phase 1 dev loop exactly once
|
||||
2. Commands in the README match recipes in the justfile name-for-name
|
||||
3. docker compose fallback documented (D-11)
|
||||
4. Two-terminal workflow documented (RESEARCH Open Question 2)
|
||||
5. Top 3 pitfalls from RESEARCH surfaced in a Troubleshooting section
|
||||
6. A fresh clone reaches the running page in ~5 minutes following README only — closes FOUND-05 success criterion 5
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md` documenting:
|
||||
- Final README line count and section count
|
||||
- Elapsed time of the clean-clone walkthrough
|
||||
- Any README edits made after the walkthrough (commit references)
|
||||
- Phase 1 completion status — all of FOUND-01..05 closed
|
||||
</output>
|
||||
117
.planning/phases/01-foundation/SKELETON.md
Normal file
117
.planning/phases/01-foundation/SKELETON.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Walking Skeleton — Xtablo Go+HTMX Rewrite
|
||||
|
||||
**Phase:** 1
|
||||
**Generated:** 2026-05-14
|
||||
|
||||
## Capability Proven End-to-End
|
||||
|
||||
A developer can clone the repo, run `just bootstrap`, `podman compose up -d`, `just migrate up`, and `just dev`, then load `http://localhost:8080/` in a browser, click "Fetch server time", and see an HTML fragment returned by the Go server — exercising templ rendering, chi routing, HTMX swap, static asset serving, slog logging, and a `/healthz` endpoint that calls `pgxpool.Ping` against the goose-migrated local Postgres.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Language / runtime | Go ≥ 1.22 (developer machine has 1.26) | No JS framework / no BaaS thesis; chi v5 requires 1.22 |
|
||||
| HTTP router | `github.com/go-chi/chi/v5` v5.2.5 | Idiomatic, standard middleware set (CONTEXT D-08) |
|
||||
| HTML templating | `github.com/a-h/templ` v0.3.1020 | Compile-time type checking; first-class HTMX fragment story (D-07) |
|
||||
| Client interactivity | HTMX v2.x (vendored at `static/htmx.min.js`) | "No JS framework" — HTMX swaps server-rendered fragments (D-10) |
|
||||
| CSS | Tailwind v4 standalone CLI binary in `./bin/tailwindcss` | Avoids any Node toolchain in `backend/` (D-12) |
|
||||
| DB driver | `github.com/jackc/pgx/v5` + `pgxpool` v5.9.2 | sqlc-recommended, richer pg types (D-16) |
|
||||
| Migrations | `github.com/pressly/goose/v3` v3.27.1 (CLI in Phase 1) | Embeddable library, one `.sql` per migration, sqlc-compatible (D-04, D-05) |
|
||||
| Query generation | `sqlc-dev/sqlc` v1.31.1 (configured; no queries yet) | Type-safe SQL without ORM (foundation for Phase 2+) |
|
||||
| Logging | `log/slog` stdlib — JSON in prod, text in dev | Stdlib, no external dep (D-17) |
|
||||
| Request IDs | `github.com/google/uuid` v1.6.0 via custom middleware | RFC 4122 UUIDs threaded through `context.Context` to slog (D-18) |
|
||||
| Live reload | `github.com/air-verse/air` v1.65.1 | Watches `.go` + `.templ`; pre_cmd runs `templ generate` |
|
||||
| Local Postgres | `podman compose` with `postgres:16-alpine` at `backend/compose.yaml` | Developer machine standard (D-11) |
|
||||
| Local task runner | `just` | Standard across the repo |
|
||||
| Deployment target | Single VPS / single container (deferred to Phase 7) | Locked in PROJECT.md |
|
||||
| Auth | Server-managed sessions, HTTP-only cookies (Phase 2) | "No JWT, no third-party auth" |
|
||||
| Directory layout | Two-binary `cmd/web` + `cmd/worker`, shared `internal/` (D-01, D-02, D-03) | See below |
|
||||
| Design-system | Custom templ component package at `backend/internal/web/ui/` (Button, Card, Badge in Phase 1) | UI-SPEC contract; mirrors `go-backend/internal/web/ui/` enum surface |
|
||||
|
||||
### Directory layout (locked)
|
||||
|
||||
```
|
||||
backend/
|
||||
cmd/
|
||||
web/main.go
|
||||
worker/main.go
|
||||
internal/
|
||||
db/ (pgxpool wiring + sqlc-generated queries)
|
||||
doc.go
|
||||
pool.go
|
||||
web/
|
||||
router.go
|
||||
handlers.go
|
||||
middleware.go
|
||||
handlers_test.go
|
||||
ui/ (custom templ design-system)
|
||||
tokens.go
|
||||
variants.go
|
||||
helpers.go
|
||||
base.css
|
||||
button.templ
|
||||
button.css
|
||||
card.templ
|
||||
card.css
|
||||
badge.templ
|
||||
badge.css
|
||||
ui_test.go
|
||||
session/doc.go (placeholder — Phase 2)
|
||||
tablos/doc.go (placeholder — Phase 3)
|
||||
tasks/doc.go (placeholder — Phase 4)
|
||||
files/doc.go (placeholder — Phase 5)
|
||||
migrations/
|
||||
0001_init.sql (no-op bootstrap; real schema lands Phase 2)
|
||||
templates/
|
||||
layout.templ
|
||||
index.templ
|
||||
fragments.templ
|
||||
static/
|
||||
htmx.min.js (vendored, not CDN)
|
||||
tailwind.css (generated by Tailwind standalone CLI)
|
||||
bin/ (gitignored — tailwindcss, air, etc.)
|
||||
.air.toml
|
||||
.env.example
|
||||
.gitignore
|
||||
compose.yaml
|
||||
go.mod / go.sum
|
||||
justfile
|
||||
sqlc.yaml
|
||||
tailwind.input.css
|
||||
README.md
|
||||
```
|
||||
|
||||
## Stack Touched in Phase 1
|
||||
|
||||
- [x] Project scaffold (`go mod init backend`, justfile, `.air.toml`, `tailwind.input.css`, `sqlc.yaml`, `compose.yaml`)
|
||||
- [x] Routing — chi router with `/`, `/healthz`, `/demo/time`, `/static/*`
|
||||
- [x] Database — `pgxpool.New(DATABASE_URL)` + `pool.Ping(ctx)` exercised by `/healthz`; one no-op `goose up` migration applied
|
||||
- [x] UI — `index.templ` rendered via `internal/web/ui` design-system (Button + Card), HTMX `hx-get` round-trip to `/demo/time` returning a templ fragment
|
||||
- [x] Deployment — local-run only in Phase 1 (Phase 7 owns container/VPS deploy). `just dev` is the documented full-stack run command.
|
||||
|
||||
## Out of Scope (Deferred to Later Slices)
|
||||
|
||||
- Authentication, sessions, users (Phase 2)
|
||||
- Tablos CRUD (Phase 3)
|
||||
- Tasks / kanban board (Phase 4)
|
||||
- File uploads + S3/R2 (Phase 5)
|
||||
- Real background jobs (Phase 6 — `cmd/worker` in Phase 1 is boot/log/shutdown only)
|
||||
- Production deploy, Dockerfile, `/readyz` (Phase 7)
|
||||
- Dark mode, icon library, web fonts (UI-SPEC defers these)
|
||||
- Form components (`Input`, `Textarea`, `Select`, `FormField`) — extend `internal/web/ui` in Phase 2
|
||||
- Modal / Table / EmptyState components — extend `internal/web/ui` in Phase 3
|
||||
- CSRF tokens, rate limiting (Phase 2)
|
||||
- Embedded `goose` library call from app startup (Phase 7)
|
||||
- CI configuration (no DEPLOY-XX requirement in Phase 1)
|
||||
|
||||
## Subsequent Slice Plan
|
||||
|
||||
Each later phase adds one vertical slice on top of this skeleton without altering its architectural decisions:
|
||||
|
||||
- Phase 2: User can sign up, log in (email + password, argon2/bcrypt), receive a session cookie, and stay logged in. Extends `internal/web/ui` with `Input`, `FormField`. Adds `users` + `sessions` tables.
|
||||
- Phase 3: Authenticated user can list / create / view / edit / delete tablos. Extends `internal/web/ui` with `Modal`, `Table`, `EmptyState`, `IconButton`.
|
||||
- Phase 4: User can run a kanban board inside a tablo (create/edit/move/reorder/delete tasks). Drag-and-drop or button-based reorder TBD.
|
||||
- Phase 5: User can attach files, list, download (signed URL), delete — backed by R2/S3.
|
||||
- Phase 6: `cmd/worker` runs a real job queue (river/asynq/pg_notify TBD) against the same Postgres.
|
||||
- Phase 7: Both binaries ship in a single multi-stage Docker image, deployed to a single VPS / Cloud Run-style host with migrations applied on deploy.
|
||||
Loading…
Reference in a new issue