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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  type BaseActionOptions,
3
+ type BaseMutationOptions,
4
+ ClientError,
3
5
  type FilteredResponseQueryOptions,
4
6
  type ListenEvent,
5
7
  type MultipleActionResult,
8
+ type MultipleMutationResult,
6
9
  type MutationEvent,
7
10
  type RawQueryResponse,
8
11
  type ResponseQueryOptions,
@@ -12,16 +15,27 @@ import {
12
15
  type WelcomeEvent,
13
16
  } from '@sanity/client'
14
17
  import {diffValue} from '@sanity/diff-patch'
18
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
15
19
  import {type Mutation, type SanityDocument} from '@sanity/types'
16
20
  import {evaluate, parse} from 'groq-js'
17
- import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs'
21
+ import {
22
+ defer,
23
+ delay,
24
+ first,
25
+ firstValueFrom,
26
+ from,
27
+ Observable,
28
+ of,
29
+ ReplaySubject,
30
+ Subject,
31
+ throwError,
32
+ } from 'rxjs'
18
33
  import {afterEach, beforeEach, expect, it, vi} from 'vitest'
19
34
 
20
35
  import {getClientState} from '../client/clientStore'
21
36
  import {createDocumentHandle} from '../config/handles'
22
37
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
23
38
  import {type StateSource} from '../store/createStateSourceAction'
