@sanity/sdk 2.8.0 → 2.9.0

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 (92) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2396 -0
  2. package/dist/_chunks-es/_internal.js +129 -0
  3. package/dist/_chunks-es/_internal.js.map +1 -0
  4. package/dist/_chunks-es/createGroqSearchFilter.js +1460 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  6. package/dist/_chunks-es/telemetryManager.js +87 -0
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  8. package/dist/_chunks-es/version.js +7 -0
  9. package/dist/_chunks-es/version.js.map +1 -0
  10. package/dist/_exports/_internal.d.ts +64 -0
  11. package/dist/_exports/_internal.js +20 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +2 -2343
  14. package/dist/index.js +383 -1777
  15. package/dist/index.js.map +1 -1
  16. package/package.json +11 -4
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +10 -1
  19. package/src/auth/authStore.test.ts +150 -1
  20. package/src/auth/authStore.ts +11 -11
  21. package/src/auth/dashboardAuth.ts +2 -2
  22. package/src/auth/handleAuthCallback.ts +9 -3
  23. package/src/auth/logout.test.ts +1 -1
  24. package/src/auth/logout.ts +1 -1
  25. package/src/auth/refreshStampedToken.test.ts +118 -1
  26. package/src/auth/refreshStampedToken.ts +3 -2
  27. package/src/auth/standaloneAuth.ts +9 -3
  28. package/src/auth/studioAuth.ts +34 -7
  29. package/src/auth/studioModeAuth.ts +2 -1
  30. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +10 -2
  31. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +5 -1
  32. package/src/auth/subscribeToStorageEventsAndSetToken.ts +2 -2
  33. package/src/auth/utils.ts +33 -0
  34. package/src/client/clientStore.test.ts +14 -0
  35. package/src/client/clientStore.ts +2 -1
  36. package/src/comlink/node/getNodeState.ts +2 -1
  37. package/src/config/sanityConfig.ts +6 -0
  38. package/src/document/actions.ts +18 -11
  39. package/src/document/applyDocumentActions.test.ts +7 -6
  40. package/src/document/applyDocumentActions.ts +10 -4
  41. package/src/document/documentStore.test.ts +536 -188
  42. package/src/document/documentStore.ts +142 -76
  43. package/src/document/events.ts +7 -2
  44. package/src/document/permissions.test.ts +18 -16
  45. package/src/document/permissions.ts +35 -11
  46. package/src/document/processActions.test.ts +359 -32
  47. package/src/document/processActions.ts +104 -76
  48. package/src/document/reducers.test.ts +117 -29
  49. package/src/document/reducers.ts +43 -36
  50. package/src/document/sharedListener.ts +16 -6
  51. package/src/document/util.ts +14 -0
  52. package/src/favorites/favorites.test.ts +9 -2
  53. package/src/presence/bifurTransport.ts +6 -1
  54. package/src/preview/getPreviewState.test.ts +115 -98
  55. package/src/preview/getPreviewState.ts +38 -60
  56. package/src/preview/previewProjectionUtils.test.ts +179 -0
  57. package/src/preview/previewProjectionUtils.ts +93 -0
  58. package/src/preview/resolvePreview.test.ts +42 -25
  59. package/src/preview/resolvePreview.ts +29 -10
  60. package/src/preview/{previewStore.ts → types.ts} +8 -17
  61. package/src/projection/getProjectionState.test.ts +16 -16
  62. package/src/projection/getProjectionState.ts +2 -1
  63. package/src/projection/projectionQuery.ts +2 -3
  64. package/src/projection/types.ts +1 -1
  65. package/src/query/queryStore.ts +2 -1
  66. package/src/releases/getPerspectiveState.ts +7 -6
  67. package/src/releases/releasesStore.test.ts +20 -5
  68. package/src/releases/releasesStore.ts +20 -8
  69. package/src/store/createStateSourceAction.test.ts +62 -0
  70. package/src/store/createStateSourceAction.ts +34 -39
  71. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  72. package/src/telemetry/devMode.test.ts +52 -0
  73. package/src/telemetry/devMode.ts +40 -0
  74. package/src/telemetry/initTelemetry.test.ts +225 -0
  75. package/src/telemetry/initTelemetry.ts +205 -0
  76. package/src/telemetry/telemetryManager.test.ts +263 -0
  77. package/src/telemetry/telemetryManager.ts +187 -0
  78. package/src/users/usersStore.test.ts +1 -0
  79. package/src/users/usersStore.ts +5 -1
  80. package/src/utils/createFetcherStore.test.ts +6 -4
  81. package/src/utils/createFetcherStore.ts +2 -1
  82. package/src/utils/getStagingApiHost.test.ts +21 -0
  83. package/src/utils/getStagingApiHost.ts +14 -0
  84. package/src/utils/ids.test.ts +1 -29
  85. package/src/utils/ids.ts +0 -10
  86. package/src/utils/setCleanupTimeout.ts +24 -0
  87. package/src/preview/previewQuery.test.ts +0 -236
  88. package/src/preview/previewQuery.ts +0 -153
  89. package/src/preview/previewStore.test.ts +0 -36
  90. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  91. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  92. package/src/preview/util.ts +0 -13
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  type BaseActionOptions,
3
+ type BaseMutationOptions,
3
4
  type FilteredResponseQueryOptions,
4
5
  type ListenEvent,
5
6
  type MultipleActionResult,
7
+ type MultipleMutationResult,
6
8
  type MutationEvent,
7
9
  type RawQueryResponse,
8
10
  type ResponseQueryOptions,
@@ -12,6 +14,7 @@ import {
12
14
  type WelcomeEvent,
13
15
  } from '@sanity/client'
14
16
  import {diffValue} from '@sanity/diff-patch'
17
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
15
18
  import {type Mutation, type SanityDocument} from '@sanity/types'
16
19
  import {evaluate, parse} from 'groq-js'
17
20
  import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs'
@@ -21,7 +24,6 @@ import {getClientState} from '../client/clientStore'
21
24
  import {createDocumentHandle} from '../config/handles'
22
25
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
23
26
  import {type StateSource} from '../store/createStateSourceAction'
