projection-store.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. /**
  2. * Projection value store (push model; session-projection subsystem page:
  3. * docs/subsystems/session-projection.md): the single
  4. * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
  5. * newer push frame; a replayed frame cannot regress), capability absence as
  6. * undefined, generation invalidation, and the Session/manager wiring (tail-page
  7. * seeding, control-stream projection routing pre- and post-instantiation, the
  8. * list rows' title projection).
  9. */
  10. import { describe, expect } from 'vitest'
  11. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  12. import { SessionSeq } from '@deepseek-ai/dsh-session/types'
  13. import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
  14. import {
  15. createClientTest, type ClientTestFixtures, webApp,
  16. } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
  17. import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
  18. import { SessionManager } from '../src/client/sessions/manager.ts'
  19. import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
  20. import { entries, plainTurn } from './event-script.client.ts'
  21. import { sessionBench } from './remote/bench.client.ts'
  22. import { FOLLOW, followScript, sessionWorld } from './remote/session.client.ts'
  23. // Test-domain keys merged into the projection map (the Service Definition package's
  24. // pure-type outlet), the same way domain host plugins merge theirs.
  25. declare module '@deepseek-ai/dsh-session-projection/types' {
  26. interface SessionProjectionMap {
  27. 'test/marks': { marks: string[] }
  28. }
  29. }
  30. const SID = 'fk-s1' as SessionId
  31. /** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */
  32. const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
  33. const it = createClientTest({ roster: API_ROSTER })
  34. /** The first client boot pays the cold module transform of the api cone. */
  35. const COLD_BOOT_TIMEOUT_MS = 60_000
  36. function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager {
  37. mock.load(sessionWorld)
  38. // Manager-routing cases never open a Session, so they do not need the broader Client Remote's $stream member.
  39. return new SessionManager(remote as unknown as SessionRemotes)
  40. }
  41. describe('Session projection value semantics', () => {
  42. it('reads undefined until a value lands (capability absence)', () => {
  43. const store = new ProjectionValueStore()
  44. expect(store.get('test/marks')).toBeUndefined()
  45. expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
  46. })
  47. it('applies frames last-wins by seq: replayed and stale frames drop', () => {
  48. const store = new ProjectionValueStore()
  49. store.apply('test/marks', { marks: ['a'] }, SessionSeq(5))
  50. store.apply('test/marks', { marks: ['a', 'b'] }, SessionSeq(9))
  51. expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
  52. store.apply('test/marks', { marks: ['stale'] }, SessionSeq(5))
  53. store.apply('test/marks', { marks: ['equal'] }, SessionSeq(9))
  54. expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
  55. })
  56. it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
  57. const store = new ProjectionValueStore()
  58. store.apply('test/marks', { marks: ['frame-20'] }, SessionSeq(20))
  59. // Stale cut: carried key loses to the newer frame; omitted key survives.
  60. store.seed({ asOfSeq: SessionSeq(10), values: { 'test/marks': { marks: ['baseline-10'] } } })
  61. expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
  62. store.seed({ asOfSeq: SessionSeq(15), values: {} })
  63. expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
  64. // Fresh cut: carried key reseeds…
  65. store.seed({ asOfSeq: SessionSeq(30), values: { 'test/marks': { marks: ['baseline-30'] } } })
  66. expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
  67. // …and an omitting fresh cut clears (capability absent as of the cut).
  68. store.seed({ asOfSeq: SessionSeq(40), values: {} })
  69. expect(store.get('test/marks')).toBeUndefined()
  70. })
  71. it('clears all generation watermarks without replacing subscribed faces', async () => {
  72. const store = new ProjectionValueStore()
  73. const face = store.faceOf('test/marks')
  74. const observed: unknown[] = []
  75. const unsubscribe = face.subscribe(() => { observed.push(face.getSnapshot()) })
  76. try {
  77. store.apply('test/marks', { marks: ['lost-tail'] }, SessionSeq(20))
  78. store.apply('empty-session', 'old generation', -1)
  79. const previous = store.values()
  80. await Promise.resolve()
  81. store.clear()
  82. await Promise.resolve()
  83. expect(face.getSnapshot()).toBeUndefined()
  84. expect(store.get('empty-session')).toBeUndefined()
  85. expect(store.faceOf('test/marks')).toBe(face)
  86. expect(store.values()).toEqual({})
  87. expect(store.values()).not.toBe(previous)
  88. store.seed({ asOfSeq: SessionSeq(1), values: { 'test/marks': { marks: ['durable'] } } })
  89. await Promise.resolve()
  90. expect(observed).toEqual([{ marks: ['lost-tail'] }, undefined, { marks: ['durable'] }])
  91. } finally {
  92. unsubscribe()
  93. }
  94. })
  95. it('notifies the key face on change (batched) and not on dropped applications', async () => {
  96. const store = new ProjectionValueStore()
  97. let keyTicks = 0
  98. let anyTicks = 0
  99. store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
  100. store.subscribeAny(() => { anyTicks += 1 })
  101. store.apply('test/marks', { marks: ['a'] }, SessionSeq(5))
  102. await Promise.resolve()
  103. expect(keyTicks).toBe(1)
  104. expect(anyTicks).toBe(1)
  105. store.apply('test/marks', { marks: ['replay'] }, SessionSeq(3))
  106. await Promise.resolve()
  107. expect(keyTicks).toBe(1)
  108. expect(anyTicks).toBe(1)
  109. })
  110. it('faces are identity-stable per key (the React binding cache premise)', () => {
  111. const store = new ProjectionValueStore()
  112. expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
  113. })
  114. it('publishes one reference-stable whole-value snapshot until a row changes', () => {
  115. const store = new ProjectionValueStore()
  116. const empty = store.values()
  117. expect(store.values()).toBe(empty)
  118. store.apply('test/marks', { marks: ['a'] }, SessionSeq(1))
  119. const populated = store.values()
  120. expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
  121. expect(populated).not.toBe(empty)
  122. expect(store.values()).toBe(populated)
  123. })
  124. })
  125. describe('Session tail-page seeding', () => {
  126. it('seeds the store from a history response carrying a projections block', async ({ mock, start }) => {
  127. const session = await sessionBench(mock, start, SID)
  128. mock.stream(FOLLOW, followScript(ok({
  129. records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false,
  130. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
  131. } as never)))
  132. await session.open()
  133. expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
  134. }, COLD_BOOT_TIMEOUT_MS)
  135. it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => {
  136. const session = await sessionBench(mock, start, SID)
  137. mock.stream(FOLLOW, followScript(ok({
  138. records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false,
  139. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
  140. } as never)))
  141. await session.open()
  142. session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9))
  143. await session.resync()
  144. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
  145. })
  146. it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => {
  147. const session = await sessionBench(mock, start, SID)
  148. mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })))
  149. await session.open()
  150. session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9))
  151. await session.resync()
  152. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
  153. })
  154. })
  155. describe('manager frame routing', () => {
  156. const sid = (s: string): SessionId => s as SessionId
  157. it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => {
  158. const manager = makeManager(mock, remote)
  159. manager.handleControlFrame({
  160. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
  161. })
  162. const session = manager.get(sid('s1'))
  163. expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
  164. // Frames after instantiation land in the same store.
  165. manager.handleControlFrame({
  166. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
  167. })
  168. expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
  169. })
  170. it('preserves a newer title when the control baseline omits it', async ({ mock, remote }) => {
  171. const manager = makeManager(mock, remote)
  172. remote.session.list.mockResolvedValue(ok({
  173. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  174. }))
  175. await manager.refreshList()
  176. manager.handleControlFrame({
  177. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
  178. })
  179. await Promise.resolve()
  180. expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
  181. manager.handleControlFrame({
  182. type: 'baseline',
  183. value: {
  184. jobs: {},
  185. projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
  186. },
  187. })
  188. await Promise.resolve()
  189. expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
  190. })
  191. it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {
  192. const manager = makeManager(mock, remote)
  193. remote.session.list.mockResolvedValue(ok({
  194. items: [{
  195. sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
  196. projections: {
  197. asOfSeq: 2,
  198. values: { 'test/marks': { marks: ['baseline'] } },
  199. },
  200. }],
  201. }))
  202. await manager.refreshList()
  203. const baseline = manager.getListSnapshot().items[0]?.projectionValues
  204. expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
  205. expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
  206. manager.handleControlFrame({
  207. type: 'projection', sessionId: sid('s1'), key: 'test/marks',
  208. value: { marks: ['live'] }, seq: 3,
  209. })
  210. await Promise.resolve()
  211. expect(manager.getListSnapshot().items[0]?.projectionValues)
  212. .toEqual({ 'test/marks': { marks: ['live'] } })
  213. expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
  214. })
  215. it('drops the projection store with the removed session', async ({ mock, remote }) => {
  216. const manager = makeManager(mock, remote)
  217. remote.session.list.mockResolvedValue(ok({
  218. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  219. }))
  220. await manager.refreshList()
  221. manager.handleControlFrame({
  222. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
  223. })
  224. manager.handleSessionRemoved(sid('s1'))
  225. expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
  226. })
  227. })