事件是流水,状态是折叠出来的投影
除了表面投影,Harness 还有另一类跨事件折叠出业务状态的投影:SessionProjectionRegistry 把每个 ProjectionDefinition 装进一个 UnitCell,随 session/event 急切 drive,靠 Object.is 同引用比较决定下游是否重算。goal 就是一个实例。
本章以中文撰写
上一章讲的表面投影,折出来的是”模型能看到什么消息”。但 Harness 里还有另一类状态——当前目标是什么、待办列表有几项、动态上下文快照是什么——它们不是一条事件能说清的,而是随一长串事件慢慢累积、不断修正出来的业务视图。这类”跨事件折叠”,由本章的 SessionProjectionRegistry 统一管理。它和表面投影共享同一条 append-only 账本,却折出另一组结果:表面给模型看,投影给业务逻辑和 UI 看。
上一章 第 24 章 拆了 SurfaceManager 怎么折消息。本章回答:除了消息,账本还能折出哪些状态,这套”注册一个投影单元、让它随事件自动累积”的机制是怎么跑起来的。
两类投影:一个折消息,一个折业务状态
先把两类投影分清楚,否则很容易混。
| 表面投影(Surface) | SessionProjection | |
|---|---|---|
| 折出什么 | 模型可见的有序消息历史 | 跨事件累积的业务状态 |
| 谁拥有 | SurfaceManager | SessionProjectionRegistry |
| 典型例子 | nodes 节点顺序、deriveEventMessage | goal 当前目标、todo 列表、运行时上下文 |
| 触发 | append 时增量折表面 | session/event 时 drive 注册单元 |
| 下游消费者 | 下次 LLM 请求的 messages | UI、业务逻辑、持久化缓存 |
表面投影回答”模型这一轮看到哪几条消息”;SessionProjection 回答”跑了这么久,现在的目标是什么、上下文快照是什么”。两者都从同一本账本现算,都不独立存状态,差别只在”折出来喂给谁”。
为什么要把业务状态也做成投影,而不是一个普通的可变字段? 因为和表面一样,业务状态也要求”可重放、可崩溃恢复”。goal 不是某次 append 时随手写进去的一个变量,而是”从会话开头到现在,所有 goal/change 事件折叠出来的结果”。把它做成投影,崩溃后只要重放账本就能重建,不需要单独维护一份数据库。
本节源码依据(2 处)
ProjectionDefinition:一份纯函数契约
每个投影单元是一份类型化契约:
interface ProjectionDefinition<K, S> {
key: K // 它拥有的状态槽
stateSchema: ZodType<S> // 校验持久化状态
init(header, inheritedEventCount): S // 空日志起步
apply(state: S, event: SessionEvent): S // pure transition
wire?: { viewSchema; view(state): V } // 客户端视图
stateVersion: number // 持久化缓存失效版本
}
逐件看:
key——这个投影单元在SessionProjectionStateMap里拥有哪个状态槽。比如 goal 占的就是'goal'这个 key。stateSchema——一份 zod schema,用来校验”从持久化缓存恢复出来的状态”是不是合法的。注释:“Validates persisted state before it seeds a fold”。init(header, inheritedEventCount)——给定空日志和不可变 header,给出初始状态。注意它接收inheritedEventCount——fork 来的会话继承了多长前缀,投影要据此起步。apply(state, event)——核心纯函数:旧 state + 一条事件 → 新 state。wire——可选的客户端视图。host-only 单元可以省略;带 wire 的会定义viewSchema(出 host 前校验)和view(state)(状态 → 发给客户端的值)。stateVersion——持久化缓存的失效版本。序列化字段或折叠语义一变就 bump,旧版本缓存行直接作废。
| 契约字段 | 职责 | 谁来实现 |
|---|---|---|
key | 标识状态槽 | 每个单元自己声明 |
stateSchema | 校验持久化状态 | 配 zod schema |
init | 空日志起步 | 接收 header + inheritedEventCount |
apply | 旧 state + 事件 → 新 state | 纯函数 |
wire.view | 状态 → 客户端视图 | host-only 可省略 |
stateVersion | 缓存失效版本 | 语义变更时 bump |
为什么要把契约压成”纯函数 + zod”,而不是一个带方法的 class? 因为纯函数最好重放、最好测试、最好持久化。一个 (state, event) => state 没有任何隐式状态——给它同一条事件序列,它永远折出同一个 state。这让崩溃恢复退化成”从 init 起步,把账本里的事件逐条 apply 一遍”,不需要任何额外的状态迁移逻辑。
本节源码依据(6 处)
whole-value event rule:同引用就是零工作
契约里最容易被忽略、却最关键的一条注释写在 apply 上:
“A unit uninterested in an event MUST return the same state reference — an unchanged reference (
Object.is) produces zero downstream work.”
这就是 whole-value event rule:一个投影单元如果对某条事件不感兴趣(比如 goal 投影只关心 goal/change),它必须返回原来那个 state 引用,而不是返回一个内容相同的新对象。
为什么这条是硬规定?因为注册表判断”要不要重算下游”,靠的就是 Object.is(prevState, nextState):
- 引用相同(
Object.is为真)→ 内容必然没变 → 不触发任何下游重算。 - 引用不同(即使内容逐字相同)→ 下游当成”状态变了”,全部重算一遍。
这把”状态变没变”的判定,从”深比较内容”降级成了”比较引用”——O(1),且零分配。前提是:不感兴趣的事件必须老老实实返回同引用。
// goal 投影的写法:不感兴趣就 return state(同一个引用)
if (event.type !== 'goal/change'
&& (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state
| apply 的返回 | 下游判定 | 成本 |
|---|---|---|
| 同一引用(不感兴趣) | Object.is 真 → 不重算 | 零 |
| 新引用(即使内容相同) | Object.is 假 → 全重算 | 冗余 |
| 真正变了的新引用 | 重算(正确) | 必要 |
为什么不直接深比较内容,而要靠”返回同引用”这个约定? 因为深比较一条可能很大的业务状态,每来一条事件都做一次,本身就是浪费。让投影作者自己负责”没改就别造新对象”,把比较压成引用相等——这是函数式数据结构里标准的”结构共享换增量”思路。契约把这条写成 MUST,正是为了避免一个不小心 return {...state} 就让整个下游白跑。
本节源码依据(4 处)
注册表与 UnitCell:随事件急切 drive
SessionProjectionRegistry 是一个 Cordis Service。它在构造时做两件事:
constructor(ctx: Context) {
super(ctx, 'sessionProjections')
ctx.on('session/created', (session) => {
if (session.seq !== 0) return // 只处理全新会话
for (const registration of this.registrations.values()) {
if (registration.cells.has(session)) continue
registration.cells.set(session, {
state: registration.def.init(session.header, session.inheritedEventCount),
observedSeq: -1,
views: [undefined, undefined],
})
}
})
ctx.on('session/event', (session, event) => {
this.drive(session, event) // 每条事件急切 drive
})
}
两件事:
session/created(且seq===0):为每个已注册的投影单元,在该 session 上建一个UnitCell。state从init(header, inheritedEventCount)起步,observedSeq=-1,views=[undefined, undefined]。session/event:每条已提交事件都调drive(session, event)——把事件喂给每个单元的apply,得到新 state,再算 view 与上一次做Object.is比较,没变就不发布。
UnitCell 就是每个 session × 每个投影单元的运行时状态:
interface UnitCell {
state: unknown
observedSeq: SessionSeqCursor // 已 apply 到哪条事件(无论是否变更)
readonly views: [unknown, unknown] // [prevView, currentView]
}
注意 cells 是一个 WeakMap<Session, UnitCell>——session 没人引用了,cell 自动回收。
| 时机 | 注册表动作 |
|---|---|
session/created(seq===0) | 为每个注册单元建 UnitCell,state=init(…) |
session/event | drive:apply → 算 view → Object.is 比较 → 变了才发布 |
| session 释放 | WeakMap 自动回收 cell |
为什么用 WeakMap 挂 cell,而不是每个 session 上直接挂字段? 因为投影单元是”全局注册”的,session 是”临时创建销毁”的。WeakMap 让注册表不用自己管理”哪个 session 死了要清掉它的单元”——session 对象一旦不可达,它的所有 cell 跟着被 GC。这把生命周期管理从手动簿记变成了语言自带的回收。
本节源码依据(6 处)
实例:goal 投影怎么写
goal 是一个完整例子。它的投影函数只认两类事件:
export function applyGoalProjection(state, event) {
if (state.failure !== null) return state // 已经失败,不再 fold
if (event.type !== 'goal/change'
&& (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state
const folded = goalFoldState(state)
try {
applyGoalEvent(folded, event)
return goalProjectionState(folded)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { ...state, failure: `goal replay failed at session event ${event.seq}: ${message}` }
}
}
export const goalProjectionDefinition = {
key: 'goal',
stateSchema: goalProjectionStateSchema,
init: () => ({ current: null, seenGoalIds: [], failure: null }),
apply: applyGoalProjection,
wire: { viewSchema: goalProjectionSchema, view: state => state.current },
stateVersion: 6,
} satisfies ProjectionDefinition<'goal', GoalProjectionState>
三个值得学的设计:
- 不感兴趣就同引用返回——第一行
if (state.failure !== null) return state和第二行的类型过滤,都是 whole-value rule 的体现。 - fold 失败记字段、不崩 drive——try/catch 把 goal fold 的异常接住,写成
state.failure = 'goal replay failed at session event N'。一个投影单元折炸了,只标记自己失败,不让整个注册表 drive 崩掉。 - view 只露当前值——
view: state => state.current把内部的seenGoalIds、failure都藏起来,只把”当前目标”发给客户端。
| 投影契约字段 | goal 实例的取值 |
|---|---|
key | 'goal' |
init | {current:null, seenGoalIds:[], failure:null} |
apply | 只认 goal/change 与 goal 来源 user/message |
view | state => state.current |
stateVersion | 6 |
为什么 fold 失败要记一个 failure 字段,而不是直接抛断 drive? 因为 goal 只是众多投影单元之一。如果它折一条坏事件就把整个注册表 drive 炸了,表面投影、其他业务投影全都跟着停摆——一个单元的 bug 不该拖垮所有人。把失败降级成”该单元进入 failure 态、不再 fold”,故障被隔离在单元内部,这是注册表作为”共享基础设施”必须有的韧性。
本节源码依据(4 处)
小结
- 两类投影分工:表面投影折模型可见消息,SessionProjection 折跨事件业务状态(goal、todo、上下文)。
- ProjectionDefinition 是纯函数契约:key + stateSchema + init + apply + wire + stateVersion,给同一事件序列永远折出同一 state。
- whole-value event rule:apply 对不感兴趣的事件必须返回同引用,Object.is 不变就零下游工作。
- 注册表急切 drive:session/created 建 UnitCell,session/event 折叠,WeakMap 自动回收。
- goal 是范本:只认自己的事件、fold 失败记 failure 不崩全局、view 只露当前值。
下一章:第 26 章
只读验证
# 验证源码身份(应输出 477b4f420553e8a52c2fbccc464d7561b239c443)
git -C "$DSH_SOURCE_DIR" rev-parse HEAD
# 逐字核对本章引用的投影契约
grep -n "produces zero downstream work" "$DSH_SOURCE_DIR/packages/session/session-projection/src/index.ts"
grep -n "stateVersion: 6" "$DSH_SOURCE_DIR/packages/goal/goal/src/index.ts"
grep -n "goal replay failed at session event" "$DSH_SOURCE_DIR/packages/goal/goal/src/index.ts"