24
- import {getDraftId, getPublishedId} from '../utils/ids'
25
27
  import {
26
28
  createDocument,
27
29
  deleteDocument,
@@ -65,6 +67,10 @@ let instance: SanityInstance
65
67
  let instance1: SanityInstance
66
68
  let instance2: SanityInstance
67
69
 
70
+ const source = {projectId: 'p', dataset: 'd'}
71
+ const source1 = {projectId: 'p', dataset: 'd1'}
72
+ const source2 = {projectId: 'p', dataset: 'd2'}
73
+
68
74
  beforeEach(() => {
69
75
  instance = createSanityInstance({projectId: 'p', dataset: 'd'})
70
76
  // test uses two instances that share the same in-memory dataset, but separate
@@ -81,8 +87,9 @@ afterEach(() => {
81
87
  })
82
88
 
83
89
  it('creates, edits, and publishes a document', async () => {
84
- const doc = createDocumentHandle({documentId: 'doc-single', documentType: 'article'})
85
- const documentState = getDocumentState(instance, doc)
90
+ const documentId = DocumentId('doc-single')
91
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
92
+ const documentState = getDocumentState<TestDocument>(instance, doc)
86
93
 
87
94
  // Initially the document is undefined
88
95
  expect(documentState.getCurrent()).toBeUndefined()
@@ -90,15 +97,19 @@ it('creates, edits, and publishes a document', async () => {
90
97
  const unsubscribe = documentState.subscribe()
91
98
 
92
99
  // Create a new document
93
- const {appeared} = await applyDocumentActions(instance, {actions: [createDocument(doc)]})
94
- expect(appeared).toContain(getDraftId(doc.documentId))
100
+ const {appeared} = await applyDocumentActions(instance, {
101
+ actions: [createDocument(doc)],
102
+ source,
103
+ })
104
+ expect(appeared).toContain(getDraftId(documentId))
95
105
 
96
106
  let currentDoc = documentState.getCurrent()
97
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
107
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
98
108
 
99
109
  // Edit the document – add a title
100
110
  await applyDocumentActions(instance, {
101
111
  actions: [editDocument(doc, {set: {title: 'My First Article'}})],
112
+ source,
102
113
  })
103
114
  currentDoc = documentState.getCurrent()
104
115
  expect(currentDoc?.title).toEqual('My First Article')
@@ -106,17 +117,19 @@ it('creates, edits, and publishes a document', async () => {
106
117
  // Publish the document; the resulting transactionId is used as the new _rev
107
118
  const {transactionId, submitted} = await applyDocumentActions(instance, {
108
119
  actions: [publishDocument(doc)],
120
+ source,
109
121
  })
110
122
  await submitted()
111
123
  currentDoc = documentState.getCurrent()
112
124
 
113
- expect(currentDoc).toMatchObject({_id: doc.documentId, _rev: transactionId})
125
+ expect(currentDoc).toMatchObject({_id: documentId, _rev: transactionId})
114
126
  unsubscribe()
115
127
  })
116
128
 
117
129
  it('creates a document with initial values', async () => {
118
- const doc = createDocumentHandle({documentId: 'doc-with-initial', documentType: 'article'})
119
- const documentState = getDocumentState(instance, doc)
130
+ const documentId = DocumentId('doc-with-initial')
131
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
132
+ const documentState = getDocumentState<TestDocument>(instance, doc)
120
133
 
121
134
  expect(documentState.getCurrent()).toBeUndefined()
122
135
 
@@ -131,11 +144,12 @@ it('creates a document with initial values', async () => {
131
144
  count: 42,
132
145
  }),
133
146
  ],
147
+ source,
134
148
  })
135
- expect(appeared).toContain(getDraftId(doc.documentId))
149
+ expect(appeared).toContain(getDraftId(documentId))
136
150
 
137
151
  const currentDoc = documentState.getCurrent()
138
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
152
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
139
153
  expect(currentDoc?.title).toEqual('Article with Initial Values')
140
154
  expect(currentDoc?.['author']).toEqual('Jane Doe')
141
155
  expect(currentDoc?.['count']).toEqual(42)
@@ -144,8 +158,9 @@ it('creates a document with initial values', async () => {
144
158
  })
145
159
 
146
160
  it('edits existing documents', async () => {
147
- const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
148
- const state = getDocumentState(instance, doc)
161
+ const documentId = DocumentId('existing-doc')
162
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
163
+ const state = getDocumentState<TestDocument>(instance, doc)
149
164
 
150
165
  // not subscribed yet so the value is undefined
151
166
  expect(state.getCurrent()).toBeUndefined()
@@ -156,15 +171,16 @@ it('edits existing documents', async () => {
156
171
  await firstValueFrom(state.observable.pipe(first((i) => !!i)))
157
172
 
158
173
  expect(state.getCurrent()).toMatchObject({
159
- _id: getDraftId(doc.documentId),
174
+ _id: getDraftId(documentId),
160
175
  title: 'existing doc',
161
176
  })
162
177
 
163
178
  await applyDocumentActions(instance, {
164
179
  actions: [editDocument(doc, {set: {title: 'updated title'}})],
180
+ source,
165
181
  })
166
182
  expect(state.getCurrent()).toMatchObject({
167
- _id: getDraftId(doc.documentId),
183
+ _id: getDraftId(documentId),
168
184
  title: 'updated title',
169
185
  })
170
186
 
@@ -172,23 +188,31 @@ it('edits existing documents', async () => {
172
188
  })
173
189
 
174
190
  it('sets optimistic changes synchronously', async () => {
175
- const doc = createDocumentHandle({documentId: 'optimistic', documentType: 'article'})
191
+ const doc1 = {
192
+ documentId: DocumentId('optimistic'),
193
+ documentType: 'article',
194
+ }
195
+ const doc2 = {
196
+ documentId: DocumentId('optimistic'),
197
+ documentType: 'article',
198
+ }
176
199
 
177
- const state1 = getDocumentState(instance1, doc)
178
- const state2 = getDocumentState(instance2, doc)
200
+ const state1 = getDocumentState(instance1, doc1)
201
+ const state2 = getDocumentState(instance2, doc2)
179
202
 
180
203
  const unsubscribe1 = state1.subscribe()
181
204
  const unsubscribe2 = state2.subscribe()
182
205
 
183
206
  // wait until the value is primed in the store
184
- await resolveDocument(instance1, doc)
207
+ await resolveDocument(instance1, doc1)
185
208
 
186
209
  // then the actions are synchronous
187
210
  expect(state1.getCurrent()).toBeNull()
188
- applyDocumentActions(instance1, {actions: [createDocument(doc)]})
189
- expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc.documentId)})
211
+ applyDocumentActions(instance1, {actions: [createDocument(doc1)], source: source1})
212
+ expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc1.documentId)})
190
213
  const actionResult1Promise = applyDocumentActions(instance1, {
191
- actions: [editDocument(doc, {set: {title: 'initial title'}})],
214
+ actions: [editDocument(doc1, {set: {title: 'initial title'}})],
215
+ source: source1,
192
216
  })
193
217
  expect(state1.getCurrent()?.title).toBe('initial title')
194
218
 
@@ -208,7 +232,8 @@ it('sets optimistic changes synchronously', async () => {
208
232
 
209
233
  // synchronous for state 2
210
234
  const actionResult2Promise = applyDocumentActions(instance2, {
211
- actions: [editDocument(doc, {set: {title: 'updated title'}})],
235
+ actions: [editDocument(doc2, {set: {title: 'updated title'}})],
236
+ source: source2,
212
237
  })
213
238
  expect(state2.getCurrent()?.title).toBe('updated title')
214
239
  // async for state 1
@@ -231,16 +256,19 @@ it('propagates changes between two instances', async () => {
231
256
  const state2Unsubscribe = state2.subscribe()
232
257
 
233
258
  // Create the document from instance1.
234
- await applyDocumentActions(instance1, {actions: [createDocument(doc)]}).then((r) => r.submitted())
259
+ await applyDocumentActions(instance1, {actions: [createDocument(doc)], source: source1}).then(
260
+ (r) => r.submitted(),
261
+ )
235
262
 
236
263
  const doc1 = state1.getCurrent()
237
264
  const doc2 = state2.getCurrent()
238
- expect(doc1?._id).toEqual(getDraftId(doc.documentId))
239
- expect(doc2?._id).toEqual(getDraftId(doc.documentId))
265
+ expect(doc1?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
266
+ expect(doc2?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
240
267
 
241
268
  // Now, edit the document from instance2.
242
269
  await applyDocumentActions(instance2, {
243
270
  actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
271
+ source: source2,
244
272
  }).then((r) => r.submitted())
245
273
 
246
274
  const updated1 = state1.getCurrent()
@@ -268,16 +296,19 @@ it('handles concurrent edits and resolves conflicts', async () => {
268
296
  createDocument(doc),
269
297
  editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
270
298
  ],
299
+ source,
271
300
  }).then((res) => res.submitted())
272
301
 
273
302
  // Both instances now issue an edit simultaneously.
274
303
  const p1 = applyDocumentActions(instance1, {
275
304
  actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
305
+ source: source1,
276
306
  }).then((r) => r.submitted())
277
307
  const p2 = applyDocumentActions(instance2, {
278
308
  actions: [
279
309
  editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
280
310
  ],
311
+ source: source2,
281
312
  }).then((r) => r.submitted())
282
313
 
283
314
  // Wait for both actions to complete (or reject).
@@ -294,27 +325,31 @@ it('handles concurrent edits and resolves conflicts', async () => {
294
325
  })
295
326
 
296
327
  it('unpublishes and discards a document', async () => {
297
- const doc = createDocumentHandle({documentId: 'doc-pub-unpub', documentType: 'article'})
298
- const documentState = getDocumentState(instance, doc)
328
+ const documentId = DocumentId('doc-pub-unpub')
329
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
330
+ const documentState = getDocumentState<TestDocument>(instance, doc)
299
331
  const unsubscribe = documentState.subscribe()
300
332
 
301
333
  // Create and publish the document.
302
- await applyDocumentActions(instance, {actions: [createDocument(doc)]})
303
- const afterPublish = await applyDocumentActions(instance, {actions: [publishDocument(doc)]})
334
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], source})
335
+ const afterPublish = await applyDocumentActions(instance, {
336
+ actions: [publishDocument(doc)],
337
+ source,
338
+ })
304
339
  const publishedDoc = documentState.getCurrent()
305
340
  expect(publishedDoc).toMatchObject({
306
- _id: getPublishedId(doc.documentId),
341
+ _id: getPublishedId(documentId),
307
342
  _rev: afterPublish.transactionId,
308
343
  })
309
344
 
310
345
  // Unpublish the document (which should delete the published version and create a draft).
311
- await applyDocumentActions(instance, {actions: [unpublishDocument(doc)]})
346
+ await applyDocumentActions(instance, {actions: [unpublishDocument(doc)], source})
312
347
  const afterUnpublish = documentState.getCurrent()
313
348
  // In our mock implementation the _id remains the same but the published copy is removed.
314
- expect(afterUnpublish?._id).toEqual(getDraftId(doc.documentId))
349
+ expect(afterUnpublish?._id).toEqual(getDraftId(documentId))
315
350
 
316
351
  // Discard the draft (which deletes the draft version).
317
- await applyDocumentActions(instance, {actions: [discardDocument(doc)]})
352
+ await applyDocumentActions(instance, {actions: [discardDocument(doc)], source})
318
353
  const afterDiscard = documentState.getCurrent()
319
354
  expect(afterDiscard).toBeNull()
320
355
 
@@ -322,17 +357,21 @@ it('unpublishes and discards a document', async () => {
322
357
  })
323
358
 
324
359
  it('deletes a document', async () => {
325
- const doc = createDocumentHandle({documentId: 'doc-delete', documentType: 'article'})
360
+ const documentId = DocumentId('doc-delete')
361
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
326
362
 
327
363
  const documentState = getDocumentState(instance, doc)
328
364
  const unsubscribe = documentState.subscribe()
329
365
 
330
- await applyDocumentActions(instance, {actions: [createDocument(doc), publishDocument(doc)]})
366
+ await applyDocumentActions(instance, {
367
+ actions: [createDocument(doc), publishDocument(doc)],
368
+ source,
369
+ })
331
370
  const docValue = documentState.getCurrent()
332
371
  expect(docValue).toBeDefined()
333
372
 
334
373
  // Delete the document.
335
- await applyDocumentActions(instance, {actions: [deleteDocument(doc)]})
374
+ await applyDocumentActions(instance, {actions: [deleteDocument(doc)], source})
336
375
  const afterDelete = documentState.getCurrent()
337
376
  expect(afterDelete).toBeNull()
338
377
 
@@ -340,14 +379,15 @@ it('deletes a document', async () => {
340
379
  })
341
380
 
342
381
  it('cleans up document state when there are no subscribers', async () => {
343
- const doc = createDocumentHandle({documentId: 'doc-cleanup', documentType: 'article'})
344
- const documentState = getDocumentState(instance, doc)
382
+ const documentId = DocumentId('doc-cleanup')
383
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
384
+ const documentState = getDocumentState<TestDocument>(instance, doc)
345
385
 
346
386
  // Subscribe to the document state.
347
387
  const unsubscribe = documentState.subscribe()
348
388
 
349
389
  // Create a document.
350
- await applyDocumentActions(instance, {actions: [createDocument(doc)]})
390
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], source})
351
391
  expect(documentState.getCurrent()).toBeDefined()
352
392
 
353
393
  // Unsubscribe from the document.
@@ -374,6 +414,7 @@ it('fetches documents if there are no active subscriptions for the actions appli
374
414
  // transaction for this action has been accepted by the server
375
415
  const setNewTitle = applyDocumentActions(instance, {
376
416
  actions: [editDocument(doc, {set: {title: 'new title'}})],
417
+ source,
377
418
  })
378
419
  expect(getCurrent()?.title).toBeUndefined()
379
420
  expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false)
@@ -382,9 +423,15 @@ it('fetches documents if there are no active subscriptions for the actions appli
382
423
  expect(getCurrent()?.title).toBe('new title')
383
424
 
384
425
  // there is an active subscriber now so the edits are synchronous
385
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title'}})]})
426
+ applyDocumentActions(instance, {
427
+ actions: [editDocument(doc, {set: {title: 'updated title'}})],
428
+ source,
429
+ })
386
430
  expect(getCurrent()?.title).toBe('updated title')
387
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated title!'}})]})
431
+ applyDocumentActions(instance, {
432
+ actions: [editDocument(doc, {set: {title: 'updated title!'}})],
433
+ source,
434
+ })
388
435
  expect(getCurrent()?.title).toBe('updated title!')
389
436
 
390
437
  expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBe(false)
@@ -392,6 +439,7 @@ it('fetches documents if there are no active subscriptions for the actions appli
392
439
  // await submitted in order to test that there is no subscriptions
393
440
  const result = await applyDocumentActions(instance, {
394
441
  actions: [editDocument(doc, {set: {title: 'updated title'}})],
442
+ source,
395
443
  })
396
444
  await result.submitted()
397
445
 
@@ -400,6 +448,7 @@ it('fetches documents if there are no active subscriptions for the actions appli
400
448
 
401
449
  const setNewNewTitle = applyDocumentActions(instance, {
402
450
  actions: [editDocument(doc, {set: {title: 'new new title'}})],
451
+ source,
403
452
  })
404
453
  // now we'll have to await again
405
454
  expect(getCurrent()?.title).toBe(undefined)
@@ -414,14 +463,15 @@ it('batches edit transaction into one outgoing transaction', async () => {
414
463
  const unsubscribe = getDocumentState(instance, doc).subscribe()
415
464
 
416
465
  // this creates its own transaction
417
- applyDocumentActions(instance, {actions: [createDocument(doc)]})
466
+ applyDocumentActions(instance, {actions: [createDocument(doc)], source})
418
467
 
419
468
  // these get batched into one
420
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!'}})]})
421
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})]})
422
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!!'}})]})
469
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!'}})], source})
470
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})], source})
471
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!!'}})], source})
423
472
  const res = await applyDocumentActions(instance, {
424
473
  actions: [editDocument(doc, {set: {title: 'name!!!!'}})],
474
+ source,
425
475
  })
426
476
  await res.submitted()
427
477
 
@@ -434,6 +484,52 @@ it('batches edit transaction into one outgoing transaction', async () => {
434
484
  unsubscribe()
435
485
  })
436
486
 
487
+ it('submits liveEdit document edits through observable.mutate', async () => {
488
+ const liveDoc = createDocumentHandle({
489
+ documentId: 'live-edit-test-doc',
490
+ documentType: 'article',
491
+ liveEdit: true,
492
+ })
493
+ const state = getDocumentState(instance, liveDoc)
494
+ const unsubscribe = state.subscribe()
495
+
496
+ await firstValueFrom(state.observable.pipe(first((doc) => doc !== undefined)))
497
+ expect(state.getCurrent()).toMatchObject({
498
+ _id: 'live-edit-test-doc',
499
+ title: 'live initial',
500
+ })
501
+
502
+ const callCountBefore = mutateSubmission.mock.calls.length
503
+
504
+ const result = await applyDocumentActions(instance, {
505
+ actions: [editDocument(liveDoc, {set: {title: 'patched via mutate'}})],
506
+ source,
507
+ })
508
+ await result.submitted()
509
+
510
+ expect(mutateSubmission.mock.calls.length).toBe(callCountBefore + 1)
511
+ const [mutationList, mutateOptions] = mutateSubmission.mock.calls[callCountBefore]!
512
+ expect(mutateOptions).toMatchObject({
513
+ transactionId: result.transactionId,
514
+ visibility: 'async',
515
+ returnDocuments: false,
516
+ returnFirst: false,
517
+ tag: 'document.mutate',
518
+ skipCrossDatasetReferenceValidation: true,
519
+ })
520
+ expect(mutationList.length).toBeGreaterThan(0)
521
+ expect(
522
+ mutationList.every(
523
+ (m) => 'patch' in m && (m as {patch: {id: string}}).patch.id === liveDoc.documentId,
524
+ ),
525
+ ).toBe(true)
526
+
527
+ expect(state.getCurrent()?.title).toBe('patched via mutate')
528
+ expect(client.action).not.toHaveBeenCalled()
529
+
530
+ unsubscribe()
531
+ })
532
+
437
533
  it('provides the consistency status via `getDocumentSyncStatus`', async () => {
438
534
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
439
535
 
@@ -443,7 +539,7 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
443
539
  const unsubscribe = syncStatus.subscribe()
444
540
  expect(syncStatus.getCurrent()).toBe(true)
445
541
 
446
- const applied = applyDocumentActions(instance, {actions: [createDocument(doc)]})
542
+ const applied = applyDocumentActions(instance, {actions: [createDocument(doc)], source})
447
543
  expect(syncStatus.getCurrent()).toBe(false)
448
544
 
449
545
  const createResult = await applied
@@ -452,11 +548,17 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
452
548
  await createResult.submitted()
453
549
  expect(syncStatus.getCurrent()).toBe(true)
454
550
 
455
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'initial name'}})]})
551
+ applyDocumentActions(instance, {
552
+ actions: [editDocument(doc, {set: {title: 'initial name'}})],
553
+ source,
554
+ })
456
555
  expect(syncStatus.getCurrent()).toBe(false)
457
556
 
458
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated name'}})]})
459
- const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)]})
557
+ applyDocumentActions(instance, {
558
+ actions: [editDocument(doc, {set: {title: 'updated name'}})],
559
+ source,
560
+ })
561
+ const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)], source})
460
562
  expect(syncStatus.getCurrent()).toBe(false)
