# 存储：SQLite 与事件投影

> 数据库文件放在哪、bun 与 node 两套驱动如何切换、19 张表与 38 个迁移怎么组织，以及 EventV2 如何把事件、序号和投影放进同一个事务。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/storage
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

OpenCode 的持久化收敛到了一个 SQLite 文件。`packages/opencode/AGENTS.md` 开头两条就是分工：Drizzle schema 在 `packages/core` 里，迁移也由 core 执行。

## 数据库在哪里

路径由 `path()` 决定：

```ts
export function path() {
  if (Flag.OPENCODE_DB) {
    if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
    return join(Global.Path.data, Flag.OPENCODE_DB)
  }
  if (
    ["latest", "beta", "prod"].includes(InstallationChannel) ||
    process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
    process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
  )
    return join(Global.Path.data, "opencode.db")
  return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
```

`packages/core/src/database/database.ts:43`。`Global.Path.data` 是 XDG 数据目录下的 `opencode/`（Linux 上通常是 `~/.local/share/opencode/`）。正式渠道共用 `opencode.db`，其他渠道各用 `opencode-<渠道>.db`，互不污染；`OPENCODE_DB` 可以指向任意文件或 `:memory:`。旧 CLI 的 `opencode db path` 打印这个路径，`opencode db` 打开 `sqlite3` 交互 shell，也能直接跑一条 SQL。

同目录的 `path.ts` 不管数据库位置，而是一组 Drizzle 列类型：把 Windows 路径的反斜杠统一存成正斜杠、读出时再转回，迁移 `20260601010001_normalize_storage_paths` 修正了历史数据。

打开连接后，`Database` 层依次设置 `journal_mode = WAL`、`synchronous = NORMAL`、`busy_timeout = 5000`、`cache_size = -64000`、`foreign_keys = ON`，做一次 `wal_checkpoint(PASSIVE)`，然后跑迁移。

## 两套驱动，一个接口

`packages/core/package.json` 用条件导入选驱动：`#sqlite` 在 bun 条件下指向 `sqlite.bun.ts`（`bun:sqlite`），在 node 条件下指向 `sqlite.node.ts`（Node 自带的 `node:sqlite`），默认 bun。两个文件几乎逐行对应：都把同步驱动包成 Effect 的通用 `SqlClient`，只有一个连接，用 `Semaphore(1)` 串行化，事务期间一直占着这把锁直到事务作用域结束。

再往上是 Drizzle。`packages/effect-drizzle-sqlite` 把 Drizzle 尚未正式发布的 `effect-sqlite` 适配器搬进仓库：查询构造器可以直接 `yield*`，事务本身就是 Effect 值。它刻意保持通用，不放 opencode 的表、路径与迁移，以便上游发布后整体替换；规格 `specs/storage/effect-sqlite-package.md` 的理由是先让 Drizzle SQLite Effect 化，各类存储才能共享同一套事务与迁移模型。`packages/effect-sqlite-node` 则是基于 `node:sqlite` 的 `SqlClient` 实现，core 声明了这个依赖，但源码实际用的是自带的 `sqlite.node.ts`。

## 19 张表

Drizzle 表定义散在各领域目录的 `sql.ts`（以及 `workspace.sql.ts`、`data-migration.sql.ts`）里，共 19 张：

| 领域 | 表 |
| --- | --- |
| 项目与工作区 | `project`、`project_directory`、`workspace` |
| 会话（两代共用） | `session`、`todo` |
| 会话（V1 形状） | `message`、`part` |
| 会话（V2） | `session_message`、`session_input`、`session_context_epoch` |
| 事件 | `event`、`event_sequence` |
| 权限与凭据 | `permission`、`credential` |
| 账号与分享 | `account`、`account_state`、`control_account`、`session_share` |
| 其他 | `data_migration` |

