mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
perf(session-projection): memoize raw views by state identity
Review follow-up: the per-step gate recomputed view(previous) on every changed apply — a property read for identity-stable views, but a fresh throwaway object per change for computing views. The registry now keeps a WeakMap from state object to raw view: the previous state's view was cached when that state was current, so each distinct state's view computes exactly once (gate and snapshot share the memo) and the quiet path allocates nothing. Unlike the earlier lastView record, an entry is keyed by the state itself — the view of that exact state by the pure-view contract — so no stamping discipline exists to get wrong. Primitive states bypass the WeakMap and compute directly.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md
|
||||
2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
|
||||
2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d
|
||||
2026-08-30-web-turn-rail-outline-jump.md: c1f190832ebfb83200d8f7cb5c01b9a45167ccab
|
||||
2026-08-30-web-turn-rail-outline-jump.zh.md: 66196430ce095e822a5e05863f591ea5cebce570
|
||||
|
||||
@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
|
||||
|
||||
**Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
|
||||
|
||||
**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
|
||||
**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; views are memoized by state object identity (review moved this off a stored last-delivered value, then off per-step recomputation), so each distinct state's view computes once and the memo — keyed by the state itself, not by what the feed delivered — cannot go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
|
||||
|
||||
**Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
|
||||
|
||||
**数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
|
||||
|
||||
**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
|
||||
**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;视图按状态对象身份做备忘(评审先从「存上一次交付值」改为现算、再改为按状态键缓存),每个不同状态只算一次,备忘的键是状态本身而非交付历史,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
|
||||
|
||||
**分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
|
||||
session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
|
||||
session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0
|
||||
session-projection.md: 2a08f1a7131094b6049ecd9f5d43df448bc6dbf3
|
||||
session-projection.zh.md: 73c1f02b897f5ac5408e3199096876d3ba438224
|
||||
|
||||
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
|
||||
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (views are memoized by state object identity — one computation per distinct state, and no last-delivered record exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
|
||||
|
||||
## The registry: `ctx.sessionProjections`
|
||||
|
||||
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
|
||||
### `ctx.sessionProjections` — `SessionProjectionRegistry`
|
||||
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
|
||||
@@ -98,7 +98,7 @@ type ProjectionChangeListener = (
|
||||
) => void
|
||||
```
|
||||
|
||||
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
|
||||
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(视图按状态对象身份做备忘——每个不同状态只算一次,且不存在会过期的「上次交付」记录),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
|
||||
|
||||
## 注册表:`ctx.sessionProjections`
|
||||
|
||||
@@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
|
||||
### `ctx.sessionProjections` — `SessionProjectionRegistry`
|
||||
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
|
||||
`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
|
||||
@@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'sessionProjections',
|
||||
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
|
||||
description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
|
||||
description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state\'s view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): () => void',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
|
||||
README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
|
||||
README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be
|
||||
README.md: 4ea86cb187f1081978be97ec0094977ef5e6778e
|
||||
README.zh.md: ab051795bdcb0547e817063bd91374954d79cb0d
|
||||
|
||||
@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
|
||||
|
||||
### Design concept
|
||||
|
||||
The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
|
||||
The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`.
|
||||
|
||||
### Source map
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
|
||||
|
||||
### 设计理念
|
||||
|
||||
本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
|
||||
本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——视图按状态对象身份做备忘,每个不同状态的视图只计算一次,且不存在会在监听器换代期间过期的「上次交付」记录(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
|
||||
|
||||
### 源码地图
|
||||
|
||||
|
||||
@@ -160,6 +160,13 @@ interface UnitCell {
|
||||
interface Registration {
|
||||
readonly def: ErasedDefinition
|
||||
readonly cells: WeakMap<Session, UnitCell>
|
||||
/**
|
||||
* Raw `view` output per state object (pure-view memo). An entry is the
|
||||
* view of that exact state — not a last-delivered record — so it cannot go
|
||||
* stale; a missing entry recomputes. Weak keys die with their states;
|
||||
* primitive states bypass the memo.
|
||||
*/
|
||||
readonly viewMemo: WeakMap<object, unknown>
|
||||
/** Live registrants sharing this unit; the last one out removes the key. */
|
||||
refs: number
|
||||
}
|
||||
@@ -170,9 +177,10 @@ interface Registration {
|
||||
* every registered unit's `apply` (eager drive), and a changed state
|
||||
* reference in a client-visible unit notifies the change feed with the
|
||||
* schema-validated view — unless the raw view output is `Object.is`-identical
|
||||
* to the unit's previous projection (identity-stable projections stay quiet;
|
||||
* both views are computed from the states in hand each step, so no stored
|
||||
* comparison value exists to go stale across listener generations).
|
||||
* to the unit's previous projection (identity-stable projections stay quiet).
|
||||
* Views are memoized by state object identity, so each distinct state's view
|
||||
* computes once and no last-delivered record exists to go stale across
|
||||
* listener generations.
|
||||
* Cells build lazily — a unit registered after events flowed, or a session
|
||||
* older than the registry, folds `init` over the in-memory log on first
|
||||
* touch (event or read). Registration is an effect (disposer rides the
|
||||
@@ -262,7 +270,7 @@ export class SessionProjectionRegistry extends Service {
|
||||
const key = erased.key
|
||||
const existing = this.registrations.get(key)
|
||||
if (existing === undefined) {
|
||||
this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 })
|
||||
this.registrations.set(key, { def: erased, cells: new WeakMap(), viewMemo: new WeakMap(), refs: 1 })
|
||||
} else {
|
||||
if (existing.def.stateVersion !== erased.stateVersion) {
|
||||
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
|
||||
@@ -636,13 +644,13 @@ export class SessionProjectionRegistry extends Service {
|
||||
cell.state = next
|
||||
cell.observedSeq = event.seq
|
||||
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
|
||||
// Identity gate on the raw view, computed from the two states in
|
||||
// hand: a changed state whose projection is reference-identical to
|
||||
// the previous state's stays quiet, so a unit can buffer working
|
||||
// fields without spamming the feed. Nothing is stored, so no
|
||||
// comparison value exists to go stale across listener generations.
|
||||
const raw = registration.def.wire.view(next)
|
||||
if (Object.is(registration.def.wire.view(previous), raw)) continue
|
||||
// Identity gate on the raw view, memoized by state identity: the
|
||||
// previous state's view was cached when that state was current, so
|
||||
// each distinct state's view computes once and the quiet path
|
||||
// allocates nothing. The memo cannot go stale — an entry is the view
|
||||
// of that exact state, not a record of what the feed last delivered.
|
||||
const raw = this.viewOf(registration.def.wire, registration.viewMemo, next)
|
||||
if (Object.is(this.viewOf(registration.def.wire, registration.viewMemo, previous), raw)) continue
|
||||
const value = registration.def.wire.viewSchema.parse(raw)
|
||||
for (const listener of this.listeners) {
|
||||
listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
|
||||
@@ -655,7 +663,22 @@ export class SessionProjectionRegistry extends Service {
|
||||
private viewCell(registration: Registration, cell: UnitCell): unknown {
|
||||
const wire = registration.def.wire
|
||||
if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`)
|
||||
return wire.viewSchema.parse(wire.view(cell.state))
|
||||
return wire.viewSchema.parse(this.viewOf(wire, registration.viewMemo, cell.state))
|
||||
}
|
||||
|
||||
/**
|
||||
* One unit's raw `view` output for one state, memoized by state object
|
||||
* identity (the pure-view contract makes the entry permanently correct).
|
||||
* Primitive states have no WeakMap key and compute directly.
|
||||
* @param wire - the unit's wire block.
|
||||
* @param memo - the unit's per-state view memo.
|
||||
* @param state - a state produced by the unit's `init`/`apply`.
|
||||
* @returns the raw (pre-validation) `view` output for that state.
|
||||
*/
|
||||
private viewOf(wire: NonNullable<ErasedDefinition['wire']>, memo: WeakMap<object, unknown>, state: unknown): unknown {
|
||||
if (typeof state !== 'object' || state === null) return wire.view(state)
|
||||
if (!memo.has(state)) memo.set(state, wire.view(state))
|
||||
return memo.get(state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,45 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it("computes each distinct state's view once: the memo serves previous states to the gate and snapshots", async () => {
|
||||
const { ctx, session } = await harness()
|
||||
let viewCalls = 0
|
||||
ctx.sessionProjections.register({
|
||||
key: 'test/buffered',
|
||||
stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
|
||||
init: () => ({ marks: [], draft: '' }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
|
||||
if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
|
||||
return state
|
||||
},
|
||||
wire: {
|
||||
viewSchema: z.array(z.string()),
|
||||
view: (state) => {
|
||||
viewCalls += 1
|
||||
return state.marks
|
||||
},
|
||||
},
|
||||
stateVersion: 1,
|
||||
})
|
||||
const seen: unknown[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key, value) => {
|
||||
if (key === 'test/buffered') seen.push(value)
|
||||
})
|
||||
// First change touches two never-seen states (init and next): two calls.
|
||||
mark(session, ['a'])
|
||||
expect(viewCalls).toBe(2)
|
||||
// Draft-only change: the new state computes, the previous is a memo hit.
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(viewCalls).toBe(3)
|
||||
mark(session, ['a', 'b'])
|
||||
expect(viewCalls).toBe(4)
|
||||
expect(seen).toEqual([['a'], ['a', 'b']])
|
||||
// Snapshot reads reuse the same memo instead of recomputing the view.
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
|
||||
expect(viewCalls).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
// A primitive-valued view compares by value under Object.is (the title
|
||||
|
||||
Reference in New Issue
Block a user