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
35 changes: 35 additions & 0 deletions .claude/skills/adding-a-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: adding-a-feature
description: How a new page, API endpoint, or admin route is wired through this repo's layers, and which files must change together. Use when adding or removing a route, page, or endpoint, when a new route 404s or its client script never runs, or when scaffolding a new service.
---

# Adding a feature

The layering is strict, and it's what makes the codebase testable:

- **Routes** map a URL to a controller method. Nothing else.
- **Controllers** orchestrate — call services, then render a template or return JSON.
- **Services** own all business logic and data access. Controllers never query the database.
- **Templates** are pure presentation, receiving fully resolved data as props.

Data is fetched before render, so templates never need loading states.

Types are the contract between layers: export a type from the service alongside its functions and
import that same type in the controller and template. Don't redeclare the shape at each layer —
if a template's props drift from the service's return type, that's the bug.

Wiring is spread across several files and a missed one fails quietly. Follow the checklist for
what you're adding:

- New page → `references/page.md`
- New API endpoint → `references/api-endpoint.md`

Both end the same way: add the co-located test (see the `writing-tests` skill) and run
`bun run check` and `bun run test` (see `verifying-changes`).

## Migrations

New tables go in `src/server/database/migrations/` as `NNN_snake_case.ts`. Create one with
`bun run migrate:create`. They apply automatically on server start and at the top of the test
run — a failed migration means the server won't boot. Add any new table to `cleanupTestData` in
`src/server/test-utils/helpers.ts` or tests will bleed into each other.
60 changes: 60 additions & 0 deletions .claude/skills/adding-a-feature/references/api-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Adding an API endpoint

Same flow as a page, without the template or client layers. `src/server/controllers/api/projects.ts`
is the full CRUD example.

## 1. Service — `src/server/services/<resource>.ts`

Export functions and their types. If a view route already needs this logic, share the service
rather than having one route call the other over HTTP — routes must not fetch routes.

## 2. Controller — `src/server/controllers/api/<resource>.ts`

Return JSON with `Response.json()`; handle the error cases explicitly.

```ts
export const examplesApi = {
async index() {
return Response.json({ examples: await getExamples() });
},
async show(req: BunRequest<"/api/examples/:id">) {
const example = await getExample(Number(req.params.id));
if (!example) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({ example });
},
};
```

## 3. Barrel — `src/server/controllers/api/index.ts`

API controllers take an `Api` suffix so they don't collide with the app controller for the same
resource:

```ts
export { examplesApi } from "./examples";
```

## 4. Route — `src/server/routes/api.ts`

```ts
"/api/examples": createRouteHandler({ GET: examplesApi.index, POST: examplesApi.create }),
"/api/examples/:id": createRouteHandler({
GET: examplesApi.show,
PUT: examplesApi.update,
DELETE: examplesApi.destroy,
}),
```

`createRouteHandler` returns 405 for any method not listed.

## 5. Test — `src/server/controllers/api/<resource>.test.ts`

See the `writing-tests` skill.

## State-changing endpoints

`csrfProtection` validates the request `Origin` against `APP_URL` and expects the token in the
`CSRF_HEADER_NAME` header for AJAX callers (form posts send it in the body instead). An endpoint
called from a browser needs it; one called by an external client needs a deliberate decision about
authentication instead. `src/server/middleware/rate-limit.ts` is available for anything
abuse-prone.
90 changes: 90 additions & 0 deletions .claude/skills/adding-a-feature/references/page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Adding a page

Worked example: a `/dashboard` page. Read `src/server/controllers/app/projects.tsx` and
`src/server/templates/projects.tsx` alongside this — they're the fullest example in the repo
(list, create, delete, auth, flash messages, a Preact island).

## 1. Service — `src/server/services/dashboard.ts`

Only if the page needs data. Export the functions and the types together; the type is what the
controller and template both import.

## 2. Template — `src/server/templates/dashboard.tsx`

Takes fully resolved data as props, wrapped in the layout:

