refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
6
packages/session/session-projection/README.i18n.yaml
Normal file
6
packages/session/session-projection/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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-projection/session-projection/README.md
|
||||
README.md: a42e88c262915fc5e53cd72205079cbc8029a8df
|
||||
README.zh.md: f60a48bd41f0c33edb85a495ada9b6818ec46ee5
|
||||
47
packages/session/session-projection/README.md
Normal file
47
packages/session/session-projection/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @deepseek-ai/dsh-session-projection
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Session-projection seam. It owns `ctx.sessionProjections`, the registry that drives every registered projection unit over committed session events and serves finished whole values to carriers, currently the api-proxy history tail page and `session/projection` push frame. A domain registers pure mathematics; the framework owns the drive. The [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) records the design rationale.
|
||||
|
||||
## Service: `SessionProjectionRegistry` (ctx key: `sessionProjections`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
|
||||
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
|
||||
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log).
|
||||
|
||||
### Key Types
|
||||
|
||||
- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
|
||||
- `ProjectionDefinition<K, S>` — `{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
|
||||
|
||||
## Contract
|
||||
|
||||
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
|
||||
- **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`/`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 the boundary `schema.parse` loudly.
|
||||
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
|
||||
- **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 seam.** 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.
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface-plus-drive package of the capability-seam split: domain host plugins (e.g. `dsh-tool-todo`) contribute units, carriers (`dsh-host-apiproxy`) consume the snapshot and change feed, and neither knows the other.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the registry only computes client-facing read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; projections never assemble or send provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
|
||||
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
|
||||
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
|
||||
- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.
|
||||
47
packages/session/session-projection/README.zh.md
Normal file
47
packages/session/session-projection/README.zh.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @deepseek-ai/dsh-session-projection
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
会话投影 seam。它拥有 `ctx.sessionProjections`:该注册表在已提交的会话事件上驱动每个已注册的投影单元,并向载体提供完整的最终值,目前包括 api-proxy 历史尾页和 `session/projection` 推送帧。领域注册的只是纯数学;驱动权归框架。[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)记录了设计理由。
|
||||
|
||||
## 服务:`SessionProjectionRegistry`(ctx 键:`sessionProjections`)
|
||||
|
||||
### 公开 API
|
||||
|
||||
- `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。
|
||||
- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。
|
||||
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。
|
||||
|
||||
### 关键类型
|
||||
|
||||
- `SessionProjectionMap`——整条链路唯一的 merge-extensible 类型表(host 侧单元、协议块、React 钩子)。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
|
||||
- `ProjectionDefinition<K, S>`——`{ key, schema, init(), apply(state, event), view(state), stateVersion }`:由三个纯同步函数外加若干声明构成的状态驱动计算单元(state-driven computation unit),绝不是一个不透明的 getter。
|
||||
|
||||
## 契约
|
||||
|
||||
- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。
|
||||
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
|
||||
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
|
||||
- **单元的同步纪律。**`init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。
|
||||
- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
|
||||
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
|
||||
- **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
|
||||
|
||||
## 职责
|
||||
|
||||
这是能力 seam 拆分中「接口 + 驱动」的那个包:领域 host 插件(如 `dsh-tool-todo`)贡献单元,载体(`dsh-host-apiproxy`)消费快照与变更流,两侧互不相识。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无——注册表只对已入日志的会话状态计算面向客户端的读模型,不触碰任何提示词、消息、schema、流或工具结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;投影从不组装或发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
|
||||
- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。
|
||||
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
|
||||
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。
|
||||
45
packages/session/session-projection/package.json
Normal file
45
packages/session/session-projection/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-projection",
|
||||
"description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
400
packages/session/session-projection/src/index.ts
Normal file
400
packages/session/session-projection/src/index.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Session-projection seam: the merge-extensible `SessionProjectionMap` type
|
||||
* table, the `ProjectionDefinition` state-driven computation unit contract,
|
||||
* and the `ctx.sessionProjections` registry that DRIVES every registered unit
|
||||
* forward eagerly over committed session events. Domain host plugins
|
||||
* contribute pure mathematics (init/apply/view); the framework owns the
|
||||
* subscription, the per-session watermark cache, and change notification;
|
||||
* carriers consume the snapshot read face and the change feed. Neither side
|
||||
* knows the other
|
||||
* (capability-seam three-way split). Design authority: the session-projection
|
||||
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
||||
*
|
||||
* 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
|
||||
* unit's transition trivially cheap and every served value self-describing.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-projection
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ZodType } from 'zod'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionProjections: SessionProjectionRegistry
|
||||
}
|
||||
}
|
||||
|
||||
import type { SessionProjectionMap } from './types.ts'
|
||||
|
||||
export type { SessionProjectionMap } from './types.ts'
|
||||
|
||||
/**
|
||||
* One domain's state-driven computation unit: three pure synchronous
|
||||
* functions plus declarations — never an opaque getter. The framework drives
|
||||
* `apply` on every committed session event; the domain holds no
|
||||
* subscriptions and owns only the mathematics. All three functions MUST be
|
||||
* synchronous (an async unit would tear the carriers' consistency cut) and
|
||||
* `state` MUST be plain JSON (the persisted-cache precondition).
|
||||
*/
|
||||
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
||||
/** The projection key this unit owns (its `SessionProjectionMap` entry). */
|
||||
key: K
|
||||
/** Validates the wire payload (`view` output) before it leaves the host. */
|
||||
schema: ZodType<SessionProjectionMap[K]>
|
||||
/**
|
||||
* State for the empty log.
|
||||
* @returns the initial state.
|
||||
*/
|
||||
init(): S
|
||||
/**
|
||||
* Pure transition: previous state + one committed event → next state. A
|
||||
* unit uninterested in an event MUST return the same state reference — an
|
||||
* unchanged reference (`Object.is`) produces zero downstream work.
|
||||
* @param state - the state covering all prior events.
|
||||
* @param event - the next committed session event.
|
||||
* @returns the next state (same reference when the event is not the unit's).
|
||||
*/
|
||||
apply(state: S, event: SessionEvent): S
|
||||
/**
|
||||
* State → wire payload (the read-side projection).
|
||||
* @param state - the current state.
|
||||
* @returns the whole current value for this unit's key.
|
||||
*/
|
||||
view(state: S): SessionProjectionMap[K]
|
||||
/**
|
||||
* Persisted-cache invalidation anchor: bump whenever the state shape or the
|
||||
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
|
||||
* rows from an older unit are discarded instead of being forward-applied
|
||||
* into garbage. Non-negative integer.
|
||||
*/
|
||||
stateVersion: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Change-feed listener: one unit's value changed for one session. `value` is
|
||||
* the schema-validated `view` output; `seq` is the unit's watermark at
|
||||
* emission (the seq of the event that caused the change).
|
||||
*/
|
||||
export type ProjectionChangeListener = (
|
||||
session: Session,
|
||||
key: Extract<keyof SessionProjectionMap, string>,
|
||||
value: unknown,
|
||||
seq: number,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* One consistent read cut over every registered unit for one session.
|
||||
* `asOfSeq` is the shared watermark — the seq of the last event every value
|
||||
* reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
|
||||
*/
|
||||
export interface ProjectionSnapshot {
|
||||
/** Seq of the last event the values reflect; -1 for an empty log. */
|
||||
asOfSeq: number
|
||||
/** Whole current value per registered key. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/**
|
||||
* One unit's checkpoint: its internal state (plain JSON by the unit
|
||||
* contract), the seq of the last event folded into it, and the unit
|
||||
* `stateVersion` that produced it — the persisted projection-cache row
|
||||
* `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
|
||||
* never authoritative, only a fold shortcut: `restore` discards it on a
|
||||
* version mismatch or when it claims events past the stored log end.
|
||||
*/
|
||||
export interface ProjectionCheckpointRow {
|
||||
/** The registering unit's `stateVersion` at fold time. */
|
||||
ver: number
|
||||
/** Seq of the last event folded into `val`; -1 for the empty log. */
|
||||
seq: number
|
||||
/** The unit's internal state — plain JSON per the unit contract. */
|
||||
val: unknown
|
||||
}
|
||||
|
||||
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
|
||||
export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>
|
||||
|
||||
/** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */
|
||||
interface ErasedDefinition {
|
||||
key: string
|
||||
schema: { parse(value: unknown): unknown }
|
||||
init(): unknown
|
||||
apply(state: unknown, event: SessionEvent): unknown
|
||||
view(state: unknown): unknown
|
||||
stateVersion: number
|
||||
}
|
||||
|
||||
/** Per-session per-unit watermark cache row. */
|
||||
interface UnitCell {
|
||||
state: unknown
|
||||
/** Seq of the last event passed through `apply` (regardless of change). */
|
||||
observedSeq: number
|
||||
}
|
||||
|
||||
/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */
|
||||
interface Registration {
|
||||
readonly def: ErasedDefinition
|
||||
readonly cells: WeakMap<Session, UnitCell>
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 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. Duplicate keys throw. Domain
|
||||
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
|
||||
* assemblies without the registry stay unaffected.
|
||||
*/
|
||||
export class SessionProjectionRegistry extends Service {
|
||||
private readonly registrations = new Map<string, Registration>()
|
||||
private readonly listeners = new Set<ProjectionChangeListener>()
|
||||
|
||||
/**
|
||||
* Create and install the registry as `ctx.sessionProjections`.
|
||||
* @param ctx - Cordis context that owns the service.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionProjections')
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
this.drive(session, event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param definition - key, boundary schema, pure unit functions, and stateVersion.
|
||||
* @returns the exact disposer that unregisters this unit.
|
||||
*/
|
||||
register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void {
|
||||
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)}`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) {
|
||||
const key = definition.key as string
|
||||
if (this.registrations.has(key)) {
|
||||
throw new Error(`session projection key ${JSON.stringify(key)} is already registered`)
|
||||
}
|
||||
this.registrations.set(key, { def: definition, cells: new WeakMap() })
|
||||
yield () => {
|
||||
this.registrations.delete(key)
|
||||
}
|
||||
}.bind(this), 'sessionProjections.register()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the change feed. The registration is an effect on the
|
||||
* calling context's fiber.
|
||||
* @param listener - called once per unit whose state reference changed, per committed event.
|
||||
* @returns the exact disposer that unsubscribes.
|
||||
*/
|
||||
onChanged(listener: ProjectionChangeListener): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}, 'sessionProjections.onChanged()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* One consistent cut over every registered unit for one session, read from
|
||||
* the watermark cache (missing cells fold lazily over the in-memory log).
|
||||
* Fully synchronous — every value and `asOfSeq` reflect the same log
|
||||
* position. Each value passes its unit's schema before leaving.
|
||||
* @param session - the session whose projection values are read.
|
||||
* @returns the snapshot; `values` is empty when no unit is registered.
|
||||
*/
|
||||
snapshot(session: Session): ProjectionSnapshot {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const cell = this.cellFor(registration, session)
|
||||
values[registration.def.key] = registration.def.schema.parse(registration.def.view(cell.state))
|
||||
}
|
||||
return { asOfSeq: session.seq - 1, values: values }
|
||||
}
|
||||
|
||||
/**
|
||||
* State-level checkpoint of every registered 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).
|
||||
* @param session - the session whose unit states are checkpointed.
|
||||
* @returns one row per registered key; empty when no unit is registered.
|
||||
*/
|
||||
checkpoint(session: Session): ProjectionCheckpoint {
|
||||
const rows: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const cell = this.cellFor(registration, session)
|
||||
rows[registration.def.key] = {
|
||||
ver: registration.def.stateVersion,
|
||||
seq: cell.observedSeq,
|
||||
val: structuredClone(cell.state),
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored seq a {@link 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 {@link 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.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @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).
|
||||
*/
|
||||
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
|
||||
let floor: number | undefined
|
||||
for (const registration of this.registrations.values()) {
|
||||
const row = checkpoint[registration.def.key]
|
||||
const need = row !== undefined && row.ver === registration.def.stateVersion
|
||||
? Math.max(row.seq + 1, 0)
|
||||
: 0
|
||||
floor = floor === undefined ? need : Math.min(floor, need)
|
||||
}
|
||||
return floor === undefined ? undefined : Math.max(floor - 1, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* View a checkpoint's rows without any log read: for every registered
|
||||
* unit whose row's `ver` matches, serve the schema-validated
|
||||
* `view` of the stored state; mismatched or absent rows leave their key
|
||||
* absent (a cold or listing consumer treats it as not-yet-available and a
|
||||
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
||||
* values are as stale as their rows, never wrong.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns whole values per key with a usable row; empty when none.
|
||||
*/
|
||||
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
if (row === undefined || row.ver !== def.stateVersion) continue
|
||||
values[def.key] = def.schema.parse(def.view(row.val))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold read: fold every registered unit over a stored log suffix, seeding
|
||||
* each from its checkpoint row when usable — the one read recipe (cached
|
||||
* state + forward tail replay + `view`) applied without a live `Session`.
|
||||
* Call with the events returned by a persistence
|
||||
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
|
||||
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
|
||||
* so a shrunk log is detected here. A row is usable iff its
|
||||
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
|
||||
* (`seq >= baseSeq - 1`), and it does not claim events past the
|
||||
* supplied end (`seq <= endSeq`); an unusable row is discarded
|
||||
* and its key refolds from `init` — which is only sound over the full
|
||||
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
|
||||
* from seq 0, e.g. after a crash-repair truncation shrank the log below
|
||||
* a row's watermark).
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number):
|
||||
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
|
||||
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
|
||||
const values: Record<string, unknown> = {}
|
||||
const refreshed: ProjectionCheckpoint = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
const usable = row !== undefined
|
||||
&& row.ver === def.stateVersion
|
||||
&& row.seq >= baseSeq - 1
|
||||
&& row.seq <= endSeq
|
||||
if (!usable && baseSeq > 0) {
|
||||
throw new Error(
|
||||
`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
|
||||
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
|
||||
)
|
||||
}
|
||||
let state = usable ? row.val : def.init()
|
||||
const from = usable ? row.seq : baseSeq - 1
|
||||
for (const event of events) {
|
||||
if (event.seq > from) state = def.apply(state, event)
|
||||
}
|
||||
values[def.key] = def.schema.parse(def.view(state))
|
||||
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
|
||||
}
|
||||
return {
|
||||
snapshot: { asOfSeq: endSeq, values: values },
|
||||
checkpoint: refreshed,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
||||
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
|
||||
let state = def.init()
|
||||
for (const event of events) state = def.apply(state, event)
|
||||
return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
|
||||
}
|
||||
|
||||
/** Read (or lazily build, folding the full in-memory log) one unit's cell. */
|
||||
private cellFor(registration: Registration, session: Session): UnitCell {
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell === undefined) {
|
||||
cell = this.buildCell(registration.def, session.events)
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
||||
private drive(session: Session, event: SessionEvent): void {
|
||||
for (const registration of this.registrations.values()) {
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell === undefined) {
|
||||
// Late build mid-stream: fold history before this event (seq = log
|
||||
// index, so the prefix slice is exact), then take the normal gate.
|
||||
cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
const next = registration.def.apply(cell.state, event)
|
||||
const changed = !Object.is(next, cell.state)
|
||||
cell.state = next
|
||||
cell.observedSeq = event.seq
|
||||
if (changed && this.listeners.size > 0) {
|
||||
const value = registration.def.schema.parse(registration.def.view(next))
|
||||
for (const listener of this.listeners) {
|
||||
listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionProjectionRegistry
|
||||
38
packages/session/session-projection/src/invariant.ts
Normal file
38
packages/session/session-projection/src/invariant.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-projection`.
|
||||
* @module @deepseek-ai/dsh-session-projection/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-projection-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the registry's own contracts (duplicate-key and
|
||||
* stateVersion rejection, effect-tied removal, the Object.is change gate) are
|
||||
* enforced synchronously inside the service and proven by its spec, the
|
||||
* drive relation (every committed `session/event` passes every unit) would
|
||||
* require re-running the drive to check — duplicating the implementation
|
||||
* rather than detecting drift — and the served-value relation (every served
|
||||
* key has a live registration) lives on each carrier's wire path, which
|
||||
* emits no cordis event this companion could observe; carrier specs assert
|
||||
* it. Synchronous-unit discipline is enforced as far as practical by the
|
||||
* boundary `schema.parse` (a Promise-returning view fails loudly).
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
17
packages/session/session-projection/src/types.ts
Normal file
17
packages/session/session-projection/src/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Pure-type outlet of the session-projection seam: the one projection type
|
||||
* table, importable from client aggregates without dragging the host-side
|
||||
* cordis Context merges of the package root (dsh-agent → dsh-session). Domain
|
||||
* packages may declare-merge through either the package root or this outlet —
|
||||
* re-export preserves symbol identity, so both land on the same table.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-projection/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* The single projection type table for the whole chain (host provider, wire
|
||||
* block, client cell, React hook). Domain packages merge their key here via
|
||||
* declaration merging; values are wire-JSON whole values. How a value is
|
||||
* rendered is the slot system's business, never this layer's.
|
||||
*/
|
||||
export interface SessionProjectionMap {}
|
||||
334
packages/session/session-projection/tests/registry.spec.ts
Normal file
334
packages/session/session-projection/tests/registry.spec.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* SessionProjectionRegistry unit drive: eager apply on committed events with
|
||||
* lazy cell build (registration after events, session after registration),
|
||||
* the Object.is no-change gate (same reference ⇒ zero change-feed work),
|
||||
* snapshot consistency (asOfSeq = last event seq; values from the watermark
|
||||
* cache), duplicate-key rejection, stateVersion validation, and effect-tied
|
||||
* removal of registrations and change listeners (HMR safety).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
'test/marks': { marks: string[] }
|
||||
'test/count': number
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
'test/mark': { marks: string[] }
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */
|
||||
type MarksState = { marks: string[] } | null
|
||||
const marksUnit = (): ProjectionDefinition<'test/marks', MarksState> => ({
|
||||
key: 'test/marks',
|
||||
schema: z.object({ marks: z.array(z.string()) }),
|
||||
init: () => null,
|
||||
apply: (state, event) => (event.type === 'test/mark' ? (event).data : state),
|
||||
view: state => state ?? { marks: [] },
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
/** Counting unit over every event — state changes on each apply. */
|
||||
const countUnit = (): ProjectionDefinition<'test/count', number> => ({
|
||||
key: 'test/count',
|
||||
schema: z.number().int().nonnegative(),
|
||||
init: () => 0,
|
||||
apply: state => state + 1,
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
return { ctx, session: ctx.sessions.create() }
|
||||
}
|
||||
|
||||
const mark = (session: Session, marks: string[]): SessionEvent =>
|
||||
session.append('test/mark', { marks })
|
||||
|
||||
describe('SessionProjectionRegistry drive', () => {
|
||||
it('drives a registered unit over committed events and snapshots the current value', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['a'])
|
||||
mark(session, ['a', 'b'])
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['a', 'b'] })
|
||||
expect(snapshot.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('builds the cell lazily from the full log for a unit registered after events flowed', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
mark(session, ['pre-registration'])
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['pre-registration'] })
|
||||
// The lazily-built cell then continues on the live drive path.
|
||||
mark(session, ['after'])
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['after'] })
|
||||
})
|
||||
|
||||
it('serves init-derived state and asOfSeq -1 for an empty log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.asOfSeq).toBe(-1)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: [] })
|
||||
})
|
||||
|
||||
it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
const seen: { key: string; value: unknown; seq: number; sessionId: string }[] = []
|
||||
ctx.sessionProjections.onChanged((changedSession, key, value, seq) => {
|
||||
seen.push({ key, value, seq, sessionId: String(changedSession.id) })
|
||||
})
|
||||
const event = mark(session, ['a'])
|
||||
// Non-matching event: apply returns the same reference — no notification.
|
||||
session.append('turn/start', { turn: 1 })
|
||||
expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }])
|
||||
})
|
||||
|
||||
it('drives independently per session (cells are per-session watermarks)', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const other = ctx.sessions.create()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['one'])
|
||||
mark(other, ['two'])
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['one'] })
|
||||
expect(ctx.sessionProjections.snapshot(other).values['test/marks']).toEqual({ marks: ['two'] })
|
||||
})
|
||||
|
||||
it('runs every registered unit — a changing unit notifies while a same-reference unit stays silent', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const changedKeys: string[] = []
|
||||
ctx.sessionProjections.onChanged((_session, key) => {
|
||||
changedKeys.push(key)
|
||||
})
|
||||
session.append('turn/start', { turn: 1 })
|
||||
// count applied (+1 change), marks returned the same reference.
|
||||
expect(changedKeys).toEqual(['test/count'])
|
||||
const snapshot = ctx.sessionProjections.snapshot(session)
|
||||
expect(snapshot.values['test/count']).toBe(1)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: [] })
|
||||
})
|
||||
|
||||
it('rejects duplicate keys loud and keeps the first unit', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
expect(() => ctx.sessionProjections.register(marksUnit())).toThrow(/"test\/marks" is already registered/)
|
||||
mark(session, ['kept'])
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] })
|
||||
})
|
||||
|
||||
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/)
|
||||
expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).toThrow(/stateVersion/)
|
||||
})
|
||||
|
||||
it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const dispose = ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['cached'])
|
||||
dispose()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
// Fresh registration rebuilds from the log, not from a stale cell.
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] })
|
||||
})
|
||||
|
||||
it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const notifications: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sessionProjections.register(marksUnit())
|
||||
inner.sessionProjections.onChanged((_session, key) => {
|
||||
notifications.push(key)
|
||||
})
|
||||
}, { inject: ['sessionProjections'] }))
|
||||
mark(session, ['live'])
|
||||
expect(notifications).toEqual(['test/marks'])
|
||||
await fiber.dispose()
|
||||
mark(session, ['after-dispose'])
|
||||
expect(notifications).toEqual(['test/marks'])
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
})
|
||||
|
||||
it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
|
||||
const markEvent = mark(session, ['a'])
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } })
|
||||
expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 })
|
||||
// Empty log: init-derived state at watermark -1.
|
||||
const fresh = ctx.sessions.create()
|
||||
expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null })
|
||||
})
|
||||
|
||||
it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
mark(session, ['a'])
|
||||
const rows = ctx.sessionProjections.checkpoint(session)
|
||||
// Hostile (or merely careless) consumer mutates the handed-out state.
|
||||
;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED')
|
||||
// The registry's authoritative cell is untouched: snapshot and a fresh
|
||||
// checkpoint both still serve the committed value.
|
||||
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] })
|
||||
expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] })
|
||||
})
|
||||
|
||||
it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => {
|
||||
const { ctx } = await harness()
|
||||
expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
expect(ctx.sessionProjections.restoreFloor({})).toBe(0)
|
||||
// Lowest usable watermark is count's 5 → the anchored tail starts AT 5
|
||||
// (one below the first needed seq 6), so the read proves seq 5 still exists.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 1, seq: 10, val: { marks: [] } },
|
||||
'test/count': { ver: 1, seq: 5, val: 6 },
|
||||
})).toBe(5)
|
||||
// A version-mismatched row forces that key back to a full refold.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 2, seq: 10, val: { marks: [] } },
|
||||
'test/count': { ver: 1, seq: 5, val: 6 },
|
||||
})).toBe(0)
|
||||
// A fresh (-1) row still needs the whole tail from 0.
|
||||
expect(ctx.sessionProjections.restoreFloor({
|
||||
'test/marks': { ver: 1, seq: -1, val: null },
|
||||
'test/count': { ver: 1, seq: -1, val: 0 },
|
||||
})).toBe(0)
|
||||
})
|
||||
|
||||
it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
// marks row usable (watermark 2, tail starts at 3); count row mismatched — but
|
||||
// a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
|
||||
expect(() => ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, tail, 3)).toThrow(/re-read from seq 0/)
|
||||
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
|
||||
const full: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } },
|
||||
{ type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } },
|
||||
...tail,
|
||||
]
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, full, 0)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
|
||||
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
|
||||
// The refreshed rows sit at the served cut, ready for a durable write-back.
|
||||
expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
|
||||
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
|
||||
})
|
||||
|
||||
it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const rows = {
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 2, val: 3 },
|
||||
}
|
||||
const tail: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
// marks already covers the tail (watermark 4): nothing re-applied.
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
|
||||
// count folds exactly seqs 3 and 4 on top of its checkpoint.
|
||||
expect(snapshot.values['test/count']).toBe(5)
|
||||
|
||||
// Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
|
||||
const { snapshot: current } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 4, val: 5 },
|
||||
}, [], 5)
|
||||
expect(current.asOfSeq).toBe(4)
|
||||
expect(current.values['test/count']).toBe(5)
|
||||
})
|
||||
|
||||
it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const values = ctx.sessionProjections.viewCheckpoint({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
|
||||
'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent
|
||||
})
|
||||
expect(values['test/marks']).toEqual({ marks: ['stored'] })
|
||||
expect('test/count' in values).toBe(false)
|
||||
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
|
||||
})
|
||||
|
||||
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } }
|
||||
// The anchored floor sits ON the watermark, so the tail read must return
|
||||
// at least seq 9 from an intact log…
|
||||
const floor = ctx.sessionProjections.restoreFloor(rows)
|
||||
expect(floor).toBe(9)
|
||||
// …an intact log serves the anchor event and the checkpoint stands as-is.
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
|
||||
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
|
||||
// …while a log crash-repaired down to fewer events returns an empty tail:
|
||||
// the row overreaches the proven end and a tail read cannot fix this key.
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
|
||||
// The full re-read discards the overreaching row and refolds from init.
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
|
||||
expect(snapshot.asOfSeq).toBe(1)
|
||||
expect(snapshot.values['test/count']).toBe(2)
|
||||
})
|
||||
|
||||
it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register({
|
||||
key: 'test/marks',
|
||||
schema: z.object({ marks: z.array(z.string()) }),
|
||||
init: () => null as MarksState,
|
||||
apply: state => state,
|
||||
// A Promise (what an accidentally-async view would return) is not the
|
||||
// declared shape: the boundary parse rejects it before it leaves.
|
||||
view: () => Promise.resolve({ marks: [] }) as never,
|
||||
stateVersion: 1,
|
||||
})
|
||||
expect(() => ctx.sessionProjections.snapshot(session)).toThrow()
|
||||
})
|
||||
})
|
||||
24
packages/session/session-projection/tsconfig.json
Normal file
24
packages/session/session-projection/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user