会话投影
SessionProjectionStateMap 是 host 侧折叠状态的 merge-extensible 类型表,SessionProjectionMap 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 ProjectionDefinition;wire 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层:
/** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives * `apply` on every committed session event; the domain holds no * subscriptions and owns only the computation. All functions MUST be * synchronous (an async unit would tear the carriers' consistency cut), and * `state` MUST be plain JSON (the persisted-cache precondition). */interface ProjectionDefinition< K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],> { /** The projection key this unit owns (its `SessionProjectionStateMap` entry). */ key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType<S> /** * State for the empty log and its immutable Session metadata. * @param header - immutable metadata for the Session being projected. * @param inheritedEventCount - exact fork-inherited prefix length. * @returns the initial state. */ init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer<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: NoInfer<S>, event: SessionEvent): NoInfer<S> /** Client view. Omit for host-only units. */ wire?: K extends keyof SessionProjectionMap ? { /** Validates the wire payload before it leaves the host. */ viewSchema: ZodType<SessionProjectionMap[K]> /** * State → wire payload (the read-side projection). The live drive keeps * the two latest raw results and compares them with `Object.is`; an * object-valued view must reuse its reference to suppress publication * across internal-only state changes. * @param state - the current state. * @returns the whole current value for this unit's key. */ view(state: NoInfer<S>): SessionProjectionMap[K] } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields 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}全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
快照与变更流
Section titled “快照与变更流”/** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value * reflects (`-1` for an empty log). */interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ asOfSeq: SessionSeqCursor /** Whole current client value per registered key. */ values: Partial<SessionProjectionMap>}/** * Change-feed listener: one unit's raw `view` result changed by `Object.is` * for one session. `value` is the schema-validated output; `seq` is the * unit's watermark at emission (the seq of the event that caused the change). */type ProjectionChangeListener = ( session: Session, key: Extract<keyof SessionProjectionMap, string>, value: unknown, seq: SessionSeq,) => voidsnapshot(session) 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 asOfSeq 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 viewSchema 校验。stateOf(session, key) 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 Object.is 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。
注册表:ctx.sessionProjections
Section titled “注册表:ctx.sessionProjections”SessionProjectionRegistry(签名)拥有驱动权:一份 session/event 订阅、对每个已注册单元即时调用 apply,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 init 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 stateVersion 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 ctx.inject(['sessionProjections'], …) 下注册,因此不带注册表的 headless 组装完全不受影响。
Cordis API
Section titled “Cordis API”Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.
ctx.sessionProjectionCache — SessionProjectionCache
Section titled “ctx.sessionProjectionCache — SessionProjectionCache”The persisted projection cache service. Opens the session_projcache domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, turn/end, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
/** * The zero-I/O listing read: whole values viewed straight from the stored * rows (version-matching keys only), each cut carried with its watermark so * a client value store can seed under its higher-seq-wins rule — as stale * as the last durable checkpoint but never wrong, and never from an * unrelated log (the caller's header is the identity witness). Fresher * paths (the history tail baseline) supersede these values whenever a * session is actually opened. * @param meta - the listed session's header (identity witness; no log read). * @param inheritedEventCount - exact inherited prefix length that completes * the checkpoint identity. * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or * `undefined` when no usable row exists for this lifecycle. */cachedSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
/** * Hydrate projection cells for an already-prepared Session without another * persistence read. The cache seeds matching rows; the supplied exact log * advances every unit to the observation cut. No checkpoint is written * because the logical observation may contain recovery events not yet durable. * @param session - exact unpublished Session retained by persistence. * @param events - exact logical event prefix represented by the observation. * @returns all projection values at the event cut. */hydratePrepared( session: Session, events: readonly SessionEvent[], ): ProjectionSnapshot
/** * Durably checkpoint one live session NOW (all mandatory points call * this; tests and carriers may too). The registry cut is snapshotted at * this boundary (states are live references), then the session's record is * replaced on the domain's write chain. NOT fail-soft — callers on the * fail-soft paths contain it. * @param session - the live session to checkpoint. * @returns resolution after durability and event emission. */async write(session: Session): Promise<void>
/** * Cold-read one session's projections from its complete log. Each unit is * seeded from the identity-checked cached rows — the registry skips `apply` * for the already-folded prefix (events at or below the row's `seq`) — and * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so * the first cold read creates the cache row and later ones seed from it. * The caller supplies the complete log in seq order: this service never * consults the persistence layer. * @param meta - the stored session header (identity witness). * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity. * @param events - the session's complete log, in seq order. * @returns the projection cut at the log end. */coldSnapshot( meta: SessionHeader, inheritedEventCount: SessionLogOffset, events: readonly SessionEvent[], ): ProjectionSnapshotTypes: Session · SessionEvent · SessionHeader · SessionLogOffset
Source: packages/session/session-projection-cache/src/index.ts
ctx.sessionProjections — SessionProjectionRegistry
Section titled “ctx.sessionProjections — SessionProjectionRegistry”ctx.sessionProjections: the projection unit table and its drive. The service subscribes to session/event once; every committed event passes every registered unit’s apply (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by Object.is. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds init over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin’s key disappears from snapshots and clients read it as capability absence. A host reader either declares sessionProjections in its plugin inject or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through ctx.inject(['sessionProjections'], ...). Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
/** * 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, 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'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): () => void
/** * Register one host-only unit. Its state is omitted from client snapshots * 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. */register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'>, ): () => void
/** * Subscribe to the change feed. The registration is an effect on the * calling context's fiber. * @param listener - called once per client-visible unit whose raw view changed by `Object.is`, per committed event. * @returns the exact disposer that unsubscribes. */onChanged(listener: ProjectionChangeListener): () => void
/** * Read one unit's current host state after materializing every registered * unit at the Session cursor. Unrelated wire views are not produced. * The returned value is live; callers must not mutate it. * @param session - the session whose state is read. * @param key - the registered unit key. * @returns current state, or `undefined` when the key is not registered. */stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
/** * One consistent cut over every registered client-visible 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 `viewSchema` before leaving. * @param session - the session whose projection values are read. * @param keys - optional client-visible outputs; state materialization remains complete. * @returns the snapshot; `values` is empty when no selected client-visible unit is registered. */snapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot
/** * Read only already-materialized client-visible cells without folding history. * Values may trail the live Session and are therefore hints, not a complete * baseline. Missing cells are omitted. * @param session - attached Session whose cached cells are inspected. * @param keys - optional wire keys to view. * @returns the lowest common cached cut, or `undefined` when no wire cell exists. */cachedSnapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
/** * 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). * @param session - the session whose unit states are checkpointed. * @returns one row per registered key. */checkpoint(session: Session): ProjectionCheckpoint
/** * 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 offset for the stored-log suffix read (`SessionHandle.read`), * or `undefined` when no unit is registered (no read needed — * {@link restore} would serve empty values regardless). */restoreFloor(checkpoint: ProjectionCheckpoint): SessionLogOffset | undefined
/** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated * `view` of the schema-validated stored state; mismatched, malformed, 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). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. */viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): Partial<SessionProjectionMap>
/** * Cold read: fold every persisted 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 stored events at or past `restoreFloor(checkpoint)` (a * `SessionHandle.read` slice) 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). * @param header - immutable metadata for the Session being restored. * @param inheritedEventCount - exact fork-inherited prefix length supplied to unit initialization. * @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: SessionLogOffset, header: SessionHeader, inheritedEventCount: SessionLogOffset, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
/** * Restore an exact cut and install its states on the supplied prepared Session. * A later publication reuses these cells; ordinary live reads and event drive * advance any constructor-owned suffix exactly once. * @param session - exact prepared Session that owns the restored log prefix. * @param checkpoint - persisted rows for this Session lifecycle. * @param events - exact events at the observation cut. * @param baseSeq - first supplied event sequence. * @returns all projection values at the supplied cut. */hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: SessionLogOffset, ): ProjectionSnapshotTypes: Session · SessionEvent · SessionHeader · SessionLogOffset