@sanity/sdk 2.3.1 → 2.5.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 (55) hide show
  1. package/dist/index.d.ts +173 -105
  2. package/dist/index.js +354 -122
  3. package/dist/index.js.map +1 -1
  4. package/package.json +12 -11
  5. package/src/_exports/index.ts +30 -0
  6. package/src/agent/agentActions.test.ts +81 -0
  7. package/src/agent/agentActions.ts +139 -0
  8. package/src/auth/authStore.test.ts +13 -13
  9. package/src/auth/refreshStampedToken.test.ts +16 -16
  10. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +6 -6
  11. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +4 -4
  12. package/src/auth/utils.ts +36 -0
  13. package/src/client/clientStore.test.ts +151 -0
  14. package/src/client/clientStore.ts +39 -1
  15. package/src/comlink/controller/actions/destroyController.test.ts +2 -2
  16. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +6 -6
  17. package/src/comlink/controller/actions/getOrCreateController.test.ts +5 -5
  18. package/src/comlink/controller/actions/getOrCreateController.ts +1 -1
  19. package/src/comlink/controller/actions/releaseChannel.test.ts +3 -2
  20. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -4
  21. package/src/comlink/node/actions/getOrCreateNode.test.ts +7 -7
  22. package/src/comlink/node/actions/releaseNode.test.ts +2 -2
  23. package/src/comlink/node/comlinkNodeStore.test.ts +4 -3
  24. package/src/config/sanityConfig.ts +49 -3
  25. package/src/document/actions.test.ts +34 -0
  26. package/src/document/actions.ts +31 -7
  27. package/src/document/applyDocumentActions.test.ts +9 -6
  28. package/src/document/applyDocumentActions.ts +9 -49
  29. package/src/document/documentStore.test.ts +148 -107
  30. package/src/document/documentStore.ts +40 -10
  31. package/src/document/permissions.test.ts +9 -9
  32. package/src/document/permissions.ts +17 -7
  33. package/src/document/processActions.test.ts +345 -0
  34. package/src/document/processActions.ts +185 -2
  35. package/src/document/reducers.ts +13 -6
  36. package/src/presence/presenceStore.ts +13 -7
  37. package/src/preview/previewStore.test.ts +10 -2
  38. package/src/preview/previewStore.ts +2 -1
  39. package/src/preview/subscribeToStateAndFetchBatches.test.ts +8 -5
  40. package/src/preview/subscribeToStateAndFetchBatches.ts +9 -3
  41. package/src/projection/projectionStore.test.ts +18 -2
  42. package/src/projection/projectionStore.ts +2 -1
  43. package/src/projection/subscribeToStateAndFetchBatches.test.ts +6 -5
  44. package/src/projection/subscribeToStateAndFetchBatches.ts +9 -3
  45. package/src/query/queryStore.ts +7 -4
  46. package/src/releases/getPerspectiveState.ts +2 -2
  47. package/src/releases/releasesStore.ts +10 -4
  48. package/src/store/createActionBinder.test.ts +8 -6
  49. package/src/store/createActionBinder.ts +50 -14
  50. package/src/store/createStateSourceAction.test.ts +12 -11
  51. package/src/store/createStateSourceAction.ts +6 -6
  52. package/src/store/createStoreInstance.test.ts +29 -16
  53. package/src/store/createStoreInstance.ts +6 -5
  54. package/src/store/defineStore.test.ts +1 -1
  55. package/src/store/defineStore.ts +12 -7
package/src/auth/utils.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {type ClientError} from '@sanity/client'
1
2
  import {EMPTY, fromEvent, Observable} from 'rxjs'
2
3
 
3
4
  import {AUTH_CODE_PARAM, DEFAULT_BASE} from './authConstants'
@@ -134,3 +135,38 @@ export function getCleanedUrl(locationUrl: string): string {
134
135
  loc.searchParams.delete('url')
135
136
  return loc.toString()
136
137
  }
