projection-store.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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. /** 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('truncate drops rows past the durable baseline and keeps the rest', () => {
  72. const store = new ProjectionValueStore()
  73. store.apply('test/marks', { marks: ['durable'] }, SessionSeq(5))
  74. store.apply('other', 'phantom', SessionSeq(50))
  75. store.truncate(SessionSeq(10))
  76. expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
  77. expect(store.get('other')).toBeUndefined()
  78. })
  79. it('notifies the key face on change (batched) and not on dropped applications', async () => {
  80. const store = new ProjectionValueStore()
  81. let keyTicks = 0
  82. let anyTicks = 0
  83. store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
  84. store.subscribeAny(() => { anyTicks += 1 })
  85. store.apply('test/marks', { marks: ['a'] }, SessionSeq(5))
  86. await Promise.resolve()
  87. expect(keyTicks).toBe(1)
  88. expect(anyTicks).toBe(1)
  89. store.apply('test/marks', { marks: ['replay'] }, SessionSeq(3))
  90. await Promise.resolve()
  91. expect(keyTicks).toBe(1)
  92. expect(anyTicks).toBe(1)
  93. })
  94. it('faces are identity-stable per key (the React binding cache premise)', () => {
  95. const store = new ProjectionValueStore()
  96. expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
  97. })
  98. it('publishes one reference-stable whole-value snapshot until a row changes', () => {
  99. const store = new ProjectionValueStore()
  100. const empty = store.values()
  101. expect(store.values()).toBe(empty)
  102. store.apply('test/marks', { marks: ['a'] }, SessionSeq(1))
  103. const populated = store.values()
  104. expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
  105. expect(populated).not.toBe(empty)
  106. expect(store.values()).toBe(populated)
  107. })
  108. })
  109. describe('Session tail-page seeding', () => {
  110. it('seeds the store from a history response carrying a projections block', async ({ mock, start }) => {
  111. const session = await sessionBench(mock, start, SID)
  112. mock.stream(FOLLOW, followScript(ok({
  113. records: entries(plainTurn(SessionSeq(0), 0, '问', '答')) as never[], hasMore: false,
  114. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
  115. } as never)))
  116. await session.open()
  117. expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
  118. }, COLD_BOOT_TIMEOUT_MS)
  119. it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async ({ mock, start }) => {
  120. const session = await sessionBench(mock, start, SID)
  121. mock.stream(FOLLOW, followScript(ok({
  122. records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false,
  123. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
  124. } as never)))
  125. await session.open()
  126. session.projections.apply('test/marks', { marks: ['pushed-9'] }, SessionSeq(9))
  127. await session.resync()
  128. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
  129. })
  130. it('treats a blockless response as no reset: pushed values survive', async ({ mock, start }) => {
  131. const session = await sessionBench(mock, start, SID)
  132. mock.stream(FOLLOW, followScript(ok({ records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], hasMore: false })))
  133. await session.open()
  134. session.projections.apply('test/marks', { marks: ['pushed'] }, SessionSeq(9))
  135. await session.resync()
  136. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
  137. })
  138. })
  139. describe('manager frame routing', () => {
  140. const sid = (s: string): SessionId => s as SessionId
  141. it('lands projection frames before instantiation and the Session adopts the same store', ({ mock, remote }) => {
  142. const manager = makeManager(mock, remote)
  143. manager.handleControlFrame({
  144. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
  145. })
  146. const session = manager.get(sid('s1'))
  147. expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
  148. // Frames after instantiation land in the same store.
  149. manager.handleControlFrame({
  150. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
  151. })
  152. expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
  153. })
  154. it('projects the title key into list rows and truncates phantom rows on the control baseline', async ({ mock, remote }) => {
  155. const manager = makeManager(mock, remote)
  156. remote.session.list.mockResolvedValue(ok({
  157. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  158. }))
  159. await manager.refreshList()
  160. manager.handleControlFrame({
  161. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
  162. })
  163. await Promise.resolve()
  164. expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
  165. // The durable baseline says the host only knows up to seq 2: the row rode
  166. // lost state and must drop (the un-flushed title precedent).
  167. manager.handleControlFrame({
  168. type: 'baseline',
  169. value: {
  170. queues: {}, jobs: {},
  171. projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
  172. },
  173. })
  174. await Promise.resolve()
  175. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  176. })
  177. it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {
  178. const manager = makeManager(mock, remote)
  179. remote.session.list.mockResolvedValue(ok({
  180. items: [{
  181. sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
  182. projections: {
  183. asOfSeq: 2,
  184. values: { 'test/marks': { marks: ['baseline'] } },
  185. },
  186. }],
  187. }))
  188. await manager.refreshList()
  189. const baseline = manager.getListSnapshot().items[0]?.projectionValues
  190. expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
  191. expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
  192. manager.handleControlFrame({
  193. type: 'projection', sessionId: sid('s1'), key: 'test/marks',
  194. value: { marks: ['live'] }, seq: 3,
  195. })
  196. await Promise.resolve()
  197. expect(manager.getListSnapshot().items[0]?.projectionValues)
  198. .toEqual({ 'test/marks': { marks: ['live'] } })
  199. expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
  200. })
  201. it('drops the projection store with the removed session', async ({ mock, remote }) => {
  202. const manager = makeManager(mock, remote)
  203. remote.session.list.mockResolvedValue(ok({
  204. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  205. }))
  206. await manager.refreshList()
  207. manager.handleControlFrame({
  208. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
  209. })
  210. manager.handleSessionRemoved(sid('s1'))
  211. expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
  212. })
  213. })