6bedc1d60d
- drizzle-orm 1.0.0-beta.15 → 0.45.2, drizzle-kit → 0.31.10 - RQBv2 defineRelations() → 旧版 relations() 回调语法 - drizzle-orm/zod → drizzle-zod 独立包 - auth/schema.ts 改由 Better Auth CLI 生成(bun run db:auth) - db/schema/index.ts 选择性导出表(不导出生成文件中的旧版 relations) - 删除 db:push script,强制 db:generate → db:migrate 工作流 - 重建迁移基线(删除旧迁移目录,全新生成初始迁移)
316 lines
14 KiB
Markdown
316 lines
14 KiB
Markdown
# 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>` (not `bun <script>`). Never use `npm`, `npx`, or `node`.**
|
|
|
|
- **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 0.45.x (`drizzle-orm/postgres-js`)
|
|
- **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 — `/setup` is 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 `_protected` layout (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 tables
|
|
- `contract.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 contracts
|
|
- `src/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`)
|
|
```typescript
|
|
import { oc } from '@orpc/contract'
|
|
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-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`)
|
|
```typescript
|
|
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: (t, { eq }) => eq(t.userId, context.user.id),
|
|
orderBy: (t, { desc }) => desc(t.createdAt),
|
|
})
|
|
}),
|
|
|
|
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`)
|
|
```typescript
|
|
// 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
|
|
```typescript
|
|
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`:
|
|
```typescript
|
|
// ✅ CORRECT
|
|
<DialogTrigger render={<Button />} />
|
|
<SidebarMenuButton render={<Link to="/admin" />}>
|
|
|
|
// ❌ WRONG — asChild does NOT exist
|
|
<DialogTrigger asChild><Button /></DialogTrigger>
|
|
```
|
|
|
|
### Dialog Forms
|
|
```typescript
|
|
<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)
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
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
|
|
```typescript
|
|
import { toast } from 'sonner'
|
|
toast.success('操作成功')
|
|
toast.error('操作失败')
|
|
```
|
|
|
|
## Database (Drizzle ORM 0.45.x)
|
|
|
|
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
|
|
- **Validation**: `drizzle-zod` (separate package, NOT `drizzle-orm/zod`)
|
|
- **Relations**: `relations()` from `drizzle-orm` in `src/server/db/relations.ts` — callback syntax
|
|
- **Table naming**: No `Table` suffix — `user`, `category`, `bookmark`
|
|
- **DB instance**: Module-level singleton `export const db = drizzle(client, { schema })` (NOT factory pattern)
|
|
- **Shared fields**: Use `...generatedFields` spread for id/createdAt/updatedAt
|
|
- **Auth schema**: Generated by Better Auth CLI (`bun run db:auth`), **never hand-edit**
|
|
- **Schema re-export**: `db/schema/index.ts` selectively exports tables only (not relations) from auth schema
|
|
- **Migration workflow**: Always `db:generate` → `db:migrate`. **Never** use `db:push`.
|
|
- **Path alias exception**: Files in the Drizzle schema chain (`db/schema/index.ts`, module `schema.ts`) MUST use relative imports — `drizzle-kit` does not resolve `@/*` aliases.
|
|
|
|
```typescript
|
|
// 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 — callback syntax
|
|
export const myTableRelations = relations(myTable, ({ one }) => ({
|
|
user: one(user, {
|
|
fields: [myTable.userId],
|
|
references: [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()` with `drizzleAdapter(db, { provider: 'pg', schema })`
|
|
- **Signup blocking**: `databaseHooks.user.create.before` checks if a user already exists → throws `APIError('FORBIDDEN')` if so
|
|
- **Client**: `src/server/auth/client.ts` — `createAuthClient()` for React
|
|
- **Server functions**: `src/server/auth/functions.ts`:
|
|
- `getSession()` — get current session via `createServerFn`
|
|
- `checkInitialized()` — check if owner account exists (used by `/setup` and `/login` routes)
|
|
- **Auth tables**: Use `text` IDs (Better Auth manages its own IDs), NOT project's UUID v7
|
|
- **Route guard**: `beforeLoad` in `_protected.tsx` calls `getSession()` → redirect to `/login`
|
|
- **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user`
|
|
- **Password reset**: Via server CLI only (`bun run cli auth reset-password`) — no web-based password recovery
|
|
|
|
## Critical Rules
|
|
|
|
**DO:**
|
|
- Run `bun run fix` before committing
|
|
- Use `@/*` path aliases (not relative imports)
|
|
- Use `render` prop (NOT `asChild`) for base-ui component delegation
|
|
- Use `ORPCError` with proper codes
|
|
- Use `drizzle-zod` for schema validation (NOT `drizzle-orm/zod`)
|
|
- Use callback syntax for `orderBy` and `where` in relational queries
|
|
- Use `move()` from `@dnd-kit/helpers` for DnD reordering
|
|
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
|
|
|
|
**DON'T:**
|
|
- Add new `src/components/ui/*.tsx` without CLI (use `bunx shadcn@latest add` to scaffold, then freely customize)
|
|
- Edit `src/routeTree.gen.ts` (auto-generated)
|
|
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
|
|
- Import from `drizzle-orm/zod` (use `drizzle-zod`)
|
|
- Use `drizzle-orm/bun-sql` driver
|
|
- Hand-edit `src/server/auth/schema.ts` (generated by Better Auth CLI, use `bun run db:auth`)
|
|
- Add `Table` suffix to Drizzle table exports
|
|
- Use `useRef` for scroll elements inside Dialog/conditional rendering
|
|
- Use `db:push` — always use `db: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`)
|