存储:SQLite 与事件投影
数据库文件放在哪、bun 与 node 两套驱动如何切换、19 张表与 38 个迁移怎么组织,以及 EventV2 如何把事件、序号和投影放进同一个事务。
OpenCode 的持久化收敛到了一个 SQLite 文件。packages/opencode/AGENTS.md 开头两条就是分工:Drizzle schema 在 packages/core 里,迁移也由 core 执行。
数据库在哪里
路径由 path() 决定:
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(参见会话与消息模型与 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 按顺序导入全部迁移。执行逻辑:
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):
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 则是“先重放、再追尾”的流:
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 文件到事件投影
- JSON 文件时代:
packages/opencode/src/storage/storage.ts按 key 读写.json文件,自带文件级迁移。它今天还在,只剩零星用途,比如撤销时写session_diff。 - 2026 年 1 月,SQLite 登场:第一个迁移建出
project、session、message、part等表;启动时曾有一段 JSON→SQLite 的一次性迁移,现已删除。 - 2026 年 3 月,事件表:迁移
20260323234822_events加入event与event_sequence。 - 删除
storage/db.ts:规格specs/storage/remove-opencode-db.md盘点出 65 处直接引用,分五组迁走:运行时与启动、SyncEvent事务边界、已有服务外壳的仓储、会话与消息读模型、零散的 CLI 与管理读写。五组均已完成,SyncEvent与 opencode 的投影器被移除,由 core 的 EventV2 与 projector 接管;原有的数据补算服务也被 core 迁移20260510033149_session_usage取代。
规格要求迁移全程守住三条不变量:事务里的嵌套读必须看到当前事务;序号分配保持 immediate 事务;提交之前绝不发布副作用。commitDurableEvent 的写法正对应这三条。
下一篇:新 CLI 与守护进程——lildax 如何在后台拉起这套服务。