@sanity/sdk 2.15.0 → 2.17.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 (41) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
  4. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  5. package/dist/_chunks-es/version.js +1 -1
  6. package/dist/index.d.ts +69 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +79 -32
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -12
  11. package/src/_exports/index.ts +2 -0
  12. package/src/auth/authStore.test.ts +1 -1
  13. package/src/auth/handleAuthCallback.test.ts +1 -1
  14. package/src/auth/refreshStampedToken.test.ts +199 -477
  15. package/src/auth/refreshStampedToken.ts +67 -195
  16. package/src/client/liveEvents.test.ts +151 -0
  17. package/src/client/liveEvents.ts +107 -0
  18. package/src/document/actions.ts +36 -0
  19. package/src/document/documentStore.test.ts +132 -1
  20. package/src/document/documentStore.ts +8 -0
  21. package/src/document/events.ts +32 -0
  22. package/src/document/listen.ts +8 -0
  23. package/src/document/processActions/edit.ts +19 -14
  24. package/src/document/processActions/shared.ts +55 -8
  25. package/src/document/processActions.test.ts +145 -0
  26. package/src/document/reducers.test.ts +396 -0
  27. package/src/document/reducers.ts +62 -1
  28. package/src/document/sharedListener.test.ts +1 -0
  29. package/src/document/sharedListener.ts +2 -0
  30. package/src/query/queryStore.test.ts +232 -38
  31. package/src/query/queryStore.ts +47 -47
  32. package/src/query/reducers.test.ts +0 -42
  33. package/src/query/reducers.ts +0 -16
  34. package/src/releases/getPerspectiveState.test.ts +3 -8
  35. package/src/releases/getPerspectiveState.ts +6 -19
  36. package/src/releases/observeReleases.test.ts +127 -0
  37. package/src/releases/observeReleases.ts +102 -0
  38. package/src/releases/releasesStore.test.ts +51 -53
  39. package/src/releases/releasesStore.ts +32 -32
  40. package/src/releases/utils/sortReleases.test.ts +36 -0
  41. package/src/releases/utils/sortReleases.ts +14 -9
@@ -2,8 +2,6 @@ import {describe, expect, it} from 'vitest'
2
2
 
3
3
  import {
4
4
  addSubscriber,
5
- cancelQuery,
6
- initializeQuery,
7
5
  type QueryStoreState,
8
6
  removeSubscriber,
9
7
  setLastLiveEventId,
@@ -109,44 +107,4 @@ describe('Query Reducers', () => {
109
107
  expect(newState.queries).not.toHaveProperty('q8')
110
108
  })
111
109
  })
112
-
113
- describe('cancelQuery', () => {
114
- it('should return state unchanged if key does not exist', () => {
115
- const state: QueryStoreState = {queries: {}}
116
- const reducer = cancelQuery('nonexistent')
117
- const newState = reducer(state)
118
- expect(newState).toBe(state)
119
- })
120
-
121
- it('should return state unchanged if query has subscribers', () => {
122
- const state: QueryStoreState = {queries: {q9: {subscribers: ['sub1']}}}
123
- const reducer = cancelQuery('q9')
124
- const newState = reducer(state)
125
- expect(newState).toBe(state)
126
- })
127
-
128
- it('should remove the query if no subscribers exist', () => {
129
- const state: QueryStoreState = {queries: {q10: {subscribers: []}}}
130
- const reducer = cancelQuery('q10')
131
- const newState = reducer(state)
132
- expect(newState.queries).not.toHaveProperty('q10')
133
- })
134
- })
135
-
136
- describe('initializeQuery', () => {
137
- it('should return state unchanged if query already exists', () => {
138
- const existing = {subscribers: ['sub1']}
139
- const state: QueryStoreState = {queries: {q11: existing}}
140
- const reducer = initializeQuery('q11')
141
- const newState = reducer(state)
142
- expect(newState).toBe(state)
143
- })
144
-
145
- it('should add the query with empty subscribers if it does not exist', () => {
146
- const state: QueryStoreState = {queries: {}}
147
- const reducer = initializeQuery('q12')
148
- const newState = reducer(state)
149
- expect(newState.queries['q12']).toEqual({subscribers: []})
150
- })
151
- })
152
110
  })
