@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.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.
Files changed (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -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
+ })
@@ -0,0 +1,102 @@
1
+ import {type ReleaseDocument, type SyncTag} from '@sanity/client'
2
+ import {
3
+ BehaviorSubject,
4
+ combineLatest,
5
+ distinctUntilChanged,
6
+ filter,
7
+ map,
8
+ type Observable,
9
+ startWith,
10
+ switchMap,
11
+ tap,
12
+ } from 'rxjs'
13
+
14
+ import {getClientState} from '../client/clientStore'
15
+ import {observeLiveEvents} from '../client/liveEvents'
16
+ import {type DocumentResource} from '../config/sanityConfig'
17
+ import {type SanityInstance} from '../store/createSanityInstance'
18
+
19
+ const RELEASES_QUERY = 'releases::all()'
20
+ const RELEASES_API_VERSION = 'v2025-05-06'
21
+
22
+ /**
23
+ * Options for {@link observeReleases}.
24
+ * @internal
25
+ */
26
+ export interface ObserveReleasesOptions {
27
+ /** Resource to scope the underlying client and live connection to. */
28
+ resource?: DocumentResource
29
+ /**
30
+ * Called when the live events connection fails due to a CORS
31
+ * misconfiguration. See `observeLiveEvents`.
32
+ */
33
+ onCorsError: (error: unknown) => void
34
+ }
35
+
36
+ /**
37
+ * Emits every release document (including archived and published), refetching
38
+ * whenever a Live Content API event matches the query's sync tags.
39
+ *
40
+ * This fetches directly through the client store — deliberately not via the
41
+ * query store — because release data is itself an input to query perspective
42
+ * resolution (`getPerspectiveState`); fetching through the query store would
43
+ * make the two stores mutually dependent.
44
+ *
45
+ * @internal
46
+ */
47
+ export function observeReleases(
48
+ instance: SanityInstance,
49
+ {resource, onCorsError}: ObserveReleasesOptions,
50
+ ): Observable<ReleaseDocument[] | undefined> {
51
+ const client$ = getClientState(instance, {
52
+ apiVersion: RELEASES_API_VERSION,
53
+ resource,
54
+ }).observable
55
+
56
+ return client$.pipe(
57
+ switchMap((client) => {
58
+ const syncTags$ = new BehaviorSubject<SyncTag[] | undefined>(undefined)
59
+
60
+ // Pair the latest live message with the latest known sync tags so that
61
+ // a message arriving while a fetch is still in flight is re-evaluated
62
+ // (and refetched if it matches) once that fetch's tags land — instead
63
+ // of being dropped and leaving the store stale until the next event.
64
+ const refetchEventId$ = combineLatest([
65
+ observeLiveEvents(instance, {resource, onCorsError}).pipe(startWith(undefined)),
66
+ syncTags$,
67
+ ]).pipe(
68
+ filter(
69
+ ([message, syncTags]) =>
70
+ message === undefined || message.tags.some((tag) => syncTags?.includes(tag)),
71
+ ),
72
+ map(([message]) => message?.id),
73
+ distinctUntilChanged(),
74
+ )
75
+
76
+ return refetchEventId$.pipe(
77
+ switchMap((lastLiveEventId) =>
78
+ client.observable.fetch<ReleaseDocument[]>(
79
+ RELEASES_QUERY,
80
+ {},
81
+ {
82
+ perspective: 'raw',
83
+ filterResponse: false,
84
+ returnQuery: false,
85
+ lastLiveEventId,
86
+ tag: 'releases',
87
+ },
88
+ ),
89
+ ),
90
+ tap((response) => {
91
+ syncTags$.next(response.syncTags)
92
+ }),
93
+ map((response) => response.result),
94
+ )
95
+ }),
96
+ // Emit synchronously on subscribe (mirroring StateSource semantics, which
97
+ // the releases store previously consumed) so the store can immediately
98
+ // record an empty list: consumers distinguish "no releases yet" from
99
+ // "never loaded", and the React hooks don't suspend on first load.
100
+ startWith(undefined),
101
+ )
102
+ }
@@ -1,13 +1,13 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {NEVER, Observable, type Observer, of, Subject} from 'rxjs'
2
3
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
4
 
4
- import {getQueryState, resolveQuery} from '../query/queryStore'
5
5
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
- import {type StateSource} from '../store/createStateSourceAction'
7
- import {getActiveReleasesState, type ReleaseDocument} from './releasesStore'
6
+ import {observeReleases} from './observeReleases'
7
+ import {getActiveReleasesState, getAllReleasesState} from './releasesStore'
8
8
 
