存储:SQLite 与事件投影

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

作者 David更新于 第 36 篇(共 38 篇)

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 文件到事件投影

  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 与守护进程——lildax 如何在后台拉起这套服务。

本页目录