@sanity/sdk 0.0.0-rc.6 → 0.0.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/README.md +7 -15
- package/dist/index.d.ts +562 -234
- package/dist/index.js +515 -256
- package/dist/index.js.map +1 -1
- package/package.json +12 -10
- package/src/_exports/index.ts +17 -2
- package/src/auth/dashboardUtils.test.ts +41 -0
- package/src/auth/dashboardUtils.ts +12 -0
- package/src/auth/getOrganizationVerificationState.test.ts +197 -0
- package/src/auth/getOrganizationVerificationState.ts +73 -0
- package/src/auth/handleAuthCallback.test.ts +2 -0
- package/src/auth/handleAuthCallback.ts +1 -0
- package/src/auth/logout.test.ts +1 -0
- package/src/auth/logout.ts +1 -0
- package/src/auth/refreshStampedToken.ts +1 -0
- package/src/auth/studioModeAuth.test.ts +1 -1
- package/src/auth/studioModeAuth.ts +1 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -0
- package/src/client/clientStore.ts +22 -18
- package/src/comlink/node/actions/releaseNode.ts +16 -14
- package/src/config/__tests__/handles.test.ts +30 -0
- package/src/config/handles.ts +67 -0
- package/src/config/sanityConfig.ts +44 -16
- package/src/document/actions.ts +188 -60
- package/src/document/applyDocumentActions.ts +12 -5
- package/src/document/documentStore.test.ts +70 -121
- package/src/document/documentStore.ts +57 -27
- package/src/document/patchOperations.test.ts +1 -1
- package/src/document/patchOperations.ts +39 -39
- package/src/document/sharedListener.ts +3 -1
- package/src/favorites/favorites.test.ts +237 -0
- package/src/favorites/favorites.ts +122 -0
- package/src/preview/resolvePreview.test.ts +3 -4
- package/src/preview/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/preview/subscribeToStateAndFetchBatches.ts +4 -2
- package/src/project/organizationVerification.test.ts +35 -0
- package/src/project/organizationVerification.ts +26 -0
- package/src/projection/getProjectionState.ts +36 -11
- package/src/projection/resolveProjection.test.ts +3 -4
- package/src/projection/resolveProjection.ts +35 -9
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +4 -2
- package/src/query/queryStore.test.ts +12 -12
- package/src/query/queryStore.ts +71 -42
- package/src/releases/getPerspectiveState.test.ts +192 -0
- package/src/releases/getPerspectiveState.ts +93 -0
- package/src/releases/releasesStore.test.ts +170 -0
- package/src/releases/releasesStore.ts +89 -0
- package/src/releases/utils/sortReleases.test.ts +336 -0
- package/src/releases/utils/sortReleases.ts +48 -0
- package/src/utils/listenQuery.test.ts +302 -0
- package/src/utils/listenQuery.ts +128 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
|
|
2
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
|
|
5
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
6
|
+
import {listenQuery as mockListenQuery} from '../utils/listenQuery'
|
|
7
|
+
import {getPerspectiveState} from './getPerspectiveState'
|
|
8
|
+
import {type ReleaseDocument} from './releasesStore'
|
|
9
|
+
|
|
10
|
+
vi.mock('../utils/listenQuery', () => ({
|
|
11
|
+
listenQuery: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('../client/clientStore', () => ({
|
|
15
|
+
getClientState: vi.fn(() => ({
|
|
16
|
+
observable: of({}),
|
|
17
|
+
getCurrent: vi.fn(),
|
|
18
|
+
subscribe: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
describe('getPerspectiveState', () => {
|
|
23
|
+
let instance: SanityInstance
|
|
24
|
+
let mockReleasesQuerySubject: Subject<ReleaseDocument[]>
|
|
25
|
+
|
|
26
|
+
const release1 = {
|
|
27
|
+
_id: 'release-1',
|
|
28
|
+
_type: 'sanity.release',
|
|
29
|
+
name: 'release1',
|
|
30
|
+
metadata: {title: 'Release 1', releaseType: 'asap'},
|
|
31
|
+
status: 'active',
|
|
32
|
+
} as unknown as ReleaseDocument
|
|
33
|
+
const release2: ReleaseDocument = {
|
|
34
|
+
...release1,
|
|
35
|
+
_id: 'release-2',
|
|
36
|
+
_rev: 'rev2',
|
|
37
|
+
name: 'release2',
|
|
38
|
+
metadata: {title: 'Release 2', releaseType: 'asap'},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const activeReleases = [release1, release2]
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
45
|
+
|
|
46
|
+
mockReleasesQuerySubject = new Subject<ReleaseDocument[]>()
|
|
47
|
+
vi.mocked(mockListenQuery).mockReturnValue(mockReleasesQuerySubject.asObservable())
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
instance.dispose()
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should return default perspective if no options or instance perspective is provided', async () => {
|
|
56
|
+
const state = getPerspectiveState(instance)
|
|
57
|
+
mockReleasesQuerySubject.next([])
|
|
58
|
+
const perspective = await firstValueFrom(state.observable)
|
|
59
|
+
expect(perspective).toBe('drafts')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should return instance perspective if provided and no options perspective', async () => {
|
|
63
|
+
instance.config.perspective = 'published'
|
|
64
|
+
const state = getPerspectiveState(instance)
|
|
65
|
+
mockReleasesQuerySubject.next([])
|
|
66
|
+
const perspective = await firstValueFrom(state.observable)
|
|
67
|
+
expect(perspective).toBe('published')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should return options perspective if provided', async () => {
|
|
71
|
+
const options: PerspectiveHandle = {perspective: 'raw'}
|
|
72
|
+
const state = getPerspectiveState(instance, options)
|
|
73
|
+
mockReleasesQuerySubject.next([])
|
|
74
|
+
const perspective = await firstValueFrom(state.observable)
|
|
75
|
+
expect(perspective).toBe('raw')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should return undefined if release perspective is requested but no active releases', async () => {
|
|
79
|
+
const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
|
|
80
|
+
const state = getPerspectiveState(instance, options)
|
|
81
|
+
mockReleasesQuerySubject.next([])
|
|
82
|
+
const perspective = await firstValueFrom(state.observable)
|
|
83
|
+
expect(perspective).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should calculate perspective based on active releases and releaseName', async () => {
|
|
87
|
+
const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
|
|
88
|
+
const state = getPerspectiveState(instance, options)
|
|
89
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
90
|
+
|
|
91
|
+
const perspective = await firstValueFrom(
|
|
92
|
+
state.observable.pipe(
|
|
93
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
94
|
+
take(1),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
expect(perspective).toEqual(['drafts', 'release1'])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should calculate perspective including multiple releases up to the specified releaseName', async () => {
|
|
101
|
+
const options: PerspectiveHandle = {perspective: {releaseName: 'release2'}}
|
|
102
|
+
const state = getPerspectiveState(instance, options)
|
|
103
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
104
|
+
const perspective = await firstValueFrom(
|
|
105
|
+
state.observable.pipe(
|
|
106
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
107
|
+
take(1),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
expect(perspective).toEqual(['drafts', 'release1', 'release2'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should filter excluded perspectives', async () => {
|
|
114
|
+
const perspectiveConfig: ReleasePerspective = {
|
|
115
|
+
releaseName: 'release2',
|
|
116
|
+
excludedPerspectives: ['drafts', 'release1'],
|
|
117
|
+
}
|
|
118
|
+
const options: PerspectiveHandle = {perspective: perspectiveConfig}
|
|
119
|
+
const state = getPerspectiveState(instance, options)
|
|
120
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
121
|
+
const perspective = await firstValueFrom(
|
|
122
|
+
state.observable.pipe(
|
|
123
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
124
|
+
take(1),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
expect(perspective).toEqual(['release2'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should throw if the specified releaseName is not found in active releases', async () => {
|
|
131
|
+
const options: PerspectiveHandle = {perspective: {releaseName: 'nonexistent'}}
|
|
132
|
+
const state = getPerspectiveState(instance, options)
|
|
133
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
134
|
+
|
|
135
|
+
await expect(
|
|
136
|
+
firstValueFrom(
|
|
137
|
+
state.observable.pipe(
|
|
138
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
139
|
+
take(1),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
).rejects.toThrow('Release "nonexistent" not found in active releases')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should reuse the same options object for identical inputs (cache test)', async () => {
|
|
146
|
+
const options1: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
|
|
147
|
+
const options2: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
|
|
148
|
+
|
|
149
|
+
const state1 = getPerspectiveState(instance, options1)
|
|
150
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
151
|
+
await firstValueFrom(
|
|
152
|
+
state1.observable.pipe(
|
|
153
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
154
|
+
take(1),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const state2 = getPerspectiveState(instance, options2)
|
|
159
|
+
const perspective2 = state2.getCurrent()
|
|
160
|
+
|
|
161
|
+
expect(perspective2).toEqual(['drafts', 'release1'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should handle changes in activeReleases (cache test)', async () => {
|
|
165
|
+
const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
|
|
166
|
+
|
|
167
|
+
const state1 = getPerspectiveState(instance, options)
|
|
168
|
+
mockReleasesQuerySubject.next(activeReleases)
|
|
169
|
+
const perspective1 = await firstValueFrom(
|
|
170
|
+
state1.observable.pipe(
|
|
171
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
172
|
+
take(1),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
expect(perspective1).toEqual(['drafts', 'release1'])
|
|
176
|
+
|
|
177
|
+
const updatedActiveReleases = [release1]
|
|
178
|
+
mockReleasesQuerySubject.next(updatedActiveReleases)
|
|
179
|
+
|
|
180
|
+
const perspectiveAfterUpdate = await firstValueFrom(
|
|
181
|
+
state1.observable.pipe(
|
|
182
|
+
filter((p): p is NonNullable<typeof p> => p !== undefined),
|
|
183
|
+
take(1),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
expect(perspectiveAfterUpdate).toEqual(['drafts', 'release1'])
|
|
187
|
+
|
|
188
|
+
const state2 = getPerspectiveState(instance, options)
|
|
189
|
+
const perspectiveNewCall = state2.getCurrent()
|
|
190
|
+
expect(perspectiveNewCall).toEqual(['drafts', 'release1'])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {createSelector} from 'reselect'
|
|
2
|
+
|
|
3
|
+
import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
|
|
4
|
+
import {bindActionByDataset} from '../store/createActionBinder'
|
|
5
|
+
import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
|
|
6
|
+
import {releasesStore, type ReleasesStoreState} from './releasesStore'
|
|
7
|
+
|
|
8
|
+
function isReleasePerspective(
|
|
9
|
+
perspective: PerspectiveHandle['perspective'],
|
|
10
|
+
): perspective is ReleasePerspective {
|
|
11
|
+
return typeof perspective === 'object' && perspective !== null && 'releaseName' in perspective
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PERSPECTIVE = 'drafts'
|
|
15
|
+
|
|
16
|
+
// Cache for options
|
|
17
|
+
const optionsCache = new Map<string, Map<string, PerspectiveHandle>>()
|
|
18
|
+
|
|
19
|
+
const selectInstancePerspective = (context: SelectorContext<ReleasesStoreState>) =>
|
|
20
|
+
context.instance.config.perspective
|
|
21
|
+
const selectActiveReleases = (context: SelectorContext<ReleasesStoreState>) =>
|
|
22
|
+
context.state.activeReleases
|
|
23
|
+
const selectOptions = (
|
|
24
|
+
_context: SelectorContext<ReleasesStoreState>,
|
|
25
|
+
options?: PerspectiveHandle,
|
|
26
|
+
) => options
|
|
27
|
+
|
|
28
|
+
const memoizedOptionsSelector = createSelector(
|
|
29
|
+
[selectActiveReleases, selectOptions],
|
|
30
|
+
(activeReleases, options) => {
|
|
31
|
+
if (!options || !activeReleases) return options
|
|
32
|
+
|
|
33
|
+
// Use release document IDs as the cache key
|
|
34
|
+
const releaseIds = activeReleases.map((release) => release._id).join(',')
|
|
35
|
+
let nestedCache = optionsCache.get(releaseIds)
|
|
36
|
+
if (!nestedCache) {
|
|
37
|
+
nestedCache = new Map<string, PerspectiveHandle>()
|
|
38
|
+
optionsCache.set(releaseIds, nestedCache)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const optionsKey = JSON.stringify(options)
|
|
42
|
+
let cachedOptions = nestedCache.get(optionsKey)
|
|
43
|
+
|
|
44
|
+
if (!cachedOptions) {
|
|
45
|
+
cachedOptions = options
|
|
46
|
+
nestedCache.set(optionsKey, cachedOptions)
|
|
47
|
+
}
|
|
48
|
+
return cachedOptions
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Provides a subscribable state source for a "perspective" for the Sanity client,
|
|
54
|
+
* which is used to fetch documents as though certain Content Releases are active.
|
|
55
|
+
*
|
|
56
|
+
* @param instance - The Sanity instance to get the perspective for
|
|
57
|
+
* @param options - The options for the perspective -- usually a release name
|
|
58
|
+
*
|
|
59
|
+
* @returns A subscribable perspective value, usually a list of applicable release names,
|
|
60
|
+
* or a single release name / default perspective (such as 'drafts').
|
|
61
|
+
*
|
|
62
|
+
* @public
|
|
63
|
+
*/
|
|
64
|
+
export const getPerspectiveState = bindActionByDataset(
|
|
65
|
+
releasesStore,
|
|
66
|
+
createStateSourceAction({
|
|
67
|
+
selector: createSelector(
|
|
68
|
+
[selectInstancePerspective, selectActiveReleases, memoizedOptionsSelector],
|
|
69
|
+
(instancePerspective, activeReleases, memoizedOptions) => {
|
|
70
|
+
const perspective =
|
|
71
|
+
memoizedOptions?.perspective ?? instancePerspective ?? DEFAULT_PERSPECTIVE
|
|
72
|
+
|
|
73
|
+
if (!isReleasePerspective(perspective)) return perspective
|
|
74
|
+
|
|
75
|
+
// if there are no active releases we can't compute the release perspective
|
|
76
|
+
if (!activeReleases || activeReleases.length === 0) return undefined
|
|
77
|
+
|
|
78
|
+
const releaseNames = activeReleases.map((release) => release.name)
|
|
79
|
+
const index = releaseNames.findIndex((name) => name === perspective.releaseName)
|
|
80
|
+
|
|
81
|
+
if (index < 0) {
|
|
82
|
+
throw new Error(`Release "${perspective.releaseName}" not found in active releases`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filteredReleases = releaseNames.slice(0, index + 1) // Include the release itself
|
|
86
|
+
|
|
87
|
+
return ['drafts', ...filteredReleases].filter(
|
|
88
|
+
(name) => !perspective.excludedPerspectives?.includes(name),
|
|
89
|
+
)
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {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 {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
7
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
|
+
import {listenQuery} from '../utils/listenQuery'
|
|
9
|
+
import {getActiveReleasesState, type ReleaseDocument} from './releasesStore'
|
|
10
|
+
|
|
11
|
+
// 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
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
21
|
+
|
|
22
|
+
describe('releasesStore', () => {
|
|
23
|
+
let instance: SanityInstance
|
|
24
|
+
const mockClient = {} as SanityClient
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.resetAllMocks()
|
|
28
|
+
consoleErrorSpy.mockClear()
|
|
29
|
+
|
|
30
|
+
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
31
|
+
|
|
32
|
+
vi.mocked(getClientState).mockReturnValue({
|
|
33
|
+
observable: of(mockClient),
|
|
34
|
+
} as StateSource<SanityClient>)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
instance.dispose()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should set active releases state when listenQuery succeeds', async () => {
|
|
42
|
+
// note that the order of the releases is important here -- they get sorted
|
|
43
|
+
const mockReleases: ReleaseDocument[] = [
|
|
44
|
+
{
|
|
45
|
+
_id: 'r2',
|
|
46
|
+
_type: 'system.release',
|
|
47
|
+
name: 'Release 2',
|
|
48
|
+
metadata: {title: 'R2', releaseType: 'scheduled'},
|
|
49
|
+
} as ReleaseDocument,
|
|
50
|
+
{
|
|
51
|
+
_id: 'r1',
|
|
52
|
+
_type: 'system.release',
|
|
53
|
+
name: 'Release 1',
|
|
54
|
+
metadata: {title: 'R1', releaseType: 'asap'},
|
|
55
|
+
} as ReleaseDocument,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
vi.mocked(listenQuery).mockReturnValue(of(mockReleases))
|
|
59
|
+
|
|
60
|
+
const state = getActiveReleasesState(instance)
|
|
61
|
+
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
63
|
+
|
|
64
|
+
expect(state.getCurrent()).toEqual(mockReleases)
|
|
65
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should update active releases state when listenQuery emits new data', async () => {
|
|
69
|
+
const releasesSubject = new Subject<ReleaseDocument[]>()
|
|
70
|
+
vi.mocked(listenQuery).mockReturnValue(releasesSubject.asObservable())
|
|
71
|
+
|
|
72
|
+
const state = getActiveReleasesState(instance)
|
|
73
|
+
|
|
74
|
+
// Initial state should be default
|
|
75
|
+
expect(state.getCurrent()).toBeUndefined() // Default initial state
|
|
76
|
+
|
|
77
|
+
// Emit initial data
|
|
78
|
+
const initialReleases: ReleaseDocument[] = [
|
|
79
|
+
{
|
|
80
|
+
_id: 'r1',
|
|
81
|
+
_type: 'system.release',
|
|
82
|
+
name: 'Initial Release 1',
|
|
83
|
+
metadata: {title: 'IR1', releaseType: 'asap'},
|
|
84
|
+
} as ReleaseDocument,
|
|
85
|
+
]
|
|
86
|
+
releasesSubject.next(initialReleases)
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
88
|
+
|
|
89
|
+
expect(state.getCurrent()).toEqual(initialReleases)
|
|
90
|
+
|
|
91
|
+
const updatedReleases: ReleaseDocument[] = [
|
|
92
|
+
{
|
|
93
|
+
_id: 'r2',
|
|
94
|
+
_type: 'system.release',
|
|
95
|
+
name: 'New Release 2',
|
|
96
|
+
metadata: {title: 'NR2', releaseType: 'scheduled'},
|
|
97
|
+
} as ReleaseDocument,
|
|
98
|
+
{
|
|
99
|
+
_id: 'r1',
|
|
100
|
+
_type: 'system.release',
|
|
101
|
+
name: 'Updated Release 1',
|
|
102
|
+
metadata: {title: 'UR1', releaseType: 'asap'},
|
|
103
|
+
} as ReleaseDocument,
|
|
104
|
+
]
|
|
105
|
+
releasesSubject.next(updatedReleases)
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
107
|
+
|
|
108
|
+
expect(state.getCurrent()).toEqual(updatedReleases)
|
|
109
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should handle empty array from listenQuery', async () => {
|
|
113
|
+
// Configure listenQuery to return an empty array
|
|
114
|
+
vi.mocked(listenQuery).mockReturnValue(of([]))
|
|
115
|
+
|
|
116
|
+
const state = getActiveReleasesState(instance)
|
|
117
|
+
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
119
|
+
|
|
120
|
+
expect(state.getCurrent()).toEqual([]) // Should be set to empty array
|
|
121
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should handle null/undefined from listenQuery by defaulting to empty array', async () => {
|
|
125
|
+
// Test null case
|
|
126
|
+
vi.mocked(listenQuery).mockReturnValue(of(null))
|
|
127
|
+
const state = getActiveReleasesState(instance)
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
129
|
+
expect(state.getCurrent()).toEqual([])
|
|
130
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
131
|
+
|
|
132
|
+
// Test undefined case
|
|
133
|
+
vi.mocked(listenQuery).mockReturnValue(of(undefined))
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
135
|
+
expect(state.getCurrent()).toEqual([])
|
|
136
|
+
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should handle errors from listenQuery by retrying and eventually setting error state', async () => {
|
|
140
|
+
vi.useFakeTimers()
|
|
141
|
+
const error = new Error('Query failed')
|
|
142
|
+
const subject = new Subject<ReleaseDocument[]>()
|
|
143
|
+
vi.mocked(listenQuery).mockReturnValue(subject.asObservable())
|
|
144
|
+
|
|
145
|
+
// initialize the store
|
|
146
|
+
const state = getActiveReleasesState(instance)
|
|
147
|
+
|
|
148
|
+
// Error the subject
|
|
149
|
+
subject.error(error)
|
|
150
|
+
|
|
151
|
+
// Advance enough to cover the retry attempts (exponential backoff: 1s, 2s, 4s)
|
|
152
|
+
for (let i = 0; i < 3; i++) {
|
|
153
|
+
const delay = Math.pow(2, i) * 1000
|
|
154
|
+
await vi.advanceTimersByTimeAsync(delay)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Verify error was logged at least once during retries
|
|
158
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
159
|
+
'[releases] Error in subscription:',
|
|
160
|
+
error,
|
|
161
|
+
'Retry count:',
|
|
162
|
+
expect.any(Number),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// not sure how to test state.setError()
|
|
166
|
+
expect(state.getCurrent()).toEqual(undefined)
|
|
167
|
+
|
|
168
|
+
vi.useRealTimers()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {type SanityDocument} from '@sanity/types'
|
|
3
|
+
import {catchError, EMPTY, retry, switchMap, timer} from 'rxjs'
|
|
4
|
+
|
|
5
|
+
import {getClientState} from '../client/clientStore'
|
|
6
|
+
import {bindActionByDataset} from '../store/createActionBinder'
|
|
7
|
+
import {createStateSourceAction} from '../store/createStateSourceAction'
|
|
8
|
+
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
9
|
+
import {listenQuery} from '../utils/listenQuery'
|
|
10
|
+
import {sortReleases} from './utils/sortReleases'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Represents a document in a Sanity dataset that represents release options.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export type ReleaseDocument = SanityDocument & {
|
|
17
|
+
name: string
|
|
18
|
+
publishAt?: string
|
|
19
|
+
metadata: {
|
|
20
|
+
title: string
|
|
21
|
+
releaseType: 'asap' | 'scheduled' | 'undecided'
|
|
22
|
+
intendedPublishAt?: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReleasesStoreState {
|
|
27
|
+
activeReleases?: ReleaseDocument[]
|
|
28
|
+
error?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const releasesStore = defineStore<ReleasesStoreState>({
|
|
32
|
+
name: 'Releases',
|
|
33
|
+
getInitialState: (): ReleasesStoreState => ({
|
|
34
|
+
activeReleases: undefined,
|
|
35
|
+
}),
|
|
36
|
+
initialize: (context) => {
|
|
37
|
+
const subscription = subscribeToReleases(context)
|
|
38
|
+
return () => subscription.unsubscribe()
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the active releases from the store.
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export const getActiveReleasesState = bindActionByDataset(
|
|
47
|
+
releasesStore,
|
|
48
|
+
createStateSourceAction({
|
|
49
|
+
selector: ({state}) => state.activeReleases,
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const RELEASES_QUERY = 'releases::all()[state == "active"]'
|
|
54
|
+
const QUERY_PARAMS = {}
|
|
55
|
+
|
|
56
|
+
const subscribeToReleases = ({instance, state}: StoreContext<ReleasesStoreState>) => {
|
|
57
|
+
return getClientState(instance, {
|
|
58
|
+
apiVersion: '2025-04-10',
|
|
59
|
+
perspective: 'raw',
|
|
60
|
+
})
|
|
61
|
+
.observable.pipe(
|
|
62
|
+
switchMap((client: SanityClient) =>
|
|
63
|
+
// releases are system documents, and are not supported by useQueryState
|
|
64
|
+
listenQuery<ReleaseDocument[]>(client, RELEASES_QUERY, QUERY_PARAMS, {
|
|
65
|
+
tag: 'releases-listener',
|
|
66
|
+
throttleTime: 1000,
|
|
67
|
+
transitions: ['update', 'appear', 'disappear'],
|
|
68
|
+
}).pipe(
|
|
69
|
+
retry({
|
|
70
|
+
count: 3,
|
|
71
|
+
delay: (error, retryCount) => {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.error('[releases] Error in subscription:', error, 'Retry count:', retryCount)
|
|
74
|
+
return timer(Math.min(1000 * Math.pow(2, retryCount), 10000))
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
catchError((error) => {
|
|
78
|
+
state.set('setError', {error})
|
|
79
|
+
return EMPTY
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
.subscribe({
|
|
85
|
+
next: (releases) => {
|
|
86
|
+
state.set('setActiveReleases', {activeReleases: sortReleases(releases ?? [])})
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
}
|