138
+
139
+ // -----------------------------------------------------------------------------
140
+ // ClientError helpers (shared)
141
+ // -----------------------------------------------------------------------------
142
+
143
+ /** @internal */
144
+ export type ApiErrorBody = {
145
+ error?: {type?: string; description?: string}
146
+ type?: string
147
+ description?: string
148
+ message?: string
149
+ }
150
+
151
+ /** @internal Extracts the structured API error body from a ClientError, if present. */
152
+ export function getClientErrorApiBody(error: ClientError): ApiErrorBody | undefined {
153
+ const body: unknown = (error as ClientError).response?.body
154
+ return body && typeof body === 'object' ? (body as ApiErrorBody) : undefined
155
+ }
156
+
157
+ /** @internal Returns the error type string from an API error body, if available. */
158
+ export function getClientErrorApiType(error: ClientError): string | undefined {
159
+ const body = getClientErrorApiBody(error)
160
+ return body?.error?.type ?? body?.type
161
+ }
162
+
163
+ /** @internal Returns the error description string from an API error body, if available. */
164
+ export function getClientErrorApiDescription(error: ClientError): string | undefined {
165
+ const body = getClientErrorApiBody(error)
166
+ return body?.error?.description ?? body?.description
167
+ }
168
+
169
+ /** @internal True if the error represents a projectUserNotFoundError. */
170
+ export function isProjectUserNotFoundClientError(error: ClientError): boolean {
171
+ return getClientErrorApiType(error) === 'projectUserNotFoundError'
172
+ }
@@ -3,6 +3,7 @@ import {Subject} from 'rxjs'
3
3
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
5
  import {getAuthMethodState, getTokenState} from '../auth/authStore'
6
+ import {canvasSource, datasetSource, mediaLibrarySource} from '../config/sanityConfig'
6
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
8
  import {getClient, getClientState} from './clientStore'
8
9
 
@@ -75,6 +76,20 @@ describe('clientStore', () => {
75
76
  ).toThrowError(/unsupported properties: illegalKey/)
76
77
  })
77
78
 
79
+ it('should throw a helpful error when called without options', () => {
80
+ expect(() =>
81
+ // @ts-expect-error Testing missing options
82
+ getClient(instance, undefined),
83
+ ).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
84
+ })
85
+
86
+ it('should throw a helpful error when called with null options', () => {
87
+ expect(() =>
88
+ // @ts-expect-error Testing null options
89
+ getClient(instance, null),
90
+ ).toThrowError(/requires a configuration object with at least an "apiVersion" property/)
91
+ })
92
+
78
93
  it('should reuse clients with identical configurations', () => {
79
94
  const options = {apiVersion: '2024-11-12', useCdn: true}
80
95
  const client1 = getClient(instance, options)
@@ -158,4 +173,140 @@ describe('clientStore', () => {
158
173
  subscription.unsubscribe()
159
174
  })
160
175
  })