```tsx
<Layout title="Dashboard" name="dashboard" user={user} csrfToken={csrfToken}>
```

`name` sets `data-page` on `<body>`, which is what dispatches the client script in step 6. Any
form that POSTs needs `<CsrfField token={csrfToken} />`.

This compiles with React's JSX runtime and renders once on the server — it never hydrates. Don't
reach for `useState` here.

## 3. Controller — `src/server/controllers/app/dashboard.tsx`

```tsx
export const dashboard = {
async index(req: BunRequest) {
const data = await getDashboardData();
return render(<Dashboard data={data} />);
},
};
```

`render()` and `redirect()` come from `src/server/utils/response.ts`. Don't set security headers —
they're applied centrally.

## 4. Barrel — `src/server/controllers/app/index.ts`

```ts
export { dashboard } from "./dashboard";
```

## 5. Route — `src/server/routes/app.tsx`

Single method:

```ts
"/dashboard": dashboard.index,
```

Multiple methods, or anything that must reject others with a 405:

```ts
"/dashboard": createRouteHandler({ GET: dashboard.index, POST: dashboard.create }),
```

Route params are typed through the handler — `projects.destroy<"/projects/:id/delete">` in
`app.tsx` is the pattern to copy.

## 6. Client script — `src/client/pages/dashboard.ts`

Export `init()`, then register it in `src/client/main.ts`:

```ts
import { init as initDashboard } from "@client/pages/dashboard";
registerPage("dashboard", { init: initDashboard });
```

Skipping the `registerPage` call is the quiet failure: the script builds, ships, and never runs.
The registered name must equal the `name` prop from step 2.

Export `cleanup()` too if the script adds listeners outside its own subtree.

## 7. Page CSS — `src/client/pages/dashboard.css`

Add `@import "./pages/dashboard.css";` to `src/client/style.css`. It is not picked up otherwise.

## 8. Test — `src/server/controllers/app/dashboard.test.ts`

See the `writing-tests` skill.

## Removing a page

The same list in reverse — template, controller, barrel export, route, nav link
(`src/server/components/nav.tsx`), client script, `registerPage` call in `main.ts`, the CSS file,
its `@import` in `style.css`, and the tests. `START_PROMPT.md` §5 lists exactly this for the stack
page and is a good checklist to mirror.
59 changes: 59 additions & 0 deletions .claude/skills/verifying-changes/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: verifying-changes
description: How to lint, typecheck, and test this repo before finishing work. Use when you have edited files under src/ and need to confirm the change is sound, when a test or lint command is behaving unexpectedly, or when deciding which command to run for a targeted check.
---

# Verifying changes

Run these through the `package.json` scripts. They set env vars and apply migrations that the
raw `bun` commands do not.

## The two commands

```bash
bun run check # biome lint + tsc --noEmit
bun run test # migrations, then every *.test.ts file
```

`bun run check` is fast and should pass before you consider a change done. `bun run test` is the
behavioural gate. The pre-commit hook runs `bun run build && bun run check`, so a lint or type
error blocks the commit.

## Targeted runs

```bash
bun run test:file src/server/services/project.test.ts # one file, migrations first
bun run lint:write # apply Biome's safe fixes
bun run typecheck # types only
```

`bun run test:file` takes a path or a directory. Prefer it over `bun test <file>` while iterating.

## Why not `bun test` directly

`bun run test` executes `src/server/test-utils/run-tests.ts`, which:

1. Applies migrations against the test database first — `bun test` alone runs against whatever
schema happens to be there.
2. Spawns one process per test file, so a module mock or a mutated global in one file can't leak
into the next.
3. Pins `SESSION_COOKIE_NAME=session_id`. Tests hardcode that cookie name; a custom value in your
`.env` otherwise leaks in and fails auth tests for reasons that look unrelated.
4. Kills any file that exceeds 60s (`TEST_FILE_TIMEOUT_MS`) and reports it as failed rather than
hanging the run.

## Reading failures

