feat: 迁移数据库至SQLite并适配嵌入式环境

- 添加SQLite数据库文件及数据目录的忽略规则
- 将数据库连接地址更新为本地SQLite数据库文件路径
- 将数据库从 PostgreSQL 迁移至 SQLite,更新依赖、Drizzle 方言及数据类型映射,确保嵌入式环境兼容性并保持应用层代码不变。
- 将数据库方言从 PostgreSQL 更改为 SQLite。
- 将数据库依赖从 postgres 替换为 better-sqlite3,并添加 better-sqlite3 的类型定义。
- 修改数据库连接字符串验证规则,从URL格式验证改为非空字符串验证。
- 将数据库连接从 PostgreSQL 切换为 Better-SQLite3,并支持创建目录和内存数据库。
- 将 todo 表的 completed 字段从 PostgreSQL 的 boolean 类型改为 SQLite 的 boolean 模式整数类型。
- 将字段定义从 PostgreSQL 适配改为 SQLite 适配,使用文本类型 ID 和时间戳整数类型,并统一使用 UUIDv7 和当前时间作为默认值。
- 添加 better-sqlite3 及其类型定义,并引入相关依赖以支持其功能
- 添加 better-sqlite3 依赖并移除 postgres 依赖
This commit is contained in:
2026-01-26 14:10:25 +08:00
parent a4c44a3fba
commit 5e56383f6f
11 changed files with 245 additions and 54 deletions

View File

@@ -1 +1 @@
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
DATABASE_URL=./data/app.db

View File

