13 KiB
AGENTS.md - Server App Guidelines
TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
Tech Stack
⚠️ This project uses Bun — NOT Node.js / npm. Always use
bun run <script>(notbun <script>). Never usenpm,npx, ornode.
- Framework: TanStack Start (React 19 SSR, file-based routing)
- Styling: Tailwind CSS v4 + shadcn/ui (base-nova style,
@base-ui/react) - Database: PostgreSQL + Drizzle ORM v1 beta (
drizzle-orm/postgres-js, RQBv2) - State: TanStack Query v5 (with MutationCache auto-invalidation)
- RPC: ORPC (contract-first, type-safe)
- Auth: Better Auth (email+password, single-owner, self-hosted)
- CLI: citty (server-side admin commands)
- DnD: @dnd-kit/react + @dnd-kit/helpers (
move()for sortable) - Virtualization: @tanstack/react-virtual (
useVirtualizer) - Build: Vite + Nitro
Architecture Overview
Route Architecture
/ → Dashboard homepage (bookmark display, daily use)
/admin → Admin panel overview (module cards)
/admin/bookmarks → Bookmark management (CRUD, DnD, Dialog forms)
/setup → One-time owner setup (first visit only, redirects to /login after)
/login → Login page (redirects to /setup if no owner exists)
- Single-owner model: Kairos is a self-hosted Life OS. Only ONE user (the owner) exists. There is NO registration page —
/setupis a one-time wizard shown on first visit. - Display pages (
/): Clean, no management UI. What users see daily. - Admin pages (
/admin/*): Full CRUD, management, configuration. Sidebar navigation. - All authenticated routes under
_protectedlayout (auth guard → redirect to/login).
Module System
Modules are directory-based under src/modules/. Each module provides:
index.ts—ModuleMetadata(id, name, icon, adminRoute)schema.ts— Drizzle tablescontract.ts— ORPC contracts (input/output Zod schemas)router.ts— ORPC handlers (business logic)components/— React UI components
Contracts and routers are registered centrally:
src/server/api/contracts/index.ts— imports all module contractssrc/server/api/routers/index.ts— imports all module routers
Directory Structure
src/
├── client/
│ └── orpc.ts # ORPC client (isomorphic: SSR direct call / CSR fetch)
├── components/
│ ├── AdminSidebar.tsx # Admin sidebar (reads module registry)
│ ├── Error.tsx # Error boundary fallback
│ ├── NotFound.tsx # 404 fallback
│ └── ui/ # shadcn/ui components (可自由修改,添加新组件用 bunx shadcn@latest add)
├── hooks/
│ └── use-mobile.ts
├── lib/
│ └── utils.ts # cn() utility
├── modules/
│ ├── registry.ts # ModuleMetadata interface + modules[]
│ └── bookmarks/ # Bookmarks module (schema, contract, router, components/)
├── cli/
│ ├── index.ts # citty CLI entrypoint (bun run cli ...)
│ └── commands/auth.ts # auth reset-password command
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout (HTML shell, Toaster)
│ ├── _protected.tsx # Auth guard layout
│ ├── _protected/
│ │ ├── index.tsx # Dashboard homepage
│ │ ├── admin.tsx # Admin layout (SidebarProvider)
│ │ └── admin/bookmarks.tsx
│ ├── login.tsx, setup.tsx
│ └── api/ # $.ts (OpenAPI), auth.$.ts, health.ts, rpc.$.ts
├── server/
│ ├── api/
│ │ ├── contracts/index.ts # Central contract registry
│ │ ├── routers/index.ts # Central router registry
│ │ ├── middlewares/ # dbMiddleware, authMiddleware
│ │ ├── interceptors.ts # Validation error transform
│ │ ├── context.ts # BaseContext type
│ │ ├── server.ts # `os = implement(contract).$context<BaseContext>()`
│ │ └── types.ts # RouterClient type export
│ ├── auth/ # Better Auth (schema, instance, client, getSession, checkInitialized)
│ └── db/ # Drizzle (fields, relations, singleton instance)
├── env.ts # @t3-oss/env-core validation
├── router.tsx # TanStack Router + QueryClient + MutationCache
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind + shadcn CSS variables
ORPC Pattern
1. Define Contract (in module: src/modules/feature/contract.ts)
import { oc } from '@orpc/contract'
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import * as schema from '@/modules/feature/schema'
import { generatedFieldKeys } from '@/server/db/fields'
const selectSchema = createSelectSchema(schema.myTable)
const insertSchema = createInsertSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
const updateSchema = createUpdateSchema(schema.myTable).omit(generatedFieldKeys).omit({ userId: true })
export const myResource = {
list: oc.input(z.void()).output(z.array(selectSchema)),
create: oc.input(insertSchema).output(selectSchema),
update: oc.input(z.object({ id: z.uuid(), data: updateSchema })).output(selectSchema),
remove: oc.input(z.object({ id: z.uuid() })).output(z.void()),
}
2. Implement Router (in module: src/modules/feature/router.ts)
import { ORPCError } from '@orpc/server'
import * as schema from '@/modules/feature/schema'
import { authMiddleware, dbMiddleware } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
export const myResource = {
list: os.feature.myResource.list
.use(dbMiddleware).use(authMiddleware)
.handler(async ({ context }) => {
return await context.db.query.myTable.findMany({
where: { userId: context.user.id },
orderBy: { createdAt: 'desc' },
})
}),
create: os.feature.myResource.create
.use(dbMiddleware).use(authMiddleware)
.handler(async ({ context, input }) => {
const [created] = await context.db.insert(schema.myTable)
.values({ ...input, userId: context.user.id }).returning()
if (!created) throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create' })
return created
}),
}
3. Register (src/server/api/contracts/index.ts + routers/index.ts)
// contracts/index.ts
import * as feature from '@/modules/feature/contract'
export const contract = { feature }
// routers/index.ts
import * as feature from '@/modules/feature/router'
export const router = os.router({ feature })
4. Use in Components
const { data } = useSuspenseQuery(orpc.feature.myResource.list.queryOptions())
const mutation = useMutation(orpc.feature.myResource.create.mutationOptions())
MutationCache Auto-Invalidation
router.tsx configures a global MutationCache that auto-invalidates queries in the same module when any mutation succeeds. No need for manual queryClient.invalidateQueries() in most cases.
UI Component Patterns
base-ui render Prop (CRITICAL)
shadcn/ui uses @base-ui/react. The render prop replaces Radix's asChild:
// ✅ CORRECT
<DialogTrigger render={<Button />} />
<SidebarMenuButton render={<Link to="/admin" />}>
// ❌ WRONG — asChild does NOT exist
<DialogTrigger asChild><Button /></DialogTrigger>
Dialog Forms
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger} />
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader><DialogTitle>标题</DialogTitle></DialogHeader>
{/* fields */}
<DialogFooter><Button type="submit">提交</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
DnD Sortable (with @dnd-kit/helpers)
import { move } from '@dnd-kit/helpers'
import { DragDropProvider } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
// In sortable item:
const { ref, handleRef, isDragging } = useSortable({ id, index, group })
// In container — use move() for reordering:
const handleDragEnd = (event) => {
if (event.canceled) return
const reordered = move(items, event) // @dnd-kit/helpers handles index mapping
setItems(reordered)
reorderMutation.mutate(reordered.map((item, i) => ({ id: item.id, orderId: i })))
}
<DragDropProvider onDragEnd={handleDragEnd}>
{items.map((item, index) => <SortableItem key={item.id} index={index} />)}
</DragDropProvider>
Virtual Scrolling in Dialogs
Use useState callback ref (NOT useRef) for scroll elements inside Dialogs — useRef doesn't trigger re-render when Dialog mounts:
import { useVirtualizer } from '@tanstack/react-virtual'
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement, // useState, NOT useRef
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
<div ref={setScrollElement} className="max-h-80 overflow-y-auto">
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} style={{ position: 'absolute', transform: `translateY(${row.start}px)` }}>
{/* row content */}
</div>
))}
</div>
</div>
Toast Notifications
import { toast } from 'sonner'
toast.success('操作成功')
toast.error('操作失败')
Database (Drizzle ORM v1 beta)
- Driver:
drizzle-orm/postgres-js(NOTbun-sql) - Validation:
drizzle-orm/zod(built-in, NOT separatedrizzle-zodpackage) - Relations:
defineRelations()insrc/server/db/relations.ts— RQBv2 object syntax - Table naming: No
Tablesuffix —user,category,bookmark - DB instance: Module-level singleton
export const db = drizzle(...)(NOT factory pattern) - Shared fields: Use
...generatedFieldsspread for id/createdAt/updatedAt - Migration workflow: Always
db:generate→db:migrate. Never usedb:push. - Path alias exception: Files in the Drizzle schema chain (
db/schema/index.ts, moduleschema.ts) MUST use relative imports —drizzle-kitdoes not resolve@/*aliases.
// Schema — use generatedFields spread
export const myTable = pgTable('my_table', {
...generatedFields, // id (uuid v7), createdAt, updatedAt
name: text('name').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
})
// Relations — RQBv2 defineRelations
export const relations = defineRelations(schema, (r) => ({
myTable: {
user: r.one.user({ from: r.myTable.userId, to: r.user.id }),
},
}))
Auth (Better Auth — Single-Owner Model)
Kairos is a self-hosted single-user app. There is NO public registration. The first visit triggers a one-time setup wizard (/setup), and all subsequent signup attempts are blocked at the database level.
- Instance:
src/server/auth/index.ts—betterAuth()withdrizzleAdapter(db, { provider: 'pg', schema }) - Signup blocking:
databaseHooks.user.create.beforechecks if a user already exists → throwsAPIError('FORBIDDEN')if so - Client:
src/server/auth/client.ts—createAuthClient()for React - Server functions:
src/server/auth/functions.ts:getSession()— get current session viacreateServerFncheckInitialized()— check if owner account exists (used by/setupand/loginroutes)
- Auth tables: Use
textIDs (Better Auth manages its own IDs), NOT project's UUID v7 - Route guard:
beforeLoadin_protected.tsxcallsgetSession()→ redirect to/login - ORPC middleware:
authMiddlewarecallsauth.api.getSession({ headers })→ injectscontext.user - Password reset: Via server CLI only (
bun run cli auth reset-password) — no web-based password recovery
Critical Rules
DO:
- Run
bun run fixbefore committing - Use
@/*path aliases (not relative imports) - Use
renderprop (NOTasChild) for base-ui component delegation - Use
ORPCErrorwith proper codes - Use
drizzle-orm/zod(NOTdrizzle-zod) - Use RQBv2 object syntax for
orderByandwhere - Use
move()from@dnd-kit/helpersfor DnD reordering - Use
useStatecallback ref for virtualizer scroll elements inside Dialogs
DON'T:
- Add new
src/components/ui/*.tsxwithout CLI (usebunx shadcn@latest addto scaffold, then freely customize) - Edit
src/routeTree.gen.ts(auto-generated) - Use
asChildprop (base-ui usesrender, NOT Radix) - Import from
drizzle-zod(usedrizzle-orm/zod) - Use
drizzle-orm/bun-sqldriver - Pass
schematodrizzle()constructor (onlyrelationsneeded in RQBv2) - Add
Tablesuffix to Drizzle table exports - Use
useReffor scroll elements inside Dialog/conditional rendering - Use
db:push— always usedb:generate→db:migrate - Use
@/*aliases in Drizzle schema files (drizzle-kit can't resolve them) - Add registration/signup functionality (single-owner model, enforced by
databaseHooks)