@sanity/sdk 2.8.0 → 2.10.0

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 (111) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2450 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1537 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +465 -1813
  15. package/dist/index.js.map +1 -1
  16. package/package.json +17 -12
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +18 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +44 -30
  35. package/src/client/clientStore.ts +49 -48
  36. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  37. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  38. package/src/comlink/node/getNodeState.ts +2 -1
  39. package/src/config/sanityConfig.ts +78 -12
  40. package/src/document/actions.ts +18 -11
  41. package/src/document/applyDocumentActions.test.ts +7 -6
  42. package/src/document/applyDocumentActions.ts +10 -4
  43. package/src/document/documentStore.test.ts +542 -188
  44. package/src/document/documentStore.ts +142 -76
  45. package/src/document/events.ts +7 -2
  46. package/src/document/permissions.test.ts +18 -16
  47. package/src/document/permissions.ts +35 -11
  48. package/src/document/processActions.test.ts +359 -32
  49. package/src/document/processActions.ts +106 -78
  50. package/src/document/reducers.test.ts +117 -29
  51. package/src/document/reducers.ts +47 -40
  52. package/src/document/sharedListener.ts +16 -6
  53. package/src/document/util.ts +14 -0
  54. package/src/favorites/favorites.test.ts +9 -2
  55. package/src/presence/bifurTransport.test.ts +46 -6
  56. package/src/presence/bifurTransport.ts +19 -2
  57. package/src/presence/presenceStore.test.ts +96 -0
  58. package/src/presence/presenceStore.ts +96 -24
  59. package/src/preview/getPreviewState.test.ts +115 -98
  60. package/src/preview/getPreviewState.ts +38 -60
  61. package/src/preview/previewProjectionUtils.test.ts +179 -0
  62. package/src/preview/previewProjectionUtils.ts +93 -0
  63. package/src/preview/resolvePreview.test.ts +42 -25
  64. package/src/preview/resolvePreview.ts +33 -10
  65. package/src/preview/{previewStore.ts → types.ts} +8 -17
  66. package/src/projection/getProjectionState.test.ts +16 -16
  67. package/src/projection/getProjectionState.ts +6 -5
  68. package/src/projection/projectionQuery.ts +2 -3
  69. package/src/projection/projectionStore.test.ts +2 -2
  70. package/src/projection/resolveProjection.ts +2 -2
  71. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  72. package/src/projection/subscribeToStateAndFetchBatches.ts +12 -11
  73. package/src/projection/types.ts +1 -1
  74. package/src/query/queryStore.test.ts +12 -12
  75. package/src/query/queryStore.ts +12 -11
  76. package/src/query/reducers.ts +3 -3
  77. package/src/releases/getPerspectiveState.ts +7 -6
  78. package/src/releases/releasesStore.test.ts +20 -5
  79. package/src/releases/releasesStore.ts +20 -8
  80. package/src/store/createActionBinder.test.ts +31 -31
  81. package/src/store/createActionBinder.ts +43 -38
  82. package/src/store/createSanityInstance.ts +2 -3
  83. package/src/store/createStateSourceAction.test.ts +62 -0
  84. package/src/store/createStateSourceAction.ts +34 -39
  85. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  86. package/src/telemetry/devMode.test.ts +52 -0
  87. package/src/telemetry/devMode.ts +40 -0
  88. package/src/telemetry/initTelemetry.test.ts +225 -0
  89. package/src/telemetry/initTelemetry.ts +205 -0
  90. package/src/telemetry/telemetryManager.test.ts +263 -0
  91. package/src/telemetry/telemetryManager.ts +187 -0
  92. package/src/users/reducers.ts +3 -4
  93. package/src/users/usersStore.test.ts +1 -0
  94. package/src/users/usersStore.ts +5 -1
  95. package/src/utils/createFetcherStore.test.ts +6 -4
  96. package/src/utils/createFetcherStore.ts +8 -5
  97. package/src/utils/getStagingApiHost.test.ts +21 -0
  98. package/src/utils/getStagingApiHost.ts +14 -0
  99. package/src/utils/ids.test.ts +1 -29
  100. package/src/utils/ids.ts +0 -10
  101. package/src/utils/isImportError.test.ts +72 -0
  102. package/src/utils/isImportError.ts +34 -0
  103. package/src/utils/object.test.ts +95 -0
  104. package/src/utils/object.ts +142 -0
  105. package/src/utils/setCleanupTimeout.ts +24 -0
  106. package/src/preview/previewQuery.test.ts +0 -236
  107. package/src/preview/previewQuery.ts +0 -153
  108. package/src/preview/previewStore.test.ts +0 -36
  109. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  110. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  111. package/src/preview/util.ts +0 -13
