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