176
+
177
+ describe('source handling', () => {
178
+ it('should create client when source is provided', () => {
179
+ const source = datasetSource('source-project', 'source-dataset')
180
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
181
+
182
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
183
+ expect.objectContaining({
184
+ apiVersion: '2024-11-12',
185
+ source: expect.objectContaining({
186
+ __sanity_internal_sourceId: {
187
+ projectId: 'source-project',
188
+ dataset: 'source-dataset',
189
+ },
190
+ }),
191
+ }),
192
+ )
193
+ // Client should be projectless - no projectId/dataset in config
194
+ expect(client.config()).not.toHaveProperty('projectId')
195
+ expect(client.config()).not.toHaveProperty('dataset')
196
+ expect(client.config()).toEqual(
197
+ expect.objectContaining({
198
+ source: expect.objectContaining({
199
+ __sanity_internal_sourceId: {
200
+ projectId: 'source-project',
201
+ dataset: 'source-dataset',
202
+ },
203
+ }),
204
+ }),
205
+ )
206
+ })
207
+
208
+ it('should create resource when source has array sourceId and be projectless', () => {
209
+ const source = mediaLibrarySource('media-lib-123')
210
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
211
+
212
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
213
+ expect.objectContaining({
214
+ '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
215
+ 'apiVersion': '2024-11-12',
216
+ }),
217
+ )
218
+ // Client should be projectless - no projectId/dataset in config
219
+ expect(client.config()).not.toHaveProperty('projectId')
220
+ expect(client.config()).not.toHaveProperty('dataset')
221
+ expect(client.config()).toEqual(
222
+ expect.objectContaining({
223
+ '~experimental_resource': {type: 'media-library', id: 'media-lib-123'},
224
+ }),
225
+ )
226
+ })
227
+
228
+ it('should create resource when canvas source is provided and be projectless', () => {
229
+ const source = canvasSource('canvas-123')
230
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
231
+
232
+ expect(vi.mocked(createClient)).toHaveBeenCalledWith(
233
+ expect.objectContaining({
234
+ '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
235
+ 'apiVersion': '2024-11-12',
236
+ }),
237
+ )
238
+ // Client should be projectless - no projectId/dataset in config
239
+ expect(client.config()).not.toHaveProperty('projectId')
240
+ expect(client.config()).not.toHaveProperty('dataset')
241
+ expect(client.config()).toEqual(
242
+ expect.objectContaining({
243
+ '~experimental_resource': {type: 'canvas', id: 'canvas-123'},
244
+ }),
245
+ )
246
+ })
247
+
248
+ it('should create projectless client when source is provided, ignoring instance config', () => {
249
+ const source = datasetSource('source-project', 'source-dataset')
250
+ const client = getClient(instance, {apiVersion: '2024-11-12', source})
251
+
252
+ // Client should be projectless - source takes precedence, instance config is ignored
253
+ expect(client.config()).not.toHaveProperty('projectId')
254
+ expect(client.config()).not.toHaveProperty('dataset')
255
+ expect(client.config()).toEqual(
256
+ expect.objectContaining({
257
+ source: expect.objectContaining({
258
+ __sanity_internal_sourceId: {
259
+ projectId: 'source-project',
260
+ dataset: 'source-dataset',
261
+ },
262
+ }),
263
+ }),
264
+ )
265
+ })
266
+
267
+ it('should warn when both source and explicit projectId/dataset are provided', () => {
268
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
269
+ const source = datasetSource('source-project', 'source-dataset')
270
+ const client = getClient(instance, {
271
+ apiVersion: '2024-11-12',
272
+ source,
273
+ projectId: 'explicit-project',
274
+ dataset: 'explicit-dataset',
275
+ })
276
+
277
+ expect(consoleSpy).toHaveBeenCalledWith(
278
+ 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
279
+ )
280
+ // Client should still be projectless despite explicit projectId/dataset
281
+ expect(client.config()).not.toHaveProperty('projectId')
282
+ expect(client.config()).not.toHaveProperty('dataset')
283
+ consoleSpy.mockRestore()
284
+ })
285
+
286
+ it('should create different clients for different sources', () => {
287
+ const source1 = datasetSource('project-1', 'dataset-1')
288
+ const source2 = datasetSource('project-2', 'dataset-2')
289
+ const source3 = mediaLibrarySource('media-lib-1')
290
+
291
+ const client1 = getClient(instance, {apiVersion: '2024-11-12', source: source1})
292
+ const client2 = getClient(instance, {apiVersion: '2024-11-12', source: source2})
293
+ const client3 = getClient(instance, {apiVersion: '2024-11-12', source: source3})
294
+
295
+ expect(client1).not.toBe(client2)
296
+ expect(client2).not.toBe(client3)
297
+ expect(client1).not.toBe(client3)
298
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(3)
299
+ })
300
+
301
+ it('should reuse clients with identical source configurations', () => {
302
+ const source = datasetSource('same-project', 'same-dataset')
303
+ const options = {apiVersion: '2024-11-12', source}
304
+
305
+ const client1 = getClient(instance, options)
306
+ const client2 = getClient(instance, options)
307
+
308
+ expect(client1).toBe(client2)
309
+ expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1)
310
+ })
311
+ })
161
312
  })
@@ -2,6 +2,7 @@ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client
2
2
  import {pick} from 'lodash-es'
3
3
 
4
4
  import {getAuthMethodState, getTokenState} from '../auth/authStore'
5
+ import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig'
5
6
  import {bindActionGlobally} from '../store/createActionBinder'
6
7
  import {createStateSourceAction} from '../store/createStateSourceAction'
7
8
  import {defineStore, type StoreContext} from '../store/defineStore'
@@ -39,6 +40,7 @@ const allowedKeys = Object.keys({
39
40
  'requestTagPrefix': null,
40
41
  'useProjectHostname': null,
41
42
  '~experimental_resource': null,
43
+ 'source': null,
42
44
  } satisfies Record<keyof ClientOptions, null>) as (keyof ClientOptions)[]
