@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.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts +69 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -32
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/_exports/index.ts +2 -0
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/handleAuthCallback.test.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +199 -477
- package/src/auth/refreshStampedToken.ts +67 -195
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.test.ts +132 -1
- package/src/document/documentStore.ts +8 -0
- package/src/document/events.ts +32 -0
- package/src/document/listen.ts +8 -0
- package/src/document/processActions/edit.ts +19 -14
- package/src/document/processActions/shared.ts +55 -8
- package/src/document/processActions.test.ts +145 -0
- package/src/document/reducers.test.ts +396 -0
- package/src/document/reducers.ts +62 -1
- package/src/document/sharedListener.test.ts +1 -0
- package/src/document/sharedListener.ts +2 -0
- package/src/query/queryStore.test.ts +232 -38
- package/src/query/queryStore.ts +47 -47
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +0 -16
- package/src/releases/getPerspectiveState.test.ts +3 -8
- package/src/releases/getPerspectiveState.ts +6 -19
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +51 -53
- package/src/releases/releasesStore.ts +32 -32
- package/src/releases/utils/sortReleases.test.ts +36 -0
- 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
|
})
|
package/src/query/reducers.ts
CHANGED
|
@@ -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('
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
6
|
+
import {observeReleases} from './observeReleases'
|
|
8
7
|
import {getActiveReleasesState, getAllReleasesState} from './releasesStore'
|
|
9
8
|
|
|
10
9
|
// Mock dependencies
|
|
11
|
-
vi.mock('
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
137
|
-
vi.mocked(
|
|
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(
|
|
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(
|
|
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(
|
|
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('
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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
|
|
106
|
-
query: RELEASES_QUERY,
|
|
107
|
-
perspective: 'raw',
|
|
101
|
+
const releases$ = observeReleases(instance, {
|
|
108
102
|
resource,
|
|
109
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
}
|