@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.
@@ -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 {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
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/comlinkNodeStore')
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
- const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
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
- const mockFetch = vi.fn().mockResolvedValue({isFavorited: false})
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
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
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
- const mockFetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'))
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
- const mockFetch = vi.fn().mockResolvedValue(mockResponse)
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 mockFetch = vi.fn().mockReturnValue(fetchPromise) // Mocks node.fetch
174
- const mockNode = {fetch: mockFetch}
175
- vi.mocked(getOrCreateNode).mockReturnValue(
176
- mockNode as unknown as Node<WindowMessage, FrameMessage>,
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
- // Allow fetcher to run and call node.fetch
172
+ // Simulate node becoming connected
173
+ subject.next({node: mockNode, status: 'connected'})
182
174
  await vi.advanceTimersByTimeAsync(1)
183
- expect(mockFetch).toHaveBeenCalledTimes(1) // node.fetch called once
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) // Allow promises to resolve
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, throwError, timeout} from 'rxjs'
8
+ import {catchError, filter, from, map, Observable, of, shareReplay, switchMap} from 'rxjs'
9
9
 
10
- import {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
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 node = getOrCreateNode(instance, {
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
- const dashboardFetch = from(
69
- node.fetch(
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
  })
@@ -6,5 +6,4 @@
6
6
  * different views quickly.
7
7
  */
8
8
  export const QUERY_STATE_CLEAR_DELAY = 1000
9
- // NOTE: Have to use vX for the text::query groq function
10
- export const QUERY_STORE_API_VERSION = 'vX'
9
+ export const QUERY_STORE_API_VERSION = 'v2025-05-06'