9
9
  // Mock dependencies
10
- vi.mock('../query/queryStore')
10
+ vi.mock('./observeReleases')
11
11
 
12
12
  describe('releasesStore', () => {
13
13
  let instance: SanityInstance
@@ -15,32 +15,24 @@ describe('releasesStore', () => {
15
15
  beforeEach(() => {
16
16
  vi.clearAllMocks()
17
17
 
18
- instance = createSanityInstance()
18
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
19
19
 
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)
20
+ vi.mocked(observeReleases).mockReturnValue(NEVER as Observable<ReleaseDocument[] | undefined>)
27
21
  })
28
22
 
29
23
  afterEach(() => {
30
24
  instance.dispose()
31
25
  })
32
26
 
33
- it('supports calls with explicit resource', () => {
34
- const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
27
+ it('supports calls without options', () => {
28
+ const state = getActiveReleasesState(instance)
35
29
 
36
30
  expect(state.getCurrent()).toBeUndefined()
37
- expect(getQueryState).toHaveBeenCalledWith(
31
+ expect(observeReleases).toHaveBeenCalledWith(
38
32
  instance,
39
33
  expect.objectContaining({
40
- query: 'releases::all()',
41
- perspective: 'raw',
42
- resource: {projectId: 'test', dataset: 'test'},
43
- tag: 'releases',
34
+ resource: {dataset: 'test', projectId: 'test'},
35
+ onCorsError: expect.any(Function),
44
36
  }),
45
37
  )
46
38
  })
@@ -51,11 +43,7 @@ describe('releasesStore', () => {
51
43
  .fn<(observer: Observer<ReleaseDocument[] | undefined>) => () => void>()
52
44
  .mockReturnValue(teardown)
53
45
 
54
- vi.mocked(getQueryState).mockReturnValue({
55
- subscribe: () => () => {},
56
- getCurrent: () => undefined,
57
- observable: new Observable(subscriber),
58
- } as StateSource<ReleaseDocument[] | undefined>)
46
+ vi.mocked(observeReleases).mockReturnValue(new Observable(subscriber))
59
47
 
60
48
  // note that the order of the releases is important here -- they get sorted
61
49
  const mockReleases: ReleaseDocument[] = [
@@ -86,11 +74,7 @@ describe('releasesStore', () => {
86
74
 
87
75
  it('should update active releases state when the query emits new data', async () => {
88
76
  const releasesSubject = new Subject<ReleaseDocument[]>()
89
- vi.mocked(getQueryState).mockReturnValue({
90
- subscribe: () => () => {},
91
- getCurrent: () => undefined,
92
- observable: releasesSubject.asObservable(),
93
- } as StateSource<ReleaseDocument[] | undefined>)
77
+ vi.mocked(observeReleases).mockReturnValue(releasesSubject.asObservable())
94
78
 
95
79
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
96
80
 
@@ -132,12 +116,8 @@ describe('releasesStore', () => {
132
116
  })
133
117
 
134
118
  it('should handle empty array from the query', async () => {
135
- // Configure query to return an empty array
136
- vi.mocked(getQueryState).mockReturnValue({
137
- subscribe: () => () => {},
138
- getCurrent: () => [],
139
- observable: of([]),
140
- } as StateSource<ReleaseDocument[] | undefined>)
119
+ // Configure the releases source to return an empty array
120
+ vi.mocked(observeReleases).mockReturnValue(of([]))
141
121
 
142
122
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
143
123
 
@@ -148,39 +128,106 @@ describe('releasesStore', () => {
148
128
 
149
129
  it('should handle null/undefined from the query by defaulting to empty array', async () => {
150
130
  // Test null case
151
- vi.mocked(getQueryState).mockReturnValue({
152
- subscribe: () => () => {},
153
- getCurrent: () => null as unknown as ReleaseDocument[] | undefined,
154
- observable: of(null as unknown as ReleaseDocument[] | undefined),
155
- } as StateSource<ReleaseDocument[] | undefined>)
131
+ vi.mocked(observeReleases).mockReturnValue(of(null as unknown as ReleaseDocument[] | undefined))
156
132
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
157
133
  await new Promise((resolve) => setTimeout(resolve, 0))
158
134
  expect(state.getCurrent()).toEqual([])
159
135
 
160
136
  // Test undefined case
161
- vi.mocked(getQueryState).mockReturnValue({
162
- subscribe: () => () => {},
163
- getCurrent: () => undefined,
164
- observable: of(undefined),
165
- } as StateSource<ReleaseDocument[] | undefined>)
137
+ vi.mocked(observeReleases).mockReturnValue(of(undefined))
166
138
  await new Promise((resolve) => setTimeout(resolve, 0))
167
139
  expect(state.getCurrent()).toEqual([])
168
140
  })
169
141
 
170
- it('should not crash when the releases query errors', async () => {
142
+ it('exposes archived/published releases through getAllReleasesState but filters them out of getActiveReleasesState', async () => {
143
+ const subject = new Subject<ReleaseDocument[]>()
144
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
145
+
146
+ const active = getActiveReleasesState(instance, {
147
+ resource: {projectId: 'test', dataset: 'test'},
148
+ })
149
+ const all = getAllReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
150
+
151
+ const releases: ReleaseDocument[] = [
152
+ {
153
+ _id: 'r-active',
154
+ _type: 'system.release',
155
+ name: 'r-active',
156
+ state: 'active',
157
+ metadata: {releaseType: 'asap'},
158
+ } as ReleaseDocument,
159
+ {
160
+ _id: 'r-archived',
161
+ _type: 'system.release',
162
+ name: 'r-archived',
163
+ state: 'archived',
164
+ metadata: {releaseType: 'asap'},
165
+ } as ReleaseDocument,
166
+ {
167
+ _id: 'r-published',
168
+ _type: 'system.release',
169
+ name: 'r-published',
170
+ state: 'published',
171
+ metadata: {releaseType: 'asap'},
172
+ } as ReleaseDocument,
173
+ ]
174
+
175
+ subject.next(releases)
176
+ await new Promise((resolve) => setTimeout(resolve, 0))
177
+
178
+ const activeNames = active.getCurrent()?.map((r) => r.name) ?? []
179
+ const allNames = all.getCurrent()?.map((r) => r.name) ?? []
180
+
181
+ expect(activeNames).toEqual(['r-active'])
182
+ expect(allNames).toEqual(expect.arrayContaining(['r-active', 'r-archived', 'r-published']))
183
+ expect(allNames).toHaveLength(3)
184
+ })
185
+
186
+ it('surfaces CORS errors from the live connection as a store-wide error', async () => {
171
187
  const subject = new Subject<ReleaseDocument[]>()
172
- vi.mocked(getQueryState).mockReturnValue({
173
- subscribe: () => () => {},
174
- getCurrent: () => undefined,
175
- observable: subject.asObservable(),
176
- } as StateSource<ReleaseDocument[] | undefined>)
188
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
177
189
 
178
190
  const state = getActiveReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
179
191
 
180
- subject.error(new Error('Query failed'))
192
+ subject.next([
193
+ {
194
+ _id: 'r1',
195
+ _type: 'system.release',
196
+ name: 'r1',
197
+ state: 'active',
198
+ metadata: {releaseType: 'asap'},
199
+ } as ReleaseDocument,
200
+ ])
201
+ await new Promise((resolve) => setTimeout(resolve, 0))
202
+ expect(state.getCurrent()).toHaveLength(1)
203
+
204
+ const [, options] = vi.mocked(observeReleases).mock.lastCall!
205
+ const corsError = new Error('CORS misconfiguration')
206
+ options.onCorsError(corsError)
207
+ await new Promise((resolve) => setTimeout(resolve, 0))
208
+
209
+ // the CORS error is recorded as store state so the releases selector
210
+ // rethrows it (handled by the Cors Error component)
211
+ expect(() => state.getCurrent()).toThrow(corsError)
212
+ })
213
+
214
+ it('should surface the error when the releases query errors', async () => {
215
+ const subject = new Subject<ReleaseDocument[]>()
216
+ vi.mocked(observeReleases).mockReturnValue(subject.asObservable())
217
+
218
+ const active = getActiveReleasesState(instance, {
219
+ resource: {projectId: 'test', dataset: 'test'},
220
+ })
221
+ const all = getAllReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
222
+
223
+ const error = new Error('Query failed')
224
+ subject.error(error)
181
225
 
182
226
  await new Promise((resolve) => setTimeout(resolve, 0))
183
227
 
184
- expect(state.getCurrent()).toEqual(undefined)
228
+ // The error must be surfaced (thrown) rather than silently swallowed,
229
+ // otherwise consumers just see an empty list with no signal.
230
+ expect(() => active.getCurrent()).toThrow(error)
231
+ expect(() => all.getCurrent()).toThrow(error)
185
232
  })
186
233
  })
@@ -1,43 +1,35 @@
1
- import {type SanityDocument} from '@sanity/types'
1
+ import {type ReleaseDocument} from '@sanity/client'
2
2
  import {map} from 'rxjs'
3
3
 
4
4
  import {type DocumentResource} from '../config/sanityConfig'
5
- /*
6
- * Although this is an import dependency cycle, it is not a logical cycle:
7
- * 1. releasesStore uses queryStore as a data source
8
- * 2. queryStore calls getPerspectiveState for computing release perspectives
9
- * 3. getPerspectiveState uses releasesStore as a data source
10
- * 4. however, queryStore does not use getPerspectiveState for the perspective used in releasesStore ("raw")
11
- */
12
- // eslint-disable-next-line import/no-cycle
13
- import {getQueryState} from '../query/queryStore'
14
5
  import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
15
6
  import {type SanityInstance} from '../store/createSanityInstance'
16
7
  import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
17
8
  import {defineStore, type StoreContext} from '../store/defineStore'
9
+ import {observeReleases} from './observeReleases'
18
10
  import {sortReleases} from './utils/sortReleases'
19
11
 
20
12
  const ARCHIVED_RELEASE_STATES = ['archived', 'published']
21
13
  const STABLE_EMPTY_RELEASES: ReleaseDocument[] = []
22
14
 
23
15
  /**
24
- * Represents a document in a Sanity dataset that represents release options.
25
- * @internal
16
+ * Lifecycle states a release document can be in. Mirrors the server's
17
+ * `ReleaseState`.
18
+ * @beta
26
19
  */
27
- export type ReleaseDocument = SanityDocument & {
28
- name: string
29
- publishAt?: string
30
- state: 'active' | 'scheduled'
31
- metadata: {
32
- title: string
33
- releaseType: 'asap' | 'scheduled' | 'undecided'
34
- intendedPublishAt?: string
35
- description?: string
36
- }
37
- }
20
+ export type ReleaseState =
21
+ | 'active'
22
+ | 'archiving'
23
+ | 'unarchiving'
24
+ | 'archived'
25
+ | 'published'
26
+ | 'publishing'
27
+ | 'scheduled'
28
+ | 'scheduling'
38
29
 
39
30
  export interface ReleasesStoreState {
40
31
  activeReleases?: ReleaseDocument[]
32
+ allReleases?: ReleaseDocument[]
41
33
  error?: unknown
42
34
  }
43
35
 
@@ -45,6 +37,7 @@ export const releasesStore = defineStore<ReleasesStoreState, BoundResourceKey>({
45
37
  name: 'Releases',
46
38
  getInitialState: (): ReleasesStoreState => ({
47
39
  activeReleases: undefined,
40
+ allReleases: undefined,
48
41
  }),
49
42
  initialize: (context) => {
50
43
  const subscription = subscribeToReleases(context)
@@ -59,7 +52,10 @@ export const releasesStore = defineStore<ReleasesStoreState, BoundResourceKey>({
59
52
  const _getActiveReleasesState = bindActionByResource(
60
53
  releasesStore,
61
54
  createStateSourceAction({
62
- selector: ({state}, _?) => state.activeReleases,
55
+ selector: ({state}) => {
56
+ if (state.error) throw state.error
57
+ return state.activeReleases
58
+ },
63
59
  }),
64
60
  )
65
61
 
@@ -69,33 +65,62 @@ const _getActiveReleasesState = bindActionByResource(
69
65
  */
70
66
  export const getActiveReleasesState = (
71
67
  instance: SanityInstance,
72
- options: {resource: DocumentResource},
73
- ): StateSource<ReleaseDocument[] | undefined> => _getActiveReleasesState(instance, options)
68
+ options?: {resource?: DocumentResource},
69
+ ): StateSource<ReleaseDocument[] | undefined> =>
70
+ // bindActionByResource keyFn destructures { resource } from the first param, so pass {} when no options
71
+ _getActiveReleasesState(instance, options ?? {})
74
72
 
75
- const RELEASES_QUERY = 'releases::all()'
73
+ /**
74
+ * Get every release in the store, including archived and published.
75
+ * @internal
76
+ */
77
+ const _getAllReleasesState = bindActionByResource(
78
+ releasesStore,
79
+ createStateSourceAction({
80
+ selector: ({state}) => {
81
+ if (state.error) throw state.error
82
+ return state.allReleases
83
+ },
84
+ }),
85
+ )
86
+
87
+ /**
88
+ * Get every release in the store, including archived and published.
89
+ * @internal
90
+ */
91
+ export const getAllReleasesState = (
92
+ instance: SanityInstance,
93
+ options?: {resource?: DocumentResource},
94
+ ): StateSource<ReleaseDocument[] | undefined> => _getAllReleasesState(instance, options ?? {})
76
95
 
77
96
  const subscribeToReleases = ({
78
97
  instance,
79
98
  state,
80
99
  key: {resource},
81
100
  }: StoreContext<ReleasesStoreState, BoundResourceKey>) => {
82
- const {observable: releases$} = getQueryState<ReleaseDocument[]>(instance, {
83
- query: RELEASES_QUERY,
84
- perspective: 'raw',
101
+ const releases$ = observeReleases(instance, {
85
102
  resource,
86
- tag: 'releases',
103
+ // Surface CORS errors as store state instead of erroring the stream so
104
+ // they can be handled by the Cors Error component
105
+ onCorsError: (error) => state.set('setError', {error}),
87
106
  })
88
- return releases$
89
- .pipe(
90
- map((releases) => {
91
- // logic here mirrors that of studio:
92
- // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
93
- state.set('setActiveReleases', {
94
- activeReleases: sortReleases(releases ?? STABLE_EMPTY_RELEASES)
95
- .filter((release) => !ARCHIVED_RELEASE_STATES.includes(release.state))
96
- .reverse(),
97
- })
98
- }),
99
- )
100
- .subscribe({error: (error) => state.set('setError', {error})})
107
+ return (
108
+ releases$
109
+ .pipe(
110
+ map((releases) => {
111
+ // logic here mirrors that of studio:
112
+ // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
113
+ const sorted = sortReleases(releases ?? STABLE_EMPTY_RELEASES).reverse()
114
+ state.set('setReleases', {
115
+ allReleases: sorted,
116
+ activeReleases: sorted.filter(
117
+ (release) => !ARCHIVED_RELEASE_STATES.includes(release.state),
118
+ ),
119
+ })
120
+ }),
121
+ )
122
+ // live-connection errors are already retried inside observeLiveEvents;
123
+ // a fetch error is terminal and surfaced via setError
124
+ .subscribe({error: (error) => state.set('setError', {error})})
125
+ )
101
126
  }
@@ -1,6 +1,6 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {describe, expect, it} from 'vitest'
2
3
 
3
- import {type ReleaseDocument} from '../releasesStore'
4
4
  import {sortReleases} from './sortReleases'
5
5
 
6
6
  // Mock function to create a release document
@@ -15,7 +15,7 @@ function createReleaseMock(
15
15
  return {
16
16
  _id: id,
17
17
  _rev: 'rev',
18
- _type: 'release',
18
+ _type: 'system.release',
19
19
  _createdAt: new Date().toISOString(),
20
20
  _updatedAt: new Date().toISOString(),
21
21
  state: 'active',
@@ -333,4 +333,40 @@ describe('sortReleases()', () => {
333
333
  expect(sorted[idx]['_id']).toContain(expectedName)
334
334
  })
335
335
  })
336
+
337
+ it('should not throw when a release has no metadata', () => {
338
+ // Releases created without a title/type can come back from the API with no
339
+ // `metadata` object at all. Sorting must not throw on these, otherwise the
340
+ // whole releases subscription errors and the list comes back empty.
341
+ const releaseWithoutMetadata = {
342
+ _id: '_.releases.rnometa',
343
+ _rev: 'rev',
344
+ _type: 'system.release',
345
+ _createdAt: '2024-10-24T00:00:00Z',
346
+ _updatedAt: '2024-10-24T00:00:00Z',
347
+ state: 'active',
348
+ name: 'rnometa',
349
+ } as unknown as ReleaseDocument
350
+
351
+ const releases: ReleaseDocument[] = [
352
+ createReleaseMock({
353
+ _id: '_.releases.rasap1',
354
+ _createdAt: '2024-10-25T00:00:00Z',
355
+ metadata: {releaseType: 'asap'},
356
+ }),
357
+ releaseWithoutMetadata,
358
+ createReleaseMock({
359
+ _id: '_.releases.rundecided1',
360
+ _createdAt: '2024-10-26T00:00:00Z',
361
+ metadata: {releaseType: 'undecided'},
362
+ }),
363
+ ]
364
+
365
+ let sorted: ReleaseDocument[] = []
366
+ expect(() => {
367
+ sorted = sortReleases(releases)
368
+ }).not.toThrow()
369
+ expect(sorted).toHaveLength(3)
370
+ expect(sorted.map((r) => r._id)).toContain('_.releases.rnometa')
371
+ })
336
372
  })