@@ -57,19 +57,3 @@ export const removeSubscriber =
57
57
  if (!subscribers.length) return {...prev, queries: omitProperty(prev.queries, key)}
58
58
  return {...prev, queries: {...prev.queries, [key]: {...prevQuery, subscribers}}}
59
59
  }
60
-
61
- export const cancelQuery =
62
- (key: string) =>
63
- (prev: QueryStoreState): QueryStoreState => {
64
- const prevQuery = prev.queries[key]
65
- if (!prevQuery) return prev
66
- if (prevQuery.subscribers.length) return prev
67
- return {...prev, queries: omitProperty(prev.queries, key)}
68
- }
69
-
70
- export const initializeQuery =
71
- (key: string) =>
72
- (prev: QueryStoreState): QueryStoreState => {
73
- if (prev.queries[key]) return prev
74
- return {...prev, queries: {...prev.queries, [key]: {subscribers: []}}}
75
- }
@@ -3,12 +3,11 @@ import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
3
3
  import {describe, expect, it, vi} from 'vitest'
4
4
 
5
5
  import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
6
- import {getQueryState} from '../query/queryStore'
7
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
- import {type StateSource} from '../store/createStateSourceAction'
9
7
  import {getPerspectiveState} from './getPerspectiveState'
8
+ import {observeReleases} from './observeReleases'
10
9
 
11
- vi.mock('../query/queryStore')
10
+ vi.mock('./observeReleases')
12
11
 