43
45
 
44
46
  const DEFAULT_CLIENT_CONFIG: ClientConfig = {
@@ -90,6 +92,11 @@ export interface ClientOptions extends Pick<ClientConfig, AllowedClientConfigKey
90
92
  * @internal
91
93
  */
92
94
  '~experimental_resource'?: ClientConfig['~experimental_resource']
95
+
96
+ /**
97
+ * @internal
98
+ */
99
+ 'source'?: DocumentSource
93
100
  }
94
101
 
95
102
  const clientStore = defineStore<ClientStoreState>({
@@ -142,6 +149,13 @@ const getClientConfigKey = (options: ClientOptions) => JSON.stringify(pick(optio
142
149
  export const getClient = bindActionGlobally(
143
150
  clientStore,
144
151
  ({state, instance}, options: ClientOptions) => {
152
+ if (!options || typeof options !== 'object') {
153
+ throw new Error(
154
+ 'getClient() requires a configuration object with at least an "apiVersion" property. ' +
155
+ 'Example: getClient(instance, { apiVersion: "2024-11-12" })',
156
+ )
157
+ }
158
+
145
159
  // Check for disallowed keys
146
160
  const providedKeys = Object.keys(options) as (keyof ClientOptions)[]
147
161
  const disallowedKeys = providedKeys.filter((key) => !allowedKeys.includes(key))
@@ -156,18 +170,42 @@ export const getClient = bindActionGlobally(
156
170
 
157
171
  const tokenFromState = state.get().token
158
172
  const {clients, authMethod} = state.get()
173
+ const hasSource = !!options.source
174
+ let sourceId = options.source?.[SOURCE_ID]
175
+
176
+ let resource
177
+ if (Array.isArray(sourceId)) {
178
+ resource = {type: sourceId[0], id: sourceId[1]}
179
+ sourceId = undefined
180
+ }
181
+
159
182
  const projectId = options.projectId ?? instance.config.projectId
160
183
  const dataset = options.dataset ?? instance.config.dataset
161
184
  const apiHost = options.apiHost ?? instance.config.auth?.apiHost
162
185
 
163
186
  const effectiveOptions: ClientOptions = {
164
187
  ...DEFAULT_CLIENT_CONFIG,
165
- ...((options.scope === 'global' || !projectId) && {useProjectHostname: false}),
188
+ ...((options.scope === 'global' || !projectId || hasSource) && {useProjectHostname: false}),
166
189
  token: authMethod === 'cookie' ? undefined : (tokenFromState ?? undefined),
167
190
  ...options,
168
191
  ...(projectId && {projectId}),
169
192
  ...(dataset && {dataset}),
170
193
  ...(apiHost && {apiHost}),
194
+ ...(resource && {'~experimental_resource': resource}),
195
+ }
196
+
197
+ // When a source is provided, don't use projectId/dataset - the client should be "projectless"
198
+ // The client code itself will ignore the non-source config, so we do this to prevent confusing the user.
199
+ // (ref: https://github.com/sanity-io/client/blob/5c23f81f5ab93a53f5b22b39845c867988508d84/src/data/dataMethods.ts#L691)
200
+ if (hasSource) {
201
+ if (options.projectId || options.dataset) {
202
+ // eslint-disable-next-line no-console
203
+ console.warn(
204
+ 'Both source and explicit projectId/dataset are provided. The source will be used and projectId/dataset will be ignored.',
205
+ )
206
+ }
207
+ delete effectiveOptions.projectId
208
+ delete effectiveOptions.dataset
171
209
  }
172
210
 
173
211
  if (effectiveOptions.token === null || typeof effectiveOptions.token === 'undefined') {
@@ -39,7 +39,7 @@ describe('destroyController', () => {
39
39
  })
40
40
 
41
41
  // Execute action
42
- destroyController({state, instance})
42
+ destroyController({state, instance, key: null})
43
43
 
44
44
  // Verify controller was destroyed and state was cleared
45
45
  expect(mockController.destroy).toHaveBeenCalled()
@@ -49,7 +49,7 @@ describe('destroyController', () => {
49
49
 
50
50
  it('should do nothing if no controller exists', () => {
51
51
  // State already has null controller, so just execute action
52
- expect(() => destroyController({state, instance})).not.toThrow()
52
+ expect(() => destroyController({state, instance, key: null})).not.toThrow()
53
53
 
54
54
  // State should remain unchanged
55
55
  expect(state.get().controller).toBeNull()
@@ -49,7 +49,7 @@ describe('getOrCreateChannel', () => {
49
49
  it('should create a new channel using the controller', () => {
50
50
  const createChannelSpy = vi.spyOn(mockController, 'createChannel')
51
51
 
52
- const channel = getOrCreateChannel({state, instance}, channelConfig)
52
+ const channel = getOrCreateChannel({state, instance, key: null}, channelConfig)
53
53
 
54
54
  expect(createChannelSpy).toHaveBeenCalledWith(channelConfig)
55
55
  expect(channel.on).toBeDefined()
@@ -70,17 +70,17 @@ describe('getOrCreateChannel', () => {
70
70
  channels: new Map(),
71
71
  })
72
72
 
73
- expect(() => getOrCreateChannel({state, instance}, channelConfig)).toThrow(
73
+ expect(() => getOrCreateChannel({state, instance, key: null}, channelConfig)).toThrow(
74
74
  'Controller must be initialized before using or creating channels',
75
75
  )
76
76
  })
77
77
 
78
78
  it('should retrieve channel directly from store once created', () => {
79
- const createdChannel = getOrCreateChannel({state, instance}, channelConfig)
79
+ const createdChannel = getOrCreateChannel({state, instance, key: null}, channelConfig)
80
80
  vi.clearAllMocks() // Clear call counts
81
81
 
82
82
  // Retrieve channel again
83
- const retrievedChannel = getOrCreateChannel({state, instance}, channelConfig)
83
+ const retrievedChannel = getOrCreateChannel({state, instance, key: null}, channelConfig)
84
84
  expect(retrievedChannel).toBeDefined()
85
85
  expect(retrievedChannel).toBe(createdChannel)
86
86
 
@@ -95,10 +95,10 @@ describe('getOrCreateChannel', () => {
95
95
  })
96
96
 
97
97
  it('should throw error when trying to create channel with different options', () => {
98
- getOrCreateChannel({state, instance}, channelConfig)
98
+ getOrCreateChannel({state, instance, key: null}, channelConfig)
99
99
 
100
100
  expect(() =>
101
- getOrCreateChannel({state, instance}, {...channelConfig, connectTo: 'window'}),
101
+ getOrCreateChannel({state, instance, key: null}, {...channelConfig, connectTo: 'window'}),
102
102
  ).toThrow('Channel "test" already exists with different options')
103
103
  })
104
104
  })
@@ -43,7 +43,7 @@ describe('getOrCreateController', () => {
43
43
  const controllerSpy = vi.spyOn(comlink, 'createController')
44
44
  const targetOrigin = 'https://test.sanity.dev'
45
45
 
46
- const controller = getOrCreateController({state, instance}, targetOrigin)
46
+ const controller = getOrCreateController({state, instance, key: null}, targetOrigin)
47
47
 
48
48
  expect(controllerSpy).toHaveBeenCalledWith({targetOrigin})
49
49
  expect(controller).toBeDefined()
@@ -56,8 +56,8 @@ describe('getOrCreateController', () => {
56
56
  const controllerSpy = vi.spyOn(comlink, 'createController')
57
57
  const targetOrigin = 'https://test.sanity.dev'
58
58
 
59
- const firstController = getOrCreateController({state, instance}, targetOrigin)
60
- const secondController = getOrCreateController({state, instance}, targetOrigin)
59
+ const firstController = getOrCreateController({state, instance, key: null}, targetOrigin)
60
+ const secondController = getOrCreateController({state, instance, key: null}, targetOrigin)
61
61
 
62
62
  expect(controllerSpy).toHaveBeenCalledTimes(1)
63
63
  expect(firstController).toBe(secondController)
@@ -68,9 +68,9 @@ describe('getOrCreateController', () => {
68
68
  const targetOrigin = 'https://test.sanity.dev'
69
69
  const targetOrigin2 = 'https://test2.sanity.dev'
70
70
 
71
- const firstController = getOrCreateController({state, instance}, targetOrigin)
71
+ const firstController = getOrCreateController({state, instance, key: null}, targetOrigin)
72
72
  const destroySpy = vi.spyOn(firstController, 'destroy')
73
- const secondController = getOrCreateController({state, instance}, targetOrigin2)
73
+ const secondController = getOrCreateController({state, instance, key: null}, targetOrigin2)
74
74
 
75
75
  expect(controllerSpy).toHaveBeenCalledTimes(2)
76
76
  expect(destroySpy).toHaveBeenCalled()
@@ -21,7 +21,7 @@ export const getOrCreateController = (
21
21
  // if the target origin has changed, we'll create a new controller,
22
22
  // but need to clean up first
23
23
  if (controller) {
24
- destroyController({state, instance})
24
+ destroyController({state, instance, key: undefined})
25
25
  }
26
26
 
27
27
  const newController = createController({targetOrigin})
@@ -19,6 +19,7 @@ const channelConfig = {
19
19
  describe('releaseChannel', () => {
20
20
  let instance: SanityInstance
21
21
  let store: StoreInstance<ComlinkControllerState>
22
+ const key = {name: 'global', projectId: 'test-project-id', dataset: 'test-dataset'}
22
23
 
23
24
  let getOrCreateChannel: (
24
25
  inst: SanityInstance,
@@ -29,14 +30,14 @@ describe('releaseChannel', () => {
29
30
 
30
31
  beforeEach(() => {
31
32
  instance = createSanityInstance({projectId: 'test-project-id', dataset: 'test-dataset'})
32
- store = createStoreInstance(instance, comlinkControllerStore)
33
+ store = createStoreInstance(instance, key, comlinkControllerStore)
33
34
 
34
35
  const bind =
35
36
  <TParams extends unknown[], TReturn>(
36
37
  action: StoreAction<ComlinkControllerState, TParams, TReturn>,
37
38
  ) =>
38
39
  (inst: SanityInstance, ...params: TParams) =>
39
- action({instance: inst, state: store.state}, ...params)
40
+ action({instance: inst, state: store.state, key}, ...params)
40
41
 
41
42
  getOrCreateChannel = bind(unboundGetOrCreateChannel)
42
43
  getOrCreateController = bind(unboundGetOrCreateController)
@@ -31,7 +31,7 @@ describe('comlinkControllerStore', () => {
31
31
 
32
32
  // Create store state directly
33
33
  const state = createStoreState<ComlinkControllerState>(
34
- comlinkControllerStore.getInitialState(instance),
34
+ comlinkControllerStore.getInitialState(instance, null),
35
35
  )
36
36
 
37
37
  const initialState = state.get()
@@ -56,13 +56,13 @@ describe('comlinkControllerStore', () => {
56
56
  vi.mocked(bindActionGlobally).mockImplementation(
57
57
  (_storeDef, action) =>
58
58
  (inst: SanityInstance, ...params: unknown[]) =>
59
- action({instance: inst, state}, ...params),
59
+ action({instance: inst, state, key: {name: 'global'}}, ...params),
60
60
  )
61
61
 
62
62
  const {comlinkControllerStore} = await import('./comlinkControllerStore')
63
63
 
64
64
  // Get the cleanup function from the store
65
- const dispose = comlinkControllerStore.initialize?.({state, instance})
65
+ const dispose = comlinkControllerStore.initialize?.({state, instance, key: null})
66
66
 
67
67
  // Run cleanup
68
68
  dispose?.()
@@ -82,7 +82,7 @@ describe('comlinkControllerStore', () => {
82
82
  })
83
83
 
84
84
  // Get the cleanup function
85
- const cleanup = comlinkControllerStore.initialize?.({state, instance})
85
+ const cleanup = comlinkControllerStore.initialize?.({state, instance, key: null})
86
86
 
87
87
  // Should not throw when no controller exists
88
88
  expect(() => cleanup?.()).not.toThrow()
@@ -40,24 +40,24 @@ describe('getOrCreateNode', () => {
40
40
  })
41
41
 
42
42
  it('should create and start a node', () => {
43
- const node = getOrCreateNode({state, instance}, nodeConfig)
43
+ const node = getOrCreateNode({state, instance, key: null}, nodeConfig)
44
44
 
45
45
  expect(comlink.createNode).toHaveBeenCalledWith(nodeConfig)
46
46
  expect(node.start).toHaveBeenCalled()
47
47
  })
48
48
 
49
49
  it('should store the node in nodeStore', () => {
50
- const node = getOrCreateNode({state, instance}, nodeConfig)
50
+ const node = getOrCreateNode({state, instance, key: null}, nodeConfig)
51
51
 
52
- expect(getOrCreateNode({state, instance}, nodeConfig)).toBe(node)
52
+ expect(getOrCreateNode({state, instance, key: null}, nodeConfig)).toBe(node)
53
53
  })
54
54
 
55
55
  it('should throw error when trying to create node with different options', () => {
56
- getOrCreateNode({state, instance}, nodeConfig)
56
+ getOrCreateNode({state, instance, key: null}, nodeConfig)
57
57
 
58
58
  expect(() =>
59
59
  getOrCreateNode(
60
- {state, instance},
60
+ {state, instance, key: null},
61
61
  {
62
62
  ...nodeConfig,
63
63
  connectTo: 'window',
@@ -74,7 +74,7 @@ describe('getOrCreateNode', () => {
74
74
  return statusUnsubMock
75
75
  })
76
76
 
77
- getOrCreateNode({state, instance}, nodeConfig)
77
+ getOrCreateNode({state, instance, key: null}, nodeConfig)
78
78
 
79
79
  expect(mockNode.onStatus).toHaveBeenCalled()
80
80
  expect(state.get().nodes.get(nodeConfig.name)?.statusUnsub).toBe(statusUnsubMock)
@@ -92,7 +92,7 @@ describe('getOrCreateNode', () => {
92
92
  return statusUnsubMock
93
93
  })
94
94
 
95
- getOrCreateNode({state, instance}, nodeConfig)
95
+ getOrCreateNode({state, instance, key: null}, nodeConfig)
96
96
 
97
97
  // Remove the node entry before triggering the status callback
98
98
  state.get().nodes.delete(nodeConfig.name)
@@ -47,7 +47,7 @@ describe('releaseNode', () => {
47
47
  expect(state.get().nodes.has('test-node')).toBe(true)
48
48
 
49
49
  // Release the node
50
- releaseNode({state, instance}, 'test-node')
50
+ releaseNode({state, instance, key: null}, 'test-node')
51
51
 
52
52
  // Check node is removed
53
53
  expect(mockNode.stop).toHaveBeenCalled()
@@ -64,7 +64,7 @@ describe('releaseNode', () => {
64
64
  })
65
65
  state.set('setup', {nodes})
66
66
 
67
- releaseNode({state, instance}, 'test-node')
67
+ releaseNode({state, instance, key: null}, 'test-node')
68
68
 
69
69
  expect(statusUnsub).toHaveBeenCalled()
70
70
  })
@@ -14,7 +14,7 @@ describe('nodeStore', () => {
14
14
  })
15
15
 
16
16
  it('should have correct initial state', () => {
17
- const initialState = comlinkNodeStore.getInitialState(instance)
17
+ const initialState = comlinkNodeStore.getInitialState(instance, null)
18
18
 
19
19
  expect(initialState.nodes).toBeInstanceOf(Map)
20
20
  expect(initialState.nodes.size).toBe(0)
@@ -25,16 +25,17 @@ describe('nodeStore', () => {
25
25
  stop: vi.fn(),
26
26
  } as unknown as Node<WindowMessage, FrameMessage>
27
27
 
28
- const initialState = comlinkNodeStore.getInitialState(instance)
28
+ const initialState = comlinkNodeStore.getInitialState(instance, null)
29
29
  initialState.nodes.set('test-node', {
30
30
  options: {name: 'test-node', connectTo: 'parent'},
31
31
  node: mockNode,
32
- refCount: 1,
32
+ status: 'idle',
33
33
  })
34
34
 
35
35
  const cleanup = comlinkNodeStore.initialize?.({
36
36
  instance,
37
37
  state: createStoreState(initialState),
38
+ key: null,
38
39
  })
39
40
 
40
41
  cleanup?.()
@@ -29,15 +29,14 @@ export interface PerspectiveHandle {
29
29
  * @public
30
30
  */
31
31
  export interface DatasetHandle<TDataset extends string = string, TProjectId extends string = string>
32
- extends ProjectHandle<TProjectId>,
33
- PerspectiveHandle {
32
+ extends ProjectHandle<TProjectId>, PerspectiveHandle {
34
33
  dataset?: TDataset
35
34
  }
36
35
 
37
36
  /**
38
37
  * Identifies a specific document type within a Sanity dataset and project.
39
38
  * Includes `projectId`, `dataset`, and `documentType`.
40
- * Optionally includes a `documentId`, useful for referencing a specific document type context, potentially without a specific document ID.
39
+ * Optionally includes a `documentId` and `liveEdit` flag.
41
40
  * @public
42
41
  */
43
42
  export interface DocumentTypeHandle<
@@ -47,6 +46,12 @@ export interface DocumentTypeHandle<
47
46
  > extends DatasetHandle<TDataset, TProjectId> {
48
47
  documentId?: string
49
48
  documentType: TDocumentType
49
+ /**
50
+ * Indicates whether this document uses liveEdit mode.
51
+ * When `true`, the document does not use the draft/published model and edits are applied directly to the document.
52
+ * @see https://www.sanity.io/docs/content-lake/drafts#ca0663a8f002
53
+ */
54
+ liveEdit?: boolean
50
55
  }
51
56
 
52
57
  /**
@@ -81,3 +86,44 @@ export interface SanityConfig extends DatasetHandle, PerspectiveHandle {
81
86
  enabled: boolean
82
87
  }
83
88
  }
89
+
90
+ export const SOURCE_ID = '__sanity_internal_sourceId'
91
+
92
+ /**
93
+ * A document source can be used for querying.
94
+ *
95
+ * @beta
96
+ * @see datasetSource Construct a document source for a given projectId and dataset.
97
+ * @see mediaLibrarySource Construct a document source for a mediaLibraryId.
98
+ * @see canvasSource Construct a document source for a canvasId.
99
+ */
100
+ export type DocumentSource = {
101
+ [SOURCE_ID]: ['media-library', string] | ['canvas', string] | {projectId: string; dataset: string}
102
+ }
103
+
104
+ /**
105
+ * Returns a document source for a projectId and dataset.
106
+ *
107
+ * @beta
108
+ */
109
+ export function datasetSource(projectId: string, dataset: string): DocumentSource {
110
+ return {[SOURCE_ID]: {projectId, dataset}}
111
+ }
112
+
113
+ /**
114
+ * Returns a document source for a Media Library.
115
+ *
116
+ * @beta
117
+ */
118
+ export function mediaLibrarySource(id: string): DocumentSource {
119
+ return {[SOURCE_ID]: ['media-library', id]}
120
+ }
121
+
122
+ /**
123
+ * Returns a document source for a Canvas.
124
+ *
125
+ * @beta
126
+ */
127
+ export function canvasSource(id: string): DocumentSource {
128
+ return {[SOURCE_ID]: ['canvas', id]}
129
+ }
@@ -42,6 +42,40 @@ describe('document actions', () => {
42
42
  documentType: typeHandle.documentType,
43
43
  })
44
44
  })
45
+
46
+ it('creates a document action with initial values', () => {
47
+ const initialValue = {
48
+ title: 'Test Title',
49
+ author: 'John Doe',
50
+ count: 42,
51
+ }
52
+ const action = createDocument(dummyDocHandle, initialValue)
53
+ expect(action).toEqual({
54
+ type: 'document.create',
55
+ documentId: 'abc123',
56
+ documentType: dummyDocHandle.documentType,
57
+ initialValue,
58
+ })
59
+ })
60
+
61
+ it('creates a document action without initialValue when not provided', () => {
62
+ const action = createDocument(dummyDocHandle, undefined)
63
+ expect(action).toEqual({
64
+ type: 'document.create',
65
+ documentId: 'abc123',
66
+ documentType: dummyDocHandle.documentType,
67
+ })
68
+ })
69
+
70
+ it('creates a document action with empty initialValue object', () => {
71
+ const action = createDocument(dummyDocHandle, {})
72
+ expect(action).toEqual({
73
+ type: 'document.create',
74
+ documentId: 'abc123',
75
+ documentType: dummyDocHandle.documentType,
76
+ initialValue: {},
77
+ })
78
+ })
45
79
  })
46
80
 
47
81
  describe('deleteDocument', () => {