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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -1,7 +1,7 @@
1
- import {type Action} from '@sanity/client'
2
- import {getPublishedId} from '@sanity/client/csm'
1
+ import {type Action, type Mutation} from '@sanity/client'
2
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
3
3
  import {jsonMatch} from '@sanity/json-match'
4
- import {type SanityDocument} from '@sanity/types'
4
+ import {type SanityDocument} from 'groq'
5
5
  import {type ExprNode} from 'groq-js'
6
6
  import {
7
7
  catchError,
@@ -17,15 +17,18 @@ import {
17
17
  Observable,
18
18
  of,
19
19
  pairwise,
20
+ retry,
20
21
  startWith,
21
22
  Subject,
22
23
  switchMap,
23
24
  tap,
24
25
  throttle,
26
+ throwError,
25
27
  timer,
26
28
  withLatestFrom,
27
29
  } from 'rxjs'
28
30
 
31
+ import {getCurrentUserState} from '../auth/authStore'
29
32
  import {type ClientOptions, getClientState} from '../client/clientStore'
30
33
  import {
31
34
  type DocumentHandle,
@@ -34,6 +37,7 @@ import {
34
37
  isDatasetResource,
35
38
  isMediaLibraryResource,
36
39
  } from '../config/sanityConfig'
40
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
37
41
  import {
38
42
  bindActionByResource,
39
43
  type BoundResourceKey,
@@ -42,11 +46,20 @@ import {
42
46
  import {type SanityInstance} from '../store/createSanityInstance'
43
47
  import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
44
48
  import {defineStore, type StoreContext} from '../store/defineStore'
45
- import {getDraftId} from '../utils/ids'
46
49
  import {type DocumentAction} from './actions'
47
- import {API_VERSION, INITIAL_OUTGOING_THROTTLE_TIME} from './documentConstants'
48
- import {type DocumentEvent, getDocumentEvents} from './events'
50
+ import {
51
+ API_VERSION,
52
+ INITIAL_OUTGOING_THROTTLE_TIME,
53
+ OUT_OF_SYNC_RETRY_BASE_DELAY,
54
+ OUT_OF_SYNC_RETRY_MAX_DELAY,
55
+ } from './documentConstants'
56
+ import {
57
+ type DocumentEvent,
58
+ type DocumentTransactionSubmissionResult,
59
+ getDocumentEvents,
60
+ } from './events'
49
61
  import {listen, OutOfSyncError} from './listen'
62
+ import {type JsonMatch} from './patchOperations'
50
63
  import {
51
64
  calculatePermissions,
52
65
  createGrantsLookup,
@@ -54,13 +67,13 @@ import {
54
67
  type DocumentPermissionsResult,
55
68
  type Grant,
56
69
  } from './permissions'
57
- import {ActionError} from './processActions'
70
+ import {ActionError} from './processActions/processActions'
71
+ import {isReleaseAction} from './processActions/releaseUtil'
58
72
  import {
59
73
  type AppliedTransaction,
60
74
  applyFirstQueuedTransaction,
61
75
  applyRemoteDocument,
62
76
  cleanupOutgoingTransaction,
63
- getDocumentIdsFromActions,
64
77
  manageSubscriberIds,
65
78
  type OutgoingTransaction,
66
79
  type QueuedTransaction,
@@ -77,6 +90,10 @@ export interface DocumentStoreState {
77
90
  applied: AppliedTransaction[]
78
91
  outgoing?: OutgoingTransaction
79
92
  grants?: Record<Grant, ExprNode>
93
+ /**
94
+ * The current user's identity (their user ID).
95
+ */
96
+ identity?: string
80
97
  error?: unknown
81
98
  sharedListener: SharedListener
82
99
  fetchDocument: (documentId: string) => Observable<SanityDocument | null>
@@ -116,6 +133,14 @@ export interface DocumentState {
116
133
  * local transactions on top of this new remote.
117
134
  */
118
135
  unverifiedRevisions?: {[TTransactionId in string]?: UnverifiedDocumentRevision}
136
+ /**
137
+ * transaction IDs recently submitted by this client for this document,
138
+ * newest last. used to label `remote-patches` events with the correct
139
+ * `origin` even after the corresponding `unverifiedRevisions` entry has
140
+ * been pruned (e.g. by a sync event that raced the listener echo). capped
141
+ * to the most recent entries.
142
+ */
143
+ recentOwnTransactionIds?: string[]
119
144
  }
120
145
 
121
146
  export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
@@ -136,6 +161,7 @@ export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
136
161
  subscribeToSubscriptionsAndListenToDocuments(context),
137
162
  subscribeToAppliedAndSubmitNextTransaction(context),
138
163
  subscribeToClientAndFetchDatasetAcl(context),
164
+ subscribeToCurrentUserAndSetIdentity(context),
139
165
  ]
140
166
 
141
167
  return () => {
@@ -158,6 +184,29 @@ export interface DocumentOptions<
158
184
  path?: TPath
159
185
  }
160
186
 
187
+ /** @beta */
188
+ export function getDocumentState<
189
+ TDocumentType extends string = string,
190
+ TDataset extends string = string,
191
+ TProjectId extends string = string,
192
+ >(
193
+ instance: SanityInstance,
194
+ options: DocumentOptions<undefined, TDocumentType, TDataset, TProjectId>,
195
+ ): StateSource<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`> | undefined | null>
196
+
197
+ /** @beta */
198
+ export function getDocumentState<
199
+ TPath extends string = string,
200
+ TDocumentType extends string = string,
201
+ TDataset extends string = string,
202
+ TProjectId extends string = string,
203
+ >(
204
+ instance: SanityInstance,
205
+ options: DocumentOptions<TPath, TDocumentType, TDataset, TProjectId>,
206
+ ): StateSource<
207
+ JsonMatch<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`>, TPath> | undefined
208
+ >
209
+
161
210
  /** @beta */
162
211
  export function getDocumentState<TData>(
163
212
  instance: SanityInstance,
@@ -175,29 +224,28 @@ const _getDocumentState = bindActionByResource(
175
224
  documentStore,
176
225
  createStateSourceAction({
177
226
  selector: ({state: {error, documentStates}}, options: DocumentOptions<string | undefined>) => {
178
- const {documentId, path, liveEdit} = options
227
+ const {documentId: docId, path, liveEdit, perspective} = options
228
+ const documentId = DocumentId(docId)
179
229
  if (error) throw error
230
+ let document: SanityDocument | null | undefined
180
231
 
181
232
  if (liveEdit) {
182
- // For liveEdit documents, only look at the single document
183
- const document = documentStates[documentId]?.local
184
- if (document === undefined) return undefined
185
- if (!path) return document
186
- const result = jsonMatch(document, path).next()
187
- if (result.done) return undefined
188
- const {value} = result.value
189
- return value
233
+ document = documentStates[documentId]?.local
234
+ } else {
235
+ let version: SanityDocument | null | undefined
236
+ if (isReleasePerspective(perspective)) {
237
+ const versionId = getVersionId(documentId, perspective.releaseName)
238
+ version = documentStates[versionId]?.local
239
+ // early exit if we don't have the version document and we're in a release perspective
240
+ if (version === undefined) return undefined
241
+ }
242
+ const draft = documentStates[getDraftId(documentId)]?.local
243
+ const published = documentStates[getPublishedId(documentId)]?.local
244
+ // early exit if we don't have all the documents for draft/published logic
245
+ if (draft === undefined || published === undefined) return undefined
246
+ document = version ?? draft ?? published
190
247
  }
191
248
 
192
- // Standard draft/published logic
193
- const draftId = getDraftId(documentId)
194
- const publishedId = getPublishedId(documentId)
195
- const draft = documentStates[draftId]?.local
196
- const published = documentStates[publishedId]?.local
197
-
198
- // wait for draft and published to be loaded before returning a value
199
- if (draft === undefined || published === undefined) return undefined
200
- const document = draft ?? published
201
249
  if (!path) return document
202
250
  const result = jsonMatch(document, path).next()
203
251
  if (result.done) return undefined
@@ -205,20 +253,24 @@ const _getDocumentState = bindActionByResource(
205
253
  return value
206
254
  },
207
255
  onSubscribe: (context, options: DocumentOptions<string | undefined>) =>
208
- manageSubscriberIds(context, options.documentId, {expandDraftPublished: !options.liveEdit}),
256
+ manageSubscriberIds(context, [options]),
209
257
  }),
210
258
  )
211
259
 
212
260
  /** @beta */
213
- export function resolveDocument<TData extends SanityDocument>(
261
+ export function resolveDocument<
262
+ TDocumentType extends string = string,
263
+ TDataset extends string = string,
264
+ TProjectId extends string = string,
265
+ >(
214
266
  instance: SanityInstance,
215
- docHandle: DocumentHandle,
216
- ): Promise<TData | null>
267
+ docHandle: DocumentHandle<TDocumentType, TDataset, TProjectId>,
268
+ ): Promise<SanityDocument<TDocumentType, `${TProjectId}.${TDataset}`> | null>
217
269
  /** @beta */
218
- export function resolveDocument(
270
+ export function resolveDocument<TData extends SanityDocument>(
219
271
  instance: SanityInstance,
220
- docHandle: DocumentHandle,
221
- ): Promise<SanityDocument | null>
272
+ docHandle: DocumentHandle<string, string, string>,
273
+ ): Promise<TData | null>
222
274
  /** @beta */
223
275
  export function resolveDocument(
224
276
  ...args: Parameters<typeof _resolveDocument>
@@ -245,32 +297,32 @@ export const getDocumentSyncStatus = bindActionByResource(
245
297
  {state: {error, documentStates: documents, outgoing, applied, queued}},
246
298
  doc: DocumentHandle,
247
299
  ) => {
248
- const documentId = typeof doc === 'string' ? doc : doc.documentId
300
+ const documentId = DocumentId(typeof doc === 'string' ? doc : doc.documentId)
249
301
  if (error) throw error
250
302
 
251
303
  if (doc.liveEdit) {
252
304
  // For liveEdit documents, only check the single document
253
- const document = documents[documentId]
254
- if (document === undefined) return undefined
255
- return !queued.length && !applied.length && !outgoing
305
+ if (documents[documentId] === undefined) return undefined
306
+ } else {
307
+ const version = isReleasePerspective(doc.perspective)
308
+ ? documents[getVersionId(documentId, doc.perspective.releaseName)]
309
+ : undefined
310
+ if (isReleasePerspective(doc.perspective) && version === undefined) return undefined
311
+ // Standard draft/published logic
312
+ const draft = documents[getDraftId(documentId)]
313
+ const published = documents[getPublishedId(documentId)]
314
+ if (draft === undefined || published === undefined) return undefined
256
315
  }
257
-
258
- // Standard draft/published logic
259
- const draftId = getDraftId(documentId)
260
- const publishedId = getPublishedId(documentId)
261
-
262
- const draft = documents[draftId]
263
- const published = documents[publishedId]
264
-
265
- if (draft === undefined || published === undefined) return undefined
266
316
  return !queued.length && !applied.length && !outgoing
267
317
  },
268
- onSubscribe: (context, doc: DocumentHandle) => manageSubscriberIds(context, doc.documentId),
318
+ onSubscribe: (context, doc: DocumentHandle) => {
319
+ return manageSubscriberIds(context, [doc])
320
+ },
269
321
  }),
270
322
  )
271
323
 
272
324
  type PermissionsStateOptions = {
273
- resource: DocumentResource
325
+ resource?: DocumentResource
274
326
  actions: DocumentAction[]
275
327
  }
276
328
 
@@ -279,8 +331,9 @@ export const getPermissionsState = bindActionByResource(
279
331
  documentStore,
280
332
  createStateSourceAction({
281
333
  selector: calculatePermissions,
282
- onSubscribe: (context, {actions}: PermissionsStateOptions) =>
283
- manageSubscriberIds(context, getDocumentIdsFromActions(actions)),
334
+ onSubscribe: (context, {actions}: PermissionsStateOptions) => {
335
+ manageSubscriberIds(context, actions)
336
+ },
284
337
  }) as StoreAction<
285
338
  DocumentStoreState,
286
339
  [PermissionsStateOptions],
@@ -301,7 +354,7 @@ export const resolvePermissions = bindActionByResource(
301
354
  /** @beta */
302
355
  export const subscribeDocumentEvents = bindActionByResource(
303
356
  documentStore,
304
- ({state}, options: {resource: DocumentResource; eventHandler: (e: DocumentEvent) => void}) => {
357
+ ({state}, options: {resource?: DocumentResource; eventHandler: (e: DocumentEvent) => void}) => {
305
358
  const {events} = state.get()
306
359
  const subscription = events.subscribe(options.eventHandler)
307
360
  return () => subscription.unsubscribe()
@@ -365,24 +418,48 @@ const subscribeToAppliedAndSubmitNextTransaction = ({
365
418
  withLatestFrom(
366
419
  getClientState(instance, {
367
420
  apiVersion: API_VERSION,
368
- ...(resource ? {resource} : {}),
421
+ resource,
369
422
  }).observable,
370
423
  ),
371
424
  concatMap(([outgoing, client]) => {
372
425
  if (!outgoing) return EMPTY
426
+
427
+ const revertOnError = catchError((error: unknown) => {
428
+ state.set('revertOutgoingTransaction', revertOutgoingTransaction)
429
+ const message = error instanceof Error ? error.message : 'Request failed'
430
+ events.next({type: 'reverted', message, outgoing, error})
431
+ return EMPTY
432
+ })
433
+
434
+ const toResult = map((result: unknown) => ({
435
+ result: result as DocumentTransactionSubmissionResult,
436
+ outgoing,
437
+ }))
438
+
439
+ // liveEdit transactions route to the mutations API; everything else routes
440
+ // to the actions API. processActions rejects transactions that mix the two,
441
+ // and reducers won't batch across that boundary, so a batch is always
442
+ // entirely liveEdit or entirely not.
443
+ if (outgoing.actions.some((action) => !isReleaseAction(action) && action.liveEdit)) {
444
+ return client.observable
445
+ .mutate(outgoing.outgoingMutations as Mutation[], {
446
+ transactionId: outgoing.transactionId,
447
+ visibility: 'async',
448
+ returnDocuments: false,
449
+ returnFirst: false,
450
+ tag: 'document.mutate',
451
+ skipCrossDatasetReferenceValidation: true,
452
+ })
453
+ .pipe(revertOnError, toResult)
454
+ }
455
+
373
456
  return client.observable
374
457
  .action(outgoing.outgoingActions as Action[], {
375
458
  transactionId: outgoing.transactionId,
376
459
  skipCrossDatasetReferenceValidation: true,
460
+ tag: 'document.action',
377
461
  })
378
- .pipe(
379
- catchError((error) => {
380
- state.set('revertOutgoingTransaction', revertOutgoingTransaction)
381
- events.next({type: 'reverted', message: error.message, outgoing, error})
382
- return EMPTY
383
- }),
384
- map((result) => ({result, outgoing})),
385
- )
462
+ .pipe(revertOnError, toResult)
386
463
  }),
387
464
  tap(({outgoing, result}) => {
388
465
  state.set('cleanupOutgoingTransaction', cleanupOutgoingTransaction)
@@ -424,8 +501,8 @@ const subscribeToSubscriptionsAndListenToDocuments = (
424
501
  ...added.map((id) => ({id, add: true})),
425
502
  ...removed.map((id) => ({id, add: false})),
426
503
  ].sort((a, b) => {
427
- const aIsDraft = a.id === getDraftId(a.id)
428
- const bIsDraft = b.id === getDraftId(b.id)
504
+ const aIsDraft = a.id === getDraftId(DocumentId(a.id))
505
+ const bIsDraft = b.id === getDraftId(DocumentId(b.id))
429
506
 
430
507
  if (aIsDraft && bIsDraft) return a.id.localeCompare(b.id, 'en-US')
431
508
  if (aIsDraft) return -1
@@ -441,10 +518,15 @@ const subscribeToSubscriptionsAndListenToDocuments = (
441
518
  switchMap((e) => {
442
519
  if (!e.add) return EMPTY
443
520
  return listen(context, e.id).pipe(
444
- catchError((error) => {
445
- // retry on `OutOfSyncError`
446
- if (error instanceof OutOfSyncError) listen(context, e.id)
447
- throw error
521
+ retry({
522
+ delay: (error, retryCount) => {
523
+ if (!(error instanceof OutOfSyncError)) return throwError(() => error)
524
+ const backoff = Math.min(
525
+ OUT_OF_SYNC_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
526
+ OUT_OF_SYNC_RETRY_MAX_DELAY,
527
+ )
528
+ return timer(backoff)
529
+ },
448
530
  }),
449
531
  tap((remote) =>
450
532
  state.set('applyRemoteDocument', (prev) =>
@@ -464,10 +546,7 @@ const subscribeToClientAndFetchDatasetAcl = ({
464
546
  state,
465
547
  key: {resource},
466
548
  }: StoreContext<DocumentStoreState, BoundResourceKey>) => {
467
- const clientOptions: ClientOptions = {apiVersion: API_VERSION}
468
- if (resource) {
469
- clientOptions.resource = resource
470
- }
549
+ const clientOptions: ClientOptions = {apiVersion: API_VERSION, resource}
471
550
 
472
551
  let uri: string
473
552
  if (resource && isDatasetResource(resource)) {
@@ -486,7 +565,6 @@ const subscribeToClientAndFetchDatasetAcl = ({
486
565
  client.observable.request<DatasetAcl>({
487
566
  uri,
488
567
  tag: 'acl.get',
489
- withCredentials: true,
490
568
  }),
491
569
  ),
492
570
  tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
@@ -495,3 +573,16 @@ const subscribeToClientAndFetchDatasetAcl = ({
495
573
  error: (error) => state.set('setError', {error}),
496
574
  })
497
575
  }
576
+
577
+ const subscribeToCurrentUserAndSetIdentity = ({
578
+ instance,
579
+ state,
580
+ }: StoreContext<DocumentStoreState, BoundResourceKey>) =>
581
+ getCurrentUserState(instance).observable.subscribe({
582
+ next: (currentUser) => state.set('setIdentity', {identity: currentUser?.id}),
583
+ // A transient identity-fetch failure (network blip, expired token, or a
584
+ // normal logout/re-login transition) should not brick all document
585
+ // operations. Reset the identity to `undefined` and keep going — GROQ's
586
+ // `identity()` then evaluates to null, matching the unauthenticated state.
587
+ error: () => state.set('setIdentity', {identity: undefined}),
588
+ })
@@ -1,6 +1,6 @@
1
1
  import {describe, expect, it} from 'vitest'
2
2
 
3
- import {type DocumentAction} from '../document/actions'
3
+ import {type Action, type DocumentAction} from '../document/actions'
4
4
  import {type DocumentEvent, getDocumentEvents} from '../document/events'
5
5
  import {type OutgoingTransaction} from '../document/reducers'
6
6
 
@@ -41,11 +41,66 @@ describe('getDocumentEvents', () => {
41
41
  expect(events).toHaveLength(outgoing.actions.length)
42
42
  events.forEach((event) => {
43
43
  const action = outgoing.actions.find(
44
- (a) => 'documentId' in event && event.documentId === a.documentId,
44
+ (a) => 'documentId' in a && 'documentId' in event && event.documentId === a.documentId,
45
45
  )
46
46
  expect(action).toBeDefined()
47
47
  expect(event.type).toEqual(expectedMap[action!.type])
48
48
  expect((event as Extract<DocumentEvent, {outgoing: unknown}>).outgoing).toBe(outgoing)
49
49
  })
50
50
  })
51
+
52
+ it('emits created/edited/deleted events for release.create/edit/delete with release doc IDs', () => {
53
+ const outgoing: OutgoingTransaction = {
54
+ transactionId: 'txn-release',
55
+ actions: [
56
+ {type: 'release.create', releaseId: 'r1'} as Action,
57
+ {type: 'release.edit', releaseId: 'r2', patch: {set: {}}} as Action,
58
+ {type: 'release.delete', releaseId: 'r3'} as Action,
59
+ ],
60
+ disableBatching: false,
61
+ batchedTransactionIds: [],
62
+ outgoingActions: [],
63
+ outgoingMutations: [],
64
+ base: {},
65
+ working: {},
66
+ previous: {},
67
+ previousRevs: {},
68
+ timestamp: '2025-02-06T00:00:00.000Z',
69
+ }
70
+
71
+ const events = getDocumentEvents(outgoing)
72
+ expect(events).toEqual([
73
+ {type: 'created', documentId: '_.releases.r1', outgoing},
74
+ {type: 'edited', documentId: '_.releases.r2', outgoing},
75
+ {type: 'deleted', documentId: '_.releases.r3', outgoing},
76
+ ])
77
+ })
78
+
79
+ it('skips release actions that have no local mutation (publish/schedule/archive/etc.)', () => {
80
+ const outgoing: OutgoingTransaction = {
81
+ transactionId: 'txn-release-noop',
82
+ actions: [
83
+ {type: 'release.publish', releaseId: 'r1'} as Action,
84
+ {
85
+ type: 'release.schedule',
86
+ releaseId: 'r2',
87
+ publishAt: '2026-01-01T00:00:00.000Z',
88
+ } as Action,
89
+ {type: 'release.unschedule', releaseId: 'r3'} as Action,
90
+ {type: 'release.archive', releaseId: 'r4'} as Action,
91
+ {type: 'release.unarchive', releaseId: 'r5'} as Action,
92
+ ],
93
+ disableBatching: false,
94
+ batchedTransactionIds: [],
95
+ outgoingActions: [],
96
+ outgoingMutations: [],
97
+ base: {},
98
+ working: {},
99
+ previous: {},
100
+ previousRevs: {},
101
+ timestamp: '2025-02-06T00:00:00.000Z',
102
+ }
103
+
104
+ expect(getDocumentEvents(outgoing)).toEqual([])
105
+ })
51
106
  })
@@ -1,8 +1,15 @@
1
- import {type SanityClient} from '@sanity/client'
1
+ import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
2
+ import {type PatchOperations} from '@sanity/types'
2
3
 
3
- import {type DocumentAction} from './actions'
4
+ import {type DocumentAction, type ReleaseAction} from './actions'
5
+ import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
4
6
  import {type OutgoingTransaction} from './reducers'
5
7
 
8
+ /** @beta Response body from submitting an outgoing transaction (actions or mutations API). */
9
+ export type DocumentTransactionSubmissionResult =
10
+ | Awaited<ReturnType<SanityClient['action']>>
11
+ | MultipleMutationResult
12
+
6
13
  /** @beta */
7
14
  export type DocumentEvent =
8
15
  | ActionErrorEvent
@@ -15,6 +22,7 @@ export type DocumentEvent =
15
22
  | DocumentPublishedEvent
16
23
  | DocumentUnpublishedEvent
17
24
  | DocumentDiscardedEvent
25
+ | DocumentRemotePatchesEvent
18
26
 
19
27
  /**
20
28
  * @beta
@@ -35,7 +43,7 @@ export interface ActionErrorEvent {
35
43
  export interface TransactionAcceptedEvent {
36
44
  type: 'accepted'
37
45
  outgoing: OutgoingTransaction
38
- result: Awaited<ReturnType<SanityClient['action']>>
46
+ result: DocumentTransactionSubmissionResult
39
47
  }
40
48
  /**
41
49
  * @beta
@@ -113,31 +121,79 @@ export interface DocumentDiscardedEvent {
113
121
  outgoing: OutgoingTransaction
114
122
  }
115
123
 
116
- export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[] {
117
- const documentIdsByAction = Object.entries(
118
- outgoing.actions.reduce(
119
- (acc, {type, documentId}) => {
120
- const ids = acc[type] || new Set()
121
- if (documentId) ids.add(documentId)
122
- acc[type] = ids
123
- return acc
124
- },
125
- {} as Record<DocumentAction['type'], Set<string>>,
126
- ),
127
- ) as [DocumentAction['type'], Set<string>][]
124
+ /**
125
+ * @beta
126
+ * Event emitted when patches for a document are observed through the listener.
127
+ *
128
+ * Unlike the whole-document snapshots the store keeps, these are the raw patch
129
+ * operations from the transaction that produced the change, preserving the
130
+ * operational intent of the edit (e.g. keyed array inserts/unsets). Consumers
131
+ * that maintain their own local state, like collaborative text editors, can
132
+ * apply these directly instead of diffing document snapshots.
133
+ *
134
+ * `origin` is `'local'` when the transaction was submitted by this document
135
+ * store instance (an own edit observed back through the listener) and
136
+ * `'remote'` when it came from another client.
137
+ */
138
+ export interface DocumentRemotePatchesEvent {
139
+ type: 'remote-patches'
140
+ /** The ID of the document version the patches apply to (e.g. a draft ID). */
141
+ documentId: string
142
+ /** The transaction ID that carried these patches. */
143
+ transactionId: string
144
+ /** The revision the document was at before this transaction. */
145
+ previousRev?: string
146
+ /** The timestamp of the transaction. */
147
+ timestamp: string
148
+ /** The patch operations, rooted at the document. */
149
+ patches: PatchOperations[]
150
+ /** Whether the transaction originated from this client or another one. */
151
+ origin: 'local' | 'remote'
152
+ }
153
+
154
+ // Release actions that write a mutation to the local release doc map onto
155
+ // the regular per-document events with `documentId = '_.releases.<releaseId>'`.
156
+ // The other release actions (publish/schedule/unschedule/archive/unarchive)
157
+ // don't mutate local state, so they aren't in the map and get skipped — they
158
+ // surface through the transaction-level `accepted`/`reverted` events instead.
159
+ const actionMap = {
160
+ 'document.create': 'created',
161
+ 'document.delete': 'deleted',
162
+ 'document.discard': 'discarded',
163
+ 'document.edit': 'edited',
164
+ 'document.publish': 'published',
165
+ 'document.unpublish': 'unpublished',
166
+ 'release.create': 'created',
167
+ 'release.edit': 'edited',
168
+ 'release.delete': 'deleted',
169
+ } satisfies Partial<Record<DocumentAction['type'] | ReleaseAction['type'], DocumentEvent['type']>>
128
170
 
129
- const actionMap = {
130
- 'document.create': 'created',
131
- 'document.delete': 'deleted',
132
- 'document.discard': 'discarded',
133
- 'document.edit': 'edited',
134
- 'document.publish': 'published',
135
- 'document.unpublish': 'unpublished',
136
- } satisfies Record<DocumentAction['type'], DocumentEvent['type']>
171
+ type MappedActionType = keyof typeof actionMap
172
+
173
+ export function getDocumentEvents(outgoing: OutgoingTransaction): DocumentEvent[] {
174
+ const documentIdsByAction = outgoing.actions.reduce(
175
+ (acc, action) => {
176
+ if (!(action.type in actionMap)) return acc
177
+ const documentId = isReleaseAction(action)
178
+ ? getReleaseDocumentId(action.releaseId)
179
+ : action.documentId
180
+ if (!documentId) return acc
181
+ const type = action.type as MappedActionType
182
+ const ids = acc[type] ?? new Set<string>()
183
+ ids.add(documentId)
184
+ acc[type] = ids
185
+ return acc
186
+ },
187
+ {} as Partial<Record<MappedActionType, Set<string>>>,
188
+ )
137
189
 
138
- return documentIdsByAction.flatMap(([actionType, documentIds]) =>
139
- Array.from(documentIds).map(
140
- (documentId): DocumentEvent => ({type: actionMap[actionType], documentId, outgoing}),
190
+ return Object.entries(documentIdsByAction).flatMap(([actionType, documentIds]) =>
191
+ Array.from(documentIds ?? []).map(
192
+ (documentId): DocumentEvent => ({
193
+ type: actionMap[actionType as MappedActionType],
194
+ documentId,
195
+ outgoing,
196
+ }),
141
197
  ),
142
198
  )
143
199
  }
@@ -205,4 +205,60 @@ describe('sortListenerEvents operator', () => {
205
205
  const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
206
206
  expect(events).toEqual([other])
207
207
  })
208
+
209
+ it('should discard replayed events already reflected in the base', async () => {
210
+ // The snapshot is at rev2; a resumed listener replays the event that
211
+ // produced rev2. It should be discarded instead of clogging the buffer.
212
+ const sync = createSyncEvent('rev2')
213
+ const replayed = createMutationEvent({
214
+ id: 'replayed',
215
+ previousRev: 'rev1',
216
+ resultRev: 'rev2',
217
+ })
218
+ const source$ = of(sync, replayed)
219
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
220
+ expect(events).toEqual([sync])
221
+ })
222
+
223
+ it('should discard the replayed part of a chain and apply the rest', async () => {
224
+ // The snapshot is at rev2; the listener replays rev1->rev2 followed by a
225
+ // genuinely new rev2->rev3. Only the new event should be emitted.
226
+ const sync = createSyncEvent('rev2')
227
+ const replayed = createMutationEvent({
228
+ id: 'replayed',
229
+ previousRev: 'rev1',
230
+ resultRev: 'rev2',
231
+ })
232
+ const fresh = createMutationEvent({
233
+ id: 'fresh',
234
+ previousRev: 'rev2',
235
+ resultRev: 'rev3',
236
+ })
237
+ const source$ = of(sync, replayed, fresh)
238
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
239
+ expect(events).toEqual([sync, fresh])
240
+ })
241
+
242
+ it('should discard a multi-event replayed chain that leads up to the base', async () => {
243
+ const sync = createSyncEvent('rev3')
244
+ // two replayed events that together lead up to the base revision
245
+ const replayedA = createMutationEvent({
246
+ id: 'replayedA',
247
+ previousRev: 'rev1',
248
+ resultRev: 'rev2',
249
+ })
250
+ const replayedB = createMutationEvent({
251
+ id: 'replayedB',
252
+ previousRev: 'rev2',
253
+ resultRev: 'rev3',
254
+ })
255
+ const fresh = createMutationEvent({
256
+ id: 'fresh',
257
+ previousRev: 'rev3',
258
+ resultRev: 'rev4',
259
+ })
260
+ const source$ = of(sync, replayedA, replayedB, fresh)
261
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
262
+ expect(events).toEqual([sync, fresh])
263
+ })
208
264
  })