docs: 重新梳理 AGENTS.md — 精简去重 + 修正过时模式 + 新增 DnD/Virtual 文档
- 根 AGENTS.md 242→135 行,去掉冗余 compile 命令和重复目录树 - server AGENTS.md 408→220 行,移除与根重复的 Code Style 章节 - 修正 ORPC 契约/路由路径(定义在模块内,非 server/api/) - 新增 @dnd-kit/helpers move()、@tanstack/react-virtual、MutationCache 模式
This commit is contained in:
@@ -6,37 +6,16 @@ Guidelines for AI agents working in this Bun monorepo.
|
||||
|
||||
> **Kairos 是一个人生操作系统(Personal Life OS)。所有开发决策必须服务于这个愿景。**
|
||||
|
||||
Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资产、决策 —— 整合进一个统一的、自托管的平台。它不是又一个效率工具或仪表盘,而是一个完整的操作系统。
|
||||
|
||||
### 核心模块
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| **财务** | 银行账户、交易流水、消费分析、预算追踪 |
|
||||
| **订阅** | 周期性服务管理 —— 费用、续期、使用频率 |
|
||||
| **健康** | 身体指标、习惯、睡眠、营养 |
|
||||
| **资产** | 实体与数字资产、保修、生命周期追踪 |
|
||||
| **日志** | 决策、复盘、人生事件 |
|
||||
| **集成** | API 对接、数据导入、自动化 |
|
||||
|
||||
每个模块是同一个统一数据层的不同视角,模块之间的数据可以交叉关联和分析。
|
||||
Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资产、决策 —— 整合进一个统一的、自托管的平台。
|
||||
|
||||
### 设计信条
|
||||
|
||||
1. **数据主权** —— 自托管、本地优先。用户数据不离开用户的机器,除非用户自己决定。所有功能必须在无外部云依赖的情况下完整可用。
|
||||
2. **关联即洞察** —— 孤立数据是噪音,关联数据是洞察。所有模块共享统一数据层,设计时必须考虑跨模块的数据关联能力。
|
||||
3. **可扩展基座** —— Kairos 是有主见的基座,不是封闭成品。架构必须支持模块的独立扩展和自定义。
|
||||
|
||||
### 架构原则
|
||||
|
||||
- **契约优先 API** —— 每个端点从类型化契约定义开始,实现紧随其后。一份契约,Web 和 CLI 两个消费者共享。
|
||||
- **Web + CLI 双入口** —— Web 应用是主界面;CLI 调用同一套 API,支持脚本编排、自动化、快速数据录入。
|
||||
- **编译为单文件** —— 服务端编译为独立 Bun 二进制,零运行时依赖,部署极简。
|
||||
1. **数据主权** —— 自托管、本地优先。用户数据不离开用户的机器,除非用户自己决定。
|
||||
2. **关联即洞察** —— 孤立数据是噪音,关联数据是洞察。所有模块共享统一数据层。
|
||||
3. **可扩展基座** —— 架构必须支持模块的独立扩展和自定义。
|
||||
|
||||
### Agent 决策指南
|
||||
|
||||
在做任何设计或实现决策时,用以下问题自检:
|
||||
|
||||
- **这是否让用户更完整地掌控自己的数据?** 如果否,重新考虑。
|
||||
- **这个模块的数据是否可以被其他模块关联使用?** 如果不能,说明数据模型设计有问题。
|
||||
- **这个功能是否可以在完全离线/自托管环境下工作?** 如果不能,必须提供无外部依赖的替代方案。
|
||||
@@ -44,78 +23,46 @@ Kairos 将个人生活的每一个维度 —— 财务、订阅、健康、资
|
||||
|
||||
## Project Overview
|
||||
|
||||
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. All commands start with `bun` — use `bun install` for dependencies and `bun run <script>` for scripts. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands (e.g. `bun build` invokes Bun's bundler, NOT your package.json script). Never use `npm`, `npx`, or `node`.**
|
||||
> **This project uses [Bun](https://bun.sh) exclusively as both the JavaScript runtime and package manager. Do NOT use Node.js / npm / yarn / pnpm. Always prefer `bun run <script>` over `bun <script>` to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, or `node`.**
|
||||
|
||||
- **Monorepo**: Bun workspaces + Turborepo orchestration
|
||||
- **Runtime**: Bun (see `mise.toml` for version) — **NOT Node.js**
|
||||
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
|
||||
- **Apps**:
|
||||
- `apps/server` - TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
|
||||
- **Packages**: `packages/tsconfig` (shared TS configs)
|
||||
- **Apps**: `apps/server` — TanStack Start fullstack web app (see `apps/server/AGENTS.md`)
|
||||
- **Packages**: `packages/tsconfig` — shared TS configs
|
||||
|
||||
## Build / Lint / Test Commands
|
||||
|
||||
### Root Commands (via Turbo)
|
||||
```bash
|
||||
# Root (via Turbo)
|
||||
bun run dev # Start all apps in dev mode
|
||||
bun run build # Build all apps
|
||||
bun run compile # Compile server to standalone binary (current platform)
|
||||
bun run compile:darwin # Compile server for macOS (arm64 + x64)
|
||||
bun run compile:linux # Compile server for Linux (x64 + arm64)
|
||||
bun run compile:windows # Compile server for Windows x64
|
||||
bun run compile # Compile server to standalone binary
|
||||
bun run fix # Lint + format (Biome auto-fix)
|
||||
bun run typecheck # TypeScript check across monorepo
|
||||
```
|
||||
|
||||
### Server App (`apps/server`)
|
||||
```bash
|
||||
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 compile:darwin # Compile for macOS (arm64 + x64)
|
||||
bun run compile:darwin:arm64 # Compile for macOS arm64
|
||||
bun run compile:darwin:x64 # Compile for macOS x64
|
||||
bun run compile:linux # Compile for Linux (x64 + arm64)
|
||||
bun run compile:linux:arm64 # Compile for Linux arm64
|
||||
bun run compile:linux:x64 # Compile for Linux x64
|
||||
bun run compile:windows # Compile for Windows (default: x64)
|
||||
bun run compile:windows:x64 # Compile for Windows x64
|
||||
bun run fix # Biome auto-fix
|
||||
bun run typecheck # TypeScript check
|
||||
|
||||
# Database (Drizzle)
|
||||
# Database (in apps/server)
|
||||
bun run db:generate # Generate migrations from schema
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:push # Push schema (dev only)
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Testing
|
||||
No test framework configured yet. When adding tests:
|
||||
```bash
|
||||
# Testing (not yet configured)
|
||||
bun test path/to/test.ts # Run single test file
|
||||
bun test -t "pattern" # Run tests matching pattern
|
||||
```
|
||||
|
||||
## Code Style (TypeScript)
|
||||
|
||||
### Formatting (Biome)
|
||||
- **Indent**: 2 spaces | **Line endings**: LF
|
||||
- **Indent**: 2 spaces | **Line width**: 120 | **Line endings**: LF
|
||||
- **Quotes**: Single `'` | **Semicolons**: Omit (ASI)
|
||||
- **Arrow parentheses**: Always `(x) => x`
|
||||
|
||||
### Imports
|
||||
Biome auto-organizes. Order: 1) External packages → 2) Internal `@/*` aliases → 3) Type imports (`import type { ... }`)
|
||||
|
||||
```typescript
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/server/db'
|
||||
import type { ReactNode } from 'react'
|
||||
```
|
||||
|
||||
### TypeScript Strictness
|
||||
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true`
|
||||
- `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`
|
||||
- Use `@/*` path aliases (maps to `src/*`)
|
||||
|
||||
### Naming Conventions
|
||||
@@ -127,16 +74,16 @@ import type { ReactNode } from 'react'
|
||||
| Functions | camelCase | `getUserById` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRIES` |
|
||||
| Types/Interfaces | PascalCase | `UserProfile` |
|
||||
| Drizzle tables | camelCase, no suffix | `user`, `category` (NOT `userTable`) |
|
||||
|
||||
### React Patterns
|
||||
- Components: arrow functions (enforced by Biome)
|
||||
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
|
||||
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
|
||||
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
|
||||
|
||||
### Error Handling
|
||||
- Use `try-catch` for async operations; throw descriptive errors
|
||||
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
|
||||
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `UNAUTHORIZED`, `INTERNAL_SERVER_ERROR`)
|
||||
- Never use empty catch blocks
|
||||
|
||||
## Database (Drizzle ORM v1 beta + postgres-js)
|
||||
@@ -144,17 +91,8 @@ import type { ReactNode } from 'react'
|
||||
- **ORM**: Drizzle ORM `1.0.0-beta` (RQBv2)
|
||||
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
|
||||
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
|
||||
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts` (contains schema info, so `drizzle()` only needs `{ relations }`)
|
||||
- **Query style**: RQBv2 object syntax (`orderBy: { createdAt: 'desc' }`, `where: { id: 1 }`)
|
||||
|
||||
```typescript
|
||||
export const myTable = pgTable('my_table', {
|
||||
id: uuid().primaryKey().default(sql`uuidv7()`),
|
||||
name: text().notNull(),
|
||||
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
|
||||
})
|
||||
```
|
||||
- **Relations**: Defined via `defineRelations()` — RQBv2 object syntax
|
||||
- **Query style**: `db.query.tableName.findMany({ orderBy: { createdAt: 'desc' }, where: { id: 1 } })`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -170,11 +108,9 @@ export const myTable = pgTable('my_table', {
|
||||
|
||||
## Development Principles
|
||||
|
||||
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
|
||||
|
||||
1. **No backward compatibility** — This project is in rapid iteration. Always use the latest API and patterns. Never keep deprecated code paths or old API fallbacks "just in case".
|
||||
2. **Always sync documentation** — When code changes, immediately update all related documentation (`AGENTS.md`, `README.md`, inline code examples). Code and docs must never drift apart. This includes updating code snippets in docs when imports, APIs, or patterns change.
|
||||
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns in the same codebase.
|
||||
1. **No backward compatibility** — Always use the latest API and patterns. Never keep deprecated code paths.
|
||||
2. **Always sync documentation** — When code changes, immediately update `AGENTS.md` and related docs.
|
||||
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
@@ -183,7 +119,6 @@ export const myTable = pgTable('my_table', {
|
||||
- Use `@/*` path aliases (not relative imports)
|
||||
- Include `createdAt`/`updatedAt` on all tables
|
||||
- Use `catalog:` for dependency versions
|
||||
- Update `AGENTS.md` and other docs whenever code patterns change
|
||||
|
||||
**DON'T:**
|
||||
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
|
||||
@@ -194,42 +129,12 @@ export const myTable = pgTable('my_table', {
|
||||
- Hardcode dependency versions in workspace packages
|
||||
- Leave docs out of sync with code changes
|
||||
|
||||
## Git Workflow
|
||||
|
||||
1. Make changes following style guide
|
||||
2. `bun run fix` - auto-format and lint
|
||||
3. `bun run typecheck` - verify types
|
||||
4. `bun run dev` - test locally
|
||||
5. Commit with descriptive message
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── server/ # TanStack Start fullstack app
|
||||
│ │ ├── components.json # shadcn/ui configuration
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── client/ # ORPC client + TanStack Query utils
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── ui/ # shadcn/ui components (auto-managed)
|
||||
│ │ │ │ └── AdminSidebar.tsx # Admin panel sidebar
|
||||
│ │ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── lib/ # Utilities (cn, etc.)
|
||||
│ │ │ ├── modules/ # Feature modules (bookmarks, etc.)
|
||||
│ │ │ ├── routes/ # File-based routing
|
||||
│ │ │ │ ├── _protected/ # Auth guard
|
||||
│ │ │ │ │ ├── index.tsx # Dashboard homepage
|
||||
│ │ │ │ │ ├── admin.tsx # Admin layout (sidebar)
|
||||
│ │ │ │ │ └── admin/ # Admin pages
|
||||
│ │ │ │ │ ├── index.tsx # Admin overview
|
||||
│ │ │ │ │ └── bookmarks.tsx # Bookmark management
|
||||
│ │ │ │ └── api/ # API routes
|
||||
│ │ │ └── server/ # API layer + database + auth
|
||||
│ │ │ ├── api/ # ORPC contracts, routers, middlewares
|
||||
│ │ │ ├── auth/ # Better Auth (schema, instance, client)
|
||||
│ │ │ └── db/ # Drizzle schema + relations
|
||||
│ │ └── AGENTS.md
|
||||
│ └── server/ # TanStack Start fullstack app (see apps/server/AGENTS.md)
|
||||
├── packages/
|
||||
│ └── tsconfig/ # Shared TS configs
|
||||
├── biome.json # Linting/formatting config
|
||||
@@ -239,4 +144,4 @@ export const myTable = pgTable('my_table', {
|
||||
|
||||
## See Also
|
||||
|
||||
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
|
||||
- `apps/server/AGENTS.md` — Detailed server app architecture, ORPC patterns, UI conventions
|
||||
|
||||
+168
-280
@@ -4,52 +4,21 @@ TanStack Start fullstack web app with ORPC (contract-first RPC) and shadcn/ui.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
> **⚠️ This project uses Bun — NOT Node.js / npm. All commands use `bun`. Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, or `node`.**
|
||||
> **⚠️ 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)
|
||||
- **Runtime**: Bun — **NOT Node.js**
|
||||
- **Package Manager**: Bun — **NOT npm / yarn / pnpm**
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **Styling**: Tailwind CSS v4 + shadcn/ui (base-nova style, OKLCH colors)
|
||||
- **UI Components**: shadcn/ui (copy-paste components in `src/components/ui/`)
|
||||
- **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
|
||||
- **State**: TanStack Query v5 (with MutationCache auto-invalidation)
|
||||
- **RPC**: ORPC (contract-first, type-safe)
|
||||
- **Auth**: Better Auth (email+password, self-hosted)
|
||||
- **DnD**: @dnd-kit/react
|
||||
- **DnD**: @dnd-kit/react + @dnd-kit/helpers (`move()` for sortable)
|
||||
- **Virtualization**: @tanstack/react-virtual (`useVirtualizer`)
|
||||
- **Build**: Vite + Nitro
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun run dev # Vite dev server (localhost:3000)
|
||||
bun run db:studio # Drizzle Studio GUI
|
||||
|
||||
# Build
|
||||
bun run build # Production build → .output/
|
||||
bun run compile # Compile to standalone binary (current platform, depends on build)
|
||||
bun run compile:darwin # Compile for macOS (arm64 + x64)
|
||||
bun run compile:linux # Compile for Linux (x64 + arm64)
|
||||
bun run compile:windows # Compile for Windows (default: x64)
|
||||
|
||||
# Code Quality
|
||||
bun run fix # Biome auto-fix
|
||||
bun run typecheck # TypeScript check
|
||||
|
||||
# Database
|
||||
bun run db:generate # Generate migrations from schema
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:push # Push schema directly (dev only)
|
||||
|
||||
# Testing (not yet configured)
|
||||
bun test path/to/test.ts # Run single test
|
||||
bun test -t "pattern" # Run tests matching pattern
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Route Architecture (Display + Admin separation)
|
||||
### Route Architecture
|
||||
|
||||
```
|
||||
/ → Dashboard homepage (bookmark display, daily use)
|
||||
@@ -61,348 +30,267 @@ bun test -t "pattern" # Run tests matching pattern
|
||||
|
||||
- **Display pages** (`/`): Clean, no management UI. What users see daily.
|
||||
- **Admin pages** (`/admin/*`): Full CRUD, management, configuration. Sidebar navigation.
|
||||
- All authenticated routes are under `_protected` layout (auth guard → redirect to `/login`).
|
||||
- All authenticated routes under `_protected` layout (auth guard → redirect to `/login`).
|
||||
|
||||
### Module System
|
||||
|
||||
Modules are directory-based under `src/modules/`. Each module is independent and pluggable.
|
||||
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
|
||||
|
||||
```typescript
|
||||
// src/modules/registry.ts
|
||||
export interface ModuleMetadata {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string // lucide-react icon name
|
||||
adminRoute: string // Admin management route (e.g., '/admin/bookmarks')
|
||||
enabled: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Each module provides: `schema.ts` (Drizzle tables), `contract.ts` (ORPC contracts), `router.ts` (ORPC handlers), `components/` (UI), and `index.ts` (metadata).
|
||||
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/ # Client-side code
|
||||
│ └── orpc.ts # ORPC client + TanStack Query utils
|
||||
├── components/ # Shared React components
|
||||
│ ├── AdminSidebar.tsx # Admin panel sidebar navigation
|
||||
├── 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 (DO NOT manually edit)
|
||||
│ ├── button.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ ├── dropdown-menu.tsx
|
||||
│ ├── input.tsx
|
||||
│ ├── separator.tsx
|
||||
│ ├── sheet.tsx
|
||||
│ ├── sidebar.tsx
|
||||
│ ├── skeleton.tsx
|
||||
│ ├── sonner.tsx
|
||||
│ ├── tooltip.tsx
|
||||
│ ├── avatar.tsx
|
||||
│ └── badge.tsx
|
||||
├── hooks/ # Custom React hooks
|
||||
│ └── use-mobile.ts # Mobile detection (shadcn sidebar dep)
|
||||
├── lib/ # Utility functions
|
||||
│ └── utils.ts # cn() utility (clsx + tailwind-merge)
|
||||
├── modules/ # Feature modules
|
||||
│ ├── registry.ts # Module metadata registry
|
||||
│ └── bookmarks/ # Bookmarks module
|
||||
│ ├── index.ts # Module metadata
|
||||
│ ├── schema.ts # Drizzle tables (category, bookmark)
|
||||
│ ├── contract.ts # ORPC contracts
|
||||
│ ├── router.ts # ORPC handlers
|
||||
│ └── components/ # Module UI
|
||||
│ ├── BookmarkCard.tsx # Display: clean card (icon + name)
|
||||
│ ├── CategoryGrid.tsx # Display: category grid for homepage
|
||||
│ ├── BookmarkFormDialog.tsx # Admin: create/edit bookmark dialog
|
||||
│ ├── BookmarkManager.tsx # Admin: bookmark list with DnD
|
||||
│ ├── CategoryFormDialog.tsx # Admin: create/edit category dialog
|
||||
│ ├── CategoryManager.tsx # Admin: category list with DnD
|
||||
│ ├── IconPickerDialog.tsx # Admin: icon picker in dialog
|
||||
│ ├── GreetingHeader.tsx # Display: time-based greeting
|
||||
│ └── SearchBar.tsx # Display: multi-engine search
|
||||
├── hooks/
|
||||
│ └── use-mobile.ts
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() utility
|
||||
├── modules/
|
||||
│ ├── registry.ts # ModuleMetadata interface + modules[]
|
||||
│ └── bookmarks/ # Bookmarks module (schema, contract, router, components/)
|
||||
├── routes/ # TanStack Router file routes
|
||||
│ ├── __root.tsx # Root layout (HTML shell, Toaster, TooltipProvider)
|
||||
│ ├── _protected.tsx # Auth guard layout (redirect to /login)
|
||||
│ ├── __root.tsx # Root layout (HTML shell, Toaster)
|
||||
│ ├── _protected.tsx # Auth guard layout
|
||||
│ ├── _protected/
|
||||
│ │ ├── index.tsx # Dashboard homepage (bookmark display)
|
||||
│ │ ├── admin.tsx # Admin layout (SidebarProvider + Outlet)
|
||||
│ │ └── admin/
|
||||
│ │ ├── index.tsx # Admin overview (module cards)
|
||||
│ │ └── bookmarks.tsx # Bookmark management page
|
||||
│ ├── login.tsx # Login page
|
||||
│ ├── signup.tsx # Signup page
|
||||
│ └── api/
|
||||
│ ├── $.ts # OpenAPI handler + Scalar docs
|
||||
│ ├── auth.$.ts # Better Auth handler
|
||||
│ ├── health.ts # Health check endpoint
|
||||
│ └── rpc.$.ts # ORPC RPC handler
|
||||
├── server/ # Server-side code
|
||||
│ ├── api/ # ORPC layer
|
||||
│ │ ├── contracts/ # Input/output schemas (Zod)
|
||||
│ │ ├── middlewares/ # Middleware (dbMiddleware, authMiddleware)
|
||||
│ │ ├── routers/ # Handler implementations
|
||||
│ │ ├── interceptors.ts # Shared error interceptors
|
||||
│ │ ├── context.ts # Request context types
|
||||
│ │ ├── server.ts # ORPC server instance
|
||||
│ │ └── types.ts # Type exports
|
||||
│ ├── auth/ # Better Auth
|
||||
│ │ ├── schema.ts # Auth tables (user, session, account, verification)
|
||||
│ │ ├── index.ts # Auth instance (betterAuth + drizzleAdapter)
|
||||
│ │ ├── client.ts # Auth client (createAuthClient)
|
||||
│ │ └── functions.ts # Server functions (getSession)
|
||||
│ └── db/
|
||||
│ ├── schema/ # Drizzle table re-exports
|
||||
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
|
||||
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
|
||||
│ └── index.ts # Database singleton (module-level export)
|
||||
├── env.ts # Environment variable validation
|
||||
├── router.tsx # Router configuration
|
||||
│ │ ├── index.tsx # Dashboard homepage
|
||||
│ │ ├── admin.tsx # Admin layout (SidebarProvider)
|
||||
│ │ └── admin/bookmarks.tsx
|
||||
│ ├── login.tsx, signup.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)
|
||||
│ └── 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 (`src/server/api/contracts/feature.contract.ts`)
|
||||
### 1. Define Contract (in module: `src/modules/feature/contract.ts`)
|
||||
```typescript
|
||||
import { oc } from '@orpc/contract'
|
||||
import { createSelectSchema } from 'drizzle-orm/zod'
|
||||
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 list = oc.input(z.void()).output(z.array(selectSchema))
|
||||
export const create = oc.input(insertSchema).output(selectSchema)
|
||||
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 (`src/server/api/routers/feature.router.ts`)
|
||||
### 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 list = os.feature.list
|
||||
.use(dbMiddleware)
|
||||
.use(authMiddleware)
|
||||
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 in Index Files
|
||||
### 3. Register (`src/server/api/contracts/index.ts` + `routers/index.ts`)
|
||||
```typescript
|
||||
// src/server/api/contracts/index.ts
|
||||
// contracts/index.ts
|
||||
import * as feature from '@/modules/feature/contract'
|
||||
export const contract = { feature }
|
||||
|
||||
// src/server/api/routers/index.ts
|
||||
// routers/index.ts
|
||||
import * as feature from '@/modules/feature/router'
|
||||
export const router = os.router({ feature })
|
||||
```
|
||||
|
||||
### 4. Use in Components
|
||||
```typescript
|
||||
import { useSuspenseQuery, useMutation } from '@tanstack/react-query'
|
||||
import { orpc } from '@/client/orpc'
|
||||
|
||||
const { data } = useSuspenseQuery(orpc.feature.list.queryOptions())
|
||||
const mutation = useMutation(orpc.feature.create.mutationOptions())
|
||||
const { data } = useSuspenseQuery(orpc.feature.myResource.list.queryOptions())
|
||||
const mutation = useMutation(orpc.feature.myResource.create.mutationOptions())
|
||||
```
|
||||
|
||||
## UI Component Patterns (shadcn/ui)
|
||||
### 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.
|
||||
|
||||
### Dialog Forms (for CRUD operations)
|
||||
## UI Component Patterns
|
||||
|
||||
### base-ui `render` Prop (CRITICAL)
|
||||
shadcn/ui uses `@base-ui/react`. The `render` prop replaces Radix's `asChild`:
|
||||
```typescript
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
// ✅ CORRECT
|
||||
<DialogTrigger render={<Button />} />
|
||||
<SidebarMenuButton render={<Link to="/admin" />}>
|
||||
|
||||
// Use render prop on DialogTrigger to avoid nested <button>
|
||||
// ❌ WRONG — asChild does NOT exist
|
||||
<DialogTrigger asChild><Button /></DialogTrigger>
|
||||
```
|
||||
|
||||
### Dialog Forms
|
||||
```typescript
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger render={<Button>打开</Button>} />
|
||||
<DialogTrigger render={trigger} />
|
||||
<DialogContent>
|
||||
{/* Form content */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader><DialogTitle>标题</DialogTitle></DialogHeader>
|
||||
{/* fields */}
|
||||
<DialogFooter><Button type="submit">提交</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### DropdownMenu (for action menus)
|
||||
### DnD Sortable (with @dnd-kit/helpers)
|
||||
```typescript
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { move } from '@dnd-kit/helpers'
|
||||
import { DragDropProvider } from '@dnd-kit/react'
|
||||
import { useSortable } from '@dnd-kit/react/sortable'
|
||||
|
||||
// Use render prop on trigger to avoid nested <button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" />}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>操作</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
// 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('操作失败')
|
||||
```
|
||||
|
||||
### Admin Sidebar
|
||||
- Uses shadcn `SidebarProvider` + `Sidebar` + `SidebarInset` pattern
|
||||
- Navigation items from module registry
|
||||
- `SidebarMenuButton` uses `render` prop for `Link` integration (NOT `asChild`)
|
||||
|
||||
### base-ui `render` Prop (IMPORTANT)
|
||||
shadcn/ui v4 uses `@base-ui/react` instead of Radix primitives. The `render` prop replaces `asChild`:
|
||||
```typescript
|
||||
// CORRECT: render prop delegates element rendering
|
||||
<DialogTrigger render={<Button />} />
|
||||
<SidebarMenuButton render={<Link to="/admin" />}>
|
||||
|
||||
// WRONG: asChild does NOT exist in base-ui components
|
||||
<DialogTrigger asChild><Button /></DialogTrigger> // ❌ asChild is NOT supported
|
||||
```
|
||||
|
||||
## Database (Drizzle ORM v1 beta)
|
||||
|
||||
- **Driver**: `drizzle-orm/postgres-js` (NOT `bun-sql`)
|
||||
- **Validation**: `drizzle-orm/zod` (built-in, NOT separate `drizzle-zod` package)
|
||||
- **Relations**: Defined via `defineRelations()` in `src/server/db/relations.ts`
|
||||
- **Query**: RQBv2 — use `db.query.tableName.findMany()` with object-style `orderBy` and `where`
|
||||
- **Table naming**: No `Table` suffix — use `user`, `category`, `bookmark` (NOT `userTable`)
|
||||
- **DB instance**: Module-level singleton `export const db = drizzle(...)` (NOT factory/closure pattern)
|
||||
- **Relations**: `defineRelations()` in `src/server/db/relations.ts` — RQBv2 object syntax
|
||||
- **Table naming**: No `Table` suffix — `user`, `category`, `bookmark`
|
||||
- **DB instance**: Module-level singleton `export const db = drizzle(...)` (NOT factory pattern)
|
||||
- **Shared fields**: Use `...generatedFields` spread for id/createdAt/updatedAt
|
||||
|
||||
### Schema Definition
|
||||
```typescript
|
||||
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
// Schema — use generatedFields spread
|
||||
export const myTable = pgTable('my_table', {
|
||||
id: uuid().primaryKey().default(sql`uuidv7()`),
|
||||
name: text().notNull(),
|
||||
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow().$onUpdateFn(() => new Date()),
|
||||
...generatedFields, // id (uuid v7), createdAt, updatedAt
|
||||
name: text('name').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
})
|
||||
```
|
||||
|
||||
### Relations (RQBv2)
|
||||
```typescript
|
||||
import { defineRelations } from 'drizzle-orm'
|
||||
import * as schema from './schema'
|
||||
|
||||
// Relations — RQBv2 defineRelations
|
||||
export const relations = defineRelations(schema, (r) => ({
|
||||
user: {
|
||||
categories: r.many.category(),
|
||||
},
|
||||
category: {
|
||||
user: r.one.user({ from: r.category.userId, to: r.user.id }),
|
||||
bookmarks: r.many.bookmark(),
|
||||
myTable: {
|
||||
user: r.one.user({ from: r.myTable.userId, to: r.user.id }),
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
### DB Instance
|
||||
```typescript
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { relations } from '@/server/db/relations'
|
||||
|
||||
export const db = drizzle({ connection: env.DATABASE_URL, relations })
|
||||
export type DB = typeof db
|
||||
```
|
||||
|
||||
## Auth (Better Auth)
|
||||
|
||||
- **Instance**: `src/server/auth/index.ts` — `betterAuth()` with `drizzleAdapter(db, { provider: 'pg', schema: authSchema })`
|
||||
- **Instance**: `src/server/auth/index.ts` — `betterAuth()` with `drizzleAdapter(db, { provider: 'pg', schema })`
|
||||
- **Client**: `src/server/auth/client.ts` — `createAuthClient()` for React
|
||||
- **Server function**: `src/server/auth/functions.ts` — `getSession()` via `createServerFn`
|
||||
- **Auth tables**: Use `text` IDs (Better Auth manages its own IDs), NOT project's UUID v7
|
||||
- **Schema key naming**: Export names must match Better Auth model names exactly (`user`, `session`, `account`, `verification`)
|
||||
|
||||
### Auth Middleware (ORPC)
|
||||
```typescript
|
||||
const sessionData = await auth.api.getSession({ headers: context.headers })
|
||||
// Injects context.user and context.session
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Formatting (Biome)
|
||||
- **Indent**: 2 spaces
|
||||
- **Quotes**: Single `'`
|
||||
- **Semicolons**: Omit (ASI)
|
||||
- **Arrow parens**: Always `(x) => x`
|
||||
|
||||
### Imports
|
||||
Biome auto-organizes:
|
||||
1. External packages
|
||||
2. Internal `@/*` aliases
|
||||
3. Type imports (`import type { ... }`)
|
||||
|
||||
### TypeScript
|
||||
- `strict: true`
|
||||
- `noUncheckedIndexedAccess: true` — array access returns `T | undefined`
|
||||
- Use `@/*` path aliases (maps to `src/*`)
|
||||
|
||||
### Naming
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Files (utils) | kebab-case | `auth-utils.ts` |
|
||||
| Files (components) | PascalCase | `UserProfile.tsx` |
|
||||
| Components | PascalCase arrow | `const Button = () => {}` |
|
||||
| Functions | camelCase | `getUserById` |
|
||||
| Types | PascalCase | `UserProfile` |
|
||||
| Drizzle tables | camelCase, no suffix | `user`, `category` (NOT `userTable`) |
|
||||
|
||||
### React
|
||||
- Use arrow functions for components (Biome enforced)
|
||||
- Use `useSuspenseQuery` for guaranteed data
|
||||
- Let React Compiler handle memoization (no manual `useMemo`/`useCallback`)
|
||||
|
||||
## Development Principles
|
||||
|
||||
> **These principles apply to ALL code changes. Agents MUST follow them on every task.**
|
||||
|
||||
1. **No backward compatibility** — Always use the latest API and patterns.
|
||||
2. **Always sync documentation** — When code changes, immediately update all related documentation.
|
||||
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API.
|
||||
- **Route guard**: `beforeLoad` in `_protected.tsx` calls `getSession()` → redirect to `/login`
|
||||
- **ORPC middleware**: `authMiddleware` calls `auth.api.getSession({ headers })` → injects `context.user`
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**DO:**
|
||||
- Run `bun run fix` before committing
|
||||
- Use `@/*` path aliases
|
||||
- Include `createdAt`/`updatedAt` on all tables
|
||||
- Use `ORPCError` with proper codes
|
||||
- Use `drizzle-orm/zod` (NOT `drizzle-zod`) for schema validation
|
||||
- Use RQBv2 object syntax for `orderBy` and `where`
|
||||
- Use `@/*` path aliases (not relative imports)
|
||||
- Use `render` prop (NOT `asChild`) for base-ui component delegation
|
||||
- Use Dialog modals for forms (NOT inline forms)
|
||||
- Use `toast` from `sonner` for CRUD notifications
|
||||
- Update `AGENTS.md` and other docs whenever code patterns change
|
||||
- Use `ORPCError` with proper codes
|
||||
- Use `drizzle-orm/zod` (NOT `drizzle-zod`)
|
||||
- Use RQBv2 object syntax for `orderBy` and `where`
|
||||
- Use `move()` from `@dnd-kit/helpers` for DnD reordering
|
||||
- Use `useState` callback ref for virtualizer scroll elements inside Dialogs
|
||||
|
||||
**DON'T:**
|
||||
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
|
||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
||||
- Manually edit `src/components/ui/*.tsx` (use `bunx shadcn@latest add`)
|
||||
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
|
||||
- Use `asChild` prop (use `render` prop instead — base-ui, NOT Radix)
|
||||
- Commit `.env` files
|
||||
- Use empty catch blocks
|
||||
- Import from `drizzle-zod` (use `drizzle-orm/zod` instead)
|
||||
- Use RQBv1 callback-style `orderBy` / old `relations()` API
|
||||
- Use `drizzle-orm/bun-sql` driver (use `drizzle-orm/postgres-js`)
|
||||
- Pass `schema` to `drizzle()` constructor (only `relations` is needed in RQBv2)
|
||||
- Use `getDB()` factory pattern (use module-level `db` export instead)
|
||||
- Edit `src/routeTree.gen.ts` (auto-generated)
|
||||
- Use `asChild` prop (base-ui uses `render`, NOT Radix)
|
||||
- Import from `drizzle-zod` (use `drizzle-orm/zod`)
|
||||
- Use `drizzle-orm/bun-sql` driver
|
||||
- Pass `schema` to `drizzle()` constructor (only `relations` needed in RQBv2)
|
||||
- Add `Table` suffix to Drizzle table exports
|
||||
- Leave docs out of sync with code changes
|
||||
- Use `useRef` for scroll elements inside Dialog/conditional rendering
|
||||
|
||||
Reference in New Issue
Block a user