- justfile: worker target depends on db-up, passes MinIO dev defaults (DATABASE_URL, S3_ENDPOINT/BUCKET/REGION/ACCESS_KEY/SECRET_KEY/USE_PATH_STYLE) - README: replace skeleton section with full "Running the Worker" docs (just worker command, expected logs, single-worker constraint, graceful shutdown, failed job retry observation) |
||
|---|---|---|
| .. | ||
| bin | ||
| cmd | ||
| internal | ||
| migrations | ||
| static | ||
| templates | ||
| .air.toml | ||
| .env.example | ||
| .gitignore | ||
| compose.yaml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| README.md | ||
| sqlc.yaml | ||
| tailwind.input.css | ||
Xtablo backend
Go + HTMX + Postgres. Phase 1: Walking Skeleton.
This README is the contract for FOUND-05: a developer with the prerequisites below should be able to clone the repo, follow the Quickstart, and see the HTMX-driven page within ~5 minutes.
Prerequisites
Install these on your dev machine before starting:
- Go ≥ 1.22 (this project's
go.moddeclares 1.26) - just — task runner (
brew install juston macOS,cargo install just, or see https://github.com/casey/just) - podman with
podman compose(preferred per D-11) or docker withdocker compose - curl
- git
You do not need to install goose, templ, sqlc, air, the Tailwind CLI, or
htmx.min.js — just bootstrap installs the Go tools into $GOBIN and
bootstrap-downloads the Tailwind binary and HTMX script into local, gitignored
paths.
Quickstart
Clone-to-running-page in ~5 minutes. Run from inside backend/.
cd backend
cp .env.example .env # adjust DATABASE_URL if Postgres is not on localhost:5432
just bootstrap # installs goose/templ/sqlc/air; bootstrap-downloads tailwindcss + htmx.min.js
just db-up # starts postgres via podman compose (see fallback below)
just migrate up # applies migrations from ./migrations
just dev # terminal 1: brings up db, runs generate, then air on :8080
# in a SECOND terminal:
just styles-watch # rebuilds static/tailwind.css on .templ / .go changes
# open http://localhost:8080
The page should render with a "Fetch server time" button. Clicking it swaps an ISO-8601 timestamp into the page via HTMX. If the page shows "No time fetched yet." and nothing happens on click, see Troubleshooting.
bootstrap is the slowest step (Go tool installs + two HTTP downloads). It only
needs to run once per clone.
docker compose fallback
compose.yaml is portable across podman and docker — the service definition is
identical. If you don't have podman:
- Replace
podman composewithdocker composementally throughout this README. - The
just db-up/just db-downrecipes callpodman composedirectly. Rundocker compose up -d postgres/docker compose downinstead, and continue with the rest of the Quickstart unchanged.
(Decision D-11.)
Project layout
backend/
cmd/
web/main.go # HTTP server entry point
worker/main.go # background worker — river periodic jobs (Phase 6)
internal/
db/ # pgxpool wiring + sqlc-generated queries
web/ # chi router, handlers, middleware, design-system
ui/ # custom templ component library (Button, Card, Badge)
session/ # placeholder — Phase 2
tablos/ # placeholder — Phase 3
tasks/ # placeholder — Phase 4
files/ # placeholder — Phase 5
migrations/ # goose .sql migrations
templates/ # .templ files (layout, index, fragments)
static/
htmx.min.js # bootstrap-downloaded by `just bootstrap`; gitignored; no runtime CDN
tailwind.css # generated by the Tailwind standalone CLI
bin/ # gitignored — tailwindcss CLI binary, etc.
.air.toml # air live-reload config
.env.example # committed; copy to .env
compose.yaml # local Postgres
go.mod / go.sum
justfile # task runner recipes — the source of truth for commands
sqlc.yaml
tailwind.input.css
README.md
HTMX is served from /static/htmx.min.js at runtime — no CDN. The justfile's
bootstrap-time unpkg.com URL is the single authoritative version pin (D-10).
Environment variables
backend/.env is gitignored; backend/.env.example is committed and lists the
three keys consumed by cmd/web (and cmd/worker for DATABASE_URL):
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
Postgres DSN used by the web + worker binaries and by just migrate |
postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable |
PORT |
HTTP port for cmd/web |
8080 |
ENV |
development enables slog's text handler; production switches to JSON |
development |
Common commands
Every command in this table is a recipe in backend/justfile.
| Recipe | What it does | When to use |
|---|---|---|
just bootstrap |
Installs Go CLI tools (goose, templ, sqlc, air); bootstrap-downloads bin/tailwindcss and static/htmx.min.js |
Once per clone; re-run after deleting bin/ or static/htmx.min.js |
just db-up |
Starts the local Postgres container | Before just migrate up / just dev if not already running |
just db-down |
Stops the local Postgres container | When you're done for the day |
just migrate up / migrate down / migrate status |
Applies / reverts / inspects goose migrations against DATABASE_URL |
After just db-up, or any time you change migrations/ |
just generate |
One-shot: templ generate, sqlc generate, Tailwind compile to static/tailwind.css |
After editing .templ, query SQL, or tailwind.input.css |
just styles-watch |
Tailwind standalone CLI in --watch mode |
In a second terminal alongside just dev (D-14) |
just dev |
Brings up Postgres, runs just generate, then runs air for Go live-reload on :8080 |
Main dev loop, terminal 1 |
just test |
templ generate then go test ./... |
Before committing |
just lint |
go vet ./... and gofmt -l check |
Before committing |
just build |
Generates assets, then builds bin/web and bin/worker |
Producing release binaries locally |
just clean |
Removes bin/, tmp/, static/htmx.min.js, static/tailwind.css, and *_templ.go files |
Reset to a fresh-clone state without dropping the Postgres volume |
Running the Worker
cmd/worker is the background job processor. It runs river periodic jobs against
the same Postgres as cmd/web. Start it with:
just worker
This requires just db-up (handled automatically as a dependency) and MinIO
running (used by the orphan-file cleanup job). If MinIO is not running, the worker
will exit on startup with "file store init failed".
What to expect
- Structured logs appear immediately at startup.
- A
"worker ready"log line appears within a few seconds afterrivermigrateand S3 init complete. - A
"worker heartbeat"log line appears almost immediately (the heartbeat job is configured withRunOnStart: true, so it fires on the first scheduler tick which happens within seconds of startup). - Subsequent heartbeat logs appear every ~1 minute.
- The orphan-file cleanup job runs every hour (no
RunOnStart— first run is ~1 hour after startup).
Single-worker constraint
Run only one worker process at a time (v1). River uses advisory locks for leader election and concurrent rivermigrate runs are unsafe. Do not run multiple worker instances against the same database in this version.
Graceful shutdown
Send SIGINT (Ctrl+C) and observe:
{"level":"INFO","msg":"shutting down"}
{"level":"INFO","msg":"shutdown complete"}
The worker calls riverClient.StopAndCancel with a 10-second timeout, which
cancels in-flight job contexts and waits for goroutines to exit before closing
the pool.
Observing failed job retries
River logs each failure via the SlogErrorHandler. A failed job produces a log
line like:
{"level":"ERROR","msg":"job error","job_id":42,"job_kind":"heartbeat","attempt":1,"max_attempts":25,"err":"..."}
River retries up to 25 times with exponential backoff (attempts^4 + jitter).
After 25 failed attempts the job is moved to the discarded state in river_job.
Troubleshooting
The three issues most likely to trip you up on a fresh clone:
-
"Fresh clone fails to build with
undefined: templates.Index" — Templ generates*_templ.gofiles from.templsources, and those generated files are not committed. Runjust generate(orjust dev, which calls it) before invokinggo builddirectly. (Pitfall 1.) -
"First request to
/healthzreturns 503 right afterjust db-up" — The Postgres container needs ~5–10 seconds to become healthy afterpodman compose up -dreturns. Checkpodman compose ps(ordocker compose ps) for thehealthystatus, or just wait and retry. Subsequent calls succeed. The 503 during warm-up is correct behavior, not a bug. (Pitfall 2.) -
"Tailwind classes used in
.templfiles don't appear in the compiled CSS" — Tailwind v4 only scans content paths declared via@sourceintailwind.input.css. Confirm the file contains@source "../templates/**/*.templ";(and equivalent globs forinternal/web/**/*.go). Re-runjust styles-watchso the watcher picks up the config change. (Pitfall 3.)
If something else is wrong and you want a clean slate without dropping the Postgres volume:
just clean # removes bin/, tmp/, static/htmx.min.js, static/tailwind.css, *_templ.go
just bootstrap # re-download tools and assets
just dev # back to a working state
Run just db-down first if you also want to drop the Postgres container.
What Phase 1 ships (and doesn't)
Ships:
- Project scaffold (
go.mod, justfile,.air.toml,tailwind.input.css,sqlc.yaml,compose.yaml) - Local Postgres via
compose.yaml(pg_isreadyhealthcheck) - goose migration pipeline (
migrations/0001_init.sqlis a no-op bootstrap) - chi router with
/,/healthz,/demo/time,/static/* - slog-based structured logging with RequestID middleware
- Graceful HTTP shutdown
- pgxpool wiring exercised by
/healthz - templ + HTMX demo (root page +
hx-getround-trip to a templ fragment) - Custom templ design-system package at
internal/web/ui/(Button, Card, Badge) - Live-reload dev loop (
just dev+just styles-watch) cmd/workerskeleton (boot, log, idle, shutdown)
Does not ship — deferred:
- Authentication, sessions, users → Phase 2
- Tablos CRUD → Phase 3
- Tasks / kanban → Phase 4
- File uploads + R2/S3 → Phase 5
- Real worker jobs → Phase 6
- Production deploy, Dockerfile,
/readyz→ Phase 7