461
563
  await publishResult.then((res) => res.submitted())
462
564
  expect(syncStatus.getCurrent()).toBe(true)
@@ -473,35 +575,45 @@ it('reverts failed outgoing transaction locally', async () => {
473
575
  })
474
576
 
475
577
  const revertedEventPromise = new Promise<TransactionRevertedEvent>((resolve) => {
476
- const unsubscribe = subscribeDocumentEvents(instance, (e) => {
477
- if (e.type === 'reverted') {
478
- resolve(e)
479
- unsubscribe()
480
- }
578
+ const unsubscribe = subscribeDocumentEvents(instance, {
579
+ source,
580
+ eventHandler: (e) => {
581
+ if (e.type === 'reverted') {
582
+ resolve(e)
583
+ unsubscribe()
584
+ }
585
+ },
481
586
  })
482
587
  })
483
588
 
484
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
589
+ const documentId = DocumentId(crypto.randomUUID())
590
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
485
591
 
486
592
  const {getCurrent, subscribe} = getDocumentState(instance, doc)
487
593
  const unsubscribe = subscribe()
488
594
 
489
- await applyDocumentActions(instance, {actions: [createDocument(doc)]})
490
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})]})
491
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the quick'}})]})
595
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], source})
596
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})], source})
597
+ applyDocumentActions(instance, {
598
+ actions: [editDocument(doc, {set: {title: 'the quick'}})],
599
+ source,
600
+ })
492
601
 
