@sanity/sdk 0.0.1 → 0.0.2
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 +27 -19
- package/dist/index.js +68 -51
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/_exports/index.ts +1 -1
- package/src/comlink/node/actions/getOrCreateNode.test.ts +42 -3
- package/src/comlink/node/actions/getOrCreateNode.ts +23 -14
- package/src/comlink/node/actions/releaseNode.test.ts +8 -73
- package/src/comlink/node/actions/releaseNode.ts +5 -18
- package/src/comlink/node/comlinkNodeStore.ts +7 -3
- package/src/comlink/node/getNodeState.test.ts +90 -0
- package/src/comlink/node/getNodeState.ts +73 -0
- package/src/document/documentConstants.ts +1 -1
- package/src/document/sharedListener.ts +1 -1
- package/src/favorites/favorites.test.ts +50 -96
- package/src/favorites/favorites.ts +22 -42
- package/src/query/queryStoreConstants.ts +1 -2
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
import {type Node} from '@sanity/comlink'
|
|
2
|
-
import {firstValueFrom} from 'rxjs'
|
|
3
|
-
import {describe, expect, it, vi} from 'vitest'
|
|
2
|
+
import {firstValueFrom, of, Subject} from 'rxjs'
|
|
3
|
+
import {describe, expect, it, type Mock, vi} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {getNodeState, type NodeState} from '../comlink/node/getNodeState'
|
|
6
6
|
import {type FrameMessage, type WindowMessage} from '../comlink/types'
|
|
7
7
|
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
8
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
9
|
import {getFavoritesState, resolveFavoritesState} from './favorites'
|
|
9
10
|
|
|
10
|
-
vi.mock('../comlink/node/
|
|
11
|
+
vi.mock('../comlink/node/getNodeState', () => ({
|
|
12
|
+
getNodeState: vi.fn(),
|
|
13
|
+
}))
|
|
11
14
|
|
|
12
15
|
let instance: SanityInstance | undefined
|
|
16
|
+
let mockFetch: ReturnType<typeof vi.fn>
|
|
17
|
+
let mockNode: Node<WindowMessage, FrameMessage>
|
|
18
|
+
let mockStateSource: StateSource<NodeState>
|
|
19
|
+
|
|
20
|
+
const setupMockStateSource = (options: {fetchImpl?: Mock; observableImpl?: unknown} = {}) => {
|
|
21
|
+
mockFetch = options.fetchImpl || vi.fn().mockResolvedValue({isFavorited: false})
|
|
22
|
+
mockNode = {fetch: mockFetch} as unknown as Node<WindowMessage, FrameMessage>
|
|
23
|
+
const defaultObservable = of({node: mockNode, status: 'connected'})
|
|
24
|
+
mockStateSource = {
|
|
25
|
+
subscribe: vi.fn((cb) => {
|
|
26
|
+
cb?.({node: mockNode, status: 'connected'})
|
|
27
|
+
return () => {}
|
|
28
|
+
}),
|
|
29
|
+
getCurrent: vi.fn(() => ({node: mockNode, status: 'connected'}) as NodeState),
|
|
30
|
+
observable: options.observableImpl || defaultObservable,
|
|
31
|
+
} as unknown as StateSource<NodeState>
|
|
32
|
+
vi.mocked(getNodeState).mockReturnValue(mockStateSource)
|
|
33
|
+
}
|
|
13
34
|
|
|
14
35
|
describe('favoritesStore', () => {
|
|
15
36
|
const mockContext = {
|
|
@@ -31,6 +52,7 @@ describe('favoritesStore', () => {
|
|
|
31
52
|
beforeEach(() => {
|
|
32
53
|
vi.resetAllMocks()
|
|
33
54
|
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
55
|
+
setupMockStateSource()
|
|
34
56
|
})
|
|
35
57
|
|
|
36
58
|
afterEach(() => {
|
|
@@ -38,12 +60,7 @@ describe('favoritesStore', () => {
|
|
|
38
60
|
})
|
|
39
61
|
|
|
40
62
|
it('creates different keys for different contexts with schema name', async () => {
|
|
41
|
-
|
|
42
|
-
const mockNode = {fetch: mockFetch}
|
|
43
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
44
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
45
|
-
)
|
|
46
|
-
|
|
63
|
+
setupMockStateSource()
|
|
47
64
|
// Make two fetches with different document IDs
|
|
48
65
|
await resolveFavoritesState(instance!, mockContext)
|
|
49
66
|
await resolveFavoritesState(instance!, {
|
|
@@ -60,12 +77,7 @@ describe('favoritesStore', () => {
|
|
|
60
77
|
})
|
|
61
78
|
|
|
62
79
|
it('creates different keys for contexts without schema name', async () => {
|
|
63
|
-
|
|
64
|
-
const mockNode = {fetch: mockFetch}
|
|
65
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
66
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
67
|
-
)
|
|
68
|
-
|
|
80
|
+
setupMockStateSource()
|
|
69
81
|
// Make two fetches with different document IDs
|
|
70
82
|
await resolveFavoritesState(instance!, mockContextNoSchema)
|
|
71
83
|
await resolveFavoritesState(instance!, {
|
|
@@ -88,6 +100,7 @@ describe('favoritesStore', () => {
|
|
|
88
100
|
beforeEach(() => {
|
|
89
101
|
vi.resetAllMocks()
|
|
90
102
|
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
103
|
+
setupMockStateSource()
|
|
91
104
|
})
|
|
92
105
|
|
|
93
106
|
afterEach(() => {
|
|
@@ -96,15 +109,8 @@ describe('favoritesStore', () => {
|
|
|
96
109
|
|
|
97
110
|
it('fetches favorite status and handles success', async () => {
|
|
98
111
|
const mockResponse = {isFavorited: true}
|
|
99
|
-
|
|
100
|
-
const mockNode = {fetch: mockFetch}
|
|
101
|
-
|
|
102
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
103
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
104
|
-
)
|
|
105
|
-
|
|
112
|
+
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
|
|
106
113
|
const result = await resolveFavoritesState(instance!, mockContext)
|
|
107
|
-
|
|
108
114
|
expect(result).toEqual(mockResponse)
|
|
109
115
|
expect(mockFetch).toHaveBeenCalledWith('dashboard/v1/events/favorite/query', {
|
|
110
116
|
document: {
|
|
@@ -120,117 +126,65 @@ describe('favoritesStore', () => {
|
|
|
120
126
|
})
|
|
121
127
|
|
|
122
128
|
it('handles error and returns default response', async () => {
|
|
123
|
-
|
|
124
|
-
const mockNode = {fetch: mockFetch}
|
|
125
|
-
|
|
126
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
127
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
128
|
-
)
|
|
129
|
-
|
|
129
|
+
setupMockStateSource({fetchImpl: vi.fn().mockRejectedValue(new Error('Failed to fetch'))})
|
|
130
130
|
const result = await resolveFavoritesState(instance!, mockContext)
|
|
131
|
-
|
|
132
131
|
expect(result).toEqual({isFavorited: false})
|
|
133
132
|
})
|
|
134
133
|
|
|
135
134
|
it('shares observable between multiple subscribers and cleans up', async () => {
|
|
136
135
|
const mockResponse = {isFavorited: true}
|
|
137
|
-
|
|
138
|
-
const mockNode = {fetch: mockFetch}
|
|
139
|
-
|
|
140
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
141
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
142
|
-
)
|
|
143
|
-
|
|
136
|
+
setupMockStateSource({fetchImpl: vi.fn().mockResolvedValue(mockResponse)})
|
|
144
137
|
const state = getFavoritesState(instance!, mockContext)
|
|
145
|
-
|
|
146
138
|
// First subscriber
|
|
147
139
|
const sub1 = state.subscribe()
|
|
148
140
|
await firstValueFrom(state.observable)
|
|
149
141
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
150
|
-
|
|
151
142
|
// Second subscriber should use cached response
|
|
152
143
|
const sub2 = state.subscribe()
|
|
153
144
|
await firstValueFrom(state.observable)
|
|
154
145
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
155
|
-
|
|
156
146
|
// Cleanup
|
|
157
147
|
sub1()
|
|
158
148
|
sub2()
|
|
159
|
-
|
|
160
|
-
// Wait for cleanup
|
|
161
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
162
|
-
|
|
163
|
-
expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
|
|
164
149
|
})
|
|
165
150
|
|
|
166
151
|
it('reuses active fetch via createFetcherStore/shareReplay when called again while pending', async () => {
|
|
167
152
|
vi.useFakeTimers()
|
|
168
|
-
|
|
169
153
|
let resolveFetch: (value: {isFavorited: boolean}) => void
|
|
170
154
|
const fetchPromise = new Promise<{isFavorited: boolean}>((resolve) => {
|
|
171
155
|
resolveFetch = resolve
|
|
172
156
|
})
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
157
|
+
const fetchSpy = vi.fn().mockReturnValue(fetchPromise)
|
|
158
|
+
// Use a Subject to simulate the observable emitting after a tick
|
|
159
|
+
const subject = new Subject<{node: Node<WindowMessage, FrameMessage>; status: string}>()
|
|
160
|
+
mockNode = {fetch: fetchSpy} as unknown as Node<WindowMessage, FrameMessage>
|
|
161
|
+
mockStateSource = {
|
|
162
|
+
subscribe: vi.fn((cb) => {
|
|
163
|
+
const sub = subject.subscribe(cb)
|
|
164
|
+
return () => sub.unsubscribe()
|
|
165
|
+
}),
|
|
166
|
+
getCurrent: vi.fn(() => ({node: mockNode, status: 'connected'}) as NodeState),
|
|
167
|
+
observable: subject.asObservable(),
|
|
168
|
+
} as unknown as StateSource<NodeState>
|
|
169
|
+
vi.mocked(getNodeState).mockReturnValue(mockStateSource)
|
|
179
170
|
// Call 1: Triggers the actual fetch
|
|
180
171
|
const promise1 = resolveFavoritesState(instance!, mockContext)
|
|
181
|
-
//
|
|
172
|
+
// Simulate node becoming connected
|
|
173
|
+
subject.next({node: mockNode, status: 'connected'})
|
|
182
174
|
await vi.advanceTimersByTimeAsync(1)
|
|
183
|
-
expect(
|
|
184
|
-
|
|
175
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
185
176
|
// Call 2: Should reuse the pending fetch via createFetcherStore/shareReplay
|
|
186
177
|
const promise2 = resolveFavoritesState(instance!, mockContext)
|
|
187
178
|
await vi.advanceTimersByTimeAsync(1)
|
|
188
|
-
|
|
189
|
-
// Verify node.fetch was NOT called again
|
|
190
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
191
|
-
|
|
179
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
192
180
|
// Resolve the underlying fetch
|
|
193
181
|
resolveFetch!({isFavorited: true})
|
|
194
|
-
await vi.advanceTimersByTimeAsync(1)
|
|
195
|
-
|
|
182
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
196
183
|
// Check results
|
|
197
184
|
const result1 = await promise1
|
|
198
185
|
const result2 = await promise2
|
|
199
186
|
expect(result1).toEqual({isFavorited: true})
|
|
200
187
|
expect(result2).toEqual({isFavorited: true})
|
|
201
|
-
|
|
202
|
-
// Allow cleanup timers
|
|
203
|
-
await vi.advanceTimersByTimeAsync(5001) // stateExpirationDelay
|
|
204
|
-
expect(vi.mocked(releaseNode)).toHaveBeenCalled()
|
|
205
|
-
|
|
206
|
-
vi.useRealTimers()
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
it('handles timeout and returns default response', async () => {
|
|
210
|
-
vi.useFakeTimers()
|
|
211
|
-
|
|
212
|
-
const mockFetch = vi.fn().mockReturnValue(new Promise(() => {})) // Promise that never resolves
|
|
213
|
-
const mockNode = {fetch: mockFetch}
|
|
214
|
-
|
|
215
|
-
vi.mocked(getOrCreateNode).mockReturnValue(
|
|
216
|
-
mockNode as unknown as Node<WindowMessage, FrameMessage>,
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
const resultPromise = resolveFavoritesState(instance!, mockContext)
|
|
220
|
-
|
|
221
|
-
// Advance time past the timeout threshold (3000ms)
|
|
222
|
-
await vi.advanceTimersByTimeAsync(3001)
|
|
223
|
-
|
|
224
|
-
const result = await resultPromise
|
|
225
|
-
|
|
226
|
-
expect(result).toEqual({isFavorited: false})
|
|
227
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
228
|
-
|
|
229
|
-
// Ensure releaseNode is still called even on timeout/error path
|
|
230
|
-
// Need to wait for the catchError and cleanup logic
|
|
231
|
-
await vi.advanceTimersByTimeAsync(1) // Allow microtasks to run
|
|
232
|
-
expect(vi.mocked(releaseNode)).toHaveBeenCalledWith(instance, 'dashboard/nodes/sdk')
|
|
233
|
-
|
|
234
188
|
vi.useRealTimers()
|
|
235
189
|
})
|
|
236
190
|
})
|
|
@@ -5,19 +5,13 @@ import {
|
|
|
5
5
|
SDK_NODE_NAME,
|
|
6
6
|
type StudioResource,
|
|
7
7
|
} from '@sanity/message-protocol'
|
|
8
|
-
import {catchError, from, map, Observable, of, shareReplay,
|
|
8
|
+
import {catchError, filter, from, map, Observable, of, shareReplay, switchMap} from 'rxjs'
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {getNodeState} from '../comlink/node/getNodeState'
|
|
11
11
|
import {type DocumentHandle} from '../config/sanityConfig'
|
|
12
12
|
import {type SanityInstance} from '../store/createSanityInstance'
|
|
13
13
|
import {createFetcherStore} from '../utils/createFetcherStore'
|
|
14
14
|
|
|
15
|
-
// Users may, in many situations, be developing
|
|
16
|
-
// without a connection to the Dashboard UI.
|
|
17
|
-
// This timeout allows us to return a fallback state
|
|
18
|
-
// instead of suspending.
|
|
19
|
-
const FAVORITES_FETCH_TIMEOUT = 3000
|
|
20
|
-
|
|
21
15
|
/**
|
|
22
16
|
* @public
|
|
23
17
|
*/
|
|
@@ -48,11 +42,10 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
|
|
|
48
42
|
},
|
|
49
43
|
fetcher: (instance: SanityInstance) => {
|
|
50
44
|
return (context: FavoriteDocumentContext): Observable<FavoriteStatusResponse> => {
|
|
51
|
-
const
|
|
45
|
+
const nodeStateSource = getNodeState(instance, {
|
|
52
46
|
name: SDK_NODE_NAME,
|
|
53
47
|
connectTo: SDK_CHANNEL_NAME,
|
|
54
48
|
})
|
|
55
|
-
|
|
56
49
|
const payload = {
|
|
57
50
|
document: {
|
|
58
51
|
id: context.documentId,
|
|
@@ -65,40 +58,27 @@ const favorites = createFetcherStore<[FavoriteDocumentContext], FavoriteStatusRe
|
|
|
65
58
|
},
|
|
66
59
|
}
|
|
67
60
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
|
|
71
|
-
'dashboard/v1/events/favorite/query',
|
|
72
|
-
payload,
|
|
73
|
-
) as Promise<FavoriteStatusResponse>,
|
|
74
|
-
).pipe(
|
|
75
|
-
timeout({
|
|
76
|
-
first: FAVORITES_FETCH_TIMEOUT,
|
|
77
|
-
with: () => throwError(() => new Error('Favorites service connection timeout')),
|
|
78
|
-
}),
|
|
79
|
-
map((response) => {
|
|
80
|
-
return {isFavorited: response.isFavorited}
|
|
81
|
-
}),
|
|
82
|
-
catchError((err) => {
|
|
83
|
-
// eslint-disable-next-line no-console
|
|
84
|
-
console.error('Favorites service connection error', err)
|
|
85
|
-
return of({isFavorited: false})
|
|
86
|
-
}),
|
|
87
|
-
// Share the same subscription between multiple subscribers
|
|
61
|
+
return nodeStateSource.observable.pipe(
|
|
62
|
+
filter((nodeState) => !!nodeState), // Only proceed when connected
|
|
88
63
|
shareReplay(1),
|
|
64
|
+
switchMap((nodeState) => {
|
|
65
|
+
const node = nodeState!.node
|
|
66
|
+
return from(
|
|
67
|
+
node.fetch(
|
|
68
|
+
// @ts-expect-error -- getOrCreateNode should be refactored to take type arguments
|
|
69
|
+
'dashboard/v1/events/favorite/query',
|
|
70
|
+
payload,
|
|
71
|
+
) as Promise<FavoriteStatusResponse>,
|
|
72
|
+
).pipe(
|
|
73
|
+
map((response) => ({isFavorited: response.isFavorited})),
|
|
74
|
+
catchError((err) => {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.error('Favorites service connection error', err)
|
|
77
|
+
return of({isFavorited: false})
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
}),
|
|
89
81
|
)
|
|
90
|
-
|
|
91
|
-
// Clean up when all subscribers are gone
|
|
92
|
-
return new Observable<FavoriteStatusResponse>((subscriber) => {
|
|
93
|
-
const subscription = dashboardFetch.subscribe(subscriber)
|
|
94
|
-
return () => {
|
|
95
|
-
subscription.unsubscribe()
|
|
96
|
-
// If this was the last subscriber, clean up
|
|
97
|
-
if (subscription.closed) {
|
|
98
|
-
releaseNode(instance, SDK_NODE_NAME)
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
})
|
|
102
82
|
}
|
|
103
83
|
},
|
|
104
84
|
})
|