@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,117 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+
3
+ import {
4
+ type DocumentResource,
5
+ isCanvasResource,
6
+ isMediaLibraryResource,
7
+ } from '../config/sanityConfig'
8
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
9
+ import {type Action, type DocumentAction} from './actions'
10
+ import {getEffectiveDocumentId} from './util'
11
+
12
+ export interface EffectiveDocModel {
13
+ /**
14
+ * If this is `undefined`, the resource has no opinion and the handle's `liveEdit`
15
+ * flag should be respected.
16
+ */
17
+ liveEdit: boolean | undefined
18
+ /**
19
+ * When `false`, the resource does not support release perspectives. Callers
20
+ * should drop any release perspective and fall back to the standard path.
21
+ */
22
+ supportsReleases: boolean
23
+ }
24
+
25
+ const MEDIA_LIBRARY_DRAFTED_TYPES = new Set(['sanity.asset'])
26
+
27
+ /**
28
+ * Different resources have different "default" editing models.
29
+ *
30
+ * Canvas uses a liveEdit model.
31
+ * Medial Library is mostly liveEdit except for `sanity.asset`, which retains the
32
+ * draft/published model. Neither resource supports release perspectives.
33
+ */
34
+ export function getEffectiveDocumentModel(
35
+ resource: DocumentResource | undefined,
36
+ documentType: string | undefined,
37
+ ): EffectiveDocModel {
38
+ if (!resource) {
39
+ return {liveEdit: undefined, supportsReleases: true}
40
+ }
41
+ if (isCanvasResource(resource)) {
42
+ return {liveEdit: true, supportsReleases: false}
43
+ }
44
+ if (isMediaLibraryResource(resource)) {
45
+ const isDrafted = documentType ? MEDIA_LIBRARY_DRAFTED_TYPES.has(documentType) : false
46
+ return {liveEdit: !isDrafted, supportsReleases: false}
47
+ }
48
+ return {liveEdit: undefined, supportsReleases: true}
49
+ }
50
+
51
+ function describeResource(resource: DocumentResource | undefined): string {
52
+ if (resource && isCanvasResource(resource)) return 'Canvas'
53
+ if (resource && isMediaLibraryResource(resource)) return 'Media Library'
54
+ return 'this resource'
55
+ }
56
+
57
+ /**
58
+ * Rewrites edit actions to match the editing model of the bound resource.
59
+ *
60
+ * Canvas and Media Library resources do not support release perspectives, and
61
+ * Canvas (plus most Media Library types) does not have a draft/published
62
+ * model. When an edit action arrives with a release perspective for a resource
63
+ * that doesn't support it, the perspective is stripped and a single warning
64
+ * is logged. When the resource forces liveEdit, the action's `liveEdit` flag
65
+ * is set so the dispatcher takes the liveEdit branch.
66
+ *
67
+ * Only `document.edit` actions are normalized today — other action types
68
+ * (publish, unpublish, release actions, etc.) pass through unchanged.
69
+ *
70
+ * @internal
71
+ */
72
+ export function normalizeActionsForResource(
73
+ actions: Action[],
74
+ resource: DocumentResource | undefined,
75
+ ): Action[] {
76
+ // collect actions that may have changed in unexpected ways
77
+ const stripped: Array<{documentType: string; documentId: string}> = []
78
+
79
+ const normalized = actions.map((action) => {
80
+ if (action.type !== 'document.edit') return action
81
+
82
+ const {liveEdit: forcedLiveEdit, supportsReleases} = getEffectiveDocumentModel(
83
+ resource,
84
+ action.documentType,
85
+ )
86
+ const shouldRemovePerspective = isReleasePerspective(action.perspective) && !supportsReleases
87
+ const shouldForceLiveEdit = forcedLiveEdit === true && !action.liveEdit
88
+
89
+ if (!shouldRemovePerspective && !shouldForceLiveEdit) return action
90
+
91
+ const corrected: DocumentAction = {...action}
92
+ if (shouldForceLiveEdit) corrected.liveEdit = true
93
+ if (shouldRemovePerspective) corrected.perspective = undefined
94
+
95
+ // ensure we're using the right document ID for the corrected model
96
+ corrected.documentId = getEffectiveDocumentId({
97
+ ...corrected,
98
+ documentId: getPublishedId(DocumentId(corrected.documentId)),
99
+ })
100
+
101
+ if (shouldRemovePerspective) {
102
+ stripped.push({documentType: action.documentType, documentId: corrected.documentId})
103
+ }
104
+
105
+ return corrected
106
+ })
107
+
108
+ if (stripped.length > 0) {
109
+ const docs = stripped.map((e) => `${e.documentType} (${e.documentId})`).join(', ')
110
+ // eslint-disable-next-line no-console
111
+ console.warn(
112
+ `[sanity-sdk] ${describeResource(resource)} does not support release perspectives — falling back to the standard editing path for: ${docs}`,
113
+ )
114
+ }
115
+
116
+ return normalized
117
+ }
@@ -16,7 +16,7 @@ import {createSanityInstance} from '../store/createSanityInstance'
16
16
  import {type StateSource} from '../store/createStateSourceAction'
