projection-store.client.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /**
  2. * Projection value store (push model; session-projection subsystem page:
  3. * docs/subsystems/session-projection.md): tentative list prewarm versus
  4. * authoritative baselines and frames, capability absence as undefined,
  5. * generation truncation, and the Session/manager wiring (tail-page seeding,
  6. * control-stream projection routing pre- and post-instantiation, the list
  7. * rows' title projection).
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import type { SessionId } from '@deepseek-ai/dsh-api-remotes/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, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
  15. import { entries, plainTurn } from './event-script.client.ts'
  16. // Test-domain keys merged into the projection map (the Service Definition 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('Session projection value 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('prewarms only tentative rows and promotes an equal-seq authoritative frame', () => {
  40. const store = new ProjectionValueStore()
  41. store.prewarm('test/marks', { marks: ['hint-5'] }, 5)
  42. store.prewarm('test/marks', { marks: ['stale-hint'] }, 3)
  43. expect(store.get('test/marks')).toEqual({ marks: ['hint-5'] })
  44. store.prewarm('test/marks', { marks: ['hint-9'] }, 9)
  45. store.apply('test/marks', { marks: ['frame-9'] }, 9)
  46. store.prewarm('test/marks', { marks: ['later-hint'] }, 20)
  47. expect(store.get('test/marks')).toEqual({ marks: ['frame-9'] })
  48. })
  49. it('a complete baseline replaces hints but preserves newer authoritative frames', () => {
  50. const store = new ProjectionValueStore()
  51. store.prewarm('test/marks', { marks: ['hint-20'] }, 20)
  52. store.prewarm('hint-only', 'stale', 20)
  53. store.apply('frame-only', 'frame-20', 20)
  54. store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
  55. expect(store.get('test/marks')).toEqual({ marks: ['baseline-10'] })
  56. expect(store.get('hint-only')).toBeUndefined()
  57. expect(store.get('frame-only')).toBe('frame-20')
  58. store.apply('test/marks', { marks: ['frame-20'] }, 20)
  59. store.seed({ asOfSeq: 15, values: { 'test/marks': { marks: ['baseline-15'] } } })
  60. expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
  61. store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
  62. expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
  63. store.seed({ asOfSeq: 40, values: {} })
  64. expect(store.get('test/marks')).toBeUndefined()
  65. })
  66. it('truncate drops rows past the durable baseline and keeps the rest', () => {
  67. const store = new ProjectionValueStore()
  68. store.apply('test/marks', { marks: ['durable'] }, 5)
  69. store.apply('other', 'phantom', 50)
  70. store.truncate(10)
  71. expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
  72. expect(store.get('other')).toBeUndefined()
  73. })
  74. it('notifies the key face on change (batched) and not on dropped applications', async () => {
  75. const store = new ProjectionValueStore()
  76. let keyTicks = 0
  77. let anyTicks = 0
  78. store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
  79. store.subscribeAny(() => { anyTicks += 1 })
  80. store.apply('test/marks', { marks: ['a'] }, 5)
  81. await Promise.resolve()
  82. expect(keyTicks).toBe(1)
  83. expect(anyTicks).toBe(1)
  84. store.apply('test/marks', { marks: ['replay'] }, 3)
  85. await Promise.resolve()
  86. expect(keyTicks).toBe(1)
  87. expect(anyTicks).toBe(1)
  88. })
  89. it('faces are identity-stable per key (the React binding cache premise)', () => {
  90. const store = new ProjectionValueStore()
  91. expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
  92. })
  93. it('publishes one reference-stable whole-value snapshot until a row changes', () => {
  94. const store = new ProjectionValueStore()
  95. const empty = store.values()
  96. expect(store.values()).toBe(empty)
  97. store.apply('test/marks', { marks: ['a'] }, 1)
  98. const populated = store.values()
  99. expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
  100. expect(populated).not.toBe(empty)
  101. expect(store.values()).toBe(populated)
  102. })
  103. })
  104. describe('Session tail-page seeding', () => {
  105. it('retains a prewarmed projection when opening the Session fails', async () => {
  106. const api = new FakeApiClient()
  107. const projections = new ProjectionValueStore()
  108. projections.prewarm('test/marks', { marks: ['cached'] }, 5)
  109. const session = new Session(SID, api, fakeRemote(api), { projections })
  110. api.onHistory = () => Promise.resolve(err({
  111. code: 'session-not-found',
  112. message: 'gone',
  113. details: { sessionId: SID },
  114. }))
  115. await session.open()
  116. expect(session.getSnapshot().openState).toBe('error')
  117. expect(session.projections.get('test/marks')).toEqual({ marks: ['cached'] })
  118. })
  119. it('seeds the store from a history response carrying a projections block', async () => {
  120. const api = new FakeApiClient()
  121. const session = new Session(SID, api, fakeRemote(api))
  122. api.onHistory = () => Promise.resolve(ok({
  123. records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
  124. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
  125. } as never))
  126. await session.open()
  127. expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
  128. })
  129. it('replaces a higher-seq prewarm hint after a successful opening', async () => {
  130. const api = new FakeApiClient()
  131. const projections = new ProjectionValueStore()
  132. projections.prewarm('test/marks', { marks: ['stale-list'] }, 9)
  133. const session = new Session(SID, api, fakeRemote(api), { projections })
  134. api.onHistory = () => Promise.resolve(ok({
  135. records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
  136. projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['authoritative'] } } },
  137. } as never))
  138. await session.open()
  139. expect(session.getSnapshot().openState).toBe('open')
  140. expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] })
  141. })
  142. it('preserves an authoritative frame below a higher hint while opening waits for its older baseline', async () => {
  143. const api = new FakeApiClient()
  144. const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
  145. api.onHistory = () => history.promise
  146. const projections = new ProjectionValueStore()
  147. projections.prewarm('test/marks', { marks: ['hint-9'] }, 9)
  148. const session = new Session(SID, api, fakeRemote(api), { projections })
  149. const opening = session.open()
  150. session.projections.apply('test/marks', { marks: ['live-3'] }, 3)
  151. history.resolve(ok({
  152. records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
  153. projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } },
  154. } as never))
  155. await opening
  156. expect(session.projections.get('test/marks')).toEqual({ marks: ['live-3'] })
  157. })
  158. it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
  159. const api = new FakeApiClient()
  160. const session = new Session(SID, api, fakeRemote(api))
  161. api.onHistory = () => Promise.resolve(ok({
  162. records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
  163. projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
  164. } as never))
  165. await session.open()
  166. session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
  167. await session.resync()
  168. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
  169. })
  170. it('treats a blockless response as no reset: pushed values survive', async () => {
  171. const api = new FakeApiClient()
  172. const session = new Session(SID, api, fakeRemote(api))
  173. api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
  174. await session.open()
  175. session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
  176. await session.resync()
  177. expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
  178. })
  179. })
  180. describe('manager frame routing', () => {
  181. const sid = (s: string): SessionId => s as SessionId
  182. it('lands projection frames before instantiation and the Session adopts the same store', async () => {
  183. const api = new FakeApiClient()
  184. const manager = new SessionManager(api, fakeRemote(api))
  185. manager.handleControlFrame({
  186. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
  187. })
  188. const session = manager.get(sid('s1'))
  189. expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
  190. // Frames after instantiation land in the same store.
  191. manager.handleControlFrame({
  192. type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
  193. })
  194. expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
  195. })
  196. it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => {
  197. const api = new FakeApiClient()
  198. const manager = new SessionManager(api, fakeRemote(api))
  199. api.onList = () => Promise.resolve(ok({
  200. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  201. }) as never)
  202. await manager.refreshList()
  203. manager.handleControlFrame({
  204. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
  205. })
  206. await Promise.resolve()
  207. expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
  208. // The durable baseline says the host only knows up to seq 2: the row rode
  209. // lost state and must drop (the un-flushed title precedent).
  210. manager.handleControlFrame({
  211. type: 'baseline',
  212. value: {
  213. queues: {}, jobs: {},
  214. projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
  215. },
  216. })
  217. await Promise.resolve()
  218. expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
  219. })
  220. it('projects every retained value into list rows with stable snapshot identity', async () => {
  221. const api = new FakeApiClient()
  222. const manager = new SessionManager(api, fakeRemote(api))
  223. api.onList = () => Promise.resolve(ok({
  224. items: [{
  225. sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
  226. projections: {
  227. asOfSeq: 2,
  228. values: { 'test/marks': { marks: ['baseline'] } },
  229. },
  230. }],
  231. }) as never)
  232. await manager.refreshList()
  233. const baseline = manager.getListSnapshot().items[0]?.projectionValues
  234. expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
  235. expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
  236. manager.handleControlFrame({
  237. type: 'projection', sessionId: sid('s1'), key: 'test/marks',
  238. value: { marks: ['live'] }, seq: 3,
  239. })
  240. await Promise.resolve()
  241. expect(manager.getListSnapshot().items[0]?.projectionValues)
  242. .toEqual({ 'test/marks': { marks: ['live'] } })
  243. expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
  244. })
  245. it('drops the projection store with the removed session', async () => {
  246. const api = new FakeApiClient()
  247. const manager = new SessionManager(api, fakeRemote(api))
  248. api.onList = () => Promise.resolve(ok({
  249. items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
  250. }) as never)
  251. await manager.refreshList()
  252. manager.handleControlFrame({
  253. type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
  254. })
  255. manager.handleSessionRemoved(sid('s1'))
  256. expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
  257. })
  258. })