493
602
  // this edit action is simulated to fail from the backend and will be reverted
494
603
  const revertedActionResult = applyDocumentActions(instance, {
495
604
  actions: [editDocument(doc, {set: {title: 'the quick brown'}})],
496
605
  transactionId: 'force-revert',
497
606
  disableBatching: true,
607
+ source,
498
608
  })
499
609
 
500
610
  applyDocumentActions(instance, {
501
611
  actions: [editDocument(doc, {set: {title: 'the quick brown fox'}})],
612
+ source,
502
613
  })
503
614
  await applyDocumentActions(instance, {
504
615
  actions: [editDocument(doc, {set: {title: 'the quick brown fox jumps'}})],
616
+ source,
505
617
  }).then((e) => e.submitted())
506
618
 
507
619
  await expect(revertedEventPromise).resolves.toMatchObject({
@@ -521,6 +633,7 @@ it('reverts failed outgoing transaction locally', async () => {
521
633
  // check that we can still edit after recovering from the error
522
634
  applyDocumentActions(instance, {
523
635
  actions: [editDocument(doc, {set: {title: 'TEST the quick fox jumps'}})],
636
+ source,
524
637
  })
525
638
  expect(getCurrent()?.title).toBe('TEST the quick fox jumps')
526
639
 
@@ -530,11 +643,14 @@ it('reverts failed outgoing transaction locally', async () => {
530
643
 
531
644
  it('removes a queued transaction if it fails to apply', async () => {
532
645
  const actionErrorEventPromise = new Promise<ActionErrorEvent>((resolve) => {
533
- const unsubscribe = subscribeDocumentEvents(instance, (e) => {
534
- if (e.type === 'error') {
535
- resolve(e)
536
- unsubscribe()
537
- }
646
+ const unsubscribe = subscribeDocumentEvents(instance, {
647
+ source,
648
+ eventHandler: (e) => {
649
+ if (e.type === 'error') {
650
+ resolve(e)
651
+ unsubscribe()
652
+ }
653
+ },
538
654
  })
539
655
  })
540
656
 
@@ -543,7 +659,10 @@ it('removes a queued transaction if it fails to apply', async () => {
543
659
  const unsubscribe = state.subscribe()
544
660
 
545
661
  await expect(
546
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: "can't set"}})]}),
662
+ applyDocumentActions(instance, {
663
+ actions: [editDocument(doc, {set: {title: "can't set"}})],
664
+ source,
665
+ }),
547
666
  ).rejects.toThrowError(/Cannot edit document/)
548
667
 
549
668
  await expect(actionErrorEventPromise).resolves.toMatchObject({
@@ -553,8 +672,8 @@ it('removes a queued transaction if it fails to apply', async () => {
553
672
  })
554
673
 
555
674
  // editing should still work after though (no crashing)
556
- await applyDocumentActions(instance, {actions: [createDocument(doc)]})
557
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'can set!'}})]})
675
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], source})
676
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'can set!'}})], source})
558
677
 
