@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.1

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.
Files changed (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -1,8 +1,8 @@
1
- import {CorsOriginError, type ResponseQueryOptions} from '@sanity/client'
1
+ import {type ResponseQueryOptions} from '@sanity/client'
2
+ import {type SanityQueryResult} from 'groq'
2
3
  import {
3
4
  catchError,
4
5
  combineLatest,
5
- defer,
6
6
  distinctUntilChanged,
7
7
  EMPTY,
8
8
  filter,
@@ -23,15 +23,8 @@ import {
23
23
  } from 'rxjs'
24
24
 
25
25
  import {getClientState} from '../client/clientStore'
26
- import {type ResourceHandle} from '../config/sanityConfig'
27
- /*
28
- * Although this is an import dependency cycle, it is not a logical cycle:
29
- * 1. queryStore uses getPerspectiveState when resolving release perspectives
30
- * 2. getPerspectiveState uses releasesStore as a data source
31
- * 3. releasesStore uses queryStore as a data source
32
- * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
33
- */
34
- // eslint-disable-next-line import/no-cycle
26
+ import {observeLiveEvents} from '../client/liveEvents'
27
+ import {type DatasetHandle} from '../config/sanityConfig'
35
28
  import {getPerspectiveState} from '../releases/getPerspectiveState'
36
29
  import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
37
30
  import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
@@ -44,6 +37,7 @@ import {
44
37
  import {type StoreState} from '../store/createStoreState'
45
38
  import {defineStore, type StoreContext} from '../store/defineStore'
46
39
  import {insecureRandomId} from '../utils/ids'
40
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
47
41
  import {
48
42
  QUERY_STATE_CLEAR_DELAY,
49
43
  QUERY_STORE_API_VERSION,
@@ -51,8 +45,6 @@ import {
51
45
  } from './queryStoreConstants'
52
46
  import {
53
47
  addSubscriber,
54
- cancelQuery,
55
- initializeQuery,
56
48
  type QueryStoreState,
57
49
  removeSubscriber,
58
50
  setLastLiveEventId,
@@ -70,7 +62,7 @@ export interface QueryOptions<
70
62
  >
71
63
  extends
72
64
  Pick<ResponseQueryOptions, 'useCdn' | 'cache' | 'next' | 'cacheMode' | 'tag'>,
73
- ResourceHandle<TDataset, TProjectId> {
65
+ DatasetHandle<TDataset, TProjectId> {
74
66
  query: TQuery
75
67
  params?: Record<string, unknown>
76
68
  }
@@ -136,11 +128,7 @@ const errorHandler = (state: StoreState<{error?: unknown}>) => {
136
128
  return (error: unknown): void => state.set('setError', {error})
137
129
  }
138
130
 
139
- const listenForNewSubscribersAndFetch = ({
140
- state,
141
- instance,
142
- key: {resource: boundResource},
143
- }: StoreContext<QueryStoreState, BoundResourceKey>) => {
131
+ const listenForNewSubscribersAndFetch = ({state, instance}: StoreContext<QueryStoreState>) => {
144
132
  return state.observable
145
133
  .pipe(
146
134
  map((s) => new Set(Object.keys(s.queries))),
@@ -172,6 +160,8 @@ const listenForNewSubscribersAndFetch = ({
172
160
  const {
173
161
  query,
174
162
  params,
163
+ projectId,
164
+ dataset,
175
165
  tag,
176
166
  resource,
177
167
  perspective: perspectiveFromOptions,
@@ -183,19 +173,15 @@ const listenForNewSubscribersAndFetch = ({
183
173
  const perspective$ = isReleasePerspective(perspectiveFromOptions)
184
174
  ? getPerspectiveState(instance, {
185
175
  perspective: perspectiveFromOptions,
186
- resource: resource ?? boundResource,
176
+ resource,
187
177
  }).observable.pipe(filter(Boolean))
188
178
  : of(perspectiveFromOptions ?? QUERY_STORE_DEFAULT_PERSPECTIVE)
189
179
 
190
- // Use the store's bound resource as fallback when the query key
191
- // doesn't include an explicit resource. The store is scoped to a
192
- // specific resource via bindActionByResource, but the captured
193
- // `instance` may have a different default resource (e.g. when the
194
- // store was first created by a caller that passed an explicit
195
- // resource while using the root app instance).
196
180
  const client$ = getClientState(instance, {
197
181
  apiVersion: QUERY_STORE_API_VERSION,
198
- resource: resource ?? boundResource,
182
+ projectId,
183
+ dataset,
184
+ resource,
199
185
  }).observable
200
186
 
201
187
  return combineLatest({
@@ -213,15 +199,19 @@ const listenForNewSubscribersAndFetch = ({
213
199
  tag,
214
200
  }),
215
201
  ),
202
+ tap(({result, syncTags}) => {
203
+ state.set('setQueryData', setQueryData(group$.key, result, syncTags))
204
+ }),
205
+ // Catch inside the per-event stream: erroring the group pipe would
206
+ // complete the group's subscription, and since `groupBy` above never
207
+ // removes its group subjects, re-adding the key would emit into a
208
+ // subject with no subscribers — the key could never be fetched again.
209
+ catchError((error) => {
210
+ state.set('setQueryError', setQueryError(group$.key, error))
211
+ return EMPTY
212
+ }),
216
213
  )
217
214
  }),
218
- catchError((error) => {
219
- state.set('setQueryError', setQueryError(group$.key, error))
220
- return EMPTY
221
- }),
222
- tap(({result, syncTags}) => {
223
- state.set('setQueryData', setQueryData(group$.key, result, syncTags))
224
- }),
225
215
  ),
226
216
  ),
227
217
  )
@@ -233,27 +223,14 @@ const listenToLiveClientAndSetLastLiveEventIds = ({
233
223
  instance,
234
224
  key: {resource},
235
225
  }: StoreContext<QueryStoreState, BoundResourceKey>) => {
236
- const liveMessages$ = getClientState(instance, {
237
- apiVersion: QUERY_STORE_API_VERSION,
226
+ const liveMessages$ = observeLiveEvents(instance, {
238
227
  resource,
239
- }).observable.pipe(
240
- switchMap((client) =>
241
- defer(() =>
242
- client.live.events({includeDrafts: !!client.config().token, tag: 'query-store'}),
243
- ).pipe(
244
- catchError((error) => {
245
- if (error instanceof CorsOriginError) {
246
- // Swallow only CORS errors in store without bubbling up so that they are handled by the Cors Error component
247
- state.set('setError', {error})
248
- return EMPTY
249
- }
250
- throw error
251
- }),
252
- ),
253
- ),
254
- share(),
255
- filter((e) => e.type === 'message'),
256
- )
228
+ // Surface CORS errors as store state (handled by the Cors Error
229
+ // component) instead of erroring the stream: a stream error here would
230
+ // reach the outer subscription's errorHandler, set the store-wide
231
+ // `state.error`, and permanently brick every query selector.
232
+ onCorsError: (error) => state.set('setError', {error}),
233
+ }).pipe(share())
257
234
 
258
235
  return state.observable
259
236
  .pipe(
@@ -293,6 +270,16 @@ const listenToLiveClientAndSetLastLiveEventIds = ({
293
270
  *
294
271
  * @beta
295
272
  */
273
+ export function getQueryState<
274
+ TQuery extends string = string,
275
+ TDataset extends string = string,
276
+ TProjectId extends string = string,
277
+ >(
278
+ instance: SanityInstance,
279
+ queryOptions: QueryOptions<TQuery, TDataset, TProjectId>,
280
+ ): StateSource<SanityQueryResult<TQuery, `${TProjectId}.${TDataset}`> | undefined>
281
+
282
+ /** @beta */
296
283
  export function getQueryState<TData>(
297
284
  instance: SanityInstance,
298
285
  queryOptions: QueryOptions,
@@ -328,7 +315,7 @@ const _getQueryState = bindActionByResource(
328
315
 
329
316
  return () => {
330
317
  // this runs on unsubscribe
331
- setTimeout(
318
+ setCleanupTimeout(
332
319
  () => state.set('removeSubscriber', removeSubscriber(key, subscriptionId)),
333
320
  QUERY_STATE_CLEAR_DELAY,
334
321
  )
@@ -341,16 +328,27 @@ const _getQueryState = bindActionByResource(
341
328
  * Resolves the result of a query without registering a lasting subscriber.
342
329
  *
343
330
  * This function fetches the result of a GROQ query and returns a promise that resolves with the query result.
344
- * Unlike `getQueryState`, which registers subscribers to keep the query live and performs automatic cleanup,
345
- * `resolveQuery` does not track subscribers. This makes it ideal for use with React Suspense, where the returned
346
- * promise is thrown to delay rendering until the query result becomes available.
331
+ * It registers a temporary subscriber for the lifetime of the resolution (released shortly after the promise
332
+ * settles), so the query state participates in normal cleanup even when no lasting subscriber ever attaches —
333
+ * e.g. when a suspended component errors before committing. This makes it ideal for use with React Suspense,
334
+ * where the returned promise is thrown to delay rendering until the query result becomes available.
347
335
  * Once the promise resolves, it is expected that a real subscriber will be added via `getQueryState` to manage ongoing updates.
348
336
  *
349
337
  * Additionally, an optional AbortSignal can be provided to cancel the query and immediately clear the associated state
350
- * if there are no active subscribers.
338
+ * if there are no other active subscribers.
351
339
  *
352
340
  * @beta
353
341
  */
342
+ export function resolveQuery<
343
+ TQuery extends string = string,
344
+ TDataset extends string = string,
345
+ TProjectId extends string = string,
346
+ >(
347
+ instance: SanityInstance,
348
+ queryOptions: ResolveQueryOptions<TQuery, TDataset, TProjectId>,
349
+ ): Promise<SanityQueryResult<TQuery, `${TProjectId}.${TDataset}`>>
350
+
351
+ /** @beta */
354
352
  export function resolveQuery<TData>(
355
353
  instance: SanityInstance,
356
354
  queryOptions: ResolveQueryOptions,
@@ -366,6 +364,14 @@ const _resolveQuery = bindActionByResource(
366
364
  const {getCurrent} = getQueryState(instance, normalized)
367
365
  const key = getQueryKey(normalized)
368
366
 
367
+ // Hold a temporary subscription for the lifetime of this resolution so the
368
+ // key participates in normal cleanup. Without one, a fetch that errors
369
+ // while a component is suspended leaves a subscriber-less key behind — the
370
+ // component never commits, so no other subscriber exists — and its stored
371
+ // error would be rethrown on every future mount without ever refetching.
372
+ const subscriptionId = insecureRandomId()
373
+ state.set('addSubscriber', addSubscriber(key, subscriptionId))
374
+
369
375
  const aborted$ = signal
370
376
  ? new Observable<void>((observer) => {
371
377
  const cleanup = () => {
@@ -383,20 +389,29 @@ const _resolveQuery = bindActionByResource(
383
389
  }).pipe(
384
390
  catchError((error) => {
385
391
  if (error instanceof Error && error.name === 'AbortError') {
386
- state.set('cancelQuery', cancelQuery(key))
392
+ // Release immediately (not after the clear delay) so that when
393
+ // this was the only subscriber, the key removal tears down the
394
+ // in-flight request right away — e.g. when useQuery switches
395
+ // to a different query
396
+ state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
387
397
  }
388
398
  throw error
389
399
  }),
390
400
  )
391
401
  : NEVER
392
402
 
393
- state.set('initializeQuery', initializeQuery(key))
394
-
395
403
  const resolved$ = state.observable.pipe(
396
404
  map(getCurrent),
397
405
  first((i) => i !== undefined),
398
406
  )
399
407
 
400
- return firstValueFrom(race([resolved$, aborted$]))
408
+ const promise = firstValueFrom(race([resolved$, aborted$]))
409
+ const releaseSubscriber = () => {
410
+ setCleanupTimeout(() => {
411
+ state.set('removeSubscriber', removeSubscriber(key, subscriptionId))
412
+ }, QUERY_STATE_CLEAR_DELAY)
413
+ }
414
+ promise.then(releaseSubscriber, releaseSubscriber)
415
+ return promise
401
416
  },
402
417
  )
@@ -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
  })
@@ -1,4 +1,4 @@
1
- import {omit} from 'lodash-es'
1
+ import {omitProperty} from '../utils/object'
2
2
 
3
3
  interface QueryState {
4
4
  syncTags?: string[]
@@ -54,22 +54,6 @@ export const removeSubscriber =
54
54
  const prevQuery = prev.queries[key]
55
55
  if (!prevQuery) return prev
56
56
  const subscribers = prevQuery.subscribers.filter((id) => id !== subscriptionId)
57
- if (!subscribers.length) return {...prev, queries: omit(prev.queries, key)}
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: omit(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
- }
@@ -1,14 +1,13 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
2
3
  import {describe, expect, it, vi} from 'vitest'
3
4
 
4
- import {type ReleasePerspective, type ResourceHandle} from '../config/sanityConfig'
5
- import {getQueryState} from '../query/queryStore'
5
+ import {type PerspectiveHandle, type ReleasePerspective} from '../config/sanityConfig'
6
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
- import {type StateSource} from '../store/createStateSourceAction'
8
7
  import {getPerspectiveState} from './getPerspectiveState'
9
- import {type ReleaseDocument} from './releasesStore'
8
+ import {observeReleases} from './observeReleases'
10
9
 
11
- vi.mock('../query/queryStore')
10
+ vi.mock('./observeReleases')
12
11
 
13
12
  vi.mock('../client/clientStore', () => ({
14
13
  getClientState: vi.fn(() => ({
@@ -39,17 +38,12 @@ describe('getPerspectiveState', () => {
39
38
 
40
39
  // the release store is reversed in getActiveReleases to match UI elsewhere
41
40
  const activeReleases = [release2, release1]
42
- const resource: ResourceHandle['resource'] = {projectId: 'p', dataset: 'd'}
43
41
 
44
42
  beforeEach(() => {
45
- instance = createSanityInstance()
43
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
46
44
 
47
45
  mockReleasesQuerySubject = new Subject<ReleaseDocument[] | undefined>()
48
- vi.mocked(getQueryState).mockReturnValue({
49
- subscribe: () => () => {},
50
- getCurrent: () => undefined,
51
- observable: mockReleasesQuerySubject.asObservable(),
52
- } as StateSource<ReleaseDocument[] | undefined>)
46
+ vi.mocked(observeReleases).mockReturnValue(mockReleasesQuerySubject.asObservable())
53
47
  })
54
48
 
55
49
  afterEach(() => {
@@ -58,7 +52,7 @@ describe('getPerspectiveState', () => {
58
52
  })
59
53
 
60
54
  it('should return default perspective if no options or instance perspective is provided', async () => {
61
- const state = getPerspectiveState(instance, {resource})
55
+ const state = getPerspectiveState(instance)
62
56
  mockReleasesQuerySubject.next([])
63
57
  const perspective = await firstValueFrom(state.observable)
64
58
  expect(perspective).toBe('drafts')
@@ -66,14 +60,14 @@ describe('getPerspectiveState', () => {
66
60
 
67
61
  it('should return instance perspective if provided and no options perspective', async () => {
68
62
  instance.config.perspective = 'published'
69
- const state = getPerspectiveState(instance, {resource})
63
+ const state = getPerspectiveState(instance)
70
64
  mockReleasesQuerySubject.next([])
71
65
  const perspective = await firstValueFrom(state.observable)
72
66
  expect(perspective).toBe('published')
73
67
  })
74
68
 
75
69
  it('should return options perspective if provided', async () => {
76
- const options = {perspective: 'raw' as const, resource}
70
+ const options: PerspectiveHandle = {perspective: 'raw'}
77
71
  const state = getPerspectiveState(instance, options)
78
72
  mockReleasesQuerySubject.next([])
79
73
  const perspective = await firstValueFrom(state.observable)
@@ -81,7 +75,7 @@ describe('getPerspectiveState', () => {
81
75
  })
82
76
 
83
77
  it('should return undefined if release perspective is requested but no active releases', async () => {
84
- const options = {perspective: {releaseName: 'release1'}, resource}
78
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
85
79
  const state = getPerspectiveState(instance, options)
86
80
  mockReleasesQuerySubject.next([])
87
81
  const perspective = await firstValueFrom(state.observable)
@@ -89,7 +83,7 @@ describe('getPerspectiveState', () => {
89
83
  })
90
84
 
91
85
  it('should calculate perspective based on active releases and releaseName', async () => {
92
- const options = {perspective: {releaseName: 'release1'}, resource}
86
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
93
87
  const state = getPerspectiveState(instance, options)
94
88
  mockReleasesQuerySubject.next(activeReleases)
95
89
 
@@ -103,7 +97,7 @@ describe('getPerspectiveState', () => {
103
97
  })
104
98
 
105
99
  it('should calculate perspective including multiple releases up to the specified releaseName', async () => {
106
- const options = {perspective: {releaseName: 'release2'}, resource}
100
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release2'}}
107
101
  const state = getPerspectiveState(instance, options)
108
102
  mockReleasesQuerySubject.next(activeReleases)
109
103
  const perspective = await firstValueFrom(
@@ -120,7 +114,7 @@ describe('getPerspectiveState', () => {
120
114
  releaseName: 'release2',
121
115
  excludedPerspectives: ['release1', 'drafts'],
122
116
  }
123
- const options = {perspective: perspectiveConfig, resource}
117
+ const options: PerspectiveHandle = {perspective: perspectiveConfig}
124
118
  const state = getPerspectiveState(instance, options)
125
119
  mockReleasesQuerySubject.next(activeReleases)
126
120
  const perspective = await firstValueFrom(
@@ -133,7 +127,7 @@ describe('getPerspectiveState', () => {
133
127
  })
134
128
 
135
129
  it('should throw if the specified releaseName is not found in active releases', async () => {
136
- const options = {perspective: {releaseName: 'nonexistent'}, resource}
130
+ const options: PerspectiveHandle = {perspective: {releaseName: 'nonexistent'}}
137
131
  const state = getPerspectiveState(instance, options)
138
132
  mockReleasesQuerySubject.next(activeReleases)
139
133
 
@@ -148,8 +142,8 @@ describe('getPerspectiveState', () => {
148
142
  })
149
143
 
150
144
  it('should reuse the same options object for identical inputs (cache test)', async () => {
151
- const options1 = {perspective: {releaseName: 'release1'}, resource}
152
- const options2 = {perspective: {releaseName: 'release1'}, resource}
145
+ const options1: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
146
+ const options2: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
153
147
 
154
148
  const state1 = getPerspectiveState(instance, options1)
155
149
  mockReleasesQuerySubject.next(activeReleases)
@@ -167,7 +161,7 @@ describe('getPerspectiveState', () => {
167
161
  })
168
162
 
169
163
  it('should handle changes in activeReleases (cache test)', async () => {
170
- const options = {perspective: {releaseName: 'release1'}, resource}
164
+ const options: PerspectiveHandle = {perspective: {releaseName: 'release1'}}
171
165
 
172
166
  const state1 = getPerspectiveState(instance, options)
173
167
  mockReleasesQuerySubject.next(activeReleases)
@@ -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'
@@ -26,7 +18,7 @@ const selectActiveReleases = (context: SelectorContext<ReleasesStoreState>) =>
26
18
  context.state.activeReleases
27
19
  const selectOptions = (
28
20
  _context: SelectorContext<ReleasesStoreState>,
29
- options: PerspectiveHandle & {resource?: DocumentResource},
21
+ options: PerspectiveHandle & {projectId?: string; dataset?: string; resource?: DocumentResource},
30
22
  ) => options
31
23
 
32
24
  const memoizedOptionsSelector = createSelector(
@@ -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
- let _boundGetPerspectiveState: BoundGetPerspectiveState | undefined
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,12 +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
- }
113
- return _boundGetPerspectiveState(instance, ...rest)
114
- }
100
+ export const getPerspectiveState: BoundGetPerspectiveState = (instance, ...rest) =>
101
+ // bindActionByResource keyFn destructures { resource } from the first param, so pass {} when no options
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
+ })