Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.github
.cache
.t3
.agents
.env
.env.*
bin
tools
docs
apps/mobile-app
**/*_test.go
**/node_modules
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copied to .env by `make` on first run. .env is gitignored; never commit secrets.

# development | test | production. Production turns on email verification and
# requires RESEND_API_KEY; development logs email links instead of sending.
APP_ENV=development

# Public origin of the app (links in emails, auth callbacks). The server binds
# 0.0.0.0, so set this to the URL people actually use (LAN/Tailscale host is fine).
PUBLIC_URL=http://localhost:3000
# Optional: origin the server uses to reach itself (defaults to PUBLIC_URL).
# INTERNAL_URL=http://127.0.0.1:3000
# Optional: extra origins allowed to call auth endpoints (comma-separated),
# e.g. the mobile app scheme or a Tailscale hostname.
# TRUSTED_ORIGINS=betterstack://,http://dev-box:3000
# Optional: listen port (Railway sets it).
# PORT=3000

# Local docker compose Postgres. Hosted Postgres uses ?sslmode=require.
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app?sslmode=disable

# At least 32 characters. Generate one with: openssl rand -base64 32
AUTH_SECRET=dev-only-secret-change-me-0123456789abcdef

# Required in production.
RESEND_API_KEY=
FROM_EMAIL=noreply@example.com

# Host ports for docker compose (override when taken by other projects).
# POSTGRES_PORT=5432
# PGWEB_PORT=5050
# TEST_POSTGRES_PORT=54329
158 changes: 158 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Single required check for branch protection and the merge queue: `ci-ok`.
# Other jobs may be skipped by path filters; ci-ok treats skipped as success.
# Draft PRs run nothing: mark ready for review to run CI (merge_group, push to
# main and workflow_dispatch always run everything).
name: CI

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
push:
branches: [main]
merge_group:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
# PRs only: push to main and merge_group always run everything.
changes:
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
pull-requests: read
outputs:
go: ${{ steps.filter.outputs.go }}
mobile: ${{ steps.filter.outputs.mobile }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
go:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'tools/**'
- '**/*.sql'
- 'packages/database/sqlc.yaml'
- '.golangci.yml'
- 'Makefile'
- 'apps/web/**'
- 'packages/**'
- 'scripts/**'
- 'Dockerfile'
- '.github/workflows/**'
mobile:
- 'apps/mobile-app/**'

# One job, not lint/test in parallel: a second runner's checkout + setup-go + cache restore (~20 s) outweighs warm lint time on a repo this size.
go:
needs: changes
if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.go == 'true')) }}
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum

- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo 'gofmt: run gofmt -w .'
exit 1
fi

- uses: sqlc-dev/setup-sqlc@v5
with:
sqlc-version: '1.31.1'

- name: Generated code is up to date
run: |
sqlc generate -f packages/database/sqlc.yaml
if [ -d apps/web/ui ]; then go tool templ generate -path apps/web/ui; fi
git diff --exit-code
test -z "$(git status --porcelain)" || { git status --short; echo 'untracked generated files: run make generate and commit'; exit 1; }

- name: Build
run: go build ./...

- uses: golangci/golangci-lint-action@v9
with:
version: v2.15.0

- name: Layering rules
run: ./scripts/check-layering.sh
env:
GOLANGCI_LINT: golangci-lint

- name: Test
run: go test -race ./...
env:
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable

# Placeholder; finalized when apps/mobile-app lands.
mobile:
needs: changes
if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.mobile == 'true')) }}
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: apps/mobile-app
steps:
- uses: actions/checkout@v7

# hashFiles is not available in job-level `if`, so each step checks it.
- uses: actions/setup-node@v7
if: hashFiles('apps/mobile-app/package.json') != ''
with:
node-version: 22
cache: npm
cache-dependency-path: apps/mobile-app/package-lock.json

- if: hashFiles('apps/mobile-app/package.json') != ''
run: npm ci

- if: hashFiles('apps/mobile-app/package.json') != ''
run: npm run --if-present lint

- if: hashFiles('apps/mobile-app/package.json') != ''
run: npm run --if-present typecheck

ci-ok:
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }}
needs: [changes, go, mobile]
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Fail if any job failed or was cancelled
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo 'changes=${{ needs.changes.result }} go=${{ needs.go.result }} mobile=${{ needs.mobile.result }}'
exit 1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
*.test
coverage.out
.t3/
.cache/
74 changes: 74 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# golangci-lint v2. `make lint` runs it; `make lint-layering` proves the
# depguard rules below still reject forbidden imports.
version: "2"

linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
enable:
- bodyclose
- copyloopvar
- depguard
- errorlint
- intrange
- misspell
- nilerr
- noctx
- rowserrcheck
- sqlclosecheck
- unconvert
- usestdlibvars
- wastedassign
settings:
# Layering: packages/database -> services -> transports (api, ui).
# See AGENTS.md "Architecture".
depguard:
rules:
transports:
files:
- "**/apps/web/internal/api/**"
- "**/apps/web/ui/**"
deny:
- pkg: github.com/jackc/pgx
desc: transports call services; only packages/database talks to Postgres
- pkg: github.com/jayleaton/ai-patterns-go/packages/database
desc: transports call services (use services.User etc.), never repositories or sqlc
services:
files:
- "**/apps/web/internal/services/**"
deny:
- pkg: net/http
desc: services are transport-agnostic; return *services.Error and let the transport map it
- pkg: github.com/a-h/templ
desc: rendering belongs to apps/web/ui
- pkg: github.com/starfederation/datastar-go
desc: SSE belongs to apps/web/ui
- pkg: github.com/jackc/pgx
desc: services use repositories from packages/database
- pkg: github.com/jayleaton/ai-patterns-go/packages/database/sqlc
desc: services use repositories, not sqlc output
- pkg: github.com/jayleaton/ai-patterns-go/apps/web/internal/api
desc: services never import a transport
- pkg: github.com/jayleaton/ai-patterns-go/apps/web/ui
desc: services never import a transport
database:
files:
- "**/packages/database/**"
deny:
- pkg: github.com/jayleaton/ai-patterns-go/apps
desc: packages/database is the bottom layer and never imports apps
exclusions:
generated: lax
presets:
- std-error-handling
rules:
- path: _test\.go
linters: [noctx]

formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/jayleaton/ai-patterns-go
94 changes: 94 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# AGENTS.md: instructions for AI assistants

This is a **Go + Datastar monorepo template**: one Go module, one static binary, server-rendered `templ` pages with Datastar, a JSON API for the mobile app, Postgres through `pgx` + `sqlc` + `goose`, and Go-native auth modelled on Better Auth. It replaces the Next.js template `jayleaton/ai-patterns-monorepo` and keeps the same patterns.

Shared vocabulary lives in [CONTEXT.md](CONTEXT.md). Use those words.

## What makes this template good

1. **Template first.** People clone this and diverge right away. Keep it generic and minimal. The example flows (the users API, the dashboard) are documentation as code: keep them canonical and small. If a change only makes sense for one project, it does not belong here.
2. **Performance without compromise.** Render on the server, keep client JavaScript to what Datastar needs, and treat binary size, memory and CI time as features. Prefer the standard library; every dependency needs a reason.
3. **Network-ready dev servers.** The server binds `0.0.0.0:3000`, so it works the same on localhost, the LAN, and Tailscale (`http://<machine-hostname>:3000`). Never bake `localhost` into code, emails or auth callbacks.
4. **Monorepo-aware.** `packages/database`, `packages/auth`, `apps/web` and `apps/mobile-app` move together. Every change gets a decision per workspace, even if it is "no change needed".

These are good defaults, not hard rules. The developer's preferences override anything here.

## Architecture (mandatory)

The template's value is its layers. Do not route around them.

`packages/database` (migrations → sqlc queries → repository) → `apps/web/internal/services` → transports: `apps/web/internal/api` (JSON) and `apps/web/ui` (HTML/Datastar).

- Each layer talks only to the layer below. Both transports call services **in-process**; nothing calls its own API over HTTP.
- Transports never import `pgx`, sqlc output or repositories. Services never import `net/http`, templ or Datastar. `packages/database` never imports `apps/`. `depguard` in `.golangci.yml` enforces this, and `make lint-layering` proves the rules still bite.
- Services return `*services.Error{Kind, Message}`. The transport maps the kind to a status code; the message is safe to show.
- JSON responses always use the envelope `{"data": T|null, "error": string|null}` (plus `details` on validation errors). Use `api.Public` / `api.Authenticated`. A handler is about five lines: decode, call the service, return.
- Input validation lives in `apps/web/internal/validate`, one type per input with CRUD names (`UpdateUserSettings`). Decode with `validate.Decode[T]`. Never validate inline.
- Routes and toggles live in `apps/web/internal/config`: `AppRoutes`, `APIRoutes`, and `Features` (email verification off in dev, on in production; `TemplateWelcome`). Never write a path literal or branch on the environment anywhere else.
- Env is read once, in `config.Load`, and validated at boot. Nothing else calls `os.Getenv`.
- `packages/auth` owns the `user`, `session`, `account` and `verification` tables (Better Auth names). App migrations may add tables that reference `"user"(id)`, and repositories may update non-credential user columns (`name`, `image`). Auth migrations run before app migrations (`goose_auth`, then `goose_app`).
- `apps/web/internal/server` and `cmd/server` are the only places that wire packages together.

## Hit every layer

The most common defect is a change that works on the path you tested and is missing everywhere else. Before calling work done, walk this list and say which entries applied:

