Files
fullstack-starter/AGENTS.md
T

8.8 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> (not bun <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)

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. ClientuseSuspenseQuery(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. SSRcreateIsomorphicFn() in src/client/orpc.ts switches between createRouterClient (server) and RPCLink (client). Routes use ensureQueryData in loaders for SSR prefetch.

7. OpenAPIOpenAPIHandler + OpenAPIReferencePlugin with Scalar docs at /api/docs, spec at /api/spec.json.

Database (Drizzle ORM v1 beta)

  • Driver: drizzle-orm/postgres-js (NOT bun-sql) | Validation: drizzle-orm/zod (NOT drizzle-zod)
  • Relations: defineRelations() in src/server/db/relations.ts (RQBv2 — drizzle() only needs { relations })
  • Query style: RQBv2 object syntax — orderBy: { createdAt: 'desc' }, where: { id: 1 }
  • Schema: All tables spread ...generatedFields for id (UUIDv7), createdAt, updatedAt (see src/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 push and migrate on 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 in src/env.ts
  • Server vars: no prefix | Client vars: VITE_ prefix required
  • Never commit .env files

Development Principles

  1. No backward compatibility — Rapid iteration. Always use latest API/patterns. No deprecated fallbacks.
  2. Always sync documentation — Code changes → immediately update AGENTS.md and related docs.
  3. 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

Git Workflow

  1. 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")