Bladeren bron

feat(gui): generic projection value store — host-pushed whole values, higher-seq-wins

The push-model client base (session-projection RFC final): ProjectionValueStore
holds key → {value, seq} per session, seeded by the tail page's projections
block and updated by session/projection frames under one rule — higher seq
wins on both paths (stale baseline cannot overwrite a newer frame; replayed
frames cannot regress; an omitting fresh baseline clears = capability absent);
truncate() drops phantom rows past a subscribed durable baseline. Per-key
identity-stable faces (always defined; absence is an undefined snapshot) feed
useProjection; the renderer contract's projections member becomes faceOf.
12 store specs cover both seq directions, absence, truncation, and batching.
imccyu 2 maanden geleden
bovenliggende
commit
531eb7cf0e

+ 183 - 0
packages/client/runtime/src/client/sessions/projection-store.ts

@@ -0,0 +1,183 @@
+/**
+ * Generic per-session projection value store (session-projection RFC, push
+ * model): the host is the only computation site; the client holds finished
+ * whole values per key — `key → { value, seq }` — seeded by the history tail
+ * page's projections block and updated by `session/projection` push frames,
+ * under the single rule **higher seq wins**. No client-side domain folding
+ * exists: a domain ships projection support with zero client code. Per-key
+ * bare observable faces feed `useProjection` (web-react binds them).
+ */
+import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
+import type { ObservableSnapshot } from '../contract/store.ts'
+import { Notifier } from './notifier.ts'
+
+// The single projection type table, typed end to end (host unit, wire block,
+// client store, React hook) — the interface package's pure-type outlet
+// (`/types`, zero imports), never the package root: the root's dsh-agent →
+// dsh-session chain would drag the host `Context.sessions` merge into the
+// client program (one program must not hold both sides). No second
+// client-side "views" table (user ruling, RFC Alternatives).
+export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
+
+/**
+ * The fifth framework hook seat (session-projection RFC): key-addressed
+ * projection reader delivered through the standard kit. `undefined` uniformly
+ * means capability absent — host unit unmounted, or no baseline/frame has
+ * carried the key yet. The selector overload mirrors useSession (per-key uSES
+ * binding; reference stability holds because a key's value reference changes
+ * only when a frame or baseline lands).
+ */
+export type UseProjection = {
+  <K extends keyof SessionProjectionMap & string>(key: K): SessionProjectionMap[K] | undefined
+  <K extends keyof SessionProjectionMap & string, S>(
+    key: K,
+    selector: (value: SessionProjectionMap[K] | undefined) => S,
+    eq?: (a: S, b: S) => boolean,
+  ): S
+}
+
+/**
+ * Tail-page projections baseline — structurally identical to the wire's
+ * `SessionProjectionsBlock` (apiproxy api layer), restated here so the
+ * React-free store depends only on the type table, not the wire package's
+ * response vocabulary.
+ */
+export interface ProjectionsBaseline {
+  /** The consistent-cut seq (equals the window tail seq by construction). */
+  asOfSeq: number
+  /** Whole current values by key; a registered key absent here means the capability is absent. */
+  values: Partial<SessionProjectionMap>
+}
+
+/** One key's row: the latest finished value and the seq it is consistent with. */
+interface Row {
+  value: unknown
+  seq: number
+}
+
+/** Per-key notification channel: the bare face plus its batching notifier. */
+interface Channel {
+  face: ObservableSnapshot<unknown>
+  notifier: Notifier
+}
+
+/**
+ * One session's projection values. Framework semantics, uniform across every
+ * key: a baseline seeds rows at its cut, a push frame updates one row, and in
+ * both paths a lower-or-equal seq loses — a replayed frame cannot regress a
+ * value, a stale baseline cannot overwrite a newer frame. A key the store has
+ * never seen reads `undefined` (capability absent). Faces are identity-stable
+ * per key (create-on-demand, cached) so the React side binds each exactly
+ * once; the store-level channel (`subscribeAny`) serves coarse consumers (the
+ * manager's list projection reads the `title` key).
+ */
+export class ProjectionValueStore {
+  private readonly rows = new Map<string, Row>()
+  private readonly channels = new Map<string, Channel>()
+  /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
+  private readonly anyNotifier = new Notifier(() => {})
+
+  /**
+   * Key-addressed bare observable face (the useProjection resolution path).
+   * Always defined — absence is an `undefined` snapshot, never a missing
+   * face, so a component may subscribe before the key ever carries a value.
+   * @param key - projection key.
+   * @returns the identity-stable face for this key.
+   */
+  faceOf(key: string): ObservableSnapshot<unknown> {
+    return this.channel(key).face
+  }
+
+  /**
+   * Current whole value for a key (erased framework read; typed reads go
+   * through `useProjection`'s map lookup).
+   * @param key - projection key.
+   * @returns the value, or undefined while the key is absent.
+   */
+  get(key: string): unknown {
+    return this.rows.get(key)?.value
+  }
+
+  /**
+   * Subscribe to any-key changes (microtask-batched) — the manager's list
+   * rebuild channel.
+   * @param listener - change callback.
+   * @returns the unsubscribe function.
+   */
+  subscribeAny(listener: () => void): () => void {
+    return this.anyNotifier.subscribe(listener)
+  }
+
+  /**
+   * Apply one finished value (the `session/projection` push-frame path).
+   * @param key - projection key.
+   * @param value - whole value computed by the host unit.
+   * @param seq - the unit's watermark at emission.
+   */
+  apply(key: string, value: unknown, seq: number): void {
+    const row = this.rows.get(key)
+    if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
+    this.rows.set(key, { value, seq })
+    this.changed(key)
+  }
+
+  /**
+   * Seed from a history tail page's projections block: every carried key
+   * lands under the same seq rule as frames; a key the block omits is
+   * capability-absent as of the cut — its row clears unless a newer frame
+   * already superseded the cut (a stale baseline can neither overwrite nor
+   * clear newer values).
+   * @param baseline - the response's projections block.
+   */
+  seed(baseline: ProjectionsBaseline): void {
+    // Erased walk: the framework crosses the open key space; per-key typing
+    // is re-established at the consumer (useProjection's map lookup).
+    const values = baseline.values as Record<string, unknown>
+    for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
+    for (const [key, row] of this.rows) {
+      if (Object.hasOwn(values, key)) continue
+      if (row.seq > baseline.asOfSeq) continue
+      this.rows.delete(key)
+      this.changed(key)
+    }
+  }
+
+  /**
+   * Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
+   * a row claiming knowledge beyond the host's own durable baseline rode
+   * state a restart lost — under last-wins it would wrongly outrank the
+   * host's recomputed (lower-seq) values forever. Durable replay and the next
+   * baseline re-seed whatever truly survived (the title-snapshot precedent,
+   * generalized).
+   * @param lastSeq - the subscribed frame's durable baseline seq.
+   */
+  truncate(lastSeq: number): void {
+    for (const [key, row] of this.rows) {
+      if (row.seq <= lastSeq) continue
+      this.rows.delete(key)
+      this.changed(key)
+    }
+  }
+
+  private changed(key: string): void {
+    this.channels.get(key)?.notifier.markDirty()
+    this.anyNotifier.markDirty()
+  }
+
+  private channel(key: string): Channel {
+    let channel = this.channels.get(key)
+    if (channel === undefined) {
+      // The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
+      const notifier = new Notifier(() => {})
+      channel = {
+        notifier,
+        face: {
+          getSnapshot: () => this.rows.get(key)?.value,
+          subscribe: listener => notifier.subscribe(listener),
+        },
+      }
+      this.channels.set(key, channel)
+    }
+    return channel
+  }
+}

