@sanity/sdk 2.5.0 → 2.7.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 (46) hide show
  1. package/dist/index.d.ts +429 -27
  2. package/dist/index.js +657 -266
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/_exports/index.ts +18 -3
  6. package/src/auth/authMode.test.ts +56 -0
  7. package/src/auth/authMode.ts +71 -0
  8. package/src/auth/authStore.test.ts +85 -4
  9. package/src/auth/authStore.ts +63 -125
  10. package/src/auth/authStrategy.ts +39 -0
  11. package/src/auth/dashboardAuth.ts +132 -0
  12. package/src/auth/standaloneAuth.ts +109 -0
  13. package/src/auth/studioAuth.ts +217 -0
  14. package/src/auth/studioModeAuth.test.ts +43 -1
  15. package/src/auth/studioModeAuth.ts +10 -1
  16. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +21 -6
  17. package/src/client/clientStore.test.ts +45 -43
  18. package/src/client/clientStore.ts +23 -9
  19. package/src/config/loggingConfig.ts +149 -0
  20. package/src/config/sanityConfig.ts +82 -22
  21. package/src/projection/getProjectionState.ts +6 -5
  22. package/src/projection/projectionQuery.test.ts +38 -55
  23. package/src/projection/projectionQuery.ts +27 -31
  24. package/src/projection/projectionStore.test.ts +4 -4
  25. package/src/projection/projectionStore.ts +3 -2
  26. package/src/projection/resolveProjection.ts +2 -2
  27. package/src/projection/statusQuery.test.ts +35 -0
  28. package/src/projection/statusQuery.ts +71 -0
  29. package/src/projection/subscribeToStateAndFetchBatches.test.ts +63 -50
  30. package/src/projection/subscribeToStateAndFetchBatches.ts +106 -27
  31. package/src/projection/types.ts +12 -0
  32. package/src/projection/util.ts +0 -1
  33. package/src/query/queryStore.test.ts +64 -0
  34. package/src/query/queryStore.ts +33 -11
  35. package/src/releases/getPerspectiveState.test.ts +17 -14
  36. package/src/releases/getPerspectiveState.ts +58 -38
  37. package/src/releases/releasesStore.test.ts +59 -61
  38. package/src/releases/releasesStore.ts +21 -35
  39. package/src/releases/utils/isReleasePerspective.ts +7 -0
  40. package/src/store/createActionBinder.test.ts +211 -1
  41. package/src/store/createActionBinder.ts +102 -13
  42. package/src/store/createSanityInstance.test.ts +85 -1
  43. package/src/store/createSanityInstance.ts +55 -4
  44. package/src/utils/logger-usage-example.md +141 -0
  45. package/src/utils/logger.test.ts +757 -0
  46. package/src/utils/logger.ts +537 -0
@@ -1,45 +1,47 @@
1
- import {type SanityClient} from '@sanity/client'
2
- import {of, Subject} from 'rxjs'
1
+ import {NEVER, Observable, type Observer, of, Subject} from 'rxjs'
3
2
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
3
 
5
- import {getClientState} from '../client/clientStore'
4
+ import {getQueryState, resolveQuery} from '../query/queryStore'
6
5
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
6
  import {type StateSource} from '../store/createStateSourceAction'
8
- import {listenQuery} from '../utils/listenQuery'
9
7
  import {getActiveReleasesState, type ReleaseDocument} from './releasesStore'
10
8
 
11
9
  // Mock dependencies
12
- vi.mock('../client/clientStore', () => ({
13
- getClientState: vi.fn(),
14
- }))
15
- vi.mock('../utils/listenQuery', () => ({
16
- listenQuery: vi.fn(),
17
- }))
18
-
19
- // Mock console.error to prevent test runner noise and allow verification
20
- let consoleErrorSpy: ReturnType<typeof vi.spyOn>
10
+ vi.mock('../query/queryStore')
21
11
 
