@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -1,6 +1,14 @@
1
1
  import {type SanityClient} from '@sanity/client'
2
- import {delay, firstValueFrom, of, Subject} from 'rxjs'
3
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
+ import {
3
+ BehaviorSubject,
4
+ delay,
5
+ firstValueFrom,
6
+ type Observable,
7
+ of,
8
+ Subject,
9
+ throwError,
10
+ } from 'rxjs'
11
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
4
12
 
5
13
  import {getTokenState} from '../auth/authStore'
6
14
  import {getClient} from '../client/clientStore'
@@ -8,8 +16,8 @@ import {createSanityInstance, type SanityInstance} from '../store/createSanityIn
8
16
  import {type SanityUser} from '../users/types'
9
17
  import {getUserState} from '../users/usersStore'
10
18
  import {createBifurTransport} from './bifurTransport'
11
- import {getPresence} from './presenceStore'
12
- import {type PresenceLocation, type TransportEvent} from './types'
19
+ import {getDocumentPresence, getPresence, reportPresence} from './presenceStore'
20
+ import {type PresenceLocation, type TransportEvent, type TransportMessage} from './types'
13
21
 
14
22
  vi.mock('../auth/authStore')
15
23
  vi.mock('../client/clientStore')
@@ -21,8 +29,10 @@ describe('presenceStore', () => {
21
29
  let mockClient: SanityClient
22
30
  let mockTokenState: Subject<string | null>
23
31
  let mockIncomingEvents: Subject<TransportEvent>
24
- let mockDispatchMessage: ReturnType<typeof vi.fn>
25
- let mockGetUserState: ReturnType<typeof vi.fn>
32
+ let mockDispatchMessage: Mock<(message: TransportMessage) => Observable<void>>
33
+ let mockConnections: BehaviorSubject<number>
34
+ let mockUnload: Subject<void>
35
+ let mockGetUserState: Mock<typeof getUserState>
26
36
 
27
37
  const mockUser: SanityUser = {
28
38
  sanityUserId: 'u123',
@@ -48,6 +58,9 @@ describe('presenceStore', () => {
48
58
 
49
59
  mockClient = {
50
60
  withConfig: vi.fn().mockReturnThis(),
61
+ observable: {
62
+ request: vi.fn(() => of({organizationId: 'test-org-id'})),
63
+ },
51
64
  } as unknown as SanityClient
52
65
 
53
66
  mockTokenState = new Subject<string | null>()
@@ -61,15 +74,22 @@ describe('presenceStore', () => {
61
74
  subscribe: vi.fn(),
62
75
  })
63
76
 
77
+ // A live connection on subscribe, mirroring `bifur.heartbeats`, which emits
78
+ // as soon as the socket is open and authorized.
79
+ mockConnections = new BehaviorSubject<number>(1)
80
+ mockUnload = new Subject<void>()
81
+
64
82
  vi.mocked(createBifurTransport).mockReturnValue([
65
83
  mockIncomingEvents.asObservable(),
66
84
  mockDispatchMessage,
85
+ mockConnections.asObservable(),
86
+ mockUnload.asObservable(),
67
87
  ])
68
88
 
69
89
  mockGetUserState = vi.fn(() => of(mockUser))
70
90
  vi.mocked(getUserState).mockImplementation(mockGetUserState)
71
91
 
72
- instance = createSanityInstance()
92
+ instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
73
93
  })
74
94
 
75
95
  afterEach(() => {
@@ -77,9 +97,8 @@ describe('presenceStore', () => {
77
97
  })
78
98
 
79
99
  describe('getPresence', () => {
80
- const key = {resource: {projectId: 'test-project', dataset: 'test-dataset'}}
81
100
  it('creates bifur transport with correct parameters', () => {
82
- getPresence(instance, key)
101
+ getPresence(instance)
83
102
 
84
103
  expect(createBifurTransport).toHaveBeenCalledWith({
85
104
  client: mockClient,
@@ -89,18 +108,18 @@ describe('presenceStore', () => {
89
108
  })
90
109
 
91
110
  it('sends rollCall message on initialization', () => {
92
- getPresence(instance, key)
111
+ getPresence(instance)
93
112
 
94
113
  expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
95
114
  })
96
115
 
97
116
  it('returns empty array when no users present', () => {
98
- const source = getPresence(instance, key)
117
+ const source = getPresence(instance)
99
118
  expect(source.getCurrent()).toEqual([])
100
119
  })
101
120
 
102
121
  it('handles state events from other users', async () => {
103
- const source = getPresence(instance, key)
122
+ const source = getPresence(instance)
104
123
 
105
124
  // Subscribe to initialize the store
106
125
  const unsubscribe = source.subscribe(() => {})
@@ -137,7 +156,7 @@ describe('presenceStore', () => {
137
156
  })
138
157
 
139
158
  it('ignores events from own session', async () => {
140
- const source = getPresence(instance, key)
159
+ const source = getPresence(instance)
141
160
  const unsubscribe = source.subscribe(() => {})
142
161
 
143
162
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -159,7 +178,7 @@ describe('presenceStore', () => {
159
178
  })
160
179
 
161
180
  it('handles disconnect events', async () => {
162
- const source = getPresence(instance, key)
181
+ const source = getPresence(instance)
163
182
  const unsubscribe = source.subscribe(() => {})
164
183
 
165
184
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -191,7 +210,7 @@ describe('presenceStore', () => {
191
210
  })
192
211
 
193
212
  it('fetches user data for present users', async () => {
194
- const source = getPresence(instance, key)
213
+ const source = getPresence(instance)
195
214
  const unsubscribe = source.subscribe(() => {})
196
215
 
197
216
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -223,7 +242,7 @@ describe('presenceStore', () => {
223
242
  })
224
243
 
225
244
  it('handles presence events correctly', async () => {
226
- const source = getPresence(instance, key)
245
+ const source = getPresence(instance)
227
246
  const unsubscribe = source.subscribe(() => {})
228
247
 
229
248
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -250,23 +269,895 @@ describe('presenceStore', () => {
250
269
 
251
270
  expect(() => {
252
271
  getPresence(instance, {resource: mediaLibraryResource})
253
- }).toThrow('Presence is not supported for media library resources')
272
+ }).toThrow('Presence is not supported for media library resources.')
254
273
  })
255
274
 
256
- it('should throw an error when initialized with a canvas resource', () => {
257
- const canvasResource = {canvasId: 'canvas123'}
275
+ it('should work with a dataset resource', () => {
276
+ const datasetResource = {projectId: 'test-project', dataset: 'test-dataset'}
258
277
 
259
278
  expect(() => {
260
- getPresence(instance, {resource: canvasResource})
261
- }).toThrow('Presence is not supported for canvas resources')
279
+ getPresence(instance, {resource: datasetResource})
280
+ }).not.toThrow()
262
281
  })
263
282
 
264
- it('should work with a dataset resource', () => {
265
- const datasetResource = {projectId: 'test-project', dataset: 'test-dataset'}
283
+ it('should work with a canvas resource', () => {
284
+ const canvasResource = {canvasId: 'canvas123'}
266
285
 
267
286
  expect(() => {
268
- getPresence(instance, {resource: datasetResource})
287
+ getPresence(instance, {resource: canvasResource})
269
288
  }).not.toThrow()
270
289
  })
290
+
291
+ it('creates a project-hostname client for dataset resources', () => {
292
+ getPresence(instance, {resource: {projectId: 'my-project', dataset: 'my-dataset'}})
293
+
294
+ expect(getClient).toHaveBeenCalledWith(instance, {
295
+ apiVersion: '2026-03-30',
296
+ projectId: 'my-project',
297
+ dataset: 'my-dataset',
298
+ useProjectHostname: true,
299
+ })
300
+ })
301
+
302
+ it('creates a resource client for canvas resources', () => {
303
+ const canvasResource = {canvasId: 'canvas123'}
304
+ getPresence(instance, {resource: canvasResource})
305
+
306
+ expect(getClient).toHaveBeenCalledWith(instance, {
307
+ apiVersion: '2026-03-30',
308
+ resource: canvasResource,
309
+ })
310
+ })
311
+
312
+ it('fetches organizationId from canvas endpoint for canvas resources', () => {
313
+ const canvasResource = {canvasId: 'canvas123'}
314
+ getPresence(instance, {resource: canvasResource})
315
+
316
+ expect(mockClient.observable.request).toHaveBeenCalledWith({
317
+ uri: '/canvases/canvas123',
318
+ tag: 'canvases.get',
319
+ })
320
+ })
321
+
322
+ it('does not fetch organizationId for dataset resources', () => {
323
+ getPresence(instance, {resource: {projectId: 'my-project', dataset: 'my-dataset'}})
324
+
325
+ expect(mockClient.observable.request).not.toHaveBeenCalled()
326
+ })
327
+
328
+ it('fetches user data for canvas users', async () => {
329
+ const source = getPresence(instance, {resource: {canvasId: 'canvas123'}})
330
+ const unsubscribe = source.subscribe(() => {})
331
+
332
+ await firstValueFrom(of(null).pipe(delay(10)))
333
+
334
+ mockIncomingEvents.next({
335
+ type: 'state',
336
+ userId: 'user-1',
337
+ sessionId: 'other-session',
338
+ timestamp: '2023-01-01T12:00:00Z',
339
+ locations: [
340
+ {
341
+ type: 'document',
342
+ documentId: 'doc-1',
343
+ path: ['title'],
344
+ lastActiveAt: '2023-01-01T12:00:00Z',
345
+ },
346
+ ],
347
+ })
348
+
349
+ await firstValueFrom(of(null).pipe(delay(50)))
350
+
351
+ expect(getUserState).toHaveBeenCalledWith(instance, {
352
+ userId: 'user-1',
353
+ resourceType: 'organization',
354
+ organizationId: 'test-org-id',
355
+ })
356
+
357
+ unsubscribe()
358
+ })
359
+
360
+ it('resolves no users when the canvas organization lookup fails', async () => {
361
+ // Regression guard: this used to be swallowed with `catchError(() => EMPTY)`
362
+ // and consumed with `first()`, so a failed request meant the user stream
363
+ // never emitted and every participant stayed "Unknown user" forever.
364
+ mockClient = {
365
+ withConfig: vi.fn().mockReturnThis(),
366
+ observable: {
367
+ request: vi.fn(() => throwError(() => new Error('canvas lookup failed'))),
368
+ },
369
+ } as unknown as SanityClient
370
+ vi.mocked(getClient).mockReturnValue(mockClient)
371
+
372
+ const source = getPresence(instance, {resource: {canvasId: 'canvas123'}})
373
+ const unsubscribe = source.subscribe(() => {})
374
+
375
+ await firstValueFrom(of(null).pipe(delay(10)))
376
+
377
+ mockIncomingEvents.next({
378
+ type: 'state',
379
+ userId: 'user-1',
380
+ sessionId: 'other-session',
381
+ timestamp: '2023-01-01T12:00:00Z',
382
+ locations: [],
383
+ })
384
+
385
+ await firstValueFrom(of(null).pipe(delay(50)))
386
+
387
+ // The presence itself still surfaces, with a placeholder user.
388
+ const presence = source.getCurrent()
389
+ expect(presence).toHaveLength(1)
390
+ expect(presence[0].user.profile.displayName).toBe('Unknown user')
391
+ // No doomed request is made without an organization to scope it to.
392
+ expect(getUserState).not.toHaveBeenCalled()
393
+
394
+ unsubscribe()
395
+ })
396
+
397
+ it('gives an unresolved user a structurally valid profile', async () => {
398
+ // The placeholder used to be cast through `as unknown as SanityUser` with
399
+ // no `profile`, so reading `user.profile.displayName` threw on first render.
400
+ mockGetUserState = vi.fn(() => of(undefined))
401
+ vi.mocked(getUserState).mockImplementation(mockGetUserState)
402
+
403
+ const source = getPresence(instance)
404
+ const unsubscribe = source.subscribe(() => {})
405
+
406
+ await firstValueFrom(of(null).pipe(delay(10)))
407
+
408
+ mockIncomingEvents.next({
409
+ type: 'state',
410
+ userId: 'user-1',
411
+ sessionId: 'other-session',
412
+ timestamp: '2023-01-01T12:00:00Z',
413
+ locations: [],
414
+ })
415
+
416
+ await firstValueFrom(of(null).pipe(delay(20)))
417
+
418
+ const [presence] = source.getCurrent()
419
+ expect(presence.user.profile.displayName).toBe('Unknown user')
420
+ expect(presence.user.profile.id).toBe('user-1')
421
+ expect(presence.user.sanityUserId).toBe('user-1')
422
+ expect(presence.user.memberships).toEqual([])
423
+
424
+ unsubscribe()
425
+ })
426
+ })
427
+
428
+ describe('getDocumentPresence', () => {
429
+ /** Puts a peer in a document at the given paths. */
430
+ const peerAt = async (
431
+ sessionId: string,
432
+ locations: {documentId: string; path?: unknown[]; selection?: unknown}[],
433
+ ) => {
434
+ mockIncomingEvents.next({
435
+ type: 'state',
436
+ userId: 'user-1',
437
+ sessionId,
438
+ timestamp: '2026-07-30T12:00:00Z',
439
+ locations: locations.map((l) => ({
440
+ type: 'document',
441
+ documentId: l.documentId,
442
+ path: (l.path ?? []) as string[],
443
+ lastActiveAt: '2026-07-30T12:00:00Z',
444
+ ...(l.selection === undefined ? {} : {selection: l.selection}),
445
+ })) as never,
446
+ })
447
+ await firstValueFrom(of(null).pipe(delay(20)))
448
+ }
449
+
450
+ it('flattens one entry per participant per location', async () => {
451
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
452
+ const unsubscribe = source.subscribe(() => {})
453
+ await firstValueFrom(of(null).pipe(delay(10)))
454
+
455
+ await peerAt('peer-a', [
456
+ {documentId: 'movie-1', path: ['title']},
457
+ {documentId: 'movie-1', path: ['body']},
458
+ ])
459
+
460
+ const presence = source.getCurrent()
461
+ expect(presence).toHaveLength(2)
462
+ expect(presence.map((p) => p.path)).toEqual([['title'], ['body']])
463
+ expect(presence.every((p) => p.sessionId === 'peer-a')).toBe(true)
464
+
465
+ unsubscribe()
466
+ })
467
+
468
+ it('treats a draft, its published version, and a release version as one document', async () => {
469
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
470
+ const unsubscribe = source.subscribe(() => {})
471
+ await firstValueFrom(of(null).pipe(delay(10)))
472
+
473
+ await peerAt('peer-a', [{documentId: 'drafts.movie-1'}])
474
+ await peerAt('peer-b', [{documentId: 'versions.r1.movie-1'}])
475
+ await peerAt('peer-c', [{documentId: 'movie-1'}])
476
+
477
+ // What a document list wants: someone is in this document, wherever exactly.
478
+ expect(source.getCurrent()).toHaveLength(3)
479
+
480
+ unsubscribe()
481
+ })
482
+
483
+ it('compares ids exactly when excludeVersions is set', async () => {
484
+ const source = getDocumentPresence(instance, {
485
+ documentId: 'drafts.movie-1',
486
+ excludeVersions: true,
487
+ })
488
+ const unsubscribe = source.subscribe(() => {})
489
+ await firstValueFrom(of(null).pipe(delay(10)))
490
+
491
+ await peerAt('peer-a', [{documentId: 'drafts.movie-1'}])
492
+ await peerAt('peer-b', [{documentId: 'versions.r1.movie-1'}])
493
+
494
+ // What a field indicator wants: a release version must not bleed into the
495
+ // draft the user is actually looking at.
496
+ const presence = source.getCurrent()
497
+ expect(presence).toHaveLength(1)
498
+ expect(presence[0].documentId).toBe('drafts.movie-1')
499
+
500
+ unsubscribe()
501
+ })
502
+
503
+ it('matches a peer in the draft when queried with a published id and excludeVersions', async () => {
504
+ // The field-indicator case, and the one that only works if the read side
505
+ // resolves the perspective exactly as the write side does. `excludeVersions`
506
+ // turns off published-id normalization, so an unresolved query for `movie-1`
507
+ // would never match a peer reporting `drafts.movie-1`.
508
+ const source = getDocumentPresence(instance, {
509
+ documentId: 'movie-1',
510
+ excludeVersions: true,
511
+ })
512
+ const unsubscribe = source.subscribe(() => {})
513
+ await firstValueFrom(of(null).pipe(delay(10)))
514
+
515
+ await peerAt('peer-a', [{documentId: 'drafts.movie-1', path: ['title']}])
516
+
517
+ const presence = source.getCurrent()
518
+ expect(presence).toHaveLength(1)
519
+ expect(presence[0].documentId).toBe('drafts.movie-1')
520
+
521
+ unsubscribe()
522
+ })
523
+
524
+ it('narrows to a field subtree, including the field itself', async () => {
525
+ const source = getDocumentPresence(instance, {documentId: 'movie-1', path: ['body']})
526
+ const unsubscribe = source.subscribe(() => {})
527
+ await firstValueFrom(of(null).pipe(delay(10)))
528
+
529
+ await peerAt('peer-a', [
530
+ {documentId: 'movie-1', path: ['title']},
531
+ {documentId: 'movie-1', path: ['body']},
532
+ {documentId: 'movie-1', path: ['body', {_key: 'b1'}, 'children']},
533
+ ])
534
+
535
+ const presence = source.getCurrent()
536
+ expect(presence).toHaveLength(2)
537
+ expect(presence.map((p) => p.path)).toEqual([['body'], ['body', {_key: 'b1'}, 'children']])
538
+
539
+ unsubscribe()
540
+ })
541
+
542
+ it('matches keyed path segments by key, not by identity', async () => {
543
+ const source = getDocumentPresence(instance, {
544
+ documentId: 'movie-1',
545
+ // A different object with the same key, which is what arrives over the wire.
546
+ path: ['cast', {_key: 'member-2'}],
547
+ })
548
+ const unsubscribe = source.subscribe(() => {})
549
+ await firstValueFrom(of(null).pipe(delay(10)))
550
+
551
+ await peerAt('peer-a', [
552
+ {documentId: 'movie-1', path: ['cast', {_key: 'member-1'}, 'name']},
553
+ {documentId: 'movie-1', path: ['cast', {_key: 'member-2'}, 'name']},
554
+ ])
555
+
556
+ const presence = source.getCurrent()
557
+ expect(presence).toHaveLength(1)
558
+ expect(presence[0].path).toEqual(['cast', {_key: 'member-2'}, 'name'])
559
+
560
+ unsubscribe()
561
+ })
562
+
563
+ it('preserves a Portable Text selection', async () => {
564
+ const selection = {
565
+ anchor: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 2},
566
+ focus: {path: [{_key: 'b1'}, 'children', {_key: 's1'}], offset: 9},
567
+ backward: false,
568
+ }
569
+
570
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
571
+ const unsubscribe = source.subscribe(() => {})
572
+ await firstValueFrom(of(null).pipe(delay(10)))
573
+
574
+ await peerAt('peer-a', [{documentId: 'movie-1', path: ['body'], selection}])
575
+
576
+ expect(source.getCurrent()[0].selection).toEqual(selection)
577
+
578
+ unsubscribe()
579
+ })
580
+
581
+ it('omits selection when the participant did not report one', async () => {
582
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
583
+ const unsubscribe = source.subscribe(() => {})
584
+ await firstValueFrom(of(null).pipe(delay(10)))
585
+
586
+ await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
587
+
588
+ expect('selection' in source.getCurrent()[0]).toBe(false)
589
+
590
+ unsubscribe()
591
+ })
592
+
593
+ it('returns a referentially stable snapshot', async () => {
594
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
595
+ const unsubscribe = source.subscribe(() => {})
596
+ await firstValueFrom(of(null).pipe(delay(10)))
597
+ await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
598
+
599
+ // React calls `getCurrent` as its `useSyncExternalStore` snapshot on every
600
+ // render. Returning a fresh array each call sends it into an infinite
601
+ // render loop, so this is load-bearing rather than a micro-optimisation.
602
+ expect(source.getCurrent()).toBe(source.getCurrent())
603
+
604
+ unsubscribe()
605
+ })
606
+
607
+ it('keeps separate snapshots per query, so callers do not evict each other', async () => {
608
+ const movie = getDocumentPresence(instance, {documentId: 'movie-1'})
609
+ const other = getDocumentPresence(instance, {documentId: 'movie-2'})
610
+ const titleOnly = getDocumentPresence(instance, {documentId: 'movie-1', path: ['title']})
611
+ const subs = [movie, other, titleOnly].map((s) => s.subscribe(() => {}))
612
+ await firstValueFrom(of(null).pipe(delay(10)))
613
+
614
+ await peerAt('peer-a', [
615
+ {documentId: 'movie-1', path: ['title']},
616
+ {documentId: 'movie-1', path: ['body']},
617
+ ])
618
+
619
+ // Interleaved reads, which is what concurrent components do.
620
+ const movieFirst = movie.getCurrent()
621
+ const titleFirst = titleOnly.getCurrent()
622
+ expect(movie.getCurrent()).toBe(movieFirst)
623
+ expect(titleOnly.getCurrent()).toBe(titleFirst)
624
+
625
+ expect(movieFirst).toHaveLength(2)
626
+ expect(titleFirst).toHaveLength(1)
627
+ expect(other.getCurrent()).toEqual([])
628
+
629
+ subs.forEach((unsubscribe) => unsubscribe())
630
+ })
631
+
632
+ it('resolves display names without anything else subscribing first', async () => {
633
+ // Regression guard: user resolution used to hang off `getPresence`'s
634
+ // `onSubscribe`, so reading presence any other way showed only
635
+ // "Unknown user". It belongs to the store, not to one state source.
636
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
637
+ const unsubscribe = source.subscribe(() => {})
638
+ await firstValueFrom(of(null).pipe(delay(10)))
639
+
640
+ await peerAt('peer-a', [{documentId: 'movie-1', path: ['title']}])
641
+ await firstValueFrom(of(null).pipe(delay(50)))
642
+
643
+ expect(getUserState).toHaveBeenCalledWith(instance, {
644
+ userId: 'user-1',
645
+ resourceType: 'project',
646
+ projectId: 'test-project',
647
+ })
648
+ expect(source.getCurrent()[0].user.profile.displayName).toBe('Test User')
649
+
650
+ unsubscribe()
651
+ })
652
+
653
+ it('ignores other documents entirely', async () => {
654
+ const source = getDocumentPresence(instance, {documentId: 'movie-1'})
655
+ const unsubscribe = source.subscribe(() => {})
656
+ await firstValueFrom(of(null).pipe(delay(10)))
657
+
658
+ await peerAt('peer-a', [{documentId: 'movie-2', path: ['title']}])
659
+
660
+ expect(source.getCurrent()).toEqual([])
661
+
662
+ unsubscribe()
663
+ })
664
+ })
665
+
666
+ describe('connection lifecycle', () => {
667
+ it('clears accumulated presence and re-rollCalls when the socket reconnects', async () => {
668
+ const source = getPresence(instance)
669
+ const unsubscribe = source.subscribe(() => {})
670
+
671
+ await firstValueFrom(of(null).pipe(delay(10)))
672
+
673
+ mockIncomingEvents.next({
674
+ type: 'state',
675
+ userId: 'user-1',
676
+ sessionId: 'other-session',
677
+ timestamp: '2023-01-01T12:00:00Z',
678
+ locations: [],
679
+ })
680
+
681
+ await firstValueFrom(of(null).pipe(delay(20)))
682
+ expect(source.getCurrent()).toHaveLength(1)
683
+ expect(mockDispatchMessage).toHaveBeenCalledTimes(1)
684
+
685
+ // A new generation means a freshly live socket. Anything accumulated is
686
+ // suspect, because peers may have come or gone while we were away.
687
+ mockConnections.next(2)
688
+
689
+ await firstValueFrom(of(null).pipe(delay(20)))
690
+ expect(source.getCurrent()).toHaveLength(0)
691
+ expect(mockDispatchMessage).toHaveBeenNthCalledWith(2, {type: 'rollCall'})
692
+
693
+ // Wiping the map is only safe because peers answer that roll call and
694
+ // repopulate it. If the inbound listener did not survive the reconnect,
695
+ // this would stay empty for the life of the page.
696
+ mockIncomingEvents.next({
697
+ type: 'state',
698
+ userId: 'user-2',
699
+ sessionId: 'a-peer-answering',
700
+ timestamp: '2023-01-01T12:00:05Z',
701
+ locations: [],
702
+ })
703
+
704
+ await firstValueFrom(of(null).pipe(delay(20)))
705
+ expect(source.getCurrent()).toHaveLength(1)
706
+ expect(source.getCurrent()[0].sessionId).toBe('a-peer-answering')
707
+
708
+ unsubscribe()
709
+ })
710
+
711
+ it('still announces a disconnect if an earlier one failed', async () => {
712
+ const source = getPresence(instance)
713
+ const unsubscribe = source.subscribe(() => {})
714
+
715
+ await firstValueFrom(of(null).pipe(delay(10)))
716
+ mockDispatchMessage.mockClear()
717
+
718
+ // `pagehide` can fire more than once: a page restored from the back/forward
719
+ // cache will fire it again. A failure on the first must not kill the stream.
720
+ mockDispatchMessage.mockReturnValueOnce(throwError(() => new Error('socket closed')))
721
+ mockUnload.next()
722
+ mockUnload.next()
723
+
724
+ expect(mockDispatchMessage.mock.calls.filter(([m]) => m.type === 'disconnect')).toHaveLength(
725
+ 2,
726
+ )
727
+
728
+ unsubscribe()
729
+ })
730
+
731
+ it('announces a disconnect when the page unloads', async () => {
732
+ const source = getPresence(instance)
733
+ const unsubscribe = source.subscribe(() => {})
734
+
735
+ await firstValueFrom(of(null).pipe(delay(10)))
736
+ mockDispatchMessage.mockClear()
737
+
738
+ mockUnload.next()
739
+
740
+ expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'disconnect'})
741
+
742
+ unsubscribe()
743
+ })
744
+ })
745
+
746
+ describe('stale session expiry', () => {
747
+ it('drops a session that stops announcing', async () => {
748
+ vi.useFakeTimers()
749
+ try {
750
+ const source = getPresence(instance)
751
+ const unsubscribe = source.subscribe(() => {})
752
+
753
+ await vi.advanceTimersByTimeAsync(10)
754
+
755
+ mockIncomingEvents.next({
756
+ type: 'state',
757
+ userId: 'user-1',
758
+ sessionId: 'other-session',
759
+ timestamp: new Date().toISOString(),
760
+ locations: [],
761
+ })
762
+
763
+ await vi.advanceTimersByTimeAsync(20)
764
+ expect(source.getCurrent()).toHaveLength(1)
765
+
766
+ // Peers re-announce every 30s, so surviving a sweep at 60s proves the
767
+ // sweep is not just deleting everything it sees.
768
+ await vi.advanceTimersByTimeAsync(60_000)
769
+ expect(source.getCurrent()).toHaveLength(1)
770
+
771
+ // Past the 90s TTL with no further announcements, the peer is gone.
772
+ await vi.advanceTimersByTimeAsync(45_000)
773
+ expect(source.getCurrent()).toHaveLength(0)
774
+
775
+ unsubscribe()
776
+ } finally {
777
+ vi.useRealTimers()
778
+ }
779
+ })
780
+
781
+ it('keeps a session alive while it keeps announcing', async () => {
782
+ vi.useFakeTimers()
783
+ try {
784
+ const source = getPresence(instance)
785
+ const unsubscribe = source.subscribe(() => {})
786
+
787
+ await vi.advanceTimersByTimeAsync(10)
788
+
789
+ // Six announcements 30s apart, spanning well past the TTL.
790
+ for (let i = 0; i < 6; i++) {
791
+ mockIncomingEvents.next({
792
+ type: 'state',
793
+ userId: 'user-1',
794
+ sessionId: 'other-session',
795
+ timestamp: new Date().toISOString(),
796
+ locations: [],
797
+ })
798
+ await vi.advanceTimersByTimeAsync(30_000)
799
+ }
800
+
801
+ expect(source.getCurrent()).toHaveLength(1)
802
+
803
+ unsubscribe()
804
+ } finally {
805
+ vi.useRealTimers()
806
+ }
807
+ })
808
+ })
809
+
810
+ describe('reportPresence', () => {
811
+ /** Drains the audit window so a pending announcement is flushed. */
812
+ const flush = () => vi.advanceTimersByTimeAsync(250)
813
+
814
+ const stateCalls = () =>
815
+ mockDispatchMessage.mock.calls
816
+ .map(([message]) => message)
817
+ .filter(
818
+ (message): message is Extract<TransportMessage, {type: 'state'}> =>
819
+ message.type === 'state',
820
+ )
821
+
822
+ it('stays silent until the app reports, so reading never broadcasts', async () => {
823
+ vi.useFakeTimers()
824
+ try {
825
+ getPresence(instance)
826
+ await flush()
827
+ await vi.advanceTimersByTimeAsync(60_000)
828
+
829
+ // A rollCall goes out on connect, but never a state announcement.
830
+ expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
831
+ expect(stateCalls()).toEqual([])
832
+ } finally {
833
+ vi.useRealTimers()
834
+ }
835
+ })
836
+
837
+ it('resolves the reported id from the perspective', async () => {
838
+ vi.useFakeTimers()
839
+ try {
840
+ getPresence(instance)
841
+
842
+ // Default: the draft, which is what the Studio's form is on and therefore
843
+ // what its field indicators compare against.
844
+ reportPresence(instance, {locations: [{documentId: 'movie-1'}]})
845
+ await flush()
846
+ expect(stateCalls().at(-1)!.locations[0].documentId).toBe('drafts.movie-1')
847
+
848
+ reportPresence(instance, {locations: [{documentId: 'movie-1', perspective: 'published'}]})
849
+ await flush()
850
+ expect(stateCalls().at(-1)!.locations[0].documentId).toBe('movie-1')
851
+
852
+ reportPresence(instance, {
853
+ locations: [{documentId: 'movie-1', perspective: {releaseName: 'autumn'}}],
854
+ })
855
+ await flush()
856
+ expect(stateCalls().at(-1)!.locations[0].documentId).toBe('versions.autumn.movie-1')
857
+
858
+ reportPresence(instance, {locations: [{documentId: 'movie-1', liveEdit: true}]})
859
+ await flush()
860
+ expect(stateCalls().at(-1)!.locations[0].documentId).toBe('movie-1')
861
+ } finally {
862
+ vi.useRealTimers()
863
+ }
864
+ })
865
+
866
+ it('announces a document-level location', async () => {
867
+ vi.useFakeTimers()
868
+ vi.setSystemTime(new Date('2026-07-30T12:00:00.000Z'))
869
+ try {
870
+ getPresence(instance)
871
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
872
+ await flush()
873
+
874
+ expect(stateCalls()).toEqual([
875
+ {
876
+ type: 'state',
877
+ locations: [
878
+ {
879
+ type: 'document',
880
+ // Resolved to the draft, which is the default perspective.
881
+ documentId: 'drafts.doc-1',
882
+ path: [],
883
+ lastActiveAt: '2026-07-30T12:00:00.000Z',
884
+ },
885
+ ],
886
+ },
887
+ ])
888
+ } finally {
889
+ vi.useRealTimers()
890
+ }
891
+ })
892
+
893
+ it('carries keyed path segments and a Portable Text selection', async () => {
894
+ vi.useFakeTimers()
895
+ try {
896
+ getPresence(instance)
897
+ // The shape the Studio expects: keyed segments for array items and spans.
898
+ reportPresence(instance, {
899
+ locations: [
900
+ {
901
+ documentId: 'doc-1',
902
+ path: ['body', {_key: 'block-1'}, 'children', {_key: 'span-1'}, 'text'],
903
+ selection: {
904
+ anchor: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 3},
905
+ focus: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 7},
906
+ backward: false,
907
+ },
908
+ },
909
+ ],
910
+ })
911
+ await flush()
912
+
913
+ const [announced] = stateCalls()
914
+ expect(announced.locations[0].path).toEqual([
915
+ 'body',
916
+ {_key: 'block-1'},
917
+ 'children',
918
+ {_key: 'span-1'},
919
+ 'text',
920
+ ])
921
+ expect(announced.locations[0].selection).toEqual({
922
+ anchor: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 3},
923
+ focus: {path: [{_key: 'block-1'}, 'children', {_key: 'span-1'}], offset: 7},
924
+ backward: false,
925
+ })
926
+ } finally {
927
+ vi.useRealTimers()
928
+ }
929
+ })
930
+
931
+ it('omits selection entirely when none was reported', async () => {
932
+ vi.useFakeTimers()
933
+ try {
934
+ getPresence(instance)
935
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
936
+ await flush()
937
+
938
+ expect('selection' in stateCalls()[0].locations[0]).toBe(false)
939
+ } finally {
940
+ vi.useRealTimers()
941
+ }
942
+ })
943
+
944
+ it('announces an empty location list as present but nowhere', async () => {
945
+ vi.useFakeTimers()
946
+ try {
947
+ getPresence(instance)
948
+ reportPresence(instance, {locations: []})
949
+ await flush()
950
+
951
+ expect(stateCalls()).toEqual([{type: 'state', locations: []}])
952
+ } finally {
953
+ vi.useRealTimers()
954
+ }
955
+ })
956
+
957
+ it('collapses reports spread across the audit window into one announcement', async () => {
958
+ vi.useFakeTimers()
959
+ try {
960
+ getPresence(instance)
961
+
962
+ // Deliberately spaced out. Reports made in the same tick are collapsed by
963
+ // `switchMap` alone, so stepping the clock between them is what actually
964
+ // exercises the audit window - which is the case that matters, since a
965
+ // user typing produces focus changes tens of milliseconds apart.
966
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['a']}]})
967
+ await vi.advanceTimersByTimeAsync(50)
968
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['b']}]})
969
+ await vi.advanceTimersByTimeAsync(50)
970
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['c']}]})
971
+ await flush()
972
+
973
+ const announced = stateCalls()
974
+ expect(announced).toHaveLength(1)
975
+ expect(announced[0].locations[0].path).toEqual(['c'])
976
+ } finally {
977
+ vi.useRealTimers()
978
+ }
979
+ })
980
+
981
+ it('does not re-announce when only lastActiveAt would differ', async () => {
982
+ vi.useFakeTimers()
983
+ try {
984
+ getPresence(instance)
985
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
986
+ await flush()
987
+ expect(stateCalls()).toHaveLength(1)
988
+
989
+ // Same place, reported again. `lastActiveAt` is regenerated, but nothing
990
+ // moved, so this must not restart the heartbeat.
991
+ await vi.advanceTimersByTimeAsync(5_000)
992
+ reportPresence(instance, {locations: [{documentId: 'doc-1', path: ['title']}]})
993
+ await flush()
994
+
995
+ expect(stateCalls()).toHaveLength(1)
996
+ } finally {
997
+ vi.useRealTimers()
998
+ }
999
+ })
1000
+
1001
+ it('re-announces every 30s while idle, so peers do not expire it', async () => {
1002
+ vi.useFakeTimers()
1003
+ try {
1004
+ getPresence(instance)
1005
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1006
+ await flush()
1007
+ expect(stateCalls()).toHaveLength(1)
1008
+
1009
+ await vi.advanceTimersByTimeAsync(30_000)
1010
+ expect(stateCalls()).toHaveLength(2)
1011
+
1012
+ await vi.advanceTimersByTimeAsync(30_000)
1013
+ expect(stateCalls()).toHaveLength(3)
1014
+ } finally {
1015
+ vi.useRealTimers()
1016
+ }
1017
+ })
1018
+
1019
+ it("answers another client's roll call", async () => {
1020
+ vi.useFakeTimers()
1021
+ try {
1022
+ getPresence(instance)
1023
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1024
+ await flush()
1025
+ expect(stateCalls()).toHaveLength(1)
1026
+
1027
+ mockIncomingEvents.next({
1028
+ type: 'rollCall',
1029
+ userId: 'user-2',
1030
+ sessionId: 'their-session',
1031
+ })
1032
+ await flush()
1033
+
1034
+ expect(stateCalls()).toHaveLength(2)
1035
+ } finally {
1036
+ vi.useRealTimers()
1037
+ }
1038
+ })
1039
+
1040
+ it('ignores the echo of its own roll call', async () => {
1041
+ vi.useFakeTimers()
1042
+ try {
1043
+ getPresence(instance)
1044
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1045
+ await flush()
1046
+ expect(stateCalls()).toHaveLength(1)
1047
+
1048
+ // Bifur publishes to the topic we are subscribed to, so our own roll call
1049
+ // comes straight back. Answering it would be a pointless round trip.
1050
+ mockIncomingEvents.next({
1051
+ type: 'rollCall',
1052
+ userId: 'user-1',
1053
+ sessionId: 'test-session-id',
1054
+ })
1055
+ await flush()
1056
+
1057
+ expect(stateCalls()).toHaveLength(1)
1058
+ } finally {
1059
+ vi.useRealTimers()
1060
+ }
1061
+ })
1062
+
1063
+ it('keeps announcing after a failed announcement', async () => {
1064
+ vi.useFakeTimers()
1065
+ try {
1066
+ getPresence(instance)
1067
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1068
+ await flush()
1069
+ expect(stateCalls()).toHaveLength(1)
1070
+
1071
+ // A dropped socket errors the in-flight request. If that error reaches the
1072
+ // announce stream it kills every trigger at once, because the heartbeat,
1073
+ // roll-call responses, and reconnect re-announce all share this chain.
1074
+ mockDispatchMessage.mockReturnValueOnce(throwError(() => new Error('socket closed')))
1075
+ await vi.advanceTimersByTimeAsync(30_000)
1076
+ expect(stateCalls()).toHaveLength(2)
1077
+
1078
+ // The heartbeat must survive the failure.
1079
+ await vi.advanceTimersByTimeAsync(30_000)
1080
+ expect(stateCalls()).toHaveLength(3)
1081
+
1082
+ // So must roll-call responses.
1083
+ mockIncomingEvents.next({type: 'rollCall', userId: 'user-2', sessionId: 'their-session'})
1084
+ await flush()
1085
+ expect(stateCalls()).toHaveLength(4)
1086
+
1087
+ // And so must the re-announce on reconnect, which is the path that matters
1088
+ // most: the failure and the reconnect have the same root cause.
1089
+ mockConnections.next(2)
1090
+ await flush()
1091
+ expect(stateCalls()).toHaveLength(5)
1092
+ } finally {
1093
+ vi.useRealTimers()
1094
+ }
1095
+ })
1096
+
1097
+ it('keeps announcing when a trigger source fails', async () => {
1098
+ vi.useFakeTimers()
1099
+ try {
1100
+ getPresence(instance)
1101
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1102
+ await flush()
1103
+ expect(stateCalls()).toHaveLength(1)
1104
+
1105
+ // The three announce triggers are combined with `merge`, which propagates
1106
+ // an error from any one of them. The inbound stream feeds the roll-call
1107
+ // trigger, so a failure there would otherwise take the heartbeat and the
1108
+ // reconnect re-announce down with it.
1109
+ mockIncomingEvents.error(new Error('inbound stream failed'))
1110
+
1111
+ await vi.advanceTimersByTimeAsync(30_000)
1112
+ expect(stateCalls()).toHaveLength(2)
1113
+
1114
+ mockConnections.next(2)
1115
+ await flush()
1116
+ expect(stateCalls()).toHaveLength(3)
1117
+ } finally {
1118
+ vi.useRealTimers()
1119
+ }
1120
+ })
1121
+
1122
+ it('re-announces after a reconnect', async () => {
1123
+ vi.useFakeTimers()
1124
+ try {
1125
+ getPresence(instance)
1126
+ reportPresence(instance, {locations: [{documentId: 'doc-1'}]})
1127
+ await flush()
1128
+ expect(stateCalls()).toHaveLength(1)
1129
+
1130
+ mockConnections.next(2)
1131
+ await flush()
1132
+
1133
+ // Peers cleared their view of us when we dropped, so we have to speak up.
1134
+ expect(stateCalls()).toHaveLength(2)
1135
+ } finally {
1136
+ vi.useRealTimers()
1137
+ }
1138
+ })
1139
+ })
1140
+
1141
+ describe('session id', () => {
1142
+ afterEach(() => {
1143
+ vi.unstubAllGlobals()
1144
+ })
1145
+
1146
+ it('gives each store its own id, so nothing can collide across tabs', () => {
1147
+ // Ids are deliberately not persisted: a tab that inherits another's
1148
+ // session storage would otherwise reuse the id and the two live clients
1149
+ // would filter each other out as their own session.
1150
+ let counter = 0
1151
+ vi.stubGlobal('crypto', {randomUUID: vi.fn(() => `session-${++counter}`)})
1152
+
1153
+ getPresence(instance, {resource: {projectId: 'p1', dataset: 'd1'}}).subscribe(() => {})
1154
+ const first = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
1155
+
1156
+ getPresence(instance, {resource: {projectId: 'p2', dataset: 'd2'}}).subscribe(() => {})
1157
+ const second = vi.mocked(createBifurTransport).mock.calls.at(-1)?.[0].sessionId
1158
+
1159
+ expect(first).toBe('session-1')
1160
+ expect(second).toBe('session-2')
1161
+ })
271
1162
  })
272
1163
  })