559
678
  expect(state.getCurrent()?.title).toBe('can set!')
560
679
 
@@ -574,7 +693,9 @@ it('returns allowed true when no permission errors occur', async () => {
574
693
  })
575
694
  const state = getDocumentState(instance, doc)
576
695
  const unsubscribe = state.subscribe()
577
- await applyDocumentActions(instance, {actions: [createDocument(doc)]}).then((r) => r.submitted())
696
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], source}).then((r) =>
697
+ r.submitted(),
698
+ )
578
699
 
579
700
  // Use an action that includes a patch (so that update permission check is bypassed).
580
701
  const permissionsState = getPermissionsState(instance, {
@@ -596,9 +717,9 @@ it('returns allowed true when no permission errors occur', async () => {
596
717
  it("should reject applying the action if a precondition isn't met", async () => {
597
718
  const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
598
719
 
599
- await expect(applyDocumentActions(instance, {actions: [deleteDocument(doc)]})).rejects.toThrow(
600
- 'The document you are trying to delete does not exist.',
601
- )
720
+ await expect(
721
+ applyDocumentActions(instance, {actions: [deleteDocument(doc)], source}),
722
+ ).rejects.toThrow('The document you are trying to delete does not exist.')
602
723
  })
603
724
 
604
725
  it("should reject applying the action if a permission isn't met", async () => {
@@ -607,9 +728,9 @@ it("should reject applying the action if a permission isn't met", async () => {
607
728
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
608
729
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
609
730
 
610
- await expect(applyDocumentActions(instance, {actions: [createDocument(doc)]})).rejects.toThrow(
611
- 'You do not have permission to create a draft for document "does-not-exist".',
612
- )
731
+ await expect(
732
+ applyDocumentActions(instance, {actions: [createDocument(doc)], source}),
733
+ ).rejects.toThrow('You do not have permission to create a draft for document "does-not-exist".')
613
734
  })
614
735
 
615
736
  it('returns allowed false with reasons when permission errors occur', async () => {
@@ -647,8 +768,57 @@ it('fetches dataset ACL and updates grants in the document store state', async (
647
768
  })
648
769
  })
649
770
 
650
- it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
771
+ it('fetches ACL for MediaLibrarySource', async () => {
772
+ const mediaLibraryInstance = createSanityInstance({
773
+ projectId: 'p',
774
+ dataset: 'd',
775
+ sources: {
776
+ 'media-library': {mediaLibraryId: 'test-media-library'},
777
+ },
778
+ })
779
+
780
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
781
+ vi.mocked(client.request).mockResolvedValue(datasetAcl)
782
+
783
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
784
+ const mediaLibrarySource = {mediaLibraryId: 'test-media-library'}
785
+
786
+ const result = await resolvePermissions(mediaLibraryInstance, {
787
+ actions: [createDocument(doc)],
788
+ source: mediaLibrarySource,
789
+ })
790
+
791
+ expect(result).toEqual({allowed: true})
792
+ mediaLibraryInstance.dispose()
793
+ })
794
+
795
+ it('fetches ACL for CanvasSource', async () => {
796
+ const canvasInstance = createSanityInstance({
797
+ projectId: 'p',
798
+ dataset: 'd',
799
+ sources: {
800
+ canvas: {canvasId: 'test-canvas'},
801
+ },
802
+ })
803
+
804
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
805
+ vi.mocked(client.request).mockResolvedValue(datasetAcl)
806
+
651
807
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
808
+ const canvasSource = {canvasId: 'test-canvas'}
809
+
810
+ const result = await resolvePermissions(canvasInstance, {
811
+ actions: [createDocument(doc)],
812
+ source: canvasSource,
813
+ })
814
+
815
+ expect(result).toEqual({allowed: true})
816
+ canvasInstance.dispose()
817
+ })
818
+
819
+ it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
820
+ const documentId = DocumentId(crypto.randomUUID())
821
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
652
822
 
653
823
  expect(await resolveDocument(instance, doc)).toBe(null)
654
824
 
@@ -656,11 +826,12 @@ it('returns a promise that resolves when a document has been loaded in the store
656
826
  const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
657
827
  const result = await applyDocumentActions(oneOffInstance, {
658
828
  actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})],
829
+ source,
659
830
  })
660
831
  await result.submitted() // wait till submitted to server before resolving
661
832
 
662
833
  await expect(resolveDocument(instance, doc)).resolves.toMatchObject({
663
- _id: getDraftId(doc.documentId),
834
+ _id: getDraftId(documentId),
664
835
  _type: 'article',
665
836
  title: 'initial title',
666
837
  })
@@ -669,9 +840,9 @@ it('returns a promise that resolves when a document has been loaded in the store
669
840
 
670
841
  it('emits an event for each action after an outgoing transaction has been accepted', async () => {
671
842
  const handler = vi.fn()
672
- const unsubscribe = subscribeDocumentEvents(instance, handler)
843
+ const unsubscribe = subscribeDocumentEvents(instance, {source, eventHandler: handler})
673
844
 
674
- const documentId = crypto.randomUUID()
845
+ const documentId = DocumentId(crypto.randomUUID())
675
846
  const doc = createDocumentHandle({documentId, documentType: 'article'})
676
847
  expect(handler).toHaveBeenCalledTimes(0)
677
848
 
@@ -681,6 +852,7 @@ it('emits an event for each action after an outgoing transaction has been accept
681
852
  editDocument(doc, {set: {title: 'new name'}}),
682
853
  publishDocument(doc),
683
854
  ],
855
+ source,
684
856
  }).then((e) => e.submitted())
685
857
  expect(handler).toHaveBeenCalledTimes(4)
686
858
 
@@ -691,6 +863,7 @@ it('emits an event for each action after an outgoing transaction has been accept
691
863
  editDocument(doc, {set: {title: 'updated name'}}),
692
864
  discardDocument(doc),
693
865
  ],
866
+ source,
694
867
  }).then((e) => e.submitted())
695
868
  expect(handler).toHaveBeenCalledTimes(9)
696
869
 
@@ -706,11 +879,127 @@ it('emits an event for each action after an outgoing transaction has been accept
706
879
  [{type: 'accepted', outgoing: {transactionId: tnx2.transactionId}}],
707
880
  ])
708
881
 
709
- await applyDocumentActions(instance, {actions: [deleteDocument(doc)]})
882
+ await applyDocumentActions(instance, {actions: [deleteDocument(doc)], source})
710
883
 
711
884
  unsubscribe()
712
885
  })
