@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,9 +1,9 @@
1
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
1
2
  import {type SanityDocument} from '@sanity/types'
2
3
  import {parse} from 'groq-js'
3
4
  import {Subject} from 'rxjs'
4
5
  import {describe, expect, it} from 'vitest'
5
6
 
6
- import {getDraftId, getPublishedId} from '../utils/ids'
7
7
  import {type DocumentEvent} from './events'
8
8
  import {type RemoteDocument} from './listen'
9
9
  import {type DocumentSet} from './processMutations'
@@ -56,7 +56,6 @@ describe('queueTransaction', () => {
56
56
  documentId: 'doc1',
57
57
  documentType: 'book',
58
58
  patches: [{set: {foo: 'bar'}}],
59
- resource: {projectId: 'p', dataset: 'd'},
60
59
  },
61
60
  ],
62
61
  }
@@ -67,8 +66,8 @@ describe('queueTransaction', () => {
67
66
  expect(newState.queued[0]).toEqual(transaction)
68
67
 
69
68
  // Check that both the published and draft documentStates got a subscription id added.
70
- const draftId = getDraftId('doc1')
71
- const pubId = getPublishedId('doc1')
69
+ const draftId = getDraftId(DocumentId('doc1'))
70
+ const pubId = getPublishedId(DocumentId('doc1'))
72
71
  expect(newState.documentStates[draftId]).toBeDefined()
73
72
  expect(newState.documentStates[pubId]).toBeDefined()
74
73
  expect(newState.documentStates[draftId]?.subscriptions).toContain('txn1')
@@ -78,8 +77,8 @@ describe('queueTransaction', () => {
78
77
 
79
78
  describe('removeQueuedTransaction', () => {
80
79
  it('removes the transaction from queued and removes subscription ids from documents', () => {
81
- const draftId = getDraftId('doc1')
82
- const pubId = getPublishedId('doc1')
80
+ const draftId = getDraftId(DocumentId('doc1'))
81
+ const pubId = getPublishedId(DocumentId('doc1'))
83
82
 
84
83
  const initialState: SyncTransactionState = {
85
84
  queued: [
@@ -91,7 +90,6 @@ describe('removeQueuedTransaction', () => {
91
90
  documentId: 'doc1',
92
91
  documentType: 'book',
93
92
  patches: [{set: {foo: 'bar'}}],
94
- resource: {projectId: 'p', dataset: 'd'},
95
93
  },
96
94
  ],
97
95
  },
@@ -100,8 +98,8 @@ describe('removeQueuedTransaction', () => {
100
98
  outgoing: undefined,
101
99
  grants,
102
100
  documentStates: {
103
- [draftId]: {id: draftId, subscriptions: ['txn1'], local: {}},
104
- [pubId]: {id: pubId, subscriptions: ['txn1'], local: {}},
101
+ [draftId]: {id: draftId, subscriptions: ['txn1']},
102
+ [pubId]: {id: pubId, subscriptions: ['txn1']},
105
103
  } as SyncTransactionState['documentStates'],
106
104
  }
107
105
 
@@ -140,19 +138,12 @@ describe('applyFirstQueuedTransaction', () => {
140
138
 
141
139
  it('returns unchanged state if any required document is not yet loaded', () => {
142
140
  // If a document's local value is undefined, the reducer should do nothing.
143
- const draftId = getDraftId('doc1')
141
+ const draftId = getDraftId(DocumentId('doc1'))
144
142
  const state: SyncTransactionState = {
145
143
  queued: [
146
144
  {
147
145
  transactionId: 'txn2',
148
- actions: [
149
- {
150
- type: 'document.discard',
151
- documentId: 'doc1',
152
- documentType: 'book',
153
- resource: {projectId: 'p', dataset: 'd'},
154
- },
155
- ],
146
+ actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
156
147
  },
157
148
  ],
158
149
  applied: [],
@@ -171,14 +162,7 @@ describe('applyFirstQueuedTransaction', () => {
171
162
  queued: [
172
163
  {
173
164
  transactionId: 'txn-missing-grants',
174
- actions: [
175
- {
176
- type: 'document.discard',
177
- documentId: 'doc1',
178
- documentType: 'book',
179
- resource: {projectId: 'p', dataset: 'd'},
180
- },
181
- ],
165
+ actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
182
166
  },
183
167
  ],
184
168
  applied: [],
@@ -192,8 +176,8 @@ describe('applyFirstQueuedTransaction', () => {
192
176
 
193
177
  it('applies the first queued transaction (using a discard action)', () => {
194
178
  // For a discard action, processActions deletes the draft document.
195
- const draftId = getDraftId('doc1')
196
- const pubId = getPublishedId('doc1')
179
+ const draftId = getDraftId(DocumentId('doc1'))
180
+ const pubId = getPublishedId(DocumentId('doc1'))
197
181
  const initialDraft = {...exampleDoc, _id: draftId, foo: 'bar', _rev: 'rev1'}
198
182
  const initialPub = {...exampleDoc, _id: pubId, foo: 'bar', _rev: 'rev1'}
199
183
 
@@ -201,14 +185,7 @@ describe('applyFirstQueuedTransaction', () => {
201
185
  queued: [
202
186
  {
203
187
  transactionId: 'txn3',
204
- actions: [
205
- {
206
- type: 'document.discard',
207
- documentId: 'doc1',
208
- documentType: 'book',
209
- resource: {projectId: 'p', dataset: 'd'},
210
- },
211
- ],
188
+ actions: [{type: 'document.discard', documentId: 'doc1', documentType: 'book'}],
212
189
  },
213
190
  ],
214
191
  applied: [],
@@ -247,31 +224,37 @@ describe('batchAppliedTransactions', () => {
247
224
  documentId: 'doc1',
248
225
  documentType: 'book',
249
226
  patches: [{set: {foo: 'a'}}],
250
- resource: {projectId: 'p', dataset: 'd'},
251
227
  },
252
228
  {
253
229
  type: 'document.edit',
254
230
  documentId: 'doc1',
255
231
  documentType: 'book',
256
232
  patches: [{set: {bar: 'b'}}],
257
- resource: {projectId: 'p', dataset: 'd'},
258
233
  },
259
234
  ],
260
235
  disableBatching: false,
261
236
  outgoingActions: [],
262
237
  outgoingMutations: [],
263
- base: {[getDraftId('doc1')]: {_id: getDraftId('doc1'), _rev: 'rev1'}} as DocumentSet,
238
+ base: {
239
+ [getDraftId(DocumentId('doc1'))]: {_id: getDraftId(DocumentId('doc1')), _rev: 'rev1'},
240
+ } as unknown as DocumentSet,
264
241
  working: {
265
- [getDraftId('doc1')]: {
242
+ [getDraftId(DocumentId('doc1'))]: {
266
243
  ...exampleDoc,
267
- _id: getDraftId('doc1'),
244
+ _id: getDraftId(DocumentId('doc1')),
268
245
  _rev: 'rev2',
269
246
  foo: 'a',
270
247
  bar: 'b',
271
248
  },
272
249
  },
273
- previous: {[getDraftId('doc1')]: {...exampleDoc, _id: getDraftId('doc1'), _rev: 'rev1'}},
274
- previousRevs: {[getDraftId('doc1')]: 'rev1'},
250
+ previous: {
251
+ [getDraftId(DocumentId('doc1'))]: {
252
+ ...exampleDoc,
253
+ _id: getDraftId(DocumentId('doc1')),
254
+ _rev: 'rev1',
255
+ },
256
+ },
257
+ previousRevs: {[getDraftId(DocumentId('doc1'))]: 'rev1'},
275
258
  timestamp: '2025-02-06T00:00:00.000Z',
276
259
  }
277
260
  const result = batchAppliedTransactions([appliedTx])
@@ -281,16 +264,16 @@ describe('batchAppliedTransactions', () => {
281
264
  })
282
265
 
283
266
  it('batches two edit transactions when possible', () => {
284
- const draftId = getDraftId('doc1')
267
+ const documentId = DocumentId('doc1')
268
+ const draftId = getDraftId(documentId)
285
269
  const appliedTx1: AppliedTransaction = {
286
270
  transactionId: 'txn5',
287
271
  actions: [
288
272
  {
289
273
  type: 'document.edit',
290
- documentId: 'doc1',
274
+ documentId,
291
275
  documentType: 'book',
292
276
  patches: [{set: {foo: 'a'}}],
293
- resource: {projectId: 'p', dataset: 'd'},
294
277
  },
295
278
  ],
296
279
  disableBatching: false,
@@ -298,7 +281,7 @@ describe('batchAppliedTransactions', () => {
298
281
  {
299
282
  actionType: 'sanity.action.document.edit',
300
283
  draftId,
301
- publishedId: getPublishedId('doc1'),
284
+ publishedId: getPublishedId(documentId),
302
285
  patch: {set: {foo: 'a'}},
303
286
  },
304
287
  ],
@@ -314,10 +297,9 @@ describe('batchAppliedTransactions', () => {
314
297
  actions: [
315
298
  {
316
299
  type: 'document.edit',
317
- documentId: 'doc1',
300
+ documentId,
318
301
  documentType: 'book',
319
302
  patches: [{set: {bar: 'b'}}],
320
- resource: {projectId: 'p', dataset: 'd'},
321
303
  },
322
304
  ],
323
305
  disableBatching: false,
@@ -325,7 +307,7 @@ describe('batchAppliedTransactions', () => {
325
307
  {
326
308
  actionType: 'sanity.action.document.edit',
327
309
  draftId,
328
- publishedId: getPublishedId('doc1'),
310
+ publishedId: getPublishedId(documentId),
329
311
  patch: {set: {bar: 'b'}},
330
312
  },
331
313
  ],
@@ -349,7 +331,7 @@ describe('batchAppliedTransactions', () => {
349
331
  })
350
332
 
351
333
  it('returns a transaction with disableBatching true if a single edit action already has disableBatching set', () => {
352
- const draftId = getDraftId('docA')
334
+ const draftId = getDraftId(DocumentId('docA'))
353
335
  const appliedTx: AppliedTransaction = {
354
336
  transactionId: 'txn-disable',
355
337
  actions: [
@@ -358,7 +340,6 @@ describe('batchAppliedTransactions', () => {
358
340
  documentId: 'docA',
359
341
  documentType: 'book',
360
342
  patches: [{set: {foo: 'a'}}],
361
- resource: {projectId: 'p', dataset: 'd'},
362
343
  },
363
344
  ],
364
345
  disableBatching: true, // already set to true
@@ -378,6 +359,85 @@ describe('batchAppliedTransactions', () => {
378
359
  // The actions array should be the same as the input's.
379
360
  expect(result?.actions).toEqual(appliedTx.actions)
380
361
  })
362
+
363
+ it('does not batch a liveEdit edit with a non-liveEdit edit', () => {
364
+ const nonLiveEditTx: AppliedTransaction = {
365
+ transactionId: 'txn-nonlive',
366
+ actions: [
367
+ {
368
+ type: 'document.edit',
369
+ documentId: 'doc1',
370
+ documentType: 'article',
371
+ patches: [{set: {foo: 'a'}}],
372
+ },
373
+ ],
374
+ disableBatching: false,
375
+ outgoingActions: [
376
+ {
377
+ actionType: 'sanity.action.document.edit',
378
+ draftId: getDraftId(DocumentId('doc1')),
379
+ publishedId: getPublishedId(DocumentId('doc1')),
380
+ patch: {set: {foo: 'a'}},
381
+ },
382
+ ],
383
+ outgoingMutations: [],
384
+ base: {
385
+ [getDraftId(DocumentId('doc1'))]: {
386
+ ...exampleDoc,
387
+ _id: getDraftId(DocumentId('doc1')),
388
+ _rev: 'rev1',
389
+ },
390
+ },
391
+ working: {
392
+ [getDraftId(DocumentId('doc1'))]: {
393
+ ...exampleDoc,
394
+ _id: getDraftId(DocumentId('doc1')),
395
+ _rev: 'rev2',
396
+ },
397
+ },
398
+ previous: {
399
+ [getDraftId(DocumentId('doc1'))]: {
400
+ ...exampleDoc,
401
+ _id: getDraftId(DocumentId('doc1')),
402
+ _rev: 'rev1',
403
+ },
404
+ },
405
+ previousRevs: {[getDraftId(DocumentId('doc1'))]: 'rev1'},
406
+ timestamp: '2025-02-06T00:00:00.000Z',
407
+ }
408
+ const liveEditTx: AppliedTransaction = {
409
+ transactionId: 'txn-live',
410
+ actions: [
411
+ {
412
+ type: 'document.edit',
413
+ documentId: 'doc2',
414
+ documentType: 'liveArticle',
415
+ liveEdit: true,
416
+ patches: [{set: {bar: 'b'}}],
417
+ },
418
+ ],
419
+ disableBatching: false,
420
+ outgoingActions: [],
421
+ outgoingMutations: [{patch: {id: 'doc2', set: {bar: 'b'}}}],
422
+ base: {doc2: {...exampleDoc, _id: 'doc2', _rev: 'rev1'}},
423
+ working: {doc2: {...exampleDoc, _id: 'doc2', _rev: 'rev2'}},
424
+ previous: {doc2: {...exampleDoc, _id: 'doc2', _rev: 'rev1'}},
425
+ previousRevs: {doc2: 'rev1'},
426
+ timestamp: '2025-02-06T00:01:00.000Z',
427
+ }
428
+
429
+ // liveEdit first: should return only the liveEdit transaction
430
+ const resultLiveFirst = batchAppliedTransactions([liveEditTx, nonLiveEditTx])
431
+ expect(resultLiveFirst?.batchedTransactionIds).toEqual(['txn-live'])
432
+ expect(resultLiveFirst?.outgoingMutations).toEqual(liveEditTx.outgoingMutations)
433
+ expect(resultLiveFirst?.outgoingActions).toHaveLength(0)
434
+
435
+ // non-liveEdit first: should return only the non-liveEdit transaction
436
+ const resultNonLiveFirst = batchAppliedTransactions([nonLiveEditTx, liveEditTx])
437
+ expect(resultNonLiveFirst?.batchedTransactionIds).toEqual(['txn-nonlive'])
438
+ expect(resultNonLiveFirst?.outgoingActions).toEqual(nonLiveEditTx.outgoingActions)
439
+ expect(resultNonLiveFirst?.outgoingMutations).toHaveLength(0)
440
+ })
381
441
  })
382
442
 
383
443
  describe('transitionAppliedTransactionsToOutgoing', () => {
@@ -394,7 +454,7 @@ describe('transitionAppliedTransactionsToOutgoing', () => {
394
454
  })
395
455
 
396
456
  it('transitions applied transactions to an outgoing transaction', () => {
397
- const draftId = getDraftId('doc1')
457
+ const draftId = getDraftId(DocumentId('doc1'))
398
458
  const initialDoc = {...exampleDoc, _id: draftId, foo: 'old', _rev: 'rev1'}
399
459
  const state: SyncTransactionState = {
400
460
  queued: [],
@@ -407,7 +467,6 @@ describe('transitionAppliedTransactionsToOutgoing', () => {
407
467
  documentId: 'doc1',
408
468
  documentType: 'book',
409
469
  patches: [{set: {foo: 'new'}}],
410
- resource: {projectId: 'p', dataset: 'd'},
411
470
  },
412
471
  ],
413
472
  disableBatching: false,
@@ -438,6 +497,9 @@ describe('transitionAppliedTransactionsToOutgoing', () => {
438
497
  expect(docState?.unverifiedRevisions).toBeDefined()
439
498
  expect(docState?.unverifiedRevisions?.['txn7']).toBeDefined()
440
499
  expect(docState?.unverifiedRevisions?.['txn7']?.previousRev).toEqual('rev1')
500
+ // the transaction ID is also tracked durably for remote-patches origin
501
+ // labeling, surviving unverified-revision pruning
502
+ expect(docState?.recentOwnTransactionIds).toEqual(['txn7'])
441
503
  })
442
504
  })
443
505
 
@@ -455,8 +517,8 @@ describe('cleanupOutgoingTransaction', () => {
455
517
  })
456
518
 
457
519
  it('removes subscription ids for all documents associated with the outgoing transaction and then clears outgoing', () => {
458
- const draftId = getDraftId('doc1')
459
- const pubId = getPublishedId('doc1')
520
+ const draftId = getDraftId(DocumentId('doc1'))
521
+ const pubId = getPublishedId(DocumentId('doc1'))
460
522
  const state: SyncTransactionState = {
461
523
  queued: [],
462
524
  applied: [],
@@ -468,7 +530,6 @@ describe('cleanupOutgoingTransaction', () => {
468
530
  documentId: 'doc1',
469
531
  documentType: 'book',
470
532
  patches: [{set: {foo: 'x'}}],
471
- resource: {projectId: 'p', dataset: 'd'},
472
533
  },
473
534
  ],
474
535
  disableBatching: false,
@@ -497,8 +558,8 @@ describe('cleanupOutgoingTransaction', () => {
497
558
 
498
559
  describe('revertOutgoingTransaction', () => {
499
560
  it('reverts the outgoing transaction and updates documentStates by removing unverified revisions', () => {
500
- const draftId = getDraftId('doc1')
501
- const pubId = getPublishedId('doc1')
561
+ const draftId = getDraftId(DocumentId('doc1'))
562
+ const pubId = getPublishedId(DocumentId('doc1'))
502
563
  // In this test we simulate a state with one applied transaction and an outgoing transaction.
503
564
  const state: SyncTransactionState = {
504
565
  queued: [],
@@ -511,7 +572,6 @@ describe('revertOutgoingTransaction', () => {
511
572
  documentId: 'doc1',
512
573
  documentType: 'book',
513
574
  patches: [{set: {foo: 'reverted'}}],
514
- resource: {projectId: 'p', dataset: 'd'},
515
575
  },
516
576
  ],
517
577
  disableBatching: false,
@@ -532,7 +592,6 @@ describe('revertOutgoingTransaction', () => {
532
592
  documentId: 'doc1',
533
593
  documentType: 'book',
534
594
  patches: [{set: {foo: 'changed'}}],
535
- resource: {projectId: 'p', dataset: 'd'},
536
595
  },
537
596
  ],
538
597
  disableBatching: false,
@@ -597,7 +656,7 @@ describe('applyRemoteDocument', () => {
597
656
  })
598
657
 
599
658
  it('verifies an unverified revision when the revision matches and previousRev is as expected', () => {
600
- const docId = getDraftId('doc1')
659
+ const docId = getDraftId(DocumentId('doc1'))
601
660
  const initialState: SyncTransactionState = {
602
661
  queued: [],
603
662
  applied: [],
@@ -637,9 +696,132 @@ describe('applyRemoteDocument', () => {
637
696
  expect(newDocState?.unverifiedRevisions?.['txn10']).toBeUndefined()
638
697
  })
639
698
 
699
+ describe('fast-forward convergence of local', () => {
700
+ const docId = getDraftId(DocumentId('doc1'))
701
+ const otherDocId = getDraftId(DocumentId('doc2'))
702
+
703
+ const makeApplied = (overrides: Partial<AppliedTransaction>): AppliedTransaction => ({
704
+ transactionId: 'txnPending',
705
+ actions: [],
706
+ working: {},
707
+ previous: {},
708
+ base: {},
709
+ previousRevs: {},
710
+ timestamp: '2025-02-06T00:06:30.000Z',
711
+ outgoingActions: [],
712
+ outgoingMutations: [],
713
+ ...overrides,
714
+ })
715
+
716
+ const makeState = (applied: AppliedTransaction[]): SyncTransactionState => ({
717
+ queued: [],
718
+ applied,
719
+ outgoing: undefined,
720
+ grants,
721
+ documentStates: {
722
+ [docId]: {
723
+ id: docId,
724
+ subscriptions: ['sub1'],
725
+ local: {...exampleDoc, _id: docId, foo: 'optimistic'},
726
+ remote: {...exampleDoc, _id: docId, foo: 'old'},
727
+ unverifiedRevisions: {
728
+ txn10: {
729
+ transactionId: 'txn10',
730
+ documentId: docId,
731
+ previousRev: 'revOld',
732
+ timestamp: '2025-02-06T00:06:00.000Z',
733
+ },
734
+ },
735
+ },
736
+ },
737
+ })
738
+
739
+ const remote: RemoteDocument = {
740
+ type: 'sync',
741
+ documentId: docId,
742
+ document: {...exampleDoc, _id: docId, foo: 'server-truth'},
743
+ revision: 'txn10',
744
+ previousRev: 'revOld',
745
+ timestamp: '2025-02-06T00:07:00.000Z',
746
+ }
747
+
748
+ it('converges local to the server document when nothing else is pending', () => {
749
+ const events = new Subject<DocumentEvent>()
750
+ const newState = applyRemoteDocument(makeState([]), remote, events)
751
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
752
+ })
753
+
754
+ it('converges local when pending work modifies only other documents', () => {
755
+ // a pending applied transaction on doc2 must not block convergence of
756
+ // doc1: nothing would ever revisit doc1's local otherwise
757
+ const otherDoc = {...exampleDoc, _id: otherDocId}
758
+ const pendingOnOtherDoc = makeApplied({
759
+ working: {[otherDocId]: {...otherDoc, _rev: 'txnPending'}},
760
+ previous: {[otherDocId]: otherDoc},
761
+ base: {[otherDocId]: otherDoc},
762
+ previousRevs: {[otherDocId]: 'txn0'},
763
+ })
764
+ const events = new Subject<DocumentEvent>()
765
+ const newState = applyRemoteDocument(makeState([pendingOnOtherDoc]), remote, events)
766
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
767
+ })
768
+
769
+ it('converges local when pending work carries this document unmodified', () => {
770
+ // a draft edit carries the published document in its working set
771
+ // without changing it; presence alone must not block convergence
772
+ const thisDoc = {...exampleDoc, _id: docId}
773
+ const pendingCarryingUnmodified = makeApplied({
774
+ working: {
775
+ [docId]: thisDoc,
776
+ [otherDocId]: {...exampleDoc, _id: otherDocId, _rev: 'txnPending'},
777
+ },
778
+ previous: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
779
+ base: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
780
+ previousRevs: {[docId]: thisDoc._rev, [otherDocId]: 'txn0'},
781
+ })
782
+ const events = new Subject<DocumentEvent>()
783
+ const newState = applyRemoteDocument(makeState([pendingCarryingUnmodified]), remote, events)
784
+ expect(newState.documentStates[docId]?.local).toEqual(remote.document)
785
+ })
786
+
787
+ it('keeps the optimistic local when pending work modifies this document', () => {
788
+ const optimistic = {...exampleDoc, _id: docId, _rev: 'txnPending', foo: 'optimistic'}
789
+ const pendingOnThisDoc = makeApplied({
790
+ working: {[docId]: optimistic},
791
+ previous: {[docId]: {...exampleDoc, _id: docId}},
792
+ base: {[docId]: {...exampleDoc, _id: docId}},
793
+ previousRevs: {[docId]: 'txn0'},
794
+ })
795
+ const state = makeState([pendingOnThisDoc])
796
+ const events = new Subject<DocumentEvent>()
797
+ const newState = applyRemoteDocument(state, remote, events)
798
+ expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
799
+ expect(newState.documentStates[docId]?.remote).toEqual(remote.document)
800
+ })
801
+
802
+ it('keeps the optimistic local when a different in-flight transaction modifies this document', () => {
803
+ const optimistic = {...exampleDoc, _id: docId, _rev: 'txnOutgoing', foo: 'optimistic'}
804
+ const outgoing = {
805
+ ...makeApplied({
806
+ transactionId: 'txnOutgoing',
807
+ working: {[docId]: optimistic},
808
+ previous: {[docId]: {...exampleDoc, _id: docId}},
809
+ base: {[docId]: {...exampleDoc, _id: docId}},
810
+ previousRevs: {[docId]: 'txn0'},
811
+ }),
812
+ disableBatching: false,
813
+ batchedTransactionIds: ['txnOutgoing'],
814
+ }
815
+ const state = {...makeState([]), outgoing}
816
+ const events = new Subject<DocumentEvent>()
817
+ const newState = applyRemoteDocument(state, remote, events)
818
+ expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
819
+ })
820
+ })
821
+
640
822
  it('rebases local changes when no matching unverified revision is found', () => {
641
823
  // In this branch we simply let processActions rebase so that the local becomes the remote.
642
- const docId = getDraftId('doc1')
824
+ const docId = getDraftId(DocumentId('doc1'))
643
825
  const initialState: SyncTransactionState = {
644
826
  queued: [],
645
827
  applied: [],
@@ -674,7 +856,7 @@ describe('applyRemoteDocument', () => {
674
856
 
675
857
  // Test that a sync event removes outdated unverified revisions.
676
858
  it('removes outdated unverified revisions when a sync event is received', () => {
677
- const docId = getDraftId('doc1')
859
+ const docId = getDraftId(DocumentId('doc1'))
678
860
  // An unverified revision created at an earlier time.
679
861
  const outdatedTimestamp = new Date('2025-02-06T00:09:00.000Z').toISOString()
680
862
  // The incoming sync event timestamp is later.
@@ -738,6 +920,399 @@ describe('applyRemoteDocument', () => {
738
920
  expect(newDocState?.remote).toEqual(remote.document)
739
921
  expect(newDocState?.remoteRev).toBe('currentRev')
740
922
  })
923
+
924
+ describe('remote-patches events', () => {
925
+ const docId = getDraftId(DocumentId('doc1'))
926
+
927
+ function createInitialState(
928
+ unverifiedRevisions: NonNullable<
929
+ SyncTransactionState['documentStates'][string]
930
+ >['unverifiedRevisions'] = {},
931
+ recentOwnTransactionIds?: string[],
932
+ ): SyncTransactionState {
933
+ return {
934
+ queued: [],
935
+ applied: [],
936
+ outgoing: undefined,
937
+ grants,
938
+ documentStates: {
939
+ [docId]: {
940
+ id: docId,
941
+ subscriptions: ['sub1'],
942
+ local: {...exampleDoc, _id: docId, foo: 'old'},
943
+ remote: {...exampleDoc, _id: docId, foo: 'old'},
944
+ unverifiedRevisions,
945
+ ...(recentOwnTransactionIds && {recentOwnTransactionIds}),
946
+ },
947
+ },
948
+ }
949
+ }
950
+
951
+ it('emits a remote-patches event with origin "remote" for foreign transactions', () => {
952
+ const events = new Subject<DocumentEvent>()
953
+ const received: DocumentEvent[] = []
954
+ events.subscribe((e) => received.push(e))
955
+
956
+ const remote: RemoteDocument = {
957
+ type: 'mutation',
958
+ documentId: docId,
959
+ document: {...exampleDoc, _id: docId, foo: 'new'},
960
+ revision: 'txnForeign',
961
+ previousRev: 'txn0',
962
+ timestamp: '2025-02-06T00:12:00.000Z',
963
+ mutations: [
964
+ {
965
+ patch: {
966
+ id: docId,
967
+ unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]'],
968
+ },
969
+ },
970
+ {
971
+ patch: {
972
+ id: docId,
973
+ insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]},
974
+ },
975
+ },
976
+ ],
977
+ }
978
+
979
+ applyRemoteDocument(createInitialState(), remote, events)
980
+
981
+ expect(received).toEqual([
982
+ {
983
+ type: 'remote-patches',
984
+ documentId: docId,
985
+ transactionId: 'txnForeign',
986
+ previousRev: 'txn0',
987
+ timestamp: '2025-02-06T00:12:00.000Z',
988
+ origin: 'remote',
989
+ patches: [
990
+ {unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]']},
991
+ {insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]}},
992
+ ],
993
+ },
994
+ ])
995
+ })
996
+
997
+ it('emits origin "local" when the transaction is one of our own unverified revisions', () => {
998
+ const events = new Subject<DocumentEvent>()
999
+ const received: DocumentEvent[] = []
1000
+ events.subscribe((e) => received.push(e))
1001
+
1002
+ const remote: RemoteDocument = {
1003
+ type: 'mutation',
1004
+ documentId: docId,
1005
+ document: {...exampleDoc, _id: docId, foo: 'new'},
1006
+ revision: 'txnOwn',
1007
+ previousRev: 'txn0',
1008
+ timestamp: '2025-02-06T00:13:00.000Z',
1009
+ mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
1010
+ }
1011
+
1012
+ applyRemoteDocument(
1013
+ createInitialState({
1014
+ txnOwn: {
1015
+ transactionId: 'txnOwn',
1016
+ documentId: docId,
1017
+ previousRev: 'txn0',
1018
+ timestamp: '2025-02-06T00:13:00.000Z',
1019
+ },
1020
+ }),
1021
+ remote,
1022
+ events,
1023
+ )
1024
+
1025
+ expect(received).toEqual([
1026
+ {
1027
+ type: 'remote-patches',
1028
+ documentId: docId,
1029
+ transactionId: 'txnOwn',
1030
+ previousRev: 'txn0',
1031
+ timestamp: '2025-02-06T00:13:00.000Z',
1032
+ origin: 'local',
1033
+ patches: [{set: {foo: 'new'}}],
1034
+ },
1035
+ ])
1036
+ })
1037
+
1038
+ it('does not emit for sync events', () => {
1039
+ const events = new Subject<DocumentEvent>()
1040
+ const received: DocumentEvent[] = []
1041
+ events.subscribe((e) => received.push(e))
1042
+
1043
+ const remote: RemoteDocument = {
1044
+ type: 'sync',
1045
+ documentId: docId,
1046
+ document: {...exampleDoc, _id: docId, foo: 'new'},
1047
+ revision: 'revSync',
1048
+ timestamp: '2025-02-06T00:14:00.000Z',
1049
+ }
1050
+
1051
+ applyRemoteDocument(createInitialState(), remote, events)
1052
+ expect(received).toEqual([])
1053
+ })
1054
+
1055
+ it('does not emit when the mutations contain no patches', () => {
1056
+ const events = new Subject<DocumentEvent>()
1057
+ const received: DocumentEvent[] = []
1058
+ events.subscribe((e) => received.push(e))
1059
+
1060
+ const remote: RemoteDocument = {
1061
+ type: 'mutation',
1062
+ documentId: docId,
1063
+ document: {...exampleDoc, _id: docId, foo: 'new'},
1064
+ revision: 'txnCreate',
1065
+ previousRev: 'txn0',
1066
+ timestamp: '2025-02-06T00:15:00.000Z',
1067
+ mutations: [{createOrReplace: {...exampleDoc, _id: docId, foo: 'new'}}],
1068
+ }
1069
+
1070
+ applyRemoteDocument(createInitialState(), remote, events)
1071
+ expect(received).toEqual([])
1072
+ })
1073
+
1074
+ it('excludes patches addressed to other documents in the same transaction', () => {
1075
+ const events = new Subject<DocumentEvent>()
1076
+ const received: DocumentEvent[] = []
1077
+ events.subscribe((e) => received.push(e))
1078
+
1079
+ const remote: RemoteDocument = {
1080
+ type: 'mutation',
1081
+ documentId: docId,
1082
+ document: {...exampleDoc, _id: docId, foo: 'new'},
1083
+ revision: 'txnMulti',
1084
+ previousRev: 'txn0',
1085
+ timestamp: '2025-02-06T00:20:00.000Z',
1086
+ mutations: [
1087
+ {patch: {id: docId, set: {foo: 'new'}}},
1088
+ {patch: {id: 'some-other-doc', set: {foo: 'other'}}},
1089
+ {patch: {query: '*[_type == "author"]', set: {foo: 'queried'}}},
1090
+ ],
1091
+ }
1092
+
1093
+ applyRemoteDocument(createInitialState(), remote, events)
1094
+
1095
+ expect(received).toHaveLength(1)
1096
+ expect((received[0] as {patches: unknown}).patches).toEqual([{set: {foo: 'new'}}])
1097
+ })
1098
+
1099
+ it('labels origin "local" via recentOwnTransactionIds when the unverified revision was pruned', () => {
1100
+ const events = new Subject<DocumentEvent>()
1101
+ const received: DocumentEvent[] = []
1102
+ events.subscribe((e) => received.push(e))
1103
+
1104
+ const remote: RemoteDocument = {
1105
+ type: 'mutation',
1106
+ documentId: docId,
1107
+ document: {...exampleDoc, _id: docId, foo: 'new'},
1108
+ revision: 'txnPruned',
1109
+ previousRev: 'txn0',
1110
+ timestamp: '2025-02-06T00:21:00.000Z',
1111
+ mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
1112
+ }
1113
+
1114
+ // a sync event racing the listener echo pruned the unverified revision,
1115
+ // but the transaction ID is still tracked as one of our own
1116
+ applyRemoteDocument(createInitialState({}, ['txnPruned']), remote, events)
1117
+
1118
+ expect(received).toHaveLength(1)
1119
+ expect(received[0]).toMatchObject({
1120
+ type: 'remote-patches',
1121
+ transactionId: 'txnPruned',
1122
+ origin: 'local',
1123
+ })
1124
+ })
1125
+ })
1126
+
1127
+ describe('rebase with preserved operations', () => {
1128
+ it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
1129
+ const docId = getDraftId(DocumentId('doc1'))
1130
+ const originalDoc: SanityDocument = {
1131
+ ...exampleDoc,
1132
+ _id: docId,
1133
+ _rev: 'rev1',
1134
+ title: 'The quick brown fox',
1135
+ }
1136
+ const locallyEditedDoc: SanityDocument = {
1137
+ ...originalDoc,
1138
+ title: 'The quick brown cat',
1139
+ _rev: 'txnLocal',
1140
+ }
1141
+
1142
+ const appliedTransaction: AppliedTransaction = {
1143
+ transactionId: 'txnLocal',
1144
+ actions: [
1145
+ {
1146
+ type: 'document.edit',
1147
+ documentId: 'doc1',
1148
+ documentType: 'author',
1149
+ patches: [{diffMatchPatch: {title: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n'}}],
1150
+ preserveOperations: true,
1151
+ },
1152
+ ],
1153
+ previous: {[docId]: originalDoc},
1154
+ base: {[docId]: originalDoc},
1155
+ working: {[docId]: locallyEditedDoc},
1156
+ previousRevs: {[docId]: 'rev1'},
1157
+ timestamp: '2025-02-06T00:16:00.000Z',
1158
+ outgoingActions: [],
1159
+ outgoingMutations: [],
1160
+ }
1161
+
1162
+ const initialState: SyncTransactionState = {
1163
+ queued: [],
1164
+ applied: [appliedTransaction],
1165
+ outgoing: undefined,
1166
+ grants,
1167
+ documentStates: {
1168
+ [docId]: {
1169
+ id: docId,
1170
+ subscriptions: ['sub1'],
1171
+ local: locallyEditedDoc,
1172
+ remote: originalDoc,
1173
+ unverifiedRevisions: {},
1174
+ },
1175
+ },
1176
+ }
1177
+
1178
+ // a foreign transaction replaced the title with a non-string, so our
1179
+ // diffMatchPatch can no longer re-apply during the rebase
1180
+ const remoteDoc: SanityDocument = {
1181
+ ...originalDoc,
1182
+ title: 42 as unknown as string,
1183
+ _rev: 'txnForeign',
1184
+ }
1185
+ const remote: RemoteDocument = {
1186
+ type: 'mutation',
1187
+ documentId: docId,
1188
+ document: remoteDoc,
1189
+ revision: 'txnForeign',
1190
+ previousRev: 'rev1',
1191
+ timestamp: '2025-02-06T00:17:00.000Z',
1192
+ mutations: [{patch: {id: docId, set: {title: 42}}}],
1193
+ }
1194
+
1195
+ const events = new Subject<DocumentEvent>()
1196
+ const received: DocumentEvent[] = []
1197
+ events.subscribe((e) => received.push(e))
1198
+
1199
+ const newState = applyRemoteDocument(initialState, remote, events)
1200
+
1201
+ const rebaseErrors = received.filter((e) => e.type === 'rebase-error')
1202
+ expect(rebaseErrors).toHaveLength(1)
1203
+ expect(rebaseErrors[0]).toMatchObject({
1204
+ type: 'rebase-error',
1205
+ transactionId: 'txnLocal',
1206
+ documentId: 'doc1',
1207
+ message: expect.stringMatching(/Failed to apply patches to the working document/),
1208
+ })
1209
+
1210
+ // the failing transaction is dropped and local converges to remote
1211
+ expect(newState.applied).toEqual([])
1212
+ expect(newState.documentStates[docId]?.local).toEqual(remoteDoc)
1213
+ expect(newState.documentStates[docId]?.remote).toEqual(remoteDoc)
1214
+ })
1215
+
1216
+ it('re-applies preserved keyed patches onto a diverged remote document', () => {
1217
+ const docId = getDraftId(DocumentId('doc1'))
1218
+ const originalDoc: SanityDocument = {
1219
+ ...exampleDoc,
1220
+ _id: docId,
1221
+ _rev: 'rev1',
1222
+ items: [
1223
+ {_key: 'a', value: 'first'},
1224
+ {_key: 'b', value: 'second'},
1225
+ ],
1226
+ }
1227
+ const locallyEditedDoc: SanityDocument = {
1228
+ ...originalDoc,
1229
+ items: [
1230
+ {_key: 'a', value: 'first'},
1231
+ {_key: 'c', value: 'in between'},
1232
+ {_key: 'b', value: 'second'},
1233
+ ],
1234
+ _rev: 'txnLocal',
1235
+ }
1236
+
1237
+ const appliedTransaction: AppliedTransaction = {
1238
+ transactionId: 'txnLocal',
1239
+ actions: [
1240
+ {
1241
+ type: 'document.edit',
1242
+ documentId: 'doc1',
1243
+ documentType: 'author',
1244
+ patches: [
1245
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
1246
+ ],
1247
+ preserveOperations: true,
1248
+ },
1249
+ ],
1250
+ previous: {[docId]: originalDoc},
1251
+ base: {[docId]: originalDoc},
1252
+ working: {[docId]: locallyEditedDoc},
1253
+ previousRevs: {[docId]: 'rev1'},
1254
+ timestamp: '2025-02-06T00:18:00.000Z',
1255
+ outgoingActions: [],
1256
+ outgoingMutations: [],
1257
+ }
1258
+
1259
+ const initialState: SyncTransactionState = {
1260
+ queued: [],
1261
+ applied: [appliedTransaction],
1262
+ outgoing: undefined,
1263
+ grants,
1264
+ documentStates: {
1265
+ [docId]: {
1266
+ id: docId,
1267
+ subscriptions: ['sub1'],
1268
+ local: locallyEditedDoc,
1269
+ remote: originalDoc,
1270
+ unverifiedRevisions: {},
1271
+ },
1272
+ },
1273
+ }
1274
+
1275
+ // a foreign transaction appended a new item concurrently
1276
+ const remoteDoc: SanityDocument = {
1277
+ ...originalDoc,
1278
+ items: [
1279
+ {_key: 'a', value: 'first'},
1280
+ {_key: 'b', value: 'second'},
1281
+ {_key: 'd', value: 'appended remotely'},
1282
+ ],
1283
+ _rev: 'txnForeign',
1284
+ }
1285
+ const remote: RemoteDocument = {
1286
+ type: 'mutation',
1287
+ documentId: docId,
1288
+ document: remoteDoc,
1289
+ revision: 'txnForeign',
1290
+ previousRev: 'rev1',
1291
+ timestamp: '2025-02-06T00:19:00.000Z',
1292
+ mutations: [
1293
+ {
1294
+ patch: {
1295
+ id: docId,
1296
+ insert: {after: 'items[-1]', items: [{_key: 'd', value: 'appended remotely'}]},
1297
+ },
1298
+ },
1299
+ ],
1300
+ }
1301
+
1302
+ const events = new Subject<DocumentEvent>()
1303
+ const newState = applyRemoteDocument(initialState, remote, events)
1304
+
1305
+ // both edits survive: our keyed insert re-anchors after item "a" while
1306
+ // the remote append is preserved
1307
+ expect(newState.documentStates[docId]?.local?.['items']).toEqual([
1308
+ {_key: 'a', value: 'first'},
1309
+ {_key: 'c', value: 'in between'},
1310
+ {_key: 'b', value: 'second'},
1311
+ {_key: 'd', value: 'appended remotely'},
1312
+ ])
1313
+ expect(newState.applied).toHaveLength(1)
1314
+ })
1315
+ })
741
1316
  })
742
1317
 
743
1318
  describe('addSubscriptionIdToDocument', () => {