22
12
  describe('releasesStore', () => {
23
13
  let instance: SanityInstance
24
- const mockClient = {} as SanityClient
25
14
 
26
15
  beforeEach(() => {
27
- vi.resetAllMocks()
28
- consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
16
+ vi.clearAllMocks()
29
17
 
30
18
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
31
19
 
32
- vi.mocked(getClientState).mockReturnValue({
33
- observable: of(mockClient),
34
- } as StateSource<SanityClient>)
20
+ vi.mocked(getQueryState).mockReturnValue({
21
+ subscribe: () => () => {},
22
+ getCurrent: () => undefined,
23
+ observable: NEVER as Observable<ReleaseDocument[] | undefined>,
24
+ } as StateSource<ReleaseDocument[] | undefined>)
25
+
26
+ vi.mocked(resolveQuery).mockResolvedValue(undefined)
35
27
  })
36
28
 
37
29
  afterEach(() => {
38
30
  instance.dispose()
39
- consoleErrorSpy.mockRestore()
40
31
  })
41
32
 
42
- it('should set active releases state when listenQuery succeeds', async () => {
33
+ it('should set active releases state when the releases query emits', async () => {
34
+ const teardown = vi.fn()
35
+ const subscriber = vi
36
+ .fn<(observer: Observer<ReleaseDocument[] | undefined>) => () => void>()
37
+ .mockReturnValue(teardown)
38
+
39
+ vi.mocked(getQueryState).mockReturnValue({
40
+ subscribe: () => () => {},
41
+ getCurrent: () => undefined,
42
+ observable: new Observable(subscriber),
43
+ } as StateSource<ReleaseDocument[] | undefined>)
44
+
43
45
  // note that the order of the releases is important here -- they get sorted
44
46
  const mockReleases: ReleaseDocument[] = [
45
47
  {
@@ -56,21 +58,24 @@ describe('releasesStore', () => {
56
58
  } as ReleaseDocument,
57
59
  ]
58
60
 
59
- vi.mocked(listenQuery).mockReturnValue(of(mockReleases))
60
-
61
61
  const state = getActiveReleasesState(instance)
62
62
 
63
+ const [observer] = subscriber.mock.lastCall!
64
+
65
+ observer.next(mockReleases)
66
+
63
67
  await new Promise((resolve) => setTimeout(resolve, 0))
64
68
 
65
69
  expect(state.getCurrent()).toEqual(mockReleases.reverse())
66
- expect(consoleErrorSpy).not.toHaveBeenCalled()
67
-
68
- vi.useRealTimers()
69
70
  })
70
71
 
71
- it('should update active releases state when listenQuery emits new data', async () => {
72
+ it('should update active releases state when the query emits new data', async () => {
72
73
  const releasesSubject = new Subject<ReleaseDocument[]>()
73
- vi.mocked(listenQuery).mockReturnValue(releasesSubject.asObservable())
74
+ vi.mocked(getQueryState).mockReturnValue({
75
+ subscribe: () => () => {},
76
+ getCurrent: () => undefined,
77
+ observable: releasesSubject.asObservable(),
78
+ } as StateSource<ReleaseDocument[] | undefined>)
74
79
 
75
80
  const state = getActiveReleasesState(instance)
76
81
 
@@ -109,65 +114,58 @@ describe('releasesStore', () => {
109
114
  await new Promise((resolve) => setTimeout(resolve, 0))
110
115
 
111
116
  expect(state.getCurrent()).toEqual(updatedReleases.reverse())
112
- expect(consoleErrorSpy).not.toHaveBeenCalled()
113
117
  })
114
118
 
115
- it('should handle empty array from listenQuery', async () => {
116
- // Configure listenQuery to return an empty array
117
- vi.mocked(listenQuery).mockReturnValue(of([]))
119
+ it('should handle empty array from the query', async () => {
120
+ // Configure query to return an empty array
121
+ vi.mocked(getQueryState).mockReturnValue({
122
+ subscribe: () => () => {},
123
+ getCurrent: () => [],
124
+ observable: of([]),
125
+ } as StateSource<ReleaseDocument[] | undefined>)
118
126
 
119
127
  const state = getActiveReleasesState(instance)
120
128
 
121
129
  await new Promise((resolve) => setTimeout(resolve, 0))
122
130
 
123
131
  expect(state.getCurrent()).toEqual([]) // Should be set to empty array
124
- expect(consoleErrorSpy).not.toHaveBeenCalled()
125
132
  })
126
133
 
127
- it('should handle null/undefined from listenQuery by defaulting to empty array', async () => {
134
+ it('should handle null/undefined from the query by defaulting to empty array', async () => {
128
135
  // Test null case
129
- vi.mocked(listenQuery).mockReturnValue(of(null))
136
+ vi.mocked(getQueryState).mockReturnValue({
137
+ subscribe: () => () => {},
138
+ getCurrent: () => null as unknown as ReleaseDocument[] | undefined,
139
+ observable: of(null as unknown as ReleaseDocument[] | undefined),
140
+ } as StateSource<ReleaseDocument[] | undefined>)
130
141
  const state = getActiveReleasesState(instance)
131
142
  await new Promise((resolve) => setTimeout(resolve, 0))
132
143
  expect(state.getCurrent()).toEqual([])
133
- expect(consoleErrorSpy).not.toHaveBeenCalled()
134
144
 
135
145
  // Test undefined case
136
- vi.mocked(listenQuery).mockReturnValue(of(undefined))
146
+ vi.mocked(getQueryState).mockReturnValue({
147
+ subscribe: () => () => {},
148
+ getCurrent: () => undefined,
149
+ observable: of(undefined),
150
+ } as StateSource<ReleaseDocument[] | undefined>)
137
151
  await new Promise((resolve) => setTimeout(resolve, 0))
138
152
  expect(state.getCurrent()).toEqual([])
139
- expect(consoleErrorSpy).not.toHaveBeenCalled()
140
153
  })
141
154
 
142
- it('should handle errors from listenQuery by retrying and eventually setting error state', async () => {
143
- vi.useFakeTimers()
144
- const error = new Error('Query failed')
155
+ it('should not crash when the releases query errors', async () => {
145
156
  const subject = new Subject<ReleaseDocument[]>()
146
- vi.mocked(listenQuery).mockReturnValue(subject.asObservable())
157
+ vi.mocked(getQueryState).mockReturnValue({
158
+ subscribe: () => () => {},
159
+ getCurrent: () => undefined,
160
+ observable: subject.asObservable(),
161
+ } as StateSource<ReleaseDocument[] | undefined>)
147
162
 
148
- // initialize the store
149
163
  const state = getActiveReleasesState(instance)
150
164
 
151
- // Error the subject
152
- subject.error(error)
153
-
154
- // Advance enough to cover the retry attempts (exponential backoff: 1s, 2s, 4s)
155
- for (let i = 0; i < 3; i++) {
156
- const delay = Math.pow(2, i) * 1000
157
- await vi.advanceTimersByTimeAsync(delay)
158
- }
165
+ subject.error(new Error('Query failed'))
159
166
 
160
- // Verify error was logged at least once during retries
161
- expect(consoleErrorSpy).toHaveBeenCalledWith(
162
- '[releases] Error in subscription:',
163
- error,
164
- 'Retry count:',
165
- expect.any(Number),
166
- )
167
+ await new Promise((resolve) => setTimeout(resolve, 0))
167
168
 
168
- // not sure how to test state.setError()
169
169
  expect(state.getCurrent()).toEqual(undefined)
170
-
171
- vi.useRealTimers()
172
170
  })
173
171
  })
@@ -1,15 +1,22 @@
1
- import {type SanityClient} from '@sanity/client'
2
1
  import {type SanityDocument} from '@sanity/types'
3
- import {catchError, EMPTY, retry, switchMap, timer} from 'rxjs'
2
+ import {map} from 'rxjs'
4
3
 
5
- import {getClientState} from '../client/clientStore'
4
+ /*
5
+ * Although this is an import dependency cycle, it is not a logical cycle:
6
+ * 1. releasesStore uses queryStore as a data source
7
+ * 2. queryStore calls getPerspectiveState for computing release perspectives
8
+ * 3. getPerspectiveState uses releasesStore as a data source
9
+ * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
10
+ */
11
+ // eslint-disable-next-line import/no-cycle
12
+ import {getQueryState} from '../query/queryStore'
6
13
  import {bindActionByDataset, type BoundDatasetKey} from '../store/createActionBinder'
7
14
  import {createStateSourceAction} from '../store/createStateSourceAction'
8
15
  import {defineStore, type StoreContext} from '../store/defineStore'
9
- import {listenQuery} from '../utils/listenQuery'
10
16
  import {sortReleases} from './utils/sortReleases'
11
17
 
12
18
  const ARCHIVED_RELEASE_STATES = ['archived', 'published']
19
+ const STABLE_EMPTY_RELEASES: ReleaseDocument[] = []
13
20
 
14
21
  /**
15
22
  * Represents a document in a Sanity dataset that represents release options.
@@ -55,51 +62,30 @@ export const getActiveReleasesState = bindActionByDataset(
55
62
  )
56
63
 
57
64
  const RELEASES_QUERY = 'releases::all()'
58
- const QUERY_PARAMS = {}
59
65
 
60
66
  const subscribeToReleases = ({
61
67
  instance,
62
68
  state,
63
69
  key: {projectId, dataset},
64
70
  }: StoreContext<ReleasesStoreState, BoundDatasetKey>) => {
65
- return getClientState(instance, {
66
- apiVersion: '2025-04-10',
71
+ const {observable: releases$} = getQueryState<ReleaseDocument[]>(instance, {
72
+ query: RELEASES_QUERY,
67
73
  perspective: 'raw',
68
74
  projectId,
69
75
  dataset,
76
+ tag: 'releases',
70
77
  })
71
- .observable.pipe(
72
- switchMap((client: SanityClient) =>
73
- // releases are system documents, and are not supported by useQueryState
74
- listenQuery<ReleaseDocument[]>(client, RELEASES_QUERY, QUERY_PARAMS, {
75
- tag: 'releases-listener',
76
- throttleTime: 1000,
77
- transitions: ['update', 'appear', 'disappear'],
78
- }).pipe(
79
- retry({
80
- count: 3,
81
- delay: (error, retryCount) => {
82
- // eslint-disable-next-line no-console
83
- console.error('[releases] Error in subscription:', error, 'Retry count:', retryCount)
84
- return timer(Math.min(1000 * Math.pow(2, retryCount), 10000))
85
- },
86
- }),
87
- catchError((error) => {
88
- state.set('setError', {error})
89
- return EMPTY
90
- }),
91
- ),
92
- ),
93
- )
94
- .subscribe({
95
- next: (releases) => {
78
+ return releases$
79
+ .pipe(
80
+ map((releases) => {
96
81
  // logic here mirrors that of studio:
97
82
  // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
98
83
  state.set('setActiveReleases', {
99
- activeReleases: sortReleases(releases ?? [])
84
+ activeReleases: sortReleases(releases ?? STABLE_EMPTY_RELEASES)
100
85
  .filter((release) => !ARCHIVED_RELEASE_STATES.includes(release.state))
101
86
  .reverse(),
102
87
  })
103
- },
104
- })
88
+ }),
89
+ )
90
+ .subscribe({error: (error) => state.set('setError', {error})})
105
91
  }
@@ -0,0 +1,7 @@
1
+ import {type PerspectiveHandle, type ReleasePerspective} from '../../config/sanityConfig'
2
+
3
+ export const isReleasePerspective = (
4
+ perspective: PerspectiveHandle['perspective'],
5
+ ): perspective is ReleasePerspective => {
6
+ return typeof perspective === 'object' && perspective !== null && 'releaseName' in perspective
7
+ }
@@ -1,6 +1,13 @@
1
1
  import {beforeEach, describe, expect, it, vi} from 'vitest'
2
2
 
3
- import {bindActionByDataset, bindActionGlobally, createActionBinder} from './createActionBinder'
3
+ import {type DocumentSource} from '../config/sanityConfig'
4
+ import {
5
+ bindActionByDataset,
6
+ bindActionBySource,
7
+ bindActionBySourceAndPerspective,
8
+ bindActionGlobally,
9
+ createActionBinder,
10
+ } from './createActionBinder'
4
11
  import {createSanityInstance} from './createSanityInstance'
5
12
  import {createStoreInstance} from './createStoreInstance'
6
13
 
@@ -153,3 +160,206 @@ describe('bindActionGlobally', () => {
153
160
  expect(storeInstance.dispose).toHaveBeenCalledTimes(1)
154
161
  })
155
162
  })
163
+
164
+ describe('bindActionBySource', () => {
165
+ it('should throw an error when provided an invalid source', () => {
166
+ const storeDefinition = {
167
+ name: 'SourceStore',
168
+ getInitialState: () => ({counter: 0}),
169
+ }
170
+ const action = vi.fn((_context) => 'success')
171
+ const boundAction = bindActionBySource(storeDefinition, action)
172
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
173
+
174
+ expect(() =>
175
+ boundAction(instance, {source: {invalid: 'source'} as unknown as DocumentSource}),
176
+ ).toThrow('Received invalid source:')
177
+ })
178
+
179
+ it('should throw an error when no source provided and projectId/dataset are missing', () => {
180
+ const storeDefinition = {
181
+ name: 'SourceStore',
182
+ getInitialState: () => ({counter: 0}),
183
+ }
184
+ const action = vi.fn((_context) => 'success')
185
+ const boundAction = bindActionBySource(storeDefinition, action)
186
+ const instance = createSanityInstance({projectId: '', dataset: ''})
187
+
188
+ expect(() => boundAction(instance, {})).toThrow(
189
+ 'This API requires a project ID and dataset configured.',
190
+ )
191
+ })
192
+
193
+ it('should work correctly with a valid dataset source', () => {
194
+ const storeDefinition = {
195
+ name: 'SourceStore',
196
+ getInitialState: () => ({counter: 0}),
197
+ }
198
+ const action = vi.fn((_context) => 'success')
199
+ const boundAction = bindActionBySource(storeDefinition, action)
200
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
201
+
202
+ const result = boundAction(instance, {
203
+ source: {projectId: 'proj2', dataset: 'ds2'},
204
+ })
205
+ expect(result).toBe('success')
206
+ })
207
+ })
208
+
209
+ describe('bindActionBySourceAndPerspective', () => {
210
+ it('should throw an error when provided an invalid source', () => {
211
+ const storeDefinition = {
212
+ name: 'PerspectiveStore',
213
+ getInitialState: () => ({counter: 0}),
214
+ }
215
+ const action = vi.fn((_context) => 'success')
216
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
217
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
218
+
219
+ expect(() =>
220
+ boundAction(instance, {
221
+ source: {invalid: 'source'} as unknown as DocumentSource,
222
+ perspective: 'drafts',
223
+ }),
224
+ ).toThrow('Received invalid source:')
225
+ })
226
+
227
+ it('should throw an error when no source provided and projectId/dataset are missing', () => {
228
+ const storeDefinition = {
229
+ name: 'PerspectiveStore',
230
+ getInitialState: () => ({counter: 0}),
231
+ }
232
+ const action = vi.fn((_context) => 'success')
233
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
234
+ const instance = createSanityInstance({projectId: '', dataset: ''})
235
+
236
+ expect(() => boundAction(instance, {perspective: 'drafts'})).toThrow(
237
+ 'This API requires a project ID and dataset configured.',
238
+ )
239
+ })
240
+
241
+ it('should work correctly with a valid dataset source and explicit perspective', () => {
242
+ const storeDefinition = {
243
+ name: 'PerspectiveStore',
244
+ getInitialState: () => ({counter: 0}),
245
+ }
246
+ const action = vi.fn((_context) => 'success')
247
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
248
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
249
+
250
+ const result = boundAction(instance, {
251
+ source: {projectId: 'proj2', dataset: 'ds2'},
252
+ perspective: 'drafts',
253
+ })
254
+ expect(result).toBe('success')
255
+ })
256
+
257
+ it('should work correctly with valid dataset source and no perspective (falls back to drafts)', () => {
258
+ const storeDefinition = {
259
+ name: 'PerspectiveStore',
260
+ getInitialState: () => ({counter: 0}),
261
+ }
262
+ const action = vi.fn((_context) => 'success')
263
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
264
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
265
+
266
+ const result = boundAction(instance, {
267
+ source: {projectId: 'proj1', dataset: 'ds1'},
268
+ })
269
+ expect(result).toBe('success')
270
+ })
271
+
272
+ it('should use instance.config.perspective when options.perspective is not provided', () => {
273
+ const storeDefinition = {
274
+ name: 'PerspectiveStore',
275
+ getInitialState: () => ({counter: 0}),
276
+ }
277
+ const action = vi.fn((context) => context.key)
278
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
279
+ const instance = createSanityInstance({
280
+ projectId: 'proj1',
281
+ dataset: 'ds1',
282
+ perspective: 'published',
283
+ })
284
+
285
+ const result = boundAction(instance, {})
286
+ expect(result).toEqual(
287
+ expect.objectContaining({
288
+ name: 'proj1.ds1:published',
289
+ perspective: 'published',
290
+ }),
291
+ )
292
+ })
293
+
294
+ it('should create separate store instances for different perspectives', () => {
295
+ const storeDefinition = {
296
+ name: 'PerspectiveStore',
297
+ getInitialState: () => ({counter: 0}),
298
+ }
299
+ const action = vi.fn((context, _options, increment: number) => {
300
+ context.state.counter += increment
301
+ return context.state.counter
302
+ })
303
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
304
+ // Use unique project/dataset so we don't reuse stores from other tests
305
+ const instance = createSanityInstance({
306
+ projectId: 'perspective-isolation',
307
+ dataset: 'ds1',
308
+ })
309
+
310
+ const resultDrafts = boundAction(instance, {perspective: 'drafts'}, 3)
311
+ const resultPublished = boundAction(instance, {perspective: 'published'}, 4)
312
+
313
+ expect(resultDrafts).toBe(3)
314
+ expect(resultPublished).toBe(4)
315
+ // Two stores: one for drafts, one for published
316
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(2)
317
+ })
318
+
319
+ it('should create separate store instance for release perspective', () => {
320
+ const storeDefinition = {
321
+ name: 'PerspectiveStore',
322
+ getInitialState: () => ({counter: 0}),
323
+ }
324
+ const action = vi.fn((_context) => 'success')
325
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
326
+ const instance = createSanityInstance({projectId: 'proj1', dataset: 'ds1'})
327
+
328
+ const result = boundAction(instance, {
329
+ perspective: {releaseName: 'release1'},
330
+ })
331
+ expect(result).toBe('success')
332
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledWith(
333
+ instance,
334
+ expect.objectContaining({
335
+ name: 'proj1.ds1:release1',
336
+ perspective: {releaseName: 'release1'},
337
+ }),
338
+ storeDefinition,
339
+ )
340
+ })
341
+
342
+ it('should reuse same store when same source and perspective are used', () => {
343
+ const storeDefinition = {
344
+ name: 'PerspectiveStore',
345
+ getInitialState: () => ({counter: 0}),
346
+ }
347
+ const action = vi.fn((context, _options, increment: number) => {
348
+ context.state.counter += increment
349
+ return context.state.counter
350
+ })
351
+ const boundAction = bindActionBySourceAndPerspective(storeDefinition, action)
352
+ // Use unique project/dataset so we don't reuse stores from other tests
353
+ const instance = createSanityInstance({
354
+ projectId: 'perspective-reuse',
355
+ dataset: 'ds1',
356
+ })
357
+
358
+ const result1 = boundAction(instance, {perspective: 'drafts'}, 2)
359
+ const result2 = boundAction(instance, {perspective: 'drafts'}, 3)
360
+
361
+ expect(result1).toBe(2)
362
+ expect(result2).toBe(5)
363
+ expect(vi.mocked(createStoreInstance)).toHaveBeenCalledTimes(1)
364
+ })
365
+ })
@@ -1,10 +1,27 @@
1
- import {type DocumentSource, SOURCE_ID} from '../config/sanityConfig'
1
+ import {type ClientPerspective} from '@sanity/client'
2
+
3
+ import {
4
+ type DatasetHandle,
5
+ type DocumentSource,
6
+ isCanvasSource,
7
+ isDatasetSource,
8
+ isMediaLibrarySource,
9
+ type ReleasePerspective,
10
+ } from '../config/sanityConfig'
11
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
2
12
  import {type SanityInstance} from './createSanityInstance'
3
13
  import {createStoreInstance, type StoreInstance} from './createStoreInstance'
4
14
  import {type StoreState} from './createStoreState'
5
15
  import {type StoreContext, type StoreDefinition} from './defineStore'
6
16
 
7
- export type BoundDatasetKey = {
17
+ export interface BoundSourceKey {
18
+ name: string
19
+ source: DocumentSource
20
+ }
21
+ export interface BoundPerspectiveKey extends BoundSourceKey {
22
+ perspective: ClientPerspective | ReleasePerspective
23
+ }
24
+ export interface BoundDatasetKey {
8
25
  name: string
9
26
  projectId: string
10
27
  dataset: string
@@ -149,26 +166,98 @@ export const bindActionByDataset = createActionBinder<
149
166
  return {name: `${projectId}.${dataset}`, projectId, dataset}
150
167
  })
151
168
 
169
+ const createSourceKey = (instance: SanityInstance, source?: DocumentSource): BoundSourceKey => {
170
+ let name: string | undefined
171
+ let sourceForKey: DocumentSource | undefined
172
+ if (source) {
173
+ sourceForKey = source
174
+ if (isDatasetSource(source)) {
175
+ name = `${source.projectId}.${source.dataset}`
176
+ } else if (isMediaLibrarySource(source)) {
177
+ name = `media-library:${source.mediaLibraryId}`
178
+ } else if (isCanvasSource(source)) {
179
+ name = `canvas:${source.canvasId}`
180
+ } else {
181
+ throw new Error(`Received invalid source: ${JSON.stringify(source)}`)
182
+ }
183
+ return {name, source: sourceForKey}
184
+ }
185
+
186
+ // TODO: remove reference to instance.config when we get to v3
187
+ const {projectId, dataset} = instance.config
188
+ if (!projectId || !dataset) {
189
+ throw new Error('This API requires a project ID and dataset configured.')
190
+ }
191
+ return {name: `${projectId}.${dataset}`, source: {projectId, dataset}}
192
+ }
193
+
152
194
  /**
153
195
  * Binds an action to a store that's scoped to a specific document source.
154
196
  **/
155
197
  export const bindActionBySource = createActionBinder<
156
- {name: string},
198
+ BoundSourceKey,
157
199
  [{source?: DocumentSource}, ...unknown[]]
158
200
  >((instance, {source}) => {
159
- if (source) {
160
- const id = source[SOURCE_ID]
161
- if (!id) throw new Error('Invalid source (missing ID information)')
162
- if (Array.isArray(id)) return {name: id.join(':')}
163
- return {name: `${id.projectId}.${id.dataset}`}
164
- }
201
+ return createSourceKey(instance, source)
202
+ })
165
203
 
166
- const {projectId, dataset} = instance.config
204
+ /**
205
+ * Binds an action to a store that's scoped to a specific document source and perspective.
206
+ *
207
+ * @remarks
208
+ * This creates actions that operate on state isolated to a specific document source and perspective.
209
+ * Different document sources and perspectives will have separate states.
210
+ *
211
+ * This is mostly useful for stores that do batch fetching operations, since the query store
212
+ * can isolate single queries by perspective.
213
+ *
214
+ * @throws Error if source or perspective is missing from the Sanity instance config
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * // Define a store
219
+ * const documentStore = defineStore<DocumentState>({
220
+ * name: 'Document',
221
+ * getInitialState: () => ({ documents: {} }),
222
+ * // ...
223
+ * })
224
+ *
225
+ * // Create source-and-perspective-specific actions
226
+ * export const fetchDocuments = bindActionBySourceAndPerspective(
227
+ * documentStore,
228
+ * ({instance, state}, documentId) => {
229
+ * // This state is isolated to the specific document source and perspective
230
+ * // ...fetch logic...
231
+ * }
232
+ * )
233
+ *
234
+ * // Usage
235
+ * fetchDocument(sanityInstance, 'doc123')
236
+ * ```
237
+ */
238
+ export const bindActionBySourceAndPerspective = createActionBinder<
239
+ BoundPerspectiveKey,
240
+ [DatasetHandle, ...unknown[]]
241
+ >((instance, options): BoundPerspectiveKey => {
242
+ const {source, perspective} = options
243
+ // TODO: remove reference to instance.config.perspective when we get to v3
244
+ const utilizedPerspective = perspective ?? instance.config.perspective ?? 'drafts'
245
+ let perspectiveKey: string
246
+ if (isReleasePerspective(utilizedPerspective)) {
247
+ perspectiveKey = utilizedPerspective.releaseName
248
+ } else if (typeof utilizedPerspective === 'string') {
249
+ perspectiveKey = utilizedPerspective
250
+ } else {
251
+ // "StackablePerspective", shouldn't be a common case, but just in case
252
+ perspectiveKey = JSON.stringify(utilizedPerspective)
253
+ }
254
+ const sourceKey = createSourceKey(instance, source)
167
255
 
168
- if (!projectId || !dataset) {
169
- throw new Error('This API requires a project ID and dataset configured.')
256
+ return {
257
+ name: `${sourceKey.name}:${perspectiveKey}`,
258
+ source: sourceKey.source,
259
+ perspective: utilizedPerspective,
170
260
  }
171
- return {name: `${projectId}.${dataset}`}
172
261
  })
173
262
 
174
263
  /**