24
- import {getDraftId, getPublishedId} from '../utils/ids'
25
39
  import {
26
40
  createDocument,
27
41
  deleteDocument,
@@ -39,7 +53,12 @@ import {
39
53
  resolvePermissions,
40
54
  subscribeDocumentEvents,
41
55
  } from './documentStore'
42
- import {type ActionErrorEvent, type TransactionRevertedEvent} from './events'
56
+ import {
57
+ type ActionErrorEvent,
58
+ type DocumentRemotePatchesEvent,
59
+ type TransactionRevertedEvent,
60
+ } from './events'
61
+ import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
43
62
  import {type DatasetAcl} from './permissions'
44
63
  import {type DocumentSet, processMutations} from './processMutations'
45
64
  import {type HttpAction} from './reducers'
@@ -51,23 +70,42 @@ interface TestDocument extends SanityDocument {
51
70
  title?: string
52
71
  }
53
72
 
73
+ // Scope the TestDocument type to the project/datasets used in tests
74
+ type AllTestSchemaTypes = TestDocument
75
+
76
+ // Augment the 'groq' module
77
+ declare module 'groq' {
78
+ interface SanitySchemas {
79
+ default: AllTestSchemaTypes
80
+ }
81
+ }
82
+
54
83
  let instance: SanityInstance
84
+ let instance1: SanityInstance
85
+ let instance2: SanityInstance
55
86
 
56
- // bindActionByResource should ensure docs from different resources are isolated
57
87
  const resource = {projectId: 'p', dataset: 'd'}
58
- const resource1 = {projectId: 'p', dataset: 'd1'}
59
- const resource2 = {projectId: 'p', dataset: 'd2'}
88
+ const source1 = {projectId: 'p', dataset: 'd1'}
89
+ const source2 = {projectId: 'p', dataset: 'd2'}
60
90
 
61
91
  beforeEach(() => {
62
- instance = createSanityInstance()
92
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
93
+ // test uses two instances that share the same in-memory dataset, but separate
94
+ // store instances. in real scenarios, this would be separate machines but with
95
+ // the same project + dataset
96
+ instance1 = createSanityInstance({projectId: 'p', dataset: 'd1'})
97
+ instance2 = createSanityInstance({projectId: 'p', dataset: 'd2'})
63
98
  })
64
99
 
65
100
  afterEach(() => {
66
101
  instance?.dispose()
102
+ instance1?.dispose()
103
+ instance2?.dispose()
67
104
  })
68
105
 
69
106
  it('creates, edits, and publishes a document', async () => {
70
- const doc = createDocumentHandle({documentId: 'doc-single', documentType: 'article', resource})
107
+ const documentId = DocumentId('doc-single')
108
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
71
109
  const documentState = getDocumentState<TestDocument>(instance, doc)
72
110
 
73
111
  // Initially the document is undefined
@@ -80,10 +118,10 @@ it('creates, edits, and publishes a document', async () => {
80
118
  actions: [createDocument(doc)],
81
119
  resource,
82
120
  })
83
- expect(appeared).toContain(getDraftId(doc.documentId))
121
+ expect(appeared).toContain(getDraftId(documentId))
84
122
 
85
123
  let currentDoc = documentState.getCurrent()
86
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
124
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
87
125
 
88
126
  // Edit the document – add a title
89
127
  await applyDocumentActions(instance, {
@@ -101,16 +139,13 @@ it('creates, edits, and publishes a document', async () => {
101
139
  await submitted()
102
140
  currentDoc = documentState.getCurrent()
103
141
 
104
- expect(currentDoc).toMatchObject({_id: doc.documentId, _rev: transactionId})
142
+ expect(currentDoc).toMatchObject({_id: documentId, _rev: transactionId})
105
143
  unsubscribe()
106
144
  })
107
145
 
108
146
  it('creates a document with initial values', async () => {
109
- const doc = createDocumentHandle({
110
- documentId: 'doc-with-initial',
111
- documentType: 'article',
112
- resource,
113
- })
147
+ const documentId = DocumentId('doc-with-initial')
148
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
114
149
  const documentState = getDocumentState<TestDocument>(instance, doc)
115
150
 
116
151
  expect(documentState.getCurrent()).toBeUndefined()
@@ -128,10 +163,10 @@ it('creates a document with initial values', async () => {
128
163
  ],
129
164
  resource,
130
165
  })
131
- expect(appeared).toContain(getDraftId(doc.documentId))
166
+ expect(appeared).toContain(getDraftId(documentId))
132
167
 
133
168
  const currentDoc = documentState.getCurrent()
134
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
169
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
135
170
  expect(currentDoc?.title).toEqual('Article with Initial Values')
136
171
  expect(currentDoc?.['author']).toEqual('Jane Doe')
137
172
  expect(currentDoc?.['count']).toEqual(42)
@@ -140,7 +175,8 @@ it('creates a document with initial values', async () => {
140
175
  })
141
176
 
142
177
  it('edits existing documents', async () => {
143
- const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article', resource})
178
+ const documentId = DocumentId('existing-doc')
179
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
144
180
  const state = getDocumentState<TestDocument>(instance, doc)
145
181
 
146
182
  // not subscribed yet so the value is undefined
@@ -152,7 +188,7 @@ it('edits existing documents', async () => {
152
188
  await firstValueFrom(state.observable.pipe(first((i) => !!i)))
153
189
 
154
190
  expect(state.getCurrent()).toMatchObject({
155
- _id: getDraftId(doc.documentId),
191
+ _id: getDraftId(documentId),
156
192
  title: 'existing doc',
157
193
  })
158
194
 
@@ -161,7 +197,7 @@ it('edits existing documents', async () => {
161
197
  resource,
162
198
  })
163
199
  expect(state.getCurrent()).toMatchObject({
164
- _id: getDraftId(doc.documentId),
200
+ _id: getDraftId(documentId),
165
201
  title: 'updated title',
166
202
  })
167
203
 
@@ -169,33 +205,31 @@ it('edits existing documents', async () => {
169
205
  })
170
206
 
171
207
  it('sets optimistic changes synchronously', async () => {
172
- const doc1 = createDocumentHandle({
173
- documentId: 'optimistic',
208
+ const doc1 = {
209
+ documentId: DocumentId('optimistic'),
174
210
  documentType: 'article',
175
- resource: resource1,
176
- })
177
- const doc2 = createDocumentHandle({
178
- documentId: 'optimistic',
211
+ }
212
+ const doc2 = {
213
+ documentId: DocumentId('optimistic'),
179
214
  documentType: 'article',
180
- resource: resource2,
181
- })
215
+ }
182
216
 
183
- const state1 = getDocumentState<TestDocument>(instance, doc1)
184
- const state2 = getDocumentState<TestDocument>(instance, doc2)
217
+ const state1 = getDocumentState(instance1, doc1)
218
+ const state2 = getDocumentState(instance2, doc2)
185
219
 
186
220
  const unsubscribe1 = state1.subscribe()
187
221
  const unsubscribe2 = state2.subscribe()
188
222
 
189
223
  // wait until the value is primed in the store
190
- await resolveDocument(instance, doc1)
224
+ await resolveDocument(instance1, doc1)
191
225
 
192
226
  // then the actions are synchronous
193
227
  expect(state1.getCurrent()).toBeNull()
194
- applyDocumentActions(instance, {actions: [createDocument(doc1)], resource: resource1})
228
+ applyDocumentActions(instance1, {actions: [createDocument(doc1)], resource: source1})
195
229
  expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc1.documentId)})
196
- const actionResult1Promise = applyDocumentActions(instance, {
230
+ const actionResult1Promise = applyDocumentActions(instance1, {
197
231
  actions: [editDocument(doc1, {set: {title: 'initial title'}})],
198
- resource: resource1,
232
+ resource: source1,
199
233
  })
200
234
  expect(state1.getCurrent()?.title).toBe('initial title')
201
235
 
@@ -214,9 +248,9 @@ it('sets optimistic changes synchronously', async () => {
214
248
  expect(state2.getCurrent()?.title).toBe('initial title')
215
249
 
216
250
  // synchronous for state 2
217
- const actionResult2Promise = applyDocumentActions(instance, {
251
+ const actionResult2Promise = applyDocumentActions(instance2, {
218
252
  actions: [editDocument(doc2, {set: {title: 'updated title'}})],
219
- resource: resource2,
253
+ resource: source2,
220
254
  })
221
255
  expect(state2.getCurrent()?.title).toBe('updated title')
222
256
  // async for state 1
@@ -231,37 +265,27 @@ it('sets optimistic changes synchronously', async () => {
231
265
  })
232
266
 
233
267
  it('propagates changes between two instances', async () => {
234
- const doc1 = createDocumentHandle({
235
- documentId: 'doc-collab',
236
- documentType: 'article',
237
- resource: resource1,
238
- })
239
- const doc2 = createDocumentHandle({
240
- documentId: 'doc-collab',
241
- documentType: 'article',
242
- resource: resource2,
243
- })
244
- const state1 = getDocumentState<TestDocument>(instance, doc1)
245
- const state2 = getDocumentState<TestDocument>(instance, doc2)
268
+ const doc = createDocumentHandle({documentId: 'doc-collab', documentType: 'article'})
269
+ const state1 = getDocumentState(instance1, doc)
270
+ const state2 = getDocumentState(instance2, doc)
246
271
 
247
272
  const state1Unsubscribe = state1.subscribe()
248
273
  const state2Unsubscribe = state2.subscribe()
249
274
 
250
- // Create the document from the store instance.
251
- await applyDocumentActions(instance, {
252
- actions: [createDocument(doc1)],
253
- resource: resource1,
254
- }).then((r) => r.submitted())
275
+ // Create the document from instance1.
276
+ await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
277
+ (r) => r.submitted(),
278
+ )
255
279
 
256
- const docState1 = state1.getCurrent()
257
- const docState2 = state2.getCurrent()
258
- expect(docState1?._id).toEqual(getDraftId(doc1.documentId))
259
- expect(docState2?._id).toEqual(getDraftId(doc1.documentId))
280
+ const doc1 = state1.getCurrent()
281
+ const doc2 = state2.getCurrent()
282
+ expect(doc1?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
283
+ expect(doc2?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
260
284
 
261
- // Now, edit the document from the other store instance.
262
- await applyDocumentActions(instance, {
263
- actions: [editDocument(doc2, {set: {title: 'Hello world!'}})],
264
- resource: resource2,
285
+ // Now, edit the document from instance2.
286
+ await applyDocumentActions(instance2, {
287
+ actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
288
+ resource: source2,
265
289
  }).then((r) => r.submitted())
266
290
 
267
291
  const updated1 = state1.getCurrent()
@@ -273,44 +297,95 @@ it('propagates changes between two instances', async () => {
273
297
  state2Unsubscribe()
274
298
  })
275
299
 
276
- it('handles concurrent edits and resolves conflicts', async () => {
277
- const doc1 = createDocumentHandle({
278
- documentId: 'doc-concurrent',
279
- documentType: 'article',
280
- resource: resource1,
300
+ it('emits remote-patches events distinguishing own and foreign transactions', async () => {
301
+ const doc = createDocumentHandle({documentId: 'doc-remote-patches', documentType: 'article'})
302
+ const state1 = getDocumentState(instance1, doc)
303
+ const state2 = getDocumentState(instance2, doc)
304
+
305
+ const state1Unsubscribe = state1.subscribe()
306
+ const state2Unsubscribe = state2.subscribe()
307
+
308
+ await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
309
+ (r) => r.submitted(),
310
+ )
311
+
312
+ const events1: DocumentRemotePatchesEvent[] = []
313
+ const events2: DocumentRemotePatchesEvent[] = []
314
+ const unsubscribeEvents1 = subscribeDocumentEvents(instance1, {
315
+ resource: source1,
316
+ eventHandler: (e) => {
317
+ if (e.type === 'remote-patches') events1.push(e)
318
+ },
281
319
  })
282
- const doc2 = createDocumentHandle({
283
- documentId: 'doc-concurrent',
284
- documentType: 'article',
285
- resource: resource2,
320
+ const unsubscribeEvents2 = subscribeDocumentEvents(instance2, {
321
+ resource: source2,
322
+ eventHandler: (e) => {
323
+ if (e.type === 'remote-patches') events2.push(e)
324
+ },
325
+ })
326
+
327
+ // edit from instance2: an own transaction for instance2, a foreign one for instance1
328
+ const result = await applyDocumentActions(instance2, {
329
+ actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
330
+ resource: source2,
331
+ }).then((r) => r.submitted())
332
+
333
+ const draftId = getDraftId(DocumentId(doc.documentId))
334
+
335
+ expect(events1).toHaveLength(1)
336
+ expect(events1[0]).toMatchObject({
337
+ type: 'remote-patches',
338
+ documentId: draftId,
339
+ transactionId: result.transactionId,
340
+ origin: 'remote',
286
341
  })
287
- const state1 = getDocumentState<TestDocument>(instance, doc1)
288
- const state2 = getDocumentState<TestDocument>(instance, doc2)
342
+ expect(events1[0].patches).toEqual(
343
+ expect.arrayContaining([{set: expect.objectContaining({title: 'Hello world!'})}]),
344
+ )
345
+
346
+ expect(events2).toHaveLength(1)
347
+ expect(events2[0]).toMatchObject({
348
+ type: 'remote-patches',
349
+ documentId: draftId,
350
+ transactionId: result.transactionId,
351
+ origin: 'local',
352
+ })
353
+
354
+ unsubscribeEvents1()
355
+ unsubscribeEvents2()
356
+ state1Unsubscribe()
357
+ state2Unsubscribe()
358
+ })
359
+
360
+ it('handles concurrent edits and resolves conflicts', async () => {
361
+ const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
362
+ const state1 = getDocumentState(instance1, doc)
363
+ const state2 = getDocumentState(instance2, doc)
289
364
 
290
365
  const state1Unsubscribe = state1.subscribe()
291
366
  const state2Unsubscribe = state2.subscribe()
292
367
 
293
- const oneOffInstance = createSanityInstance()
368
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
294
369
 
295
370
  // Create the initial document from a one-off instance.
296
371
  await applyDocumentActions(oneOffInstance, {
297
372
  actions: [
298
- createDocument(doc1),
299
- editDocument(doc1, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
373
+ createDocument(doc),
374
+ editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
300
375
  ],
301
376
  resource,
302
377
  }).then((res) => res.submitted())
303
378
 
304
- // Both store instances now issue an edit simultaneously.
305
- const p1 = applyDocumentActions(instance, {
306
- actions: [editDocument(doc1, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
307
- resource: resource1,
379
+ // Both instances now issue an edit simultaneously.
380
+ const p1 = applyDocumentActions(instance1, {
381
+ actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
382
+ resource: source1,
308
383
  }).then((r) => r.submitted())
309
- const p2 = applyDocumentActions(instance, {
384
+ const p2 = applyDocumentActions(instance2, {
310
385
  actions: [
311
- editDocument(doc2, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
386
+ editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
312
387
  ],
313
- resource: resource2,
388
+ resource: source2,
314
389
  }).then((r) => r.submitted())
315
390
 
316
391
  // Wait for both actions to complete (or reject).
@@ -326,8 +401,76 @@ it('handles concurrent edits and resolves conflicts', async () => {
326
401
  oneOffInstance.dispose()
327
402
  })
328
403
 
404
+ it('interleaves concurrent keyed-array edits when operations are preserved', async () => {
405
+ const doc = createDocumentHandle({documentId: 'doc-preserve-ops', documentType: 'article'})
406
+ const state1 = getDocumentState(instance1, doc)
407
+ const state2 = getDocumentState(instance2, doc)
408
+
409
+ const state1Unsubscribe = state1.subscribe()
410
+ const state2Unsubscribe = state2.subscribe()
411
+
412
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
413
+ await applyDocumentActions(oneOffInstance, {
414
+ actions: [
415
+ createDocument(doc),
416
+ editDocument(doc, {
417
+ set: {
418
+ items: [
419
+ {_key: 'a', value: 'first'},
420
+ {_key: 'b', value: 'second'},
421
+ ],
422
+ },
423
+ }),
424
+ ],
425
+ resource,
426
+ }).then((r) => r.submitted())
427
+
428
+ // both instances insert into the same keyed array simultaneously, each
429
+ // preserving their operational patches instead of re-diffing snapshots
430
+ const p1 = applyDocumentActions(instance1, {
431
+ actions: [
432
+ editDocument(
433
+ doc,
434
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'from instance1'}]}},
435
+ {preserveOperations: true},
436
+ ),
437
+ ],
438
+ resource: source1,
439
+ }).then((r) => r.submitted())
440
+ const p2 = applyDocumentActions(instance2, {
441
+ actions: [
442
+ editDocument(
443
+ doc,
444
+ {insert: {after: 'items[_key=="b"]', items: [{_key: 'd', value: 'from instance2'}]}},
445
+ {preserveOperations: true},
446
+ ),
447
+ ],
448
+ resource: source2,
449
+ }).then((r) => r.submitted())
450
+
451
+ await Promise.allSettled([p1, p2])
452
+
453
+ const finalDoc1 = state1.getCurrent()
454
+ const finalDoc2 = state2.getCurrent()
455
+
456
+ // neither edit overwrote the other: both keyed inserts landed and both
457
+ // instances converged on the same interleaved result
458
+ expect(finalDoc1?.['items']).toEqual(finalDoc2?.['items'])
459
+ expect(finalDoc1?.['items']).toEqual([
460
+ {_key: 'a', value: 'first'},
461
+ {_key: 'c', value: 'from instance1'},
462
+ {_key: 'b', value: 'second'},
463
+ {_key: 'd', value: 'from instance2'},
464
+ ])
465
+
466
+ state1Unsubscribe()
467
+ state2Unsubscribe()
468
+ oneOffInstance.dispose()
469
+ })
470
+
329
471
  it('unpublishes and discards a document', async () => {
330
- const doc = createDocumentHandle({documentId: 'doc-pub-unpub', documentType: 'article', resource})
472
+ const documentId = DocumentId('doc-pub-unpub')
473
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
331
474
  const documentState = getDocumentState<TestDocument>(instance, doc)
332
475
  const unsubscribe = documentState.subscribe()
333
476
 
@@ -339,7 +482,7 @@ it('unpublishes and discards a document', async () => {
339
482
  })
340
483
  const publishedDoc = documentState.getCurrent()
341
484
  expect(publishedDoc).toMatchObject({
342
- _id: getPublishedId(doc.documentId),
485
+ _id: getPublishedId(documentId),
343
486
  _rev: afterPublish.transactionId,
344
487
  })
345
488
 
@@ -347,7 +490,7 @@ it('unpublishes and discards a document', async () => {
347
490
  await applyDocumentActions(instance, {actions: [unpublishDocument(doc)], resource})
348
491
  const afterUnpublish = documentState.getCurrent()
349
492
  // In our mock implementation the _id remains the same but the published copy is removed.
350
- expect(afterUnpublish?._id).toEqual(getDraftId(doc.documentId))
493
+ expect(afterUnpublish?._id).toEqual(getDraftId(documentId))
351
494
 
352
495
  // Discard the draft (which deletes the draft version).
353
496
  await applyDocumentActions(instance, {actions: [discardDocument(doc)], resource})
@@ -358,9 +501,10 @@ it('unpublishes and discards a document', async () => {
358
501
  })
359
502
 
360
503
  it('deletes a document', async () => {
361
- const doc = createDocumentHandle({documentId: 'doc-delete', documentType: 'article', resource})
504
+ const documentId = DocumentId('doc-delete')
505
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
362
506
 
363
- const documentState = getDocumentState<TestDocument>(instance, doc)
507
+ const documentState = getDocumentState(instance, doc)
364
508
  const unsubscribe = documentState.subscribe()
365
509
 
366
510
  await applyDocumentActions(instance, {
@@ -379,7 +523,8 @@ it('deletes a document', async () => {
379
523
  })
380
524
 
381
525
  it('cleans up document state when there are no subscribers', async () => {
382
- const doc = createDocumentHandle({documentId: 'doc-cleanup', documentType: 'article', resource})
526
+ const documentId = DocumentId('doc-cleanup')
527
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
383
528
  const documentState = getDocumentState<TestDocument>(instance, doc)
384
529
 
385
530
  // Subscribe to the document state.
@@ -397,14 +542,14 @@ it('cleans up document state when there are no subscribers', async () => {
397
542
  await new Promise((resolve) => setTimeout(resolve, 30))
398
543
 
399
544
  // When a new subscriber is created, if the state was cleared it should return undefined.
400
- const newDocumentState = getDocumentState<TestDocument>(instance, doc)
545
+ const newDocumentState = getDocumentState(instance, doc)
401
546
  expect(newDocumentState.getCurrent()).toBeUndefined()
402
547
  })
403
548
 
404
549
  it('fetches documents if there are no active subscriptions for the actions applied', async () => {
405
- const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article', resource})
550
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
406
551
 
407
- const {getCurrent} = getDocumentState<TestDocument>(instance, doc)
552
+ const {getCurrent} = getDocumentState(instance, doc)
408
553
  expect(getCurrent()).toBeUndefined()
409
554
  expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBeUndefined()
410
555
 
@@ -457,13 +602,9 @@ it('fetches documents if there are no active subscriptions for the actions appli
457
602
  })
458
603
 
459
604
  it('batches edit transaction into one outgoing transaction', async () => {
460
- const doc = createDocumentHandle({
461
- documentId: crypto.randomUUID(),
462
- documentType: 'article',
463
- resource,
464
- })
605
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
465
606
 
466
- const unsubscribe = getDocumentState<TestDocument>(instance, doc).subscribe()
607
+ const unsubscribe = getDocumentState(instance, doc).subscribe()
467
608
 
468
609
  // this creates its own transaction
469
610
  applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
@@ -490,12 +631,54 @@ it('batches edit transaction into one outgoing transaction', async () => {
490
631
  unsubscribe()
491
632
  })
492
633
 
493
- it('provides the consistency status via `getDocumentSyncStatus`', async () => {
494
- const doc = createDocumentHandle({
495
- documentId: crypto.randomUUID(),
634
+ it('submits liveEdit document edits through observable.mutate', async () => {
635
+ const liveDoc = createDocumentHandle({
636
+ documentId: 'live-edit-test-doc',
496
637
  documentType: 'article',
638
+ liveEdit: true,
639
+ })
640
+ const state = getDocumentState(instance, liveDoc)
641
+ const unsubscribe = state.subscribe()
642
+
643
+ await firstValueFrom(state.observable.pipe(first((doc) => doc !== undefined)))
644
+ expect(state.getCurrent()).toMatchObject({
645
+ _id: 'live-edit-test-doc',
646
+ title: 'live initial',
647
+ })
648
+
649
+ const callCountBefore = mutateSubmission.mock.calls.length
650
+
651
+ const result = await applyDocumentActions(instance, {
652
+ actions: [editDocument(liveDoc, {set: {title: 'patched via mutate'}})],
497
653
  resource,
498
654
  })
655
+ await result.submitted()
656
+
657
+ expect(mutateSubmission.mock.calls.length).toBe(callCountBefore + 1)
658
+ const [mutationList, mutateOptions] = mutateSubmission.mock.calls[callCountBefore]!
659
+ expect(mutateOptions).toMatchObject({
660
+ transactionId: result.transactionId,
661
+ visibility: 'async',
662
+ returnDocuments: false,
663
+ returnFirst: false,
664
+ tag: 'document.mutate',
665
+ skipCrossDatasetReferenceValidation: true,
666
+ })
667
+ expect(mutationList.length).toBeGreaterThan(0)
668
+ expect(
669
+ mutationList.every(
670
+ (m) => 'patch' in m && (m as {patch: {id: string}}).patch.id === liveDoc.documentId,
671
+ ),
672
+ ).toBe(true)
673
+
674
+ expect(state.getCurrent()?.title).toBe('patched via mutate')
675
+ expect(client.action).not.toHaveBeenCalled()
676
+
677
+ unsubscribe()
678
+ })
679
+
680
+ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
681
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
499
682
 
500
683
  const syncStatus = getDocumentSyncStatus(instance, doc)
501
684
  expect(syncStatus.getCurrent()).toBeUndefined()
@@ -550,13 +733,10 @@ it('reverts failed outgoing transaction locally', async () => {
550
733
  })
551
734
  })
552
735
 
553
- const doc = createDocumentHandle({
554
- documentId: crypto.randomUUID(),
555
- documentType: 'article',
556
- resource,
557
- })
736
+ const documentId = DocumentId(crypto.randomUUID())
737
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
558
738
 
559
- const {getCurrent, subscribe} = getDocumentState<TestDocument>(instance, doc)
739
+ const {getCurrent, subscribe} = getDocumentState(instance, doc)
560
740
  const unsubscribe = subscribe()
561
741
 
562
742
  await applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
@@ -621,12 +801,8 @@ it('removes a queued transaction if it fails to apply', async () => {
621
801
  })
622
802
  })
623
803
 
624
- const doc = createDocumentHandle({
625
- documentId: crypto.randomUUID(),
626
- documentType: 'article',
627
- resource,
628
- })
629
- const state = getDocumentState<TestDocument>(instance, doc)
804
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
805
+ const state = getDocumentState(instance, doc)
630
806
  const unsubscribe = state.subscribe()
631
807
 
632
808
  await expect(
@@ -664,9 +840,8 @@ it('returns allowed true when no permission errors occur', async () => {
664
840
  const doc = createDocumentHandle({
665
841
  documentId: 'doc-perm-allowed',
666
842
  documentType: 'article',
667
- resource,
668
843
  })
669
- const state = getDocumentState<TestDocument>(instance, doc)
844
+ const state = getDocumentState(instance, doc)
670
845
  const unsubscribe = state.subscribe()
671
846
  await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
672
847
  r.submitted(),
@@ -674,7 +849,6 @@ it('returns allowed true when no permission errors occur', async () => {
674
849
 
675
850
  // Use an action that includes a patch (so that update permission check is bypassed).
676
851
  const permissionsState = getPermissionsState(instance, {
677
- resource,
678
852
  actions: [
679
853
  {
680
854
  ...doc,
@@ -691,11 +865,7 @@ it('returns allowed true when no permission errors occur', async () => {
691
865
  })
692
866
 
693
867
  it("should reject applying the action if a precondition isn't met", async () => {
694
- const doc = createDocumentHandle({
695
- documentId: 'does-not-exist',
696
- documentType: 'article',
697
- resource,
698
- })
868
+ const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
699
869
 
700
870
  await expect(
701
871
  applyDocumentActions(instance, {actions: [deleteDocument(doc)], resource}),
@@ -703,11 +873,7 @@ it("should reject applying the action if a precondition isn't met", async () =>
703
873
  })
704
874
 
705
875
  it("should reject applying the action if a permission isn't met", async () => {
706
- const doc = createDocumentHandle({
707
- documentId: 'does-not-exist',
708
- documentType: 'article',
709
- resource,
710
- })
876
+ const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
711
877
 
712
878
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
713
879
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
@@ -721,12 +887,8 @@ it('returns allowed false with reasons when permission errors occur', async () =
721
887
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
722
888
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
723
889
 
724
- const doc = createDocumentHandle({
725
- documentId: 'doc-perm-denied',
726
- documentType: 'article',
727
- resource,
728
- })
729
- const result = await resolvePermissions(instance, {actions: [createDocument(doc)], resource})
890
+ const doc = createDocumentHandle({documentId: 'doc-perm-denied', documentType: 'article'})
891
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
730
892
 
731
893
  const message = 'You do not have permission to create a draft for document "doc-perm-denied".'
732
894
  expect(result).toMatchObject({
@@ -744,39 +906,119 @@ it('fetches dataset ACL and updates grants in the document store state', async (
744
906
  ]
745
907
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
746
908
 
747
- const book = createDocumentHandle({
748
- documentId: crypto.randomUUID(),
749
- documentType: 'book',
750
- resource,
751
- })
752
- const author = createDocumentHandle({
753
- documentId: crypto.randomUUID(),
754
- documentType: 'author',
755
- resource,
756
- })
909
+ const book = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'book'})
910
+ const author = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'author'})
757
911
 
758
- expect(await resolvePermissions(instance, {actions: [createDocument(book)], resource})).toEqual({
912
+ expect(await resolvePermissions(instance, {actions: [createDocument(book)]})).toEqual({
759
913
  allowed: true,
760
914
  })
761
- expect(
762
- await resolvePermissions(instance, {actions: [createDocument(author)], resource}),
763
- ).toMatchObject({
915
+ expect(await resolvePermissions(instance, {actions: [createDocument(author)]})).toMatchObject({
764
916
  allowed: false,
765
917
  message: expect.stringContaining('You do not have permission to create a draft for document'),
766
918
  })
767
919
  })
768
920
 
921
+ it('does not send credentials with the dataset ACL request', async () => {
922
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
923
+ await resolvePermissions(instance, {actions: [createDocument(doc)]})
924
+
925
+ const aclCall = vi
926
+ .mocked(client.request)
927
+ .mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
928
+ expect(aclCall).toBeDefined()
929
+ expect(aclCall![0]).not.toHaveProperty('withCredentials')
930
+ })
931
+
932
+ it('retries transient dataset ACL fetch failures instead of failing fatally', async () => {
933
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
934
+
935
+ // the first attempt fails like a dropped connection; subsequent attempts succeed
936
+ let aclAttempts = 0
937
+ client.observable.request = vi.fn().mockReturnValue(
938
+ defer(() => {
939
+ aclAttempts++
940
+ if (aclAttempts === 1) return throwError(() => new Error('socket hang up'))
941
+ return of(datasetAcl)
942
+ }),
943
+ )
944
+
945
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
946
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
947
+
948
+ expect(result).toEqual({allowed: true})
949
+ expect(aclAttempts).toBe(2)
950
+
951
+ // the store never entered the fatal error state
952
+ const documentState = getDocumentState(instance, doc)
953
+ expect(() => documentState.getCurrent()).not.toThrow()
954
+ })
955
+
956
+ it('does not retry dataset ACL fetch failures caused by 4xx client errors', async () => {
957
+ const forbidden = new ClientError({
958
+ statusCode: 403,
959
+ headers: {},
960
+ body: {error: 'Forbidden', message: 'Session does not have access to this dataset'},
961
+ })
962
+
963
+ let aclAttempts = 0
964
+ client.observable.request = vi.fn().mockReturnValue(
965
+ defer(() => {
966
+ aclAttempts++
967
+ return throwError(() => forbidden)
968
+ }),
969
+ )
970
+
971
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
972
+ const documentState = getDocumentState(instance, doc)
973
+ const unsubscribe = documentState.subscribe()
974
+
975
+ // the error is fatal: it surfaces through the store instead of being retried
976
+ await vi.waitFor(() => {
977
+ expect(() => documentState.getCurrent()).toThrow(
978
+ 'Forbidden - Session does not have access to this dataset',
979
+ )
980
+ })
981
+ expect(aclAttempts).toBe(1)
982
+
983
+ unsubscribe()
984
+ })
985
+
986
+ it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async () => {
987
+ const rateLimited = new ClientError({
988
+ statusCode: 429,
989
+ headers: {},
990
+ body: {error: 'Too Many Requests', message: 'Rate limit exceeded'},
991
+ })
992
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
993
+
994
+ // the first attempt is rate limited; subsequent attempts succeed
995
+ let aclAttempts = 0
996
+ client.observable.request = vi.fn().mockReturnValue(
997
+ defer(() => {
998
+ aclAttempts++
999
+ if (aclAttempts === 1) return throwError(() => rateLimited)
1000
+ return of(datasetAcl)
1001
+ }),
1002
+ )
1003
+
1004
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1005
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1006
+
1007
+ expect(result).toEqual({allowed: true})
1008
+ expect(aclAttempts).toBe(2)
1009
+
1010
+ // the store never entered the fatal error state
1011
+ const documentState = getDocumentState(instance, doc)
1012
+ expect(() => documentState.getCurrent()).not.toThrow()
1013
+ })
1014
+
769
1015
  it('fetches ACL for MediaLibraryResource', async () => {
770
1016
  const mediaLibraryInstance = createSanityInstance()
771
1017
 
772
1018
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
773
1019
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
774
1020
 
775
- const doc = createDocumentHandle({
776
- documentId: crypto.randomUUID(),
777
- documentType: 'article',
778
- resource,
779
- })
1021
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
780
1022
  const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
781
1023
 
782
1024
  const result = await resolvePermissions(mediaLibraryInstance, {
@@ -794,11 +1036,7 @@ it('fetches ACL for CanvasResource', async () => {
794
1036
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
795
1037
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
796
1038
 
797
- const doc = createDocumentHandle({
798
- documentId: crypto.randomUUID(),
799
- documentType: 'article',
800
- resource,
801
- })
1039
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
802
1040
  const canvasResource = {canvasId: 'test-canvas'}
803
1041
 
804
1042
  const result = await resolvePermissions(canvasInstance, {
@@ -811,16 +1049,13 @@ it('fetches ACL for CanvasResource', async () => {
811
1049
  })
812
1050
 
813
1051
  it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
814
- const doc = createDocumentHandle({
815
- documentId: crypto.randomUUID(),
816
- documentType: 'article',
817
- resource,
818
- })
1052
+ const documentId = DocumentId(crypto.randomUUID())
1053
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
819
1054
 
820
1055
  expect(await resolveDocument(instance, doc)).toBe(null)
821
1056
 
822
1057
  // use one-off instance to create the document in the mock backend
823
- const oneOffInstance = createSanityInstance()
1058
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
824
1059
  const result = await applyDocumentActions(oneOffInstance, {
825
1060
  actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})],
826
1061
  resource,
@@ -828,7 +1063,7 @@ it('returns a promise that resolves when a document has been loaded in the store
828
1063
  await result.submitted() // wait till submitted to server before resolving
829
1064
 
830
1065
  await expect(resolveDocument(instance, doc)).resolves.toMatchObject({
831
- _id: getDraftId(doc.documentId),
1066
+ _id: getDraftId(documentId),
832
1067
  _type: 'article',
833
1068
  title: 'initial title',
834
1069
  })
@@ -839,8 +1074,8 @@ it('emits an event for each action after an outgoing transaction has been accept
839
1074
  const handler = vi.fn()
840
1075
  const unsubscribe = subscribeDocumentEvents(instance, {resource, eventHandler: handler})
841
1076
 
842
- const documentId = crypto.randomUUID()
843
- const doc = createDocumentHandle({documentId, documentType: 'article', resource})
1077
+ const documentId = DocumentId(crypto.randomUUID())
1078
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
844
1079
  expect(handler).toHaveBeenCalledTimes(0)
845
1080
 
846
1081
  const tnx1 = await applyDocumentActions(instance, {
@@ -881,6 +1116,188 @@ it('emits an event for each action after an outgoing transaction has been accept
881
1116
  unsubscribe()
882
1117
  })
883
1118
 
1119
+ it('creates and edits a version document with a release perspective', async () => {
1120
+ const documentId = DocumentId('doc-release')
1121
+ const releaseName = 'test-release'
1122
+ const doc = createDocumentHandle({
1123
+ documentId,
1124
+ documentType: 'article',
1125
+ perspective: {releaseName},
1126
+ })
1127
+ const versionId = `versions.${releaseName}.${documentId}`
1128
+
1129
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1130
+ expect(documentState.getCurrent()).toBeUndefined()
1131
+
1132
+ const unsubscribe = documentState.subscribe()
1133
+
1134
+ // Create a version document for the release
1135
+ const {appeared} = await applyDocumentActions(instance, {
1136
+ actions: [createDocument(doc)],
1137
+ })
1138
+ expect(appeared).toContain(versionId)
1139
+
1140
+ let currentDoc = documentState.getCurrent()
1141
+ expect(currentDoc?._id).toEqual(versionId)
1142
+ expect(currentDoc?._type).toEqual('article')
1143
+
1144
+ // Edit the version document
1145
+ await applyDocumentActions(instance, {
1146
+ actions: [editDocument(doc, {set: {title: 'Release Version Title'}})],
1147
+ })
1148
+ currentDoc = documentState.getCurrent()
1149
+ expect(currentDoc?.title).toEqual('Release Version Title')
1150
+ expect(currentDoc?._id).toEqual(versionId)
1151
+
1152
+ unsubscribe()
1153
+ })
1154
+
1155
+ it('creates a version document with initial values and then discards it', async () => {
1156
+ const documentId = DocumentId('doc-release-discard')
1157
+ const releaseName = 'test-release-discard'
1158
+ const doc = createDocumentHandle({
1159
+ documentId,
1160
+ documentType: 'article',
1161
+ perspective: {releaseName},
1162
+ })
1163
+ const versionId = `versions.${releaseName}.${documentId}`
1164
+
1165
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1166
+ const unsubscribe = documentState.subscribe()
1167
+
1168
+ // Create a version document with initial values
1169
+ await applyDocumentActions(instance, {
1170
+ actions: [createDocument(doc, {title: 'Initial Release Title'})],
1171
+ })
1172
+
1173
+ let currentDoc = documentState.getCurrent()
1174
+ expect(currentDoc?._id).toEqual(versionId)
1175
+ expect(currentDoc?.title).toEqual('Initial Release Title')
1176
+
1177
+ // Discard the version document
1178
+ const {disappeared} = await applyDocumentActions(instance, {
1179
+ actions: [discardDocument(doc)],
1180
+ })
1181
+ expect(disappeared).toContain(versionId)
1182
+
1183
+ currentDoc = documentState.getCurrent()
1184
+ expect(currentDoc).toBeNull()
1185
+
1186
+ unsubscribe()
1187
+ })
1188
+
1189
+ it('version edits are isolated from draft state', async () => {
1190
+ const documentId = DocumentId('doc-version-isolation')
1191
+ const releaseName = 'isolation-release'
1192
+ const versionDoc = createDocumentHandle({
1193
+ documentId,
1194
+ documentType: 'article',
1195
+ perspective: {releaseName},
1196
+ })
1197
+ const draftDoc = createDocumentHandle({documentId, documentType: 'article'})
1198
+ const versionId = `versions.${releaseName}.${documentId}`
1199
+
1200
+ const versionState = getDocumentState<TestDocument>(instance, versionDoc)
1201
+ const draftState = getDocumentState<TestDocument>(instance, draftDoc)
1202
+
1203
+ const unsubscribeVersion = versionState.subscribe()
1204
+ const unsubscribeDraft = draftState.subscribe()
1205
+
1206
+ // Create draft and version documents
1207
+ await applyDocumentActions(instance, {
1208
+ actions: [createDocument(draftDoc)],
1209
+ })
1210
+ await applyDocumentActions(instance, {
1211
+ actions: [editDocument(draftDoc, {set: {title: 'Draft Title'}})],
1212
+ })
1213
+ await applyDocumentActions(instance, {
1214
+ actions: [createDocument(versionDoc, {title: 'Release Title'})],
1215
+ })
1216
+
1217
+ // Version perspective shows the version doc
1218
+ expect(versionState.getCurrent()?._id).toEqual(versionId)
1219
+ expect(versionState.getCurrent()?.title).toEqual('Release Title')
1220
+
1221
+ // Draft state shows the draft doc
1222
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
1223
+
1224
+ // Editing the version doc should not affect the draft
1225
+ await applyDocumentActions(instance, {
1226
+ actions: [editDocument(versionDoc, {set: {title: 'Updated Release Title'}})],
1227
+ })
1228
+ expect(versionState.getCurrent()?.title).toEqual('Updated Release Title')
1229
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
1230
+
1231
+ unsubscribeVersion()
1232
+ unsubscribeDraft()
1233
+ })
1234
+
1235
+ it('subscribeToSubscriptionsAndListenToDocuments recovers from an OutOfSyncError instead of failing the document store', async () => {
1236
+ const documentId = DocumentId('doc-out-of-sync-recovery')
1237
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
1238
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1239
+ const unsubscribe = documentState.subscribe()
1240
+ const draftId = getDraftId(documentId)
1241
+
1242
+ // Establish a synced document so the listener has a base revision.
1243
+ const created = await applyDocumentActions(instance, {
1244
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial'}})],
1245
+ })
1246
+ await created.submitted()
1247
+ expect(documentState.getCurrent()?.title).toBe('initial')
1248
+
1249
+ // Capture the fetchDocument spy so we can detect when the listener
1250
+ // re-subscribes. (every frest subscription re-runs fetchDocument)
1251
+ const fetchDocumentSpy = vi.mocked(createFetchDocument).mock.results.at(-1)?.value as ReturnType<
1252
+ typeof vi.fn
1253
+ >
1254
+ const fetchCallsBefore = fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length
1255
+
1256
+ // Inject DEFAULT_MAX_BUFFER_SIZE mutations whose previousRev doesn't chain
1257
+ // to the base revision (basically forcing the error)
1258
+ const sharedListener = (
1259
+ createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
1260
+ )()
1261
+ for (let i = 0; i < DEFAULT_MAX_BUFFER_SIZE; i++) {
1262
+ sharedListener.events.next({
1263
+ type: 'mutation',
1264
+ documentId: draftId,
1265
+ eventId: `bad-${i}`,
1266
+ identity: 'user',
1267
+ mutations: [],
1268
+ timestamp: new Date().toISOString(),
1269
+ transactionId: `bad-tx-${i}`,
1270
+ transactionCurrentEvent: 0,
1271
+ transactionTotalEvents: 1,
1272
+ previousRev: `dangling-rev-${i}`,
1273
+ resultRev: `bad-rev-${i}`,
1274
+ transition: 'update',
1275
+ visibility: 'query',
1276
+ })
1277
+ }
1278
+
1279
+ // The above should have triggered a retry and re-subscribed to the listener
1280
+ await vi.waitFor(() => {
1281
+ expect(fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length).toBeGreaterThan(
1282
+ fetchCallsBefore,
1283
+ )
1284
+ })
1285
+
1286
+ // The store stayed healthy through the OutOfSyncError.
1287
+ expect(() => documentState.getCurrent()).not.toThrow()
1288
+ expect(() => getDocumentSyncStatus(instance, doc).getCurrent()).not.toThrow()
1289
+
1290
+ // A follow-up edit still propagates through the recovered listener.
1291
+ const edited = await applyDocumentActions(instance, {
1292
+ actions: [editDocument(doc, {set: {title: 'after-recovery'}})],
1293
+ resource,
1294
+ })
1295
+ await edited.submitted()
1296
+ expect(documentState.getCurrent()?.title).toBe('after-recovery')
1297
+
1298
+ unsubscribe()
1299
+ })
1300
+
884
1301
  vi.mock('../client/clientStore.ts', () => ({
885
1302
  getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
886
1303
  }))
@@ -914,12 +1331,30 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
914
1331
  ...original,
915
1332
  INITIAL_OUTGOING_THROTTLE_TIME: 0,
916
1333
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1334
+ OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1335
+ OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1336
+ ACL_RETRY_BASE_DELAY: 0,
1337
+ ACL_RETRY_MAX_DELAY: 0,
917
1338
  }
918
1339
  })
919
1340
 
920
1341
  let client: SanityClient
921
1342
 
1343
+ /** Mock for `client.observable.mutate` (liveEdit submission path); implementation reset in `beforeEach`. */
1344
+ const mutateSubmission = vi.fn(
1345
+ async (
1346
+ _mutations: Mutation[],
1347
+ _options?: BaseMutationOptions,
1348
+ ): Promise<MultipleMutationResult> => ({
1349
+ transactionId: '',
1350
+ documentIds: [],
1351
+ results: [],
1352
+ }),
1353
+ )
1354
+
922
1355
  beforeEach(() => {
1356
+ mutateSubmission.mockReset()
1357
+
923
1358
  const client$ = (getClientState as () => StateSource<SanityClient>)()
924
1359
  .observable as ReplaySubject<SanityClient>
925
1360
  const sharedListener = (
@@ -930,14 +1365,130 @@ beforeEach(() => {
930
1365
  )()
931
1366
 
932
1367
  let documents: DocumentSet = {
933
- [getDraftId('existing-doc')]: {
934
- _id: getDraftId('existing-doc'),
1368
+ [getDraftId(DocumentId('existing-doc'))]: {
1369
+ _id: getDraftId(DocumentId('existing-doc')),
935
1370
  _createdAt: '2025-02-06T06:43:46.236Z',
936
1371
  _updatedAt: '2025-02-06T06:43:46.236Z',
937
1372
  _rev: 'initial-rev',
938
1373
  _type: 'book',
939
1374
  title: 'existing doc',
940
1375
  },
1376
+ 'live-edit-test-doc': {
1377
+ _id: 'live-edit-test-doc',
1378
+ _type: 'article',
1379
+ _createdAt: '2025-02-06T06:43:46.236Z',
1380
+ _updatedAt: '2025-02-06T06:43:46.236Z',
1381
+ _rev: 'rev-live-initial',
1382
+ title: 'live initial',
1383
+ },
1384
+ }
1385
+
1386
+ const emitDatasetChangeEvents = (
1387
+ prior: DocumentSet,
1388
+ next: DocumentSet,
1389
+ transactionId: string,
1390
+ timestamp: string,
1391
+ ) => {
1392
+ const existingIds = new Set(
1393
+ Object.entries(prior)
1394
+ .filter(([, value]) => !!value)
1395
+ .map(([key]) => key),
1396
+ )
1397
+ const resultingIds = new Set(
1398
+ Object.entries(next)
1399
+ .filter(([, value]) => !!value)
1400
+ .map(([key]) => key),
1401
+ )
1402
+ const allKeys = new Set([...existingIds, ...resultingIds])
1403
+
1404
+ const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1405
+ updated: string[]
1406
+ appeared: string[]
1407
+ disappeared: string[]
1408
+ }>(
1409
+ (acc, id) => {
1410
+ if (existingIds.has(id) && resultingIds.has(id)) {
1411
+ acc.updated.push(id)
1412
+ } else if (!existingIds.has(id) && resultingIds.has(id)) {
1413
+ acc.appeared.push(id)
1414
+ } else if (!resultingIds.has(id) && existingIds.has(id)) {
1415
+ acc.disappeared.push(id)
1416
+ }
1417
+ return acc
1418
+ },
1419
+ {updated: [], appeared: [], disappeared: []},
1420
+ )
1421
+
1422
+ const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1423
+ let transactionCurrentEvent = 0
1424
+
1425
+ const mutationEvents: MutationEvent[] = []
1426
+
1427
+ for (const id of appeared) {
1428
+ transactionCurrentEvent++
1429
+ const nextDoc = next[id]!
1430
+ mutationEvents.push({
1431
+ type: 'mutation',
1432
+ documentId: id,
1433
+ eventId: `${transactionId}#${id}`,
1434
+ identity: 'example-user',
1435
+ mutations: [{create: nextDoc}],
1436
+ timestamp,
1437
+ transactionId,
1438
+ transactionCurrentEvent,
1439
+ transactionTotalEvents,
1440
+ transition: 'appear',
1441
+ visibility: 'query',
1442
+ resultRev: transactionId,
1443
+ })
1444
+ }
1445
+
1446
+ for (const id of updated) {
1447
+ transactionCurrentEvent++
1448
+ const prevDoc = prior[id]!
1449
+ const nextDoc = next[id]!
1450
+
1451
+ mutationEvents.push({
1452
+ type: 'mutation',
1453
+ documentId: id,
1454
+ eventId: `${transactionId}#${id}`,
1455
+ identity: 'example-user',
1456
+ mutations: diffValue(prevDoc, nextDoc).map((patch): Mutation => ({patch: {...patch, id}})),
1457
+ timestamp,
1458
+ transactionCurrentEvent,
1459
+ transactionTotalEvents,
1460
+ transactionId,
1461
+ transition: 'update',
1462
+ visibility: 'query',
1463
+ previousRev: prevDoc._rev,
1464
+ resultRev: transactionId,
1465
+ })
1466
+ }
1467
+
1468
+ for (const id of disappeared) {
1469
+ transactionCurrentEvent++
1470
+ const prevDoc = prior[id]!
1471
+ mutationEvents.push({
1472
+ type: 'mutation',
1473
+ documentId: id,
1474
+ eventId: `${transactionId}#${id}`,
1475
+ identity: 'example-user',
1476
+ mutations: [{delete: {id}}],
1477
+ timestamp,
1478
+ transactionId,
1479
+ transactionCurrentEvent,
1480
+ transactionTotalEvents,
1481
+ transition: 'disappear',
1482
+ visibility: 'query',
1483
+ previousRev: prevDoc._rev,
1484
+ })
1485
+ }
1486
+
1487
+ documents = next
1488
+
1489
+ for (const mutationEvent of mutationEvents) {
1490
+ sharedListener.events.next(mutationEvent)
1491
+ }
941
1492
  }
942
1493
 
943
1494
  vi.mocked(createFetchDocument).mockReturnValue(
@@ -949,6 +1500,33 @@ beforeEach(() => {
949
1500
  ),
950
1501
  )
951
1502
 
1503
+ mutateSubmission.mockImplementation(
1504
+ async (
1505
+ mutations: Mutation[],
1506
+ options: BaseMutationOptions = {},
1507
+ ): Promise<MultipleMutationResult> => {
1508
+ const transactionId = options.transactionId ?? crypto.randomUUID()
1509
+ const timestamp = new Date().toISOString()
1510
+ const prior = {...documents}
1511
+ let next = {...documents}
1512
+ next = processMutations({documents: next, mutations, transactionId, timestamp})
1513
+
1514
+ if (!options.dryRun) {
1515
+ emitDatasetChangeEvents(prior, next, transactionId, timestamp)
1516
+ }
1517
+
1518
+ await new Promise((resolve) => setTimeout(resolve, 0))
1519
+
1520
+ return {
1521
+ transactionId,
1522
+ documentIds: Object.entries(next)
1523
+ .filter(([, v]) => !!v)
1524
+ .map(([k]) => k),
1525
+ results: [],
1526
+ }
1527
+ },
1528
+ )
1529
+
952
1530
  const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t
953
1531
 
954
1532
  const fetch = vi.fn(
@@ -1029,7 +1607,7 @@ beforeEach(() => {
1029
1607
  continue
1030
1608
  }
1031
1609
  case 'sanity.action.document.edit': {
1032
- const documentSource = next[i.draftId] ?? next[i.publishedId]
1610
+ const documentSource = (i.draftId && next[i.draftId]) ?? next[i.publishedId]
1033
1611
  if (!documentSource) {
1034
1612
  throw new Error(
1035
1613
  `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`,
@@ -1039,8 +1617,8 @@ beforeEach(() => {
1039
1617
  next = processMutations({
1040
1618
  documents: next,
1041
1619
  mutations: [
1042
- {createIfNotExists: {...documentSource, _id: i.draftId}},
1043
- {patch: {id: i.draftId, ...i.patch}},
1620
+ {createIfNotExists: {...documentSource, _id: i.draftId!}},
1621
+ {patch: {id: i.draftId!, ...i.patch}},
1044
1622
  ],
1045
1623
  transactionId,
1046
1624
  timestamp,
@@ -1121,114 +1699,15 @@ beforeEach(() => {
1121
1699
  continue
1122
1700
  }
1123
1701
  default: {
1124
- throw new Error(`Unsupported action for mock backend: ${i.actionType}`)
1702
+ throw new Error(
1703
+ `Unsupported action for mock backend: ${(i as {actionType: string}).actionType}`,
1704
+ )
1125
1705
  }
1126
1706
  }
1127
1707
  }
1128
1708
 
1129
1709
  if (!dryRun) {
1130
- const existingIds = new Set(
1131
- Object.entries(documents)
1132
- .filter(([, value]) => !!value)
1133
- .map(([key]) => key),
1134
- )
1135
- const resultingIds = new Set(
1136
- Object.entries(next)
1137
- .filter(([, value]) => !!value)
1138
- .map(([key]) => key),
1139
- )
1140
- const allKeys = new Set([...existingIds, ...resultingIds])
1141
-
1142
- const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1143
- updated: string[]
1144
- appeared: string[]
1145
- disappeared: string[]
1146
- }>(
1147
- (acc, id) => {
1148
- if (existingIds.has(id) && resultingIds.has(id)) {
1149
- acc.updated.push(id)
1150
- } else if (!existingIds.has(id) && resultingIds.has(id)) {
1151
- acc.appeared.push(id)
1152
- } else if (!resultingIds.has(id) && existingIds.has(id)) {
1153
- acc.disappeared.push(id)
1154
- }
1155
- return acc
1156
- },
1157
- {updated: [], appeared: [], disappeared: []},
1158
- )
1159
-
1160
- const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1161
- let transactionCurrentEvent = 0
1162
-
1163
- const mutationEvents: MutationEvent[] = []
1164
-
1165
- for (const id of appeared) {
1166
- transactionCurrentEvent++ // index seems to start at 1
1167
- const nextDoc = next[id]!
1168
- mutationEvents.push({
1169
- type: 'mutation',
1170
- documentId: id,
1171
- eventId: `${transactionId}#${id}`,
1172
- identity: 'example-user',
1173
- mutations: [{create: nextDoc}],
1174
- timestamp,
1175
- transactionId,
1176
- transactionCurrentEvent,
1177
- transactionTotalEvents,
1178
- transition: 'appear',
1179
- visibility: 'query',
1180
- resultRev: transactionId,
1181
- })
1182
- }
1183
-
1184
- for (const id of updated) {
1185
- transactionCurrentEvent++
1186
- const prevDoc = documents[id]!
1187
- const nextDoc = next[id]!
1188
-
1189
- mutationEvents.push({
1190
- type: 'mutation',
1191
- documentId: id,
1192
- eventId: `${transactionId}#${id}`,
1193
- identity: 'example-user',
1194
- mutations: diffValue(prevDoc, nextDoc).map(
1195
- (patch): Mutation => ({patch: {...patch, id}}),
1196
- ),
1197
- timestamp,
1198
- transactionCurrentEvent,
1199
- transactionTotalEvents,
1200
- transactionId,
1201
- transition: 'update',
1202
- visibility: 'query',
1203
- previousRev: prevDoc._rev,
1204
- resultRev: transactionId,
1205
- })
1206
- }
1207
-
1208
- for (const id of disappeared) {
1209
- transactionCurrentEvent++
1210
- const prevDoc = documents[id]!
1211
- mutationEvents.push({
1212
- type: 'mutation',
1213
- documentId: id,
1214
- eventId: `${transactionId}#${id}`,
1215
- identity: 'example-user',
1216
- mutations: [{delete: {id}}],
1217
- timestamp,
1218
- transactionId,
1219
- transactionCurrentEvent,
1220
- transactionTotalEvents,
1221
- transition: 'disappear',
1222
- visibility: 'query',
1223
- previousRev: prevDoc._rev,
1224
- })
1225
- }
1226
-
1227
- documents = next
1228
-
1229
- for (const mutationEvent of mutationEvents) {
1230
- sharedListener.events.next(mutationEvent)
1231
- }
1710
+ emitDatasetChangeEvents(documents, next, transactionId, timestamp)
1232
1711
  }
1233
1712
 
1234
1713
  // add a tick for realism
@@ -1255,6 +1734,7 @@ beforeEach(() => {
1255
1734
  action: (...args: Parameters<typeof action>) => from(action(...args)),
1256
1735
  fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
1257
1736
  request: (...args: Parameters<typeof request>) => from(request(...args)),
1737
+ mutate: (...args: Parameters<typeof mutateSubmission>) => from(mutateSubmission(...args)),
1258
1738
  },
1259
1739
  } as SanityClient
1260
1740
  client$.next(client)