@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
@@ -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
  })
@@ -1,4 +1,4 @@
1
- import {type ReleaseDocument} from '../releasesStore'
1
+ import {type ReleaseDocument} from '@sanity/client'
2
2
 
3
3
  // mirrors the order of the releases in the releases list in Studio
4
4
  // https://github.com/sanity-io/sanity/blob/main/packages/sanity/src/core/releases/hooks/utils.ts
@@ -6,25 +6,30 @@ export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[
6
6
  // The order should always be:
7
7
  // [undecided (sortByCreatedAt), scheduled(sortBy publishAt || metadata.intendedPublishAt), asap(sortByCreatedAt)]
8
8
  return [...releases].sort((a, b) => {
9
+ // metadata is optional on a release document; guard against releases that
10
+ // have no metadata at all so sorting can't throw and wipe out the list.
11
+ const aType = a.metadata?.releaseType
12
+ const bType = b.metadata?.releaseType
13
+
9
14
  // undecided are always first, then by createdAt descending
10
- if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType !== 'undecided') {
15
+ if (aType === 'undecided' && bType !== 'undecided') {
11
16
  return -1
12
17
  }
13
- if (a.metadata.releaseType !== 'undecided' && b.metadata.releaseType === 'undecided') {
18
+ if (aType !== 'undecided' && bType === 'undecided') {
14
19
  return 1
15
20
  }
16
- if (a.metadata.releaseType === 'undecided' && b.metadata.releaseType === 'undecided') {
21
+ if (aType === 'undecided' && bType === 'undecided') {
17
22
  // Sort by createdAt
18
23
  return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime()
19
24
  }
20
25
 
21
26
  // Scheduled are always at the middle, then by publishAt descending
22
- if (a.metadata.releaseType === 'scheduled' && b.metadata.releaseType === 'scheduled') {
23
- const aPublishAt = a['publishAt'] || a.metadata['intendedPublishAt']
27
+ if (aType === 'scheduled' && bType === 'scheduled') {
28
+ const aPublishAt = a['publishAt'] || a.metadata?.['intendedPublishAt']
24
29
  if (!aPublishAt) {
25
30
  return 1
26
31
  }
27
- const bPublishAt = b['publishAt'] || b.metadata['intendedPublishAt']
32
+ const bPublishAt = b['publishAt'] || b.metadata?.['intendedPublishAt']
28
33
  if (!bPublishAt) {
29
34
  return -1
30
35
  }
@@ -32,13 +37,13 @@ export function sortReleases(releases: ReleaseDocument[] = []): ReleaseDocument[
32
37
  }
33
38
 
34
39
  // ASAP are always last, then by createdAt descending
35
- if (a.metadata.releaseType === 'asap' && b.metadata.releaseType !== 'asap') {
40
+ if (aType === 'asap' && bType !== 'asap') {
36
41
  return 1
37
42
  }
38
- if (a.metadata.releaseType !== 'asap' && b.metadata.releaseType === 'asap') {
43
+ if (aType !== 'asap' && bType === 'asap') {
39
44
  return -1
40
45
  }
41
- if (a.metadata.releaseType === 'asap' && b.metadata.releaseType === 'asap') {
46
+ if (aType === 'asap' && bType === 'asap') {
42
47
  // Sort by createdAt
43
48
  return new Date(b._createdAt).getTime() - new Date(a._createdAt).getTime()
44
49
  }