V1 的消息与分片在 `message`、`part`，V2 的投影消息在 `session_message`（参见[会话与消息模型](https://daiw.net/manual/opencode/session-model)与 [V2 会话内核](https://daiw.net/manual/opencode/session-v2)）。

## 38 个迁移

迁移放在 `packages/core/src/database/migration/`，文件名是“14 位时间戳 + 名字”，从 `20260127222353_familiar_lady_ursula` 到 `20260622202450_simplify_session_input` 共 38 个；名字有的是 drizzle-kit 式的随机词组，有的是描述性的（如 `session_input_inbox`）。每个迁移就是 `{ id, up(tx) }`，`up` 里逐条 `tx.run(SQL)`，`migration.gen.ts` 按顺序导入全部迁移。执行逻辑：

```ts
export function apply(db: Database) {
  return lock.withPermit(
    Effect.gen(function* () {
      const tables = yield* db.all<{ name: string }>(
        sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
      )
      if (tables.some((table) => table.name === "session")) return yield* applyOnly(db, migrations)
      if (tables.length > 0) return yield* Effect.die("Database is not empty and has no session table")
      yield* db.transaction((tx) =>
        Effect.gen(function* () {
          yield* schema.up(tx)
          // ...
        }),
      )
    }),
  )
}
```

`packages/core/src/database/migration.ts:18`（删减后）。空库不回放历史，而是执行 `schema.gen.ts` 里的当前全量 schema（19 条 `CREATE TABLE`），再把所有迁移记为已完成；有 `session` 表的老库逐个补跑未完成的迁移，每个一个事务；非空却没有 `session` 表的库拒绝启动。更早的安装用 drizzle-kit 的 `__drizzle_migrations` 日志，`applyOnly` 会先据此给新的 `migration` 表播种，避免旧 SQL 被重放。

## EventV2：事件、序号与投影同一个事务

`packages/core/src/event.ts` 是 V2 的事件总线。定义里带 `durable`（聚合字段与版本号）的事件是持久事件。发布时，一个 `behavior: "immediate"` 事务里依次完成：读出聚合在 `event_sequence` 里的最新序号并分配下一个、运行注册的投影器、执行可选的 `commit` 钩子、更新序号表、插入 `event` 行（类型带版本后缀，如 `session.next.prompted.1`）：

```ts
                          for (const projector of list) {
                            yield* projector(committed)
                          }
                          if (commit) yield* commit(seq)
```

`packages/core/src/event.ts:320`。投影器失败则整个事务回滚，事件与读模型要么同时存在、要么都不存在；`commit` 钩子是只随新事件提交、不参与重放的本地投影，Context Epoch 推进快照用的就是它。提交之后才通知订阅者；非持久事件只通知内存里的监听者。

读取侧有两种。`readAggregate` 分页读一个聚合，是 `/history` 接口的实现；`durable` 则是“先重放、再追尾”的流：

```ts
            const wakes = yield* subscribeDurable(input.aggregateID)
            let sequence = input.after ?? -1
            const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
              Effect.tap((events) =>
                Effect.sync(() => {
                  sequence = events.at(-1)?.durable?.seq ?? sequence
                }),
              ),
            )
            const historical = yield* read
            const live = Stream.fromSubscription(wakes).pipe(
              Stream.mapEffect(() => read),
              Stream.flattenIterable,
            )
```

`packages/core/src/event.ts:588`。先订阅、后读历史，交接时就不会漏掉提交；唤醒信号是容量为 1 的 sliding PubSub，只表示“有新行了”，每次醒来都重查 SQLite——真相在行里，不在内存通知里。`replay`、`replayAll` 用于工作区同步：带着 `ownerID` 按原序号写入，同序号内容一致就幂等跳过，不一致即报 `Replay diverged`。

## 演进：从 JSON 文件到事件投影

1. **JSON 文件时代**：`packages/opencode/src/storage/storage.ts` 按 key 读写 `.json` 文件，自带文件级迁移。它今天还在，只剩零星用途，比如撤销时写 `session_diff`。
2. **2026 年 1 月，SQLite 登场**：第一个迁移建出 `project`、`session`、`message`、`part` 等表；启动时曾有一段 JSON→SQLite 的一次性迁移，现已删除。
3. **2026 年 3 月，事件表**：迁移 `20260323234822_events` 加入 `event` 与 `event_sequence`。
4. **删除 `storage/db.ts`**：规格 `specs/storage/remove-opencode-db.md` 盘点出 65 处直接引用，分五组迁走：运行时与启动、`SyncEvent` 事务边界、已有服务外壳的仓储、会话与消息读模型、零散的 CLI 与管理读写。五组均已完成，`SyncEvent` 与 opencode 的投影器被移除，由 core 的 EventV2 与 projector 接管；原有的数据补算服务也被 core 迁移 `20260510033149_session_usage` 取代。

规格要求迁移全程守住三条不变量：事务里的嵌套读必须看到当前事务；序号分配保持 immediate 事务；提交之前绝不发布副作用。`commitDurableEvent` 的写法正对应这三条。

下一篇：[新 CLI 与守护进程](https://daiw.net/manual/opencode/cli-daemon)——lildax 如何在后台拉起这套服务。