713
886
 
887
+ it('creates and edits a version document with a release perspective', async () => {
888
+ const documentId = DocumentId('doc-release')
889
+ const releaseName = 'test-release'
890
+ const doc = createDocumentHandle({
891
+ documentId,
892
+ documentType: 'article',
893
+ perspective: {releaseName},
894
+ })
895
+ const versionId = `versions.${releaseName}.${documentId}`
896
+
897
+ const documentState = getDocumentState<TestDocument>(instance, doc)
898
+ expect(documentState.getCurrent()).toBeUndefined()
899
+
900
+ const unsubscribe = documentState.subscribe()
901
+
902
+ // Create a version document for the release
903
+ const {appeared} = await applyDocumentActions(instance, {
904
+ actions: [createDocument(doc)],
905
+ })
906
+ expect(appeared).toContain(versionId)
907
+
908
+ let currentDoc = documentState.getCurrent()
909
+ expect(currentDoc?._id).toEqual(versionId)
910
+ expect(currentDoc?._type).toEqual('article')
911
+
912
+ // Edit the version document
913
+ await applyDocumentActions(instance, {
914
+ actions: [editDocument(doc, {set: {title: 'Release Version Title'}})],
915
+ })
916
+ currentDoc = documentState.getCurrent()
917
+ expect(currentDoc?.title).toEqual('Release Version Title')
918
+ expect(currentDoc?._id).toEqual(versionId)
919
+
920
+ unsubscribe()
921
+ })
922
+
923
+ it('creates a version document with initial values and then discards it', async () => {
924
+ const documentId = DocumentId('doc-release-discard')
925
+ const releaseName = 'test-release-discard'
926
+ const doc = createDocumentHandle({
927
+ documentId,
928
+ documentType: 'article',
929
+ perspective: {releaseName},
930
+ })
931
+ const versionId = `versions.${releaseName}.${documentId}`
932
+
933
+ const documentState = getDocumentState<TestDocument>(instance, doc)
934
+ const unsubscribe = documentState.subscribe()
935
+
936
+ // Create a version document with initial values
937
+ await applyDocumentActions(instance, {
938
+ actions: [createDocument(doc, {title: 'Initial Release Title'})],
939
+ })
940
+
941
+ let currentDoc = documentState.getCurrent()
942
+ expect(currentDoc?._id).toEqual(versionId)
943
+ expect(currentDoc?.title).toEqual('Initial Release Title')
944
+
945
+ // Discard the version document
946
+ const {disappeared} = await applyDocumentActions(instance, {
947
+ actions: [discardDocument(doc)],
948
+ })
949
+ expect(disappeared).toContain(versionId)
950
+
951
+ currentDoc = documentState.getCurrent()
952
+ expect(currentDoc).toBeNull()
953
+
954
+ unsubscribe()
955
+ })
956
+
957
+ it('version edits are isolated from draft state', async () => {
958
+ const documentId = DocumentId('doc-version-isolation')
959
+ const releaseName = 'isolation-release'
960
+ const versionDoc = createDocumentHandle({
961
+ documentId,
962
+ documentType: 'article',
963
+ perspective: {releaseName},
964
+ })
965
+ const draftDoc = createDocumentHandle({documentId, documentType: 'article'})
966
+ const versionId = `versions.${releaseName}.${documentId}`
967
+
968
+ const versionState = getDocumentState<TestDocument>(instance, versionDoc)
969
+ const draftState = getDocumentState<TestDocument>(instance, draftDoc)
970
+
971
+ const unsubscribeVersion = versionState.subscribe()
972
+ const unsubscribeDraft = draftState.subscribe()
973
+
974
+ // Create draft and version documents
975
+ await applyDocumentActions(instance, {
976
+ actions: [createDocument(draftDoc)],
977
+ })
978
+ await applyDocumentActions(instance, {
979
+ actions: [editDocument(draftDoc, {set: {title: 'Draft Title'}})],
980
+ })
981
+ await applyDocumentActions(instance, {
982
+ actions: [createDocument(versionDoc, {title: 'Release Title'})],
983
+ })
984
+
985
+ // Version perspective shows the version doc
986
+ expect(versionState.getCurrent()?._id).toEqual(versionId)
987
+ expect(versionState.getCurrent()?.title).toEqual('Release Title')
988
+
989
+ // Draft state shows the draft doc
990
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
991
+
992
+ // Editing the version doc should not affect the draft
993
+ await applyDocumentActions(instance, {
994
+ actions: [editDocument(versionDoc, {set: {title: 'Updated Release Title'}})],
995
+ })
996
+ expect(versionState.getCurrent()?.title).toEqual('Updated Release Title')
997
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
998
+
999
+ unsubscribeVersion()
1000
+ unsubscribeDraft()
1001
+ })
1002
+
714
1003
  vi.mock('../client/clientStore.ts', () => ({
715
1004
  getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
716
1005
  }))