@@ -1,9 +1,12 @@
1
- import {getPublishedId} from '@sanity/client/csm'
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
2
  import {type Mutation, type PatchOperations, type SanityDocumentLike} from '@sanity/types'
3
- import {omit} from 'lodash-es'
4
3
 
4
+ import {type DocumentHandle} from '../config/sanityConfig'
5
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
6
  import {type StoreContext} from '../store/defineStore'
6
- import {getDraftId, insecureRandomId} from '../utils/ids'
7
+ import {insecureRandomId} from '../utils/ids'
8
+ import {omitProperty} from '../utils/object'
9
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
7
10
  import {type DocumentAction} from './actions'
8
11
  import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
9
12
  import {type DocumentState, type DocumentStoreState} from './documentStore'
@@ -18,9 +21,13 @@ export type SyncTransactionState = Pick<
18
21
  'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants'
19
22
  >
20
23
 
24
+ type DocumentHandleLike = Pick<DocumentHandle, 'perspective'> & {
25
+ documentId?: string
26
+ liveEdit?: boolean
27
+ }
28
+
21
29
  type ActionMap = {
22
30
  create: 'sanity.action.document.version.create'
23
- createLiveEdit: 'sanity.action.document.create'
24
31
  discard: 'sanity.action.document.version.discard'
25
32
  unpublish: 'sanity.action.document.unpublish'
26
33
  delete: 'sanity.action.document.delete'
@@ -35,7 +42,6 @@ type OptimisticLock = {
35
42
 
36
43
  export type HttpAction =
37
44
  | {actionType: ActionMap['create']; publishedId: string; attributes: SanityDocumentLike}
38
- | {actionType: ActionMap['createLiveEdit']; publishedId: string; attributes: SanityDocumentLike}
39
45
  | {actionType: ActionMap['discard']; versionId: string; purge?: boolean}
40
46
  | {actionType: ActionMap['unpublish']; draftId: string; publishedId: string}
41
47
  | {actionType: ActionMap['delete']; publishedId: string; includeDrafts?: string[]}
@@ -115,9 +121,9 @@ export interface AppliedTransaction extends QueuedTransaction {
115
121
  outgoingActions: HttpAction[]
116
122
 
117
123
  /**
118
- * similar to `outgoingActions` but comprised of mutations instead of action.
119
- * this left here for debugging purposes but could be used to send mutations
120
- * to Content Lake instead of actions.
124
+ * similar to `outgoingActions` but comprised of mutations instead of actions.
125
+ * Useful for debugging, and is also used by liveEdit documents to send mutations,
126
+ * since they can't use the Actions API which is pretty dependent on the draft model.
121
127
  */
122
128
  outgoingMutations: Mutation[]
123
129
  }
@@ -145,7 +151,7 @@ export function queueTransaction(
145
151
  transaction: QueuedTransaction,
146
152
  ): SyncTransactionState {
147
153
  const {transactionId, actions} = transaction
148
- const prevWithSubscriptionIds = getDocumentIdsFromActions(actions).reduce(
154
+ const prevWithSubscriptionIds = getDocumentIdsFromHandleLikes(actions).reduce(
149
155
  (acc, id) => addSubscriptionIdToDocument(acc, id, transactionId),
150
156
  prev,
151
157
  )
@@ -163,7 +169,7 @@ export function removeQueuedTransaction(
163
169
  const transaction = prev.queued.find((t) => t.transactionId === transactionId)
164
170
  if (!transaction) return prev
165
171
 
166
- const prevWithSubscriptionIds = getDocumentIdsFromActions(transaction.actions).reduce(
172
+ const prevWithSubscriptionIds = getDocumentIdsFromHandleLikes(transaction.actions).reduce(
167
173
  (acc, id) => removeSubscriptionIdFromDocument(acc, id, transactionId),
168
174
  prev,
169
175
  )
@@ -179,7 +185,7 @@ export function applyFirstQueuedTransaction(prev: SyncTransactionState): SyncTra
179
185
  if (!queued) return prev
180
186
  if (!prev.grants) return prev
181
187
 
182
- const ids = getDocumentIdsFromActions(queued.actions)
188
+ const ids = getDocumentIdsFromHandleLikes(queued.actions)
183
189
  // the local value is only ever `undefined` if it has not been loaded yet
184
190
  // we can't get the next applied state unless all relevant documents are ready
185
191
  if (ids.some((id) => prev.documentStates[id]?.local === undefined)) return prev
@@ -265,6 +271,9 @@ export function batchAppliedTransactions([curr, ...rest]: AppliedTransaction[]):
265
271
  if (!next) return undefined
266
272
  if (next.disableBatching) return editAction
267
273
 
274
+ // Don't batch a liveEdit edit with a non-liveEdit edit — they route to different APIs
275
+ if (!!action.liveEdit !== !!next.actions[0]?.liveEdit) return editAction
276
+
268
277
  return {
269
278
  disableBatching: false,
270
279
  // Use the transactionId from the later (next) transaction.
@@ -337,7 +346,7 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
337
346
  if (!outgoing) return prev
338
347
 
339
348
  let next = prev
340
- const ids = getDocumentIdsFromActions(outgoing.actions)
349
+ const ids = getDocumentIdsFromHandleLikes(outgoing.actions)
341
350
  for (const transactionId of outgoing.batchedTransactionIds) {
342
351
  for (const documentId of ids) {
343
352
  next = removeSubscriptionIdFromDocument(next, documentId, transactionId)
@@ -383,7 +392,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
383
392
  local: documentId in working ? working[documentId] : local,
384
393
  unverifiedRevisions:
385
394
  prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
386
- ? omit(unverifiedRevisions, prev.outgoing.transactionId)
395
+ ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
387
396
  : unverifiedRevisions,
388
397
  }
389
398
  return [documentId, next] as const
@@ -411,7 +420,7 @@ export function applyRemoteDocument(
411
420
  const revisionToVerify = revision ? prevUnverifiedRevisions?.[revision] : undefined
412
421
  let unverifiedRevisions = prevUnverifiedRevisions ?? EMPTY_REVISIONS
413
422
  if (revision && revisionToVerify) {
414
- unverifiedRevisions = omit(prevUnverifiedRevisions, revision)
423
+ unverifiedRevisions = omitProperty(prevUnverifiedRevisions, revision)
415
424
  }
416
425
 
417
426
  // if this remote document is from a `'sync'` event (meaning that the whole
@@ -538,7 +547,7 @@ export function removeSubscriptionIdFromDocument(
538
547
 
539
548
  if (!prevDocState) return prev
540
549
  if (!subscriptions.length) {
541
- return {...prev, documentStates: omit(prev.documentStates, documentId)}
550
+ return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
542
551
  }
543
552
  return {
544
553
  ...prev,
@@ -551,22 +560,10 @@ export function removeSubscriptionIdFromDocument(
551
560
 
552
561
  export function manageSubscriberIds(
553
562
  {state}: StoreContext<SyncTransactionState>,
554
- documentId: string | string[],
555
- options?: {expandDraftPublished?: boolean},
563
+ handles: DocumentHandleLike[],
556
564
  ): () => void {
557
- const expandDraftPublished = options?.expandDraftPublished ?? true
558
- const documentIds = Array.from(
559
- new Set(
560
- expandDraftPublished
561
- ? (Array.isArray(documentId) ? documentId : [documentId]).flatMap((id) => [
562
- getPublishedId(id),
563
- getDraftId(id),
564
- ])
565
- : Array.isArray(documentId)
566
- ? documentId
567
- : [documentId],
568
- ),
569
- )
565
+ const documentIds = getDocumentIdsFromHandleLikes(handles)
566
+
570
567
  const subscriptionId = insecureRandomId()
571
568
  state.set('addSubscribers', (prev) =>
572
569
  documentIds.reduce(
@@ -576,7 +573,7 @@ export function manageSubscriberIds(
576
573
  )
577
574
 
578
575
  return () => {
579
- setTimeout(() => {
576
+ setCleanupTimeout(() => {
580
577
  state.set('removeSubscribers', (prev) =>
581
578
  documentIds.reduce(
582
579
  (acc, id) => removeSubscriptionIdFromDocument(acc, id, subscriptionId),
@@ -587,13 +584,23 @@ export function manageSubscriberIds(
587
584
  }
588
585
  }
589
586
 
590
- export function getDocumentIdsFromActions(actions: DocumentAction[]): string[] {
591
- return Array.from(
592
- new Set(
593
- actions
594
- .map((i) => i.documentId)
595
- .filter((i) => typeof i === 'string')
596
- .flatMap((documentId) => [getPublishedId(documentId), getDraftId(documentId)]),
597
- ),
598
- )
587
+ // document handles are passed in via the public facing API, but we also need to
588
+ // pull the correct document ids from action bodies, which have similar but not
589
+ // identical shapes to the document handles.
590
+ function getDocumentIdsFromHandleLikes(handles: DocumentHandleLike[]): string[] {
591
+ return handles.flatMap((handle) => {
592
+ const idsForDocument = []
593
+ if (!handle.documentId) return []
594
+ if (handle.liveEdit) {
595
+ return [handle.documentId]
596
+ }
597
+ if (isReleasePerspective(handle.perspective)) {
598
+ idsForDocument.push(
599
+ getVersionId(DocumentId(handle.documentId), handle.perspective.releaseName),
600
+ )
601
+ }
602
+ idsForDocument.push(getPublishedId(DocumentId(handle.documentId)))
603
+ idsForDocument.push(getDraftId(DocumentId(handle.documentId)))
604
+ return idsForDocument
605
+ })
599
606
  }
@@ -14,6 +14,7 @@ import {
14
14
  } from 'rxjs'
15
15
 
16
16
  import {getClientState} from '../client/clientStore'
17
+ import {type DocumentResource, isDatasetResource} from '../config/sanityConfig'
17
18
  import {type SanityInstance} from '../store/createSanityInstance'
18
19
 
19
20
  const API_VERSION = 'v2025-05-06'
@@ -23,10 +24,15 @@ export interface SharedListener {
23
24
  dispose: () => void
24
25
  }
25
26
 
26
- export function createSharedListener(instance: SanityInstance): SharedListener {
27
+ export function createSharedListener(
28
+ instance: SanityInstance,
29
+ resource?: DocumentResource,
30
+ ): SharedListener {
27
31
  const dispose$ = new Subject<void>()
28
32
  const events$ = getClientState(instance, {
29
33
  apiVersion: API_VERSION,
34
+ // TODO: remove in v3 when we're ready for everything to be queried via resource
35
+ resource: resource && !isDatasetResource(resource) ? resource : undefined,
30
36
  }).observable.pipe(
31
37
  switchMap((client) =>
32
38
  // TODO: it seems like the client.listen method is not emitting disconnected
@@ -62,13 +68,17 @@ export function createSharedListener(instance: SanityInstance): SharedListener {
62
68
  }
63
69
  }
64
70
 
65
- export function createFetchDocument(instance: SanityInstance) {
71
+ export function createFetchDocument(instance: SanityInstance, resource?: DocumentResource) {
66
72
  return function (documentId: string): Observable<SanityDocument | null> {
67
- return getClientState(instance, {apiVersion: API_VERSION}).observable.pipe(
73
+ return getClientState(instance, {
74
+ apiVersion: API_VERSION,
75
+ // TODO: remove in v3 when we're ready for everything to be queried via resource
76
+ resource: resource && !isDatasetResource(resource) ? resource : undefined,
77
+ }).observable.pipe(
68
78
  switchMap((client) => {
69
- // TODO: remove this once the client is updated to v7 the new type is available in @sanity/mutate/_unstable_store
70
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
- const loadDocument = createDocumentLoaderFromClient(client as any)
79
+ // creates a observable request to the /doc/{documentId} endpoint for a given document id
80
+ // should work across all kinds of document IDs (drafts.**, version.**., etc.)
81
+ const loadDocument = createDocumentLoaderFromClient(client)
72
82
  return loadDocument(documentId)
73
83
  }),
74
84
  map((result) => {
@@ -0,0 +1,14 @@
1
+ import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
+
3
+ import {type DocumentHandle} from '../config/sanityConfig'
4
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
+
6
+ export function getEffectiveDocumentId(doc: DocumentHandle): string {
7
+ if (doc.liveEdit) {
8
+ return doc.documentId
9
+ } else if (isReleasePerspective(doc.perspective)) {
10
+ return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
11
+ } else {
12
+ return getPublishedId(DocumentId(doc.documentId))
13
+ }
14
+ }
@@ -126,9 +126,15 @@ describe('favoritesStore', () => {
126
126
  })
127
127
 
128
128
  it('handles error and returns default response', async () => {
129
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
129
130
  setupMockStateSource({fetchImpl: vi.fn().mockRejectedValue(new Error('Failed to fetch'))})
130
131
  const result = await resolveFavoritesState(instance!, mockContext)
131
132
  expect(result).toEqual({isFavorited: false})
133
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
134
+ 'Favorites service connection error',
135
+ expect.any(Error),
136
+ )
137
+ consoleErrorSpy.mockRestore()
132
138
  })
133
139
 
134
140
  it('shares observable between multiple subscribers and cleans up', async () => {
@@ -140,8 +146,9 @@ describe('favoritesStore', () => {
140
146
  await firstValueFrom(state.observable)
141
147
  expect(mockFetch).toHaveBeenCalledTimes(1)
142
148
  // Second subscriber should use cached response
143
- const sub2 = state.subscribe()
144
- await firstValueFrom(state.observable)
149
+ const state2 = getFavoritesState(instance!, mockContext)
150
+ const sub2 = state2.subscribe()
151
+ await firstValueFrom(state2.observable)
145
152
  expect(mockFetch).toHaveBeenCalledTimes(1)
146
153
  // Cleanup
147
154
  sub1()
@@ -45,16 +45,18 @@ describe('createBifurTransport', () => {
45
45
 
46
46
  beforeEach(() => {
47
47
  vi.useFakeTimers()
48
+ vi.clearAllMocks()
48
49
  mockBifurClient = {
49
50
  listen: vi.fn(() => new Subject<never>()),
50
51
  request: vi.fn(() => of(undefined)),
51
52
  }
52
53
  fromUrlMock.mockReturnValue(mockBifurClient)
53
54
 
55
+ // Default mock is a dataset client using project hostname
54
56
  mockSanityClient = {
55
57
  config: () => ({
56
58
  dataset: 'test-dataset',
57
- url: 'http://localhost:3333',
59
+ url: 'https://test-project.api.sanity.io/v2022-06-30',
58
60
  requestTagPrefix: 'test-tag',
59
61
  }),
60
62
  withConfig: vi.fn().mockReturnThis(),
@@ -63,7 +65,7 @@ describe('createBifurTransport', () => {
63
65
  token$ = new Subject<string | null>()
64
66
  })
65
67
 
66
- it('constructs the bifur client with the correct URL', () => {
68
+ it('constructs the bifur client URL for a dataset resource', () => {
67
69
  createBifurTransport({
68
70
  client: mockSanityClient,
69
71
  token$,
@@ -71,13 +73,51 @@ describe('createBifurTransport', () => {
71
73
  })
72
74
 
73
75
  expect(fromUrlMock).toHaveBeenCalledWith(
74
- 'ws://localhost:3333/socket/test-dataset?tag=test-tag',
75
- {
76
- token$,
77
- },
76
+ 'wss://test-project.api.sanity.io/v2022-06-30/socket/test-dataset?tag=test-tag',
77
+ {token$},
78
+ )
79
+ })
80
+
81
+ it('constructs the bifur client URL for a canvas resource', () => {
82
+ const canvasClient = {
83
+ config: () => ({
84
+ resource: {type: 'canvas', id: 'canvas-123'},
85
+ url: 'https://api.sanity.io/v2022-06-30',
86
+ requestTagPrefix: 'test-tag',
87
+ }),
88
+ withConfig: vi.fn().mockReturnThis(),
89
+ } as unknown as SanityClient
90
+
91
+ createBifurTransport({
92
+ client: canvasClient,
93
+ token$,
94
+ sessionId: 'session-id-123',
95
+ })
96
+
97
+ expect(fromUrlMock).toHaveBeenCalledWith(
98
+ 'wss://api.sanity.io/v2022-06-30/socket/canvases/canvas-123?tag=test-tag',
99
+ {token$},
78
100
  )
79
101
  })
80
102
 
103
+ it('throws when no canvas resource or dataset is configured', () => {
104
+ const invalidClient = {
105
+ config: () => ({
106
+ url: 'https://api.sanity.io/v2022-06-30',
107
+ requestTagPrefix: 'test-tag',
108
+ }),
109
+ withConfig: vi.fn().mockReturnThis(),
110
+ } as unknown as SanityClient
111
+
112
+ expect(() =>
113
+ createBifurTransport({
114
+ client: invalidClient,
115
+ token$,
116
+ sessionId: 'session-id-123',
117
+ }),
118
+ ).toThrow('Unable to determine presence URL')
119
+ })
120
+
81
121
  it('handles incoming rollCall events', () => {
82
122
  const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
83
123
  mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
@@ -36,8 +36,25 @@ type IncomingBifurEvent = RollCallEvent | BifurStateMessage | BifurDisconnectMes
36
36
 
37
37
  function getBifurClient(client: SanityClient, token$: Observable<string | null>): BifurClient {
38
38
  const bifurVersionedClient = client.withConfig({apiVersion: '2022-06-30'})
39
- const {dataset, url: baseUrl, requestTagPrefix = 'sanity.studio'} = bifurVersionedClient.config()
40
- const url = `${baseUrl.replace(/\/+$/, '')}/socket/${dataset}`.replace(/^http/, 'ws')
39
+ const {
40
+ resource,
41
+ dataset,
42
+ url: baseUrl,
43
+ requestTagPrefix = 'sanity.sdk.presence',
44
+ } = bifurVersionedClient.config()
45
+
46
+ let resourcePath: string
47
+ if (resource?.type === 'canvas') {
48
+ resourcePath = `canvases/${resource.id}`
49
+ } else if (dataset) {
50
+ // Dataset clients use project hostname — dataset name alone is the socket path
51
+ resourcePath = dataset
52
+ } else {
53
+ throw new Error(`Unable to determine presence URL: no canvas resource or dataset configured`)
54
+ }
55
+
56
+ const url = `${baseUrl}/socket/${resourcePath}`.replace(/^http/, 'ws')
57
+
41
58
  const urlWithTag = `${url}?tag=${requestTagPrefix}`
42
59
 
43
60
  return fromUrl(urlWithTag, {token$})
@@ -48,6 +48,9 @@ describe('presenceStore', () => {
48
48
 
49
49
  mockClient = {
50
50
  withConfig: vi.fn().mockReturnThis(),
51
+ observable: {
52
+ request: vi.fn(() => of({organizationId: 'test-org-id'})),
53
+ },
51
54
  } as unknown as SanityClient
52
55
 
53
56
  mockTokenState = new Subject<string | null>()
@@ -243,5 +246,98 @@ describe('presenceStore', () => {
243
246
 
244
247
  unsubscribe()
245
248
  })
249
+
250
+ it('should throw an error when initialized with a media library resource', () => {
251
+ const mediaLibraryResource = {mediaLibraryId: 'ml123'}
252
+
253
+ expect(() => {
254
+ getPresence(instance, {resource: mediaLibraryResource})
255
+ }).toThrow('Presence is not supported for media library resources.')
256
+ })
257
+
258
+ it('should work with a dataset resource', () => {
259
+ const datasetResource = {projectId: 'test-project', dataset: 'test-dataset'}
260
+
261
+ expect(() => {
262
+ getPresence(instance, {resource: datasetResource})
263
+ }).not.toThrow()
264
+ })
265
+
266
+ it('should work with a canvas resource', () => {
267
+ const canvasResource = {canvasId: 'canvas123'}
268
+
269
+ expect(() => {
270
+ getPresence(instance, {resource: canvasResource})
271
+ }).not.toThrow()
272
+ })
273
+
274
+ it('creates a project-hostname client for dataset resources', () => {
275
+ getPresence(instance, {resource: {projectId: 'my-project', dataset: 'my-dataset'}})
276
+
277
+ expect(getClient).toHaveBeenCalledWith(instance, {
278
+ apiVersion: '2026-03-30',
279
+ projectId: 'my-project',
280
+ dataset: 'my-dataset',
281
+ useProjectHostname: true,
282
+ })
283
+ })
284
+
285
+ it('creates a resource client for canvas resources', () => {
286
+ const canvasResource = {canvasId: 'canvas123'}
287
+ getPresence(instance, {resource: canvasResource})
288
+
289
+ expect(getClient).toHaveBeenCalledWith(instance, {
290
+ apiVersion: '2026-03-30',
291
+ resource: canvasResource,
292
+ })
293
+ })
294
+
295
+ it('fetches organizationId from canvas endpoint for canvas resources', () => {
296
+ const canvasResource = {canvasId: 'canvas123'}
297
+ getPresence(instance, {resource: canvasResource})
298
+
299
+ expect(mockClient.observable.request).toHaveBeenCalledWith({
300
+ uri: '/canvases/canvas123',
301
+ tag: 'canvases.get',
302
+ })
303
+ })
304
+
305
+ it('does not fetch organizationId for dataset resources', () => {
306
+ getPresence(instance, {resource: {projectId: 'my-project', dataset: 'my-dataset'}})
307
+
308
+ expect(mockClient.observable.request).not.toHaveBeenCalled()
309
+ })
310
+
311
+ it('fetches user data for canvas users', async () => {
312
+ const source = getPresence(instance, {resource: {canvasId: 'canvas123'}})
313
+ const unsubscribe = source.subscribe(() => {})
314
+
315
+ await firstValueFrom(of(null).pipe(delay(10)))
316
+
317
+ mockIncomingEvents.next({
318
+ type: 'state',
319
+ userId: 'user-1',
320
+ sessionId: 'other-session',
321
+ timestamp: '2023-01-01T12:00:00Z',
322
+ locations: [
323
+ {
324
+ type: 'document',
325
+ documentId: 'doc-1',
326
+ path: ['title'],
327
+ lastActiveAt: '2023-01-01T12:00:00Z',
328
+ },
329
+ ],
330
+ })
331
+
332
+ await firstValueFrom(of(null).pipe(delay(50)))
333
+
334
+ expect(getUserState).toHaveBeenCalledWith(instance, {
335
+ userId: 'user-1',
336
+ resourceType: 'organization',
337
+ organizationId: 'test-org-id',
338
+ })
339
+
340
+ unsubscribe()
341
+ })
246
342
  })
247
343
  })