| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- /**
- * Service Definition and drive registry for the session-projection capability 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 '@deepseek-ai/cordis'
- import type { ZodType } from 'zod'
- import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
- declare module '@deepseek-ai/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 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
- }
- /**
- * 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 registration contract already proved the typed form). */
- 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
- * once the last registrant releases it).
- *
- * `refs` exists because one unit definition already serves every session — the
- * cells are keyed by `Session` — while the registrants are now per-session:
- * an agent preset mounts the same tool package once per agent, so N sessions
- * on one preset register the same key N times. Without a count the first
- * registrant would own the disposer, and its session ending would strip the
- * projection from every other live session.
- */
- interface Registration {
- readonly def: ErasedDefinition
- readonly cells: WeakMap<Session, UnitCell>
- /** Live registrants sharing this unit; the last one out removes the key. */
- refs: number
- }
- /**
- * `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. 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.
- */
- 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, state 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
- const existing = this.registrations.get(key)
- if (existing === undefined) {
- this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 })
- } else {
- // A differing `stateVersion` is the one incompatibility this can name:
- // the versioned contract says the cached state shape differs, so the
- // two registrants cannot share cells. Anything else about a definition
- // is functions, which no runtime comparison can tell apart.
- if (existing.def.stateVersion !== definition.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(definition.stateVersion)}`)
- }
- existing.refs += 1
- }
- yield () => {
- const live = this.registrations.get(key)
- /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */
- if (live === undefined) return
- live.refs -= 1
- if (live.refs === 0) 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
|