projection-store.spec.ts 8.3 KB

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