# Forge 数据库层重构与迁移方案

> 原则：**不影响现有任何功能**。Electron 端用户的本地 `forge.db` 升级后必须数据完整、行为一致。
> 状态：设计稿，待 review 后启动 P0。

---

## 目录

1. [现状诊断](#1-现状诊断)
2. [目标与非目标](#2-目标与非目标)
3. [技术选型：Drizzle ORM](#3-技术选型drizzle-orm)
4. [关键决策](#4-关键决策)
5. [总体架构](#5-总体架构)
6. [目录结构](#6-目录结构)
7. [方言路由层](#7-方言路由层)
8. [Schema 三镜像设计](#8-schema-三镜像设计)
9. [Repository 层规范](#9-repository-层规范)
10. [迁移策略（保护现有数据）](#10-迁移策略保护现有数据)
11. [Electron 打包约束](#11-electron-打包约束)
12. [分阶段落地计划](#12-分阶段落地计划)
13. [风险与回滚](#13-风险与回滚)
14. [验收清单](#14-验收清单)

---

## 1. 现状诊断

### 1.1 量化指标

| 维度 | 现状 | 问题 |
|---|---|---|
| ORM | 裸 `better-sqlite3`，全手写 SQL 字符串 | 无类型推断 |
| Schema 定义 | 30 张表的 `CREATE TABLE` 内联在 `db.ts` 单文件（966 行） | schema 与代码同文件，无法独立审视 |
| 迁移机制 | 18 处手写 `ensureColumn` / `try-catch CHECK 重建` / `DROP+重建` | 不可重放、无版本号、无回滚 |
| SQL 散布 | 85 个 API route 直接 `.prepare('...')` | 业务逻辑与存储耦合，无 Repository 边界 |
| 方言耦合 | 硬编码 SQLite 方言：`datetime('now')`、`INTEGER` 布尔、`PRAGMA`、`||` 拼接 | 换 PG/MySQL 需逐行改 SQL |
| 连接模型 | 单文件 SQLite，`globalThis.__forgeDb` 单例 | 无连接池、无法横向扩展 |
| 类型安全 | 85 处 `.all() as Record<string, unknown>[]` | IDE 无法提示，拼写错误运行时才暴露 |

### 1.2 现有表清单（共 30 张表 + webhook_tokens）

| 域 | 表 |
|---|---|
| 会话/消息 | `sessions`, `messages` |
| 配置 | `settings`, `user_settings`, `api_providers`, `workspaces` |
| Agent/Skill/MCP | `agents`, `agent_skills`, `skills`, `mcp_servers`, `hooks` |
| IM Bridge | `im_channels`, `channel_bindings`, `channel_permission_links`, `channel_outbound_refs`, `channel_audit_logs`, `channel_dedupe`, `weixin_context_tokens`, `im_pairing_requests`, `weixin_sync_cursors` |
| 调度 | `cron_tasks`, `task_executions` |
| 市场 | `marketplace_templates` |
| 认证（Better-Auth） | `user`, `account`, `session`, `verification` |
| 多租户 | `teams`, `team_members`, `workspace_members` |
| 外部 | `webhook_tokens`, `invitations` |

### 1.3 现有外键（共 20 处，集中在两个父表）

- `user` 的 7 个子表 CASCADE
- `im_channels` 的 5 个子表 CASCADE
- `agents` 的 4 个子表（agent_skills×2 CASCADE + SET NULL×2）
- 其余：`sessions→messages`、`cron_tasks→task_executions`、`teams/team_members/workspace_members`

---

## 2. 目标与非目标

### 2.1 目标

1. **三方言透明**：`DB_DIALECT=sqlite|pg|mysql`，消费层（85 个 route）零改动。
2. **彻底消除散落 SQL**：85 个 `.prepare()` 全部收敛到 Repository。
3. **迁移可版本化**：用 drizzle-kit 替代 18 处手写幂等补丁。
4. **Electron 友好**：默认 SQLite，PG/MySQL driver 动态 import。
5. **MySQL 一等公民**：从 schema 设计第一天考虑 MySQL 限制，非事后补丁。
6. **现有功能零回归**：Electron 用户的本地 `forge.db` 升级后数据完整、行为一致。

### 2.2 非目标

- 不引入微服务、不拆库拆表（forge 是单机/单服务器应用）。
- 不追求"100% 透明"——复杂 JOIN 允许 Repository 内 `sql.raw()` + `isSqlite()` 分支。
- 不实现读写分离、分库分表。

---

## 3. 技术选型：Drizzle ORM

### 3.1 候选对比

| 框架 | SQLite | PG | MySQL | 连接池 | 类型推断 | 迁移工具 | Electron 友好 |
|---|---|---|---|---|---|---|---|
| **Drizzle** | ✅ 4 个 driver（一等公民） | ✅ | ✅ | 三方言统一 | TS 全量推断 | drizzle-kit | ✅ 无 native engine |
| Prisma | ⚠️ "建议开发用" | ✅ | ✅ | 自带 | 需 generate | 自带 | ❌ ~15MB Rust engine |
| Kysely | ✅ | ✅ | ✅ | 手写 | TS 全量 | 无内置 | ✅ |
| TypeORM | ⚠️ | ✅ | ✅ | 自带 | 装饰器元数据 | 自带 | ⚠️ 重 |

### 3.2 选 Drizzle 的理由（按重要性排序）

1. **SQLite 一等公民**：4 个 SQLite driver 适配器，`better-sqlite3` 同步 API 与 forge 现状无缝衔接，tinyship 项目已实证可行。
2. **三方言 API 真正统一**：`db.select().from().where()` / `db.transaction()` / drizzle-kit 在三方言下零改动。
3. **无 native binary 依赖**（除 forge 已在用的 `better-sqlite3`）：Electron 打包友好。
4. **类型推断全量**：schema → TS 类型自动推，消除 85 处 `as Record<string, unknown>`。
5. **迁移工具内置**：drizzle-kit 替代手写 `ensureColumn`。
6. **体积小**：~30KB（Prisma engine ~15MB）。
7. **技术栈收敛**：一个 ORM 覆盖 schema + query + transaction + migration + studio。

### 3.3 不选 Prisma 的关键原因

- Prisma 官方文档自述 SQLite "建议用于开发和测试"，生产建议 PG/MySQL。
- Rust query engine binary ~15MB，Electron 打包要按平台分发 native binary，痛苦。
- `relationMode: "prisma"` 在 SQLite 下部分特性不支持。

### 3.4 不选 Kysely 的关键原因

- 没有 schema 定义层——表结构靠手写 `interface` 与 DB 对齐，易漂移。
- 没有内置迁移工具。
- 没有 ORM 的"表对象"概念，所有查询从 `db.selectFrom('table')` 字符串表名开始。

---

## 4. 关键决策

### 4.1 默认方言 = `sqlite`

与 tinyship 默认 pg **相反**——这是 forge 的 Electron 单机部署形态决定的。`DB_DIALECT` 未设置时走 SQLite，保证 Electron 用户零配置。

### 4.2 全表无外键，级联由 Repository 事务承担

**决策**：schema 三镜像（sqlite/pg/mysql）**全部不写 `.references()` 的 DDL**。

**理由**：

| 方言 | FK 痛点 |
|---|---|
| SQLite | 默认关闭，需 per-connection `pragma('foreign_keys = ON')`；CASCADE 是触发器实现，每次 DELETE 多次表扫描；表重建需临时关 FK（forge 已踩过坑，`db.ts:370`） |
| MySQL | **最危险**——FK 在子父表加 gap lock / next-key lock，高并发删除 `im_channels` 时 5 张子表级联删除形成锁链，死锁高发；`ON DELETE SET NULL` 要求列 NULLable 且建表即失败（PG/SQLite 宽松） |
| PG | 实现最干净，但仍有写放大：子表 INSERT/UPDATE 外键列需父表存在性检查 |

**三方言 FK 行为差异会让 Repository 无法统一**——这是"统一接口层"目标的根本对立面。

**补偿措施（必须配套）**：

1. **Repository 删除方法强制 `db.transaction()` 包裹级联**，删除顺序由应用层显式控制。
2. **关键删除路径单测**：每个 `repo.delete()` 写测试，断言"删父后子表对应行数为 0"。
3. **可选孤儿清理 cron**：复用 forge 现有 `cron_tasks` 框架，加一个 `cleanup_orphans` 心跳任务作为兜底。
4. **`.references()` 仅用于类型层关系声明**（drizzle 允许软关系，不影响 DDL），辅助 IDE 跳转。

### 4.3 JSON 字段暂不升级

现 schema 用 `text`/`varchar` 存 JSON 字符串（`tools_config`/`config`/`credentials`/`metadata`）。三方言统一保持 `text`/`varchar`，应用层 `JSON.parse/stringify`。等真有 JSON 查询需求再升 PG `jsonb` / MySQL `json`。

### 4.4 MySQL 大小写敏感处理

MySQL 默认 `utf8mb4_general_ci` 大小写不敏感，PG/SQLite 默认敏感。MySQL schema 的 unique 字段（`email` 等）显式 `varchar(N) COLLATE utf8mb4_bin`，与 PG/SQLite 行为对齐。

### 4.5 Better-Auth schema 严格对齐

`user/account/session/verification` 四表是 Better-Auth 的 schema。三方言镜像严格按 Better-Auth 官方文档字段名对齐（如 `createdAt` 驼峰），不自创字段名。

### 4.6 动态 import PG/MySQL driver

Electron 应用必须用 `await import()` 动态加载 PG/MySQL driver，否则 `mysql2`/`pg` 会被打进 Electron bundle，造成包体膨胀与 native 冲突。SQLite driver 静态 import（Electron 必备）。

---

## 5. 总体架构

```
┌─────────────────────────────────────────────────────┐
│  消费层（85 个 API route）                            │
│  import { repos } from '@/lib/database'             │
│  const agent = await repos.agent.findById(id, uid)  │
└──────────────────┬──────────────────────────────────┘
                   │ 统一接口
┌──────────────────▼──────────────────────────────────┐
│  Repository 层（~18 个 repo）                        │
│  全部基于 drizzle 的 select/insert/update/delete     │
│  删除操作强制 db.transaction() 包裹级联              │
└──────────────────┬──────────────────────────────────┘
                   │ 统一 ORM API
┌──────────────────▼──────────────────────────────────┐
│  Drizzle ORM                                         │
│  db.transaction() / eq() / sql`` / innerJoin()       │
└──────────────────┬──────────────────────────────────┘
                   │ DB_DIALECT env 路由
┌──────────┬────────┴───────────┬─────────────────────┐
│ SQLite   │   PostgreSQL       │      MySQL          │
│ (默认)   │                    │                     │
│better-   │   pg.Pool          │  mysql2 Pool        │
│sqlite3   │ drizzle/node-pg    │  drizzle/mysql2     │
│ 静态import│  动态 import       │  动态 import        │
└──────────┴────────────────────┴─────────────────────┘
```

**整个栈从上到下技术栈是一个：Drizzle**。SQLite/PG/MySQL 只是 drizzle 三个 adapter 的选择。Repository 层不知道方言，消费层不知道 Repository 怎么实现。

---

## 6. 目录结构

```
src/lib/database/
├── index.ts                       # 统一入口，对外唯一门面
├── dialect.ts                     # getDialect() / isSqlite / isPg / isMysql
├── client.ts                      # db 单例创建，env 路由到 driver
│
├── drivers/
│   ├── sqlite.ts                  # better-sqlite3 + drizzle，Electron 默认，静态 import
│   ├── pg.ts                      # node-postgres Pool + drizzle，动态 import
│   └── mysql.ts                   # mysql2/promise Pool + drizzle，动态 import
│
├── schema/
│   ├── index.ts                   # 代理转发，消费方只 import 这里
│   ├── sqlite/                    # 30 张表，sqliteTable 定义
│   │   ├── sessions.ts
│   │   ├── messages.ts
│   │   ├── ... (每张表一个文件)
│   │   └── index.ts
│   ├── pg/                        # 镜像，pgTable 定义
│   │   └── ...
│   └── mysql/                     # 镜像，mysqlTable 定义
│       └── ...
│
├── repositories/                  # forge 独有层（tinyship 没有的）
│   ├── session.repo.ts
│   ├── message.repo.ts
│   ├── agent.repo.ts
│   ├── im-channel.repo.ts
│   ├── cron-task.repo.ts
│   ├── api-provider.repo.ts
│   ├── user.repo.ts
│   ├── ... (按业务域组织，约 18 个 repo)
│   └── index.ts                   # export const repos = { session, message, ... }
│
├── shared/
│   ├── enums.ts                   # role/status/action_type 等枚举常量
│   └── dialect-helpers.ts         # nowExpr() 等方言适配器
│
└── migrations/
    ├── sqlite/                    # drizzle-kit generate 产物
    │   ├── 0000_baseline_from_legacy.sql   # 冻结现有 db.ts 的所有 CREATE+ALTER
    │   └── 0001_*.sql                      # 后续增量
    ├── pg/
    └── mysql/
```

---

## 7. 方言路由层

### 7.1 `dialect.ts`

```ts
export type Dialect = 'sqlite' | 'pg' | 'mysql'

export function getDialect(): Dialect {
  const d = (process.env.DB_DIALECT?.toLowerCase() || 'sqlite') as Dialect
  if (!['sqlite', 'pg', 'mysql'].includes(d)) {
    throw new Error(`Unknown DB_DIALECT: ${d}. Must be 'sqlite' | 'pg' | 'mysql'.`)
  }
  return d
}

export const isSqlite = () => getDialect() === 'sqlite'
export const isPg     = () => getDialect() === 'pg'
export const isMysql  = () => getDialect() === 'mysql'
```

### 7.2 `client.ts`（关键：PG/MySQL 动态 import）

```ts
import { getDialect } from './dialect'
import * as sqliteSchema from './schema/sqlite'
import * as pgSchema from './schema/pg'
import * as mysqlSchema from './schema/mysql'

const dialect = getDialect()
const schema = dialect === 'sqlite' ? sqliteSchema
             : dialect === 'pg'     ? pgSchema
             :                         mysqlSchema

let _db: any

if (dialect === 'sqlite') {
  // 静态 require：Electron 必备，必须进 bundle 但 externalize native addon
  const Database = require('better-sqlite3')
  const { drizzle } = require('drizzle-orm/better-sqlite3')
  const { getDbPath } = require('./sqlite-path')  // 沿用现有 getDbPath 逻辑
  const sqlite = new Database(getDbPath())
  sqlite.pragma('journal_mode = WAL')
  // 注意：foreign_keys = OFF，配合"无外键"决策
  sqlite.pragma('foreign_keys = OFF')
  _db = drizzle(sqlite, { schema })
} else if (dialect === 'pg') {
  // 动态 import：Electron 打包时不进 bundle
  const { Pool } = await import('pg')
  const { drizzle } = await import('drizzle-orm/node-postgres')
  const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
  _db = drizzle(pool, { schema })
} else {
  const mysql = await import('mysql2/promise')
  const { drizzle } = await import('drizzle-orm/mysql2')
  const pool = mysql.createPool(process.env.DATABASE_URL)
  _db = drizzle(pool, { schema, mode: 'default' })
}

export const db = _db
```

### 7.3 连接池说明

| 方言 | 连接模型 | 说明 |
|---|---|---|
| SQLite | 单例（`better-sqlite3` 同步） | SQLite 是单文件单写锁，多连接写吞吐与单连接一致；drizzle 包装单实例，与 forge 现有 `globalThis.__forgeDb` 单例等价 |
| PG | `pg.Pool`（max 10） | drizzle 直接包装 `pg.Pool`，所有 Pool 参数（`max`/`idleTimeoutMillis`）原样透传 |
| MySQL | `mysql2.createPool`（connectionLimit 10） | 同上 |

SQLite 场景下"连接池"不是优势点（两者都是单例），但 PG/MySQL 场景下 drizzle 给的池化能力是 forge 现状完全没有的。

---

## 8. Schema 三镜像设计

### 8.1 跨方言字段映射对照表

| 语义 | SQLite | PG | MySQL |
|---|---|---|---|
| 主键 UUID | `text().primaryKey()` | `text().primaryKey()` 或 `uuid()` | `varchar(36).primaryKey()` |
| 短文本(≤255) | `text` | `text` | `varchar(N)` |
| 长文本(JSON/正文) | `text` | `text`（暂不升 jsonb） | `text`（暂不升 json） |
| 布尔 | `integer({mode:'boolean'})` | `boolean` | `boolean` |
| 时间戳 | `text` ISO 字符串 或 `integer({mode:'timestamp'})` | `timestamp({withTimezone:true})` | `datetime({fsp:3})` |
| 数值金额 | `text` 字符串 | `numeric` | `decimal(10,2)` |
| 枚举 | `text` | `text` + pg enum | `mysqlEnum` |

### 8.2 样例：`sessions` 表三镜像

**SQLite（`schema/sqlite/sessions.ts`）**

```ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  title: text('title').notNull().default('New Session'),
  workspace: text('workspace').notNull().default(''),
  model: text('model').notNull().default('claude-sonnet-4-6'),
  status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
  permissionMode: text('permission_mode').notNull().default(''),
  backend: text('backend').notNull().default('claude'),
  userId: text('user_id').notNull().default(''),
  createdAt: text('created_at').notNull().default(sql`(datetime('now'))`),
  updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`),
})
```

**PostgreSQL（`schema/pg/sessions.ts`）**

```ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'

export const sessions = pgTable('sessions', {
  id: text('id').primaryKey(),
  title: text('title').notNull().default('New Session'),
  workspace: text('workspace').notNull().default(''),
  model: text('model').notNull().default('claude-sonnet-4-6'),
  status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
  permissionMode: text('permission_mode').notNull().default(''),
  backend: text('backend').notNull().default('claude'),
  userId: text('user_id').notNull().default(''),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
```

**MySQL（`schema/mysql/sessions.ts`）⚠️ 关键差异**

```ts
import { mysqlTable, varchar, datetime, mysqlEnum } from 'drizzle-orm/mysql-core'

export const sessions = mysqlTable('sessions', {
  id: varchar('id', { length: 36 }).primaryKey(),                       // ① varchar 代替 text
  title: varchar('title', { length: 255 }).notNull().default('New Session'),
  workspace: varchar('workspace', { length: 512 }).notNull().default(''),
  model: varchar('model', { length: 64 }).notNull().default('claude-sonnet-4-6'),
  status: mysqlEnum('status', ['active', 'archived']).notNull().default('active'),
  permissionMode: varchar('permission_mode', { length: 32 }).notNull().default(''),
  backend: varchar('backend', { length: 32 }).notNull().default('claude'),
  userId: varchar('user_id', { length: 36 }).notNull().default(''),
  createdAt: datetime('created_at', { mode: 'date', fsp: 3 }).notNull(), // ② defaultNow() 即 CURRENT_TIMESTAMP
  updatedAt: datetime('updated_at', { mode: 'date', fsp: 3 }).notNull(),
})
```

### 8.3 MySQL 必须在 schema 阶段处理的 6 个陷阱

| 陷阱 | 影响 | 处理 |
|---|---|---|
| `TEXT/BLOB` 不能 DEFAULT | SQLite 大量 `TEXT NOT NULL DEFAULT ''` | MySQL 改 `varchar(N)`（短文本）或保留 `text` 去掉 default、应用层填值 |
| 没有 `datetime('now')` 表达式 | SQLite 的 `DEFAULT (datetime('now'))` 无效 | MySQL 用 `DEFAULT CURRENT_TIMESTAMP`（drizzle `defaultNow()` 输出此表达式） |
| `||` 不是字符串拼接 | 是逻辑 OR | 用 drizzle 的 `sql.join()` 或应用层拼接，raw SQL 禁用 `\|\|` |
| UPSERT 语法不同 | SQLite `INSERT OR IGNORE`、PG `ON CONFLICT`、MySQL `ON DUPLICATE KEY UPDATE` | 统一用 drizzle 的 `onConflictKey().doNothing()/doUpdate()`，不写 raw |
| `CREATE INDEX IF NOT EXISTS` | MySQL 8.0 之前不支持 | 由 drizzle-kit 生成的 migration 保证幂等 |
| 大小写敏感 | 默认 `utf8mb4_general_ci` 不敏感 | unique 字段显式 `varchar(N) COLLATE utf8mb4_bin` |

### 8.4 Schema 代理模块（消费方零改动）

`schema/index.ts`：

```ts
import { getDialect } from '../dialect'
import * as sqliteSchema from './sqlite'
import * as pgSchema from './pg'
import * as mysqlSchema from './mysql'

const impl = getDialect() === 'sqlite' ? sqliteSchema
           : getDialect() === 'pg'     ? pgSchema
           :                             mysqlSchema

export const sessions            = impl.sessions
export const messages            = impl.messages
export const user                = impl.user
export const account             = impl.account
// ... 30 张表全部 re-export
```

> 消费方永远 `import { sessions, user } from '@/lib/database'`，方言透明。

---

## 9. Repository 层规范

### 9.1 为什么需要 Repository 层

forge 的 85 处散落 SQL 是核心债。tinyship 没有这层是因为它表少；forge 30 张表 + 85 个调用点，不加 Repository 层换 ORM 就只是"换了更漂亮的字符串拼接器"。

### 9.2 路由替换对照

**改造前**（`app/api/sessions/route.ts`）：

```ts
const db = getDb()
const rows = db.prepare('SELECT * FROM sessions WHERE user_id = ? ORDER BY updated_at DESC').all(user.id)
```

**改造后**：

```ts
import { repos } from '@/lib/database'
const rows = await repos.session.findByUser(user.id)
```

### 9.3 样例：`session.repo.ts`

```ts
import { db } from '../client'
import { sessions, messages } from '../schema'
import { eq, and, desc, sql } from 'drizzle-orm'
import { nowExpr } from '../shared/dialect-helpers'

export const sessionRepo = {
  findByUser(userId: string, opts?: { workspace?: string }) {
    const conds = [eq(sessions.userId, userId)]
    if (opts?.workspace) conds.push(eq(sessions.workspace, opts.workspace))
    return db.select().from(sessions).where(and(...conds)).orderBy(desc(sessions.updatedAt))
  },

  findById(id: string, userId: string) {
    return db.select().from(sessions)
      .where(and(eq(sessions.id, id), eq(sessions.userId, userId))).limit(1)
  },

  create(data: { id: string; title: string; workspace: string; model: string; userId: string; backend?: string }) {
    return db.insert(sessions).values({
      ...data,
      backend: data.backend ?? 'claude',
    }).returning()
  },

  touch(id: string) {
    return db.update(sessions).set({ updatedAt: nowExpr() }).where(eq(sessions.id, id))
  },

  countMessages(sessionId: string) {
    return db.select({ count: sql<number>`count(*)` })
      .from(messages).where(eq(messages.sessionId, sessionId))
  },

  /** 删除会话：事务级联删 messages（替代原 FK CASCADE） */
  async deleteById(id: string, userId: string) {
    return db.transaction(async (tx) => {
      // 1. 先删子表 messages
      await tx.delete(messages).where(eq(messages.sessionId, id))
      // 2. 再删父表 sessions
      await tx.delete(sessions).where(and(eq(sessions.id, id), eq(sessions.userId, userId)))
    })
  },
}
```

### 9.4 SET NULL 复刻样例（最易遗漏点）

当前 `agents` 删除时 `agents.parent_id` 和 `channel_permission_links.agent_id` 是 `SET NULL`。Repository 必须显式复刻：

```ts
// agent.repo.ts
async deleteById(id: string, userId: string) {
  return db.transaction(async (tx) => {
    // 1. SET NULL 复刻：引用本 agent 的其它 agent 的 parent_id
    await tx.update(agents).set({ parentId: null }).where(eq(agents.parentId, id))
    // 2. SET NULL 复刻：channel_permission_links.agent_id
    await tx.update(channelPermissionLinks).set({ agentId: null }).where(eq(channelPermissionLinks.agentId, id))
    // 3. CASCADE 复刻：agent_skills
    await tx.delete(agentSkills).where(eq(agentSkills.agentId, id))
    // 4. 最后删父记录
    await tx.delete(agents).where(and(eq(agents.id, id), eq(agents.userId, userId)))
  })
}
```

> **规则**：每个 Repository 的 `delete()` 方法开头，先用 grep 确认所有引用本表的列，把对应的 SET NULL / CASCADE 逻辑显式写进去。

### 9.5 方言适配器（`shared/dialect-helpers.ts`）

```ts
import { sql } from 'drizzle-orm'
import { getDialect } from '../dialect'

/** 当前时间表达式：SQLite datetime('now') / PG now() / MySQL NOW() */
export function nowExpr() {
  return sql.raw(
    getDialect() === 'sqlite' ? `datetime('now')` :
    getDialect() === 'pg'     ? 'now()' :
                                'NOW()'
  )
}
```

### 9.6 Repository 全清单（按域）

| 域 | Repository | 替换 route 数（估） |
|---|---|---|
| 会话 | sessionRepo, messageRepo | ~12 |
| Agent | agentRepo, skillRepo, agentSkillRepo | ~10 |
| IM | imChannelRepo, channelBindingRepo, channelPermissionRepo, channelAuditRepo, pairingRepo, weixinContextRepo | ~25 |
| 调度 | cronTaskRepo, taskExecutionRepo | ~8 |
| 配置 | apiProviderRepo, mcpServerRepo, hookRepo, settingsRepo, workspaceRepo | ~15 |
| 认证 | userRepo, accountRepo, sessionAuthRepo, verificationRepo | ~10 |
| 多租户 | teamRepo, workspaceMemberRepo | ~5 |

---

## 10. 迁移策略（保护现有数据）

**最高优先级约束**：现有 Electron 用户的 `~/.forge/forge.db` 升级后数据必须完整、行为一致。

### 10.1 三套独立 migration

```
migrations/
  sqlite/0000_baseline_from_legacy.sql   # 冻结当前 db.ts 的所有 CREATE+ALTER 逻辑
  sqlite/0001_*.sql                      # 后续增量由 drizzle-kit generate
  pg/0000_init.sql
  mysql/0000_init.sql
```

### 10.2 SQLite 现有数据迁移路径（关键）

1. **首次启动检测旧库**：检查 `forge.db` 是否存在且无 `__drizzle_migrations` 表。
2. **基准快照**：把当前 `db.ts` 的所有 `CREATE TABLE + ALTER + ensureColumn` 逻辑冻结为 `0000_baseline_from_legacy.sql`，在 drizzle migrations 表中标记为已应用（因为旧库已经具备这些 schema）。
3. **后续迁移走 drizzle-kit**：从 0001 起所有 schema 变更由 kit 生成。
4. **删除外键**：在 baseline 之后追加一个 `0001_drop_foreign_keys.sql`，用 SQLite 的"重建表"模式移除所有 `FOREIGN KEY` 约束（SQLite 不支持 `ALTER TABLE DROP CONSTRAINT`，需重建）。

> 这避免了"用 drizzle 重建一遍却丢光现有 Electron 用户的本地数据"的灾难。

### 10.3 迁移运行器（`scripts/migrate.ts`）

```ts
import { getDialect } from '../src/lib/database/dialect'

async function main() {
  const dialect = getDialect()
  const migrator = dialect === 'sqlite' ? require('./migrators/sqlite')
               : dialect === 'pg'     ? require('./migrators/pg')
               :                         require('./migrators/mysql')
  await migrator.migrate(`./migrations/${dialect}`)
  console.log(`[migrate] ${dialect} migrations applied`)
}
main()
```

### 10.4 package.json 脚本

```jsonc
{
  "scripts": {
    "db:generate:sqlite": "drizzle-kit generate --config=drizzle.sqlite.config.ts",
    "db:generate:pg":     "drizzle-kit generate --config=drizzle.pg.config.ts",
    "db:generate:mysql":  "drizzle-kit generate --config=drizzle.mysql.config.ts",
    "db:push:sqlite":     "drizzle-kit push --config=drizzle.sqlite.config.ts",
    "db:push:pg":         "drizzle-kit push --config=drizzle.pg.config.ts",
    "db:push:mysql":      "drizzle-kit push --config=drizzle.mysql.config.ts",
    "db:migrate":         "tsx scripts/migrate.ts",
    "db:studio:sqlite":   "drizzle-kit studio --config=drizzle.sqlite.config.ts",
    "db:seed":            "tsx scripts/seed.ts"
  }
}
```

---

## 11. Electron 打包约束

| 项 | 处理 |
|---|---|
| `better-sqlite3` native addon | `electron-builder.json` 的 `asarUnpack` + Next.js `serverExternalPackages`，沿用现有配置 |
| `pg` / `mysql2` | **动态 `await import()`**，Tree-shaking 时不会被 Electron main process bundle 拉入 |
| Next.js (Turbopack) | `next.config.ts` 的 `serverExternalPackages: ['better-sqlite3','pg','mysql2','drizzle-orm']` |
| 驱动类型声明 | `@types/pg`、`@types/mysql2` 放 `devDependencies` |

---

## 12. 分阶段落地计划

**总原则**：每阶段可独立合并、可独立回滚、不破坏现有功能。每阶段完成必须全量冒烟测试通过才进入下一阶段。

### P0：Schema 抽离 + SQLite Drizzle 化（不影响任何 route）

**目标**：把 30 张表从 `db.ts` 的 `CREATE TABLE` 迁到 `schema/sqlite/*.ts`，drizzle 接管连接，但 route 仍可用旧 `getDb()`。

**产出**：
- `src/lib/database/schema/sqlite/*.ts`（30 张表）
- `src/lib/database/client.ts`（SQLite 分支）
- `src/lib/database/dialect.ts`
- `migrations/sqlite/0000_baseline_from_legacy.sql`（冻结现有 schema）
- `migrations/sqlite/0001_drop_foreign_keys.sql`（重建表去 FK）
- `src/lib/database/index.ts` 暂时同时 export `db`（新）和 re-export `getDb`（旧）

**验证**：
- 现有 Electron 启动，`forge.db` 自动迁移，30 张表结构与原版逐字段一致
- 85 个 route 仍走 `getDb()`，行为零变化
- 全量 API 冒烟通过

**风险**：低，行为等价。最大风险是 baseline 与现有 schema 漂移，需逐表 `PRAGMA table_info` 对比。

**回滚**：删除 `src/lib/database/`，恢复 `db.ts`，用户数据无损（baseline 是只增不改）。

### P1：Repository 层建立 + 85 处 SQL 收敛

**目标**：建 18 个 repo，85 个 route 的 `.prepare()` 全部替换为 repo 调用，`db.ts` 删除。

**产出**：
- `src/lib/database/repositories/*.ts`（18 个 repo）
- 85 个 route 改造
- 删除 `src/lib/db.ts`
- 删除 `src/lib/database/index.ts` 的 `getDb` re-export

**验证**：
- 全 route 冒烟，每个 API 行为不变
- 每个 `repo.deleteById()` 写单测，断言子表对应行数为 0
- TypeScript 编译零 `as Record<string, unknown>`

**风险**：中，体力活，必须增量验证（每收一个表跑一次冒烟）。

**回滚**：git revert 该阶段的 commit 块。Repository 是纯增量，删除不影响 P0 已迁移的 schema。

**注意**：`webhook_tokens` 表的定义不在 `db.ts` 主函数，P0 启动前必须先 `grep` 定位它的定义来源，否则会漏表。

### P2：PG 支持

**目标**：`schema/pg/*.ts` 镜像 + `drivers/pg.ts`，`DB_DIALECT=pg` 跑通。

**产出**：
- `src/lib/database/schema/pg/*.ts`（30 张表）
- `src/lib/database/drivers/pg.ts`
- `migrations/pg/0000_init.sql`
- `client.ts` 增加 PG 分支（动态 import）

**验证**：
- `docker compose up postgres` 起 PG
- `DB_DIALECT=pg pnpm db:push:pg`
- 全 API 冒烟，与 SQLite 行为逐项对比
- Better-Auth 的 4 张表字段名与 Better-Auth 官方 PG schema 对齐

**风险**：中。主要在字段类型映射（`text` vs `timestamp` vs `numeric`）和 Better-Auth schema 对齐。

**回滚**：`DB_DIALECT` 切回 sqlite，PG 分支代码不执行。

### P3：MySQL 支持

**目标**：`schema/mysql/*.ts` 镜像 + `drivers/mysql.ts`，`DB_DIALECT=mysql` 跑通。

**产出**：
- `src/lib/database/schema/mysql/*.ts`（30 张表，处理 6 个 MySQL 陷阱）
- `src/lib/database/drivers/mysql.ts`
- `migrations/mysql/0000_init.sql`
- `client.ts` 增加 MySQL 分支（动态 import）

**验证**：
- `docker compose up mysql` 起 MySQL 8
- `DB_DIALECT=mysql pnpm db:push:mysql`
- 全 API 冒烟
- 重点测：unique 字段大小写（`utf8mb4_bin`）、TEXT default 缺失、UPSERT 语法、`||` 拼接

**风险**：高，方言细节最多。MySQL schema 与 sqlite/pg 的字段类型分叉最严重。

**回滚**：`DB_DIALECT` 切回 sqlite/pg。

### P4：文档与收尾

**产出**：
- `docs/database.md`：三方言连接配置、迁移命令、能力矩阵
- 删除 P0 的兼容代码（baseline 之后的旧 `getDb` 痕迹）
- `README` 更新部署说明

**验证**：文档评审。

---

## 13. 风险与回滚

### 13.1 风险矩阵

| 风险 | 概率 | 影响 | 缓解 |
|---|---|---|---|
| SQLite baseline 与现有 schema 漂移 | 中 | 高（用户数据丢失） | P0 逐表 `PRAGMA table_info` 对比，写 schema diff 测试 |
| Repository 级联删除遗漏某个子表 | 中 | 高（孤儿数据） | 每个 `delete()` 单测断言子表行数；孤儿清理 cron 兜底 |
| Better-Auth schema 字段名不对齐 | 中 | 中（登录失败） | P2/P3 严格按 Better-Auth 官方 schema，不自创字段名 |
| Electron 打包漏 externalize | 低 | 高（启动崩溃） | `serverExternalPackages` + `asarUnpack`，CI 打包后冒烟 |
| MySQL 大小写敏感差异 | 中 | 低（unique 冲突） | unique 字段 `utf8mb4_bin` collation |
| 动态 import 在 Electron main 进程失效 | 低 | 高（PG/MySQL 不可用） | P2/P3 在 Electron 打包后实测，非仅 `next dev` |

### 13.2 总体回滚策略

- **每阶段独立 commit 块**，git revert 即可回滚单阶段。
- **P0 完成后 `getDb()` 仍可用**，万一 P1 Repository 改造出问题，可快速回滚 route 而不丢 P0 的 schema 迁移。
- **Electron 用户数据无损**：baseline migration 是只增不改，回滚代码不会回滚已迁移的 schema。

---

## 14. 验收清单

### 14.1 P0 验收

- [ ] `src/lib/database/schema/sqlite/` 含 30 张表定义
- [ ] 现有 `forge.db` 升级后，`PRAGMA table_info` 与原版逐表逐字段一致
- [ ] `__drizzle_migrations` 表存在，baseline 标记已应用
- [ ] 85 个 route 行为零变化（全量冒烟）
- [ ] Electron 打包后启动正常

### 14.2 P1 验收

- [ ] `src/lib/database/repositories/` 含 18 个 repo
- [ ] 85 个 route 零 `.prepare()` 调用
- [ ] `src/lib/db.ts` 已删除
- [ ] TypeScript 编译零 `as Record<string, unknown>`（schema 导出的表）
- [ ] 每个 `repo.deleteById()` 有单测，断言子表行数为 0
- [ ] 全量 API 冒烟通过

### 14.3 P2 验收

- [ ] `DB_DIALECT=pg` 启动成功
- [ ] `pnpm db:push:pg` 建表成功
- [ ] 全 API 冒烟通过
- [ ] Better-Auth 登录/注册可用

### 14.4 P3 验收

- [ ] `DB_DIALECT=mysql` 启动成功
- [ ] `pnpm db:push:mysql` 建表成功
- [ ] 全 API 冒烟通过
- [ ] unique 字段大小写敏感行为与 PG/SQLite 一致
- [ ] UPSERT / 时间默认值 / `||` 拼接全部正确

### 14.5 跨阶段验收

- [ ] 同一份 Repository 代码，三方言下行为一致
- [ ] `docs/database.md` 含三方言连接配置与能力矩阵
- [ ] Electron 包体未因 PG/MySQL driver 显著膨胀（动态 import 验证）

---

## 附录：决策摘要

| 决策 | 选择 | 理由 |
|---|---|---|
| ORM | Drizzle | SQLite 一等公民、三方言 API 统一、无 native engine、类型推断全量 |
| 默认方言 | sqlite | Electron 单机部署形态决定 |
| 外键 | 全表无 FK | 三方言 FK 行为差异破坏 Repository 统一性；级联由事务承担 |
| 级联 | Repository `db.transaction()` | 显式、可控、三方言零改动 |
| JSON | 暂保持 text/varchar | 跨方言查询语义不同，等真有需求再升 |
| MySQL 大小写 | unique 字段 `utf8mb4_bin` | 与 PG/SQLite 行为对齐 |
| Driver import | SQLite 静态、PG/MySQL 动态 | Electron 打包友好 |
| 迁移 | drizzle-kit + baseline 快照 | 保护现有 Electron 用户数据 |
