go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
Showing only changes of commit 74e1c3c126 - Show all commits

View file

@ -0,0 +1,799 @@
# Phase 1: Foundation - Research
**Researched:** 2026-05-14
**Domain:** Go web server scaffold (chi + templ + HTMX + Tailwind + pgx/pgxpool + goose + sqlc)
**Confidence:** HIGH
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Directory Layout**
- **D-01:** Two-binary layout with shared `internal/`:
```
backend/
cmd/web/main.go
cmd/worker/main.go
internal/
db/ (sqlc-generated queries + pgx pool wiring)
web/ (chi router, handlers, middleware)
session/ (placeholder package — populated in Phase 2)
tablos/ (placeholder — Phase 3)
tasks/ (placeholder — Phase 4)
files/ (placeholder — Phase 5)
migrations/ (goose .sql files)
templates/ (.templ files)
static/ (tailwind.css output, htmx.min.js)
compose.yaml
justfile
.env.example
README.md
```
- **D-02:** Phase 1 creates the directory skeleton for all `internal/<domain>` packages (empty `doc.go` is fine) so later phases drop files in without restructuring.
- **D-03:** `cmd/worker` in Phase 1 is a minimal binary that boots, connects to Postgres, logs "worker ready", and exits cleanly on signal. Real job runtime is Phase 6.
**Migrations**
- **D-04:** Use **goose** (`pressly/goose`). Embeddable library + CLI; supports Go-based migrations; one `.sql` per migration with `-- +goose Up/Down` annotations.
- **D-05:** `just migrate up` / `just migrate down` / `just migrate status` wired via the goose CLI for local dev. Production migration strategy (embed vs CLI) decided in Phase 7.
- **D-06:** Phase 1 includes one trivial bootstrap migration (e.g., `0001_init.sql`) so the migration pipeline is exercised end-to-end.
**Templating + Router**
- **D-07:** **templ** (`a-h/templ`) for HTML.
- **D-08:** **chi** (`go-chi/chi/v5`) as the HTTP router. Middleware stack: `RequestID → RealIP → Logger (structured) → Recoverer → GracefulShutdown wiring`.
- **D-09:** `templ generate` runs via `just generate` (alongside `sqlc generate`).
- **D-10:** Base layout template renders a Tailwind-styled page with HTMX loaded from `/static/htmx.min.js` (vendored, not CDN). Include one working `hx-get` example.
**Local Dev Stack**
- **D-11:** **podman compose** for local Postgres (`backend/compose.yaml`). README documents that docker compose also works.
- **D-12:** **Standalone Tailwind CLI binary** (no Node/pnpm in `backend/`). Downloaded by a `just bootstrap` recipe into `./bin/tailwindcss` (gitignored); version pinned in the justfile.
- **D-13:** **air** (`cosmtrek/air` → now `air-verse/air`) for Go live-reload (`just dev`). Watches `.go` + `.templ`; triggers `templ generate` and rebuild.
- **D-14:** Tailwind in watch mode runs as a separate process (`just styles`) or via air's `pre_cmd` — planner decides.
**Configuration & Operational Basics**
- **D-15:** Env-driven config via `.env`. Required keys: `DATABASE_URL`, `PORT`, `ENV`. Provide `.env.example`.
- **D-16:** Postgres driver: **pgx/v5** with `pgxpool`. sqlc emits pgx-compatible code (`sqlc.yaml` engine: postgresql, sql_package: pgx/v5).
- **D-17:** Structured logging: `log/slog` (Go 1.21+) with JSON handler in prod, text in dev, switched by `ENV`.
- **D-18:** Request ID middleware attaches a UUID per request and threads it into slog via `context.Context`.
- **D-19:** Graceful shutdown: `cmd/web` traps SIGINT/SIGTERM, calls `http.Server.Shutdown` (default 10s), then closes the pgx pool.
- **D-20:** `/healthz` returns 200 with `{"status":"ok","db":"ok"}` only when `db.Ping` succeeds; otherwise 503 with `{"status":"degraded","db":"down"}`.
### Claude's Discretion
- Concrete chi middleware order within the agreed stack and slog handler configuration details.
- Exact `air.toml` settings, file watch globs, and whether tailwind runs as a separate `just styles` process or air `pre_cmd`.
- Whether goose runs migrations via library call from a `backend migrate` subcommand or pure CLI in Phase 1.
- Layout/CSS specifics of the demo page (minimal but professional; one `hx-get` interaction is enough).
- Whether to include a basic `internal/web/handlers_test.go` smoke test now or defer to Phase 2.
### Deferred Ideas (OUT OF SCOPE)
- Single-binary subcommand layout (`backend web` / `backend worker`).
- Embedded goose migrations called from app startup (Phase 1 uses CLI-only).
- `/readyz` endpoint (DEPLOY-04, Phase 7).
- Production logging configuration (sampling, redaction, log shipping).
- Full handler test suite (Phase 2 establishes testing strategy).
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| FOUND-01 | Fresh `backend/` Go package with module init, `cmd/web` and `cmd/worker` entrypoints, runnable HTTP server returning `/healthz` | Standard Stack (Go, chi, pgx); Architecture (two-binary layout, healthz handler pattern) |
| FOUND-02 | Postgres connection pool with env-driven config and a versioned migration tool wired into a `justfile` | Standard Stack (pgxpool, goose); Code Examples (pgxpool init, goose CLI invocation) |
| FOUND-03 | HTMX + Tailwind + templ rendering pipeline producing a base layout with a working dev loop (template hot-reload, CSS rebuild) | Standard Stack (templ, tailwind standalone, air); Architecture (static asset serving, dev loop) |
| FOUND-04 | Structured logging, request ID middleware, and graceful shutdown on the web server | Standard Stack (log/slog, chi middleware); Code Examples (slog handler switch, RequestID propagation, http.Server.Shutdown) |
| FOUND-05 | `.env.example`, local Postgres via `compose.yaml`, and a `justfile` documenting `dev`, `migrate`, `test`, `lint` | Architecture (compose.yaml shape, justfile recipes); Environment Availability (podman, just, Go, tailwind binary) |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
The repo CLAUDE.md describes the existing JS monorepo. The Go rewrite section explicitly establishes:
- Go + HTMX + Tailwind + Postgres + sqlc — **no third-party auth, no JS framework, no managed BaaS** in `backend/`.
- Server-managed sessions only (HTTP-only cookies). No JWTs.
- One web binary + one worker binary, same repo.
- Single VPS / container deploy. No Kubernetes.
- GSD workflow enforcement: use `/gsd-execute-phase` for phase work — direct edits outside GSD are disallowed.
These directly constrain Phase 1: no Node/npm dependency inside `backend/`, no JWT libraries, no Auth provider SDKs. The Tailwind standalone CLI choice exists specifically to honor "no JS toolchain in `backend/`."
## Summary
Phase 1 is a Walking Skeleton: the thinnest end-to-end slice that proves `air → templ → chi → pgxpool → Postgres → goose → tailwind` all wire together and live-reload on a developer's machine. Every architectural decision has already been locked in CONTEXT.md, so research focuses on **verified versions, canonical wiring patterns, and known pitfalls** rather than alternatives.
The stack is well-trodden — the developer's pre-existing `go-backend/` scratch directory already demonstrates a working templ + chi + pgx + sqlc + podman compose pipeline. The new `backend/` discards the pnpm-Tailwind path in favor of the standalone Tailwind binary and adds goose for migrations (which `go-backend/` did not use).
**Primary recommendation:** Build the smallest possible end-to-end loop first (web boots → `/healthz` calls `db.Ping` → root route renders one templ page with one `hx-get` button → goose applies one no-op migration), then layer in slog/RequestID/graceful shutdown. Resist adding anything that does not satisfy a FOUND-XX requirement.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| HTTP routing & middleware | Go server (`internal/web`) | — | chi router owns all request lifecycle |
| HTML rendering | Go server (templ → HTML) | Browser (HTMX swaps) | templ renders server-side; HTMX issues partial-fetch round-trips |
| Partial fragment fetch | Browser (HTMX `hx-get`) | Go server (templ partial) | HTMX makes the request; server returns an HTML fragment |
| DB connection pool | Go server (`internal/db`, pgxpool) | — | Single pool wired at startup, shared across handlers |
| Migrations | CLI (goose) against local Postgres | — | Phase 1 is CLI-driven via justfile; library embedding deferred |
| Static asset delivery | Go server (`http.FileServer` from `/static`) | — | Self-hosted (no CDN); `htmx.min.js` + `tailwind.css` vendored |
| CSS build | Local toolchain (Tailwind standalone CLI) | — | Compile-time artifact in `static/tailwind.css` |
| Live reload | Local toolchain (air) | — | Dev-only; watches `.go` + `.templ` |
| Process supervision | OS (signal handling in `cmd/web` and `cmd/worker`) | — | SIGINT/SIGTERM → graceful shutdown |
| Containerized Postgres | Local container runtime (podman compose) | — | Local dev only; prod Postgres is external (Phase 7) |
| Observability (logs) | Go server (`log/slog` to stdout) | — | JSON in prod, text in dev. No shipping in Phase 1. |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Go | 1.22+ (existing `go-backend/` uses 1.26) | Runtime | chi v5.2+ requires Go 1.22 minimum [VERIFIED: chi v5.2.5 release notes] |
| `github.com/go-chi/chi/v5` | v5.2.5 | HTTP router + middleware | Idiomatic, minimal, standard middleware set [VERIFIED: GitHub releases, Feb 5] |
| `github.com/a-h/templ` | v0.3.1020 | Type-safe HTML templates | Compiled, type-checked at build time; first-class HTMX fit [VERIFIED: GitHub releases, May 10] |
| `github.com/jackc/pgx/v5` | v5.9.2 | Postgres driver + pgxpool | Higher performance and richer types than `database/sql`; sqlc's recommended driver [VERIFIED: tags page, Apr 19, 2026] |
| `github.com/pressly/goose/v3` | v3.27.1 | DB migrations (CLI + library) | Embeddable, single-file SQL migrations, supports Go migrations [VERIFIED: GitHub releases, Apr 24] |
| `github.com/sqlc-dev/sqlc` | v1.31.1 | SQL → typed Go code generator | Type-safe queries, pgx integration [VERIFIED: GitHub releases, Apr 22] |
| `log/slog` | std lib (Go 1.21+) | Structured logging | Standard library; no external dep [CITED: pkg.go.dev/log/slog] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `github.com/google/uuid` | v1.6.0 | Request ID generation | RequestID middleware emits UUIDv4 per request [VERIFIED: go.mod in existing go-backend] |
| `github.com/air-verse/air` | v1.65.1 (CLI; not imported) | Go live-reload | Dev-only; configured via `.air.toml` [VERIFIED: GitHub releases, Apr 12; repo moved from `cosmtrek/air` to `air-verse/air`] |
| Tailwind standalone CLI | v4.x (pin in justfile) | CSS compile | Avoids Node/pnpm in `backend/` [CITED: tailwindcss.com/blog/standalone-cli] |
| HTMX | v2.x (vendor `htmx.min.js` into `static/`) | Client-side AJAX | Required for `hx-get` demo (success criterion 3) [ASSUMED: latest stable; planner verifies during execution] |
| `just` | latest | Task runner | Already in use in `go-backend/`; project standard [VERIFIED: existing justfile] |
| `podman compose` | matches developer's machine | Local Postgres | Locked in D-11 [VERIFIED: existing go-backend uses podman] |
### Alternatives Considered (rejected per CONTEXT.md)
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| chi | net/http 1.22 ServeMux | Smaller dep budget but hand-rolled middleware composition; rejected |
| goose | golang-migrate, atlas | golang-migrate is split-file; atlas is declarative/heavier — rejected for embeddability + sqlc alignment |
| templ | html/template | Templ is type-checked at compile time; html/template is runtime-typed — rejected |
| Tailwind standalone | pnpm + tailwindcss npm | Would reintroduce Node toolchain — rejected (load-bearing decision) |
| podman | docker | Developer machine standard — both supported via portable `compose.yaml` |
**Installation:**
```bash
# Go module
go mod init backend
go get github.com/go-chi/chi/v5@v5.2.5
go get github.com/a-h/templ@v0.3.1020
go get github.com/jackc/pgx/v5@v5.9.2
go get github.com/pressly/goose/v3@v3.27.1
go get github.com/google/uuid@v1.6.0
# CLI tools (developer machine)
go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
go install github.com/a-h/templ/cmd/templ@v0.3.1020
go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1
go install github.com/air-verse/air@v1.65.1
# Tailwind standalone (just bootstrap recipe)
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-{os}-{arch}
chmod +x tailwindcss-{os}-{arch}
mv tailwindcss-{os}-{arch} backend/bin/tailwindcss
```
**Version verification:** All versions listed above were checked against GitHub releases on 2026-05-14. Re-verify with `go list -m -u <module>` before pinning if more than ~30 days elapse before Phase 1 lands.
## Architecture Patterns
### System Architecture Diagram
```
┌───────────────────────────────────────────────────────────────┐
│ Developer Machine │
│ │
│ just dev │
│ ├─▶ podman compose up -d postgres ──▶ Postgres :5432 │
│ ├─▶ tailwind --watch ──▶ static/tailwind.css │
│ └─▶ air ──▶ rebuilds cmd/web on .go/.templ change │
│ │
│ just migrate up ──▶ goose CLI ──▶ migrations/*.sql ──▶ DB │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ cmd/web │
│ │
│ main() ─▶ load env (.env) │
│ ─▶ slog handler (JSON/text by ENV) │
│ ─▶ pgxpool.New(DATABASE_URL) │
│ ─▶ chi.NewRouter() │
│ ├─ RequestID (uuid → ctx → slog) │
│ ├─ RealIP │
│ ├─ Logger (slog-backed) │
│ ├─ Recoverer │
│ ├─ /healthz ──▶ db.Ping → JSON │
│ ├─ /static/* ──▶ http.FileServer(static/) │
│ ├─ / ──▶ templ Layout(Index) │
│ └─ /demo/time ──▶ templ Fragment (hx-get target) │
│ ─▶ http.Server.ListenAndServe │
│ ─▶ SIGINT/SIGTERM → Server.Shutdown(10s) → pool.Close │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ Browser │
│ GET / ──▶ HTML (layout + htmx.min.js + Tailwind CSS) │
│ Button click ──▶ hx-get /demo/time ──▶ HTML fragment swap │
└───────────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────┐
│ cmd/worker (Phase 1: skeleton only) │
│ main() ─▶ pgxpool.New ─▶ slog "worker ready" ─▶ wait on sig │
└───────────────────────────────────────────────────────────────┘
```
### Recommended Project Structure
```
backend/
├── cmd/
│ ├── web/main.go # web entrypoint (chi server)
│ └── worker/main.go # worker entrypoint (skeleton)
├── internal/
│ ├── db/
│ │ ├── doc.go # package doc
│ │ ├── pool.go # pgxpool.New wrapper
│ │ └── sqlc/ # generated (empty in Phase 1)
│ ├── web/
│ │ ├── router.go # chi.Router assembly
│ │ ├── handlers.go # /healthz, /, /demo/time
│ │ ├── middleware.go # RequestID + slog
│ │ └── handlers_test.go # (optional, Claude's discretion)
│ ├── session/doc.go # placeholder (Phase 2)
│ ├── tablos/doc.go # placeholder (Phase 3)
│ ├── tasks/doc.go # placeholder (Phase 4)
│ └── files/doc.go # placeholder (Phase 5)
├── templates/
│ ├── layout.templ # base HTML + <head>
│ ├── index.templ # root page with hx-get button
│ └── fragments.templ # server-rendered partials
├── migrations/
│ └── 0001_init.sql # no-op or schema_migrations baseline
├── static/
│ ├── htmx.min.js # vendored
│ └── tailwind.css # generated by tailwind standalone
├── bin/ # gitignored — tailwind CLI lives here
├── .air.toml
├── .env.example # DATABASE_URL, PORT, ENV
├── .gitignore # bin/, tailwind.css, tmp/, .env
├── compose.yaml # Postgres service
├── go.mod / go.sum
├── justfile # dev, migrate, generate, test, lint, build
├── sqlc.yaml # engine: postgresql, sql_package: pgx/v5
├── tailwind.input.css # @tailwind base/components/utilities
└── README.md # 5-minute quickstart
```
### Pattern 1: pgxpool wiring with health check
**What:** Create a single `*pgxpool.Pool` at startup, share across handlers via a struct, expose `Ping` for `/healthz`.
**When to use:** Every Go+Postgres service; required for FOUND-02 and the `/healthz` DB check.
**Example:**
```go
// Source: pkg.go.dev/github.com/jackc/pgx/v5/pgxpool (canonical pattern)
import "github.com/jackc/pgx/v5/pgxpool"
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 10
cfg.MinConns = 1
return pgxpool.NewWithConfig(ctx, cfg)
}
// In /healthz handler:
if err := pool.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status":"degraded","db":"down"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status":"ok","db":"ok"})
```
### Pattern 2: chi middleware order (standard)
**What:** Stack middleware so request IDs/IPs are available to the logger, panics never escape, and shutdown signals are honored.
**When to use:** Every chi router in this project.
**Example:**
```go
// Source: github.com/go-chi/chi v5 README (canonical middleware order)
r := chi.NewRouter()
r.Use(middleware.RequestID) // chi-provided; or custom UUID-emitting middleware
r.Use(middleware.RealIP)
r.Use(slogLoggingMiddleware) // custom: reads RequestID from ctx, attaches to slog.Logger
r.Use(middleware.Recoverer) // recovers from panics; must be after Logger
```
### Pattern 3: slog handler switch by ENV
**What:** Text handler in dev (human-readable), JSON handler in prod (machine-parseable).
**When to use:** App startup in both `cmd/web` and `cmd/worker`.
**Example:**
```go
// Source: pkg.go.dev/log/slog (handler constructors)
var handler slog.Handler
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
if os.Getenv("ENV") == "production" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
```
### Pattern 4: RequestID → context → slog
**What:** Generate UUID per request, attach to `context.Context`, derive a per-request `*slog.Logger`.
**When to use:** All HTTP handlers — required for FOUND-04.
**Example:**
```go
type ctxKey string
const requestIDKey ctxKey = "request_id"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.NewString()
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func LoggerFromContext(ctx context.Context) *slog.Logger {
if id, ok := ctx.Value(requestIDKey).(string); ok {
return slog.Default().With("request_id", id)
}
return slog.Default()
}
```
### Pattern 5: Graceful shutdown
**What:** Trap SIGINT/SIGTERM, call `http.Server.Shutdown`, close pgxpool, exit.
**When to use:** Both `cmd/web` and `cmd/worker`.
**Example:**
```go
// Source: chi v5 graceful shutdown example
srv := &http.Server{Addr: ":"+port, Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err); os.Exit(1)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { slog.Error("shutdown", "err", err) }
pool.Close()
```
### Pattern 6: templ + chi handler integration
**What:** templ components implement `Render(ctx, io.Writer) error`. Write directly from a chi handler.
**Example:**
```go
// Source: templ.guide (HTTP server integration)
func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.Index().Render(r.Context(), w)
}
// For HTMX fragment:
func demoTimeHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.TimeFragment(time.Now()).Render(r.Context(), w)
}
```
### Pattern 7: One `hx-get` demo (success criterion 3)
```html
<!-- templates/index.templ renders this -->
<button hx-get="/demo/time" hx-target="#demo-out" hx-swap="innerHTML">
What time is it?
</button>
<div id="demo-out"></div>
<script src="/static/htmx.min.js"></script>
```
Server returns an HTML fragment (e.g. `<span>2026-05-14T12:00:00Z</span>`). Zero JS required client-side beyond HTMX.
### Anti-Patterns to Avoid
- **Loading HTMX from a CDN.** D-10 mandates vendoring. CDN couples the app to network reachability and breaks the "single binary + static" thesis.
- **Spawning a new `pgxpool` per request.** One pool for the process lifetime; share via dependency injection.
- **Putting `templ generate` inside `go run`.** Run it via `just generate` (or air's `pre_cmd`) before the build — `.templ` files don't compile by themselves.
- **Logging the raw `Authorization` header or `Cookie` in the request logger.** Not in scope Phase 1 but the logger middleware should be written from day one with a known safe-fields list.
- **Using chi's `middleware.Logger`.** It writes plain text. Replace with a slog-backed middleware to keep one logging format.
- **Hardcoding port/DSN.** Read from env per D-15.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Connection pooling | Custom pool over `database/sql` | `pgxpool.Pool` | Battle-tested, exposed `Ping`/`Stat`/`Acquire` |
| Request IDs | Custom random string generator | `github.com/google/uuid` v1.6.0 | RFC 4122 conformant, collision-resistant |
| Middleware composition | Manual `http.Handler` wrapping | chi's `Use` chain | Order and short-circuiting handled correctly |
| Migrations | `psql -f` from a Makefile | `goose` | Version tracking, status, rollback support |
| HTML template typing | string concat or html/template | `templ` | Compile-time type safety prevents XSS by default |
| Live reload | shell loops + inotify | `air` | Handles partial-rebuild edge cases (panic/exit codes) |
| CSS build | Hand-curated CSS | Tailwind standalone CLI | Purges unused classes; consistent design tokens |
| Graceful shutdown wiring | Custom signal goroutine | `signal.Notify` + `http.Server.Shutdown` | Documented stdlib idiom |
**Key insight:** Phase 1 is well-trodden territory. Every piece of the scaffold has a canonical Go ecosystem answer; deviation should require an explicit reason in the plan.
## Runtime State Inventory
> Phase 1 is a **greenfield** phase — `backend/` does not yet exist. No rename/refactor/migration involved. Section omitted.
## Common Pitfalls
### Pitfall 1: `templ generate` not run before `go build`
**What goes wrong:** `*.templ.go` files are missing; compilation fails with "undefined: templates.Index".
**Why it happens:** Templ files compile to Go via a separate generator. Devs forget after `git clone`.
**How to avoid:**
- Make `just generate` the first step in `just dev`, `just build`, and `just test`.
- Document the bootstrap order in README.md.
- Add `*.templ.go` to `.gitignore` so generated files are never committed (forces regeneration).
**Warning signs:** Fresh-clone "undefined" compile errors; CI failures only on first builds.
### Pitfall 2: pgxpool dialing before Postgres is ready
**What goes wrong:** `just dev` starts the web binary before `compose up -d postgres` has finished initializing → first request returns 503.
**Why it happens:** `podman compose up -d` returns once the container exists, not once Postgres is accepting connections.
**How to avoid:**
- Use `compose.yaml` healthcheck (`pg_isready -U xtablo`) as in `go-backend/compose.yaml`.
- In `just dev`, wait for healthy before starting `air`, or accept that `/healthz` returns 503 for the first few seconds (this is actually correct behavior).
- pgxpool's `New` does not eagerly connect — connections are lazy. Don't try to "fix" this by adding a startup `Ping` retry loop.
**Warning signs:** Intermittent first-request 503s on cold starts.
### Pitfall 3: Tailwind config doesn't see `.templ` files
**What goes wrong:** Tailwind purges all utility classes used only in `.templ` files → blank-looking page after CSS rebuild.
**Why it happens:** Tailwind v4 scans content paths declared in CSS (`@source`) or config. Default glob is `*.html` and `*.{js,ts,jsx,tsx}``.templ` is not included.
**How to avoid:** Add explicit content sources in `tailwind.input.css`:
```css
@source "../templates/**/*.templ";
@source "../internal/web/**/*.go";
```
**Warning signs:** Classes work in `templ-generate`d Go files but disappear after Tailwind rebuild.
### Pitfall 4: Forgetting to close `pgxpool` on shutdown
**What goes wrong:** Process exits with active connections in flight; Postgres logs `client unexpectedly closed`.
**Why it happens:** `os.Exit` after `http.Server.Shutdown` skips deferred `pool.Close()`.
**How to avoid:** Always call `pool.Close()` explicitly after `Shutdown` returns, not via `defer` from `main`.
### Pitfall 5: air watching too much (or too little)
**What goes wrong:** Rebuilds loop on its own generated output (`*.templ.go`, `tailwind.css`) or fails to pick up `.templ` edits.
**Why it happens:** Default `air.toml` watches `.go` only and includes everything in `tmp/`.
**How to avoid:** Configure `.air.toml` explicitly:
- `include_ext = ["go", "templ"]`
- `exclude_dir = ["tmp", "bin", "static", ".git", "internal/db/sqlc"]`
- `exclude_regex = [".*_templ\\.go$"]` (generated files; let templ regenerate via pre-cmd, then air rebuilds)
- `pre_cmd = ["templ generate"]`
### Pitfall 6: chi `middleware.Logger` clashes with slog
**What goes wrong:** Two log lines per request, one plain-text from chi, one structured from your slog middleware.
**Why it happens:** Adding `middleware.Logger` from chi alongside a custom slog logger.
**How to avoid:** Don't use chi's built-in Logger. Write a thin slog-backed alternative or use `github.com/go-chi/httplog/v2`.
### Pitfall 7: Goose migration directory mismatch with sqlc
**What goes wrong:** sqlc generates code from a schema that doesn't match what goose has applied; queries fail at runtime.
**Why it happens:** sqlc's `schema` path doesn't include the goose migrations directory, or the order differs.
**How to avoid:** Point `sqlc.yaml` `schema` at `migrations/` directly so sqlc reads the same `.sql` files goose runs:
```yaml
sql:
- engine: postgresql
schema: "migrations"
queries: "internal/db/queries"
gen: { go: { sql_package: "pgx/v5", ... } }
```
**Note:** Phase 1 has no queries yet — but get this config right now to avoid Phase 2 friction.
### Pitfall 8: HTMX served from CDN by accident in the demo
**What goes wrong:** Copy-pasted HTMX example uses `<script src="https://unpkg.com/htmx.org">` instead of `/static/htmx.min.js`.
**Why it happens:** Every HTMX tutorial uses unpkg.
**How to avoid:** Vendor `htmx.min.js` (the file is ~50KB) into `static/` during `just bootstrap`. Reference it via `/static/htmx.min.js`.
## Code Examples
Verified patterns from official sources:
### `/healthz` handler (FOUND-01, success criterion 2)
```go
// Source: derived from pgxpool docs + chi router pattern
func healthzHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/json")
if err := pool.Ping(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "degraded", "db": "down"})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok", "db": "ok"})
}
}
```
### Goose migration file (`migrations/0001_init.sql`)
```sql
-- Source: github.com/pressly/goose README format
-- +goose Up
-- Phase 1: no-op bootstrap migration. Schema lands in Phase 2.
SELECT 1;
-- +goose Down
SELECT 1;
```
### justfile recipes (FOUND-05)
```makefile
# Reference: existing go-backend/justfile (adapted: pnpm removed)
set shell := ["bash", "-cu"]
database_url := "postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable"
tailwind := "./bin/tailwindcss"
default:
@just --list
bootstrap:
# Downloads tailwind standalone to ./bin (see Pitfall 8 + D-12)
mkdir -p bin
curl -sSL -o bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.X.X/tailwindcss-$(uname -s | tr A-Z a-z)-$(uname -m)
chmod +x bin/tailwindcss
db-up:
podman compose up -d postgres
db-down:
podman compose down
migrate cmd="status":
GOOSE_DRIVER=postgres GOOSE_DBSTRING='{{database_url}}' GOOSE_MIGRATION_DIR=migrations \
goose {{cmd}}
generate:
templ generate
sqlc generate
{{tailwind}} -i tailwind.input.css -o static/tailwind.css
styles-watch:
{{tailwind}} -i tailwind.input.css -o static/tailwind.css --watch
dev: db-up
just generate
DATABASE_URL='{{database_url}}' air -c .air.toml
test:
just generate
go test ./...
lint:
go vet ./...
gofmt -l . | (grep . && exit 1 || exit 0)
build:
just generate
go build -o bin/web ./cmd/web
go build -o bin/worker ./cmd/worker
```
### `compose.yaml` (lifted from go-backend with seed mount removed)
```yaml
# Source: existing go-backend/compose.yaml — strip dev seed mounts for Phase 1
services:
postgres:
image: postgres:16-alpine
container_name: xtablo-backend-postgres
restart: unless-stopped
environment:
POSTGRES_DB: xtablo
POSTGRES_USER: xtablo
POSTGRES_PASSWORD: xtablo
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xtablo -d xtablo"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
```
### `.env.example`
```bash
# Source: D-15
DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable
PORT=8080
ENV=development
```
### `.air.toml` (key sections)
```toml
# Source: github.com/air-verse/air README, adapted for templ
root = "."
tmp_dir = "tmp"
[build]
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
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `cosmtrek/air` import path | `air-verse/air` | Repo transferred 2024 | Use new path in `go install`; old path still redirects |
| `lib/pq` Postgres driver | `pgx/v5 + pgxpool` | pgx v5 release (2022) | Standard for new Go services; richer pg types, better perf |
| `database/sql` + handcrafted queries | `sqlc` codegen | Stable since ~2020 | Type-safe SQL without ORM overhead |
| `html/template` | `a-h/templ` | Templ stable for ~2 years | Compile-time type checking; first-class HTMX fragment story |
| Tailwind via PostCSS + Node | Tailwind standalone CLI (v4) | Tailwind v4 (2024) | Pure binary; no JS toolchain dependency |
| logrus / zap | `log/slog` (stdlib) | Go 1.21 (Aug 2023) | Stdlib structured logging; no external dep needed for greenfield |
**Deprecated/outdated:**
- `cosmtrek/air` URL (still works, but canonical is `air-verse/air`).
- `lib/pq` for new code (still maintained but pgx is preferred for new services).
- Any tutorial that uses `pq.NewConnector` or `sql.Open("postgres", ...)` — translate to pgx.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | HTMX latest stable is v2.x — vendor whatever the latest 2.x `htmx.min.js` is at execution time | Standard Stack | Low. If v3 ships before execution, planner should re-check API compatibility for `hx-get`/`hx-target` (no breaking changes expected for these core attributes). |
| A2 | Tailwind v4.x is the current major; pin a specific 4.x patch in the justfile during execution | Standard Stack, Code Examples | Low. v3 → v4 introduced `@source` directive; if v4 is not available on a platform binary, fall back to v3 syntax. |
| A3 | Go toolchain available on developer machine is ≥ 1.22 (chi v5.2+ requirement) | Standard Stack | Existing `go-backend/go.mod` declares `go 1.26.0`, so this is highly likely. Verify in Environment Availability step. |
| A4 | `podman compose` v2 syntax matches `docker compose` v2 syntax for the services used here | Architecture | Low. Both implement the Compose spec; `postgres:16-alpine` is universally supported. |
**Interpretation:** All four assumptions are LOW risk; planner can proceed without user confirmation but should re-verify HTMX/Tailwind versions at the moment of bootstrap.
## Open Questions
1. **Goose: pure CLI in Phase 1, or library-call from a `backend migrate` subcommand now?**
- What we know: D-04 + D-05 say CLI for Phase 1; D-71 (Claude's discretion) says planner may set up for Phase 7's likely library approach.
- What's unclear: Whether to add the `cmd/migrate/main.go` (or `cmd/web` subcommand) skeleton now or wait.
- Recommendation: Pure CLI in Phase 1. Adding the library wiring now blurs the phase boundary and creates two migration paths (CLI + lib) before one is hardened. Phase 7 can introduce `cmd/web migrate` cleanly.
2. **Tailwind watch: separate `just styles` process, or air `pre_cmd`?**
- What we know: D-14 leaves this to the planner.
- What's unclear: Whether developers run `just dev` (one terminal) or `just dev` + `just styles` (two terminals).
- Recommendation: Two terminals — keep concerns separated. Tailwind watch is independent of Go rebuild; piping through air's `pre_cmd` would trigger CSS rebuild on every `.go` save, which is wasteful. Document the two-terminal workflow in README.
3. **Phase 1 `/healthz` smoke test: ship it or defer?**
- What we know: Phase 2 establishes the testing strategy.
- What's unclear: Whether a single `handlers_test.go` covering `/healthz` (with a stub `Pinger` interface) earns its keep now.
- Recommendation: Ship it. It exercises the chi+slog wiring, provides a regression net for Phase 2 to extend, and the `Pinger` interface stub costs <10 lines. See Validation Architecture below.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Go toolchain | Build/run web+worker | ✓ (existing `go-backend` uses 1.26) | 1.26 | — |
| `just` | All recipes | ✓ (used by existing go-backend) | latest | — |
| `podman` + `podman compose` | Local Postgres | ✓ (developer standard per D-11) | latest | docker compose with same compose.yaml |
| `git` | Repo ops | ✓ | — | — |
| `curl` | `just bootstrap` (tailwind download) | ✓ (macOS/Linux standard) | — | `wget` |
| `goose` CLI | `just migrate` | ✗ (likely missing) | — | `go install github.com/pressly/goose/v3/cmd/goose@v3.27.1` in `just bootstrap` |
| `templ` CLI | `just generate` | ✗ (likely missing) | — | `go install github.com/a-h/templ/cmd/templ@v0.3.1020` in `just bootstrap` |
| `sqlc` CLI | `just generate` | ✗ (likely missing) | — | `go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1` in `just bootstrap` |
| `air` CLI | `just dev` | ✗ (likely missing) | — | `go install github.com/air-verse/air@v1.65.1` in `just bootstrap` |
| Tailwind standalone | CSS build | ✗ (missing) | — | `just bootstrap` downloads pinned version to `./bin/tailwindcss` |
| HTMX `htmx.min.js` | Demo page | ✗ (missing) | — | `just bootstrap` curls latest 2.x release into `static/htmx.min.js` |
**Missing dependencies with no fallback:** None. Every missing tool has an install path.
**Missing dependencies with fallback:** All Phase 1 tools (goose, templ, sqlc, air, tailwind, htmx) are bootstrappable via `just bootstrap`. Plan must include this recipe.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go stdlib `testing` (Go 1.26) |
| Config file | none (Go convention) |
| Quick run command | `go test ./internal/web/...` |
| Full suite command | `just test` (runs `just generate` then `go test ./...`) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| FOUND-01 | `cmd/web` builds; `cmd/worker` builds | smoke (build) | `go build ./cmd/web ./cmd/worker` | ❌ Wave 0 |
| FOUND-01 | `/healthz` returns 200 with `{"status":"ok","db":"ok"}` when DB up | unit (handler + stub Pinger) | `go test ./internal/web -run TestHealthz_OK` | ❌ Wave 0 |
| FOUND-01 | `/healthz` returns 503 with `{"status":"degraded","db":"down"}` when DB down | unit (handler + stub Pinger returns error) | `go test ./internal/web -run TestHealthz_Down` | ❌ Wave 0 |
| FOUND-02 | `pgxpool.New` succeeds against compose Postgres | integration (manual or local-only) | `go test ./internal/db -run TestPool_Connects` (skipped if `DATABASE_URL` unset) | ❌ Wave 0 |
| FOUND-02 | `goose up` applies bootstrap migration cleanly | manual (justfile execution) | `just migrate up && just migrate status` | manual |
| FOUND-03 | Root route returns 200 with HTML containing `hx-get` attribute | unit (httptest) | `go test ./internal/web -run TestIndex_RendersHxGet` | ❌ Wave 0 |
| FOUND-03 | `/demo/time` returns HTML fragment | unit (httptest) | `go test ./internal/web -run TestDemoTime_Fragment` | ❌ Wave 0 |
| FOUND-03 | `just dev` live-reloads on `.go` and `.templ` edits | manual | manual visual check | manual |
| FOUND-04 | RequestID middleware sets `X-Request-ID` header | unit (httptest + middleware chain) | `go test ./internal/web -run TestRequestID_HeaderSet` | ❌ Wave 0 |
| FOUND-04 | slog handler is JSON when `ENV=production`, text otherwise | unit (capture handler output) | `go test ./internal/web -run TestSlog_HandlerSwitch` | ❌ Wave 0 |
| FOUND-04 | Graceful shutdown closes pgxpool | manual or signal-driven test | `go test ./internal/web -run TestShutdown_ClosesPool` (optional; manual acceptable) | manual |
| FOUND-05 | `.env.example` exists and contains `DATABASE_URL`, `PORT`, `ENV` | smoke (file existence + grep) | `test -f .env.example && grep -q DATABASE_URL .env.example` | manual |
| FOUND-05 | `compose.yaml` brings up Postgres healthily | manual | `just db-up && podman compose ps` | manual |
| FOUND-05 | `justfile` exposes `dev`, `migrate`, `test`, `lint` | smoke | `just --list \| grep -E '^(dev\|migrate\|test\|lint)\b'` | manual |
| FOUND-05 | README quickstart works in <5min on fresh clone | manual (peer review) | dev follows README | manual |
### Sampling Rate
- **Per task commit:** `go test ./internal/web/...` (fast: <2s for the listed unit tests)
- **Per wave merge:** `just test` (runs `just generate` then `go test ./...`)
- **Phase gate:** `just check` equivalent (`generate` + `test` + `build`) green, plus the manual checks (compose up, just dev visual, README walkthrough) before `/gsd-verify-work`.
### Wave 0 Gaps
- [ ] `backend/internal/web/handlers_test.go` — covers FOUND-01 (/healthz OK + down), FOUND-03 (index + fragment), FOUND-04 (RequestID, slog switch)
- [ ] `backend/internal/db/pool_test.go` — covers FOUND-02 (pgxpool connects); skip when `DATABASE_URL` unset for CI
- [ ] Shared test helpers (`stubPinger` implementing a `Pinger` interface) live in `internal/web/handlers_test.go`
- [ ] No framework install needed (stdlib `testing` only)
- [ ] `.github/` or similar CI config is **out of scope** for Phase 1 (no DEPLOY-XX requirement applies). Local `just test` is the gate.
## Security Domain
> Phase 1 introduces no authentication, no user input handling, and no data persistence beyond a no-op migration. ASVS scope is therefore minimal. Full security controls land in Phase 2 (AUTH-01..07). The notes below establish the *foundation hooks* Phase 2 will use.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no (Phase 2) | — |
| V3 Session Management | no (Phase 2) | — |
| V4 Access Control | no (Phase 3 owners-only) | — |
| V5 Input Validation | partial | No user input in Phase 1 surfaces. When `/demo/time` accepts no params, no validation required. templ auto-escapes interpolations — preserves this property for future phases. |
| V6 Cryptography | no | No secrets handled in Phase 1 beyond `DATABASE_URL` in env. |
| V7 Error Handling & Logging | yes | slog with RequestID; **do not log** `DATABASE_URL`, raw headers, or full request bodies. |
| V8 Data Protection | partial | `.env` must be in `.gitignore`; `.env.example` is the only env file committed. |
| V14 Configuration | yes | Env-driven config; `.env.example` documents required keys; no secrets in repo. |
### Known Threat Patterns for {Go web + Postgres + HTMX}
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| XSS via unescaped HTML | Tampering | templ auto-escapes by default; never use `templ.Raw` on user data (no user data in Phase 1) |
| SQL injection | Tampering | sqlc + pgx parameterized queries (no queries in Phase 1, but the wiring exists) |
| Information disclosure via logs | Information disclosure | slog handler with explicit field allowlist; never log full Authorization/Cookie headers |
| Open file traversal via `/static/` | Tampering | `http.FileServer` over an `http.Dir("static")` rooted at a single dir is safe; do **not** use `http.StripPrefix` incorrectly such that paths can escape |
| Secret commit (`.env`) | Information disclosure | `.gitignore` includes `.env`; `.env.example` only |
| Panic → process crash | DoS | `middleware.Recoverer` recovers panics; structured log + 500 response |
| Slow client → resource exhaustion | DoS | `http.Server.ReadTimeout`, `WriteTimeout`, `IdleTimeout` set explicitly (Phase 1 should set sane defaults, e.g., 15s/15s/60s) |
## Sources
### Primary (HIGH confidence)
- pressly/goose releases page (v3.27.1, Apr 24): https://github.com/pressly/goose/releases
- a-h/templ releases (v0.3.1020, May 10): https://github.com/a-h/templ/releases
- go-chi/chi releases (v5.2.5, Feb 5): https://github.com/go-chi/chi/releases
- jackc/pgx tags (v5.9.2, Apr 19, 2026): https://github.com/jackc/pgx/tags
- sqlc-dev/sqlc releases (v1.31.1, Apr 22): https://github.com/sqlc-dev/sqlc/releases
- air-verse/air releases (v1.65.1, Apr 12): https://github.com/air-verse/air/releases
- Tailwind standalone CLI announcement: https://tailwindcss.com/blog/standalone-cli
- pressly/goose README (CLI commands, file format): https://github.com/pressly/goose
- `log/slog` package docs: https://pkg.go.dev/log/slog
- `jackc/pgx/v5/pgxpool` docs: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
- templ guide (HTTP integration): https://templ.guide
- Existing `go-backend/` (verified working reference for templ+chi+pgx+sqlc+podman compose wiring)
### Secondary (MEDIUM confidence)
- chi middleware order convention — derived from chi v5 README + community-standard examples.
- HTMX v2 attribute set (`hx-get`, `hx-target`, `hx-swap`) — stable since v1; backward-compatible in v2 for core attributes.
### Tertiary (LOW confidence)
- Tailwind v4 `@source` directive syntax — should be verified at execution time against the pinned Tailwind version (v3 used `content` arrays in `tailwind.config.js`).
- Exact HTMX 2.x patch version — vendor latest at execution time.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — every version verified against GitHub releases / tags.
- Architecture: HIGH — locked in CONTEXT.md; patterns match existing `go-backend/` reference and Go ecosystem canon.
- Pitfalls: HIGH — drawn from canonical docs and the existing `go-backend/` pitfalls (e.g., templ generate ordering already proven in that scaffold).
- Validation strategy: HIGH — stdlib `testing` is the only sane choice for Go; Wave 0 file list is concrete.
- Security: HIGH-for-Phase-1-scope — Phase 1 has minimal attack surface; real security work is Phase 2.
**Research date:** 2026-05-14
**Valid until:** 2026-06-14 (30 days; stack is stable, but re-verify Tailwind/HTMX/air versions if execution slips past this).