@sanity/sdk 3.0.0-rc.1 → 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 (120) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +71 -3
  3. package/dist/_exports/_internal.d.ts.map +1 -1
  4. package/dist/_exports/_internal.js +332 -24
  5. package/dist/_exports/_internal.js.map +1 -1
  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 -2335
  29. package/dist/index.js +4048 -3874
  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 +13 -19
  45. package/src/_exports/_internal.ts +1 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +38 -86
  49. package/src/applications/applications.test-d.ts +5 -0
  50. package/src/applications/applications.ts +5 -2
  51. package/src/auth/authMode.test.ts +0 -25
  52. package/src/auth/authMode.ts +3 -6
  53. package/src/auth/authStore.test.ts +1 -1
  54. package/src/auth/authStore.ts +0 -1
  55. package/src/comments/addonDatasetStore.test.ts +215 -0
  56. package/src/comments/addonDatasetStore.ts +249 -0
  57. package/src/comments/buildCommentThreads.test.ts +83 -0
  58. package/src/comments/buildCommentThreads.ts +54 -0
  59. package/src/comments/commentActions.test.ts +540 -0
  60. package/src/comments/commentActions.ts +555 -0
  61. package/src/comments/commentFieldPath.test.ts +40 -0
  62. package/src/comments/commentFieldPath.ts +44 -0
  63. package/src/comments/commentsConstants.ts +80 -0
  64. package/src/comments/commentsStore.test.ts +419 -0
  65. package/src/comments/commentsStore.ts +483 -0
  66. package/src/comments/normalizeComment.test.ts +116 -0
  67. package/src/comments/normalizeComment.ts +45 -0
  68. package/src/comments/observeComments.test.ts +193 -0
  69. package/src/comments/observeComments.ts +113 -0
  70. package/src/comments/reducers.test.ts +277 -0
  71. package/src/comments/reducers.ts +316 -0
  72. package/src/comments/types.ts +249 -0
  73. package/src/comments/weakenReferences.test.ts +47 -0
  74. package/src/comments/weakenReferences.ts +29 -0
  75. package/src/config/sanityConfig.ts +3 -75
  76. package/src/document/applyDocumentActions.test.ts +8 -12
  77. package/src/document/documentConstants.ts +8 -0
  78. package/src/document/documentStore.test.ts +104 -19
  79. package/src/document/documentStore.ts +36 -5
  80. package/src/document/patchOperations.test.ts +27 -0
  81. package/src/document/patchOperations.ts +10 -1
  82. package/src/document/permissions.test.ts +3 -1
  83. package/src/document/reducers.test.ts +133 -0
  84. package/src/document/reducers.ts +18 -3
  85. package/src/document/util.test.ts +46 -0
  86. package/src/document/util.ts +37 -1
  87. package/src/installations/installations.test-d.ts +5 -0
  88. package/src/installations/installations.ts +4 -0
  89. package/src/presence/bifurTransport.test.ts +155 -3
  90. package/src/presence/bifurTransport.ts +79 -12
  91. package/src/presence/paths.test.ts +70 -0
  92. package/src/presence/paths.ts +41 -0
  93. package/src/presence/presenceStore.test.ts +822 -2
  94. package/src/presence/presenceStore.ts +518 -102
  95. package/src/presence/types.ts +175 -3
  96. package/src/preview/types.ts +0 -25
  97. package/src/projection/projectionStore.test.ts +0 -8
  98. package/src/projection/types.ts +0 -8
  99. package/src/query/queryStore.ts +7 -6
  100. package/src/store/createActionBinder.test.ts +51 -29
  101. package/src/store/createActionBinder.ts +16 -62
  102. package/src/store/createSanityInstance.test.ts +0 -68
  103. package/src/store/createSanityInstance.ts +0 -63
  104. package/src/store/createStateSourceAction.ts +1 -1
  105. package/src/users/reducers.test.ts +0 -3
  106. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
  107. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  108. package/dist/_chunks-es/_internal.js +0 -134
  109. package/dist/_chunks-es/_internal.js.map +0 -1
  110. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
  111. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  112. package/dist/_chunks-es/telemetryManager.js +0 -93
  113. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  114. package/dist/index.d.ts.map +0 -1
  115. package/src/applications/userApplications.test.ts +0 -126
  116. package/src/applications/userApplications.ts +0 -261
  117. package/src/preview/getPreviewState.test.ts +0 -137
  118. package/src/preview/getPreviewState.ts +0 -69
  119. package/src/preview/resolvePreview.test.ts +0 -64
  120. package/src/preview/resolvePreview.ts +0 -43
