diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml index 0480790dd1..5917e80616 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml @@ -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/architecture/2026-08-19-session-projection-state-and-client-views.md -2026-08-19-session-projection-state-and-client-views.md: fea5474c78f04cc66aa317d69e72b9fcde6bfe9e -2026-08-19-session-projection-state-and-client-views.zh.md: 5ec82ed44576be209259308d406bde2a5ebf6825 +2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4 +2026-08-19-session-projection-state-and-client-views.zh.md: edd2edaf0bc897bb2084325a5768e549637ed720 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md index fea5474c78..14da0525b2 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md @@ -12,7 +12,7 @@ The projection registry persisted each unit's internal fold state without a runt `SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`. -A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Client-visible units are always checkpointed. A host-only unit omits `wire` and is checkpointed only when `persist` is true. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. +A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. ## Consequences @@ -24,5 +24,5 @@ The original [session-projection proposal](../../proposed/architecture/2026-07-2 - **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations. - **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately. -- **Persist every host-only unit** — rejected because persistence is a cold-read optimization with storage cost; an internal unit opts in only when its consumers need cold reconstruction. +- **Opt-in persistence for host-only units** — rejected: a `persist` flag lets a unit silently skip the durable cache, and the savings (one small row per session) never justify the asymmetry or the stateVersion confusion it invites. Every unit's state is checkpointed uniformly. - **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md index 5ec82ed445..edd2edaf0b 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md @@ -12,7 +12,7 @@ `SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。 -如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。客户端可见单元始终写入检查点。host-only 单元省略 `wire`,且仅在 `persist` 为 true 时写入检查点。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 +如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 ## 结果 @@ -24,5 +24,5 @@ - **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。 - **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。 -- **持久化所有 host-only 单元**——不予采用,因为持久化是带存储成本的冷读优化;内部单元只有在消费方需要冷重建时才选择加入。 +- **host-only 单元按需选择持久化**——不予采用:`persist` 标志会让单元悄悄跳过持久化缓存,而省下的(每会话一行小记录)永远不值得这种不对称或它带来的 stateVersion 困惑。每个单元的状态统一写入检查点。 - **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 6b2d80bc51..2ca1deafad 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -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: bdcd14d6105297992793c879301bf2d69fa4e359 -session-projection.zh.md: 10696f8321e2caf2f07a7a3b291c7f6a66f7a021 +session-projection.md: 50ddc8ddb80a2df1a1c8f54133da134f571ef1ce +session-projection.zh.md: 06b9f71c263805572ef408d04f9c43021dc13597 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index bdcd14d610..50ddc8ddb8 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -175,11 +175,11 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire' | 'persist'> & { wire: NonNullable['wire']> persist?: true }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -224,7 +224,7 @@ snapshot(session: Session): ProjectionSnapshot * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. - * @returns one row per persisted key; empty when no persisted unit is registered. + * @returns one row per registered key. */ checkpoint(session: Session): ProjectionCheckpoint @@ -241,7 +241,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the seq to hand the persistence `readFrom`, or `undefined` - * when no persisted unit is registered (no read needed — {@link restore} would + * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined @@ -285,5 +285,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:180`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 10696f8321..06b9f71c26 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -175,11 +175,11 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire' | 'persist'> & { wire: NonNullable['wire']> persist?: true }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -224,7 +224,7 @@ snapshot(session: Session): ProjectionSnapshot * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. - * @returns one row per persisted key; empty when no persisted unit is registered. + * @returns one row per registered key. */ checkpoint(session: Session): ProjectionCheckpoint @@ -241,7 +241,7 @@ checkpoint(session: Session): ProjectionCheckpoint * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the seq to hand the persistence `readFrom`, or `undefined` - * when no persisted unit is registered (no read needed — {@link restore} would + * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined @@ -285,5 +285,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:180`](../../packages/session/session-projection/src/index.ts) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b79d9d7c04..74d455cedc 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1113,14 +1113,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 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. 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. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. 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, \'wire\' | \'persist\'> & { wire: NonNullable[\'wire\']> persist?: true }, ): () => void', + signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], returns: 'the exact disposer that unregisters this unit.', }, { signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): () => void', - description: 'Register one host-only unit. Its state is omitted from client snapshots and persisted only when `persist` is true.', + description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], returns: 'the exact disposer that unregisters this unit.', }, @@ -1146,13 +1146,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'checkpoint(session: Session): ProjectionCheckpoint', description: 'State-level checkpoint of every persisted unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). This is the write side of the persisted projection cache: the returned rows are the `(key → {ver, seq, val})` part of the durable `(sessionId, key, ver, seq, val)` rows. Every `val` is a DETACHED structured clone — never the live cell reference: the watermark cache is this registry\'s authoritative mutable state, and a caller reaching the live reference could corrupt every subsequent snapshot and frame through it (plain JSON by the unit contract, so the clone is total).', parameters: [{ name: 'session', description: 'the session whose unit states are checkpointed.' }], - returns: 'one row per persisted key; empty when no persisted unit is registered.', + returns: 'one row per registered key.', }, { signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row pulls the floor to `0` — that key must refold the full log). The one-below anchor is load-bearing: the tail then proves how far the stored log still extends, so restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }], - returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no persisted unit is registered (no read needed — {@link restore} would serve empty values regardless).', + returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).', }, { signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', @@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n persist?: boolean;\n init(): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 8341ce9715..4100b166ea 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -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-cache/README.md -README.md: ace9363ae0257715f470d22e1787364fdca31ae3 -README.zh.md: f2e05ae865b92c63877061c37f80f77a9e57925d +README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085 +README.zh.md: 9760cf3cf8382bda6866e679f1d884990a09f0cf diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index ace9363ae0..33908578a5 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every client-visible or explicitly persisted projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). +The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index f2e05ae865..9760cf3cf8 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -持久投影缓存(`ctx.sessionProjectionCache`):把每个 client-visible 或显式选择持久化的投影单元状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 +持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 8a7467d91a..89154ec108 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -184,7 +184,6 @@ describe('SessionProjectionCache write policy', () => { ctx.sessionProjections.register({ key: 'cache-test/marks2', stateSchema: z.custom>(() => true), - persist: true, init: () => new Map(), apply: state => state, stateVersion: 1, diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 7e44416cf4..a0d33df991 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -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: 55ba3e0d72229049303b246987651a2a801711a9 -README.zh.md: f312773aedc98aaf4d91261d1d3ba15ea7a9cfff +README.md: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c +README.zh.md: 3ca07bbe944c56a5538530c8c341e4c0ad002e94 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 55ba3e0d72..3b7ccecb70 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -17,7 +17,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. - `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here. -- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. +- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. ## Contract @@ -25,7 +25,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; 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, which fails `wire.viewSchema.parse`. -- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`. +- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. - **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index f312773aed..3ca07bbe94 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -17,7 +17,7 @@ - `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。 - `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。 -- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 +- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 ## 约定 @@ -25,7 +25,7 @@ - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 -- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。 +- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 0c06984d85..e457370794 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -47,8 +47,6 @@ export interface ProjectionDefinition< key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType - /** Persist a host-only unit. Client-visible units are always persisted. */ - persist?: boolean /** * State for the empty log. * @returns the initial state. @@ -135,7 +133,6 @@ interface ErasedDefinition { apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number - persist: boolean } /** Per-session per-unit watermark cache row. */ @@ -207,14 +204,13 @@ export class SessionProjectionRegistry extends Service { K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( - definition: Omit, 'wire' | 'persist'> & { + definition: Omit, 'wire'> & { wire: NonNullable['wire']> - persist?: true }, ): () => void /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -240,7 +236,6 @@ export class SessionProjectionRegistry extends Service { ? undefined : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, stateVersion: definition.stateVersion, - persist: wire !== undefined || definition.persist === true, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) @@ -254,9 +249,6 @@ export class SessionProjectionRegistry extends Service { 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)}`) } - if (existing.def.persist !== erased.persist) { - throw new Error(`session projection key ${JSON.stringify(key)} is already registered with persist ${String(existing.def.persist)}; refusing to share it with persist ${String(erased.persist)}`) - } existing.refs += 1 } yield () => { @@ -332,12 +324,11 @@ export class SessionProjectionRegistry extends Service { * every subsequent snapshot and frame through it (plain JSON by the unit * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. - * @returns one row per persisted key; empty when no persisted unit is registered. + * @returns one row per registered key. */ checkpoint(session: Session): ProjectionCheckpoint { const rows: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { - if (!registration.def.persist) continue const cell = this.cellFor(registration, session) rows[registration.def.key] = { ver: registration.def.stateVersion, @@ -361,13 +352,12 @@ export class SessionProjectionRegistry extends Service { * re-read. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @returns the seq to hand the persistence `readFrom`, or `undefined` - * when no persisted unit is registered (no read needed — {@link restore} would + * when no unit is registered (no read needed — {@link restore} would * serve empty values regardless). */ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined { let floor: number | undefined for (const registration of this.registrations.values()) { - if (!registration.def.persist) continue const row = checkpoint[registration.def.key] const need = row !== undefined && row.ver === registration.def.stateVersion ? Math.max(row.seq + 1, 0) @@ -438,7 +428,6 @@ export class SessionProjectionRegistry extends Service { const refreshed: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { const def = registration.def - if (!def.persist) continue const row = checkpoint[def.key] const usable = row !== undefined && row.ver === def.stateVersion diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index cbc49cf004..8d184a759f 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -34,7 +34,7 @@ declare module '@deepseek-ai/dsh-session/types' { type MarksState = { marks: string[] } | null /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ -const marksUnit = (): Omit, 'wire' | 'persist'> +const marksUnit = (): Omit, 'wire'> & { wire: NonNullable['wire']> } => ({ key: 'test/marks', stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), @@ -51,7 +51,6 @@ const marksUnit = (): Omit, 'wire const countUnit = (): ProjectionDefinition<'test/count', number> => ({ key: 'test/count', stateSchema: z.number().int().nonnegative(), - persist: true, init: () => 0, apply: state => state + 1, stateVersion: 1, @@ -172,14 +171,6 @@ describe('SessionProjectionRegistry drive', () => { .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) }) - it('refuses to share a key across a persistence-policy change', async () => { - const { ctx } = await harness() - ctx.sessionProjections.register(countUnit()) - - expect(() => ctx.sessionProjections.register({ ...countUnit(), persist: false })) - .toThrow(/already registered with persist true; refusing to share it with persist false/) - }) - it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/)