@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
@@ -16,27 +16,34 @@ import {
16
16
  tap,
17
17
  } from 'rxjs'
18
18
 
19
+ import {isDatasetSource} from '../config/sanityConfig'
19
20
  import {getQueryState, resolveQuery} from '../query/queryStore'
20
- import {type BoundDatasetKey} from '../store/createActionBinder'
21
+ import {type BoundPerspectiveKey} from '../store/createActionBinder'
21
22
  import {type StoreContext} from '../store/defineStore'
22
23
  import {
23
24
  createProjectionQuery,
24
25
  processProjectionQuery,
25
26
  type ProjectionQueryResult,
26
27
  } from './projectionQuery'
28
+ import {buildStatusQueryIds, processStatusQueryResults} from './statusQuery'
27
29
  import {type ProjectionStoreState} from './types'
28
- import {PROJECTION_PERSPECTIVE, PROJECTION_TAG} from './util'
30
+ import {PROJECTION_TAG} from './util'
29
31
 
30
32
  const BATCH_DEBOUNCE_TIME = 50
31
33
 
32
34
  const isSetEqual = <T>(a: Set<T>, b: Set<T>) =>
33
35
  a.size === b.size && Array.from(a).every((i) => b.has(i))
34
36
 
37
+ interface StatusQueryResult {
38
+ _id: string
39
+ _updatedAt: string
40
+ }
41
+
35
42
  export const subscribeToStateAndFetchBatches = ({
36
43
  state,
37
44
  instance,
38
- key: {projectId, dataset},
39
- }: StoreContext<ProjectionStoreState, BoundDatasetKey>): Subscription => {
45
+ key: {source, perspective},
46
+ }: StoreContext<ProjectionStoreState, BoundPerspectiveKey>): Subscription => {
40
47
  const documentProjections$ = state.observable.pipe(
41
48
  map((s) => s.documentProjections),
42
49
  distinctUntilChanged(isEqual),
@@ -92,34 +99,43 @@ export const subscribeToStateAndFetchBatches = ({
92
99
  const {query, params} = createProjectionQuery(ids, documentProjections)
93
100
  const controller = new AbortController()
94
101
 
95
- return new Observable<ProjectionQueryResult[]>((observer) => {
102
+ // Build status query IDs and query
103
+ const statusQueryIds = buildStatusQueryIds(ids, perspective)
104
+ const statusQuery = `*[_id in $statusIds]{_id, _updatedAt}`
105
+ const statusParams = {statusIds: statusQueryIds}
106
+
107
+ const projectionQuery$ = new Observable<ProjectionQueryResult[]>((observer) => {
96
108
  const {getCurrent, observable} = getQueryState<ProjectionQueryResult[]>(instance, {
97
- query,
98
- params,
99
- projectId,
100
- dataset,
101
- tag: PROJECTION_TAG,
102
- perspective: PROJECTION_PERSPECTIVE,
109
+ ...{
110
+ query,
111
+ params,
112
+ tag: PROJECTION_TAG,
113
+ perspective,
114
+ },
115
+ // temporary guard here until we're ready for everything to be queried via global API
116
+ ...(source && !isDatasetSource(source) ? {source} : {}),
103
117
  })
104
118
 
105
- const source$ = defer(() => {
119
+ const querySource$ = defer(() => {
106
120
  if (getCurrent() === undefined) {
107
121
  return from(
108
122
  resolveQuery<ProjectionQueryResult[]>(instance, {
109
- query,
110
- params,
111
- projectId,
112
- dataset,
113
- tag: PROJECTION_TAG,
114
- perspective: PROJECTION_PERSPECTIVE,
115
- signal: controller.signal,
123
+ ...{
124
+ query,
125
+ params,
126
+ tag: PROJECTION_TAG,
127
+ signal: controller.signal,
128
+ perspective,
129
+ },
130
+ // temporary guard here until we're ready for everything to be queried via global API in v3
131
+ ...(source && !isDatasetSource(source) ? {source} : {}),
116
132
  }),
117
133
  ).pipe(switchMap(() => observable))
118
134
  }
119
135
  return observable
120
136
  }).pipe(filter((result): result is ProjectionQueryResult[] => result !== undefined))
121
137
 
122
- const subscription = source$.subscribe(observer)
138
+ const subscription = querySource$.subscribe(observer)
123
139
 
124
140
  return () => {
125
141
  if (!controller.signal.aborted) {
@@ -127,16 +143,79 @@ export const subscribeToStateAndFetchBatches = ({
127
143
  }
128
144
  subscription.unsubscribe()
129
145
  }
130
- }).pipe(map((data) => ({data, ids})))
146
+ })
147
+
148
+ const statusQuery$ = new Observable<StatusQueryResult[]>((observer) => {
149
+ const {getCurrent, observable} = getQueryState<StatusQueryResult[]>(instance, {
150
+ ...{
151
+ query: statusQuery,
152
+ params: statusParams,
153
+ tag: PROJECTION_TAG,
154
+ perspective: 'raw',
155
+ },
156
+ // temporary guard here until we're ready for everything to be queried via global API
157
+ ...(source && !isDatasetSource(source) ? {source} : {}),
158
+ })
159
+
160
+ const statusQuerySource$ = defer(() => {
161
+ if (getCurrent() === undefined) {
162
+ return from(
163
+ resolveQuery<StatusQueryResult[]>(instance, {
164
+ ...{
165
+ query: statusQuery,
166
+ params: statusParams,
167
+ tag: PROJECTION_TAG,
168
+ signal: controller.signal,
169
+ perspective: 'raw',
170
+ },
171
+ // temporary guard here until we're ready for everything to be queried via global API
172
+ ...(source && !isDatasetSource(source) ? {source} : {}),
173
+ }),
174
+ ).pipe(switchMap(() => observable))
175
+ }
176
+ return observable
177
+ }).pipe(filter((result): result is StatusQueryResult[] => result !== undefined))
178
+
179
+ const subscription = statusQuerySource$.subscribe(observer)
180
+
181
+ return () => {
182
+ subscription.unsubscribe()
183
+ }
184
+ })
185
+
186
+ // Combine both streams: emit whenever either has a new value (after both have
187
+ // emitted at least one defined value). This keeps reacting to query store updates.
188
+ return combineLatest([projectionQuery$, statusQuery$]).pipe(
189
+ filter(
190
+ (pair): pair is [ProjectionQueryResult[], StatusQueryResult[]] =>
191
+ pair[0] !== undefined && pair[1] !== undefined,
192
+ ),
193
+ map(([projection, status]) => ({
194
+ data: projection,
195
+ ids,
196
+ statusResults: status,
197
+ })),
198
+ )
131
199
  }),
132
- map(({ids, data}) =>
133
- processProjectionQuery({
134
- projectId,
135
- dataset,
200
+ map(({ids, data, statusResults}) => {
201
+ // Process status results into documentStatuses (same shape as _status returned to users)
202
+ const documentStatuses = processStatusQueryResults(statusResults)
203
+ state.set('updateStatuses', (prev) => ({
204
+ documentStatuses: {
205
+ ...prev.documentStatuses,
206
+ ...documentStatuses,
207
+ },
208
+ }))
209
+
210
+ // Assemble projection values with _status (buildStatusForDocument in statusQuery)
211
+ const currentState = state.get()
212
+ return processProjectionQuery({
136
213
  ids,
137
214
  results: data,
138
- }),
139
- ),
215
+ documentStatuses: currentState.documentStatuses,
216
+ perspective: perspective,
217
+ })
218
+ }),
140
219
  )
141
220
  .subscribe({
142
221
  next: (processedValues) => {
@@ -29,6 +29,11 @@ interface DocumentProjectionSubscriptions {
29
29
  }
30
30
  }
31
31
 
32
+ interface DocumentStatus {
33
+ lastEditedDraftAt?: string
34
+ lastEditedPublishedAt?: string
35
+ lastEditedVersionAt?: string
36
+ }
32
37
  export interface ProjectionStoreState<TValue extends object = object> {
33
38
  /**
34
39
  * A map of document IDs to their projection values, organized by projection hash
@@ -50,4 +55,11 @@ export interface ProjectionStoreState<TValue extends object = object> {
50
55
  subscriptions: {
51
56
  [documentId: string]: DocumentProjectionSubscriptions
52
57
  }
58
+
59
+ /**
60
+ * A map of document IDs to their status information (same shape as _status returned to users)
61
+ */
62
+ documentStatuses: {
63
+ [documentId: string]: DocumentStatus
64
+ }
53
65
  }
@@ -1,5 +1,4 @@
1
1
  export const PROJECTION_TAG = 'projection'
2
- export const PROJECTION_PERSPECTIVE = 'raw'
3
2
  export const PROJECTION_STATE_CLEAR_DELAY = 1000
4
3
 
5
4
  export const STABLE_EMPTY_PROJECTION = {
@@ -3,6 +3,7 @@ import {delay, filter, firstValueFrom, Observable, of, Subject} from 'rxjs'
3
3
  import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
5
  import {getClientState} from '../client/clientStore'
6
+ import {isCanvasSource} from '../config/sanityConfig'
6
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
8
  import {type StateSource} from '../store/createStateSourceAction'
8
9
  import {getQueryState, resolveQuery} from './queryStore'
@@ -16,6 +17,23 @@ vi.mock('../client/clientStore', () => ({
16
17
  getClientState: vi.fn(),
17
18
  }))
18
19
 
20
+ // Avoid initializing releases store/perspectives in these tests to prevent
21
+ // side-effect queries (e.g. releases::all()) that would duplicate fetch calls
22
+ vi.mock('../releases/getPerspectiveState', async () => {
23
+ const actual = await vi.importActual('../releases/getPerspectiveState')
24
+ return {
25
+ ...actual,
26
+ getPerspectiveState: vi.fn(
27
+ (_instance, options?: {perspective?: unknown}) =>
28
+ ({
29
+ subscribe: () => () => {},
30
+ getCurrent: () => (options?.perspective ?? 'drafts') as unknown,
31
+ observable: of((options?.perspective ?? 'drafts') as unknown),
32
+ }) as unknown as StateSource<unknown>,
33
+ ),
34
+ }
35
+ })
36
+
19
37
  describe('queryStore', () => {
20
38
  let instance: SanityInstance
21
39
  let liveEvents: Subject<LiveEvent>
@@ -56,6 +74,7 @@ describe('queryStore', () => {
56
74
  })
57
75
 
58
76
  afterEach(() => {
77
+ vi.mocked(getClientState).mockClear()
59
78
  instance.dispose()
60
79
  })
61
80
 
@@ -428,4 +447,49 @@ describe('queryStore', () => {
428
447
 
429
448
  base.dispose()
430
449
  })
450
+
451
+ it('uses source from params when passed in query options (listenForNewSubscribersAndFetch)', async () => {
452
+ const query = '*[_type == "movie"]'
453
+ const mediaLibrarySource = {mediaLibraryId: 'ml123'}
454
+
455
+ const state = getQueryState(instance, {query, source: mediaLibrarySource})
456
+ const unsubscribe = state.subscribe()
457
+
458
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
459
+
460
+ // Verify getClientState was called with the source from params in listenForNewSubscribersAndFetch
461
+ // This call includes projectId, dataset, and source
462
+ expect(getClientState).toHaveBeenCalledWith(
463
+ instance,
464
+ expect.objectContaining({
465
+ source: expect.objectContaining({
466
+ mediaLibraryId: 'ml123',
467
+ }),
468
+ }),
469
+ )
470
+
471
+ unsubscribe()
472
+ })
473
+
474
+ it('uses source from store context key when not a dataset source (listenToLiveClientAndSetLastLiveEventIds)', async () => {
475
+ const query = '*[_type == "movie"]'
476
+ const canvasSource = {canvasId: 'canvas456'}
477
+
478
+ const state = getQueryState(instance, {query, source: canvasSource})
479
+ const unsubscribe = state.subscribe()
480
+
481
+ await firstValueFrom(state.observable.pipe(filter((i) => i !== undefined)))
482
+
483
+ // Verify getClientState was called with the canvas source for live events
484
+ // The source is extracted from the store key and passed when it's not a dataset source
485
+ // This call only has apiVersion and source (no projectId/dataset)
486
+ const calls = vi.mocked(getClientState).mock.calls
487
+ const liveClientCall = calls.find(
488
+ ([_instance, options]) =>
489
+ isCanvasSource(options.source!) && options.source.canvasId === 'canvas456',
490
+ )
491
+ expect(liveClientCall).toBeDefined()
492
+
493
+ unsubscribe()
494
+ })
431
495
  })
@@ -14,6 +14,7 @@ import {
14
14
  mergeMap,
15
15
  NEVER,
16
16
  Observable,
17
+ of,
17
18
  pairwise,
18
19
  race,
19
20
  share,
@@ -23,9 +24,18 @@ import {
23
24
  } from 'rxjs'
24
25
 
25
26
  import {getClientState} from '../client/clientStore'
26
- import {type DatasetHandle, type DocumentSource} from '../config/sanityConfig'
27
+ import {type DatasetHandle, isDatasetSource} from '../config/sanityConfig'
28
+ /*
29
+ * Although this is an import dependency cycle, it is not a logical cycle:
30
+ * 1. queryStore uses getPerspectiveState when resolving release perspectives
31
+ * 2. getPerspectiveState uses releasesStore as a data source
32
+ * 3. releasesStore uses queryStore as a data source
33
+ * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
34
+ */
35
+ // eslint-disable-next-line import/no-cycle
27
36
  import {getPerspectiveState} from '../releases/getPerspectiveState'
28
- import {bindActionBySource} from '../store/createActionBinder'
37
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
38
+ import {bindActionBySource, type BoundSourceKey} from '../store/createActionBinder'
29
39
  import {type SanityInstance} from '../store/createSanityInstance'
30
40
  import {
31
41
  createStateSourceAction,
@@ -58,11 +68,12 @@ export interface QueryOptions<
58
68
  TQuery extends string = string,
59
69
  TDataset extends string = string,
60
70
  TProjectId extends string = string,
61
- > extends Pick<ResponseQueryOptions, 'useCdn' | 'cache' | 'next' | 'cacheMode' | 'tag'>,
71
+ >
72
+ extends
73
+ Pick<ResponseQueryOptions, 'useCdn' | 'cache' | 'next' | 'cacheMode' | 'tag'>,
62
74
  DatasetHandle<TDataset, TProjectId> {
63
75
  query: TQuery
64
76
  params?: Record<string, unknown>
65
- source?: DocumentSource
66
77
  }
67
78
 
68
79
  /**
@@ -105,7 +116,7 @@ function normalizeOptionsWithPerspective(
105
116
  }
106
117
  }
107
118
 
108
- const queryStore = defineStore<QueryStoreState>({
119
+ const queryStore = defineStore<QueryStoreState, BoundSourceKey>({
109
120
  name: 'QueryStore',
110
121
  getInitialState: () => ({queries: {}}),
111
122
  initialize(context) {
@@ -166,9 +177,13 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
166
177
  ...restOptions
167
178
  } = parseQueryKey(group$.key)
168
179
 
169
- const perspective$ = getPerspectiveState(instance, {
170
- perspective: perspectiveFromOptions,
171
- }).observable.pipe(filter(Boolean))
180
+ // Short-circuit perspective resolution for non-release perspectives to avoid
181
+ // touching the releases store (and its initialization) unnecessarily.
182
+ const perspective$ = isReleasePerspective(perspectiveFromOptions)
183
+ ? getPerspectiveState(instance, {
184
+ perspective: perspectiveFromOptions,
185
+ }).observable.pipe(filter(Boolean))
186
+ : of(perspectiveFromOptions ?? QUERY_STORE_DEFAULT_PERSPECTIVE)
172
187
 
173
188
  const client$ = getClientState(instance, {
174
189
  apiVersion: QUERY_STORE_API_VERSION,
@@ -177,8 +192,12 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
177
192
  source,
178
193
  }).observable
179
194
 
180
- return combineLatest([lastLiveEventId$, client$, perspective$]).pipe(
181
- switchMap(([lastLiveEventId, client, perspective]) =>
195
+ return combineLatest({
196
+ lastLiveEventId: lastLiveEventId$,
197
+ client: client$,
198
+ perspective: perspective$,
199
+ }).pipe(
200
+ switchMap(({lastLiveEventId, client, perspective}) =>
182
201
  client.observable.fetch(query, params, {
183
202
  ...restOptions,
184
203
  perspective,
@@ -206,9 +225,12 @@ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QuerySt
206
225
  const listenToLiveClientAndSetLastLiveEventIds = ({
207
226
  state,
208
227
  instance,
209
- }: StoreContext<QueryStoreState>) => {
228
+ key: {source},
229
+ }: StoreContext<QueryStoreState, BoundSourceKey>) => {
210
230
  const liveMessages$ = getClientState(instance, {
211
231
  apiVersion: QUERY_STORE_API_VERSION,
232
+ // temporary guard here until we're ready for everything to be queried via global api
233
+ ...(source && !isDatasetSource(source) ? {source} : {}),
212
234
  }).observable.pipe(
213
235
  switchMap((client) =>
214
236
  defer(() =>
@@ -2,14 +2,13 @@ import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
2
2
  import {describe, expect, it, vi} from 'vitest'
3
3
 
4
4
  import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
5
+ import {getQueryState} from '../query/queryStore'
5
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
- import {listenQuery as mockListenQuery} from '../utils/listenQuery'
7
+ import {type StateSource} from '../store/createStateSourceAction'
7
8
  import {getPerspectiveState} from './getPerspectiveState'
8
9
  import {type ReleaseDocument} from './releasesStore'
9
10
 
10
- vi.mock('../utils/listenQuery', () => ({
11
- listenQuery: vi.fn(),
12
- }))
11
+ vi.mock('../query/queryStore')
13
12
 
14
13
  vi.mock('../client/clientStore', () => ({
15
14
  getClientState: vi.fn(() => ({
@@ -21,7 +20,7 @@ vi.mock('../client/clientStore', () => ({
21
20
 
22
21
  describe('getPerspectiveState', () => {
23
22
  let instance: SanityInstance
24
- let mockReleasesQuerySubject: Subject<ReleaseDocument[]>
23
+ let mockReleasesQuerySubject: Subject<ReleaseDocument[] | undefined>
25
24
 
26
25
  const release1 = {
27
26
  _id: 'release-1',
@@ -44,8 +43,12 @@ describe('getPerspectiveState', () => {
44
43
  beforeEach(() => {
45
44
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
46
45
 
47
- mockReleasesQuerySubject = new Subject<ReleaseDocument[]>()
48
- vi.mocked(mockListenQuery).mockReturnValue(mockReleasesQuerySubject.asObservable())
46
+ mockReleasesQuerySubject = new Subject<ReleaseDocument[] | undefined>()
47
+ vi.mocked(getQueryState).mockReturnValue({
48
+ subscribe: () => () => {},
49
+ getCurrent: () => undefined,
50
+ observable: mockReleasesQuerySubject.asObservable(),
51
+ } as StateSource<ReleaseDocument[] | undefined>)
49
52
  })
50
53
 
51
54
  afterEach(() => {
@@ -95,7 +98,7 @@ describe('getPerspectiveState', () => {
95
98
  take(1),
96
99
  ),
97
100
  )
98
- expect(perspective).toEqual(['drafts', 'release1'])
101
+ expect(perspective).toEqual(['release1', 'drafts'])
99
102
  })
100
103
 
101
104
  it('should calculate perspective including multiple releases up to the specified releaseName', async () => {
@@ -108,13 +111,13 @@ describe('getPerspectiveState', () => {
108
111
  take(1),
109
112
  ),
110
113
  )
111
- expect(perspective).toEqual(['drafts', 'release1', 'release2'])
114
+ expect(perspective).toEqual(['release2', 'release1', 'drafts'])
112
115
  })
113
116
 
114
117
  it('should filter excluded perspectives', async () => {
115
118
  const perspectiveConfig: ReleasePerspective = {
116
119
  releaseName: 'release2',
117
- excludedPerspectives: ['drafts', 'release1'],
120
+ excludedPerspectives: ['release1', 'drafts'],
118
121
  }
119
122
  const options: PerspectiveHandle = {perspective: perspectiveConfig}
120
123
  const state = getPerspectiveState(instance, options)
@@ -159,7 +162,7 @@ describe('getPerspectiveState', () => {
159
162
  const state2 = getPerspectiveState(instance, options2)
160
163
  const perspective2 = state2.getCurrent()
161
164
 
162
- expect(perspective2).toEqual(['drafts', 'release1'])
165
+ expect(perspective2).toEqual(['release1', 'drafts'])
163
166
  })
164
167
 
165
168
  it('should handle changes in activeReleases (cache test)', async () => {
@@ -173,7 +176,7 @@ describe('getPerspectiveState', () => {
173
176
  take(1),
174
177
  ),
175
178
  )
176
- expect(perspective1).toEqual(['drafts', 'release1'])
179
+ expect(perspective1).toEqual(['release1', 'drafts'])
177
180
 
178
181
  const updatedActiveReleases = [release1]
179
182
  mockReleasesQuerySubject.next(updatedActiveReleases)
@@ -184,10 +187,10 @@ describe('getPerspectiveState', () => {
184
187
  take(1),
185
188
  ),
186
189
  )
187
- expect(perspectiveAfterUpdate).toEqual(['drafts', 'release1'])
190
+ expect(perspectiveAfterUpdate).toEqual(['release1', 'drafts'])
188
191
 
189
192
  const state2 = getPerspectiveState(instance, options)
190
193
  const perspectiveNewCall = state2.getCurrent()
191
- expect(perspectiveNewCall).toEqual(['drafts', 'release1'])
194
+ expect(perspectiveNewCall).toEqual(['release1', 'drafts'])
192
195
  })
193
196
  })
@@ -1,17 +1,20 @@
1
1
  import {createSelector} from 'reselect'
2
2
 
3
- import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
4
- import {bindActionByDataset} from '../store/createActionBinder'
3
+ import {type PerspectiveHandle} from '../config/sanityConfig'
4
+ import {bindActionByDataset, type BoundStoreAction} from '../store/createActionBinder'
5
5
  import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
6
+ /*
7
+ * Although this is an import dependency cycle, it is not a logical cycle:
8
+ * 1. getPerspectiveState uses releasesStore as a data source
9
+ * 2. releasesStore uses queryStore as a data source
10
+ * 3. queryStore calls getPerspectiveState for computing release perspectives
11
+ * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
12
+ */
13
+ // eslint-disable-next-line import/no-cycle
6
14
  import {releasesStore, type ReleasesStoreState} from './releasesStore'
15
+ import {isReleasePerspective} from './utils/isReleasePerspective'
7
16
  import {sortReleases} from './utils/sortReleases'
8
17
 
9
- function isReleasePerspective(
10
- perspective: PerspectiveHandle['perspective'],
11
- ): perspective is ReleasePerspective {
12
- return typeof perspective === 'object' && perspective !== null && 'releaseName' in perspective
13
- }
14
-
15
18
  const DEFAULT_PERSPECTIVE = 'drafts'
16
19
 
17
20
  // Cache for options
@@ -50,6 +53,44 @@ const memoizedOptionsSelector = createSelector(
50
53
  },
51
54
  )
52
55
 
56
+ // Lazily bind the action itself to avoid circular import initialization issues with `releasesStore`
57
+ const _getPerspectiveStateSelector = createStateSourceAction({
58
+ selector: createSelector(
59
+ [selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector],
60
+ (instancePerspective, activeReleases, memoizedOptions) => {
61
+ const perspective = memoizedOptions?.perspective ?? instancePerspective ?? DEFAULT_PERSPECTIVE
62
+
63
+ if (!isReleasePerspective(perspective)) return perspective
64
+
65
+ // if there are no active releases we can't compute the release perspective
66
+ if (!activeReleases || activeReleases.length === 0) return undefined
67
+
68
+ const releaseNames = sortReleases(activeReleases).map((release) => release.name)
69
+ const index = releaseNames.findIndex((name) => name === perspective.releaseName)
70
+
71
+ if (index < 0) {
72
+ throw new Error(`Release "${perspective.releaseName}" not found in active releases`)
73
+ }
74
+
75
+ const filteredReleases = releaseNames.slice(0, index + 1) // Include the release itself
76
+
77
+ return ['drafts', ...filteredReleases]
78
+ .filter((name) => !perspective.excludedPerspectives?.includes(name))
79
+ .reverse()
80
+ },
81
+ ),
82
+ })
83
+
84
+ type OmitFirst<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never
85
+ type SelectorParams = OmitFirst<Parameters<typeof _getPerspectiveStateSelector>>
86
+ type BoundGetPerspectiveState = BoundStoreAction<
87
+ ReleasesStoreState,
88
+ SelectorParams,
89
+ ReturnType<typeof _getPerspectiveStateSelector>
90
+ >
91
+
92
+ let _boundGetPerspectiveState: BoundGetPerspectiveState | undefined
93
+
53
94
  /**
54
95
  * Provides a subscribable state source for a "perspective" for the Sanity client,
55
96
  * which is used to fetch documents as though certain Content Releases are active.
@@ -62,33 +103,12 @@ const memoizedOptionsSelector = createSelector(
62
103
  *
63
104
  * @public
64
105
  */
65
- export const getPerspectiveState = bindActionByDataset(
66
- releasesStore,
67
- createStateSourceAction({
68
- selector: createSelector(
69
- [selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector],
70
- (instancePerspective, activeReleases, memoizedOptions) => {
71
- const perspective =
72
- memoizedOptions?.perspective ?? instancePerspective ?? DEFAULT_PERSPECTIVE
73
-
74
- if (!isReleasePerspective(perspective)) return perspective
75
-
76
- // if there are no active releases we can't compute the release perspective
77
- if (!activeReleases || activeReleases.length === 0) return undefined
78
-
79
- const releaseNames = sortReleases(activeReleases).map((release) => release.name)
80
- const index = releaseNames.findIndex((name) => name === perspective.releaseName)
81
-
82
- if (index < 0) {
83
- throw new Error(`Release "${perspective.releaseName}" not found in active releases`)
84
- }
85
-
86
- const filteredReleases = releaseNames.slice(0, index + 1) // Include the release itself
87
-
88
- return ['drafts', ...filteredReleases].filter(
89
- (name) => !perspective.excludedPerspectives?.includes(name),
90
- )
91
- },
92
- ),
93
- }),
94
- )
106
+ export const getPerspectiveState: BoundGetPerspectiveState = (...args) => {
107
+ if (!_boundGetPerspectiveState) {
108
+ _boundGetPerspectiveState = bindActionByDataset(
109
+ releasesStore,
110
+ _getPerspectiveStateSelector,
111
+ ) as BoundGetPerspectiveState
112
+ }
113
+ return _boundGetPerspectiveState(...args)
114
+ }