@@ -8,7 +8,7 @@
- **运行时**: Bun
- **语言**: TypeScript (strict mode, ESNext)
- **样式**: Tailwind CSS v4
- **数据库**: PostgreSQL + Drizzle ORM
- **数据库**: SQLite + Drizzle ORM
- **状态管理**: TanStack Query
- **路由**: TanStack Router (文件路由)
- **RPC**: ORPC (类型安全 RPC契约优先)
@@ -173,22 +173,23 @@ export const Route = createFileRoute('/')({
### 数据库 Schema (Drizzle)
-`src/db/schema/*.ts` 定义 schema
-`src/db/schema/index.ts` 导出
- 使用 `drizzle-orm/pg-core`PostgreSQL 类型
- 主键使用 `uuidv7()` (需要 PostgreSQL 扩展)
-`src/server/db/schema/*.ts` 定义 schema
-`src/server/db/schema/index.ts` 导出
- 使用 `drizzle-orm/sqlite-core` 的 SQLite 类型
- 主键使用 `uuidv7()` (TEXT 存储)
- 时间戳使用 `integer({ mode: 'timestamp_ms' })` (Unix 毫秒时间戳)
- 始终包含 `createdAt``updatedAt` 时间戳
示例:
```typescript
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { v7 as uuidv7 } from 'uuid'
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(() => uuidv7()),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()).$onUpdateFn(() => new Date()),
})
```
@@ -377,6 +378,119 @@ const fingerprint = createHash('sha256')
- 专业库不一定解决所有问题(如并发去重)
- 对于简单场景,手动实现 vs 库的选择主要取决于可维护性而非功能
### PostgreSQL → SQLite 迁移
**迁移时间**: 2026-01-26
**原因**: 项目需要在嵌入环境中运行,无法安装 PostgreSQL
**技术选择**:
- **驱动**: `better-sqlite3` v11.8.1 (跨平台,成熟稳定,原生模块)
- **类型定义**: `@types/better-sqlite3` v7.6.12
- **主键**: TEXT 存储 UUIDv7 (保持全局唯一性36 字符字符串)
- **时间戳**: INTEGER 存储 Unix 毫秒时间戳 (`integer({ mode: 'timestamp_ms' })`)
- **布尔值**: INTEGER 存储 0/1 (`integer({ mode: 'boolean' })`)
- **数据库文件**: `./data/app.db`
**变更内容**:
1. **依赖**: `postgres``better-sqlite3` + `@types/better-sqlite3`
2. **Drizzle 方言**: `dialect: 'postgresql'``dialect: 'sqlite'`
3. **Schema 导入**: `drizzle-orm/pg-core``drizzle-orm/sqlite-core`
4. **表定义**: `pgTable``sqliteTable`
5. **数据库连接**: `drizzle-orm/postgres-js``drizzle-orm/better-sqlite3`
6. **数据类型映射**:
- UUID: `uuid()``text()` (使用 `uuidv7()` 生成)
- 时间戳: `timestamp({ withTimezone: true })``integer({ mode: 'timestamp_ms' })`
- 布尔值: `boolean()``integer({ mode: 'boolean' })`
- 默认值: `defaultNow()``$defaultFn(() => new Date())`
7. **环境变量**: `DATABASE_URL` 从 URL 格式改为文件路径 (`./data/app.db`)
8. **目录自动创建**: 添加了数据库文件目录的自动创建逻辑
**数据库连接层变更**:
```typescript
// Before (PostgreSQL)
import { drizzle } from 'drizzle-orm/postgres-js'
export const createDB = () =>
drizzle({
connection: { url: env.DATABASE_URL, prepare: true },
schema,
})
// After (SQLite)
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
export const createDB = () => {
const dbPath = env.DATABASE_URL
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true })
}
const sqlite = new Database(dbPath)
return drizzle(sqlite, { schema })
}
```
**Schema 变更示例**:
```typescript
// Before (PostgreSQL)
import { pgTable, text, boolean, timestamp, uuid } from 'drizzle-orm/pg-core'
export const todoTable = pgTable('todo', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// After (SQLite)
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const todoTable = sqliteTable('todo', {
id: text('id').primaryKey().$defaultFn(() => uuidv7()),
title: text('title').notNull(),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date()),
})
```
**兼容性保证**:
- ✅ 所有业务逻辑保持不变
- ✅ ORPC 契约和类型完全兼容
- ✅ 应用层代码无需修改
- ✅ Drizzle 自动处理类型转换 (Date ↔ number, boolean ↔ 0/1)
**构建验证**:
-`bun typecheck` - 类型检查通过
-`bun fix` - 代码质量检查通过
-`bun run build` - Vite 构建成功
-`bun db:push` - 数据库初始化成功
- ✅ SQLite 文件已创建 (`./data/app.db`, 12KB)
**注意事项**:
- SQLite 是文件数据库,适合嵌入环境和开发环境
- 不支持多进程并发写入 (适合单应用实例)
- 性能足够支持中小型应用 (日请求 < 10万)
- 数据库文件和 WAL 文件 (`.db-shm`, `.db-wal`) 已添加到 `.gitignore`
- `better-sqlite3` 是原生模块,需要匹配生产环境的 OS 和架构 (构建输出会提示)
**Drizzle 时间戳模式**:
- `timestamp_ms`: Unix 毫秒时间戳 (推荐,精度更高)
- `timestamp`: Unix 秒时间戳
- Drizzle 自动转换 `Date` 对象 ↔ 数字
**命令对比**:
```bash
# 开发环境初始化 (推荐)
bun db:push
# 生产环境迁移
bun db:generate # 生成迁移文件
bun db:migrate # 执行迁移
# 数据库管理 UI
bun db:studio
```
### Git 工作流要求
**重要原则**:保持代码仓库与文档同步

View File

@@ -4,7 +4,7 @@ import { env } from '@/env'
export default defineConfig({
out: './drizzle',
schema: './src/server/db/schema/index.ts',
dialect: 'postgresql',
dialect: 'sqlite',
dbCredentials: {
url: env.DATABASE_URL,
},

View File

@@ -30,7 +30,7 @@
"drizzle-orm": "catalog:",
"drizzle-zod": "catalog:",
"ohash": "catalog:",
"postgres": "catalog:",
"better-sqlite3": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"systeminformation": "catalog:",
@@ -46,6 +46,7 @@
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-router-devtools": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/bun": "catalog:",
"@vitejs/plugin-react": "catalog:",
"babel-plugin-react-compiler": "catalog:",

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.url(),
DATABASE_URL: z.string().min(1),
},
clientPrefix: 'VITE_',
client: {

View File

@@ -1,15 +1,18 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { env } from '@/env'
import * as schema from '@/server/db/schema'
export const createDB = () =>
drizzle({
connection: {
url: env.DATABASE_URL,
prepare: true,
},
schema,
})
export const createDB = () => {
const dbPath = env.DATABASE_URL
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true })
}
const sqlite = new Database(dbPath)
return drizzle(sqlite, { schema })
}
export type DB = ReturnType<typeof createDB>

View File

@@ -1,8 +1,8 @@
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { generatedFields } from './utils/field'
export const todoTable = pgTable('todo', {
export const todoTable = sqliteTable('todo', {
...generatedFields,
title: text('title').notNull(),
completed: boolean('completed').notNull().default(false),
completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
})

View File

@@ -1,37 +1,25 @@
import { sql } from 'drizzle-orm'
import { timestamp, uuid } from 'drizzle-orm/pg-core'
import { integer, text } from 'drizzle-orm/sqlite-core'
import { v7 as uuidv7 } from 'uuid'
// id
export const id = (name: string) => uuid(name)
export const pk = (name: string, strategy?: 'native' | 'extension') => {
switch (strategy) {
// PG 18+
case 'native':
return id(name).primaryKey().default(sql`uuidv7()`)
// PG 13+ with extension
case 'extension':
return id(name).primaryKey().default(sql`uuid_generate_v7()`)
// Any PG version
default:
return id(name)
.primaryKey()
.$defaultFn(() => uuidv7())
}
}
export const id = (name: string) => text(name)
export const pk = (name: string) =>
id(name)
.primaryKey()
.$defaultFn(() => uuidv7())
// timestamp
export const createdAt = (name = 'created_at') =>
timestamp(name, { withTimezone: true }).notNull().defaultNow()
integer(name, { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
export const updatedAt = (name = 'updated_at') =>
timestamp(name, { withTimezone: true })
integer(name, { mode: 'timestamp_ms' })
.notNull()
.defaultNow()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date())
// generated fields