+ 187 - 0
packages/client/runtime/tests/projection-store.spec.ts

@@ -0,0 +1,187 @@
+/**
+ * Projection value store (session-projection RFC, push model): the single
+ * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
+ * newer push frame; a replayed frame cannot regress), capability absence as
+ * undefined, generation truncation, and the Session/manager wiring (tail-page
+ * seeding, session/projection frame routing pre- and post-instantiation, the
+ * list rows' title projection).
+ */
+import { describe, expect, it } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
+import { Session } from '../src/client/sessions/session.ts'
+import { SessionManager } from '../src/client/sessions/manager.ts'
+import { FakeApiClient, ok } from './fake-api.ts'
+import { entries, plainTurn } from './event-script.ts'
+
+// Test-domain keys merged into the projection map (the interface package's
+// pure-type outlet), the same way domain host plugins merge theirs.
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionMap {
+    'test/marks': { marks: string[] }
+  }
+}
+
+const SID = 'fk-s1' as SessionId
+
+describe('ProjectionValueStore semantics', () => {
+  it('reads undefined until a value lands (capability absence)', () => {
+    const store = new ProjectionValueStore()
+    expect(store.get('test/marks')).toBeUndefined()
+    expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
+  })
+
+  it('applies frames last-wins by seq: replayed and stale frames drop', () => {
+    const store = new ProjectionValueStore()
+    store.apply('test/marks', { marks: ['a'] }, 5)
+    store.apply('test/marks', { marks: ['a', 'b'] }, 9)
+    expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
+    store.apply('test/marks', { marks: ['stale'] }, 5)
+    store.apply('test/marks', { marks: ['equal'] }, 9)
+    expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
+  })
+
+  it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
+    const store = new ProjectionValueStore()
+    store.apply('test/marks', { marks: ['frame-20'] }, 20)
+    // Stale cut: carried key loses to the newer frame; omitted key survives.
+    store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } as never })
+    expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
+    store.seed({ asOfSeq: 15, values: {} })
+    expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
+    // Fresh cut: carried key reseeds…
+    store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } as never })
+    expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
+    // …and an omitting fresh cut clears (capability absent as of the cut).
+    store.seed({ asOfSeq: 40, values: {} })
+    expect(store.get('test/marks')).toBeUndefined()
+  })
+
+  it('truncate drops rows past the durable baseline and keeps the rest', () => {
+    const store = new ProjectionValueStore()
+    store.apply('test/marks', { marks: ['durable'] }, 5)
+    store.apply('other', 'phantom', 50)
+    store.truncate(10)
+    expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
+    expect(store.get('other')).toBeUndefined()
+  })
+
+  it('notifies the key face on change (batched) and not on dropped applications', async () => {
+    const store = new ProjectionValueStore()
+    let keyTicks = 0
+    let anyTicks = 0
+    store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
+    store.subscribeAny(() => { anyTicks += 1 })
+    store.apply('test/marks', { marks: ['a'] }, 5)
+    await Promise.resolve()
+    expect(keyTicks).toBe(1)
+    expect(anyTicks).toBe(1)
+    store.apply('test/marks', { marks: ['replay'] }, 3)
+    await Promise.resolve()
+    expect(keyTicks).toBe(1)
+    expect(anyTicks).toBe(1)
+  })
+
+  it('faces are identity-stable per key (the React binding cache premise)', () => {
+    const store = new ProjectionValueStore()
+    expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
+  })
+})
+
+describe('Session tail-page seeding', () => {
+  it('seeds the store from a history response carrying a projections block', async () => {
+    const api = new FakeApiClient()
+    const session = new Session(SID, api)
+    api.onHistory = () => Promise.resolve(ok({
+      events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
+      projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
+    } as never))
+    await session.open()
+    expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
+  })
+
+  it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
+    const api = new FakeApiClient()
+    const session = new Session(SID, api)
+    api.onHistory = () => Promise.resolve(ok({
+      events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
+      projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
+    } as never))
+    await session.open()
+    session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
+    await session.resync()
+    expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
+  })
+
+  it('treats a blockless response as no reset: pushed values survive', async () => {
+    const api = new FakeApiClient()
+    const session = new Session(SID, api)
+    api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
+    await session.open()
+    session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
+    await session.resync()
+    expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
+  })
+})
+
+describe('manager frame routing', () => {
+  const sid = (s: string): SessionId => s as SessionId
+
+  it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleMuxEnvelope({
+      rpcId: 'p1' as never,
+      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
+    })
+    const session = manager.get(sid('s1'))
+    expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
+    // Frames after instantiation land in the same store.
+    manager.handleMuxEnvelope({
+      rpcId: 'p2' as never,
+      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
+    })
+    expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
+  })
+
+  it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    api.onList = () => Promise.resolve(ok({
+      items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
+    }) as never)
+    await manager.refreshList()
+    manager.handleMuxEnvelope({
+      rpcId: 't1' as never,
+      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
+    })
+    await Promise.resolve()
+    expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
+    // The durable baseline says the host only knows up to seq 2: the row rode
+    // lost state and must drop (the un-flushed title precedent).
+    manager.handleMuxEnvelope({
+      rpcId: 'sub' as never,
+      payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
+    })
+    await Promise.resolve()
+    expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
+  })
+
+  it('drops the projection store with the removed session', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    api.onList = () => Promise.resolve(ok({
+      items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
+    }) as never)
+    await manager.refreshList()
+    manager.handleMuxEnvelope({
+      rpcId: 't1' as never,
+      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
+    })
+    manager.handleHostEnvelope({
+      rpcId: 'rm' as never,
+      payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
+    })
+    expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
+  })
+})

