2026-07-27 16:06:20 +08:00
/**
2026-08-09 15:34:32 +08:00
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible `SessionProjectionMap` type
2026-07-27 21:34:14 +08:00
* 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;
2026-08-05 12:46:38 +08:00
* carriers consume the snapshot read face and the change feed. Neither side
* knows the other
2026-07-27 21:34:14 +08:00
* (capability-seam three-way split). Design authority: the session-projection
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
2026-07-27 16:06:20 +08:00
*
2026-07-27 21:34:14 +08:00
* 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.
2026-07-27 16:06:20 +08:00
*
* @module @deepseek-ai/dsh-session-projection
*/
2026-08-10 22:04:06 +08:00
import { Context , Service } from '@deepseek-ai/cordis'
2026-07-27 16:06:20 +08:00
import type { ZodType } from 'zod'
2026-07-27 21:34:14 +08:00
import type { Session , SessionEvent } from '@deepseek-ai/dsh-session'
2026-07-27 16:06:20 +08:00
2026-08-10 22:04:06 +08:00
declare module '@deepseek-ai/cordis' {
2026-07-27 16:06:20 +08:00
interface Context {
sessionProjections : SessionProjectionRegistry
}
}
2026-07-27 16:11:33 +08:00
import type { SessionProjectionMap } from './types.ts'
export type { SessionProjectionMap } from './types.ts'
2026-07-27 16:06:20 +08:00
/**
2026-07-27 21:34:14 +08:00
* 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).
2026-07-27 16:06:20 +08:00
*/
2026-07-27 21:34:14 +08:00
export interface ProjectionDefinition < K extends keyof SessionProjectionMap , S > {
/** The projection key this unit owns (its `SessionProjectionMap` entry). */
2026-07-27 16:06:20 +08:00
key : K
2026-07-27 21:34:14 +08:00
/** Validates the wire payload (`view` output) before it leaves the host. */
2026-07-27 16:06:20 +08:00
schema : ZodType < SessionProjectionMap [ K ] >
/**
2026-07-27 21:34:14 +08:00
* State for the empty log.
* @returns the initial state.
2026-07-27 16:06:20 +08:00
*/
2026-07-27 21:34:14 +08:00
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 ]
/**
2026-08-09 15:27:21 +08:00
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
2026-07-28 22:45:35 +08:00
* 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.
2026-07-27 21:34:14 +08:00
*/
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 ,
2026-07-28 11:48:01 +08:00
key : Extract < keyof SessionProjectionMap , string > ,
2026-07-27 21:34:14 +08:00
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 >
}
2026-07-28 01:12:39 +08:00
/**
* One unit's checkpoint: its internal state (plain JSON by the unit
2026-07-28 22:45:35 +08:00
* contract), the seq of the last event folded into it, and the unit
2026-07-28 01:12:39 +08:00
* `stateVersion` that produced it — the persisted projection-cache row
2026-07-28 22:45:35 +08:00
* `(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.
2026-07-28 01:12:39 +08:00
*/
export interface ProjectionCheckpointRow {
/** The registering unit's `stateVersion` at fold time. */
2026-07-28 22:45:35 +08:00
ver : number
/** Seq of the last event folded into `val`; -1 for the empty log. */
seq : number
2026-07-28 01:12:39 +08:00
/** The unit's internal state — plain JSON per the unit contract. */
2026-07-28 22:45:35 +08:00
val : unknown
2026-07-28 01:12:39 +08:00
}
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
export type ProjectionCheckpoint = Record < string , ProjectionCheckpointRow >
2026-08-09 15:34:32 +08:00
/** Type-erased unit view the drive machinery works with (the registration contract already proved the typed form). */
2026-07-27 21:34:14 +08:00
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
2026-07-27 16:06:20 +08:00
}
2026-08-06 13:08:30 +08:00
/**
* 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.
*/
2026-07-27 21:34:14 +08:00
interface Registration {
readonly def : ErasedDefinition
readonly cells : WeakMap < Session , UnitCell >
2026-08-06 13:08:30 +08:00
/** Live registrants sharing this unit; the last one out removes the key. */
refs : number
2026-07-27 21:34:14 +08:00
}
2026-07-27 16:06:20 +08:00
/**
2026-07-27 21:34:14 +08:00
* `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
2026-08-06 13:08:30 +08:00
* and clients read it as capability absence. Domain
2026-07-27 21:34:14 +08:00
* plugins register under `ctx.inject(['sessionProjections'], …)` so headless
2026-08-06 13:08:30 +08:00
* 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.
2026-07-27 16:06:20 +08:00
*/
export class SessionProjectionRegistry extends Service {
2026-07-27 21:34:14 +08:00
private readonly registrations = new Map < string , Registration > ( )
private readonly listeners = new Set < ProjectionChangeListener > ( )
2026-07-27 16:06:20 +08:00
/**
* Create and install the registry as `ctx.sessionProjections`.
* @param ctx - Cordis context that owns the service.
*/
constructor ( ctx : Context ) {
super ( ctx , 'sessionProjections' )
2026-07-27 21:34:14 +08:00
ctx . on ( 'session/event' , ( session : Session , event : SessionEvent ) = > {
this . drive ( session , event )
} )
2026-07-27 16:06:20 +08:00
}
/**
2026-07-27 21:34:14 +08:00
* 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.
2026-08-09 15:27:21 +08:00
* @param definition - key, state schema, pure unit functions, and stateVersion.
2026-07-27 21:34:14 +08:00
* @returns the exact disposer that unregisters this unit.
2026-07-27 16:06:20 +08:00
*/
2026-07-27 21:34:14 +08:00
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 ) } ` )
}
2026-07-27 16:06:20 +08:00
const dispose = this . ctx . effect ( function * ( this : SessionProjectionRegistry ) {
2026-07-27 21:34:14 +08:00
const key = definition . key as string
2026-08-06 13:08:30 +08:00
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
2026-07-27 16:06:20 +08:00
}
yield ( ) = > {
2026-08-06 13:08:30 +08:00
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 )
2026-07-27 16:06:20 +08:00
}
} . bind ( this ) , 'sessionProjections.register()' )
return ( ) = > void dispose ( )
}
/**
2026-07-27 21:34:14 +08:00
* 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.
2026-07-27 16:06:20 +08:00
*/
2026-07-27 21:34:14 +08:00
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 ) )
}
2026-07-28 11:48:01 +08:00
return { asOfSeq : session.seq - 1 , values : values }
2026-07-27 21:34:14 +08:00
}
2026-07-28 01:12:39 +08:00
/**
* 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
2026-07-28 22:45:35 +08:00
* 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
2026-07-28 01:43:05 +08:00
* 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).
2026-07-28 01:12:39 +08:00
* @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 ] = {
2026-07-28 22:45:35 +08:00
ver : registration.def.stateVersion ,
seq : cell.observedSeq ,
val : structuredClone ( cell . state ) ,
2026-07-28 01:12:39 +08:00
}
}
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
2026-07-28 22:45:35 +08:00
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
2026-07-28 01:12:39 +08:00
* 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 ]
2026-07-28 22:45:35 +08:00
const need = row !== undefined && row . ver === registration . def . stateVersion
? Math . max ( row . seq + 1 , 0 )
2026-07-28 01:12:39 +08:00
: 0
floor = floor === undefined ? need : Math.min ( floor , need )
}
return floor === undefined ? undefined : Math . max ( floor - 1 , 0 )
}
2026-07-28 01:38:15 +08:00
/**
* View a checkpoint's rows without any log read: for every registered
2026-07-28 22:45:35 +08:00
* unit whose row's `ver` matches, serve the schema-validated
2026-07-28 01:38:15 +08:00
* `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 ]
2026-07-28 22:45:35 +08:00
if ( row === undefined || row . ver !== def . stateVersion ) continue
values [ def . key ] = def . schema . parse ( def . view ( row . val ) )
2026-07-28 01:38:15 +08:00
}
2026-07-28 13:02:21 +08:00
return values
2026-07-28 01:38:15 +08:00
}
2026-07-28 01:12:39 +08:00
/**
* 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
2026-07-28 22:45:35 +08:00
* `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
2026-07-28 01:12:39 +08:00
* 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
2026-07-28 22:45:35 +08:00
&& row . ver === def . stateVersion
&& row . seq >= baseSeq - 1
&& row . seq <= endSeq
2026-07-28 01:12:39 +08:00
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' ,
)
}
2026-07-28 22:45:35 +08:00
let state = usable ? row.val : def.init ( )
const from = usable ? row.seq : baseSeq - 1
2026-07-28 01:12:39 +08:00
for ( const event of events ) {
if ( event . seq > from ) state = def . apply ( state , event )
}
values [ def . key ] = def . schema . parse ( def . view ( state ) )
2026-07-28 22:45:35 +08:00
refreshed [ def . key ] = { ver : def.stateVersion , seq : endSeq , val : state }
2026-07-28 01:12:39 +08:00
}
return {
2026-07-28 13:02:21 +08:00
snapshot : { asOfSeq : endSeq , values : values } ,
2026-07-28 01:12:39 +08:00
checkpoint : refreshed ,
}
}
2026-07-27 21:34:14 +08:00
/** 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 ) {
2026-07-28 11:48:01 +08:00
listener ( session , registration . def . key as Extract < keyof SessionProjectionMap , string > , value , event . seq )
2026-07-27 21:34:14 +08:00
}
}
}
2026-07-27 16:06:20 +08:00
}
}
export default SessionProjectionRegistry