17
17
  import {createFetchDocument, createSharedListener} from './sharedListener'
18
18
 
19
- const instance = createSanityInstance()
19
+ const instance = createSanityInstance({projectId: 'p', dataset: 'd'})
20
20
 
21
21
  vi.mock('../client/clientStore.ts', () => ({getClientState: vi.fn()}))
22
22
  vi.mock('@sanity/mutate/_unstable_store', () => ({createDocumentLoaderFromClient: vi.fn()}))
@@ -43,14 +43,16 @@ describe('createSharedListener', () => {
43
43
  })
44
44
 
45
45
  it('should call client.listen with the expected parameters', () => {
46
- createSharedListener(instance, {projectId: 'p', dataset: 'd'}).events.subscribe()
46
+ createSharedListener(instance).events.subscribe()
47
47
  expect(fakeClient.listen).toHaveBeenCalledTimes(1)
48
48
  expect(fakeClient.listen).toHaveBeenCalledWith(
49
49
  '*',
50
50
  {},
51
51
  {
52
- events: ['mutation', 'welcome', 'reconnect'],
52
+ events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
53
+ enableResume: true,
53
54
  includeResult: false,
55
+ includeAllVersions: true,
54
56
  tag: 'document-listener',
55
57
  },
56
58
  )
@@ -62,7 +64,7 @@ describe('createSharedListener', () => {
62
64
  const mutationEvent: MutationEvent = {type: 'mutation'} as MutationEvent
63
65
  const reconnectEvent: ReconnectEvent = {type: 'reconnect'} as ReconnectEvent
64
66
 
65
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
67
+ const sharedListener = createSharedListener(instance)
66
68
  // Start collecting the emitted events.
67
69
  const eventsPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
68
70
 
@@ -80,7 +82,7 @@ describe('createSharedListener', () => {
80
82
  it('should replay the welcome event for new subscribers', async () => {
81
83
  const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
82
84
 
83
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
85
+ const sharedListener = createSharedListener(instance)
84
86
  // First subscription: emit welcome and complete.
85
87
  const firstPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
86
88
  fakeListenSubject.next(welcomeEvent)
@@ -97,7 +99,7 @@ describe('createSharedListener', () => {
97
99
  const mutationEvent = {type: 'mutation'} as MutationEvent
98
100
  const reconnectEvent = {type: 'reconnect'} as ReconnectEvent
99
101
 
100
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
102
+ const sharedListener = createSharedListener(instance)
101
103
  const eventsPromise = lastValueFrom(sharedListener.events.pipe(toArray()))
102
104
  fakeListenSubject.next(mutationEvent)
103
105
  fakeListenSubject.next(reconnectEvent)
@@ -110,7 +112,7 @@ describe('createSharedListener', () => {
110
112
  const welcomeEvent = {type: 'welcome'} as WelcomeEvent
111
113
  const mutationEvent = {type: 'mutation'} as MutationEvent
112
114
 
113
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
115
+ const sharedListener = createSharedListener(instance)
114
116
 
115
117
  // Subscribe two observers concurrently.
116
118
  const subscriber1 = sharedListener.events.pipe(bufferTime(0))
@@ -133,7 +135,7 @@ describe('createSharedListener', () => {
133
135
 
134
136
  it('should propagate errors from the underlying client.listen observable', async () => {
135
137
  const errorMessage = 'Test error'
136
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
138
+ const sharedListener = createSharedListener(instance)
137
139
 
138
140
  const error$ = sharedListener.events.pipe(
139
141
  toArray(),
@@ -149,7 +151,7 @@ describe('createSharedListener', () => {
149
151
  it('should stop emitting events after calling dispose', async () => {
150
152
  const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
151
153
  const mutationEvent: MutationEvent = {type: 'mutation'} as MutationEvent
152
- const sharedListener = createSharedListener(instance, {projectId: 'p', dataset: 'd'})
154
+ const sharedListener = createSharedListener(instance)
153
155
 
154
156
  const events: ListenEvent<SanityDocument>[] = []
155
157
  const subscription = sharedListener.events.subscribe((event) => {
@@ -187,7 +189,7 @@ describe('createFetchDocument', () => {
187
189
  })
188
190
 
189
191
  it('should call createDocumentLoaderFromClient with the fetched client', async () => {
190
- const fetchDocument = createFetchDocument(instance, {projectId: 'p', dataset: 'd'})
192
+ const fetchDocument = createFetchDocument(instance)
191
193
  const accessibleResult = {
192
194
  id: 'doc1',
193
195
  document: {_id: 'doc1', _rev: 'rev1'},
@@ -201,7 +203,7 @@ describe('createFetchDocument', () => {
201
203
  })
202
204
 
203
205
  it('should return the document when it is accessible', async () => {
204
- const fetchDocument = createFetchDocument(instance, {projectId: 'p', dataset: 'd'})
206
+ const fetchDocument = createFetchDocument(instance)
205
207
  const accessibleResult = {
206
208
  id: 'doc1',
207
209
  document: {_id: 'doc1', _rev: 'rev1', title: 'Test Document'},
@@ -214,7 +216,7 @@ describe('createFetchDocument', () => {
214
216
  })
215
217
 
216
218
  it('should return null when the document is inaccessible due to existence', async () => {
217
- const fetchDocument = createFetchDocument(instance, {projectId: 'p', dataset: 'd'})
219
+ const fetchDocument = createFetchDocument(instance)
218
220
  const inaccessibleResult = {
219
221
  accessible: false,
220
222
  id: 'doc1',
@@ -227,7 +229,7 @@ describe('createFetchDocument', () => {
227
229
  })
228
230
 
229
231
  it('should throw an error when the document is inaccessible due to permissions', async () => {
230
- const fetchDocument = createFetchDocument(instance, {projectId: 'p', dataset: 'd'})
232
+ const fetchDocument = createFetchDocument(instance)
231
233
  const inaccessibleResult = {
232
234
  accessible: false,
233
235
  id: 'doc1',
@@ -241,7 +243,7 @@ describe('createFetchDocument', () => {
241
243
  })
242
244
 
243
245
  it('should propagate errors from the loadDocument observable', async () => {
244
- const fetchDocument = createFetchDocument(instance, {projectId: 'p', dataset: 'd'})
246
+ const fetchDocument = createFetchDocument(instance)
245
247
  const errorMessage = 'Load document failed'
246
248
  fakeLoadDocument.mockReturnValue(throwError(() => new Error(errorMessage)))
247
249
 
@@ -16,6 +16,7 @@ import {
16
16
  import {getClientState} from '../client/clientStore'
17
17
  import {type DocumentResource} from '../config/sanityConfig'
18
18
  import {type SanityInstance} from '../store/createSanityInstance'
19
+ import {dedupeListenerEvents, groupTransactionEvents} from './listenerEventOperators'
19
20
 
20
21
  const API_VERSION = 'v2025-05-06'
21
22
 
@@ -26,12 +27,12 @@ export interface SharedListener {
26
27
 
27
28
  export function createSharedListener(
28
29
  instance: SanityInstance,
29
- resource: DocumentResource,
30
+ resource?: DocumentResource,
30
31
  ): SharedListener {
31
32
  const dispose$ = new Subject<void>()
32
33
  const events$ = getClientState(instance, {
33
34
  apiVersion: API_VERSION,
34
- ...(resource ? {resource} : {}),
35
+ resource,
35
36
  }).observable.pipe(
36
37
  switchMap((client) =>
37
38
  // TODO: it seems like the client.listen method is not emitting disconnected
@@ -41,8 +42,15 @@ export function createSharedListener(
41
42
  '*',
42
43
  {},
43
44
  {
44
- events: ['mutation', 'welcome', 'reconnect'],
45
+ // with `enableResume`, a reconnect replays the events missed while
46
+ // offline (emitting `welcomeback`) instead of forcing a refetch of
47
+ // every subscribed document. when the backend cannot resume, it
48
+ // emits `welcome` or `reset`, both of which trigger a refetch
49
+ events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
50
+ enableResume: true,
45
51
  includeResult: false,
52
+ // the document store handles version documents, so we need to include all versions
53
+ includeAllVersions: true,
46
54
  tag: 'document-listener',
47
55
  // // from manual testing, it seems like mendoza patches may be
48
56
  // // causing some ambiguity/wonkiness
@@ -51,6 +59,11 @@ export function createSharedListener(
51
59
  },
52
60
  ),
53
61
  ),
62
+ // resumed events are delivered at-least-once, so drop replayed duplicates
63
+ dedupeListenerEvents(),
64
+ // hold multi-document transactions (e.g. publish) until complete so
65
+ // per-document processing never observes half a transaction
66
+ groupTransactionEvents(),
54
67
  takeUntil(dispose$),
55
68
  share(),
56
69
  )
@@ -67,13 +80,16 @@ export function createSharedListener(
67
80
  }
68
81
  }
69
82
 
70
- export function createFetchDocument(instance: SanityInstance, resource: DocumentResource) {
83
+ export function createFetchDocument(instance: SanityInstance, resource?: DocumentResource) {
71
84
  return function (documentId: string): Observable<SanityDocument | null> {
72
- return getClientState(instance, {apiVersion: API_VERSION, resource}).observable.pipe(
85
+ return getClientState(instance, {
86
+ apiVersion: API_VERSION,
87
+ resource,
88
+ }).observable.pipe(
73
89
  switchMap((client) => {
74
- // TODO: remove this once the client is updated to v7 the new type is available in @sanity/mutate/_unstable_store
75
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
- const loadDocument = createDocumentLoaderFromClient(client as any)
90
+ // creates a observable request to the /doc/{documentId} endpoint for a given document id
91
+ // should work across all kinds of document IDs (drafts.**, version.**., etc.)
92
+ const loadDocument = createDocumentLoaderFromClient(client)
77
93
  return loadDocument(documentId)
78
94
  }),
79
95
  map((result) => {
@@ -0,0 +1,14 @@
1
+ import {DocumentId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
+
3
+ import {type DocumentHandle} from '../config/sanityConfig'
4
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
+
6
+ export function getEffectiveDocumentId(doc: DocumentHandle): string {
7
+ if (doc.liveEdit) {
8
+ return doc.documentId
9
+ } else if (isReleasePerspective(doc.perspective)) {
10
+ return getVersionId(DocumentId(doc.documentId), doc.perspective.releaseName)
11
+ } else {
12
+ return getPublishedId(DocumentId(doc.documentId))
13
+ }
14
+ }
@@ -39,7 +39,6 @@ describe('favoritesStore', () => {
39
39
  resourceId: 'res456',
40
40
  resourceType: 'studio' as const,
41
41
  schemaName: 'movieSchema',
42
- resource: {projectId: 'p', dataset: 'd'},
43
42
  }
44
43
 
45
44
  const mockContextNoSchema = {
@@ -47,13 +46,12 @@ describe('favoritesStore', () => {
47
46
  documentType: 'movie',
48
47
  resourceId: 'res456',
49
48
  resourceType: 'studio' as const,
50
- resource: {projectId: 'p', dataset: 'd'},
51
49
  }
52
50
 
53
51
  describe('createFavoriteKey', () => {
54
52
  beforeEach(() => {
55
53
  vi.resetAllMocks()
56
- instance = createSanityInstance()
54
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
57
55
  setupMockStateSource()
58
56
  })
59
57
 
@@ -101,7 +99,7 @@ describe('favoritesStore', () => {
101
99
  describe('fetcher', () => {
102
100
  beforeEach(() => {
103
101
  vi.resetAllMocks()
104
- instance = createSanityInstance()
102
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
105
103
  setupMockStateSource()
106
104
  })
107
105
 
@@ -148,8 +146,9 @@ describe('favoritesStore', () => {
148
146
  await firstValueFrom(state.observable)
149
147
  expect(mockFetch).toHaveBeenCalledTimes(1)
150
148
  // Second subscriber should use cached response
151
- const sub2 = state.subscribe()
152
- await firstValueFrom(state.observable)
149
+ const state2 = getFavoritesState(instance!, mockContext)
150
+ const sub2 = state2.subscribe()
151
+ await firstValueFrom(state2.observable)
153
152
  expect(mockFetch).toHaveBeenCalledTimes(1)
154
153
  // Cleanup
155
154
  sub1()
@@ -0,0 +1,68 @@
1
+ import {expectTypeOf, test} from 'vitest'
2
+
3
+ import {type SanityInstance} from '../store/createSanityInstance'
4
+ import {type StateSource} from '../store/createStateSourceAction'
5
+ import {type FetcherSnapshot} from '../store/fetcherStore'
6
+ import {
7
+ type Installation,
8
+ installation,
9
+ type InstallationAccess,
10
+ type InstallationActiveConfig,
11
+ type InstallationBase,
12
+ type InstallationInclude,
13
+ type InstallationInterface,
14
+ installations,
15
+ type InstallationsResponse,
16
+ } from './installations'
17
+
18
+ const instance = {} as SanityInstance
19
+
20
+ type IncludeKeys = 'activeConfig' | 'access' | 'interfaces'
21
+
22
+ // Whether key `K` of `T` is optional, without an `{}`-based check.
23
+ type KeyModifier<T, K extends keyof T> =
24
+ Pick<T, K> extends Required<Pick<T, K>> ? 'required' : 'optional'
25
+
26
+ test('Installation — no includes: only the base shape', () => {
27
+ expectTypeOf<Installation>().toEqualTypeOf<InstallationBase>()
28
+ expectTypeOf<Installation<never>>().toEqualTypeOf<InstallationBase>()
29
+ expectTypeOf<Extract<keyof Installation, IncludeKeys>>().toEqualTypeOf<never>()
30
+ })
31
+
32
+ test('Installation — each token adds its top-level field, required, others absent', () => {
33
+ expectTypeOf<Installation<'access'>['access']>().toEqualTypeOf<InstallationAccess[]>()
34
+ expectTypeOf<
35
+ Extract<keyof Installation<'access'>, 'activeConfig' | 'interfaces'>
36
+ >().toEqualTypeOf<never>()
37
+
38
+ expectTypeOf<Installation<'interfaces'>['interfaces']>().toEqualTypeOf<InstallationInterface[]>()
39
+ expectTypeOf<
40
+ Installation<'activeConfig'>['activeConfig']
41
+ >().toEqualTypeOf<InstallationActiveConfig | null>()
42
+ })
43
+
44
+ test('Installation — multiple tokens add each field', () => {
45
+ type Both = Installation<'access' | 'interfaces'>
46
+ expectTypeOf<Both['access']>().toEqualTypeOf<InstallationAccess[]>()
47
+ expectTypeOf<Both['interfaces']>().toEqualTypeOf<InstallationInterface[]>()
48
+ expectTypeOf<Extract<keyof Both, 'activeConfig'>>().toEqualTypeOf<never>()
49
+ })
50
+
51
+ test('Installation — a wide include array makes the fields optional', () => {
52
+ type Wide = Installation<InstallationInclude>
53
+ expectTypeOf<KeyModifier<Wide, 'access'>>().toEqualTypeOf<'optional'>()
54
+ expectTypeOf<KeyModifier<Wide, 'interfaces'>>().toEqualTypeOf<'optional'>()
55
+ expectTypeOf<KeyModifier<Wide, 'activeConfig'>>().toEqualTypeOf<'optional'>()
56
+ })
57
+
58
+ test('installations.resolveState — resolves the wide list envelope', () => {
59
+ expectTypeOf(
60
+ installations.resolveState(instance, {organizationId: 'org_1'}),
61
+ ).resolves.toEqualTypeOf<InstallationsResponse<InstallationInclude>>()
62
+ })
63
+
64
+ test('installation.getState — exposes the snapshot envelope', () => {
65
+ expectTypeOf(installation.getState(instance, 'inst_1')).toEqualTypeOf<
66
+ StateSource<FetcherSnapshot<Installation<InstallationInclude>>>
67
+ >()
68
+ })
@@ -0,0 +1,62 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {of} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {getClientState} from '../client/clientStore'
6
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
+ import {type StateSource} from '../store/createStateSourceAction'
8
+ import {installation, installations} from './installations'
9
+
10
+ vi.mock('../client/clientStore')
11
+
12
+ describe('installations', () => {
13
+ let instance: SanityInstance
14
+ const request = vi.fn()
15
+
16
+ beforeEach(() => {
17
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
18
+ request.mockReset()
19
+ vi.mocked(getClientState).mockReturnValue({
20
+ observable: of({observable: {request}} as unknown as SanityClient),
21
+ } as StateSource<SanityClient>)
22
+ })
23
+
24
+ afterEach(() => {
25
+ instance.dispose()
26
+ })
27
+
28
+ it('lists installations for an organization on the global client', async () => {
29
+ const response = {nextCursor: null, data: [{id: 'inst1'}]}
30
+ request.mockReturnValue(of(response))
31
+
32
+ const result = await installations.resolveState(instance, {
33
+ organizationId: 'org1',
34
+ include: ['interfaces'],
35
+ })
36
+
37
+ expect(result).toEqual(response)
38
+ expect(getClientState).toHaveBeenCalledWith(instance, {
39
+ apiVersion: 'vX',
40
+ scope: 'global',
41
+ })
42
+ expect(request).toHaveBeenCalledWith({
43
+ uri: '/installations',
44
+ query: {organizationId: 'org1', include: 'interfaces'},
45
+ tag: 'installations.list',
46
+ })
47
+ })
48
+
49
+ it('fetches a single installation by id', async () => {
50
+ const inst = {id: 'inst1', applicationId: 'app1'}
51
+ request.mockReturnValue(of(inst))
52
+
53
+ const result = await installation.resolveState(instance, 'inst1')
54
+
55
+ expect(result).toEqual(inst)
56
+ expect(request).toHaveBeenCalledWith({
57
+ uri: '/installations/inst1',
58
+ query: {},
59
+ tag: 'installations.get',
60
+ })
61
+ })
62
+ })