@@ -749,7 +1038,21 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
749
1038
 
750
1039
  let client: SanityClient
751
1040
 
1041
+ /** Mock for `client.observable.mutate` (liveEdit submission path); implementation reset in `beforeEach`. */
1042
+ const mutateSubmission = vi.fn(
1043
+ async (
1044
+ _mutations: Mutation[],
1045
+ _options?: BaseMutationOptions,
1046
+ ): Promise<MultipleMutationResult> => ({
1047
+ transactionId: '',
1048
+ documentIds: [],
1049
+ results: [],
1050
+ }),
1051
+ )
1052
+
752
1053
  beforeEach(() => {
1054
+ mutateSubmission.mockReset()
1055
+
753
1056
  const client$ = (getClientState as () => StateSource<SanityClient>)()
754
1057
  .observable as ReplaySubject<SanityClient>
755
1058
  const sharedListener = (
@@ -760,14 +1063,130 @@ beforeEach(() => {
760
1063
  )()
761
1064
 
762
1065
  let documents: DocumentSet = {
763
- [getDraftId('existing-doc')]: {
764
- _id: getDraftId('existing-doc'),
1066
+ [getDraftId(DocumentId('existing-doc'))]: {
1067
+ _id: getDraftId(DocumentId('existing-doc')),
765
1068
  _createdAt: '2025-02-06T06:43:46.236Z',
766
1069
  _updatedAt: '2025-02-06T06:43:46.236Z',
767
1070
  _rev: 'initial-rev',
768
1071
  _type: 'book',
769
1072
  title: 'existing doc',
770
1073
  },
1074
+ 'live-edit-test-doc': {
1075
+ _id: 'live-edit-test-doc',
1076
+ _type: 'article',
1077
+ _createdAt: '2025-02-06T06:43:46.236Z',
1078
+ _updatedAt: '2025-02-06T06:43:46.236Z',
1079
+ _rev: 'rev-live-initial',
1080
+ title: 'live initial',
1081
+ },
1082
+ }
1083
+
1084
+ const emitDatasetChangeEvents = (
1085
+ prior: DocumentSet,
1086
+ next: DocumentSet,
1087
+ transactionId: string,
1088
+ timestamp: string,
1089
+ ) => {
1090
+ const existingIds = new Set(
1091
+ Object.entries(prior)
1092
+ .filter(([, value]) => !!value)
1093
+ .map(([key]) => key),
1094
+ )
1095
+ const resultingIds = new Set(
1096
+ Object.entries(next)
1097
+ .filter(([, value]) => !!value)
1098
+ .map(([key]) => key),
1099
+ )
1100
+ const allKeys = new Set([...existingIds, ...resultingIds])
1101
+
1102
+ const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1103
+ updated: string[]
1104
+ appeared: string[]
1105
+ disappeared: string[]
1106
+ }>(
1107
+ (acc, id) => {
1108
+ if (existingIds.has(id) && resultingIds.has(id)) {
1109
+ acc.updated.push(id)
1110
+ } else if (!existingIds.has(id) && resultingIds.has(id)) {
1111
+ acc.appeared.push(id)
1112
+ } else if (!resultingIds.has(id) && existingIds.has(id)) {
1113
+ acc.disappeared.push(id)
1114
+ }
1115
+ return acc
1116
+ },
1117
+ {updated: [], appeared: [], disappeared: []},
1118
+ )
1119
+
1120
+ const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1121
+ let transactionCurrentEvent = 0
1122
+
1123
+ const mutationEvents: MutationEvent[] = []
1124
+
1125
+ for (const id of appeared) {
1126
+ transactionCurrentEvent++
1127
+ const nextDoc = next[id]!
1128
+ mutationEvents.push({
1129
+ type: 'mutation',
1130
+ documentId: id,
1131
+ eventId: `${transactionId}#${id}`,
1132
+ identity: 'example-user',
1133
+ mutations: [{create: nextDoc}],
1134
+ timestamp,
1135
+ transactionId,
1136
+ transactionCurrentEvent,
1137
+ transactionTotalEvents,
1138
+ transition: 'appear',
1139
+ visibility: 'query',
1140
+ resultRev: transactionId,
1141
+ })
1142
+ }
1143
+
1144
+ for (const id of updated) {
1145
+ transactionCurrentEvent++
1146
+ const prevDoc = prior[id]!
1147
+ const nextDoc = next[id]!
1148
+
1149
+ mutationEvents.push({
1150
+ type: 'mutation',
1151
+ documentId: id,
1152
+ eventId: `${transactionId}#${id}`,
1153
+ identity: 'example-user',
1154
+ mutations: diffValue(prevDoc, nextDoc).map((patch): Mutation => ({patch: {...patch, id}})),
1155
+ timestamp,
1156
+ transactionCurrentEvent,
1157
+ transactionTotalEvents,
1158
+ transactionId,
1159
+ transition: 'update',
1160
+ visibility: 'query',
1161
+ previousRev: prevDoc._rev,
1162
+ resultRev: transactionId,
1163
+ })
1164
+ }
1165
+
1166
+ for (const id of disappeared) {
1167
+ transactionCurrentEvent++
1168
+ const prevDoc = prior[id]!
1169
+ mutationEvents.push({
1170
+ type: 'mutation',
1171
+ documentId: id,
1172
+ eventId: `${transactionId}#${id}`,
1173
+ identity: 'example-user',
1174
+ mutations: [{delete: {id}}],
1175
+ timestamp,
1176
+ transactionId,
1177
+ transactionCurrentEvent,
1178
+ transactionTotalEvents,
1179
+ transition: 'disappear',
1180
+ visibility: 'query',
1181
+ previousRev: prevDoc._rev,
1182
+ })
1183
+ }
1184
+
1185
+ documents = next
1186
+
1187
+ for (const mutationEvent of mutationEvents) {
1188
+ sharedListener.events.next(mutationEvent)
1189
+ }
771
1190
  }
772
1191
 
773
1192
  vi.mocked(createFetchDocument).mockReturnValue(
@@ -779,6 +1198,33 @@ beforeEach(() => {
779
1198
  ),
780
1199
  )
781
1200
 
1201
+ mutateSubmission.mockImplementation(
1202
+ async (
1203
+ mutations: Mutation[],
1204
+ options: BaseMutationOptions = {},
1205
+ ): Promise<MultipleMutationResult> => {
1206
+ const transactionId = options.transactionId ?? crypto.randomUUID()
1207
+ const timestamp = new Date().toISOString()
1208
+ const prior = {...documents}
1209
+ let next = {...documents}
1210
+ next = processMutations({documents: next, mutations, transactionId, timestamp})
1211
+
1212
+ if (!options.dryRun) {
1213
+ emitDatasetChangeEvents(prior, next, transactionId, timestamp)
1214
+ }
1215
+
1216
+ await new Promise((resolve) => setTimeout(resolve, 0))
1217
+
1218
+ return {
1219
+ transactionId,
1220
+ documentIds: Object.entries(next)
1221
+ .filter(([, v]) => !!v)
1222
+ .map(([k]) => k),
1223
+ results: [],
1224
+ }
1225
+ },
1226
+ )
1227
+
782
1228
  const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t
783
1229
 
784
1230
  const fetch = vi.fn(
@@ -859,8 +1305,8 @@ beforeEach(() => {
859
1305
  continue
860
1306
  }
861
1307
  case 'sanity.action.document.edit': {
862
- const source = next[i.draftId] ?? next[i.publishedId]
863
- if (!source) {
1308
+ const documentSource = (i.draftId && next[i.draftId]) ?? next[i.publishedId]
1309
+ if (!documentSource) {
864
1310
  throw new Error(
865
1311
  `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`,
866
1312
  )
@@ -869,8 +1315,8 @@ beforeEach(() => {
869
1315
  next = processMutations({
870
1316
  documents: next,
871
1317
  mutations: [
872
- {createIfNotExists: {...source, _id: i.draftId}},
873
- {patch: {id: i.draftId, ...i.patch}},
1318
+ {createIfNotExists: {...documentSource, _id: i.draftId!}},
1319
+ {patch: {id: i.draftId!, ...i.patch}},
874
1320
  ],
875
1321
  transactionId,
876
1322
  timestamp,
@@ -951,114 +1397,15 @@ beforeEach(() => {
951
1397
  continue
952
1398
  }
953
1399
  default: {
954
- throw new Error(`Unsupported action for mock backend: ${i.actionType}`)
1400
+ throw new Error(
1401
+ `Unsupported action for mock backend: ${(i as {actionType: string}).actionType}`,
1402
+ )
955
1403
  }
956
1404
  }
957
1405
  }
958
1406
 
959
1407
  if (!dryRun) {
960
- const existingIds = new Set(
961
- Object.entries(documents)
962
- .filter(([, value]) => !!value)
963
- .map(([key]) => key),
964
- )
965
- const resultingIds = new Set(
966
- Object.entries(next)
967
- .filter(([, value]) => !!value)
968
- .map(([key]) => key),
969
- )
970
- const allKeys = new Set([...existingIds, ...resultingIds])
971
-
972
- const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
973
- updated: string[]
974
- appeared: string[]
975
- disappeared: string[]
976
- }>(
977
- (acc, id) => {
978
- if (existingIds.has(id) && resultingIds.has(id)) {
979
- acc.updated.push(id)
980
- } else if (!existingIds.has(id) && resultingIds.has(id)) {
981
- acc.appeared.push(id)
982
- } else if (!resultingIds.has(id) && existingIds.has(id)) {
983
- acc.disappeared.push(id)
984
- }
985
- return acc
986
- },
987
- {updated: [], appeared: [], disappeared: []},
988
- )
989
-
990
- const transactionTotalEvents = appeared.length + disappeared.length + updated.length
991
- let transactionCurrentEvent = 0
992
-
993
- const mutationEvents: MutationEvent[] = []
994
-
995
- for (const id of appeared) {
996
- transactionCurrentEvent++ // index seems to start at 1
997
- const nextDoc = next[id]!
998
- mutationEvents.push({
999
- type: 'mutation',
1000
- documentId: id,
1001
- eventId: `${transactionId}#${id}`,
1002
- identity: 'example-user',
1003
- mutations: [{create: nextDoc}],
1004
- timestamp,
1005
- transactionId,
1006
- transactionCurrentEvent,
1007
- transactionTotalEvents,
1008
- transition: 'appear',
1009
- visibility: 'query',
1010
- resultRev: transactionId,
1011
- })
1012
- }
1013
-
1014
- for (const id of updated) {
1015
- transactionCurrentEvent++
1016
- const prevDoc = documents[id]!
1017
- const nextDoc = next[id]!
1018
-
1019
- mutationEvents.push({
1020
- type: 'mutation',
1021
- documentId: id,
1022
- eventId: `${transactionId}#${id}`,
1023
- identity: 'example-user',
1024
- mutations: diffValue(prevDoc, nextDoc).map(
1025
- (patch): Mutation => ({patch: {...patch, id}}),
1026
- ),
1027
- timestamp,
1028
- transactionCurrentEvent,
1029
- transactionTotalEvents,
1030
- transactionId,
1031
- transition: 'update',
1032
- visibility: 'query',
1033
- previousRev: prevDoc._rev,
1034
- resultRev: transactionId,
1035
- })
1036
- }
1037
-
1038
- for (const id of disappeared) {
1039
- transactionCurrentEvent++
1040
- const prevDoc = documents[id]!
1041
- mutationEvents.push({
1042
- type: 'mutation',
1043
- documentId: id,
1044
- eventId: `${transactionId}#${id}`,
1045
- identity: 'example-user',
1046
- mutations: [{delete: {id}}],
1047
- timestamp,
1048
- transactionId,
1049
- transactionCurrentEvent,
1050
- transactionTotalEvents,
1051
- transition: 'disappear',
1052
- visibility: 'query',
1053
- previousRev: prevDoc._rev,
1054
- })
1055
- }
1056
-
1057
- documents = next
1058
-
1059
- for (const mutationEvent of mutationEvents) {
1060
- sharedListener.events.next(mutationEvent)
1061
- }
1408
+ emitDatasetChangeEvents(documents, next, transactionId, timestamp)
1062
1409
  }
1063
1410
 
1064
1411
  // add a tick for realism
@@ -1085,6 +1432,7 @@ beforeEach(() => {
1085
1432
  action: (...args: Parameters<typeof action>) => from(action(...args)),
1086
1433
  fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
1087
1434
  request: (...args: Parameters<typeof request>) => from(request(...args)),
1435
+ mutate: (...args: Parameters<typeof mutateSubmission>) => from(mutateSubmission(...args)),
1088
1436
  },
1089
1437
  } as SanityClient
1090
1438
  client$.next(client)