From b967deb4b175719512cc200d6c65c08ed61fb2d8 Mon Sep 17 00:00:00 2001 From: MAO Dongyang Date: Tue, 20 Jan 2026 16:56:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=87=B3=20SQLite=20=E5=B9=B6=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Postgres 数据库替换为 SQLite - 并同步添加 README 文档以优化项目初始化流程 --- .gitignore | 3 + AGENTS.md | 28 +++-- README.md | 242 +++++++++++++++++++++++++++++++++++++ bun.lock | 29 ----- drizzle.config.ts | 5 +- package.json | 4 +- scripts/init-db.ts | 22 ++++ src/db/index.ts | 78 ++++++++++-- src/db/schema/todo.ts | 34 ++++-- src/env.ts | 4 +- src/orpc/client.ts | 31 +++++ src/orpc/contracts/todo.ts | 17 +++ src/orpc/handlers/todo.ts | 24 ++++ src/orpc/middlewares/db.ts | 39 ++++-- 14 files changed, 482 insertions(+), 78 deletions(-) create mode 100644 README.md create mode 100644 scripts/init-db.ts diff --git a/.gitignore b/.gitignore index ce51d38..ba250a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ ### Custom ### +# SQLite database +data/ + # TanStack .tanstack/ diff --git a/AGENTS.md b/AGENTS.md index 11bc1ea..cb9bb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - **运行时**: Bun - **语言**: TypeScript (strict mode, ESNext) - **样式**: Tailwind CSS v4 -- **数据库**: PostgreSQL + Drizzle ORM +- **数据库**: SQLite (Bun 内置) + Drizzle ORM - **状态管理**: TanStack Query - **路由**: TanStack Router (文件路由) - **RPC**: ORPC (类型安全 RPC,契约优先) @@ -44,9 +44,10 @@ biome format --write . # 仅格式化代码 ### 数据库 ```bash +bun db:init # 初始化 SQLite 数据库 (创建表) bun db:generate # 从 schema 生成迁移文件 bun db:migrate # 执行数据库迁移 -bun db:push # 直接推送 schema 变更 (仅开发环境) +bun db:studio # 打开 Drizzle Studio 数据库管理界面 ``` ### 测试 @@ -145,20 +146,25 @@ export const Route = createFileRoute('/')({ - 在 `src/db/schema/*.ts` 定义 schema - 从 `src/db/schema/index.ts` 导出 -- 使用 `drizzle-orm/pg-core` 的 PostgreSQL 类型 -- 主键使用 `uuidv7()` (需要 PostgreSQL 扩展) +- 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型 +- 主键使用 `crypto.randomUUID()` 生成 UUID - 始终包含 `createdAt` 和 `updatedAt` 时间戳 示例: ```typescript -import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' import { sql } from 'drizzle-orm' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -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()), +export const myTable = sqliteTable('my_table', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + name: text('name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`) + .$onUpdateFn(() => new Date()), }) ``` @@ -273,5 +279,5 @@ const mutation = useMutation(orpc.myFeature.create.mutationOptions()) --- -**最后更新**: 2026-01-18 +**最后更新**: 2026-01-20 **项目版本**: 基于 package.json 依赖版本 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a8e1c9 --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# Fullstack Starter (SQLite) + +一个基于 **TanStack Start + Bun + Tauri + SQLite** 的全栈桌面应用脚手架。 + +包含一个完整的 **Todo List** 示例,展示了从前端到后端的完整数据流。 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端框架 | React 19 + TanStack Router (文件路由) | +| 状态管理 | TanStack Query | +| 样式 | Tailwind CSS v4 | +| RPC 通信 | ORPC (类型安全,契约优先) | +| 数据库 | SQLite (Bun 内置) + Drizzle ORM | +| 桌面壳 | Tauri v2 | +| 运行时 | Bun | +| 构建 | Vite + Turbo | + +## 快速开始 + +### 前置要求 + +- [Bun](https://bun.sh/) >= 1.0 +- [Rust](https://www.rust-lang.org/) (仅 Tauri 桌面应用需要) + +### 安装与运行 + +```bash +# 1. 克隆项目 +git clone +cd fullstack-starter-SQLite + +# 2. 安装依赖 +bun install + +# 3. 初始化数据库 +bun run db:init + +# 4. 启动开发服务器 +bun run dev:vite # 仅 Web (http://localhost:3000) +bun run dev # Tauri 桌面应用 + Web +``` + +### 构建 + +```bash +bun run build:vite # 构建 Web 版本 +bun run build # 构建 Tauri 桌面安装包 +``` + +## 项目结构 + +``` +├── src/ +│ ├── components/ # 可复用组件 +│ │ ├── Error.tsx # 错误边界组件 +│ │ └── NotFound.tsx # 404 页面组件 +│ │ +│ ├── db/ # 数据库层 +│ │ ├── index.ts # 数据库连接 +│ │ └── schema/ # Drizzle 表定义 +│ │ ├── index.ts # Schema 导出入口 +│ │ └── todo.ts # Todo 表定义 +│ │ +│ ├── orpc/ # RPC 层 (后端 API) +│ │ ├── contracts/ # 契约定义 (输入/输出 Schema) +│ │ │ └── todo.ts # Todo API 契约 +│ │ ├── handlers/ # 业务逻辑实现 +│ │ │ └── todo.ts # Todo CRUD 处理器 +│ │ ├── middlewares/ # 中间件 +│ │ │ └── db.ts # 数据库注入中间件 +│ │ ├── contract.ts # 契约聚合 +│ │ ├── router.ts # 路由聚合 +│ │ ├── client.ts # 同构客户端 (SSR/CSR) +│ │ ├── server.ts # 服务端实例 +│ │ └── index.ts # 导出入口 +│ │ +│ ├── routes/ # 页面路由 (文件路由) +│ │ ├── __root.tsx # 根布局 +│ │ ├── index.tsx # 首页 (Todo List) +│ │ └── api/ +│ │ └── rpc.$.ts # RPC HTTP 端点 +│ │ +│ ├── integrations/ # 第三方库集成 +│ ├── lib/ # 工具函数 +│ ├── env.ts # 环境变量验证 +│ ├── router.tsx # 路由配置 +│ └── styles.css # 全局样式 +│ +├── scripts/ +│ └── init-db.ts # 数据库初始化脚本 +│ +├── src-tauri/ # Tauri 桌面应用配置 +├── data/ # SQLite 数据库文件 (gitignore) +└── drizzle.config.ts # Drizzle 配置 +``` + +## 开发指南 + +### 添加新功能的步骤 + +以添加一个 "Note" 功能为例: + +#### 1. 定义数据库 Schema + +```typescript +// src/db/schema/note.ts +import { sql } from 'drizzle-orm' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const noteTable = sqliteTable('note', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + content: text('content').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), +}) +``` + +```typescript +// src/db/schema/index.ts +export * from './todo.ts' +export * from './note.ts' // 添加导出 +``` + +#### 2. 定义 API 契约 + +```typescript +// src/orpc/contracts/note.ts +import { oc } from '@orpc/contract' +import { z } from 'zod' + +export const note = { + list: oc.output(z.array(noteSchema)), + create: oc.input(z.object({ content: z.string() })).output(noteSchema), +} +``` + +#### 3. 实现业务逻辑 + +```typescript +// src/orpc/handlers/note.ts +import { os } from '@/orpc/server' +import { dbProvider } from '@/orpc/middlewares/db' +import { noteTable } from '@/db/schema' + +export const list = os.note.list + .use(dbProvider) + .handler(async ({ context }) => { + return context.db.select().from(noteTable) + }) + +export const create = os.note.create + .use(dbProvider) + .handler(async ({ context, input }) => { + const [note] = await context.db.insert(noteTable).values(input).returning() + return note + }) +``` + +#### 4. 注册到路由 + +```typescript +// src/orpc/contract.ts +import { note } from './contracts/note' +export const contract = { todo, note } + +// src/orpc/router.ts +import * as note from './handlers/note' +export const router = os.router({ todo, note }) +``` + +#### 5. 在页面中使用 + +```typescript +// src/routes/notes.tsx +import { orpc } from '@/orpc' +import { useSuspenseQuery, useMutation } from '@tanstack/react-query' + +const NotesPage = () => { + const { data: notes } = useSuspenseQuery(orpc.note.list.queryOptions()) + const createNote = useMutation(orpc.note.create.mutationOptions()) + + // ... +} +``` + +### 常用命令 + +| 命令 | 说明 | +|------|------| +| `bun run dev` | 启动 Tauri + Vite 开发服务器 | +| `bun run dev:vite` | 仅启动 Vite 开发服务器 | +| `bun run build` | 完整构建 (Tauri 桌面应用) | +| `bun run build:vite` | 仅构建 Web 版本 | +| `bun run db:init` | 初始化/重置数据库 | +| `bun run db:studio` | 打开 Drizzle Studio | +| `bun run typecheck` | TypeScript 类型检查 | +| `bun run fix` | 自动修复格式和 Lint 问题 | + +### 代码规范 + +- **格式化**: 使用 Biome,2 空格缩进,单引号 +- **导入**: 使用 `@/*` 路径别名 +- **组件**: 箭头函数组件 +- **命名**: 文件 kebab-case,组件 PascalCase + +## 核心概念 + +### ORPC 数据流 + +``` +前端组件 + ↓ useSuspenseQuery / useMutation +ORPC 客户端 (src/orpc/client.ts) + ↓ 自动选择 SSR 直调 / CSR HTTP +契约验证 (src/orpc/contracts/) + ↓ 输入/输出类型安全 +处理器 (src/orpc/handlers/) + ↓ 业务逻辑 +中间件 (src/orpc/middlewares/) + ↓ 注入数据库连接 +Drizzle ORM + ↓ 类型安全查询 +SQLite 数据库 +``` + +### 同构渲染 + +- **SSR**: 服务端直接调用 router,无 HTTP 开销 +- **CSR**: 客户端通过 `/api/rpc` 端点调用 + +### 数据库 + +- SQLite 文件存储在 `./data/app.db` +- 使用 WAL 模式提高并发性能 +- 单例模式管理连接 + +## 许可证 + +MIT diff --git a/bun.lock b/bun.lock index b2e32f3..7100680 100644 --- a/bun.lock +++ b/bun.lock @@ -18,15 +18,12 @@ "@tauri-apps/api": "^2.9.1", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", - "postgres": "^3.4.8", "react": "^19.2.3", "react-dom": "^19.2.3", "zod": "^4.3.5", }, "devDependencies": { "@biomejs/biome": "^2.3.11", - "@effect/platform": "^0.94.1", - "@effect/schema": "^0.75.5", "@tailwindcss/vite": "^4.1.18", "@tanstack/devtools-vite": "^0.4.1", "@tanstack/react-devtools": "^0.9.2", @@ -132,10 +129,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@effect/platform": ["@effect/platform@0.94.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.14" } }, "sha512-SlL8OMTogHmMNnFLnPAHHo3ua1yrB1LNQOVQMiZsqYu9g3216xjr0gn5WoDgCxUyOdZcseegMjWJ7dhm/2vnfg=="], - - "@effect/schema": ["@effect/schema@0.75.5", "", { "dependencies": { "fast-check": "^3.21.0" }, "peerDependencies": { "effect": "^3.9.2" } }, "sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw=="], - "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -208,18 +201,6 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], @@ -704,8 +685,6 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -812,20 +791,12 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], - - "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nf3": ["nf3@0.3.4", "", {}, "sha512-GnEgxkyJBjxbI+PxWICbQ2CaoAKeH8g7NaN8EidW+YvImlY/9HUJaGJ+1+ycEqBiZpZtIMyd/ppCXkkUw4iMrA=="], "nitro": ["nitro-nightly@3.0.1-20260115-135431-98fc91c5", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "^2.0.1-rc.8", "jiti": "^2.6.1", "nf3": "^0.3.4", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.108.0", "oxc-transform": "^0.108.0", "srvx": "^0.10.0", "undici": "^7.18.2", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.5" }, "peerDependencies": { "rolldown": ">=1.0.0-beta.0", "rollup": "^4", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-dLGCF/NjNz0dfso6NP4Eck6HSVXCT2Mu3CIpsj6dDxfnQycXVvrRLXY5/mK2qnNjfVwr3PsbDLTrqAKNWIKWMw=="], - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], diff --git a/drizzle.config.ts b/drizzle.config.ts index be0d92c..c4077d8 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,10 @@ import { defineConfig } from 'drizzle-kit' -import { env } from '@/env' export default defineConfig({ out: './drizzle', schema: './src/db/schema/index.ts', - dialect: 'postgresql', + dialect: 'sqlite', dbCredentials: { - url: env.DATABASE_URL, + url: './data/app.db', }, }) diff --git a/package.json b/package.json index 7988937..953ef7e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:tauri": "tauri build", "build:vite": "vite build", "db:generate": "drizzle-kit generate", + "db:init": "bun run scripts/init-db.ts", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", @@ -31,15 +32,12 @@ "@tauri-apps/api": "^2.9.1", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", - "postgres": "^3.4.8", "react": "^19.2.3", "react-dom": "^19.2.3", "zod": "^4.3.5" }, "devDependencies": { "@biomejs/biome": "^2.3.11", - "@effect/platform": "^0.94.1", - "@effect/schema": "^0.75.5", "@tailwindcss/vite": "^4.1.18", "@tanstack/devtools-vite": "^0.4.1", "@tanstack/react-devtools": "^0.9.2", diff --git a/scripts/init-db.ts b/scripts/init-db.ts new file mode 100644 index 0000000..07b5e2d --- /dev/null +++ b/scripts/init-db.ts @@ -0,0 +1,22 @@ +import { Database } from 'bun:sqlite' +import { mkdir } from 'node:fs/promises' + +// Ensure data directory exists +await mkdir('./data', { recursive: true }) + +const db = new Database('./data/app.db', { create: true }) +db.exec('PRAGMA journal_mode = WAL;') + +// Create todo table +db.exec(` + CREATE TABLE IF NOT EXISTS todo ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); +`) + +console.log('Database initialized successfully!') +db.close() diff --git a/src/db/index.ts b/src/db/index.ts index 4da3faa..d9f2553 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,13 +1,69 @@ -import { drizzle } from 'drizzle-orm/postgres-js' -import * as schema from '@/db/schema' -import { env } from '@/env' +/** + * 数据库连接模块 + * + * 使用 Bun 内置的 SQLite 驱动,无需额外安装原生模块。 + * 数据库文件存储在可执行文件同级的 data/app.db + */ -export function createDb() { - return drizzle({ - connection: { - url: env.DATABASE_URL, - prepare: true, - }, - schema, - }) +import { Database } from 'bun:sqlite' +import { existsSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import * as schema from '@/db/schema' + +/** + * 获取数据库路径 + * - 在打包后的 sidecar 中,使用可执行文件所在目录 + * - 在开发模式下,使用项目根目录 + */ +function getDbPath(): string { + const execPath = process.execPath + const isBundled = !execPath.includes('node') && !execPath.includes('bun') + + const baseDir = isBundled ? dirname(execPath) : process.cwd() + const dataDir = join(baseDir, 'data') + + // 确保 data 目录存在 + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }) + } + + return join(dataDir, 'app.db') } + +/** 数据库文件路径 */ +const DB_PATH = getDbPath() + +/** + * 初始化数据库表结构 + */ +function initTables(sqlite: Database) { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS todo ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `) +} + +/** + * 创建数据库连接 + * + * 启用 WAL (Write-Ahead Logging) 模式以提高并发读写性能。 + * 如果数据库文件不存在,会自动创建并初始化表结构。 + */ +export function createDb() { + const sqlite = new Database(DB_PATH, { create: true }) + sqlite.exec('PRAGMA journal_mode = WAL;') + + // 自动初始化表结构 + initTables(sqlite) + + return drizzle(sqlite, { schema }) +} + +/** 数据库实例类型 */ +export type Db = ReturnType diff --git a/src/db/schema/todo.ts b/src/db/schema/todo.ts index ce7b280..962cd33 100644 --- a/src/db/schema/todo.ts +++ b/src/db/schema/todo.ts @@ -1,15 +1,33 @@ +/** + * Todo 表 Schema + * + * 使用 SQLite 数据类型: + * - text: 字符串类型 + * - integer: 整数类型 (可配置为 boolean/timestamp 模式) + */ import { sql } from 'drizzle-orm' -import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -export const todoTable = pgTable('todo', { - id: uuid('id').primaryKey().default(sql`uuidv7()`), +export const todoTable = sqliteTable('todo', { + /** 主键 UUID */ + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + + /** 待办事项标题 */ title: text('title').notNull(), - completed: boolean('completed').notNull().default(false), - createdAt: timestamp('created_at', { withTimezone: true }) + + /** 是否已完成 (SQLite 用 0/1 表示布尔值) */ + completed: integer('completed', { mode: 'boolean' }).notNull().default(false), + + /** 创建时间 (Unix 时间戳) */ + createdAt: integer('created_at', { mode: 'timestamp' }) .notNull() - .defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }) + .default(sql`(unixepoch())`), + + /** 更新时间 (Unix 时间戳,自动更新) */ + updatedAt: integer('updated_at', { mode: 'timestamp' }) .notNull() - .defaultNow() + .default(sql`(unixepoch())`) .$onUpdateFn(() => new Date()), }) diff --git a/src/env.ts b/src/env.ts index e8825d9..8b32bdc 100644 --- a/src/env.ts +++ b/src/env.ts @@ -2,9 +2,7 @@ import { createEnv } from '@t3-oss/env-core' import { z } from 'zod' export const env = createEnv({ - server: { - DATABASE_URL: z.url(), - }, + server: {}, clientPrefix: 'VITE_', client: { VITE_APP_TITLE: z.string().min(1).optional(), diff --git a/src/orpc/client.ts b/src/orpc/client.ts index b5d0e8f..2dbc526 100644 --- a/src/orpc/client.ts +++ b/src/orpc/client.ts @@ -1,3 +1,12 @@ +/** + * ORPC 同构客户端 + * + * 根据运行环境自动选择最优调用方式: + * - SSR (服务端): 直接调用 router,无 HTTP 开销 + * - CSR (客户端): 通过 /api/rpc 端点 HTTP 调用 + * + * 同时配置了 TanStack Query 集成,mutation 成功后自动刷新相关查询。 + */ import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { createRouterClient } from '@orpc/server' @@ -7,6 +16,12 @@ import { getRequestHeaders } from '@tanstack/react-start/server' import { router } from './router' import type { RouterClient } from './types' +/** + * 创建同构 ORPC 客户端 + * + * 服务端: 直接调用路由处理器 + * 客户端: 通过 HTTP 调用 /api/rpc 端点 + */ const getORPCClient = createIsomorphicFn() .server(() => createRouterClient(router, { @@ -24,7 +39,23 @@ const getORPCClient = createIsomorphicFn() const client: RouterClient = getORPCClient() +/** + * ORPC + TanStack Query 工具 + * + * 使用方式: + * ```tsx + * // 查询 + * const { data } = useSuspenseQuery(orpc.todo.list.queryOptions()) + * + * // 变更 + * const mutation = useMutation(orpc.todo.create.mutationOptions()) + * mutation.mutate({ title: '新任务' }) + * ``` + * + * 配置了自动缓存失效: 创建/更新/删除操作后自动刷新列表 + */ export const orpc = createTanstackQueryUtils(client, { + // 配置 mutation 成功后自动刷新相关查询 experimental_defaults: { todo: { create: { diff --git a/src/orpc/contracts/todo.ts b/src/orpc/contracts/todo.ts index 4e06241..55fc485 100644 --- a/src/orpc/contracts/todo.ts +++ b/src/orpc/contracts/todo.ts @@ -1,3 +1,9 @@ +/** + * Todo API 契约 + * + * 使用 ORPC 契约定义 API 的输入/输出类型。 + * drizzle-zod 自动从表 schema 生成验证规则。 + */ import { oc } from '@orpc/contract' import { createInsertSchema, @@ -7,24 +13,34 @@ import { import { z } from 'zod' import { todoTable } from '@/db/schema' +/** 查询返回的完整 Todo 类型 */ const selectSchema = createSelectSchema(todoTable) +/** 创建 Todo 时的输入类型 (排除自动生成的字段) */ const insertSchema = createInsertSchema(todoTable).omit({ id: true, createdAt: true, updatedAt: true, }) +/** 更新 Todo 时的输入类型 (所有字段可选) */ const updateSchema = createUpdateSchema(todoTable).omit({ id: true, createdAt: true, updatedAt: true, }) +// ============================================================ +// API 契约定义 +// ============================================================ + +/** 获取所有 Todo */ export const list = oc.input(z.void()).output(z.array(selectSchema)) +/** 创建新 Todo */ export const create = oc.input(insertSchema).output(selectSchema) +/** 更新 Todo */ export const update = oc .input( z.object({ @@ -34,6 +50,7 @@ export const update = oc ) .output(selectSchema) +/** 删除 Todo */ export const remove = oc .input( z.object({ diff --git a/src/orpc/handlers/todo.ts b/src/orpc/handlers/todo.ts index 9136e14..59a23aa 100644 --- a/src/orpc/handlers/todo.ts +++ b/src/orpc/handlers/todo.ts @@ -1,9 +1,20 @@ +/** + * Todo API 处理器 + * + * 实现 Todo CRUD 操作的业务逻辑。 + * 每个处理器都使用 dbProvider 中间件获取数据库连接。 + */ import { ORPCError } from '@orpc/server' import { eq } from 'drizzle-orm' import { todoTable } from '@/db/schema' import { dbProvider } from '@/orpc/middlewares' import { os } from '@/orpc/server' +/** + * 获取所有 Todo + * + * 按创建时间倒序排列 (最新的在前) + */ export const list = os.todo.list .use(dbProvider) .handler(async ({ context }) => { @@ -13,6 +24,11 @@ export const list = os.todo.list return todos }) +/** + * 创建新 Todo + * + * @throws ORPCError NOT_FOUND - 创建失败时 + */ export const create = os.todo.create .use(dbProvider) .handler(async ({ context, input }) => { @@ -28,6 +44,11 @@ export const create = os.todo.create return newTodo }) +/** + * 更新 Todo + * + * @throws ORPCError NOT_FOUND - Todo 不存在时 + */ export const update = os.todo.update .use(dbProvider) .handler(async ({ context, input }) => { @@ -44,6 +65,9 @@ export const update = os.todo.update return updatedTodo }) +/** + * 删除 Todo + */ export const remove = os.todo.remove .use(dbProvider) .handler(async ({ context, input }) => { diff --git a/src/orpc/middlewares/db.ts b/src/orpc/middlewares/db.ts index 01d55ce..35d2d51 100644 --- a/src/orpc/middlewares/db.ts +++ b/src/orpc/middlewares/db.ts @@ -1,22 +1,41 @@ +/** + * 数据库中间件 + * + * 为 ORPC 处理器提供数据库连接。使用单例模式管理连接, + * 避免每次请求都创建新连接。 + */ import { os } from '@orpc/server' -import { createDb } from '@/db' +import { createDb, type Db } from '@/db' -const IS_SERVERLESS = false // TODO: 这里需要优化 - -let globalDb: ReturnType | null = null - -function getDb() { - if (IS_SERVERLESS) { - return createDb() - } +/** 全局数据库实例 (单例模式) */ +let globalDb: Db | null = null +/** + * 获取数据库实例 + * + * 首次调用时创建连接,后续调用返回同一实例。 + * 这种模式适合长时间运行的服务器进程。 + */ +function getDb(): Db { if (!globalDb) { globalDb = createDb() } - return globalDb } +/** + * 数据库提供者中间件 + * + * 使用方式: + * ```ts + * export const list = os.todo.list + * .use(dbProvider) + * .handler(async ({ context }) => { + * // context.db 可用 + * return context.db.query.todoTable.findMany() + * }) + * ``` + */ export const dbProvider = os.middleware(async ({ context, next }) => { const db = getDb()