projection-store.client.spec.ts 10.0 KB

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