projection-store.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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 truncation, 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. function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): SessionManager {
  35. mock.load(sessionWorld)
  36. // Manager-routing cases never open a Session, so they do not need the broader Client Remote's $stream member.
  37. return new SessionManager(remote as unknown as SessionRemotes)
  38. }
  39. describe('Session projection value semantics', () => {
  40. it('reads undefined until a value lands (capability absence)', () => {
  41. const store = new ProjectionValueStore()
  42. expect(store.get('test/marks')).toBeUndefined()
  43. expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
  44. })
  45. it('applies frames last-wins by seq: replayed and stale frames drop', () => {
  46. const store = new ProjectionValueStore()
  47. store.apply('test/marks', { marks: ['a'] }, SessionSeq(5))
  48. store.apply('test/marks', { marks: ['a', 'b'] }, SessionSeq(9))
  49. expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
  50. store.apply('test/marks', { marks: ['stale'] }, SessionSeq(5))
  51. store.apply('test/marks', { marks: ['equal'] }, SessionSeq(9))
  52. expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
  53. })
  54. it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
  55. const store = new ProjectionValueStore()
  56. store.apply('test/marks', { marks: ['frame-20'] }, SessionSeq(20))
  57. // Stale cut: carried key loses to the newer frame; omitted key survives.
  58. store.seed({ asOfSeq: SessionSeq(10), values: { 'test/marks': { marks: ['baseline-10'] } } })
  59. expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
  60. store.seed({ asOfSeq: SessionSeq(15), values: {} })
  61. expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
  62. // Fresh cut: carried key reseeds…
  63. store.seed({ asOfSeq: SessionSeq(30), values: { 'test/marks': { marks: ['baseline-30'] } } })
  64. expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
  65. // …and an omitting fresh cut clears (capability absent as of the cut).
  66. store.seed({ asOfSeq: SessionSeq(40), values: {} })
  67. expect(store.get('test/marks')).toBeUndefined()
  68. })
  69. it('truncate drops rows past the durable baseline and keeps the rest', () => {
  70. const store = new ProjectionValueStore()
  71. store.apply('test/marks', { marks: ['durable'] }, SessionSeq(5))
  72. store.apply('other', 'phantom', SessionSeq(50))
  73. store.truncate(SessionSeq(10))
  74. expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
  75. expect(store.get('other')).toBeUndefined()
  76. })
  77. it('notifies the key face on change (batched) and not on dropped applications', async () => {
  78. const store = new ProjectionValueStore()
  79. let keyTicks = 0
  80. let anyTicks = 0
  81. store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
  82. store.subscribeAny(() => { anyTicks += 1 })
  83. store.apply('test/marks', { marks: ['a'] }, SessionSeq(5))
  84. await Promise.resolve()
  85. expect(keyTicks).toBe(1)
  86. expect(anyTicks).toBe(1)
  87. store.apply('test/marks', { marks: ['replay'] }, SessionSeq(3))
  88. await Promise.resolve()
  89. expect(keyTicks).toBe(1)
  90. expect(anyTicks).toBe(1)
  91. })
  92. it('faces are identity-stable per key (the React binding cache premise)', () => {
  93. const store = new ProjectionValueStore()
  94. expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
  95. })
  96. it('publishes one reference-stable whole-value snapshot until a row changes', () => {
  97. const store = new ProjectionValueStore()
  98. const empty = store.values()
  99. expect(store.values()).toBe(empty)
  100. store.apply('test/marks', { marks: ['a'] }, SessionSeq(1))
  101. const populated = store.values()
  102. expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
  103. expect(populated).not.toBe(empty)
  104. expect(store.values()).toBe(populated)
  105. })
  106. })
  107. describe('Session tail-page seeding', () => {
  108. it('seeds the store from a history response carrying a projections block', async ({ mock, start }) => {
  109. const session = await sessionBench(mock, start, SID)
  110. mock.stream(FOLLOW, followScript(ok({
  111. records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false,
  112. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
  113. } as never)))
  114. await session.open()
  115. expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
  116. })
  117. it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => {
  118. const session = await sessionBench(mock, start, SID)
  119. mock.stream(FOLLOW, followScript(ok({
  120. records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false,
  121. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
  122. } as never)))
  123. await session.open()
  124. session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9))
  125. await session.resync()
  126. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
  127. })
  128. it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => {
  129. const session = await sessionBench(mock, start, SID)
  130. mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })))
  131. await session.open()
  132. session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9))
  133. await session.resync()
  134. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
  135. })
  136. })
  137. describe('manager frame routing', () => {
  138. const sid = (s: string): SessionId => s as SessionId
  139. it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => {
  140. const manager = makeManager(mock, remote)
  141. manager.handleControlFrame({
  142. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
  143. })
  144. const session = manager.get(sid('s1'))
  145. expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
  146. // Frames after instantiation land in the same store.
  147. manager.handleControlFrame({
  148. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
  149. })
  150. expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
  151. })
  152. it('projects the title key into list rows and truncates phantom rows on the control baseline', async ({ mock, remote }) => {
  153. const manager = makeManager(mock, remote)
  154. remote.session.list.mockResolvedValue(ok({
  155. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  156. }))
  157. await manager.refreshList()
  158. manager.handleControlFrame({
  159. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
  160. })
  161. await Promise.resolve()
  162. expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
  163. // The durable baseline says the host only knows up to seq 2: the row rode
  164. // lost state and must drop (the un-flushed title precedent).
  165. manager.handleControlFrame({
  166. type: 'baseline',
  167. value: {
  168. queues: {}, jobs: {},
  169. projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
  170. },
  171. })
  172. await Promise.resolve()
  173. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  174. })
  175. it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {
  176. const manager = makeManager(mock, remote)
  177. remote.session.list.mockResolvedValue(ok({
  178. items: [{
  179. sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
  180. projections: {
  181. asOfSeq: 2,
  182. values: { 'test/marks': { marks: ['baseline'] } },
  183. },
  184. }],
  185. }))
  186. await manager.refreshList()
  187. const baseline = manager.getListSnapshot().items[0]?.projectionValues
  188. expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
  189. expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
  190. manager.handleControlFrame({
  191. type: 'projection', sessionId: sid('s1'), key: 'test/marks',
  192. value: { marks: ['live'] }, seq: 3,
  193. })
  194. await Promise.resolve()
  195. expect(manager.getListSnapshot().items[0]?.projectionValues)
  196. .toEqual({ 'test/marks': { marks: ['live'] } })
  197. expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
  198. })
  199. it('drops the projection store with the removed session', async ({ mock, remote }) => {
  200. const manager = makeManager(mock, remote)
  201. remote.session.list.mockResolvedValue(ok({
  202. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  203. }))
  204. await manager.refreshList()
  205. manager.handleControlFrame({
  206. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
  207. })
  208. manager.handleSessionRemoved(sid('s1'))
  209. expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
  210. })
  211. })