9.5 KiB
AGENTS.md — AI Coding Agent Guidelines
Project Overview
This project uses Bun exclusively. Do NOT use Node.js / npm / yarn / pnpm. Always use
bun run <script>(notbun <script>) to avoid conflicts with Bun built-in subcommands.
- Framework: TanStack Start (React 19 SSR, file-based routing)
- Runtime/PM: Bun (see
mise.toml) — NOT Node.js / npm / yarn / pnpm - Language: TypeScript (strict mode) | Styling: Tailwind CSS v4
- Database: PostgreSQL + Drizzle ORM v1 beta (
drizzle-orm/postgres-js, RQBv2) - State: TanStack Query v5 | RPC: ORPC (contract-first) | Build: Vite + Nitro
Commands
bun run dev # Vite dev server (localhost:3000)
bun run build # Production build → .output/
bun run compile # Compile to standalone binary (current platform)
bun run fix # Biome auto-fix (lint + format, runs `biome check --write`)
bun run typecheck # TypeScript check (`tsc --noEmit`)
bun run db:push # Push schema to dev DB (dev only, fast iteration)
bun run db:generate # Generate migration SQL files (for production)
bun run db:migrate # Apply migrations (production deployment)
bun run db:studio # Drizzle Studio GUI
bun test path/to/test.ts # Run single test (not yet configured)
docker compose up --build # Build image & start app + postgres
docker compose up -d # Start in background (detached)
docker compose down # Stop and remove containers
Code Style
Formatting (Biome): 2-space indent, LF, single quotes, semicolons as needed (omitted unless syntactically required), max line width 120, arrow parens always (x) => x
Imports — Biome auto-organizes alphabetically within two groups. import type is interleaved in its group alphabetically, NOT a separate group:
// 1) External packages (alphabetical, types interleaved)
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ReactNode } from 'react'
// 2) @/* aliases (alphabetical, types interleaved)
import { orpc } from '@/client/orpc'
import { TodoForm } from '@/components/TodoForm'
import type { RouterClient } from '@/server/api/types'
TypeScript: strict: true, noUncheckedIndexedAccess, verbatimModuleSyntax, erasableSyntaxOnly. Use @/* path aliases (→ src/*). For root-level files outside src/, use @/../file (e.g., @/../package.json).
Naming: Files (utils): kebab-case.ts | Files (components): PascalCase.tsx | Components: const Button = () => {} | Functions: camelCase | Constants: UPPER_SNAKE | Types: PascalCase
React: Arrow function components (Biome enforced via useArrowFunction: "error"). Routes: export const Route = createFileRoute(...). Data: useSuspenseQuery(orpc.feature.list.queryOptions())
Errors: ORPCError with codes (NOT_FOUND, INTERNAL_SERVER_ERROR, INPUT_VALIDATION_FAILED). Never empty catch blocks. Validation errors are auto-transformed by interceptors (see ORPC section).
ORPC Pattern
Contract → Router → Handler → Client (fully type-safe, zero manual type definitions):
1. Contract (src/server/api/contracts/feature.contract.ts) — Zod schemas generated from Drizzle tables:
const selectSchema = createSelectSchema(featureTable)
const insertSchema = createInsertSchema(featureTable).omit(generatedFieldKeys)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
2. Router (src/server/api/routers/feature.router.ts) — Handlers chaining db middleware:
export const list = os.feature.list.use(db).handler(async ({ context }) => {
return context.db.query.featureTable.findMany({ orderBy: { createdAt: 'desc' } })
})
3. Register — Add to contracts/index.ts and routers/index.ts (barrel exports)
4. Client — useSuspenseQuery(orpc.feature.list.queryOptions()) / useMutation(orpc.feature.create.mutationOptions()). Mutation invalidation is configured globally via experimental_defaults in src/client/orpc.ts.
5. Interceptors — Registered at Handler level (RPCHandler/OpenAPIHandler in route files), NOT in server.ts:
const handler = new RPCHandler(router, {
interceptors: [onError(logError)],
clientInterceptors: [onError(handleValidationError)],
})
6. SSR — createIsomorphicFn() in src/client/orpc.ts switches between createRouterClient (server) and RPCLink (client). Routes use ensureQueryData in loaders for SSR prefetch.
7. OpenAPI — OpenAPIHandler + OpenAPIReferencePlugin with Scalar docs at /api/docs, spec at /api/spec.json.
Database (Drizzle ORM v1 beta)
- Driver:
drizzle-orm/postgres-js(NOTbun-sql) | Validation:drizzle-orm/zod(NOTdrizzle-zod) - Relations:
defineRelations()insrc/server/db/relations.ts(RQBv2 —drizzle()only needs{ relations }) - Query style: RQBv2 object syntax —
orderBy: { createdAt: 'desc' },where: { id: 1 } - Schema: All tables spread
...generatedFieldsforid(UUIDv7),createdAt,updatedAt(seesrc/server/db/fields.ts)
Database Workflow
- Local dev:
db:push— fast iteration, no migration files needed - Production:
db:generate→ review SQL →db:migrate— versioned, auditable - Never mix
pushandmigrateon the same database instance drizzle/migration directory is committed to Git
⚠️ drizzle.config.ts Caveat
drizzle.config.ts runs outside Vite — @/* path aliases do not work. Use relative imports (./src/env).
Environment Variables
- Validated via
@t3-oss/env-core+ Zod insrc/env.ts - Server vars: no prefix | Client vars:
VITE_prefix required - Never commit
.envfiles
Development Principles
- No backward compatibility — Rapid iteration. Always use latest API/patterns. No deprecated fallbacks.
- Always sync documentation — Code changes → immediately update
AGENTS.mdand related docs. - Forward-only migration — Fully adopt new APIs when upgrading. No mixing old/new patterns.
Critical Rules
DO: bun run fix before committing | @/* path aliases | ...generatedFields on all tables | ORPCError with proper codes | drizzle-orm/zod for validation | RQBv2 object syntax | Update docs with code changes
DON'T: npm/npx/node/yarn/pnpm | Edit routeTree.gen.ts | as any/@ts-ignore/@ts-expect-error | Commit .env | Empty catch blocks | Import from drizzle-zod | RQBv1 callback-style API | drizzle-orm/bun-sql driver | Pass schema to drizzle() | Import os from @orpc/server in middleware (use @/server/api/server) | Leave docs out of sync
Docker
- Dockerfile: Multi-stage —
oven/bun:1(build + compile) →gcr.io/distroless/cc-debian13:nonroot(runtime) - Runtime: Bun-compiled standalone binary (glibc-linked, requires
ccdistroless variant) - Compose:
app(port 3000) +postgres:17-alpine(port 5432) with health check - Migrations: Run separately —
bun run db:push(dev) orbun run db:migrate(prod) against the compose DB - Env:
DATABASE_URLset incompose.yamlpointing to thedbservice
Git Workflow
- Make changes → 2.
bun run fix→ 3.bun run typecheck→ 4.bun run dev(test) → 5. Commit
Directory Structure
src/
├── client/orpc.ts # ORPC client (isomorphic SSR/CSR) + TanStack Query utils
├── components/ # React components (PascalCase.tsx, flat structure)
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout (shell, meta, error/notFound, DevTools)
│ ├── index.tsx # Home route (loader + component)
│ └── api/ # API routes
│ ├── $.ts # OpenAPI handler (Scalar docs + spec)
│ ├── rpc.$.ts # RPC handler (interceptors registered here)
│ └── health.ts # Health check endpoint
├── server/
│ ├── api/
│ │ ├── contracts/ # ORPC contracts (Zod schemas from Drizzle tables)
│ │ ├── routers/ # ORPC handlers (use db middleware, throw ORPCError)
│ │ ├── middlewares/ # Middleware (db context injection)
│ │ ├── interceptors.ts # Error interceptors (registered in route handlers)
│ │ ├── context.ts # Request context types (BaseContext, DBContext)
│ │ ├── server.ts # implement(contract).$context<BaseContext>()
│ │ └── types.ts # RouterClient, RouterInputs, RouterOutputs
│ └── db/
│ ├── schema/ # Drizzle table definitions (...generatedFields spread)
│ ├── fields.ts # Shared fields: pk (UUIDv7), createdAt, updatedAt
│ ├── relations.ts # Drizzle relations (RQBv2 defineRelations)
│ └── index.ts # DB factory (createDB, getDB singleton)
├── env.ts # Environment variable validation (@t3-oss/env-core)
├── router.tsx # Router + QueryClient + SSR query integration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry (@import "tailwindcss")