- **`DATABASE_URL is required for tests`** — `.env.test` is missing or unloaded. It needs a
separate database from development; see `START_PROMPT.md` §1.
- **A file reported as `TIMED OUT`** — usually an unclosed SQL connection. Service tests need
`await connection.end()` in `afterAll`.
- **Auth or session assertions failing across many files** — check for `SESSION_COOKIE_NAME` in
your `.env`, and that you ran the script rather than `bun test`.
- **Type errors in `email-providers/resend.ts`** — that file is excluded in `tsconfig.json`, so
`bun run check` will not catch regressions there.

## Browser checks

For user-visible changes, confirm in the browser with the `/browse` skill against
http://localhost:3000. The dev server is already running in another tab — don't start one.
28 changes: 28 additions & 0 deletions .claude/skills/writing-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: writing-tests
description: Testing patterns for this repo — which layer gets mocked, how service tests reach PostgreSQL, and how client tests get a DOM. Use when adding or changing a *.test.ts / *.test.tsx file, when a new module needs test coverage, or when an existing test fails in a way that looks like a setup problem.
---

# Writing tests

Tests are co-located: `home.test.ts` sits next to `home.tsx`. Test user-visible behaviour rather
than implementation, and cover both guest and authenticated paths for anything auth-aware.

The mocking boundary is the same everywhere: **mock the service layer, exercise everything above
it for real.** Controllers are tested against real `Response` objects and real rendered HTML, not
against assertions that a render function was called.

Pick the reference for the layer you're working in:

| Layer | Reference |
|---|---|
| `controllers/api/`, `controllers/app/`, `controllers/admin/` | `references/controllers.md` |
| `services/`, `middleware/` | `references/services.md` |
| `src/client/**` | `references/client.md` |

`src/server/test-utils/` holds the shared kit: `helpers.ts` (`cleanupTestData`, `seedTestData`,
`randomEmail`), `setup.ts` (`createMockRequest`, `expectJsonResponse`), `factories.ts`, and
`bun-request.ts` for building `BunRequest` values with route params.

Run everything with `bun run test` — see the `verifying-changes` skill for why the raw `bun test`
command misbehaves here.
62 changes: 62 additions & 0 deletions .claude/skills/writing-tests/references/client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Client tests

DOM globals come from happy-dom, preloaded for every test file via `bunfig.toml`
(`src/client/test-utils/setup.ts`). You don't register it yourself.

## Page scripts

Build a fixture matching the server-rendered HTML, call `init()`, assert on the DOM.

```ts
import { afterEach, beforeEach, describe, expect, test } from "bun:test";

describe("projects page", () => {
beforeEach(() => {
document.body.innerHTML = `
<table id="projects-list"><tbody><tr><td>Test Project</td></tr></tbody></table>
`;
});

afterEach(() => {
document.body.innerHTML = "";
});

test("filters rows", async () => {
const { init } = await import("./projects");
init();
// ...assert
});
});
```

Import the page module **dynamically inside the test**. A top-level import is cached across
tests in the same file, so `init()` would run against stale module state.

The fixture has to match what the server actually renders — the same ids, classes, and
`data-` attributes the script queries. If you change the template, change the fixture.

## Preact islands

Render into a container and assert on the output:

```ts
/** @jsxImportSource preact */
import { render } from "preact";

const container = document.createElement("div");
document.body.appendChild(container);
render(<ProjectSearch projects={[{ id: 1, title: "Test" }]} />, container);
expect(container.textContent).toContain("Test");
```

The `/** @jsxImportSource preact */` pragma on line 1 is required — without it the file compiles
against React's runtime and the render fails.

Islands here reach outside their own tree (`ProjectSearch` toggles rows in the server-rendered
table by id), so the fixture usually needs that surrounding markup in `document.body` too.

## Page registration

Pages are wired in `src/client/main.ts` with `registerPage(name, { init })`, and dispatched from
`document.body.dataset.page` — set by the `name` prop on `<Layout>`. A page script that isn't
registered never runs, and no test will tell you.
Loading