- **Entry points.** A new page needs its handler, its route in `config.AppRoutes`, a Datastar loading indicator, and translations in every locale. A new endpoint needs its route in `config.APIRoutes`.
- **Workspaces.** A feature usually spans `packages/database` (migration, query, repository) plus each surface that shows it: `apps/web/ui`, `apps/web/internal/api`, and `apps/mobile-app`. Doing one half is doing half the feature.
- **Contracts.** Migrations and sqlc queries are the contract. Change them, run `make generate`, and follow every consumer: repository, service, validator, both transports.
- **Reverse states.** If you add a way in, add the way out and a way to see it. Delete needs restore, disable needs enable, set needs clear.
- **Environments.** Dev (Docker Postgres, email verification off, email links logged) and production (Resend, verification on) differ only through `config.Features` and env. Both paths must work.
- **Docs.** Behaviour a template user would notice goes in `README.md`; patterns go here.

## The three ways to hurt yourself

**Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or kill a PID found by matching a name, path or port. Many servers run on this machine and yours can match. Kill only the PID you captured at spawn.

**Trusting the wrong database.** The Docker Postgres from `make db-up` is the sandbox: seed it, reset it, break it. A hosted database (Railway, Neon, production) is never a playground: don't point dev at one, migrate one, or copy real data in or out unless explicitly asked. `docker compose down -v` deletes the local volume.

**Baking in origins.** Never hardcode `http://localhost:3000` in code, emails or auth callbacks. Origins come from `PUBLIC_URL`, `INTERNAL_URL` and `TRUSTED_ORIGINS` through `config.Config`, or every non-local client breaks.

## Dev loop

- `make db-up && make migrate && make dev` starts Postgres + pgweb (bound to 127.0.0.1), runs migrations, and serves on `0.0.0.0:3000`. `.env` is created from `.env.example` on first run; set `POSTGRES_PORT` etc. there if the defaults are taken.
- Until `packages/auth` is wired, development signs every request in as a seeded dev user (`config.Features.DevAuth`). It is never on in production.
- A running dev server is fine to leave up. Stop only what you started, by PID.
- Inspect data with pgweb (`127.0.0.1:5050`). Seed realistic data before verifying a user-facing flow; an empty database is a bad test. Secrets go in `.env` only when the flow needs them, and never get committed.

## Verifying

- `make lint test` before every commit. `make ci` runs exactly what CI runs: build, generated-code diff, gofmt + golangci-lint, the layering proof, and `go test -race` against a throwaway Postgres.
- Run the narrowest thing first: `go test ./apps/web/internal/services/...`.
- Behaviour changes ship with focused tests. Services and handlers test against `services/servicetest` fakes; repositories test against real Postgres with `dbtest.New(t, dbtest.App)` (each test gets its own cloned database, so use `t.Parallel()`).
- A test that needs a sleep or timeout to pass is wrong.
- User-visible UI changes get one pass in a real browser, over the LAN or Tailscale URL when origins matter.

## Pull requests

- Never open a PR unless the developer asks.
- Conventional commit titles in plain language: `fix(api): PATCH /users no longer drops the image`.
- Body: the problem in a sentence or two, then the fix. UI changes need before/after images; motion needs a short video.
- One concern per PR. If the description says "also", split it.
- `ci-ok` is the single required check. When babysitting CI, verify each finding against the source, fix real ones, and dismiss false positives with a reason.

## Where code lives

- `apps/web/cmd/server`: the binary (`server serve`, `server migrate`).
- `apps/web/internal/config`: env config, `AppRoutes`/`APIRoutes`, `Features`.
- `apps/web/internal/server`: middleware, `/healthz`, and the mounts (auth at `/api/auth/`, API, UI at `/`).
- `apps/web/internal/api`: JSON transport `/api/core/v1`: envelope, route wrapper, handlers.
- `apps/web/internal/services`: business rules. `apps/web/internal/validate`: input types.
- `apps/web/ui`: templ pages, Datastar handlers, i18n, static assets.
- `packages/database`: pool, `migrations/` (goose, embedded), `queries/` (sqlc input), `sqlc/` (generated, never edit), repositories, `model/`, `dbtest/`.
- `packages/auth`: Better Auth–style auth, its own migrations and endpoints.
- `apps/mobile-app`: the Expo app, which talks to the JSON API with bearer tokens.
- Root: `Makefile`, `docker-compose.yml`, `Dockerfile`, `railway.json`, `.golangci.yml`, `tools/` (pinned dev tools, run via `go tool -modfile=tools/go.mod`).

## Taste

- Complexity belongs at the layer boundary. Repositories hide SQL, services hide business rules, the route wrapper hides auth and envelopes, and templates stay dumb.
- Small interfaces, defined by the consumer (`services.UserRepository`). Concrete types out, interfaces in.
- Typed values over `any` and `map[string]any`. Generics only where they remove real repetition.
- Errors are values: wrap with context, compare with `errors.Is`/`errors.As`, and never log and return the same error.
- Comments say how a thing is used and move with the code. Document functions, not every line.
- If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# CLAUDE.md

Read and follow [AGENTS.md](AGENTS.md). Shared vocabulary is in [CONTEXT.md](CONTEXT.md).
Loading
Loading