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

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 (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -0,0 +1,150 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, it} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {getOrganizationsCacheKey, organizations} from './organizations'
9
+
10
+ vi.mock('../client/clientStore')
11
+
12
+ describe('organizations', () => {
13
+ let instance: SanityInstance
14
+
15
+ beforeEach(() => {
16
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
17
+ })
18
+
19
+ afterEach(() => {
20
+ instance.dispose()
21
+ })
22
+
23
+ it('calls `client.observable.request` against `/organizations` and returns the result', async () => {
24
+ const list = [{id: 'org_1'}, {id: 'org_2'}]
25
+ const request = vi.fn().mockReturnValue(of(list))
26
+
27
+ const mockClient = {
28
+ observable: {request} as unknown as SanityClient['observable'],
29
+ } as SanityClient
30
+
31
+ vi.mocked(getClientState).mockReturnValue({
32
+ observable: of(mockClient),
33
+ } as StateSource<SanityClient>)
34
+
35
+ const result = await organizations.resolveState(instance)
36
+ expect(result).toEqual(list)
37
+ expect(request).toHaveBeenCalledWith({
38
+ uri: '/organizations',
39
+ query: {
40
+ includeImplicitMemberships: 'false',
41
+ includeMembers: 'false',
42
+ includeFeatures: 'false',
43
+ },
44
+ tag: 'organizations.list',
45
+ })
46
+ })
47
+
48
+ it('serializes query params (booleans → strings) and respects flags', async () => {
49
+ const request = vi.fn().mockReturnValue(of([]))
50
+ const mockClient = {
51
+ observable: {request} as unknown as SanityClient['observable'],
52
+ } as SanityClient
53
+
54
+ vi.mocked(getClientState).mockReturnValue({
55
+ observable: of(mockClient),
56
+ } as StateSource<SanityClient>)
57
+
58
+ await organizations.resolveState(instance, {
59
+ includeMembers: true,
60
+ includeFeatures: true,
61
+ includeImplicitMemberships: true,
62
+ })
63
+
64
+ expect(request).toHaveBeenCalledWith({
65
+ uri: '/organizations',
66
+ query: {
67
+ includeImplicitMemberships: 'true',
68
+ includeMembers: 'true',
69
+ includeFeatures: 'true',
70
+ },
71
+ tag: 'organizations.list',
72
+ })
73
+ })
74
+ })
75
+
76
+ describe('organizations cache key generation', () => {
77
+ let instance: SanityInstance
78
+
79
+ beforeEach(() => {
80
+ instance = createSanityInstance({})
81
+ })
82
+
83
+ afterEach(() => {
84
+ instance.dispose()
85
+ })
86
+
87
+ it('default call excludes all segments (all flags default-false)', () => {
88
+ expect(getOrganizationsCacheKey(instance)).toBe('organizations')
89
+ })
90
+
91
+ it('treats undefined and the matching default as the same key', () => {
92
+ expect(getOrganizationsCacheKey(instance)).toBe(
93
+ getOrganizationsCacheKey(instance, {
94
+ includeMembers: false,
95
+ includeFeatures: false,
96
+ includeImplicitMemberships: false,
97
+ }),
98
+ )
99
+ })
100
+
101
+ it('explicit includeMembers: true appends :members', () => {
102
+ expect(getOrganizationsCacheKey(instance, {includeMembers: true})).toBe('organizations:members')
103
+ })
104
+
105
+ it('explicit includeFeatures: true appends :features', () => {
106
+ expect(getOrganizationsCacheKey(instance, {includeFeatures: true})).toBe(
107
+ 'organizations:features',
108
+ )
109
+ })
110
+
111
+ it('explicit includeImplicitMemberships: true appends :implicit', () => {
112
+ expect(getOrganizationsCacheKey(instance, {includeImplicitMemberships: true})).toBe(
113
+ 'organizations:implicit',
114
+ )
115
+ })
116
+
117
+ it('combines all segments in order', () => {
118
+ expect(
119
+ getOrganizationsCacheKey(instance, {
120
+ includeMembers: true,
121
+ includeFeatures: true,
122
+ includeImplicitMemberships: true,
123
+ }),
124
+ ).toBe('organizations:members:features:implicit')
125
+ })
126
+
127
+ it('produces distinct keys for each meaningful option permutation', () => {
128
+ const keys = new Set([
129
+ getOrganizationsCacheKey(instance),
130
+ getOrganizationsCacheKey(instance, {includeMembers: true}),
131
+ getOrganizationsCacheKey(instance, {includeFeatures: true}),
132
+ getOrganizationsCacheKey(instance, {includeImplicitMemberships: true}),
133
+ getOrganizationsCacheKey(instance, {includeMembers: true, includeFeatures: true}),
134
+ getOrganizationsCacheKey(instance, {
135
+ includeMembers: true,
136
+ includeImplicitMemberships: true,
137
+ }),
138
+ getOrganizationsCacheKey(instance, {
139
+ includeFeatures: true,
140
+ includeImplicitMemberships: true,
141
+ }),
142
+ getOrganizationsCacheKey(instance, {
143
+ includeMembers: true,
144
+ includeFeatures: true,
145
+ includeImplicitMemberships: true,
146
+ }),
147
+ ])
148
+ expect(keys.size).toBe(8)
149
+ })
150
+ })
@@ -0,0 +1,107 @@
1
+ import {switchMap} from 'rxjs'
2
+
3
+ import {getClientState} from '../client/clientStore'
4
+ import {
5
+ type OrganizationBase,
6
+ type OrganizationMember,
7
+ type OrganizationOptions,
8
+ } from '../organization/organization'
9
+ import {type SanityInstance} from '../store/createSanityInstance'
10
+ import {defineFetcher} from '../store/fetcherStore'
11
+ import {buildQuery} from '../utils/buildQuery'
12
+
13
+ const API_VERSION = 'v2025-02-19'
14
+
15
+ /**
16
+ * The list shape returned from `/organizations`, with `members` and/or
17
+ * `features` conditionally included based on the query options used.
18
+ * @public
19
+ */
20
+ export type Organizations<
21
+ IncludeMembers extends boolean = false,
22
+ IncludeFeatures extends boolean = false,
23
+ > = (Pick<
24
+ OrganizationBase,
25
+ | 'id'
26
+ | 'name'
27
+ | 'slug'
28
+ | 'createdAt'
29
+ | 'updatedAt'
30
+ | 'defaultRoleName'
31
+ | 'dashboardStatus'
32
+ | 'aiFeaturesStatus'
33
+ > &
34
+ // `boolean extends T` is non-distributive — true only when T is the wide
35
+ // `boolean`, in which case the field is optional. Literal `true`/`false`
36
+ // fall through to the strict branch.
37
+ (boolean extends IncludeMembers
38
+ ? {members?: OrganizationMember[]}
39
+ : IncludeMembers extends true
40
+ ? {members: OrganizationMember[]}
41
+ : unknown) &
42
+ (boolean extends IncludeFeatures
43
+ ? {features?: string[]}
44
+ : IncludeFeatures extends true
45
+ ? {features: string[]}
46
+ : unknown))[]
47
+
48
+ /** @public */
49
+ export interface OrganizationsOptions<
50
+ IncludeMembers extends boolean = false,
51
+ IncludeFeatures extends boolean = false,
52
+ > extends Omit<OrganizationOptions<IncludeMembers, IncludeFeatures>, 'organizationId'> {
53
+ /**
54
+ * When `true`, includes organisations the user has access to via
55
+ * project-level grants, not just direct organisation memberships.
56
+ */
57
+ includeImplicitMemberships?: boolean
58
+ }
59
+
60
+ function normalizeOrganizationsOptions(options?: OrganizationsOptions<boolean, boolean>) {
61
+ return {
62
+ includeImplicitMemberships: options?.includeImplicitMemberships ?? false,
63
+ includeMembers: options?.includeMembers ?? false,
64
+ includeFeatures: options?.includeFeatures ?? false,
65
+ }
66
+ }
67
+
68
+ /** @internal */
69
+ export function getOrganizationsCacheKey(
70
+ _instance: SanityInstance,
71
+ options?: OrganizationsOptions<boolean, boolean>,
72
+ ): string {
73
+ const {includeMembers, includeFeatures, includeImplicitMemberships} =
74
+ normalizeOrganizationsOptions(options)
75
+ const membersKey = includeMembers ? ':members' : ''
76
+ const featuresKey = includeFeatures ? ':features' : ''
77
+ const implicitKey = includeImplicitMemberships ? ':implicit' : ''
78
+ return `organizations${membersKey}${featuresKey}${implicitKey}`
79
+ }
80
+
81
+ /**
82
+ * Fetcher for the current user's organizations (`GET /organizations`), on the
83
+ * shared fetcher cache.
84
+ *
85
+ * @internal
86
+ */
87
+ export const organizations = defineFetcher<
88
+ [options?: OrganizationsOptions<boolean, boolean>],
89
+ Organizations<boolean, boolean>
90
+ >({
91
+ name: 'organizations',
92
+ getKey: getOrganizationsCacheKey,
93
+ fetch: (instance) => (options) =>
94
+ getClientState(instance, {apiVersion: API_VERSION, scope: 'global'}).observable.pipe(
95
+ switchMap((client) =>
96
+ client.observable.request<Organizations<boolean, boolean>>({
97
+ uri: '/organizations',
98
+ query: buildQuery(normalizeOrganizationsOptions(options)),
99
+ tag: 'organizations.list',
100
+ }),
101
+ ),
102
+ ),
103
+ tags: (data) => [
104
+ {type: 'organization', id: 'LIST'},
105
+ ...data.map((org) => ({type: 'organization', id: org.id})),
106
+ ],
107
+ })
@@ -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$})
@@ -1,6 +1,6 @@
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 {delay, firstValueFrom, type Observable, of, Subject} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
4
4
 
5
5
  import {getTokenState} from '../auth/authStore'
6
6
  import {getClient} from '../client/clientStore'
@@ -9,7 +9,7 @@ import {type SanityUser} from '../users/types'
9
9
  import {getUserState} from '../users/usersStore'
10
10
  import {createBifurTransport} from './bifurTransport'
11
11
  import {getPresence} from './presenceStore'
12
- import {type PresenceLocation, type TransportEvent} from './types'
12
+ import {type PresenceLocation, type TransportEvent, type TransportMessage} from './types'
13
13
 
14
14
  vi.mock('../auth/authStore')
15
15
  vi.mock('../client/clientStore')
@@ -21,8 +21,8 @@ describe('presenceStore', () => {
21
21
  let mockClient: SanityClient
22
22
  let mockTokenState: Subject<string | null>
23
23
  let mockIncomingEvents: Subject<TransportEvent>
24
- let mockDispatchMessage: ReturnType<typeof vi.fn>
25
- let mockGetUserState: ReturnType<typeof vi.fn>
24
+ let mockDispatchMessage: Mock<(message: TransportMessage) => Observable<void>>
25
+ let mockGetUserState: Mock<typeof getUserState>
26
26
 
27
27
  const mockUser: SanityUser = {
28
28
  sanityUserId: 'u123',
@@ -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>()
@@ -69,7 +72,7 @@ describe('presenceStore', () => {
69
72
  mockGetUserState = vi.fn(() => of(mockUser))
70
73
  vi.mocked(getUserState).mockImplementation(mockGetUserState)
71
74
 
72
- instance = createSanityInstance()
75
+ instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
73
76
  })
74
77
 
75
78
  afterEach(() => {
@@ -77,9 +80,8 @@ describe('presenceStore', () => {
77
80
  })
78
81
 
79
82
  describe('getPresence', () => {
80
- const key = {resource: {projectId: 'test-project', dataset: 'test-dataset'}}
81
83
  it('creates bifur transport with correct parameters', () => {
82
- getPresence(instance, key)
84
+ getPresence(instance)
83
85
 
84
86
  expect(createBifurTransport).toHaveBeenCalledWith({
85
87
  client: mockClient,
@@ -89,18 +91,18 @@ describe('presenceStore', () => {
89
91
  })
90
92
 
91
93
  it('sends rollCall message on initialization', () => {
92
- getPresence(instance, key)
94
+ getPresence(instance)
93
95
 
94
96
  expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
95
97
  })
96
98
 
97
99
  it('returns empty array when no users present', () => {
98
- const source = getPresence(instance, key)
100
+ const source = getPresence(instance)
99
101
  expect(source.getCurrent()).toEqual([])
100
102
  })
101
103
 
102
104
  it('handles state events from other users', async () => {
103
- const source = getPresence(instance, key)
105
+ const source = getPresence(instance)
104
106
 
105
107
  // Subscribe to initialize the store
106
108
  const unsubscribe = source.subscribe(() => {})
@@ -137,7 +139,7 @@ describe('presenceStore', () => {
137
139
  })
138
140
 
139
141
  it('ignores events from own session', async () => {
140
- const source = getPresence(instance, key)
142
+ const source = getPresence(instance)
141
143
  const unsubscribe = source.subscribe(() => {})
142
144
 
143
145
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -159,7 +161,7 @@ describe('presenceStore', () => {
159
161
  })
160
162
 
161
163
  it('handles disconnect events', async () => {
162
- const source = getPresence(instance, key)
164
+ const source = getPresence(instance)
163
165
  const unsubscribe = source.subscribe(() => {})
164
166
 
165
167
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -191,7 +193,7 @@ describe('presenceStore', () => {
191
193
  })
192
194
 
193
195
  it('fetches user data for present users', async () => {
194
- const source = getPresence(instance, key)
196
+ const source = getPresence(instance)
195
197
  const unsubscribe = source.subscribe(() => {})
196
198
 
197
199
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -223,7 +225,7 @@ describe('presenceStore', () => {
223
225
  })
224
226
 
225
227
  it('handles presence events correctly', async () => {
226
- const source = getPresence(instance, key)
228
+ const source = getPresence(instance)
227
229
  const unsubscribe = source.subscribe(() => {})
228
230
 
229
231
  await firstValueFrom(of(null).pipe(delay(10)))
@@ -250,23 +252,92 @@ describe('presenceStore', () => {
250
252
 
251
253
  expect(() => {
252
254
  getPresence(instance, {resource: mediaLibraryResource})
253
- }).toThrow('Presence is not supported for media library resources')
255
+ }).toThrow('Presence is not supported for media library resources.')
254
256
  })
255
257
 
256
- it('should throw an error when initialized with a canvas resource', () => {
257
- const canvasResource = {canvasId: 'canvas123'}
258
+ it('should work with a dataset resource', () => {
259
+ const datasetResource = {projectId: 'test-project', dataset: 'test-dataset'}
258
260
 
259
261
  expect(() => {
260
- getPresence(instance, {resource: canvasResource})
261
- }).toThrow('Presence is not supported for canvas resources')
262
+ getPresence(instance, {resource: datasetResource})
263
+ }).not.toThrow()
262
264
  })
263
265
 
264
- it('should work with a dataset resource', () => {
265
- const datasetResource = {projectId: 'test-project', dataset: 'test-dataset'}
266
+ it('should work with a canvas resource', () => {
267
+ const canvasResource = {canvasId: 'canvas123'}
266
268
 
267
269
  expect(() => {
268
- getPresence(instance, {resource: datasetResource})
270
+ getPresence(instance, {resource: canvasResource})
269
271
  }).not.toThrow()
270
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
+ })
271
342
  })
272
343
  })