- migrate: 校验已应用 migration 的 SHA-256,拒绝 schema drift; split 后 trim + skip empty,避免空 statement 触发 SQL 错误 - todo.contract: update 拒绝空 patch - env: DATABASE_URL 限定 postgres(ql):// scheme,配置错误更早失败 - compile: autoloadDotenv: false,二进制部署不再吞 cwd 的 .env - Error.tsx: 生产环境隐藏 error.message,避免内部错误泄露 - AGENTS: 同步 generatedFieldKeys / migrator 行为新描述
20 KiB
AGENTS.md
Compact, repo-specific notes for AI agents. Generic language/framework knowledge is omitted — only things that will bite you if you don't know. Pair this with README.md (user-facing quick-start + add-a-feature checklist + deploy flow).
Stack & runtime
- Bun-only (
mise.tomlpinsbun = 1.3.13). Never invokenpm/npx/node/yarn/pnpm. Usebun run <script>(barebun <script>can collide with Bun built-in subcommands). - TanStack Start (React 19 SSR, file-routed) + Vite 8 + Nitro (nightly, preset
bun). Vite dev port is strict 3000. - PostgreSQL + Drizzle ORM
0.45.2(0.x, NOT 1.0 beta) — see "Drizzle" section, this matters a lot. - ORPC (contract-first), TanStack Query v5, Tailwind v4.
Scripts
bun run dev # bunx --bun vite dev (localhost:3000)
bun run build # bunx --bun vite build → .output/
bun run compile # bun compile.ts → out/server-<target> (standalone CLI binary)
bun run cli <cmd> # bun bin.ts <cmd> — run a CLI subcommand in source (dev)
bun run typecheck # tsc --noEmit
bun run test # bun test — runs all *.test.ts files (colocated with source)
bun run fix # biome check --write (lint + format + organize imports)
bun run db:push # dev only — push schema to DB, no migration file
bun run db:generate # drizzle-kit generate && embed-migrations.ts (regenerates migrations.gen.ts)
bun run db:embed # embed-migrations.ts only — regenerate migrations.gen.ts from ./drizzle/
bun run db:migrate # apply migrations via drizzle-kit (local dev convenience; prod uses ./server migrate)
bun run db:studio # Drizzle Studio
Cross-compile targets live under compile:{linux,darwin,windows}[:arch]. compile.ts accepts --target bun-<os>-<arch>; default derives from host.
Before committing: bun run fix && bun run typecheck && bun run test. No CI, no pre-commit hooks, no lint-staged — so these are on you.
Drizzle (v0.x — critical)
Why it matters: the project was on 1.0 beta and was rolled back. Online docs default to 1.0 beta APIs that do NOT exist here. If typecheck complains, you are probably importing a 1.0 beta API.
- Driver:
drizzle-orm/postgres-js. Do NOT usedrizzle-orm/bun-sql. drizzle()is called with{ connection, schema }whereschema = import * as schema from '@/server/db/schema'. There is norelations.tsand nodefineRelationsin 0.x.- Zod generators live in the separate
drizzle-zodpackage (^0.8.3). Import fromdrizzle-zod, notdrizzle-orm/zod(that subpath only exists in 1.0 beta). - Relational queries use RQB v1 callback syntax:
Do NOT use the v2 object form (
db.query.todoTable.findMany({ orderBy: (t, { desc }) => desc(t.createdAt), })orderBy: { createdAt: 'desc' },where: { id }) — it won't type-check. - To add relations later: declare per-table with
relations()fromdrizzle-ormand export them from the same file as the table; they get picked up automatically becauseindex.tsdoesdrizzle({ schema })viaimport *. - Every table must spread
...generatedFieldsfromsrc/server/db/fields.ts(idUUIDv7 via$defaultFn(uuidv7),createdAt,updatedAtwith$onUpdateFn).generatedFieldKeysis hand-written and usessatisfies Record<keyof typeof generatedFields, true>so any field-key drift fails typecheck; it feedscreateInsertSchema(...).omit(...)/createUpdateSchema(...).omit(...). src/server/db/index.tsexports a module-levelconst db = drizzle(...)— not a lazy singleton. On Bun this is a long-lived process, so top-level side effects are fine and requested. Don't reintroducegetDB/closeDBceremony; the Nitro shutdown plugin callsdb.$client.end()directly. (Cloudflare Workers would need per-request init — we don't support that deployment target.)drizzle.config.tsruns outside Vite —@/*path aliases do NOT resolve there. It currently doesimport { env } from './src/env'(relative). Preserve that.- Migrations are embedded in the binary, not read from disk.
bun run db:generatechainsdrizzle-kit generate && bun embed-migrations.ts, which regeneratessrc/server/db/migrations.gen.ts(committed, AUTO-GENERATED header) byimport sql_<idx> from '../../../drizzle/<tag>.sql' with { type: 'text' }.src/cli/migrate.tsreadsembeddedMigrations, validates SHA-256 hash of every already-applied migration against the embedded SQL (rejects schema drift if anyone edited an applied migration), then applies pending entries viadb.execute(sql\...`)+db.transaction(...)against thedrizzle.__drizzle_migrationsbook-keeping table — public APIs only, nodb.dialect/db.session(those are@internal). Each migration is split on--> statement-breakpoint; empty fragments are trimmed and skipped. Dev helpersdb:push/drizzle-kit migratestill read./drizzle/`.
CLI & single-binary deploy
bun run compile produces a single executable that dispatches subcommands via citty. Entry is bin.ts at repo root, subcommands live in src/cli/.
./server [serve] # default — start the HTTP server
./server migrate # apply embedded migrations
./server --help
Nitro side-effect pitfall (important). Under the bun preset, .output/server/index.mjs has a top-level serve(...) call — merely importing it starts the HTTP server. bin.ts therefore must not eager-import any subcommand module, and src/cli/serve.ts reaches .output/server/index.mjs through the src/cli/_serve-nitro.mjs bridge (with _serve-nitro.d.mts for types, since .output/ doesn't exist at typecheck time). Citty's subCommands: { x: () => import('...') } lazy-loader is what keeps --help and migrate from booting the server.
Citty eager-loads subcommand modules for --help to read each subcommand's meta. So every src/cli/*.ts module body must be side-effect-free: do NOT static-import @/env, @/server/db/*, or anything that reads env at module-load time. Use await import('@/env') inside run(). Otherwise ./server --help (or any subcommand's help) will fail with env validation errors before printing.
Add a subcommand: drop a file in src/cli/ that default-exports defineCommand({...}), then register it in bin.ts's subCommands with a () => import(...) thunk. Keep top-level imports limited to citty + Node built-ins; pull env / db / etc. via await import(...) inside run().
Deploy flow is always migrate-then-serve. Migrations are embedded in the binary (see "Drizzle" section), so the binary is the only artifact — no ./drizzle/ directory at runtime. Dockerfile copies just ./server. compose.yaml models the pattern with a one-shot migrate service that app depends_on: service_completed_successfully. On k8s, run ./server migrate as an initContainer or a Helm pre-upgrade Job; run ./server (= ./server serve) as the main container.
Compile flags
compile.ts builds with --minify --bytecode --sourcemap=inline:
bytecode— pre-compiles JS to bytecode and embeds it in the binary; ~2x startup on app-sized binaries (Bun docs benchmark). Requires--compile; top-levelawaitmust live insideasyncfunctions (it already does in this repo).minify— shrinks the binary and the bytecode it derives from.sourcemap: 'inline'— embeds the source map in the binary so error stack traces stay decodable. Bun also writes a residualout/bin.js.mapnext to the output;compile.tsremoves it so the binary is the only artifact.
ORPC
Contract → Router → Handler → Client, all type-safe from a single contract.
osis built insrc/server/api/server.tsviaimplement(contract).$context<BaseContext>(). Always importosfrom@/server/api/server, never from@orpc/serverdirectly.ORPCError,onError,ValidationErrorcome from@orpc/server.- Contracts (
src/server/api/contracts/*.contract.ts) generate Zod from Drizzle tables viadrizzle-zod:Barrel-aggregated inconst insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)contracts/index.tsasexport const contract = { todo }. - Routers (
src/server/api/routers/*.router.ts) importdbdirectly from@/server/dbin their handlers. There is nomiddlewares/directory by default —dbdoesn't need one (module-level const). When you actually need per-request context (auth, tenant, rate-limit), createsrc/server/api/middlewares/<name>.middleware.tswithos.middleware(...)and extendBaseContextincontext.ts. - Interceptors are attached at the handler level, not in
server.tsand not onos. Bothsrc/routes/api/rpc.$.ts(RPCHandler) andsrc/routes/api/$.ts(OpenAPIHandler) register[onError(logError)](server) and[onError(handleValidationError)](client). The validation interceptor rewritesBAD_REQUEST + ValidationErrorintoINPUT_VALIDATION_FAILED(422) and output validation errors intoOUTPUT_VALIDATION_FAILED.logErrorcallslogger.errorfrom@/server/logger— neverconsole.*directly. - OpenAPI/Scalar: docs at
/api/docs, spec at/api/spec.json(handler prefix/api, plugin paths/docsand/spec.json). - SSR isomorphism (
src/client/orpc.ts):createIsomorphicFn().server(createRouterClient(...)).client(new RPCLink(...)). Server branch readsgetRequestHeaders()for context; client branch POSTs to${origin}/api/rpc. - Mutation invalidation is colocated at the call site via
mutationOptions({ onSuccess }), not insrc/client/orpc.ts.orpcinsrc/client/orpc.tsis a plaincreateTanstackQueryUtils(client)— noexperimental_defaults. Per-feature query helpers live insrc/client/queries/<feature>.ts(e.g.useInvalidateTodos); routes/components compose those hooks rather than holding query keys inline. Seesrc/client/queries/todo.ts+src/routes/index.tsxfor the canonical shape. - SSR prefetch in route loaders:
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions()). Components useuseSuspenseQuery(orpc.feature.list.queryOptions()).
Code style (Biome)
- 2-space, LF, single quotes, semicolons as-needed (omitted unless required), 120-col, arrow parens always,
useArrowFunction: "error"(covers function expressions only). AlsonoReactPropAssignments: "error". - Route components use
function Foo()declarations, placed below theRouteconfig so the file reads top-down (route on top, component below). This is the official TanStack Router/Start pattern and relies on hoisting —const Foo = () => {}would TDZ-error when referenced fromcreateFileRoute({ component: Foo })above it. Inline arrows are fine for trivial leaf components (e.g. plain redirect routes). Non-route components (UI primitives insrc/components/) useconst Foo = () => {}. - Imports are auto-organized into two groups (external, then
@/*), each alphabetical, withimport typeinterleaved (NOT a separate group).bun run fixhandles this; don't hand-sort. - Files: utils
kebab-case.ts, componentsPascalCase.tsx. routeTree.gen.tsis generated — ignored by Biome, never edit.
Testing
bun test runs all *.test.ts files. Tests are colocated next to the source they exercise (e.g. src/server/api/contracts/todo.contract.test.ts). Use import { describe, expect, test } from 'bun:test'. The @/* alias resolves in tests via tsconfig paths. No Vitest, no Jest, no separate test config — keep it that way unless you need a browser/JSDOM environment.
Endpoints
/— Todos UI (file route)./health— bareGETreturningok(200,text/plain). Liveness only — no DB check, so it stays green even when Postgres is down. Add a separate/readyif you ever need readiness./api/rpc— ORPC RPC handler (POST)./api/*— ORPC OpenAPI handler. Docs at/api/docs, spec at/api/spec.json.
TypeScript
Strict mode, plus noUncheckedIndexedAccess, verbatimModuleSyntax, erasableSyntaxOnly, noImplicitOverride. No as any / @ts-ignore / @ts-expect-error.
Path alias: @/* → src/*. For files outside src/ use @/../<file> (example in the codebase: src/routes/api/$.ts imports name, version from @/../package.json).
Env
src/env.ts via @t3-oss/env-core. Server: DATABASE_URL (required, z.url()). client: {} is empty by default — any client-side env must be VITE_-prefixed. Never commit .env.
Docker / deploy
- Multi-stage:
oven/bun:1.3.13builds and runsbun compile.ts, thengcr.io/distroless/cc-debian13:nonrootruns the single./serverbinary. Thecc(glibc) distroless variant is required because Bun's compiled binary links glibc. compose.yaml: one-shotmigrateservice runs./server migratewithrestart: "no", thenappstarts (depends_on: migrate: service_completed_successfully).DATABASE_URL=postgres://postgres:postgres@db:5432/postgresfor both.- Distroless has no shell, so any init-then-serve pattern must use exec-form
command: [...], notsh -c.
Layout (non-obvious parts only)
src/
├── client/
│ ├── orpc.ts # isomorphic ORPC client + TanStack Query utils (no global invalidation defaults)
│ └── queries/ # per-feature query hooks: keys, options, `useInvalidate<Feature>` helpers
├── cli/ # CLI subcommands (loaded lazily by bin.ts via citty)
│ ├── serve.ts # `./server serve` — imports the Nitro bridge on demand
│ ├── migrate.ts # `./server migrate` — applies embedded migrations via public `db.execute(sql)` + `db.transaction()`
│ ├── _serve-nitro.mjs # bridge: `import('../../.output/server/index.mjs')`
│ └── _serve-nitro.d.mts # types for the bridge (build output has no .d.ts)
├── routes/
│ ├── __root.tsx # root route + RootDocument shell
│ ├── index.tsx # Todos UI
│ ├── health.ts # GET /health → "ok" (no DB)
│ └── api/
│ ├── $.ts # OpenAPI + Scalar; interceptors registered here
│ └── rpc.$.ts # RPC; interceptors registered here
├── server/
│ ├── logger.ts # the only log entrypoint — wrap before swapping to pino/otel
│ ├── api/
│ │ ├── server.ts # the ONLY place to build `os`
│ │ ├── context.ts # BaseContext (add per-request fields when you add middlewares)
│ │ ├── interceptors.ts # logError (→ logger), handleValidationError
│ │ ├── types.ts # Router{Client,Inputs,Outputs} derived from Contract
│ │ ├── contracts/ # Zod schemas from Drizzle tables (barrel: contract); colocated *.test.ts
│ │ └── routers/ # os.* handlers (barrel: router) — import db directly
│ ├── db/
│ │ ├── index.ts # module-level `export const db = drizzle({...})`
│ │ ├── fields.ts # generatedFields (id/createdAt/updatedAt) + generatedFieldKeys
│ │ ├── migrations.gen.ts # AUTO-GENERATED by `bun run db:embed`; embeds ./drizzle/*.sql via `with { type: 'text' }`
│ │ └── schema/ # pgTable definitions; also put `relations()` here when adding
│ └── plugins/
│ └── shutdown.ts # SIGINT/SIGTERM → db.$client.end() with 500ms delay (prod only)
├── components/ # non-route UI primitives (PascalCase, arrow const)
├── env.ts # t3-oss env validation
├── router.tsx # QueryClient + setupRouterSsrQueryIntegration
├── sql.d.ts # ambient `declare module '*.sql'` — load-bearing for `with { type: 'text' }` imports in migrations.gen.ts
├── styles.css # Tailwind v4 entry
└── routeTree.gen.ts # auto-generated, do not edit
bin.ts # citty entry (root) — keep imports minimal (see "CLI" section)
compile.ts # `bun build --compile` driver; resolves --target; sets minify/bytecode/sourcemap
embed-migrations.ts # codegen: scans ./drizzle/meta/_journal.json → src/server/db/migrations.gen.ts
drizzle/ # SQL migrations (source of truth for `db:generate`; not shipped in binary)
patches/ # Bun `patchedDependencies` — patches `@tanstack/start-plugin-core` to drop the `@rsbuild/core` mis-import (see `package.json`)
Nitro plugins are wired in vite.config.ts (nitro({ plugins: [...] })), not via a Nitro config file.
Don'ts (specific, non-obvious)
- Don't edit
routeTree.gen.tsorsrc/server/db/migrations.gen.ts. - Don't eager-import anything from
.output/inbin.tsor any module it statically imports — it starts the HTTP server as a side effect. Subcommands must be lazy via citty's() => import(...)thunks. - Don't re-add an auto-migrate Nitro plugin. Migrations are an explicit deploy step via
./server migrate. - Don't reach into
db.dialect/db.sessionfrommigrate.ts— they're@internal. The current implementation uses publicdb.execute(sql)+db.transaction(...)against the documenteddrizzle.__drizzle_migrationsschema. - Don't add
./drizzle/back to the runtime image — migrations are embedded into the binary. - Don't reintroduce
getDB/closeDBor any "lazy DB init" pattern — that's a Cloudflare Workers shape; we deploy on Bun processes. - Don't import
osfrom@orpc/serverin middleware/routers — always@/server/api/server. - Don't import from
drizzle-orm/zod(1.0 beta only). Usedrizzle-zod. - Don't use RQB v2 object syntax,
defineRelations, or passrelationstodrizzle(). All are 1.0 beta. - Don't use
drizzle-orm/bun-sql. - Don't use
@/*aliases indrizzle.config.ts. - Don't commit
.env.
Room-to-grow rules (discipline for the first real feature)
These keep the starter from setting bad precedents as it grows. Append, don't restructure.
- Per-feature client query code — keys, options,
useInvalidate<Feature>helpers — lives insrc/client/queries/<feature>.ts. Routes/components compose these hooks; they don't hold query keys inline. - Mutation invalidation stays explicit at the mutation call site (
mutationOptions({ onSuccess })) or in a feature query helper. Do not reintroduce globalexperimental_defaultsor any implicit cache policy. - Client state defaults to route/component local state. Create a store (zustand or equivalent) only when state is shared across routes or needs persistence.
- Middlewares (
src/server/api/middlewares/<name>.middleware.ts) derive request-scoped context or gate access. They do NOT orchestrate business flow, hold side effects, or replace handlers/services. - Interceptors (
src/server/api/interceptors.ts) do cross-cutting error logging, transport normalization, and validation rewrites. They do NOT read business data. - One file per Drizzle table. Relations live in the same file and are exported as
<entity>Relations. No globalrelations.ts. - One router file per feature (
routers/<feature>.router.ts). Only introducerouters/<domain>/index.tswhen a domain grows past ~5 router files or needs shared domain helpers. - All server-side logging goes through
src/server/logger.ts. Do not callconsole.error/info/warndirectly from business code. - CLI subcommand modules keep top-level imports to
citty+ Node built-ins. Env, db, and server code areawait import(...)-ed insiderun()(seebin.tscomment for why). - Every new business feature ships with at least one
bun testcovering a contract schema, a pure helper, or a router behavior.