Compare commits

...

18 Commits

Author SHA1 Message Date
imbytecat c20cf02d9f chore: update Docker Compose configuration
- Use postgres:18-alpine image

- Update environment variable format

- Rename volume from pgdata to postgres_data

- Increase healthcheck interval to 10s
2026-04-02 03:49:38 +08:00
imbytecat 341315a01b chore: upgrade PostgreSQL to v18 and restructure compose.yaml 2026-04-02 03:43:37 +08:00
imbytecat 77b3484415 refactor: 改用 Nitro 插件实现启动时数据库迁移 2026-04-02 03:42:45 +08:00
imbytecat 5de4d5f940 chore: upgrade PostgreSQL to v18 and restructure compose.yaml 2026-04-02 02:45:00 +08:00
imbytecat ed770909ef chore: 添加 Docker 打包和 Compose 编排支持 2026-04-02 02:43:21 +08:00
imbytecat 9175909033 chore: 更新依赖 2026-04-02 00:57:49 +08:00
imbytecat 5f5f6c469a chore: remove unused shadcn MCP 2026-04-02 00:53:30 +08:00
imbytecat 087796038e chore(routes): 重新生成路由树以反映健康检查端点删除 2026-04-02 00:49:33 +08:00
imbytecat ca4e25827f chore(api): 删除未使用的健康检查端点 2026-04-02 00:48:41 +08:00
imbytecat 7700ba4520 docs: 修正 AGENTS.md 与代码库的 12 处不一致 2026-04-02 00:37:44 +08:00
imbytecat c67e773086 refactor: 抽取 UI 组件、改进错误页面、统一导入路径并简化数据库接口 2026-04-02 00:13:43 +08:00
imbytecat 4ec4576fc5 chore: remove Node.js from mise.toml (pure Bun project) 2026-04-01 23:28:35 +08:00
imbytecat 22363279c8 docs: 精简 AGENTS.md 文档结构并优化内容呈现 2026-04-01 23:24:23 +08:00
imbytecat b38d475b6f chore(deps): 升级 @orpc/* 至 1.13.13, @tanstack/react-query 至 5.96.1, @biomejs/biome 至 2.4.10 2026-04-01 23:18:42 +08:00
imbytecat 486cb7b129 chore(vscode): add promptToUseWorkspaceVersion for TypeScript SDK 2026-04-01 20:56:10 +08:00
imbytecat c894631c64 chore(db): 提交初始数据库迁移文件 2026-04-01 19:54:49 +08:00
imbytecat ce3684fdc0 fix: use relative import in drizzle.config.ts for path alias compatibility 2026-04-01 19:48:43 +08:00
imbytecat cd7b65fda4 refactor: flatten monorepo into standalone project 2026-04-01 19:43:21 +08:00
82 changed files with 749 additions and 2623 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.output/
.tanstack/
out/
.git/
.env
.env.*
*.md
*.tsbuildinfo
*.bun-build
.vscode/
-3
View File
@@ -9,9 +9,6 @@
# Bun build
*.bun-build
# Turborepo
.turbo/
### Node ###
# Logs
+1
View File
@@ -44,6 +44,7 @@
"**/routeTree.gen.ts": true
},
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"search.exclude": {
"**/routeTree.gen.ts": true
}
+122 -173
View File
@@ -1,218 +1,167 @@
# AGENTS.md - AI Coding Agent Guidelines
Guidelines for AI agents working in this Bun monorepo.
# AGENTS.md AI Coding Agent Guidelines
## 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. Do NOT use Node.js / npm / yarn / pnpm. Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands.**
- **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`)
- `apps/desktop` - Electron desktop shell, sidecar server pattern (see `apps/desktop/AGENTS.md`)
- **Packages**: `packages/tsconfig` (shared TS configs)
- **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
## Build / Lint / Test Commands
## Commands
### Root Commands (via Turbo)
```bash
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 dist # Package desktop distributable (current platform)
bun run dist:linux # Package desktop for Linux (x64 + arm64)
bun run dist:mac # Package desktop for macOS (arm64 + x64)
bun run dist:win # Package desktop for Windows x64
bun run fix # Lint + format (Biome auto-fix)
bun run typecheck # TypeScript check across monorepo
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)
docker compose up --build # Build image & start app + postgres
docker compose up -d # Start in background (detached)
docker compose down # Stop and remove containers
```
### 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
## Code Style
# Database (Drizzle)
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
```
### Desktop App (`apps/desktop`)
```bash
bun run dev # electron-vite dev mode (requires server dev running)
bun run build # electron-vite build (main + preload)
bun run dist # Build + package for current platform
bun run dist:linux # Build + package for Linux (x64 + arm64)
bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
bun run dist:mac # Build + package for macOS (arm64 + x64)
bun run dist:mac:arm64 # Build + package for macOS arm64
bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
```
### Testing
No test framework configured yet. When adding tests:
```bash
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
- **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 { ... }`)
**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:
```typescript
// 1) External packages (alphabetical, types interleaved)
import { useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
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 Strictness
- `strict: true`, `noUncheckedIndexedAccess: true`, `noImplicitOverride: true`, `verbatimModuleSyntax: true`
- Use `@/*` path aliases (maps to `src/*`)
**TypeScript:** `strict: true`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`, `erasableSyntaxOnly`. Use `@/*` path aliases (→ `src/*`). For root-level files outside `src/`, use `@/../file` (e.g., `@/../package.json`).
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Files (utils) | kebab-case | `auth-utils.ts` |
| Files (components) | PascalCase | `UserProfile.tsx` |
| Components | PascalCase arrow | `const Button = () => {}` |
| Functions | camelCase | `getUserById` |
| Constants | UPPER_SNAKE | `MAX_RETRIES` |
| Types/Interfaces | PascalCase | `UserProfile` |
**Naming:** Files (utils): `kebab-case.ts` | Files (components): `PascalCase.tsx` | Components: `const Button = () => {}` | Functions: `camelCase` | Constants: `UPPER_SNAKE` | Types: `PascalCase`
### React Patterns
- Components: arrow functions (enforced by Biome)
- Routes: TanStack Router file conventions (`export const Route = createFileRoute(...)`)
- Data fetching: `useSuspenseQuery(orpc.feature.list.queryOptions())`
**React:** Arrow function components (Biome enforced via `useArrowFunction: "error"`). Routes: `export const Route = createFileRoute(...)`. Data: `useSuspenseQuery(orpc.feature.list.queryOptions())`
### Error Handling
- Use `try-catch` for async operations; throw descriptive errors
- ORPC: Use `ORPCError` with proper codes (`NOT_FOUND`, `INPUT_VALIDATION_FAILED`)
- Never use empty catch blocks
**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).
## Database (Drizzle ORM v1 beta + postgres-js)
## ORPC Pattern
- **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 }`)
**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**:
```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()),
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:
```typescript
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. Client**`useSuspenseQuery(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`:
```typescript
const handler = new RPCHandler(router, {
interceptors: [onError(logError)],
clientInterceptors: [onError(handleValidationError)],
})
```
**6. SSR**`createIsomorphicFn()` in `src/client/orpc.ts` switches between `createRouterClient` (server) and `RPCLink` (client). Routes use `ensureQueryData` in loaders for SSR prefetch.
**7. OpenAPI**`OpenAPIHandler` + `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
- Use `@t3-oss/env-core` with Zod validation in `src/env.ts`
- Validated via `@t3-oss/env-core` + Zod in `src/env.ts`
- Server vars: no prefix | Client vars: `VITE_` prefix required
- Never commit `.env` files
## Dependency Management
- All versions centralized in root `package.json` `catalog` field
- Workspace packages use `"catalog:"` — never hardcode versions
- Internal packages use `"workspace:*"` references
## 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** — 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:**
- Run `bun run fix` before committing
- 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
**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:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- Commit `.env` files
- Use empty catch blocks `catch(e) {}`
- Hardcode dependency versions in workspace packages
- Leave docs out of sync 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
## Docker
- **Dockerfile**: Multi-stage — `oven/bun:1` (build + compile) → `gcr.io/distroless/cc-debian13:nonroot` (runtime)
- **Runtime**: Bun-compiled standalone binary (glibc-linked, requires `cc` distroless variant)
- **Compose**: `app` + `db` with health check dependency
- **Migrations**: Nitro plugin runs `migrate()` at server startup (see `src/server/plugins/migrate.ts`)
- **Env**: `DATABASE_URL` set in `compose.yaml` pointing to the `db` service
## 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
1. Make changes → 2. `bun run fix` → 3. `bun run typecheck` → 4. `bun run dev` (test) → 5. Commit
## Directory Structure
```
.
├── apps/
│ ├── server/ # TanStack Start fullstack app
│ │ ├── src/
├── client/ # ORPC client + TanStack Query utils
│ │ ├── components/
├── routes/ # File-based routing
│ │ ── server/ # API layer + database
│ ├── api/ # ORPC contracts, routers, middlewares
│ └── db/ # Drizzle schema
│ │ └── AGENTS.md
── desktop/ # Electron desktop shell
├── src/
│ ├── main/
│ │ └── index.ts # Main process entry
│ └── preload/
│ └── index.ts # Preload script
├── electron.vite.config.ts
── electron-builder.yml # Packaging config
└── AGENTS.md
├── packages/
└── tsconfig/ # Shared TS configs
├── biome.json # Linting/formatting config
├── turbo.json # Turbo task orchestration
── package.json # Workspace root + dependency catalog
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")
```
## See Also
- `apps/server/AGENTS.md` - Detailed TanStack Start / ORPC patterns
- `apps/desktop/AGENTS.md` - Electron desktop development guide
+22
View File
@@ -0,0 +1,22 @@
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build \
&& bun run compile \
&& mv out/server-* out/server
FROM gcr.io/distroless/cc-debian13:nonroot
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/out/server ./server
COPY --from=build --chown=nonroot:nonroot /app/drizzle ./drizzle
ENV HOST=0.0.0.0
EXPOSE 3000
CMD ["./server"]
-3
View File
@@ -1,3 +0,0 @@
# electron-vite build output
out/
dist/
-95
View File
@@ -1,95 +0,0 @@
# AGENTS.md - Desktop App Guidelines
Thin Electron shell hosting the fullstack server app.
## Tech Stack
> **⚠️ This project uses Bun as the package manager. Runtime is Electron (Node.js). Always use `bun run <script>` (not `bun <script>`) to avoid conflicts with Bun built-in subcommands. Never use `npm`, `npx`, `yarn`, or `pnpm`.**
- **Type**: Electron desktop shell
- **Design**: Server-driven desktop (thin native window hosting web app)
- **Runtime**: Electron (Main/Renderer) + Sidecar server binary (Bun-compiled)
- **Build Tool**: electron-vite (Vite-based, handles main + preload builds)
- **Packager**: electron-builder (installers, signing, auto-update)
- **Orchestration**: Turborepo
## Architecture
- **Server-driven design**: The desktop app is a "thin" native shell. It does not contain UI or business logic; it opens a BrowserWindow pointing to the `apps/server` TanStack Start application.
- **Dev mode**: Opens a BrowserWindow pointing to `localhost:3000`. Requires `apps/server` to be running separately (Turbo handles this).
- **Production mode**: Spawns a compiled server binary (from `resources/`) as a sidecar process, waits for readiness, then loads its URL.
## Commands
```bash
bun run dev # electron-vite dev (requires server dev running)
bun run build # electron-vite build (main + preload)
bun run dist # Build + package for current platform
bun run dist:linux # Build + package for Linux (x64 + arm64)
bun run dist:linux:x64 # Build + package for Linux x64
bun run dist:linux:arm64 # Build + package for Linux arm64
bun run dist:mac # Build + package for macOS (arm64 + x64)
bun run dist:mac:arm64 # Build + package for macOS arm64
bun run dist:mac:x64 # Build + package for macOS x64
bun run dist:win # Build + package for Windows x64
bun run fix # Biome auto-fix
bun run typecheck # TypeScript check
```
## Directory Structure
```
.
├── src/
│ ├── main/
│ │ └── index.ts # Main process (server lifecycle + BrowserWindow)
│ └── preload/
│ └── index.ts # Preload script (security isolation)
├── resources/ # Sidecar binaries (gitignored, copied from server build)
├── out/ # electron-vite build output (gitignored)
├── electron.vite.config.ts
├── electron-builder.yml # Packaging configuration
├── package.json
├── turbo.json
└── AGENTS.md
```
## Development Workflow
1. **Start server**: `bun run dev` in `apps/server` (or use root `bun run dev` via Turbo).
2. **Start desktop**: `bun run dev` in `apps/desktop`.
3. **Connection**: Main process polls `localhost:3000` until responsive, then opens BrowserWindow.
## Production Build Workflow
From monorepo root, run `bun run dist` to execute the full pipeline automatically (via Turbo task dependencies):
1. **Build server**: `apps/server``vite build``.output/`
2. **Compile server**: `apps/server``bun compile.ts --target ...``out/server-{os}-{arch}`
3. **Package desktop**: `apps/desktop``electron-vite build` + `electron-builder` → distributable
The `electron-builder.yml` `extraResources` config reads binaries directly from `../server/out/`, no manual copy needed.
To build for a specific platform explicitly, use `bun run dist:linux` / `bun run dist:mac` / `bun run dist:win` in `apps/desktop`.
For single-arch output, use `bun run dist:linux:x64`, `bun run dist:linux:arm64`, `bun run dist:mac:x64`, or `bun run dist:mac:arm64`.
## 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.
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.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## Critical Rules
**DO:**
- Use arrow functions for all utility functions.
- Keep the desktop app as a thin shell — no UI or business logic.
- Use `catalog:` for all dependency versions in `package.json`.
**DON'T:**
- Use `npm`, `npx`, `yarn`, or `pnpm`. Use `bun` for package management.
- Include UI components or business logic in the desktop app.
- Use `as any` or `@ts-ignore`.
- Leave docs out of sync with code changes.
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

-48
View File
@@ -1,48 +0,0 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/refs/heads/master/packages/app-builder-lib/scheme.json
appId: com.furtherverse.desktop
productName: Furtherverse
executableName: furtherverse
npmRebuild: false
asarUnpack:
- resources/**
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.env,.env.*,bun.lock}"
- "!{tsconfig.json,tsconfig.node.json}"
- "!{AGENTS.md,README.md,CHANGELOG.md}"
# macOS
mac:
target:
- dmg
category: public.app-category.productivity
extraResources:
- from: ../server/out/server-darwin-${arch}
to: server
dmg:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
# Windows
win:
target:
- portable
extraResources:
- from: ../server/out/server-windows-${arch}.exe
to: server.exe
portable:
artifactName: ${productName}-${version}-${os}-${arch}-Portable.${ext}
# Linux
linux:
target:
- AppImage
category: Utility
extraResources:
- from: ../server/out/server-linux-${arch}
to: server
appImage:
artifactName: ${productName}-${version}-${os}-${arch}.${ext}
-11
View File
@@ -1,11 +0,0 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'electron-vite'
export default defineConfig({
main: {},
preload: {},
renderer: {
plugins: [react(), tailwindcss()],
},
})
-37
View File
@@ -1,37 +0,0 @@
{
"name": "@furtherverse/desktop",
"version": "1.0.0",
"private": true,
"main": "out/main/index.js",
"scripts": {
"build": "electron-vite build",
"dev": "electron-vite dev --watch",
"dist": "electron-builder",
"dist:linux": "bun run dist:linux:x64 && bun run dist:linux:arm64",
"dist:linux:arm64": "electron-builder --linux --arm64",
"dist:linux:x64": "electron-builder --linux --x64",
"dist:mac": "bun run dist:mac:arm64 && bun run dist:mac:x64",
"dist:mac:arm64": "electron-builder --mac --arm64",
"dist:mac:x64": "electron-builder --mac --x64",
"dist:win": "electron-builder --win --x64",
"fix": "biome check --write",
"typecheck": "tsc -b"
},
"dependencies": {
"motion": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tree-kill": "catalog:"
},
"devDependencies": {
"@furtherverse/tsconfig": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@types/node": "catalog:",
"@vitejs/plugin-react": "catalog:",
"electron": "catalog:",
"electron-builder": "catalog:",
"electron-vite": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}
View File
-198
View File
@@ -1,198 +0,0 @@
import { join } from 'node:path'
import { app, BrowserWindow, dialog, session, shell } from 'electron'
import { createSidecarRuntime } from './sidecar'
const DEV_SERVER_URL = 'http://localhost:3000'
const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:'])
let mainWindow: BrowserWindow | null = null
let windowCreationPromise: Promise<void> | null = null
let isQuitting = false
const showErrorAndQuit = (title: string, detail: string) => {
if (isQuitting) {
return
}
dialog.showErrorBox(title, detail)
app.quit()
}
const sidecar = createSidecarRuntime({
devServerUrl: DEV_SERVER_URL,
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
isQuitting: () => isQuitting,
onUnexpectedStop: (detail) => {
showErrorAndQuit('Service Stopped', detail)
},
})
const toErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const canOpenExternally = (url: string): boolean => {
try {
const parsed = new URL(url)
return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol)
} catch {
return false
}
}
const loadSplash = async (windowRef: BrowserWindow) => {
if (process.env.ELECTRON_RENDERER_URL) {
await windowRef.loadURL(process.env.ELECTRON_RENDERER_URL)
return
}
await windowRef.loadFile(join(__dirname, '../renderer/index.html'))
}
const createWindow = async () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.focus()
return
}
const windowRef = new BrowserWindow({
width: 1200,
height: 800,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
})
mainWindow = windowRef
windowRef.webContents.setWindowOpenHandler(({ url }) => {
if (!canOpenExternally(url)) {
if (!app.isPackaged) {
console.warn(`Blocked external URL: ${url}`)
}
return { action: 'deny' }
}
void shell.openExternal(url)
return { action: 'deny' }
})
windowRef.webContents.on('will-navigate', (event, url) => {
const allowed = [DEV_SERVER_URL, sidecar.lastResolvedUrl].filter((v): v is string => v != null)
const isAllowed = allowed.some((origin) => url.startsWith(origin))
if (!isAllowed) {
event.preventDefault()
if (canOpenExternally(url)) {
void shell.openExternal(url)
} else if (!app.isPackaged) {
console.warn(`Blocked navigation to: ${url}`)
}
}
})
windowRef.on('closed', () => {
if (mainWindow === windowRef) {
mainWindow = null
}
})
try {
await loadSplash(windowRef)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
if (!windowRef.isDestroyed()) {
windowRef.show()
}
const targetUrl = await sidecar.resolveUrl()
if (isQuitting || windowRef.isDestroyed()) {
return
}
try {
await windowRef.loadURL(targetUrl)
} catch (error) {
if (mainWindow === windowRef) {
mainWindow = null
}
if (!windowRef.isDestroyed()) {
windowRef.destroy()
}
throw error
}
}
const ensureWindow = async () => {
if (windowCreationPromise) {
return windowCreationPromise
}
windowCreationPromise = createWindow().finally(() => {
windowCreationPromise = null
})
return windowCreationPromise
}
const beginQuit = () => {
isQuitting = true
sidecar.stop()
}
const handleWindowCreationError = (error: unknown, context: string) => {
console.error(`${context}:`, error)
showErrorAndQuit(
"App Couldn't Start",
app.isPackaged
? 'A required component failed to start. Please reinstall the app.'
: `${context}: ${toErrorMessage(error)}`,
)
}
app
.whenReady()
.then(() => {
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
return ensureWindow()
})
.catch((error) => {
handleWindowCreationError(error, 'Failed to create window')
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (isQuitting || BrowserWindow.getAllWindows().length > 0) {
return
}
ensureWindow().catch((error) => {
handleWindowCreationError(error, 'Failed to re-create window')
})
})
app.on('before-quit', beginQuit)
-256
View File
@@ -1,256 +0,0 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createServer } from 'node:net'
import { join } from 'node:path'
import killProcessTree from 'tree-kill'
const SERVER_HOST = '127.0.0.1'
const SERVER_READY_TIMEOUT_MS = 10_000
const SERVER_REQUEST_TIMEOUT_MS = 1_500
const SERVER_POLL_INTERVAL_MS = 250
const SERVER_PROBE_PATHS = ['/api/health', '/']
type SidecarState = {
process: ChildProcess | null
startup: Promise<string> | null
url: string | null
}
type SidecarRuntimeOptions = {
devServerUrl: string
isPackaged: boolean
resourcesPath: string
isQuitting: () => boolean
onUnexpectedStop: (detail: string) => void
}
type SidecarRuntime = {
resolveUrl: () => Promise<string>
stop: () => void
lastResolvedUrl: string | null
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const isProcessAlive = (processToCheck: ChildProcess | null): processToCheck is ChildProcess => {
if (!processToCheck || !processToCheck.pid) {
return false
}
return processToCheck.exitCode === null && !processToCheck.killed
}
const getAvailablePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const addr = server.address()
if (!addr || typeof addr === 'string') {
server.close()
reject(new Error('Failed to resolve port'))
return
}
server.close(() => resolve(addr.port))
})
server.on('error', reject)
})
const isServerReady = async (url: string): Promise<boolean> => {
for (const probePath of SERVER_PROBE_PATHS) {
try {
const probeUrl = new URL(probePath, `${url}/`)
const response = await fetch(probeUrl, {
method: 'GET',
cache: 'no-store',
signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS),
})
if (response.status < 500) {
if (probePath === '/api/health' && response.status === 404) {
continue
}
return true
}
} catch {
// Expected: probe request fails while server is still starting up
}
}
return false
}
const waitForServer = async (url: string, isQuitting: () => boolean, processRef?: ChildProcess): Promise<boolean> => {
const start = Date.now()
while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) {
if (processRef && processRef.exitCode !== null) {
return false
}
if (await isServerReady(url)) {
return true
}
await sleep(SERVER_POLL_INTERVAL_MS)
}
return false
}
const resolveBinaryPath = (resourcesPath: string): string => {
const binaryName = process.platform === 'win32' ? 'server.exe' : 'server'
return join(resourcesPath, binaryName)
}
const formatUnexpectedStopMessage = (
isPackaged: boolean,
code: number | null,
signal: NodeJS.Signals | null,
): string => {
if (isPackaged) {
return 'The background service stopped unexpectedly. Please restart the app.'
}
return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).`
}
export const createSidecarRuntime = (options: SidecarRuntimeOptions): SidecarRuntime => {
const state: SidecarState = {
process: null,
startup: null,
url: null,
}
const resetState = (processRef?: ChildProcess) => {
if (processRef && state.process !== processRef) {
return
}
state.process = null
state.url = null
}
const stop = () => {
const runningServer = state.process
resetState()
if (!runningServer?.pid || runningServer.exitCode !== null) {
return
}
killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => {
if (error) {
console.error('Failed to stop server process:', error)
}
})
}
const attachLifecycleHandlers = (processRef: ChildProcess) => {
processRef.on('error', (error) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop('The background service crashed unexpectedly. Please restart the app.')
return
}
console.error('Failed to start server process:', error)
})
processRef.on('exit', (code, signal) => {
if (state.process !== processRef) {
return
}
const hadReadyServer = state.url !== null
resetState(processRef)
if (!options.isQuitting() && hadReadyServer) {
options.onUnexpectedStop(formatUnexpectedStopMessage(options.isPackaged, code, signal))
}
})
}
const startPackagedServer = async (): Promise<string> => {
if (state.url && isProcessAlive(state.process)) {
return state.url
}
if (state.startup) {
return state.startup
}
state.startup = (async () => {
const binaryPath = resolveBinaryPath(options.resourcesPath)
if (!existsSync(binaryPath)) {
throw new Error(`Sidecar server binary is missing: ${binaryPath}`)
}
if (options.isQuitting()) {
throw new Error('Application is shutting down.')
}
const port = await getAvailablePort()
const nextServerUrl = `http://${SERVER_HOST}:${port}`
const processRef = spawn(binaryPath, [], {
env: {
...process.env,
HOST: SERVER_HOST,
PORT: String(port),
},
stdio: 'ignore',
windowsHide: true,
})
processRef.unref()
state.process = processRef
attachLifecycleHandlers(processRef)
const ready = await waitForServer(nextServerUrl, options.isQuitting, processRef)
if (ready && isProcessAlive(processRef)) {
state.url = nextServerUrl
return nextServerUrl
}
const failureReason =
processRef.exitCode !== null
? `The service exited early (code ${processRef.exitCode}).`
: `The service did not respond at ${nextServerUrl} within 10 seconds.`
stop()
throw new Error(failureReason)
})().finally(() => {
state.startup = null
})
return state.startup
}
const resolveUrl = async (): Promise<string> => {
if (options.isPackaged) {
return startPackagedServer()
}
const ready = await waitForServer(options.devServerUrl, options.isQuitting)
if (!ready) {
throw new Error('Dev server not responding. Run `bun dev` in apps/server first.')
}
state.url = options.devServerUrl
return options.devServerUrl
}
return {
resolveUrl,
stop,
get lastResolvedUrl() {
return state.url
},
}
}
-1
View File
@@ -1 +0,0 @@
export {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

@@ -1,33 +0,0 @@
import { motion } from 'motion/react'
import logoImage from '../assets/logo.png'
export const SplashApp = () => {
return (
<main className="m-0 flex h-screen w-screen cursor-default select-none items-center justify-center overflow-hidden bg-white font-sans antialiased">
<motion.section
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center gap-8"
initial={{ opacity: 0, y: 4 }}
transition={{
duration: 1,
ease: [0.16, 1, 0.3, 1],
}}
>
<img alt="Logo" className="h-20 w-auto object-contain" draggable={false} src={logoImage} />
<div className="relative h-[4px] w-36 overflow-hidden rounded-full bg-zinc-100">
<motion.div
animate={{ x: '100%' }}
className="h-full w-full bg-zinc-800"
initial={{ x: '-100%' }}
transition={{
duration: 2,
ease: [0.4, 0, 0.2, 1],
repeat: Infinity,
}}
/>
</div>
</motion.section>
</main>
)
}
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furtherverse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { SplashApp } from './components/SplashApp'
import './styles.css'
// biome-ignore lint/style/noNonNullAssertion: 一定存在
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SplashApp />
</StrictMode>,
)
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@furtherverse/tsconfig/react.json",
"compilerOptions": {
"composite": true,
"types": ["vite/client"]
},
"include": ["src/renderer/**/*"]
}
-11
View File
@@ -1,11 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@furtherverse/tsconfig/base.json",
"compilerOptions": {
"composite": true,
"types": ["node"]
},
"include": ["src/main/**/*", "src/preload/**/*", "electron.vite.config.ts"]
}
-41
View File
@@ -1,41 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"outputs": ["out/**"]
},
"dist": {
"dependsOn": ["build", "@furtherverse/server#compile"],
"outputs": ["dist/**"]
},
"dist:linux": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:linux:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:arm64"],
"outputs": ["dist/**"]
},
"dist:linux:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:linux:x64"],
"outputs": ["dist/**"]
},
"dist:mac": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:mac:arm64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:arm64"],
"outputs": ["dist/**"]
},
"dist:mac:x64": {
"dependsOn": ["build", "@furtherverse/server#compile:darwin:x64"],
"outputs": ["dist/**"]
},
"dist:win": {
"dependsOn": ["build", "@furtherverse/server#compile:windows:x64"],
"outputs": ["dist/**"]
}
}
}
-278
View File
@@ -1,278 +0,0 @@
# AGENTS.md - Server App Guidelines
TanStack Start fullstack web app with ORPC (contract-first RPC).
## 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`.**
- **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
- **Database**: PostgreSQL + Drizzle ORM v1 beta (`drizzle-orm/postgres-js`, RQBv2)
- **State**: TanStack Query v5
- **RPC**: ORPC (contract-first, type-safe)
- **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: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
# 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
```
## Directory Structure
```
src/
├── client/ # Client-side code
│ └── orpc.ts # ORPC client + TanStack Query utils (single entry point)
├── components/ # React components
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # Root layout
│ ├── index.tsx # Home page
│ └── api/
│ ├── $.ts # OpenAPI handler + Scalar docs
│ ├── health.ts # Health check endpoint
│ └── rpc.$.ts # ORPC RPC handler
├── server/ # Server-side code
│ ├── api/ # ORPC layer
│ │ ├── contracts/ # Input/output schemas (Zod)
│ │ ├── middlewares/ # Middleware (db provider, auth)
│ │ ├── routers/ # Handler implementations
│ │ ├── interceptors.ts # Shared error interceptors
│ │ ├── context.ts # Request context
│ │ ├── server.ts # ORPC server instance
│ │ └── types.ts # Type exports
│ └── db/
│ ├── schema/ # Drizzle table definitions
│ ├── fields.ts # Shared field builders (id, createdAt, updatedAt)
│ ├── relations.ts # Drizzle relations (defineRelations, RQBv2)
│ └── index.ts # Database instance (postgres-js driver)
├── env.ts # Environment variable validation
├── router.tsx # Router configuration
├── routeTree.gen.ts # Auto-generated (DO NOT EDIT)
└── styles.css # Tailwind entry
```
## ORPC Pattern
### 1. Define Contract (`src/server/api/contracts/feature.contract.ts`)
```typescript
import { oc } from '@orpc/contract'
import { createSelectSchema } from 'drizzle-orm/zod'
import { z } from 'zod'
import { featureTable } from '@/server/db/schema'
const selectSchema = createSelectSchema(featureTable)
export const list = oc.input(z.void()).output(z.array(selectSchema))
export const create = oc.input(insertSchema).output(selectSchema)
```
### 2. Implement Router (`src/server/api/routers/feature.router.ts`)
```typescript
import { ORPCError } from '@orpc/server'
import { db } from '../middlewares'
import { os } from '../server'
export const list = os.feature.list.use(db).handler(async ({ context }) => {
return await context.db.query.featureTable.findMany({
orderBy: { createdAt: 'desc' },
})
})
```
### 3. Register in Index Files
```typescript
// src/server/api/contracts/index.ts
import * as feature from './feature.contract'
export const contract = { feature }
// src/server/api/routers/index.ts
import * as feature from './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())
```
## 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`
### Schema Definition
```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
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 (RQBv2)
```typescript
// src/server/db/relations.ts
import { defineRelations } from 'drizzle-orm'
import * as schema from './schema'
export const relations = defineRelations(schema, (r) => ({
// Define relations here using r.one / r.many / r.through
}))
```
### DB Instance
```typescript
// src/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { relations } from '@/server/db/relations'
// In RQBv2, relations already contain schema info — no separate schema import needed
const db = drizzle({
connection: env.DATABASE_URL,
relations,
})
```
### RQBv2 Query Examples
```typescript
// Object-style orderBy (NOT callback style)
const todos = await db.query.todoTable.findMany({
orderBy: { createdAt: 'desc' },
})
// Object-style where
const todo = await db.query.todoTable.findFirst({
where: { id: someId },
})
```
## 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
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { db } from '@/server/db'
import type { ReactNode } from 'react'
```
### 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` |
### React
- Use arrow functions for components (Biome enforced)
- Use `useSuspenseQuery` for guaranteed data
## Environment Variables
```typescript
// src/env.ts - using @t3-oss/env-core
import { createEnv } from '@t3-oss/env-core'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
},
clientPrefix: 'VITE_',
client: {
VITE_API_URL: z.string().optional(),
},
})
```
## 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.
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.
3. **Forward-only migration** — When upgrading dependencies, fully adopt the new API. Don't mix old and new patterns.
## 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`
- Update `AGENTS.md` and other docs whenever code patterns change
**DON'T:**
- Use `npm`, `npx`, `node`, `yarn`, `pnpm` — always use `bun` / `bunx`
- Edit `src/routeTree.gen.ts` (auto-generated)
- Use `as any`, `@ts-ignore`, `@ts-expect-error`
- 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)
- Import `os` from `@orpc/server` in middleware — use `@/server/api/server` (the local typed instance)
- Leave docs out of sync with code changes
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": "//",
"files": {
"includes": ["**", "!**/routeTree.gen.ts"]
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}
-58
View File
@@ -1,58 +0,0 @@
{
"name": "@furtherverse/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "bunx --bun vite build",
"compile": "bun compile.ts",
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
"compile:darwin:x64": "bun compile.ts --target bun-darwin-x64",
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
"compile:linux:arm64": "bun compile.ts --target bun-linux-arm64",
"compile:linux:x64": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun run compile:windows:x64",
"compile:windows:x64": "bun compile.ts --target bun-windows-x64",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "bunx --bun vite dev",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",
"@orpc/tanstack-query": "catalog:",
"@orpc/zod": "catalog:",
"@t3-oss/env-core": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:",
"drizzle-orm": "catalog:",
"postgres": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"uuid": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@furtherverse/tsconfig": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/devtools-vite": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-router-devtools": "catalog:",
"@types/bun": "catalog:",
"@vitejs/plugin-react": "catalog:",
"drizzle-kit": "catalog:",
"nitro": "catalog:",
"tailwindcss": "catalog:",
"vite": "catalog:"
}
}
-3
View File
@@ -1,3 +0,0 @@
export function ErrorComponent() {
return <div>An unhandled error happened!</div>
}
-3
View File
@@ -1,3 +0,0 @@
export function NotFoundComponent() {
return <div>404 - Not Found</div>
}
-27
View File
@@ -1,27 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { name, version } from '@/../package.json'
const createHealthResponse = (): Response =>
Response.json(
{
status: 'ok',
service: name,
version,
timestamp: new Date().toISOString(),
},
{
status: 200,
headers: {
'cache-control': 'no-store',
},
},
)
export const Route = createFileRoute('/api/health')({
server: {
handlers: {
GET: async () => createHealthResponse(),
HEAD: async () => new Response(null, { status: 200 }),
},
},
})
-193
View File
@@ -1,193 +0,0 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import type { ChangeEventHandler, SubmitEventHandler } from 'react'
import { useState } from 'react'
import { orpc } from '@/client/orpc'
export const Route = createFileRoute('/')({
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
})
function Todos() {
const [newTodoTitle, setNewTodoTitle] = useState('')
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (newTodoTitle.trim()) {
createMutation.mutate({ title: newTodoTitle.trim() })
setNewTodoTitle('')
}
}
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
setNewTodoTitle(e.target.value)
}
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
updateMutation.mutate({
id,
data: { completed: !currentCompleted },
})
}
const handleDeleteTodo = (id: string) => {
deleteMutation.mutate({ id })
}
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider"></div>
</div>
</div>
{/* Add Todo Form */}
<form onSubmit={handleCreateTodo} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={newTodoTitle}
onChange={handleInputChange}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending || !newTodoTitle.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
{createMutation.isPending ? '添加中' : '添加'}
</button>
</div>
</form>
{/* Progress Bar (Only visible when there are tasks) */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1"></p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => handleToggleTodo(todo.id, todo.completed)}
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed
? 'bg-indigo-500 border-indigo-500'
: 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
: 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}
-1
View File
@@ -1 +0,0 @@
@import "tailwindcss";
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@furtherverse/tsconfig/react.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
-47
View File
@@ -1,47 +0,0 @@
{
"$schema": "../../node_modules/turbo/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"env": ["NODE_ENV", "VITE_*"],
"inputs": ["src/**", "public/**", "package.json", "tsconfig.json", "vite.config.ts"],
"outputs": [".output/**"]
},
"compile": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:darwin:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:arm64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:linux:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows": {
"dependsOn": ["build"],
"outputs": ["out/**"]
},
"compile:windows:x64": {
"dependsOn": ["build"],
"outputs": ["out/**"]
}
}
}
+7 -1
View File
@@ -6,7 +6,8 @@
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": ["**", "!**/routeTree.gen.ts"]
},
"formatter": {
"enabled": true,
@@ -33,6 +34,11 @@
"arrowParentheses": "always"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"enabled": true,
"actions": {
+115 -891
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
[install]
publicHoistPattern = ["@types/*", "bun-types", "nitro*"]
+29
View File
@@ -0,0 +1,29 @@
services:
app:
build: .
depends_on:
db:
condition: service_healthy
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/postgres
db:
image: postgres:18-alpine
# ports:
# - "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
healthcheck:
test: [ "CMD", "pg_isready", "-U", "postgres" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
@@ -1,5 +1,5 @@
import { defineConfig } from 'drizzle-kit'
import { env } from '@/env'
import { env } from './src/env'
export default defineConfig({
out: './drizzle',
@@ -0,0 +1,7 @@
CREATE TABLE "todo" (
"id" uuid PRIMARY KEY,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"title" text NOT NULL,
"completed" boolean DEFAULT false NOT NULL
);
@@ -0,0 +1,88 @@
{
"version": "8",
"dialect": "postgres",
"id": "91ea16cc-3353-493c-bc2c-4fc9dea2fce7",
"prevIds": ["00000000-0000-0000-0000-000000000000"],
"ddl": [
{
"isRlsEnabled": false,
"name": "todo",
"entityType": "tables",
"schema": "public"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "id",
"entityType": "columns",
"schema": "public",
"table": "todo"
},
{
"type": "timestamp with time zone",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "now()",
"generated": null,
"identity": null,
"name": "created_at",
"entityType": "columns",
"schema": "public",
"table": "todo"
},
{
"type": "timestamp with time zone",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "now()",
"generated": null,
"identity": null,
"name": "updated_at",
"entityType": "columns",
"schema": "public",
"table": "todo"
},
{
"type": "text",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "title",
"entityType": "columns",
"schema": "public",
"table": "todo"
},
{
"type": "boolean",
"typeSchema": null,
"notNull": true,
"dimensions": 0,
"default": "false",
"generated": null,
"identity": null,
"name": "completed",
"entityType": "columns",
"schema": "public",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "todo_pkey",
"schema": "public",
"table": "todo",
"entityType": "pks"
}
],
"renames": []
}
-1
View File
@@ -1,3 +1,2 @@
[tools]
bun = "1"
node = "24"
-4
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"shadcn": {
"type": "local",
"command": ["bunx", "--bun", "shadcn", "mcp"]
},
"tanstack": {
"type": "remote",
"url": "https://tanstack.com/api/mcp"
+42 -51
View File
@@ -1,68 +1,59 @@
{
"name": "@furtherverse/monorepo",
"name": "fullstack-starter",
"version": "1.0.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "turbo run build",
"compile": "turbo run compile",
"compile:darwin": "turbo run compile:darwin",
"compile:linux": "turbo run compile:linux",
"compile:windows": "turbo run compile:windows",
"dev": "turbo run dev",
"dist": "turbo run dist",
"dist:linux": "turbo run dist:linux",
"dist:mac": "turbo run dist:mac",
"dist:win": "turbo run dist:win",
"fix": "turbo run fix",
"typecheck": "turbo run typecheck"
"build": "bunx --bun vite build",
"compile": "bun compile.ts",
"compile:darwin": "bun run compile:darwin:arm64 && bun run compile:darwin:x64",
"compile:darwin:arm64": "bun compile.ts --target bun-darwin-arm64",
"compile:darwin:x64": "bun compile.ts --target bun-darwin-x64",
"compile:linux": "bun run compile:linux:x64 && bun run compile:linux:arm64",
"compile:linux:arm64": "bun compile.ts --target bun-linux-arm64",
"compile:linux:x64": "bun compile.ts --target bun-linux-x64",
"compile:windows": "bun run compile:windows:x64",
"compile:windows:x64": "bun compile.ts --target bun-windows-x64",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "bunx --bun vite dev",
"fix": "biome check --write",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@biomejs/biome": "^2.4.9",
"turbo": "^2.8.20",
"typescript": "^6.0.2"
},
"catalog": {
"@orpc/client": "^1.13.11",
"@orpc/contract": "^1.13.11",
"@orpc/openapi": "^1.13.11",
"@orpc/server": "^1.13.11",
"@orpc/tanstack-query": "^1.13.11",
"@orpc/zod": "^1.13.11",
"dependencies": {
"@orpc/client": "^1.13.13",
"@orpc/contract": "^1.13.13",
"@orpc/openapi": "^1.13.13",
"@orpc/server": "^1.13.13",
"@orpc/tanstack-query": "^1.13.13",
"@orpc/zod": "^1.13.13",
"@t3-oss/env-core": "^0.13.11",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-router": "^1.168.10",
"@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.167.6",
"@types/bun": "^1.3.11",
"@types/node": "^24.12.0",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"@tanstack/react-start": "^1.167.16",
"drizzle-orm": "1.0.0-beta.15-859cf75",
"electron": "^34.0.0",
"electron-builder": "^26.8.1",
"electron-vite": "^5.0.0",
"motion": "^12.38.0",
"nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca",
"postgres": "^3.4.8",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2",
"tree-kill": "^1.2.2",
"uuid": "^13.0.0",
"vite": "^8.0.2",
"zod": "^4.3.6"
},
"overrides": {
"@types/node": "catalog:"
"devDependencies": {
"@biomejs/biome": "^2.4.10",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/devtools-vite": "^0.6.0",
"@tanstack/react-devtools": "^0.10.1",
"@tanstack/react-query-devtools": "^5.96.1",
"@tanstack/react-router-devtools": "^1.166.11",
"@types/bun": "^1.3.11",
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "1.0.0-beta.15-859cf75",
"nitro": "npm:nitro-nightly@3.0.1-20260324-103046-9ce219ca",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3"
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Bun",
"extends": "./base.json",
"compilerOptions": {
"types": ["bun-types"]
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@furtherverse/tsconfig",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
"./base.json": "./base.json",
"./bun.json": "./bun.json",
"./react.json": "./react.json"
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
export const ErrorComponent = ({ error, reset }: { error: Error; reset: () => void }) => {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100">
<svg
className="w-8 h-8 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 mt-2">{error.message}</p>
</div>
<div className="flex items-center justify-center gap-4">
<button
type="button"
onClick={reset}
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-colors"
>
</button>
<a href="/" className="px-6 py-2.5 text-slate-600 hover:text-slate-900 font-medium transition-colors">
</a>
</div>
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Link } from '@tanstack/react-router'
export const NotFoundComponent = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100">
<span className="text-3xl font-bold text-slate-400">404</span>
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900"></h1>
<p className="text-slate-500 mt-2">访</p>
</div>
<Link
to="/"
className="inline-block px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-colors"
>
</Link>
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import type { SubmitEventHandler } from 'react'
import { useState } from 'react'
interface TodoFormProps {
onSubmit: (title: string) => void
isPending: boolean
}
export const TodoForm = ({ onSubmit, isPending }: TodoFormProps) => {
const [title, setTitle] = useState('')
const handleSubmit: SubmitEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
if (title.trim()) {
onSubmit(title.trim())
setTitle('')
}
}
return (
<form onSubmit={handleSubmit} className="relative group z-10">
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="添加新任务..."
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
disabled={isPending}
/>
<button
type="submit"
disabled={isPending || !title.trim()}
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
>
{isPending ? '添加中' : '添加'}
</button>
</div>
</form>
)
}
+77
View File
@@ -0,0 +1,77 @@
import type { RouterOutputs } from '@/server/api/types'
type Todo = RouterOutputs['todo']['list'][number]
interface TodoItemProps {
todo: Todo
onToggle: (id: string, completed: boolean) => void
onDelete: (id: string) => void
}
export const TodoItem = ({ todo, onToggle, onDelete }: TodoItemProps) => {
return (
<div
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
todo.completed ? 'bg-slate-50/50' : ''
}`}
>
<button
type="button"
onClick={() => onToggle(todo.id, todo.completed)}
className={`shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
todo.completed ? 'bg-indigo-500 border-indigo-500' : 'border-slate-300 hover:border-indigo-500 bg-white'
}`}
>
{todo.completed && (
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className={`text-lg transition-all duration-200 truncate ${
todo.completed ? 'text-slate-400 line-through decoration-slate-300 decoration-2' : 'text-slate-700'
}`}
>
{todo.title}
</p>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-linear-to-l from-white via-white to-transparent sm:static sm:bg-none">
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
</span>
<button
type="button"
onClick={() => onDelete(todo.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
title="删除"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
)
}
@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as ApiSplatRouteImport } from './routes/api/$'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
@@ -19,11 +18,6 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
@@ -38,34 +32,30 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/$': typeof ApiSplatRoute
'/api/health': typeof ApiHealthRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
fullPaths: '/' | '/api/$' | '/api/rpc/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
to: '/' | '/api/$' | '/api/rpc/$'
id: '__root__' | '/' | '/api/$' | '/api/rpc/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiSplatRoute: typeof ApiSplatRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
}
@@ -78,13 +68,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': {
id: '/api/$'
path: '/api/$'
@@ -105,7 +88,6 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiSplatRoute: ApiSplatRoute,
ApiHealthRoute: ApiHealthRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
}
export const routeTree = rootRouteImport
@@ -34,8 +34,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
],
}),
shellComponent: RootDocument,
errorComponent: () => <ErrorComponent />,
notFoundComponent: () => <NotFoundComponent />,
errorComponent: ErrorComponent,
notFoundComponent: NotFoundComponent,
})
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
+87
View File
@@ -0,0 +1,87 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { orpc } from '@/client/orpc'
import { TodoForm } from '@/components/TodoForm'
import { TodoItem } from '@/components/TodoItem'
export const Route = createFileRoute('/')({
component: Todos,
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
},
})
function Todos() {
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
const createMutation = useMutation(orpc.todo.create.mutationOptions())
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
const todos = listQuery.data
const completedCount = todos.filter((todo) => todo.completed).length
const totalCount = todos.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
return (
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
<div className="max-w-2xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight"></h1>
<p className="text-slate-500 mt-1"></p>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-900">
{completedCount}
<span className="text-slate-400 text-lg">/{totalCount}</span>
</div>
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider"></div>
</div>
</div>
<TodoForm onSubmit={(title) => createMutation.mutate({ title })} isPending={createMutation.isPending} />
{/* Progress Bar */}
{totalCount > 0 && (
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* Todo List */}
<div className="space-y-3">
{todos.length === 0 ? (
<div className="py-20 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<svg
className="w-8 h-8 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<p className="text-slate-500 text-lg font-medium"></p>
<p className="text-slate-400 text-sm mt-1"></p>
</div>
) : (
todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={(id, completed) => updateMutation.mutate({ id, data: { completed: !completed } })}
onDelete={(id) => deleteMutation.mutate({ id })}
/>
))
)}
</div>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import { os } from '../server'
import { os } from '@/server/api/server'
import * as todo from './todo.router'
export const router = os.router({
@@ -1,8 +1,8 @@
import { ORPCError } from '@orpc/server'
import { eq } from 'drizzle-orm'
import { db } from '@/server/api/middlewares'
import { os } from '@/server/api/server'
import { todoTable } from '@/server/db/schema'
import { db } from '../middlewares'
import { os } from '../server'
export const list = os.todo.list.use(db).handler(async ({ context }) => {
const todos = await context.db.query.todoTable.findMany({
@@ -13,11 +13,7 @@ export type DB = ReturnType<typeof createDB>
export const getDB = (() => {
let db: DB | null = null
return (singleton = true): DB => {
if (!singleton) {
return createDB()
}
return (): DB => {
db ??= createDB()
return db
}
+17
View File
@@ -0,0 +1,17 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import { env } from '@/env'
export default async () => {
if (import.meta.dev) return
const db = drizzle({ connection: { url: env.DATABASE_URL, max: 1 } })
try {
console.log('Applying migrations...')
await migrate(db, { migrationsFolder: './drizzle' })
console.log('Migrations applied successfully.')
} finally {
await db.$client.end()
}
}
@@ -1,9 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Base",
"compilerOptions": {
"target": "esnext",
"lib": ["ESNext"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "preserve",
"skipLibCheck": true,
@@ -22,7 +21,12 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"types": ["bun"]
"jsx": "react-jsx",
"types": ["bun"],
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules"]
}
-22
View File
@@ -1,22 +0,0 @@
{
"$schema": "./node_modules/turbo/schema.json",
"dangerouslyDisablePackageManagerCheck": true,
"globalDependencies": ["bun.lock"],
"tasks": {
"build": {
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
},
"fix": {
"cache": false
},
"typecheck": {
"inputs": ["package.json", "tsconfig.json", "tsconfig.*.json", "**/*.{ts,tsx,d.ts}"],
"outputs": []
}
},
"ui": "tui"
}
@@ -15,6 +15,7 @@ export default defineConfig({
nitro({
preset: 'bun',
serveStatic: 'inline',
plugins: ['./src/server/plugins/migrate.ts'],
}),
],
resolve: {