@@ -1,5 +1,8 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+ import {type Path} from '@sanity/types'
1
3
  import {createSelector} from 'reselect'
2
4
  import {
5
+ auditTime,
3
6
  catchError,
4
7
  combineLatest,
5
8
  distinctUntilChanged,
@@ -7,10 +10,13 @@ import {
7
10
  filter,
8
11
  first,
9
12
  map,
13
+ merge,
10
14
  type Observable,
11
15
  of,
12
16
  Subscription,
13
17
  switchMap,
18
+ timer,
19
+ withLatestFrom,
14
20
  } from 'rxjs'
15
21
 
16
22
  import {getTokenState} from '../auth/authStore'
@@ -21,6 +27,7 @@ import {
21
27
  isDatasetResource,
22
28
  isMediaLibraryResource,
23
29
  } from '../config/sanityConfig'
30
+ import {getEditingDocumentId} from '../document/util'
24
31
  import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
25
32
  import {type SanityInstance} from '../store/createSanityInstance'
26
33
  import {
@@ -31,22 +38,146 @@ import {
31
38
  import {defineStore, type StoreContext} from '../store/defineStore'
32
39
  import {type SanityUser} from '../users/types'
33
40
  import {getUserState} from '../users/usersStore'
41
+ import {createLogger} from '../utils/logger'
34
42
  import {createBifurTransport} from './bifurTransport'
35
- import {type PresenceLocation, type TransportEvent, type UserPresence} from './types'
43
+ import {startsWithPath} from './paths'
44
+ import {
45
+ type DocumentPresence,
46
+ type DocumentPresenceOptions,
47
+ type PresenceLocation,
48
+ type ReportPresenceOptions,
49
+ type TransportEvent,
50
+ type TransportMessage,
51
+ type UserPresence,
52
+ type WirePresenceLocation,
53
+ } from './types'
54
+
55
+ const logger = createLogger('presence')
36
56
 
57
+ /**
58
+ * Used for the canvas organization lookup only. The socket itself is pinned to
59
+ * `2022-06-30` in `bifurTransport`, because that is the version Bifur compares
60
+ * against when deciding whether a connection may authenticate over RPC rather
61
+ * than requiring a JWT at upgrade. Do not align these two.
62
+ */
37
63
  const PRESENCE_API_VERSION = '2026-03-30'
38
64
 
65
+ /**
66
+ * How often the current user re-announces while idle. Matches the Studio so the
67
+ * two agree on how long a quiet peer should be trusted.
68
+ */
69
+ const REPORT_MIN_INTERVAL = 30_000
70
+
71
+ /** Collapses a burst of focus changes into a single announcement. */
72
+ const REPORT_AUDIT_TIME = 200
73
+
74
+ /**
75
+ * How long a session may go without announcing before it is dropped. Bifur
76
+ * publishes nothing when a peer's socket dies, so a peer that crashes, is
77
+ * force-quit, or loses power would otherwise stay visible forever.
78
+ *
79
+ * Peers re-announce every 30 seconds, so this allows three missed announcements.
80
+ */
81
+ const SESSION_TTL = 90_000
82
+
83
+ /** How often to look for sessions that have gone quiet. */
84
+ const SWEEP_INTERVAL = 15_000
85
+
86
+ type PresenceSession = {
87
+ userId: string
88
+ locations: PresenceLocation[]
89
+ /**
90
+ * When we last heard from this session, on our own clock. Used for expiry in
91
+ * preference to the sender's `lastActiveAt`, which is subject to clock skew:
92
+ * a peer whose clock runs slow would otherwise be expired immediately, and one
93
+ * whose clock runs fast would never be expired at all.
94
+ */
95
+ lastSeenAt: number
96
+ }
97
+
39
98
  type PresenceStoreState = {
40
- locations: Map<string, {userId: string; locations: PresenceLocation[]}>
99
+ locations: Map<string, PresenceSession>
41
100
  users: Record<string, SanityUser | undefined>
42
101
  organizationId?: string
102
+ /**
103
+ * Set when the canvas organization lookup fails. Without it, consumers waiting
104
+ * on `organizationId` would wait forever.
105
+ */
106
+ organizationIdError?: unknown
107
+ /**
108
+ * Where this client says it is. `undefined` means the app has never reported,
109
+ * which keeps it silent: reading presence must not make an app start
110
+ * broadcasting. An empty array is different, and means "here, but not in any
111
+ * particular document".
112
+ */
113
+ localLocations?: WirePresenceLocation[]
43
114
  }
44
115
 
45
116
  const getInitialState = (): PresenceStoreState => ({
46
- locations: new Map<string, {userId: string; locations: PresenceLocation[]}>(),
117
+ locations: new Map<string, PresenceSession>(),
47
118
  users: {},
48
119
  })
49
120
 
121
+ /**
122
+ * Sends a message without letting a failure tear down the stream it was sent on.
123
+ *
124
+ * This matters most for the announce pipeline, which carries the idle heartbeat,
125
+ * roll-call responses, and the re-announce on reconnect. Those share one chain, so
126
+ * an unhandled error from a single rejected RPC would silence this client
127
+ * permanently while it carried on reading presence normally. A dropped socket
128
+ * errors the in-flight request, which is exactly the case where staying alive
129
+ * matters, since the reconnect that follows is what makes us visible again.
130
+ *
131
+ * No retry is needed here. The 30 second heartbeat sends again on its own, and a
132
+ * recovered connection triggers an immediate re-announce.
133
+ */
134
+ const sendSafely = (
135
+ dispatch: (message: TransportMessage) => Observable<void>,
136
+ message: TransportMessage,
137
+ ): Observable<void> =>
138
+ dispatch(message).pipe(
139
+ catchError((error: unknown) => {
140
+ logger.warn('Failed to send presence message', {messageType: message.type, error})
141
+ return EMPTY
142
+ }),
143
+ )
144
+
145
+ /**
146
+ * Reduces a stream to a bare trigger that cannot fail.
147
+ *
148
+ * The announce pipeline combines three inputs with `merge`, which propagates an
149
+ * error from any one of them. Since that one pipeline carries the idle heartbeat,
150
+ * roll-call responses, and the re-announce on reconnect, a single failing input
151
+ * would silence this client for good. Losing one trigger is survivable; losing
152
+ * all three is not.
153
+ */
154
+ const asTrigger = (source$: Observable<unknown>, name: string): Observable<void> =>
155
+ source$.pipe(
156
+ map(() => undefined),
157
+ catchError((error: unknown) => {
158
+ logger.error('Presence announce trigger failed', {trigger: name, error})
159
+ return EMPTY
160
+ }),
161
+ )
162
+
163
+ /** Ignores `lastActiveAt`, which changes on every report even when nothing moved. */
164
+ const locationKey = ({documentId, path, selection}: WirePresenceLocation) =>
165
+ JSON.stringify([documentId, path, selection ?? null])
166
+
167
+ /**
168
+ * Compares reported locations by value. Key order within a caller-supplied
169
+ * `selection` could in principle differ and read as a change, which costs one
170
+ * extra announcement and nothing else.
171
+ */
172
+ function isEqualLocations(
173
+ a: WirePresenceLocation[] | undefined,
174
+ b: WirePresenceLocation[] | undefined,
175
+ ): boolean {
176
+ if (a === b) return true
177
+ if (!a || !b || a.length !== b.length) return false
178
+ return a.every((location, index) => locationKey(location) === locationKey(b[index]))
179
+ }
180
+
50
181
  /** @public */
51
182
  export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
52
183
  name: 'presence',
@@ -62,6 +193,11 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
62
193
  throw new Error('Presence is not supported for media library resources.')
63
194
  }
64
195
 
196
+ // A fresh id per store, deliberately not persisted. Reusing an id across
197
+ // page loads would collide whenever a tab inherits another's session
198
+ // storage (`window.open`, or a same-origin iframe), making two live clients
199
+ // filter each other out as self. Stale sessions are handled by the
200
+ // disconnect on unload, with the expiry sweep above as the backstop.
65
201
  const sessionId = crypto.randomUUID()
66
202
 
67
203
  // Dataset resources must use the project hostname so the socket URL is project-specific.
@@ -80,7 +216,7 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
80
216
 
81
217
  const token$ = getTokenState(instance).observable.pipe(distinctUntilChanged())
82
218
 
83
- const [incomingEvents$, dispatch] = createBifurTransport({
219
+ const [incomingEvents$, dispatch, connections$, unload$] = createBifurTransport({
84
220
  client,
85
221
  token$,
86
222
  sessionId,
@@ -88,36 +224,186 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
88
224
 
89
225
  const subscription = new Subscription()
90
226
 
227
+ // Subscribed before the roll call below, deliberately. The transport
228
+ // establishes its listener when this stream is subscribed, and a roll call
229
+ // sent before we are listening would have its answers arrive to no one.
91
230
  subscription.add(
92
- incomingEvents$.subscribe((event: TransportEvent) => {
93
- if ('sessionId' in event && event.sessionId === sessionId) {
94
- return
95
- }
96
-
97
- if (event.type === 'state') {
98
- state.set('presence/state', (prevState: PresenceStoreState) => {
99
- const newLocations = new Map(prevState.locations)
100
- newLocations.set(event.sessionId, {
101
- userId: event.userId,
102
- locations: event.locations,
231
+ incomingEvents$.subscribe({
232
+ next: (event: TransportEvent) => {
233
+ if ('sessionId' in event && event.sessionId === sessionId) {
234
+ return
235
+ }
236
+
237
+ if (event.type === 'state') {
238
+ state.set('presence/state', (prevState: PresenceStoreState) => {
239
+ const newLocations = new Map(prevState.locations)
240
+ newLocations.set(event.sessionId, {
241
+ userId: event.userId,
242
+ locations: event.locations,
243
+ lastSeenAt: Date.now(),
244
+ })
245
+
246
+ return {
247
+ ...prevState,
248
+ locations: newLocations,
249
+ }
250
+ })
251
+ } else if (event.type === 'disconnect') {
252
+ state.set('presence/disconnect', (prevState: PresenceStoreState) => {
253
+ const newLocations = new Map(prevState.locations)
254
+ newLocations.delete(event.sessionId)
255
+ return {...prevState, locations: newLocations}
103
256
  })
257
+ }
258
+ },
259
+ // The transport re-establishes its listener on every live connection, so
260
+ // this should not fire. Handled rather than left to become an unhandled
261
+ // error, which is how presence used to die silently and for good.
262
+ error: (error: unknown) => {
263
+ logger.error('Presence event stream failed', {error})
264
+ },
265
+ }),
266
+ )
104
267
 
105
- return {
106
- ...prevState,
107
- locations: newLocations,
108
- }
109
- })
110
- } else if (event.type === 'disconnect') {
111
- state.set('presence/disconnect', (prevState: PresenceStoreState) => {
112
- const newLocations = new Map(prevState.locations)
113
- newLocations.delete(event.sessionId)
114
- return {...prevState, locations: newLocations}
115
- })
116
- }
268
+ // Subscribing keeps the socket alive and reconnects it with backoff. Each
269
+ // emission is a freshly live connection: anything we accumulated is now
270
+ // suspect, so start over and ask everyone to announce themselves again.
271
+ subscription.add(
272
+ connections$.subscribe(() => {
273
+ state.set('presence/reset', (prevState) => ({
274
+ ...prevState,
275
+ locations: new Map<string, PresenceSession>(),
276
+ }))
277
+ sendSafely(dispatch, {type: 'rollCall'}).subscribe()
278
+ }),
279
+ )
280
+
281
+ // Restarted per connection so a long reconnect backoff does not expire
282
+ // everyone while we are offline — reconnecting clears the map anyway.
283
+ //
284
+ // Uses an rxjs timer rather than `setCleanupTimeout`, despite the guidance in
285
+ // core-package-conventions, because this is a recurring sweep and not a
286
+ // cleanup timer. Unref'ing it would not help anyway: a live presence
287
+ // subscription holds an open WebSocket, and that is what keeps a Node process
288
+ // alive. Anything running server-side should not subscribe to presence at all.
289
+ subscription.add(
290
+ connections$.pipe(switchMap(() => timer(SWEEP_INTERVAL, SWEEP_INTERVAL))).subscribe(() => {
291
+ state.set('presence/expire', (prevState) => {
292
+ const cutoff = Date.now() - SESSION_TTL
293
+ const stale = [...prevState.locations].filter(([, s]) => s.lastSeenAt < cutoff)
294
+ if (stale.length === 0) return prevState
295
+
296
+ const newLocations = new Map(prevState.locations)
297
+ for (const [staleSessionId] of stale) newLocations.delete(staleSessionId)
298
+ return {...prevState, locations: newLocations}
299
+ })
117
300
  }),
118
301
  )
119
302
 
120
- dispatch({type: 'rollCall'}).subscribe()
303
+ subscription.add(
304
+ unload$.pipe(switchMap(() => sendSafely(dispatch, {type: 'disconnect'}))).subscribe(),
305
+ )
306
+
307
+ // Announce where we are. A location change, a peer asking for a roll call, or
308
+ // a reconnect all restart `timer(0, ...)`, so we announce immediately and then
309
+ // every 30s while idle. That idle tick is what tells peers we are still here,
310
+ // and is what their expiry sweep measures against.
311
+ const localLocations$ = state.observable.pipe(
312
+ map((s) => s.localLocations),
313
+ // Compared by value, not by reference. The Studio compares by reference and
314
+ // so never dedupes, because its form rebuilds the array on every call.
315
+ distinctUntilChanged(isEqualLocations),
316
+ )
317
+
318
+ const rollCallRequests$ = incomingEvents$.pipe(
319
+ // Bifur echoes our own roll call back to us, since we are subscribed to the
320
+ // same topic we published to. Answering it would be a pointless round trip.
321
+ filter((event) => event.type === 'rollCall' && event.sessionId !== sessionId),
322
+ )
323
+
324
+ subscription.add(
325
+ merge(
326
+ asTrigger(localLocations$, 'locationChange'),
327
+ asTrigger(rollCallRequests$, 'rollCall'),
328
+ asTrigger(connections$, 'connection'),
329
+ )
330
+ .pipe(
331
+ switchMap(() => timer(0, REPORT_MIN_INTERVAL)),
332
+ withLatestFrom(localLocations$),
333
+ map(([, locations]) => locations),
334
+ // Stay silent until the app has reported at least once.
335
+ filter((locations): locations is WirePresenceLocation[] => locations !== undefined),
336
+ auditTime(REPORT_AUDIT_TIME),
337
+ switchMap((locations) => sendSafely(dispatch, {type: 'state', locations})),
338
+ )
339
+ .subscribe(),
340
+ )
341
+
342
+ // Resolve display names for everyone we can see.
343
+ //
344
+ // This belongs to the store rather than to one state source: it is driven by
345
+ // which user ids are in state, and every selector needs it. It used to hang off
346
+ // `getPresence`'s `onSubscribe`, which meant anything reading presence another
347
+ // way saw only "Unknown user".
348
+ const userIds$ = state.observable.pipe(
349
+ map((s) =>
350
+ Array.from(s.locations.values())
351
+ .map((l) => l.userId)
352
+ .filter((id): id is string => !!id),
353
+ ),
354
+ distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
355
+ )
356
+
357
+ // For canvas resources, wait for the organizationId to arrive. A failed lookup
358
+ // resolves to `undefined` rather than never emitting, so one failed request
359
+ // does not leave every user permanently unresolved. Dataset resources emit
360
+ // immediately so the stream is never blocked.
361
+ const organizationId$: Observable<string | undefined> = isCanvasResource(resource)
362
+ ? state.observable.pipe(
363
+ filter((s) => s.organizationId !== undefined || s.organizationIdError !== undefined),
364
+ first(),
365
+ map((s) => s.organizationId),
366
+ )
367
+ : of(undefined)
368
+
369
+ subscription.add(
370
+ combineLatest([userIds$, organizationId$])
371
+ .pipe(
372
+ switchMap(([userIds, organizationId]) => {
373
+ if (userIds.length === 0) {
374
+ return of([])
375
+ }
376
+ // Without an organization there is nothing to scope the lookup to, so
377
+ // skip the request rather than making one that cannot succeed.
378
+ if (!isDatasetResource(resource) && !organizationId) {
379
+ return of([])
380
+ }
381
+ const userObservables = userIds.map((userId) =>
382
+ getUserState(instance, {
383
+ userId,
384
+ ...(isDatasetResource(resource)
385
+ ? {resourceType: 'project', projectId: resource.projectId}
386
+ : {resourceType: 'organization', organizationId}),
387
+ }).pipe(filter((v): v is NonNullable<typeof v> => !!v)),
388
+ )
389
+ return combineLatest(userObservables)
390
+ }),
391
+ )
392
+ .subscribe((users) => {
393
+ state.set('presence/users', (prevState) => ({
394
+ ...prevState,
395
+ users: {
396
+ ...prevState.users,
397
+ ...users.reduce<Record<string, SanityUser>>((acc, user) => {
398
+ if (user) {
399
+ acc[user.profile.id] = user
400
+ }
401
+ return acc
402
+ }, {}),
403
+ },
404
+ }))
405
+ }),
406
+ )
121
407
 
122
408
  // Canvas resources need the organizationId to resolve users — fetch it once from the canvas endpoint
123
409
  if (isCanvasResource(resource)) {
@@ -128,15 +414,19 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
128
414
  uri: `/canvases/${resource.canvasId}`,
129
415
  tag: 'canvases.get',
130
416
  })
131
- .pipe(catchError(() => EMPTY))
132
- .subscribe(({organizationId}) => {
133
- state.set('presence/organizationId', (prev) => ({...prev, organizationId}))
417
+ .subscribe({
418
+ next: ({organizationId}) => {
419
+ state.set('presence/organizationId', (prev) => ({...prev, organizationId}))
420
+ },
421
+ error: (organizationIdError: unknown) => {
422
+ state.set('presence/organizationIdError', (prev) => ({...prev, organizationIdError}))
423
+ },
134
424
  }),
135
425
  )
136
426
  }
137
427
 
138
428
  return () => {
139
- dispatch({type: 'disconnect'}).subscribe()
429
+ sendSafely(dispatch, {type: 'disconnect'}).subscribe()
140
430
  subscription.unsubscribe()
141
431
  }
142
432
  },
@@ -145,88 +435,134 @@ export const presenceStore = defineStore<PresenceStoreState, BoundResourceKey>({
145
435
  const selectLocations = (state: PresenceStoreState) => state.locations
146
436
  const selectUsers = (state: PresenceStoreState) => state.users
147
437
 
438
+ /**
439
+ * Stands in for a participant whose profile has not resolved yet, or cannot be
440
+ * resolved. This must be a structurally valid `SanityUser`: consumers read
441
+ * `user.profile`, and a partial object would throw there on first render.
442
+ */
443
+ const createUnresolvedUser = (userId: string): SanityUser => ({
444
+ sanityUserId: userId,
445
+ profile: {
446
+ id: userId,
447
+ displayName: 'Unknown user',
448
+ email: '',
449
+ provider: '',
450
+ createdAt: '',
451
+ },
452
+ memberships: [],
453
+ })
454
+
148
455
  const selectPresence = createSelector(
149
456
  selectLocations,
150
457
  selectUsers,
151
458
  (locations, users): UserPresence[] => {
152
- return Array.from(locations.entries()).map(([sessionId, {userId, locations: locs}]) => {
153
- const user = users[userId]
154
-
155
- return {
156
- user:
157
- user ||
158
- ({
159
- id: userId,
160
- displayName: 'Unknown user',
161
- name: 'Unknown user',
162
- email: '',
163
- } as unknown as SanityUser),
164
- sessionId,
165
- locations: locs,
166
- }
167
- })
459
+ return Array.from(locations.entries()).map(([sessionId, {userId, locations: locs}]) => ({
460
+ user: users[userId] || createUnresolvedUser(userId),
461
+ sessionId,
462
+ locations: locs,
463
+ }))
168
464
  },
169
465
  )
170
466
 
467
+ /**
468
+ * Results per state object, then per query.
469
+ *
470
+ * `createStateSourceAction` runs its selector afresh on every `getCurrent()`, and
471
+ * `getCurrent` is what React's `useSyncExternalStore` calls as its snapshot. A
472
+ * selector that builds a new array each call therefore sends React into an
473
+ * infinite render loop, which is why the store docs point at memoized selectors.
474
+ *
475
+ * A single `reselect` memo slot is not enough here, because the selector is
476
+ * parameterised per document and callers watching different documents would
477
+ * evict each other. Keyed on the state object, which is replaced on every change,
478
+ * so entries are collected once that state is unreachable.
479
+ */
480
+ const documentPresenceCache = new WeakMap<PresenceStoreState, Map<string, DocumentPresence[]>>()
481
+
482
+ /** Flattens the session map down to one document. */
483
+ function selectDocumentPresence(
484
+ state: PresenceStoreState,
485
+ options: DocumentPresenceOptions,
486
+ ): DocumentPresence[] {
487
+ const cacheKey = JSON.stringify([
488
+ getEditingDocumentId(options),
489
+ options.path ?? null,
490
+ options.excludeVersions ?? false,
491
+ ])
492
+
493
+ let perState = documentPresenceCache.get(state)
494
+ if (!perState) {
495
+ perState = new Map<string, DocumentPresence[]>()
496
+ documentPresenceCache.set(state, perState)
497
+ }
498
+
499
+ const cached = perState.get(cacheKey)
500
+ if (cached) return cached
501
+
502
+ const computed = computeDocumentPresence(state, options)
503
+ perState.set(cacheKey, computed)
504
+ return computed
505
+ }
506
+
507
+ /**
508
+ * The id a query and a location are compared on. Exact when `excludeVersions` is
509
+ * set, otherwise normalized so a draft, its published version, and any release
510
+ * version count as one document.
511
+ */
512
+ const scopeId = (documentId: string, excludeVersions: boolean | undefined): string =>
513
+ excludeVersions ? documentId : getPublishedId(DocumentId(documentId))
514
+
515
+ /**
516
+ * Declared `string[]`, actually a `Path`. See the note on
517
+ * {@link PresenceLocation.path}; `DocumentPresence.path` reports it honestly.
518
+ */
519
+ const pathOf = (location: PresenceLocation): Path => location.path as unknown as Path
520
+
521
+ function matchesQuery(
522
+ location: PresenceLocation,
523
+ target: string,
524
+ {path, excludeVersions}: DocumentPresenceOptions,
525
+ ): boolean {
526
+ if (scopeId(location.documentId, excludeVersions) !== target) return false
527
+ return !path || startsWithPath(path, pathOf(location))
528
+ }
529
+
530
+ function toDocumentPresence(
531
+ users: PresenceStoreState['users'],
532
+ sessionId: string,
533
+ userId: string,
534
+ location: PresenceLocation,
535
+ ): DocumentPresence {
536
+ return {
537
+ user: users[userId] || createUnresolvedUser(userId),
538
+ sessionId,
539
+ documentId: location.documentId,
540
+ path: pathOf(location),
541
+ lastActiveAt: location.lastActiveAt,
542
+ ...(location.selection === undefined ? {} : {selection: location.selection}),
543
+ }
544
+ }
545
+
546
+ function computeDocumentPresence(
547
+ state: PresenceStoreState,
548
+ options: DocumentPresenceOptions,
549
+ ): DocumentPresence[] {
550
+ // Resolved before scoping, and identically to the write side, or a query for a
551
+ // document would not match the client reporting it.
552
+ const target = scopeId(getEditingDocumentId(options), options.excludeVersions)
553
+
554
+ return Array.from(state.locations).flatMap(([sessionId, session]) =>
555
+ session.locations
556
+ .filter((location) => matchesQuery(location, target, options))
557
+ .map((location) => toDocumentPresence(state.users, sessionId, session.userId, location)),
558
+ )
559
+ }
560
+
171
561
  const _getPresence = bindActionByResource(
172
562
  presenceStore,
173
563
  createStateSourceAction({
174
564
  selector: (context: SelectorContext<PresenceStoreState>): UserPresence[] =>
175
565
  selectPresence(context.state),
176
- onSubscribe: (context: StoreContext<PresenceStoreState, BoundResourceKey>) => {
177
- const resource = context.key.resource
178
- const userIds$ = context.state.observable.pipe(
179
- map((state) =>
180
- Array.from(state.locations.values())
181
- .map((l) => l.userId)
182
- .filter((id): id is string => !!id),
183
- ),
184
- distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
185
- )
186
-
187
- // For canvas resources, wait for organizationId to be fetched and stored in state.
188
- // For dataset resources, emit undefined immediately so the stream isn't blocked.
189
- const organizationId$: Observable<string | undefined> = isCanvasResource(resource)
190
- ? context.state.observable.pipe(
191
- map((s) => s.organizationId),
192
- filter((id): id is string => id !== undefined),
193
- first(),
194
- )
195
- : of(undefined)
196
-
197
- const subscription = combineLatest([userIds$, organizationId$])
198
- .pipe(
199
- switchMap(([userIds, organizationId]) => {
200
- if (userIds.length === 0) {
201
- return of([])
202
- }
203
- const userObservables = userIds.map((userId) =>
204
- getUserState(context.instance, {
205
- userId,
206
- ...(isDatasetResource(resource)
207
- ? {resourceType: 'project', projectId: resource.projectId}
208
- : {resourceType: 'organization', organizationId}),
209
- }).pipe(filter((v): v is NonNullable<typeof v> => !!v)),
210
- )
211
- return combineLatest(userObservables)
212
- }),
213
- )
214
- .subscribe((users) => {
215
- context.state.set('presence/users', (prevState) => ({
216
- ...prevState,
217
- users: {
218
- ...prevState.users,
219
- ...users.reduce<Record<string, SanityUser>>((acc, user) => {
220
- if (user) {
221
- acc[user.profile.id] = user
222
- }
223
- return acc
224
- }, {}),
225
- },
226
- }))
227
- })
228
- return () => subscription.unsubscribe()
229
- },
230
566
  }),
231
567
  )
232
568
 
@@ -239,3 +575,83 @@ export function getPresence(
239
575
  // in reality, this will always be passed a resource
240
576
  return _getPresence(instance, params ?? {})
241
577
  }
578
+
579
+ const _getDocumentPresence = bindActionByResource(
580
+ presenceStore,
581
+ createStateSourceAction({
582
+ selector: (
583
+ {state}: SelectorContext<PresenceStoreState>,
584
+ options: {resource?: DocumentResource} & DocumentPresenceOptions,
585
+ ): DocumentPresence[] => selectDocumentPresence(state, options),
586
+ }),
587
+ )
588
+
589
+ /**
590
+ * Presence within a single document, flattened to one entry per participant per
591
+ * location so it can be rendered directly against a field.
592
+ *
593
+ * Reading presence never announces anything. Use `reportPresence` to make the
594
+ * current user visible to others.
595
+ *
596
+ * @param instance - the Sanity instance
597
+ * @param params - the document to look at, its perspective, an optional `path` to
598
+ * narrow to a field subtree, and `excludeVersions` to compare document ids exactly
599
+ *
600
+ * @beta
601
+ */
602
+ export function getDocumentPresence(
603
+ instance: SanityInstance,
604
+ params: {resource?: DocumentResource} & DocumentPresenceOptions,
605
+ ): StateSource<DocumentPresence[]> {
606
+ return _getDocumentPresence(instance, params)
607
+ }
608
+
609
+ const _reportPresence = bindActionByResource(
610
+ presenceStore,
611
+ (
612
+ {state}: StoreContext<PresenceStoreState, BoundResourceKey>,
613
+ {locations}: {resource?: DocumentResource; locations: ReportPresenceOptions[]},
614
+ ) => {
615
+ const lastActiveAt = new Date().toISOString()
616
+ const wireLocations: WirePresenceLocation[] = locations.map((location) => ({
617
+ type: 'document',
618
+ // The specific document being edited, not the canonical id. Other clients,
619
+ // the Studio included, compare exact ids for field-level presence.
620
+ documentId: getEditingDocumentId(location),
621
+ path: location.path ?? [],
622
+ lastActiveAt,
623
+ ...(location.selection === undefined ? {} : {selection: location.selection}),
624
+ }))
625
+
626
+ state.set('presence/report', (prevState) => ({...prevState, localLocations: wireLocations}))
627
+ },
628
+ )
629
+
630
+ /**
631
+ * Announces where the current user is, so other clients in the same project and
632
+ * dataset can show them. Reading presence never announces anything, so this is
633
+ * the only way an app becomes visible to others.
634
+ *
635
+ * Call it again whenever the user moves. Announcements are collapsed over a short
636
+ * window and then repeated every 30 seconds while idle, which is what tells peers
637
+ * the session is still alive.
638
+ *
639
+ * Which specific document each location resolves to depends on its perspective, and
640
+ * that matters for interoperability: other clients, the Studio included, compare
641
+ * exact document ids for field-level presence. Pass the perspective you are editing
642
+ * under rather than building a draft or version id yourself.
643
+ *
644
+ * @param instance - the Sanity instance
645
+ * @param params - the resource to announce on, plus the locations to report.
646
+ * Pass one location with a `documentId` for document-level presence, add a
647
+ * `path` for field-level presence, and pass an empty array to appear present
648
+ * without being in any document.
649
+ *
650
+ * @beta
651
+ */
652
+ export function reportPresence(
653
+ instance: SanityInstance,
654
+ params: {resource?: DocumentResource; locations: ReportPresenceOptions[]},
655
+ ): void {
656
+ return _reportPresence(instance, params)
657
+ }