+ 7 - 6
packages/client/ui-slots/src/renderer.ts

@@ -45,13 +45,14 @@ export interface SessionMaybeProvideInfo {
   /** Static plain-member roster; values are undefined with the session. */
   props: Record<string, unknown>
   /**
-   * Key-addressed projection-cell sources (the useProjection framework seat,
-   * session-projection RFC). Unlike `hooks`, the key space is open — cells
-   * come and go with domain plugins — so the render side binds per resolved
-   * cell instead of per static roster member. Absent with the session; an
-   * unresolved key uniformly reads as capability absent.
+   * Key-addressed projection value sources (the useProjection framework seat,
+   * session-projection RFC). Unlike `hooks`, the key space is open — values
+   * arrive from host-computed push frames — so the render side binds per
+   * resolved key instead of per static roster member. Faces are always
+   * defined per key (absence is an `undefined` snapshot); the whole member is
+   * absent with the session.
    */
-  projections?: { cellOf(key: string): HostObservable<unknown> | undefined } | undefined
+  projections?: { faceOf(key: string): HostObservable<unknown> } | undefined
 }
 
 /** Definite per-session standard props resolved for strict session slots. */

+ 14 - 14
packages/client/web-react/src/session-provider.tsx

@@ -86,12 +86,12 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
 /**
  * The useProjection framework seat (session-projection RFC), one bound
  * function per provide bundle (cached by info identity — components may hold
- * it across renders). Key-addressed: the key resolves a per-session cell
- * source, whose bound selector hook comes from the same per-source cache as
- * every other kit hook, so exactly one uSES subscription runs per call and
- * the subscribe reference stays stable while the cell lives. An unresolved
- * key (no cell, no session, plugin unloaded) reads `undefined` — capability
- * absence — through the absent source, keeping the hook order constant.
+ * it across renders). Key-addressed: the key resolves a per-session value
+ * face off the projection store; the bound selector hook comes from the same
+ * per-source cache as every other kit hook, so exactly one uSES subscription
+ * runs per call and the subscribe reference stays stable per key. A key no
+ * baseline or frame has carried (or a no-session bundle) reads `undefined` —
+ * capability absence — keeping the hook order constant.
  */
 export function projectionHook(info: SessionMaybeProvideInfo): (
   key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
@@ -99,14 +99,14 @@ export function projectionHook(info: SessionMaybeProvideInfo): (
   let hook = projectionHookCache.get(info)
   if (hook === undefined) {
     hook = (key, selector, eq) => {
-      const cell = info.projections?.cellOf(key)
-      // The absent branch binds the shared absent source so the caller's
-      // selector still runs over `undefined` (absence flows through the
-      // selector) and the uSES call count stays constant across resolution.
-      const useCell = observableHook(cell ?? absentSource)
-      // Whole values are frozen event/wire data (identical reference between
-      // events), so the identity selector needs no equality function.
-      return useCell(selector ?? (value => value), eq)
+      // The no-session (faceless) branch binds the shared absent source so
+      // the caller's selector still runs over `undefined` (absence flows
+      // through the selector) and the uSES call count stays constant.
+      const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
+      // Whole values are finished wire payloads (reference changes only when
+      // a frame or baseline lands), so the identity selector needs no
+      // equality function.
+      return useValue(selector ?? (value => value), eq)
     }
     projectionHookCache.set(info, hook)
   }

+ 6 - 4
packages/client/web-react/tests/use-projection.spec.tsx

@@ -3,8 +3,8 @@
  * useProjection standard-kit delivery (session-projection RFC): the fifth
  * framework hook seat rides the same provide channel as useSession — a
  * session slot component receives `useProjection` in its kit, key-addressed
- * over the bundle's projection face; unresolved keys (no cell, no face, no
- * session) uniformly read `undefined`; live cell changes re-render; the
+ * over the bundle's projection face; unresolved keys (no value, no face, no
+ * session) uniformly read `undefined`; live value changes re-render; the
  * selector overload runs over the whole value.
  */
 import { describe, expect, it } from 'vitest'
@@ -27,6 +27,8 @@ type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => un
 function makeHost() {
   const current = observable<string | undefined>(undefined)
   const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
+  /** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
+  const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
   const sessionEntries: StoredEntry[] = []
   let withFace = true
   const rootEntry: StoredEntry = {
@@ -39,7 +41,7 @@ function makeHost() {
     sessionId: id,
     hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
     props: {},
-    ...(withFace ? { projections: { cellOf: (key: string) => cells.get(key) } } : {}),
+    ...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
   })
   const host: SlotRendererHost = {
     subscribe: () => () => {},
@@ -66,7 +68,7 @@ function makeHost() {
 }
 
 describe('useProjection standard-kit delivery', () => {
-  it('reads the cell value through the kit, undefined for unresolved keys, and follows live changes', () => {
+  it('reads the projected value through the kit, undefined for unresolved keys, and follows live changes', () => {
     const h = makeHost()
     const cell = observable<unknown>({ marks: ['a'] })
     h.cells.set('test/marks', cell)