refactor(session-projection): checkpoint every projection unit uniformly
Drop the persist?: boolean opt-in: every unit's state — client-visible and host-only alike — is now written to the projection cache. A unit can no longer silently skip the durable cache, host-only units no longer need an explicit flag to participate in cold restore, and the persist-sharing conflict check disappears with the field. - ProjectionDefinition/ErasedDefinition lose persist; register overloads simplify; checkpoint/restoreFloor/restore fold every registered unit. - Registry and cache tests drop the persist:true fixtures and the persistence-policy sharing test. - READMEs and the state-and-client-views note record the uniform rule; cordis API catalog and subsystem signatures regenerated.
This commit is contained in:
parent
cb0747091b
commit
327b86d2ea
16 changed files with 38 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ProjectionDefinition<K, S>, 'wire' | 'persist'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> persist?: true }, ): () => void
|
||||
register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['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)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
|
|
|||
|
|
@ -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<ProjectionDefinition<K, S>, 'wire' | 'persist'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> persist?: true }, ): () => void
|
||||
register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['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)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
|
|
|||
|
|
@ -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<ProjectionDefinition<K, S>, \'wire\' | \'persist\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> persist?: true }, ): () => void',
|
||||
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'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<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'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<SessionProjectionMap>',
|
||||
|
|
@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||
},
|
||||
{
|
||||
name: 'ProjectionDefinition',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n key: K;\n stateSchema: ZodType<S>;\n persist?: boolean;\n init(): NoInfer<S>;\n apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType<SessionProjectionMap[K]>;\n view(state: NoInfer<S>): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n key: K;\n stateSchema: ZodType<S>;\n init(): NoInfer<S>;\n apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType<SessionProjectionMap[K]>;\n view(state: NoInfer<S>): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionSnapshot',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,6 @@ describe('SessionProjectionCache write policy', () => {
|
|||
ctx.sessionProjections.register({
|
||||
key: 'cache-test/marks2',
|
||||
stateSchema: z.custom<Map<string, string>>(() => true),
|
||||
persist: true,
|
||||
init: () => new Map<string, string>(),
|
||||
apply: state => state,
|
||||
stateVersion: 1,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<K, S>` — `{ 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<K, S>` — `{ 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
- `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
|
||||
- `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。
|
||||
- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。
|
||||
- `ProjectionDefinition<K, S>`——`{ 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')`,注册表缺席时完全省略自己的块与帧。
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,6 @@ export interface ProjectionDefinition<
|
|||
key: K
|
||||
/** Validates persisted state before it seeds a fold. */
|
||||
stateSchema: ZodType<S>
|
||||
/** 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<ProjectionDefinition<K, S>, 'wire' | 'persist'> & {
|
||||
definition: Omit<ProjectionDefinition<K, S>, 'wire'> & {
|
||||
wire: NonNullable<ProjectionDefinition<K, S>['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
|
||||
|
|
|
|||
|
|
@ -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<ProjectionDefinition<'test/marks', MarksState>, 'wire' | 'persist'>
|
||||
const marksUnit = (): Omit<ProjectionDefinition<'test/marks', MarksState>, 'wire'>
|
||||
& { wire: NonNullable<ProjectionDefinition<'test/marks', MarksState>['wire']> } => ({
|
||||
key: 'test/marks',
|
||||
stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
|
||||
|
|
@ -51,7 +51,6 @@ const marksUnit = (): Omit<ProjectionDefinition<'test/marks', MarksState>, '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/)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue