@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
@@ -1,6 +1,6 @@
1
1
  import {createClient, type SanityClient} from '@sanity/client'
2
2
  import {Subject} from 'rxjs'
3
- import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
5
  import {getAuthMethodState, getTokenState} from '../auth/authStore'
6
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
@@ -33,7 +33,10 @@ beforeEach(() => {
33
33
  vi.mocked(createClient).mockImplementation(
34
34
  (clientConfig) => ({config: () => clientConfig}) as SanityClient,
35
35
  )
36
- instance = createSanityInstance()
36
+ instance = createSanityInstance({
37
+ projectId: 'test-project',
38
+ dataset: 'test-dataset',
39
+ })
37
40
  })
38
41
 
39
42
  afterEach(() => {
@@ -42,6 +45,43 @@ afterEach(() => {
42
45
 
43
46
  describe('clientStore', () => {
44
47
  describe('getClient', () => {
48
+ it('should create a client with default configuration', () => {
49
+ const client = getClient(instance, {apiVersion: '2024-11-12'})
50
+
51
+ const defaultConfiguration = {
52
+ useCdn: false,
53
+ ignoreBrowserTokenWarning: true,
54
+ allowReconfigure: false,
55
+ requestTagPrefix: 'sanity.sdk',
56
+ projectId: 'test-project',
57
+ dataset: 'test-dataset',
58
+ token: 'initial-token',
59
+ }
60
+
61
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith({
62
+ ...defaultConfiguration,
63
+ apiVersion: '2024-11-12',
64
+ })
65
+ expect(client.config()).toEqual({
66
+ ...defaultConfiguration,
67
+ apiVersion: '2024-11-12',
68
+ })
69
+ })
70
+
71
+ it('should pass staging apiHost when __SANITY_STAGING__ is true and no explicit apiHost', () => {
72
+ vi.stubGlobal('__SANITY_STAGING__', true)
73
+
74
+ getClient(instance, {apiVersion: '2024-11-12'})
75
+
76
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
77
+ expect.objectContaining({
78
+ apiHost: 'https://api.sanity.work',
79
+ }),
80
+ )
81
+
82
+ vi.unstubAllGlobals()
83
+ })
84
+
45
85
  it('should throw when using disallowed configuration keys', () => {
46
86
  expect(() =>
47
87
  getClient(instance, {
@@ -150,8 +190,8 @@ describe('clientStore', () => {
150
190
  })
151
191
  })
152
192
 
153
- describe('source handling', () => {
154
- it('should create client when source is provided', () => {
193
+ describe('resource handling', () => {
194
+ it('should create resource when media library resource is provided and be projectless', () => {
155
195
  const client = getClient(instance, {
156
196
  apiVersion: '2024-11-12',
157
197
  resource: {mediaLibraryId: 'media-lib-123'},
@@ -159,8 +199,8 @@ describe('clientStore', () => {
159
199
 
160
200
  expect(vi.mocked(createClient)).toHaveBeenCalledWith(
161
201
  expect.objectContaining({
162
- 'apiVersion': '2024-11-12',
163
- '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
202
+ resource: {type: 'media-library', id: 'media-lib-123'},
203
+ apiVersion: '2024-11-12',
164
204
  }),
165
205
  )
166
206
  // Client should be projectless - no projectId/dataset in config
@@ -168,12 +208,12 @@ describe('clientStore', () => {
168
208
  expect(client.config()).not.toHaveProperty('dataset')
169
209
  expect(client.config()).toEqual(
170
210
  expect.objectContaining({
171
- '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
211
+ resource: {type: 'media-library', id: 'media-lib-123'},
172
212
  }),
173
213
  )
174
214
  })
175
215
 
176
- it('should create resource when canvas source is provided and be projectless', () => {
216
+ it('should create resource when canvas resource is provided and be projectless', () => {
177
217
  const client = getClient(instance, {
178
218
  apiVersion: '2024-11-12',
179
219
  resource: {canvasId: 'canvas-123'},
@@ -181,8 +221,8 @@ describe('clientStore', () => {
181
221
 
182
222
  expect(vi.mocked(createClient)).toHaveBeenCalledWith(
183
223
  expect.objectContaining({
184
- '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
185
- 'apiVersion': '2024-11-12',
224
+ resource: {type: 'canvas', id: 'canvas-123'},
225
+ apiVersion: '2024-11-12',
186
226
  }),
187
227
  )
188
228
  // Client should be projectless - no projectId/dataset in config
@@ -190,47 +230,32 @@ describe('clientStore', () => {
190
230
  expect(client.config()).not.toHaveProperty('dataset')
191
231
  expect(client.config()).toEqual(
192
232
  expect.objectContaining({
193
- '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
233
+ resource: {type: 'canvas', id: 'canvas-123'},
194
234
  }),
195
235
  )
196
236
  })
197
237
 
198
- // skipped until we migrate to using source for project and dataset
199
- it.skip('should create projectless client when source is provided, ignoring instance config', () => {
238
+ it('should transform dataset resource to project-based config for now', () => {
200
239
  const client = getClient(instance, {
201
240
  apiVersion: '2024-11-12',
202
241
  resource: {projectId: 'source-project', dataset: 'source-dataset'},
203
242
  })
204
243
 
205
- // Client should be projectless - source takes precedence, instance config is ignored
206
- expect(client.config()).not.toHaveProperty('projectId')
207
- expect(client.config()).not.toHaveProperty('dataset')
208
- expect(client.config()).toEqual(
244
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
209
245
  expect.objectContaining({
210
- '~experimental_resource': {type: 'dataset', id: 'source-project.source-dataset'},
246
+ projectId: 'source-project',
247
+ dataset: 'source-dataset',
211
248
  }),
212
249
  )
213
- })
214
-
215
- it('should warn when both source and explicit projectId/dataset are provided', () => {
216
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
217
- const client = getClient(instance, {
218
- apiVersion: '2024-11-12',
219
- resource: {mediaLibraryId: 'media-lib-123'},
220
- projectId: 'explicit-project',
221
- dataset: 'explicit-dataset',
222
- })
223
-
224
- expect(consoleSpy).toHaveBeenCalledWith(
225
- 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
250
+ expect(client.config()).toEqual(
251
+ expect.objectContaining({
252
+ projectId: 'source-project',
253
+ dataset: 'source-dataset',
254
+ }),
226
255
  )
227
- // Client should still be projectless despite explicit projectId/dataset
228
- expect(client.config()).not.toHaveProperty('projectId')
229
- expect(client.config()).not.toHaveProperty('dataset')
230
- consoleSpy.mockRestore()
231
256
  })
232
257
 
233
- it('should create different clients for different sources', () => {
258
+ it('should create different clients for different resources', () => {
234
259
  const client1 = getClient(instance, {
235
260
  apiVersion: '2024-11-12',
236
261
  resource: {projectId: 'source-project', dataset: 'source-dataset'},
@@ -250,7 +275,7 @@ describe('clientStore', () => {
250
275
  expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
251
276
  })
252
277
 
253
- it('should reuse clients with identical source configurations', () => {
278
+ it('should reuse clients with identical resource configurations', () => {
254
279
  const client1 = getClient(instance, {
255
280
  apiVersion: '2024-11-12',
256
281
  resource: {projectId: 'source-project', dataset: 'source-dataset'},
@@ -1,10 +1,4 @@
1
- import {
2
- type ClientConfig,
3
- type ClientPerspective,
4
- createClient,
5
- type SanityClient,
6
- } from '@sanity/client'
7
- import {pick} from 'lodash-es'
1
+ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client'
8
2
 
9
3
  import {getAuthMethodState, getTokenState} from '../auth/authStore'
10
4
  import {
@@ -16,6 +10,8 @@ import {
16
10
  import {bindActionGlobally} from '../store/createActionBinder'
17
11
  import {createStateSourceAction} from '../store/createStateSourceAction'
18
12
  import {defineStore, type StoreContext} from '../store/defineStore'
13
+ import {getStagingApiHost} from '../utils/getStagingApiHost'
14
+ import {pickProperties} from '../utils/object'
19
15
 
20
16
  const DEFAULT_API_VERSION = '2024-11-12'
21
17
  const DEFAULT_REQUEST_TAG_PREFIX = 'sanity.sdk'
@@ -35,31 +31,30 @@ type AllowedClientConfigKey =
35
31
  | 'useProjectHostname'
36
32
 
37
33
  const allowedKeys = Object.keys({
38
- 'apiHost': null,
39
- 'useCdn': null,
40
- 'token': null,
41
- 'perspective': null,
42
- 'proxy': null,
43
- 'withCredentials': null,
44
- 'timeout': null,
45
- 'maxRetries': null,
46
- 'dataset': null,
47
- 'projectId': null,
48
- 'scope': null,
49
- 'apiVersion': null,
50
- 'requestTagPrefix': null,
51
- 'useProjectHostname': null,
52
- '~experimental_resource': null,
53
- 'resource': null,
34
+ apiHost: null,
35
+ useCdn: null,
36
+ token: null,
37
+ perspective: null,
38
+ proxy: null,
39
+ withCredentials: null,
40
+ timeout: null,
41
+ maxRetries: null,
42
+ dataset: null,
43
+ projectId: null,
44
+ scope: null,
45
+ apiVersion: null,
46
+ requestTagPrefix: null,
47
+ useProjectHostname: null,
48
+ resource: null,
54
49
  } satisfies Record<keyof ClientOptions, null>) as (keyof ClientOptions)[]
55
50
 
56
- const DEFAULT_CLIENT_CONFIG = {
51
+ const DEFAULT_CLIENT_CONFIG: ClientConfig = {
57
52
  apiVersion: DEFAULT_API_VERSION,
58
53
  useCdn: false,
59
54
  ignoreBrowserTokenWarning: true,
60
55
  allowReconfigure: false,
61
56
  requestTagPrefix: DEFAULT_REQUEST_TAG_PREFIX,
62
- } satisfies ClientConfig
57
+ }
63
58
 
64
59
  /**
65
60
  * States tracked by the client store
@@ -71,11 +66,6 @@ export interface ClientStoreState {
71
66
  authMethod?: 'localstorage' | 'cookie'
72
67
  }
73
68
 
74
- interface ClientResource {
75
- type: 'dataset' | 'media-library' | 'canvas'
76
- id: string
77
- }
78
-
79
69
  /**
80
70
  * Options used when retrieving a client instance from the client store.
81
71
  *
@@ -92,34 +82,23 @@ interface ClientResource {
92
82
  *
93
83
  * @public
94
84
  */
95
- export interface ClientOptions extends Omit<
96
- Pick<ClientConfig, AllowedClientConfigKey>,
97
- 'resource'
98
- > {
99
- /**
100
- * Narrows the inherited `perspective` to exclude stackable perspectives,
101
- * which are not supported by the SDK.
102
- */
103
- 'perspective'?: Exclude<ClientPerspective, readonly unknown[]>
85
+ export interface ClientOptions extends Pick<ClientConfig, AllowedClientConfigKey> {
104
86
  /**
105
87
  * An optional flag to choose between the default client (typically project-level)
106
88
  * and the global client ('global'). When set to `'global'`, the global client
107
89
  * is used.
108
90
  */
109
- 'scope'?: 'default' | 'global'
91
+ scope?: 'default' | 'global'
110
92
  /**
111
93
  * A required string indicating the API version for the client.
112
94
  */
113
- 'apiVersion': string
114
- /**
115
- * @internal
116
- */
117
- '~experimental_resource'?: ClientConfig['~experimental_resource']
95
+ apiVersion: string
118
96
 
119
97
  /**
120
98
  * @internal
99
+ * The SDK resource to use for the client -- this will get transformed into a ClientConfig resource.
121
100
  */
122
- 'resource'?: DocumentResource
101
+ resource?: DocumentResource
123
102
  }
124
103
 
125
104
  const clientStore = defineStore<ClientStoreState>({
@@ -156,7 +135,13 @@ const listenToAuthMethod = ({instance, state}: StoreContext<ClientStoreState>) =
156
135
  })
157
136
  }
158
137
 
159
- const getClientConfigKey = (options: ClientOptions) => JSON.stringify(pick(options, ...allowedKeys))
138
+ type ClientInstanceCacheKeyInput = ClientConfig &
139
+ Partial<Pick<ClientOptions, 'scope'>> & {
140
+ apiVersion: string
141
+ }
142
+
143
+ const getClientConfigKey = (options: ClientInstanceCacheKeyInput) =>
144
+ JSON.stringify(pickProperties(options, allowedKeys))
160
145
 
161
146
  /**
162
147
  * Retrieves a Sanity client instance configured with the provided options.
@@ -193,10 +178,10 @@ export const getClient = bindActionGlobally(
193
178
 
194
179
  const tokenFromState = state.get().token
195
180
  const {clients, authMethod} = state.get()
181
+ let projectId = options.projectId ?? instance.config.projectId
182
+ let dataset = options.dataset ?? instance.config.dataset
196
183
 
197
- let resource: ClientResource | undefined
198
- let projectId: string | undefined = options.projectId
199
- let dataset: string | undefined = options.dataset
184
+ let resource: ClientConfig['resource'] | undefined
200
185
 
201
186
  if (options.resource) {
202
187
  if (isMediaLibraryResource(options.resource)) {
@@ -204,43 +189,31 @@ export const getClient = bindActionGlobally(
204
189
  } else if (isCanvasResource(options.resource)) {
205
190
  resource = {type: 'canvas', id: options.resource.canvasId}
206
191
  } else if (isDatasetResource(options.resource)) {
192
+ // use project-based routes for datasets to avoid existing CORS and Studio auth cookie issues
207
193
  projectId = options.resource.projectId
208
194
  dataset = options.resource.dataset
209
195
  }
210
- // temporarily excluding dataset resource as a resource for now. Many of the global API endpoints require vX api version.
211
- // When ready, remove the above check and uncomment the below check.
212
- // } else if (isDatasetResource(options.resource)) {
213
- // resource = {type: 'dataset', id: `${options.resource.projectId}.${options.resource.dataset}`}
214
196
  }
215
197
 
216
- const apiHost = options.apiHost ?? instance.config.auth?.apiHost
198
+ const apiHost = options.apiHost ?? instance.config.auth?.apiHost ?? getStagingApiHost()
217
199
 
218
- const effectiveOptions = {
200
+ const effectiveOptions: ClientConfig & {apiVersion: string} = {
219
201
  ...DEFAULT_CLIENT_CONFIG,
220
202
  ...((options.scope === 'global' || !projectId || resource) && {useProjectHostname: false}),
221
203
  token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
222
204
  ...options,
223
205
  ...(projectId && {projectId}),
224
206
  ...(dataset && {dataset}),
207
+ ...(resource ? {resource} : {resource: undefined}),
225
208
  ...(apiHost && {apiHost}),
226
- ...(resource && {'~experimental_resource': resource}),
227
- } as ClientOptions
209
+ }
228
210
 
229
- // When a MediaLibrary or Canvas resource is provided, don't use projectId/dataset - the client should be "projectless"
211
+ // When a resource is provided, don't use projectId/dataset - the client should be "projectless"
230
212
  // The client code itself will ignore the non-resource config, so we do this to prevent confusing the user.
231
213
  // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
232
- // Note: DatasetResource is handled differently - it extracts projectId/dataset from the resource and uses those
233
- if (options.resource) {
234
- if (options.projectId || options.dataset) {
235
- // eslint-disable-next-line no-console
236
- console.warn(
237
- 'Both resource and explicit projectId/dataset are provided. The resource will be used and projectId/dataset will be ignored.',
238
- )
239
- }
240
- if (resource) {
241
- delete effectiveOptions.projectId
242
- delete effectiveOptions.dataset
243
- }
214
+ if (resource) {
215
+ delete effectiveOptions.projectId
216
+ delete effectiveOptions.dataset
244
217
  }
245
218
 
246
219
  if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') {
@@ -252,14 +225,11 @@ export const getClient = bindActionGlobally(
252
225
  delete effectiveOptions.withCredentials
253
226
  }
254
227
 
255
- // Don't pass our DocumentResource to createClient - we've already derived projectId/dataset or ~experimental_resource
256
- const {resource: _omitResource, ...clientConfig} = effectiveOptions
257
-
258
228
  const key = getClientConfigKey(effectiveOptions)
259
229
 
260
230
  if (clients[key]) return clients[key]
261
231
 
262
- const client = createClient(clientConfig as ClientConfig)
232
+ const client = createClient(effectiveOptions)
263
233
  state.set('addClient', (prev) => ({clients: {...prev.clients, [key]: client}}))
264
234
 
265
235
  return client
@@ -0,0 +1,151 @@
1
+ import {
2
+ ConnectionFailedError,
3
+ CorsOriginError,
4
+ DisconnectError,
5
+ type LiveEvent,
6
+ type LiveEventMessage,
7
+ type SanityClient,
8
+ } from '@sanity/client'
9
+ import {of, Subject} from 'rxjs'
10
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
13
+ import {type StateSource} from '../store/createStateSourceAction'
14
+ import {getClientState} from './clientStore'
15
+ import {LIVE_EVENTS_RETRY_DELAY, observeLiveEvents} from './liveEvents'
16
+
17
+ vi.mock('./clientStore', () => ({getClientState: vi.fn()}))
18
+
19
+ describe('observeLiveEvents', () => {
20
+ let instance: SanityInstance
21
+ let liveEventSubjects: Subject<LiveEvent>[]
22
+ let events: ReturnType<typeof vi.fn>
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks()
26
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
27
+
28
+ liveEventSubjects = []
29
+ events = vi.fn().mockImplementation(() => {
30
+ const subject = new Subject<LiveEvent>()
31
+ liveEventSubjects.push(subject)
32
+ return subject
33
+ })
34
+
35
+ vi.mocked(getClientState).mockReturnValue({
36
+ observable: of({
37
+ config: vi.fn().mockReturnValue({token: 'token'}),
38
+ live: {events},
39
+ } as unknown as SanityClient),
40
+ } as StateSource<SanityClient>)
41
+ })
42
+
43
+ afterEach(() => {
44
+ instance.dispose()
45
+ })
46
+
47
+ it('emits only message events', () => {
48
+ const messages: LiveEventMessage[] = []
49
+ const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe((message) =>
50
+ messages.push(message),
51
+ )
52
+
53
+ liveEventSubjects[0].next({type: 'welcome'} as LiveEvent)
54
+ liveEventSubjects[0].next({type: 'message', id: 'e1', tags: ['s1:a']} as LiveEvent)
55
+
56
+ expect(messages).toEqual([{type: 'message', id: 'e1', tags: ['s1:a']}])
57
+ subscription.unsubscribe()
58
+ })
59
+
60
+ it('reports CORS errors through onCorsError and completes without erroring', () => {
61
+ const onCorsError = vi.fn()
62
+ const error = vi.fn()
63
+ const complete = vi.fn()
64
+ observeLiveEvents(instance, {onCorsError}).subscribe({error, complete})
65
+
66
+ const corsError = new CorsOriginError({projectId: 'test'})
67
+ liveEventSubjects[0].error(corsError)
68
+
69
+ expect(onCorsError).toHaveBeenCalledWith(corsError)
70
+ expect(error).not.toHaveBeenCalled()
71
+ expect(complete).toHaveBeenCalled()
72
+ })
73
+
74
+ it('completes without retrying on DisconnectError', async () => {
75
+ vi.useFakeTimers()
76
+ try {
77
+ const error = vi.fn()
78
+ const complete = vi.fn()
79
+ observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
80
+
81
+ liveEventSubjects[0].error(new DisconnectError('Server disconnected client'))
82
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
83
+
84
+ expect(error).not.toHaveBeenCalled()
85
+ expect(complete).toHaveBeenCalled()
86
+ expect(events).toHaveBeenCalledTimes(1)
87
+ } finally {
88
+ vi.useRealTimers()
89
+ }
90
+ })
91
+
92
+ it('completes without retrying on a 4xx connection rejection', async () => {
93
+ vi.useFakeTimers()
94
+ try {
95
+ const error = vi.fn()
96
+ const complete = vi.fn()
97
+ observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
98
+
99
+ // The server rejected the connection with a 401 (e.g. expired token) —
100
+ // it will keep rejecting, so retrying would reconnect once per second
101
+ // forever
102
+ liveEventSubjects[0].error(
103
+ new ConnectionFailedError('EventSource connection failed', {status: 401}),
104
+ )
105
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
106
+
107
+ expect(error).not.toHaveBeenCalled()
108
+ expect(complete).toHaveBeenCalled()
109
+ expect(events).toHaveBeenCalledTimes(1)
110
+ } finally {
111
+ vi.useRealTimers()
112
+ }
113
+ })
114
+
115
+ it('retries a connection failure without a status (transient network failure)', async () => {
116
+ vi.useFakeTimers()
117
+ try {
118
+ const error = vi.fn()
119
+ const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
120
+
121
+ // No status means the failure could be transient (native EventSource
122
+ // exposes no status) — reconnecting is correct here
123
+ liveEventSubjects[0].error(new ConnectionFailedError('EventSource connection failed'))
124
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
125
+
126
+ expect(events).toHaveBeenCalledTimes(2)
127
+ expect(error).not.toHaveBeenCalled()
128
+ subscription.unsubscribe()
129
+ } finally {
130
+ vi.useRealTimers()
131
+ }
132
+ })
133
+
134
+ it('retries the connection after server-initiated errors', async () => {
135
+ vi.useFakeTimers()
136
+ try {
137
+ const error = vi.fn()
138
+ const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
139
+ expect(events).toHaveBeenCalledTimes(1)
140
+
141
+ liveEventSubjects[0].error(new Error('ChannelError'))
142
+ await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
143
+
144
+ expect(events).toHaveBeenCalledTimes(2)
145
+ expect(error).not.toHaveBeenCalled()
146
+ subscription.unsubscribe()
147
+ } finally {
148
+ vi.useRealTimers()
149
+ }
150
+ })
151
+ })
@@ -0,0 +1,107 @@
1
+ import {
2
+ ConnectionFailedError,
3
+ CorsOriginError,
4
+ DisconnectError,
5
+ type LiveEventMessage,
6
+ } from '@sanity/client'
7
+ import {catchError, defer, EMPTY, filter, type Observable, retry, switchMap} from 'rxjs'
8
+
9
+ import {type DocumentResource} from '../config/sanityConfig'
10
+ import {type SanityInstance} from '../store/createSanityInstance'
11
+ import {getClientState} from './clientStore'
12
+
13
+ /**
14
+ * All live-events subscriptions must use identical connection options
15
+ * (API version, request tag, `includeDrafts`): `@sanity/client` caches the
16
+ * underlying EventSource per URL + options, so divergent options would
17
+ * silently split what should be a single shared connection.
18
+ */
19
+ const LIVE_EVENTS_API_VERSION = 'v2025-05-06'
20
+
21
+ /**
22
+ * Delay before re-establishing the live events connection after a
23
+ * server-initiated error (e.g. ChannelError, MessageError). Live errors are
24
+ * retried rather than surfaced because an errored live connection would
25
+ * otherwise poison the consuming store. Plain connection drops never reach
26
+ * this — they are reconnected inside `@sanity/client`.
27
+ *
28
+ * @internal
29
+ */
30
+ export const LIVE_EVENTS_RETRY_DELAY = 1000
31
+
32
+ /**
33
+ * Options for {@link observeLiveEvents}.
34
+ * @internal
35
+ */
36
+ export interface ObserveLiveEventsOptions {
37
+ /** Resource to scope the underlying client and live connection to. */
38
+ resource?: DocumentResource
39
+ /**
40
+ * Called when the connection fails due to a CORS misconfiguration. The
41
+ * error is swallowed (the stream never errors; it idles until a new client
42
+ * is emitted) so consumers can surface it as store state instead of a
43
+ * stream error.
44
+ */
45
+ onCorsError: (error: unknown) => void
46
+ }
47
+
48
+ /**
49
+ * Emits Live Content API messages for a dataset so consumers can invalidate
50
+ * fetched data via sync tags.
51
+ *
52
+ * All consumers observe the same underlying EventSource connection:
53
+ * `@sanity/client` caches the live-events stream per URL and connection
54
+ * options, and every subscription made through this function uses the same
55
+ * API version and request tag.
56
+ *
57
+ * @internal
58
+ */
59
+ export function observeLiveEvents(
60
+ instance: SanityInstance,
61
+ {resource, onCorsError}: ObserveLiveEventsOptions,
62
+ ): Observable<LiveEventMessage> {
63
+ return getClientState(instance, {
64
+ apiVersion: LIVE_EVENTS_API_VERSION,
65
+ resource,
66
+ }).observable.pipe(
67
+ switchMap((client) =>
68
+ defer(() =>
69
+ client.live.events({includeDrafts: !!client.config().token, tag: 'live-events'}),
70
+ ).pipe(
71
+ catchError((error) => {
72
+ if (error instanceof CorsOriginError) {
73
+ // Swallow CORS errors without bubbling up so that they can be
74
+ // handled by the consumer (e.g. shown via the Cors Error component)
75
+ onCorsError(error)
76
+ return EMPTY
77
+ }
78
+ if (error instanceof DisconnectError) {
79
+ // The server explicitly told this client to stop reconnecting —
80
+ // end live updates without erroring (consumers keep serving data,
81
+ // they just stop receiving live invalidation)
82
+ return EMPTY
83
+ }
84
+ if (
85
+ error instanceof ConnectionFailedError &&
86
+ typeof error.status === 'number' &&
87
+ error.status >= 400 &&
88
+ error.status < 500
89
+ ) {
90
+ // The server rejected the connection with a 4xx (e.g. a 401 from
91
+ // an expired token) — it will keep rejecting, so retrying would
92
+ // reconnect once per second forever. End live updates like a
93
+ // DisconnectError. A ConnectionFailedError without a status stays
94
+ // retryable: it may be a transient network failure.
95
+ return EMPTY
96
+ }
97
+ throw error
98
+ }),
99
+ // Server-initiated live errors (e.g. ChannelError, MessageError) are
100
+ // retried, not surfaced. Dropped connections are already reconnected
101
+ // inside @sanity/client.
102
+ retry({delay: LIVE_EVENTS_RETRY_DELAY}),
103
+ ),
104
+ ),
105
+ filter((e): e is LiveEventMessage => e.type === 'message'),
106
+ )
107
+ }
@@ -7,7 +7,10 @@ import {type ComlinkControllerState} from '../comlinkControllerStore'
7
7
  import {destroyController} from './destroyController'
8
8
 
9
9
  describe('destroyController', () => {
10
- const instance = createSanityInstance()
10
+ const instance = createSanityInstance({
11
+ projectId: 'test-project-id',
12
+ dataset: 'test-dataset',
13
+ })
11
14
  let state: ReturnType<typeof createStoreState<ComlinkControllerState>>
12
15
  let mockController: {destroy: ReturnType<typeof vi.fn>}
13
16
 
@@ -13,7 +13,10 @@ const channelConfig = {
13
13
  }
14
14
 
15
15
  describe('getOrCreateChannel', () => {
16
- const instance = createSanityInstance()
16
+ const instance = createSanityInstance({
17
+ projectId: 'test-project-id',
18
+ dataset: 'test-dataset',
19
+ })
17
20
  let state: ReturnType<typeof createStoreState<ComlinkControllerState>>
18
21
  let mockController: Partial<Controller>
19
22
  let mockChannel: Partial<ChannelInstance<FrameMessage, WindowMessage>>