13
12
  vi.mock('../client/clientStore', () => ({
14
13
  getClientState: vi.fn(() => ({
@@ -44,11 +43,7 @@ describe('getPerspectiveState', () => {
44
43
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
45
44
 
46
45
  mockReleasesQuerySubject = new Subject<ReleaseDocument[] | undefined>()
47
- vi.mocked(getQueryState).mockReturnValue({
48
- subscribe: () => () => {},
49
- getCurrent: () => undefined,
50
- observable: mockReleasesQuerySubject.asObservable(),
51
- } as StateSource<ReleaseDocument[] | undefined>)
46
+ vi.mocked(observeReleases).mockReturnValue(mockReleasesQuerySubject.asObservable())
52
47
  })
53
48
 
54
49
  afterEach(() => {
@@ -3,14 +3,6 @@ import {createSelector} from 'reselect'
3
3
  import {type DocumentResource, type PerspectiveHandle} from '../config/sanityConfig'
4
4
  import {bindActionByResource, 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
14
6
  import {releasesStore, type ReleasesStoreState} from './releasesStore'
15
7
  import {isReleasePerspective} from './utils/isReleasePerspective'
16
8
  import {sortReleases} from './utils/sortReleases'
@@ -53,7 +45,6 @@ const memoizedOptionsSelector = createSelector(
53
45
  },
54
46
  )
55
47
 
56
- // Lazily bind the action itself to avoid circular import initialization issues with `releasesStore`
57
48
  const _getPerspectiveStateSelector = createStateSourceAction({
58
49
  selector: createSelector(
59
50
  [selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector],
@@ -89,7 +80,10 @@ type BoundGetPerspectiveState = BoundStoreAction<
89
80
  ReturnType<typeof _getPerspectiveStateSelector>
90
81
  >
91
82
 
92
- let _boundGetPerspectiveState: BoundGetPerspectiveState | undefined
83
+ const _boundGetPerspectiveState = bindActionByResource(
84
+ releasesStore,
85
+ _getPerspectiveStateSelector,
86
+ ) as BoundGetPerspectiveState
93
87
 
94
88
  /**
95
89
  * Provides a subscribable state source for a "perspective" for the Sanity client,
@@ -103,13 +97,6 @@ let _boundGetPerspectiveState: BoundGetPerspectiveState | undefined
103
97
  *
104
98
  * @public
105
99
  */
106
- export const getPerspectiveState: BoundGetPerspectiveState = (instance, ...rest) => {
107
- if (!_boundGetPerspectiveState) {
108
- _boundGetPerspectiveState = bindActionByResource(
109
- releasesStore,
110
- _getPerspectiveStateSelector,
111
- ) as BoundGetPerspectiveState
112
- }
100
+ export const getPerspectiveState: BoundGetPerspectiveState = (instance, ...rest) =>
113
101
  // bindActionByResource keyFn destructures { resource } from the first param, so pass {} when no options
114
- return _boundGetPerspectiveState(instance, ...(rest.length ? rest : [{}]))
115
- }
102
+ _boundGetPerspectiveState(instance, ...(rest.length ? rest : [{}]))
@@ -0,0 +1,127 @@
1
+ import {type LiveEventMessage, type ReleaseDocument, type SanityClient} from '@sanity/client'
2
+ import {of, Subject} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {observeLiveEvents} from '../client/liveEvents'
7
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
+ import {type StateSource} from '../store/createStateSourceAction'
9
+ import {observeReleases} from './observeReleases'
10
+
11
+ vi.mock('../client/clientStore', () => ({getClientState: vi.fn()}))
12
+ vi.mock('../client/liveEvents', () => ({observeLiveEvents: vi.fn()}))
13
+
14
+ describe('observeReleases', () => {
15
+ let instance: SanityInstance
16
+ let liveMessages: Subject<LiveEventMessage>
17
+ let fetch: ReturnType<typeof vi.fn>
18
+
19
+ const release = {
20
+ _id: 'r1',
21
+ _type: 'system.release',
22
+ name: 'r1',
23
+ metadata: {title: 'R1', releaseType: 'asap'},
24
+ } as ReleaseDocument
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks()
28
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
29
+ liveMessages = new Subject<LiveEventMessage>()
30
+
31
+ fetch = vi.fn().mockReturnValue(of({result: [release], syncTags: ['s1:tag']}))
32
+ vi.mocked(getClientState).mockReturnValue({
33
+ observable: of({observable: {fetch}} as unknown as SanityClient),
34
+ } as StateSource<SanityClient>)
35
+ vi.mocked(observeLiveEvents).mockReturnValue(liveMessages.asObservable())
36
+ })
37
+
38
+ afterEach(() => {
39
+ instance.dispose()
40
+ })
41
+
42
+ it('emits undefined synchronously, then fetches releases through the raw perspective', () => {
43
+ const releases$ = observeReleases(instance, {onCorsError: vi.fn()})
44
+ const emissions: (ReleaseDocument[] | undefined)[] = []
45
+ const subscription = releases$.subscribe((releases) => emissions.push(releases))
46
+
47
+ // the synchronous undefined lets the releases store immediately record an
48
+ // empty list, so consumers (e.g. suspending React hooks) never hang on a
49
+ // fetch that has not resolved yet
50
+ expect(emissions).toEqual([undefined, [release]])
51
+ expect(fetch).toHaveBeenCalledWith(
52
+ 'releases::all()',
53
+ {},
54
+ expect.objectContaining({
55
+ perspective: 'raw',
56
+ tag: 'releases',
57
+ lastLiveEventId: undefined,
58
+ }),
59
+ )
60
+ subscription.unsubscribe()
61
+ })
62
+
63
+ it('refetches with the live event id when a message matches the sync tags', () => {
64
+ const releases$ = observeReleases(instance, {onCorsError: vi.fn()})
65
+ const emissions: (ReleaseDocument[] | undefined)[] = []
66
+ const subscription = releases$.subscribe((releases) => emissions.push(releases))
67
+
68
+ liveMessages.next({type: 'message', id: 'event-1', tags: ['s1:tag']} as LiveEventMessage)
69
+
70
+ expect(fetch).toHaveBeenCalledTimes(2)
71
+ expect(fetch).toHaveBeenLastCalledWith(
72
+ 'releases::all()',
73
+ {},
74
+ expect.objectContaining({lastLiveEventId: 'event-1'}),
75
+ )
76
+ expect(emissions).toHaveLength(3) // initial undefined + two fetch results
77
+ subscription.unsubscribe()
78
+ })
79
+
80
+ it('ignores live events that do not match the sync tags', () => {
81
+ const releases$ = observeReleases(instance, {onCorsError: vi.fn()})
82
+ const subscription = releases$.subscribe()
83
+
84
+ liveMessages.next({type: 'message', id: 'event-1', tags: ['s1:other']} as LiveEventMessage)
85
+
86
+ expect(fetch).toHaveBeenCalledTimes(1)
87
+ subscription.unsubscribe()
88
+ })
89
+
90
+ it('refetches for a matching message that arrived while a fetch was still in flight', () => {
91
+ const fetchResponses: Subject<{result: ReleaseDocument[]; syncTags: string[]}>[] = []
92
+ fetch.mockImplementation(() => {
93
+ const response$ = new Subject<{result: ReleaseDocument[]; syncTags: string[]}>()
94
+ fetchResponses.push(response$)
95
+ return response$
96
+ })
97
+
98
+ const releases$ = observeReleases(instance, {onCorsError: vi.fn()})
99
+ const emissions: (ReleaseDocument[] | undefined)[] = []
100
+ const subscription = releases$.subscribe((releases) => emissions.push(releases))
101
+
102
+ expect(fetch).toHaveBeenCalledTimes(1)
103
+
104
+ // A message lands while the initial fetch is in flight — its sync tags are
105
+ // not known yet, so it can't be evaluated right away…
106
+ liveMessages.next({type: 'message', id: 'event-1', tags: ['s1:tag']} as LiveEventMessage)
107
+ expect(fetch).toHaveBeenCalledTimes(1)
108
+
109
+ // …but once the fetch completes with matching tags, it must trigger a
110
+ // refetch instead of being dropped (which would leave the store stale)
111
+ fetchResponses[0].next({result: [release], syncTags: ['s1:tag']})
112
+ fetchResponses[0].complete()
113
+
114
+ expect(fetch).toHaveBeenCalledTimes(2)
115
+ expect(fetch).toHaveBeenLastCalledWith(
116
+ 'releases::all()',
117
+ {},
118
+ expect.objectContaining({lastLiveEventId: 'event-1'}),
119
+ )
120
+
121
+ fetchResponses[1].next({result: [release], syncTags: ['s1:tag']})
122
+ fetchResponses[1].complete()
123
+ expect(emissions).toHaveLength(3) // initial undefined + two fetch results
124
+
125
+ subscription.unsubscribe()
126
+ })
127
+ })
@@ -0,0 +1,102 @@
1
+ import {type ReleaseDocument, type SyncTag} from '@sanity/client'
2
+ import {
3
+ BehaviorSubject,
4
+ combineLatest,
5
+ distinctUntilChanged,
6
+ filter,
7
+ map,
8
+ type Observable,
9
+ startWith,
10
+ switchMap,
11
+ tap,
12
+ } from 'rxjs'
13
+
14
+ import {getClientState} from '../client/clientStore'
15
+ import {observeLiveEvents} from '../client/liveEvents'
16
+ import {type DocumentResource} from '../config/sanityConfig'
17
+ import {type SanityInstance} from '../store/createSanityInstance'
18
+
19
+ const RELEASES_QUERY = 'releases::all()'
20
+ const RELEASES_API_VERSION = 'v2025-05-06'
21
+
22
+ /**
23
+ * Options for {@link observeReleases}.
24
+ * @internal
25
+ */
26
+ export interface ObserveReleasesOptions {
27
+ /** Resource to scope the underlying client and live connection to. */
28
+ resource?: DocumentResource
29
+ /**
30
+ * Called when the live events connection fails due to a CORS
31
+ * misconfiguration. See `observeLiveEvents`.
32
+ */
33
+ onCorsError: (error: unknown) => void
34
+ }
35
+
36
+ /**
37
+ * Emits every release document (including archived and published), refetching
38
+ * whenever a Live Content API event matches the query's sync tags.
39
+ *
40
+ * This fetches directly through the client store — deliberately not via the
41
+ * query store — because release data is itself an input to query perspective
42
+ * resolution (`getPerspectiveState`); fetching through the query store would
43
+ * make the two stores mutually dependent.
44
+ *
45
+ * @internal
46
+ */
47
+ export function observeReleases(
48
+ instance: SanityInstance,
49
+ {resource, onCorsError}: ObserveReleasesOptions,
50
+ ): Observable<ReleaseDocument[] | undefined> {
51
+ const client$ = getClientState(instance, {
52
+ apiVersion: RELEASES_API_VERSION,
53
+ resource,
54
+ }).observable
55
+
56
+ return client$.pipe(
57
+ switchMap((client) => {
58
+ const syncTags$ = new BehaviorSubject<SyncTag[] | undefined>(undefined)
59
+
60
+ // Pair the latest live message with the latest known sync tags so that
61
+ // a message arriving while a fetch is still in flight is re-evaluated
62
+ // (and refetched if it matches) once that fetch's tags land — instead
63
+ // of being dropped and leaving the store stale until the next event.
64
+ const refetchEventId$ = combineLatest([
65
+ observeLiveEvents(instance, {resource, onCorsError}).pipe(startWith(undefined)),
66
+ syncTags$,
67
+ ]).pipe(
68
+ filter(
69
+ ([message, syncTags]) =>
70
+ message === undefined || message.tags.some((tag) => syncTags?.includes(tag)),
71
+ ),
72
+ map(([message]) => message?.id),
73
+ distinctUntilChanged(),
74
+ )
75
+
76
+ return refetchEventId$.pipe(
77
+ switchMap((lastLiveEventId) =>
78
+ client.observable.fetch<ReleaseDocument[]>(
79
+ RELEASES_QUERY,
80
+ {},
81
+ {
82
+ perspective: 'raw',
83
+ filterResponse: false,
84
+ returnQuery: false,
85
+ lastLiveEventId,
86
+ tag: 'releases',
87
+ },
88
+ ),
89
+ ),
90
+ tap((response) => {
91
+ syncTags$.next(response.syncTags)
92
+ }),
93
+ map((response) => response.result),
94
+ )
95
+ }),
96
+ // Emit synchronously on subscribe (mirroring StateSource semantics, which
97
+ // the releases store previously consumed) so the store can immediately
98
+ // record an empty list: consumers distinguish "no releases yet" from
99
+ // "never loaded", and the React hooks don't suspend on first load.
100
+ startWith(undefined),
101
+ )
102
+ }
@@ -2,13 +2,12 @@ import {type ReleaseDocument} from '@sanity/client'
2
2
  import {NEVER, Observable, type Observer, of, Subject} from 'rxjs'
3
3
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
4
 
5
- import {getQueryState, resolveQuery} from '../query/queryStore'
6
5
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
- import {type StateSource} from '../store/createStateSourceAction'
6
+ import {observeReleases} from './observeReleases'
8
7
  import {getActiveReleasesState, getAllReleasesState} from './releasesStore'
9
8
 
10
9
  // Mock dependencies
11
- vi.mock('../query/queryStore')
10
+ vi.mock('./observeReleases')
12
11
 
13
12
  describe('releasesStore', () => {
14
13
  let instance: SanityInstance
@@ -18,13 +17,7 @@ describe('releasesStore', () => {
18
17
 
19
18
  instance = createSanityInstance({projectId: 'test', dataset: 'test'})
20
19
 
21
- vi.mocked(getQueryState).mockReturnValue({
22
- subscribe: () => () => {},
23
- getCurrent: () => undefined,
24
- observable: NEVER as Observable<ReleaseDocument[] | undefined>,
25
- } as StateSource<ReleaseDocument[] | undefined>)
26
-
27
- vi.mocked(resolveQuery).mockResolvedValue(undefined)
20
+ vi.mocked(observeReleases).mockReturnValue(NEVER as Observable<ReleaseDocument[] | undefined>)
28
21
  })
29
22
 
30
23
  afterEach(() => {
@@ -35,13 +28,11 @@ describe('releasesStore', () => {
35
28
  const state = getActiveReleasesState(instance)
36
29
 
37
30
  expect(state.getCurrent()).toBeUndefined()
38
- expect(getQueryState).toHaveBeenCalledWith(
31
+ expect(observeReleases).toHaveBeenCalledWith(
39
32
  instance,
40
33
  expect.objectContaining({
41
- query: 'releases::all()',
42
- perspective: 'raw',
43
- tag: 'releases',
44
34
  resource: {dataset: 'test', projectId: 'test'},
35
+ onCorsError: expect.any(Function),
45
36
  }),
46
37
  )
47
38
  })
@@ -52,11 +43,7 @@ describe('releasesStore', () => {
52
43
  .fn<(observer: Observer<ReleaseDocument[] | undefined>) => () => void>()
53
44
  .mockReturnValue(teardown)
54
45
 
55
- vi.mocked(getQueryState).mockReturnValue({
56
- subscribe: () => () => {},
57
- getCurrent: () => undefined,
58
- observable: new Observable(subscriber),
59
- } as StateSource<ReleaseDocument[] | undefined>)
46
+ vi.mocked(observeReleases).mockReturnValue(new Observable(subscriber))
60
47
 
61
48
  // note that the order of the releases is important here -- they get sorted
62
49
  const mockReleases: ReleaseDocument[] = [
@@ -87,11 +74,7 @@ describe('releasesStore', () => {
87
74
 
88
75
  it('should update active releases state when the query emits new data', async () => {
89
76
  const releasesSubject = new Subject<ReleaseDocument[]>()
90
- vi.mocked(getQueryState).mockReturnValue({
91
- subscribe: () => () => {},
92
- getCurrent: () => undefined,
93
- observable: releasesSubject.asObservable(),
94
- } as StateSource<ReleaseDocument[] | undefined>)
77
+ vi.mocked(observeReleases).mockReturnValue(releasesSubject.asObservable())
95
78
 
96
79
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
97
80
 
@@ -133,12 +116,8 @@ describe('releasesStore', () => {
133
116
  })
134
117
 
135
118
  it('should handle empty array from the query', async () => {
136
- // Configure query to return an empty array
137
- vi.mocked(getQueryState).mockReturnValue({
138
- subscribe: () => () => {},
139
- getCurrent: () => [],
140
- observable: of([]),
141
- } as StateSource<ReleaseDocument[] | undefined>)
119
+ // Configure the releases source to return an empty array
120
+ vi.mocked(observeReleases).mockReturnValue(of([]))
142
121
 
143
122
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
144
123
 
@@ -149,32 +128,20 @@ describe('releasesStore', () => {
149
128
 
150
129
  it('should handle null/undefined from the query by defaulting to empty array', async () => {
151
130
  // Test null case
152
- vi.mocked(getQueryState).mockReturnValue({
153
- subscribe: () => () => {},
154
- getCurrent: () => null as unknown as ReleaseDocument[] | undefined,
155
- observable: of(null as unknown as ReleaseDocument[] | undefined),
156
- } as StateSource<ReleaseDocument[] | undefined>)
131
+ vi.mocked(observeReleases).mockReturnValue(of(null as unknown as ReleaseDocument[] | undefined))
157
132
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
158
133
  await new Promise((resolve) => setTimeout(resolve, 0))
159
134
  expect(state.getCurrent()).toEqual([])
160
135
 
161
136
  // Test undefined case
162
- vi.mocked(getQueryState).mockReturnValue({
163
- subscribe: () => () => {},
164
- getCurrent: () => undefined,
165
- observable: of(undefined),
166
- } as StateSource<ReleaseDocument[] | undefined>)
137
+ vi.mocked(observeReleases).mockReturnValue(of(undefined))
167
138
  await new Promise((resolve) => setTimeout(resolve, 0))
168
139
  expect(state.getCurrent()).toEqual([])
169
140
  })
170
141
 
171
142
  it('exposes archived/published releases through getAllReleasesState but filters them out of getActiveReleasesState', async () => {
172
143
  const subject = new Subject<ReleaseDocument[]>()
173
- vi.mocked(getQueryState).mockReturnValue({
174
- subscribe: () => () => {},
175
- getCurrent: () => undefined,
176
- observable: subject.asObservable(),
177
- } as StateSource<ReleaseDocument[] | undefined>)
144
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
178
145
 
179
146
  const active = getActiveReleasesState(instance, {
180
147
  resource: {projectId: 'test', dataset: 'test'},
@@ -216,20 +183,51 @@ describe('releasesStore', () => {
216
183
  expect(allNames).toHaveLength(3)
217
184
  })
218
185
 
219
- it('should not crash when the releases query errors', async () => {
186
+ it('surfaces CORS errors from the live connection as a store-wide error', async () => {
220
187
  const subject = new Subject<ReleaseDocument[]>()
221
- vi.mocked(getQueryState).mockReturnValue({
222
- subscribe: () => () => {},
223
- getCurrent: () => undefined,
224
- observable: subject.asObservable(),
225
- } as StateSource<ReleaseDocument[] | undefined>)
188
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
226
189
 
227
190
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
228
191
 
229
- subject.error(new Error('Query failed'))
192
+ subject.next([
193
+ {
194
+ _id: 'r1',
195
+ _type: 'system.release',
196
+ name: 'r1',
197
+ state: 'active',
198
+ metadata: {releaseType: 'asap'},
199
+ } as ReleaseDocument,
200
+ ])
201
+ await new Promise((resolve) => setTimeout(resolve, 0))
202
+ expect(state.getCurrent()).toHaveLength(1)
203
+
204
+ const [, options] = vi.mocked(observeReleases).mock.lastCall!
205
+ const corsError = new Error('CORS misconfiguration')
206
+ options.onCorsError(corsError)
207
+ await new Promise((resolve) => setTimeout(resolve, 0))
208
+
209
+ // the CORS error is recorded as store state so the releases selector
210
+ // rethrows it (handled by the Cors Error component)
211
+ expect(() => state.getCurrent()).toThrow(corsError)
212
+ })
213
+
214
+ it('should surface the error when the releases query errors', async () => {
215
+ const subject = new Subject<ReleaseDocument[]>()
216
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
217
+
218
+ const active = getActiveReleasesState(instance, {
219
+ resource: {projectId: 'test', dataset: 'test'},
220
+ })
221
+ const all = getAllReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
222
+
223
+ const error = new Error('Query failed')
224
+ subject.error(error)
230
225
 
231
226
  await new Promise((resolve) => setTimeout(resolve, 0))
232
227
 
233
- expect(state.getCurrent()).toEqual(undefined)
228
+ // The error must be surfaced (thrown) rather than silently swallowed,
229
+ // otherwise consumers just see an empty list with no signal.
230
+ expect(() => active.getCurrent()).toThrow(error)
231
+ expect(() => all.getCurrent()).toThrow(error)
234
232
  })
235
233
  })
@@ -2,19 +2,11 @@ import {type ReleaseDocument} from '@sanity/client'
2
2
  import {map} from 'rxjs'
3
3
 
4
4
  import {type DocumentResource} from '../config/sanityConfig'
5
- /*
6
- * Although this is an import dependency cycle, it is not a logical cycle:
7
- * 1. releasesStore uses queryStore as a data source
8
- * 2. queryStore calls getPerspectiveState for computing release perspectives
9
- * 3. getPerspectiveState uses releasesStore as a data source
10
- * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
11
- */
12
- // eslint-disable-next-line import/no-cycle
13
- import {getQueryState} from '../query/queryStore'
14
5
  import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
15
6
  import {type SanityInstance} from '../store/createSanityInstance'
16
7
  import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
17
8
  import {defineStore, type StoreContext} from '../store/defineStore'
9
+ import {observeReleases} from './observeReleases'
18
10
  import {sortReleases} from './utils/sortReleases'
19
11
 
20
12
  const ARCHIVED_RELEASE_STATES = ['archived', 'published']
@@ -60,7 +52,10 @@ export const releasesStore = defineStore<ReleasesStoreState, BoundResourceKey>({
60
52
  const _getActiveReleasesState = bindActionByResource(
61
53
  releasesStore,
62
54
  createStateSourceAction({
63
- selector: ({state}, _?) => state.activeReleases,
55
+ selector: ({state}) => {
56
+ if (state.error) throw state.error
57
+ return state.activeReleases
58
+ },
64
59
  }),
65
60
  )
66
61
 
@@ -82,7 +77,10 @@ export const getActiveReleasesState = (
82
77
  const _getAllReleasesState = bindActionByResource(
83
78
  releasesStore,
84
79
  createStateSourceAction({
85
- selector: ({state}, _?) => state.allReleases,
80
+ selector: ({state}) => {
81
+ if (state.error) throw state.error
82
+ return state.allReleases
83
+ },
86
84
  }),
87
85
  )
88
86
 
@@ -95,32 +93,34 @@ export const getAllReleasesState = (
95
93
  options?: {resource?: DocumentResource},
96
94
  ): StateSource<ReleaseDocument[] | undefined> => _getAllReleasesState(instance, options ?? {})
97
95
 
98
- const RELEASES_QUERY = 'releases::all()'
99
-
100
96
  const subscribeToReleases = ({
101
97
  instance,
102
98
  state,
103
99
  key: {resource},
104
100
  }: StoreContext<ReleasesStoreState, BoundResourceKey>) => {
105
- const {observable: releases$} = getQueryState<ReleaseDocument[]>(instance, {
106
- query: RELEASES_QUERY,
107
- perspective: 'raw',
101
+ const releases$ = observeReleases(instance, {
108
102
  resource,
109
- tag: 'releases',
103
+ // Surface CORS errors as store state instead of erroring the stream so
104
+ // they can be handled by the Cors Error component
105
+ onCorsError: (error) => state.set('setError', {error}),
110
106
  })
111
- return releases$
112
- .pipe(
113
- map((releases) => {
114
- // logic here mirrors that of studio:
115
- // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
116
- const sorted = sortReleases(releases ?? STABLE_EMPTY_RELEASES).reverse()
117
- state.set('setReleases', {
118
- allReleases: sorted,
119
- activeReleases: sorted.filter(
120
- (release) => !ARCHIVED_RELEASE_STATES.includes(release.state),
121
- ),
122
- })
123
- }),
124
- )
125
- .subscribe({error: (error) => state.set('setError', {error})})
107
+ return (
108
+ releases$
109
+ .pipe(
110
+ map((releases) => {
111
+ // logic here mirrors that of studio:
112
+ // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
113
+ const sorted = sortReleases(releases ?? STABLE_EMPTY_RELEASES).reverse()
114
+ state.set('setReleases', {
115
+ allReleases: sorted,
116
+ activeReleases: sorted.filter(
117
+ (release) => !ARCHIVED_RELEASE_STATES.includes(release.state),
118
+ ),
119
+ })
120
+ }),
121
+ )
122
+ // live-connection errors are already retried inside observeLiveEvents;
123
+ // a fetch error is terminal and surfaced via setError
124
+ .subscribe({error: (error) => state.set('setError', {error})})
125
+ )
126
126
  }