作者:互联网 时间: 2026-07-15 19:27:15
// packages/core/src/event/sql.tsexport const EventSequenceTable = sqliteTable("event_sequence", {aggregate_id: text().notNull().primaryKey(), // ← 聚合根 ID(如 sessionID)seq: integer().notNull(), // ← 当前已提交的最大序列号owner_id: text(), // ← 聚合根的持有者(用于多节点互斥)})export const EventTable = sqliteTable("event", {id: text().$type<EventV2.ID>().primaryKey(),// ← 事件 UUIDaggregate_id: text().notNull().references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),seq: integer().notNull(),// ← 聚合内单调递增序列号type: text().notNull(),// ← 版本化类型(如 "session.next.step.ended.2")data: text({ mode: "json" }).notNull(),// ← JSON 序列化的事件 payload}, (table) => [uniqueIndex("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),])
两张表构成经典的 Event Sourcing 存储模型:

EventSequenceTable:每个聚合根一行,记录当前 seq 高水位和 owner_id。是"序列号分配器"。EventTable:追加写入的事件日志,(aggregate_id, seq) 唯一索引保证同一聚合内不会有重复序列号。EventV2.publish(definition, data) 的事务内做了哪些事?publish(definition, data, options?)└─ publishEvent(definition, event, options?.commit) └─ commitDurableEvent(definition, event, undefined, commit) // durable 事件└─ db.transaction { ... }// 单事务原子操作 └─ notify(event, isolateListeners=true)// 事务后通知
commitDurableEvent 的核心是 db.transaction 块(event.ts:237-363),所有操作在同一个 SQLite 事务中执行:
// packages/core/src/event.ts:237-363 (简化)yield* Effect.uninterruptible(Effect.gen(function* () {const committed = yield* db.transaction(() =>Effect.gen(function* () {// ── 步骤 1:读取当前聚合的序列号高水位 ──const row = yield* db.select({ seq, ownerID }).from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).get().pipe(Effect.orDie)const latest = row?.seq ?? -1// ── 步骤 2:幂等性检查(仅 replay 路径)──if (input && input.seq <= latest) {// 查已存储的同 seq 事件,比较 id + type + data 是否深相等const stored = yield* db.select().from(EventTable).where(and(eq(aggregate_id, aggregateID), eq(seq, input.seq))).get().pipe(Effect.orDie)if (stored?.id === event.id &&stored.type === versionedType(definition.type, durable.version) &&isDeepStrictEqual(stored.data, encoded)) {return// ← 幂等成功:事件已存在且内容一致,直接返回}yield* Effect.die(new InvalidDurableEventError({ ... "Replay diverged" }))}// ── 步骤 3:序列号分配 ──const seq = input?.seq ?? latest + 1// publish 分配 latest+1;replay 用指定 seq// ── 步骤 4:Event ID 唯一性检查 ──const stored = yield* db.select({ aggregateID, seq }).from(EventTable).where(eq(EventTable.id, event.id)).get().pipe(Effect.orDie)if (stored) yield* Effect.die(new InvalidDurableEventError({ ... "already exists" }))// ── 步骤 5:执行 Projector(在事务内!)──const committed = { ...event, durable: { aggregateID, seq, version: durable.version } }for (const projector of list) {yield* projector(committed)// ← 投影到 session/session_message/part 等读模型表}// ── 步骤 5b:执行用户提供的 commit 回调(在事务内!)──if (commit) yield* commit(seq) // ← 额外的本地操作投影// ── 步骤 6:持久化事件 + 更新序列号 ──yield* db.insert(EventSequenceTable).values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]).onConflictDoUpdate({ target: aggregate_id, set: { seq, ... } }).run().pipe(Effect.orDie)yield* db.insert(EventTable).values([{ id: event.id, aggregate_id: aggregateID, seq, type: versionedType(definition.type, durable.version), data: encoded }]).run().pipe(Effect.orDie)return { aggregateID, seq }}), { behavior: "immediate" }).pipe(Effect.orDie)// ── 事务后:唤醒 durable stream 订阅者 ──if (committed) {yield* Effect.forEach(pubsub.durable.get(committed.aggregateID) ?? [],(wake) => PubSub.publish(wake, undefined), { discard: true })}return committed}),)
这是最关键的设计决策——Projector 与事件写入在同一个 SQLite 事务中。这意味着:
session_message 表违反约束),整个事务回滚——事件不会写入 EventTable,序列号不会递增。不存在"事件已存储但投影未完成"的半完成状态。session/session_message/part)与事件日志在任何时刻都保持一致。查询读模型不需要"等待投影追上"。对比其他 Event Sourcing 系统(如 Kafka + CQRS):通常 Projector 是异步的,读模型有最终一致性延迟。opencode 选择同步投影是因为它运行在单进程 SQLite 上,事务开销极低,且 Agent 会话对一致性要求极高(不能出现"工具执行了但消息没显示"的状态)。
事务提交后,notify(event, isolateListeners=true) 执行:
// packages/core/src/event.ts:406-417function notify(event: Payload, isolateListeners: boolean) {return Effect.gen(function* () {yield* Effect.forEach(listeners, (listener) =>isolateListeners ? observe(event, listener) : listener(event), // ← 隔离失败{ discard: true })const typed = pubsub.typed.get(event.type)if (typed) yield* PubSub.publish(typed, event) // ← 类型订阅yield* PubSub.publish(pubsub.all, event)// ← 全局订阅})}
isolateListeners=true 时用 observe 包裹——单个 listener 失败不会影响其他 listener,只记录日志。这在事务提交后很重要:通知失败不应回滚已提交的事务。
aggregateID + seq 如何保证顺序和幂等?每个 durable 事件的 definition 指定 durable.aggregate 字段名(对 Session 事件统一为 "sessionID"):
// packages/schema/src/session-event.ts:38-43const options = {durable: {aggregate: "sessionID",// ← 从 data.sessionID 取值作为 aggregateIDversion: 1,},} as const
commitDurableEvent 从事件数据中提取这个字段:
// packages/core/src/event.ts:218-219const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
因此 aggregateID = sessionID,每个 Session 是一个独立的聚合根。seq 在每个聚合内从 0 开始单调递增。
latest + 1 分配publish 路径(非 replay)的序列号分配:
// packages/core/src/event.ts:294const seq = input?.seq ?? latest + 1// publish 时 input 为 undefined,分配 latest+1
latest 从 EventSequenceTable 读取,如果聚合不存在则 -1,所以第一个事件的 seq = 0。
并发安全:由于序列号分配在 db.transaction 内,SQLite 的 immediate 事务行为会在事务开始时获取写锁——两个并发的 publish 对同一聚合会串行化,第二个事务读到第一个事务提交后的 latest,不会冲突。
replay 路径(input.seq 指定)的幂等检查分三层:
第一层:序列号已存在时的内容深比较
// packages/core/src/event.ts:262-289if (input && input.seq <= latest) {const stored = yield* db.select().from(EventTable).where(and(eq(aggregate_id, aggregateID), eq(seq, input.seq))).get()if (stored?.id === event.id &&// ① ID 相同stored.type === versionedType(definition.type, ...) &&// ② 类型+版本相同isDeepStrictEqual(stored.data, encoded)) {// ③ 数据深相等// 幂等成功——可以补充 owner_id 后直接返回if (input.ownerID && row?.ownerID == null) {yield* db.update(EventSequenceTable).set({ owner_id: input.ownerID })...}return// ← 不重复写入,不重新投影}yield* Effect.die(new InvalidDurableEventError({ ... "Replay diverged" }))}
如果 seq 已存在但内容不一致——抛出 diverged 错误。这是 replay 的核心分歧检测机制(详见 Q3)。
第二层:Event ID 全局唯一检查
// packages/core/src/event.ts:303-315const stored = yield* db.select({ aggregateID, seq }).from(EventTable).where(eq(EventTable.id, event.id)).get()if (stored)yield* Effect.die(new InvalidDurableEventError({... `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`}))
即使 seq 是新的,如果 event.id 已存在(可能在不同聚合中),也拒绝写入。这防止了事件被重复提交到不同聚合。
第三层:序列号连续性检查
// packages/core/src/event.ts:295-301if (input && seq !== latest + 1) {yield* Effect.die(new InvalidDurableEventError({... `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`}))}
replay 时如果 seq 不是 latest + 1(跳号),直接报错。这保证了事件序列没有空洞。
EventSequenceTable.owner_id 用于多节点场景的聚合根互斥:
// packages/core/src/event.ts:254-261 (strictOwner 检查)if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {yield* Effect.die(new InvalidDurableEventError({... `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`}))}// packages/core/src/event.ts:291-293 (非 strict 时静默跳过)if (input && row?.ownerID && row.ownerID !== input.ownerID) {return// ← 不是自己的聚合,静默跳过}
这支持未来的 clustered ownership(runner/llm.ts:51 的 TODO 项):多个节点可以 replay 同一聚合的事件,但只有 owner 可以 publish 新事件。
replay 和 replayAll 如何检测事件分歧(divergence)?replay 接收一个 SerializedEvent(从外部存储或日志中恢复的序列化事件),通过 commitDurableEvent 的 input.seq 路径提交:
// packages/core/src/event.ts:441-478function replay(event: SerializedEvent, options?) {return Effect.gen(function* () {const definition = Durable.get(event.type)if (!definition?.durable) {yield* Effect.die(new InvalidDurableEventError({ ... "Unknown durable event type" }))} else {const payload = {id: event.id, type: definition.type,data: Schema.decodeUnknownSync(definition.data)(event.data),} as Payloadconst committed = yield* commitDurableEvent(definition, payload, {seq: event.seq,// ← 指定 seqaggregateID: event.aggregateID,// ← 指定 aggregateIDownerID: options?.ownerID,strictOwner: options?.strictOwner,})if (committed && options?.publish) {yield* notify({ ...payload, durable: { ... } }, true) // ← 可选通知}}})}
分歧检测发生在 commitDurableEvent 的第一层检查(§3.3):如果 input.seq <= latest,查已存储的同 seq 事件,比较 id + type + data:
Effect.die(InvalidDurableEventError("Replay diverged"))isDeepStrictEqual 是 Node.js 的深严格相等——比较两个值的结构、类型、值,不接受 -0 === 0 等隐式转换。这确保了分歧检测的精确性。
replayAll 在逐个 replay 之前做两个前置校验:
// packages/core/src/event.ts:480-512function replayAll(events: SerializedEvent[], options?) {return Effect.gen(function* () {const source = events[0]?.aggregateIDif (!source) return undefined// ── 校验 1:所有事件必须属于同一聚合 ──if (events.some((event) => event.aggregateID !== source)) {yield* Effect.die(new InvalidDurableEventError({ ... "must belong to the same aggregate" }))}// ── 校验 2:序列号必须连续递增 ──const start = events[0]?.seq ?? 0for (const [index, event] of events.entries()) {const seq = start + indexif (event.seq !== seq) {yield* Effect.die(new InvalidDurableEventError({... `Sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`}))}}// ── 逐个 replay ──for (const event of events) {yield* replay(event, options)}return source})}
这两个校验确保 replay 的事件批次是一个完整的、连续的聚合历史片段。如果批次内部就不一致(跨聚合或跳号),无需等到逐个 replay 才发现错误。
假设聚合 ses_abc 已有序列 0, 1, 2,现在 replay 一批事件 seq=1, 2, 3:
replay(seq=1):1 <= 2(latest),查已存储的 seq=1 事件 → 比较 id+type+data → 一致 → 幂等返回replay(seq=2):2 <= 2(latest),查已存储的 seq=2 事件 → 比较 → 一致 → 幂等返回replay(seq=3):3 > 2(latest),新事件 → 检查 seq === latest+1(3) → 通过 → 写入如果步骤 1 中存储的事件 data 与 replay 的 data 不一致 → Effect.die("Replay diverged"),整个 replayAll 中止。
projector.ts 的 layer 注册了 28 个投影器,覆盖 Session 生命周期的全部事件。投影目标为 6 张表:
| 目标表 | 用途 |
|---|---|
SessionTable | Session 元信息(标题、agent、model、cost、tokens、revert 状态) |
MessageTable | V1 旧消息表(MessageUpdated/MessageRemoved) |
PartTable | V1 旧消息部件表(PartUpdated/PartRemoved) |
SessionMessageTable | V2 新消息表(Step/Text/Tool/Reasoning/Compaction 等) |
SessionInputTable | 用户输入队列(Prompted/PromptAdmitted) |
WorkspaceTable | 工作区时间戳(Session.Created 时更新) |
SessionTable(8 个)| 事件 | 投影行为 |
|---|---|
SessionV1.Event.Created | INSERT INTO SessionTable + 更新 WorkspaceTable.time_used |
SessionV1.Event.Updated | UPDATE SessionTable SET ... |
SessionEvent.Moved | UPDATE SessionTable SET directory/path/workspace_id + SessionContextEpoch.reset |
SessionV1.Event.Deleted | DELETE FROM SessionTable |
SessionEvent.AgentSwitched | UPDATE SessionTable SET agent + run(db, event) |
SessionEvent.ModelSwitched | UPDATE SessionTable SET model + run(db, event) |
SessionEvent.RevertEvent.Staged | UPDATE SessionTable SET revert = ... |
SessionEvent.RevertEvent.Cleared | UPDATE SessionTable SET revert = null |
SessionEvent.RevertEvent.Committed | DELETE FROM SessionMessageTable WHERE seq > boundary + 清理输入 + 清除 revert |
MessageTable / PartTable(3 个)| 事件 | 投影行为 |
|---|---|
SessionV1.Event.MessageUpdated | INSERT INTO MessageTable ON CONFLICT DO UPDATE |
SessionV1.Event.MessageRemoved | 撤销 usage → DELETE FROM MessageTable |
SessionV1.Event.PartUpdated | INSERT INTO PartTable ON CONFLICT DO UPDATE + usage 增量更新 |
SessionV1.Event.PartRemoved | 撤销 usage → DELETE FROM PartTable |
SessionInputTable(2 个)| 事件 | 投影行为 |
|---|---|
SessionEvent.Prompted | SessionInput.projectPrompted(设置 promoted_seq)+ run(db, event) |
SessionEvent.PromptAdmitted | SessionInput.projectAdmitted(设置 admitted_seq) |
run(db, event) 写 SessionMessageTable(15 个)这 15 个事件都委托给 run(db, event) → SessionMessageUpdater.update(adapter, event),后者使用 Immer produce 不可变更新助手消息的部件:
| 事件 | message-updater 行为 |
|---|---|
AgentSwitched | appendMessage(AgentSwitched) |
ModelSwitched | appendMessage(ModelSwitched) |
ContextUpdated | appendMessage(System) |
Synthetic | appendMessage(Synthetic) |
Shell.Started | appendMessage(Shell) |
Shell.Ended | updateShell(设置 output + completed) |
Step.Started | 完成当前 assistant → appendMessage(Assistant) |
Step.Ended | updateAssistant(设置 completed + finish + cost + tokens + snapshot) |
Step.Failed | updateAssistant(设置 completed + error) |
Text.Started | updateAssistant(push AssistantText part) |
Text.Ended | updateAssistant(设置 text 最终值) |
Tool.Input.Started | updateAssistant(push AssistantTool part, status=pending) |
Tool.Input.Ended | updateAssistant(设置 tool.input) |
Tool.Called | updateAssistant(status=running, 设置 input + provider) |
Tool.Progress | updateAssistant(更新 structured + content) |
Tool.Success | updateAssistant(status=completed, 设置 result + content + outputPaths) |
Tool.Failed | updateAssistant(status=error, 设置 error) |
Reasoning.Started | updateAssistant(push AssistantReasoning part) |
Reasoning.Ended | updateAssistant(设置 text 最终值) |
Compaction.Ended | appendMessage(Compaction) |
注意:Text.Delta、Reasoning.Delta、Tool.Input.Delta、Compaction.Delta 这些实时流事件没有 durable 配置,不会写入事件日志,只通过 notify 实时推送。它们在 message-updater 中有处理逻辑(如 text.delta 追加文本),但仅在内存 adapter 中生效——持久化的是 text.ended 的最终值。
run(db, event) 的 Adapter 模式// packages/core/src/session/projector.ts:112-191function run(db: DatabaseService, event: SessionEvent.Event) {return Effect.gen(function* () {const adapter: SessionMessageUpdater.Adapter = {getCurrentAssistant() { /* 查最新的未完成 assistant 行 */ },getAssistant(messageID) { /* 查指定 assistant 行 */ },getCurrentShell(callID) { /* 查最新的匹配 callID 的 shell 行 */ },updateAssistant: updateMessage, // ← UPDATE SessionMessageTableupdateShell: updateMessage, // ← UPDATE SessionMessageTableappendMessage,// ← INSERT SessionMessageTable}yield* SessionMessageUpdater.update(adapter, event)})}
message-updater.ts 的 update 函数用 SessionEvent.All.match(event, { ... }) 做模式匹配——一个巨大的 switch,每种事件类型对应一个 Immer recipe。Adapter 抽象让同一套更新逻辑可以工作在 SQLite(projector.ts)和内存(memory() adapter,用于测试)两种后端上。
PartUpdated 和 PartRemoved/MessageRemoved 涉及 cost/token 的增量更新:
// packages/core/src/session/projector.ts:312-329 (PartUpdated)yield* events.project(SessionV1.Event.PartUpdated, (event) =>Effect.gen(function* () {// ...const previous = row && usage(row.data) // ← 旧 part 的 usageconst next = usage(event.data.part)// ← 新 part 的 usageif (previous) yield* applyUsage(db, row.session_id, previous, -1)// ← 减去旧值if (next) yield* applyUsage(db, sessionID, next)// ← 加上新值}),)
applyUsage 用 SQL 增量表达式 `cost + ${value.cost * sign}` 更新 SessionTable。sign = -1 时是减法——用于 PartRemoved 或 PartUpdated 的"先减旧值再加新值"。这保证了即使 part 被更新或删除,Session 的累计 cost/tokens 仍然准确。
RevertEvent.Committed 如何通过 seq 边界截断消息实现"回滚"?回滚功能允许用户"撤销到某条消息"——把该消息之后的所有状态变更"抹掉"。这通过三个事件实现:
RevertEvent.Staged:暂存回滚意图(记录目标 messageID + files),写入 SessionTable.revert 字段RevertEvent.Cleared:取消回滚意图,清除 SessionTable.revert 字段RevertEvent.Committed:执行回滚——截断消息 + 清理输入 + 清除意图RevertEvent.Committed 的投影逻辑// packages/core/src/session/projector.ts:415-454yield* events.project(SessionEvent.RevertEvent.Committed, (event) =>Effect.gen(function* () {// ── 步骤 1:查找边界消息的 seq ──const boundary = yield* db.select({ seq: SessionMessageTable.seq }).from(SessionMessageTable).where(and(eq(SessionMessageTable.session_id, event.data.sessionID),eq(SessionMessageTable.id, event.data.messageID), // ← 目标回滚消息)).get().pipe(Effect.orDie)if (!boundary) return yield* Effect.die(`Revert boundary message not found: ${event.data.messageID}`)// ── 步骤 2:截断 SessionMessageTable(删除 seq > boundary 的所有消息)──yield* db.delete(SessionMessageTable).where(and(eq(SessionMessageTable.session_id, event.data.sessionID),gt(SessionMessageTable.seq, boundary.seq),// ← 只删 seq 大于边界的)).run().pipe(Effect.orDie)// ── 步骤 3:截断 SessionInputTable(删除 admitted_seq 或 promoted_seq > boundary 的输入)──yield* db.delete(SessionInputTable).where(and(eq(SessionInputTable.session_id, event.data.sessionID),or(gt(SessionInputTable.admitted_seq, boundary.seq),// ← 回滚后提交的输入gt(SessionInputTable.promoted_seq, boundary.seq),// ← 回滚后提升的输入),)).run().pipe(Effect.orDie)// ── 步骤 4:清除 revert 意图 ──yield* db.update(SessionTable).set({ revert: null, time_updated: DateTime.toEpochMillis(event.data.timestamp) }).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)// ── 步骤 5:重置上下文 epoch ──yield* SessionContextEpoch.reset(db, event.data.sessionID)}),)
seq 边界截断的精妙之处回滚的核心是 gt(SessionMessageTable.seq, boundary.seq)——按 seq 而非 messageID 截断。这利用了 Event Sourcing 的序号天然时间线特性:
SessionMessageTable 行的 seq 来自事件的 event.durable.seq(insertMessage 函数 projector.ts:203)。seq 是聚合内单调递增的,所以 seq > boundary.seq 等价于"在边界消息之后创建的所有消息"。DELETE WHERE seq > boundary 就能截断所有 assistant 消息、tool 调用、text 部件、reasoning 部件等——因为它们都存储在同一张 SessionMessageTable 中,通过 seq 排序。SessionInputTable?回滚后,在边界之后提交的用户输入(steer/queue)也应该被清除——否则 runner 重启时会尝试 promote 这些输入,但它们对应的状态已经被截断了。
清理条件是 admitted_seq > boundary OR promoted_seq > boundary:
admitted_seq > boundary:用户在回滚点之后提交了新输入promoted_seq > boundary:用户在回滚点之前提交但之后被提升的输入这两个条件确保回滚后的输入队列与消息历史一致。
关键:RevertEvent.Committed 不删除 EventTable 中的事件。它只截断读模型(SessionMessageTable/SessionInputTable),事件日志保持完整。
这意味着:
RevertEvent.Committed),记录了回滚到哪个边界。replayAll 从事件日志重建任意时间点的状态——包括回滚前的状态。insertMessage 用 INSERT 而非 UPSERT,被删除的行不会被事件日志中的旧事件重新创建(除非显式 replay)。这是 Event Sourcing 的经典权衡:读模型是"当前状态投影",事件日志是"完整历史"。回滚修改的是投影,不是历史。
基于上述 Event Sourcing 架构,opencode V2 的崩溃恢复分三层:
第一层:事务原子性
commitDurableEvent 在单个 SQLite 事务中完成"事件写入 + Projector 执行 + 序列号更新"。如果崩溃发生在事务提交前,所有变更丢失——状态一致。如果崩溃发生在事务提交后,所有变更已持久化——状态一致。不存在中间状态。
第二层:事件日志重放
如果读模型(SessionMessageTable 等)损坏或不一致,可以从 EventTable 重放。replayAll 逐个事件通过 commitDurableEvent 的 input.seq 路径提交——如果同 seq 事件已存在且内容一致(幂等),直接跳过;如果内容不一致(diverged),报错。这让重放是安全的——不会产生重复或冲突。
第三层:durable stream 续传
durable(aggregateID, after?) 返回一个 Stream,先读取 after 之后的历史事件,然后订阅实时 PubSub 通知:
// packages/core/src/event.ts:585-604const durable = (input: { aggregateID: string; after?: number }) =>Stream.unwrap(Effect.gen(function* () {const wakes = yield* subscribeDurable(input.aggregateID)let sequence = input.after ?? -1const 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), // ← 被 wake 唤醒后增量读Stream.flattenIterable,)return Stream.concat(Stream.fromIterable(historical), live)}))
subscribeDurable 注册一个 PubSub.sliding(1) 到 pubsub.durable.get(aggregateID)。每次该聚合有新事件提交时,commitDurableEvent 事务后会 PubSub.publish(wake, undefined) 唤醒订阅者,订阅者用 readAfter(aggregateID, sequence) 增量读取新事件。
sliding(1) 策略只保留最后一个 wake 信号——多次 publish 合并为一次读取,避免高频事件下的重复查询。
V1 的状态管理是"内存优先 + SQLite 持久化"——SessionV1 在内存中维护消息列表,通过 SessionV1.Event 事件同步到 SQLite。崩溃后从 SQLite 恢复,但恢复逻辑分散在各处。
V2 的状态管理是"事件日志为唯一真相源"——所有状态变更先写事件日志(EventTable),Projector 在事务内同步投影到读模型。崩溃后只需重放事件日志即可重建全部状态。读模型是"事件日志的物化视图",可以随时删除重建。
这种设计让 V2 具备了 V1 不具备的能力:
RevertEvent.Committed 通过 seq 边界截断,而非 V1 的"删除消息列表中的元素"。replay 的深比较能发现事件日志与本地状态的不一致。owner_id + strictOwner 支持未来的 clustered ownership。after 位置续传,不会丢失事件。| 概念 | 文件 | 行号 |
|---|---|---|
commitDurableEvent 事务体 | packages/core/src/event.ts | 205-367 |
| Projector 事务内执行 | 同上 | 320-322 |
序列号分配 latest + 1 | 同上 | 294 |
幂等深比较 isDeepStrictEqual | 同上 | 269-273 |
分歧检测 Replay diverged | 同上 | 284-289 |
| Event ID 唯一性检查 | 同上 | 303-315 |
| 序列号连续性检查 | 同上 | 295-301 |
| owner_id 互斥 | 同上 | 254-261, 291-293 |
publishEvent + notify | 同上 | 369-396 |
replay | 同上 | 441-478 |
replayAll 前置校验 | 同上 | 480-512 |
durable stream | 同上 | 585-604 |
subscribeDurable PubSub | 同上 | 565-583 |
EventSequenceTable / EventTable 定义 | packages/core/src/event/sql.ts | 4-25 |
SessionProjector 28 个投影器注册 | packages/core/src/session/projector.ts | 211-456 |
run(db, event) Adapter 模式 | 同上 | 112-191 |
insertMessage(seq 来自事件) | 同上 | 193-209 |
RevertEvent.Committed 截断逻辑 | 同上 | 415-454 |
applyUsage 增量更新 | 同上 | 90-110 |
SessionMessageUpdater.update 模式匹配 | packages/core/src/session/message-updater.ts | 78-395 |
| 事件定义 + durable 配置 | packages/schema/src/session-event.ts | 38-43, 54+ |
Event.define 工厂 | packages/schema/src/event.ts | — |
Durable manifest | packages/schema/src/durable-event-manifest.ts | — |