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
172 changes: 131 additions & 41 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@

Guidelines for AI coding agents working in this repository.

Backend conventions (middleware philosophy, error handling, feature layout,
migration workflow) live in @server/README.md — read it before writing backend
code (the @ auto-imports it for Claude Code; other agents: open the file).

## Tech Stack

- **Runtime:** Bun
- **Frontend:** React 19 + Vite + TailwindCSS v4
- **Data fetching:** TanStack Query over type-safe Hono RPC
- **Backend:** Hono (running on Bun)
- **Database:** PostgreSQL + Drizzle ORM
- **Auth:** Better Auth
- **Validation:** Zod
- **Testing:** Vitest + PGlite (ephemeral test databases)
- **Secrets:** Infisical
- **Linting/Formatting:** Biome
- **i18n:** i18next

Expand All @@ -18,48 +25,67 @@ Guidelines for AI coding agents working in this repository.
```bash
# Development
bun dev # Start full dev environment (DB, Vite, Drizzle Studio)
bun run build # Production build
bun run build # Production build (SSG prerender + Vite build)
bun start # Run production server

# Testing
bun run test:dev # Run vitest with env injected — use this locally
bun run test # Run vitest bare (CI provides env)

# Code Quality
bun run lint # Fix lint issues (Biome + locale check)
bun run lint:check # Check only (CI)
bun run format # Fix formatting
bun run format:check # Check only (CI)
bun run typecheck # Type-check all projects
bun run all # Run format + lint + typecheck + build
bun run all # format + lint + typecheck + build + test

# Database
bun run db:start # Start PostgreSQL container
bun run db:push # Sync schema to DB (dev only)
bun run db:generate # Generate migrations
bun run db:migrate # Apply migrations
bun run db:regenerate-auth # Regenerate Better Auth schema
bun run db:push # Sync schema to DB (dev iteration only)
bun run db:generate # Generate migration SQL (once, when the branch is ready)
bun run db:migrate # Apply migrations manually (deploys apply them on boot)
bun run auth:generate # Regenerate Better Auth schema (after plugin changes)
```

### Secrets (Infisical)

Env-dependent scripts (`dev`, `db:*`, `test:dev`, `auth:generate`) are wrapped
in `infisical run --` — secrets come from Infisical, not a local `.env`.
Running the underlying tools bare (`vitest`, `drizzle-kit push`) fails `env.ts`
validation; always go through the package scripts.

## Project Structure

```
web/ # Frontend (React)
components/ui/ # Reusable UI components (shadcn pattern)
lib/api.ts # Type-safe Hono RPC client
lib/api.ts # Hono RPC client wrapped for TanStack Query
lib/typed-client.ts # queryOptions/mutationOptions wrapper (typed ApiError)
lib/auth-client.ts # Better Auth client
i18n/locales/ # Translation files

server/ # Backend (Hono)
server.tsx # Entry point & route assembly
auth.ts # Better Auth config
logger.ts # Pino logger with trace context
middleware/ # Global middleware
server/ # Backend (Hono) — conventions in server/README.md
server.ts # Entry point & route assembly
lib/ # Shared server infra
auth.ts # Better Auth config
logger.ts # Pino logger with trace context
http.ts # apiError helper
router.ts # createRouter (Hono with typed context)
tracing.ts # withSpan + span helpers
middleware/ # Ambient providers (session, db) + per-route guards (requireAuth)
features/ # Feature modules (one folder per feature)
{feature}/
index.ts # Re-exports feature routes
routes/ # One file per route handler
index.ts # Assembles the feature's routes
routes/ # One file per route handler (+ colocated *.test.ts)
utils/testing/ # Test helpers (getTestApp, PGlite test DB, test users)
database/
schema/auth.ts # Better Auth tables (auto-generated)
schema/app.ts # Custom tables (add your tables here)
migrations/ # Generated SQL, applied on server boot

env.ts # Environment schema (Zod)
env.ts # Environment schema (@t3-oss/env-core + Zod)
scripts/ # Dev/CI scripts (check-locale, db-import)
```

## Code Style
Expand All @@ -85,77 +111,141 @@ env.ts # Environment schema (Zod)

### Hono Routes
- One route handler per file in `server/features/{feature}/routes/`
- Use Zod validation with `zValidator`:
- Build routes with `createRouter()` (typed context), never bare `new Hono()`
- Guards (`requireAuth`) are declared per-route, never globally; ambient
middleware already provides `c.get("db")` and `c.get("user")`
```typescript
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { createRouter } from "@/server/lib/router";
import { requireAuth } from "@/server/middleware/auth.middleware";

const schema = z.object({ name: z.string().min(1) });
const schema = z.object({ name: z.string().trim().min(1) });

export const myRoute = new Hono().get(
export const createItemRoute = createRouter().post(
"/",
zValidator("query", schema),
requireAuth,
zValidator("json", schema),
async (c) => {
const { name } = c.req.valid("query");
return c.json({ message: `Hello, ${name}!` });
}
const db = c.get("db");
const user = c.get("user");
const { name } = c.req.valid("json");

const [created] = await db
.insert(item)
.values({ name, ownerId: user.id })
.returning();
return c.json(created, 201);
},
);
```

### Error Handling
- Validate input with Zod via `zValidator`
- **Expected** errors are returned: `return apiError(c, 409, "Limit reached")`
(from `@/server/lib/http`) — these become typed error responses in the RPC
client. **Unexpected** errors just throw; Hono's `onError` logs and maps them
to 500. Details: `server/README.md` § Error handling
- Wrap frontend pages in `<ErrorBoundary>`

### Database (Drizzle)
- Define tables in `server/database/schema/app.ts`
- Use `drizzle-orm/pg-core` for table definitions
- Import tables from `@/server/database/schema` (the index re-exports all)
- Reference auth tables from `./auth.ts` for foreign keys

### Environment Variables
- Define in `env.ts` with Zod schemas
- Define in `env.ts` — add new variables to **both** the `server` schema and
`runtimeEnvStrict`
- Access via `import env from "@/env"`
- Never hardcode secrets
- Values are injected by Infisical; never hardcode secrets

### i18n
- Add translations to `web/i18n/locales/{lang}/common.ts`
- Use `useTranslation("common")` hook
- Run `bun run check-locale` to verify all keys exist in all locales

### Error Handling
- Use Zod for input validation
- Wrap pages in `<ErrorBoundary>`
- Server errors logged via `logger` from `@/server/logger`

### Logging (Server)
```typescript
import { logger } from "@/server/logger";
import { logger } from "@/server/lib/logger";
logger.info({ userId, action }, "User performed action");
```
Logger auto-injects traceId, spanId, userId when available.

### Tracing (OpenTelemetry)
- HTTP requests, DB queries, and fetch calls are auto-traced
- For custom spans: `import { withSpan } from "@/server/tracing"`
- For custom spans: `import { withSpan } from "@/server/lib/tracing"`

## Testing

- Tests are colocated with routes: `routes/{route-name}.test.ts`
- `getTestApp` mounts your router on a fresh PGlite database (migrations
applied) with test auth middleware — authenticate by sending an
`X-Test-User-Id` header; omit it to exercise 401 paths
- Run with `bun run test:dev`

```typescript
import { expect, test } from "vitest";
import { createRouter } from "@/server/lib/router";
import { getTestApp } from "@/server/utils/testing/test-app";
import { TestUser1, testUsers } from "@/server/utils/testing/test-users";
import { createItemRoute } from "./create-item";

test("creates an item", async () => {
const { app, dbClient } = await getTestApp(
createRouter().route("/", createItemRoute),
{ testUsers },
);

const res = await app.request("/", {
method: "POST",
headers: { "X-Test-User-Id": TestUser1.id, "Content-Type": "application/json" },
body: JSON.stringify({ name: "Hello" }),
});

expect(res.status).toBe(201);
dbClient.close();
});
```

## Adding New Features

**Backend Route:** Create `server/features/{feature}/routes/{route-name}.ts`, re-export in `index.ts`, mount in `server/server.tsx`
**Backend Route:** Create `server/features/{feature}/routes/{route-name}.ts`,
assemble in the feature's `index.ts`, mount the prefix in `server/server.ts`.
Path layering rules: `server/README.md` § API paths

**Frontend Page:** Add route to `web/router.tsx`, create component in `web/components/`

**Database Table:** Add to `server/database/schema/app.ts`, run `bun run db:push` (dev) or `db:generate && db:migrate` (prod)
**Database Table:** Add to `server/database/schema/app.ts`. While iterating run
`bun run db:push`; when the branch is ready run `bun run db:generate` once and
commit the migration — deploys apply it on boot. Details: `server/README.md`
§ Database migrations

## Better Auth

- Config: `server/auth.ts`
- Config: `server/lib/auth.ts`
- Client: `web/lib/auth-client.ts`
- After adding plugins: `bun run db:regenerate-auth`
- Session in routes: `await auth.api.getSession({ headers: c.req.raw.headers })`
- After adding plugins: `bun run auth:generate`
- Current user in routes: apply `requireAuth` and read `c.get("user")` — the
ambient `sessionMiddleware` resolves the session once per request; never call
`auth.api.getSession` inside a handler

## API Client (Frontend)

Use type-safe Hono RPC:
The Hono RPC client is wrapped (`web/lib/typed-client.ts`) so every endpoint
exposes `queryOptions` / `mutationOptions` with a typed `ApiError` channel:

```typescript
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "~/lib/api";
import { useMutation } from "@tanstack/react-query";

const mutation = useMutation(api["my-route"].$get.mutationOptions({}));
mutation.mutate({ query: { name: "World" } });
// Queries: request input goes in `input`, TanStack options alongside
const itemsQuery = useQuery(api.items.$get.queryOptions({ enabled: !!session }));

// Mutations: `mutate` takes the Hono request shape
const createItem = useMutation(api.items.$post.mutationOptions({}));
createItem.mutate({ json: { name: "Hello" } });

// Errors are typed: `status` is a literal union that narrows `body`
const message = createItem.error?.body.error;
```
1 change: 1 addition & 0 deletions CLAUDE.md
Loading
Loading