@sanity/sdk 2.8.0 → 2.10.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 (111) hide show
  1. package/dist/_chunks-dts/utils.d.ts +2450 -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 +1537 -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 +465 -1813
  15. package/dist/index.js.map +1 -1
  16. package/package.json +17 -12
  17. package/src/_exports/_internal.ts +14 -0
  18. package/src/_exports/index.ts +18 -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 +44 -30
  35. package/src/client/clientStore.ts +49 -48
  36. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  37. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  38. package/src/comlink/node/getNodeState.ts +2 -1
  39. package/src/config/sanityConfig.ts +78 -12
  40. package/src/document/actions.ts +18 -11
  41. package/src/document/applyDocumentActions.test.ts +7 -6
  42. package/src/document/applyDocumentActions.ts +10 -4
  43. package/src/document/documentStore.test.ts +542 -188
  44. package/src/document/documentStore.ts +142 -76
  45. package/src/document/events.ts +7 -2
  46. package/src/document/permissions.test.ts +18 -16
  47. package/src/document/permissions.ts +35 -11
  48. package/src/document/processActions.test.ts +359 -32
  49. package/src/document/processActions.ts +106 -78
  50. package/src/document/reducers.test.ts +117 -29
  51. package/src/document/reducers.ts +47 -40
  52. package/src/document/sharedListener.ts +16 -6
  53. package/src/document/util.ts +14 -0
  54. package/src/favorites/favorites.test.ts +9 -2
  55. package/src/presence/bifurTransport.test.ts +46 -6
  56. package/src/presence/bifurTransport.ts +19 -2
  57. package/src/presence/presenceStore.test.ts +96 -0
  58. package/src/presence/presenceStore.ts +96 -24
  59. package/src/preview/getPreviewState.test.ts +115 -98
  60. package/src/preview/getPreviewState.ts +38 -60
  61. package/src/preview/previewProjectionUtils.test.ts +179 -0
  62. package/src/preview/previewProjectionUtils.ts +93 -0
  63. package/src/preview/resolvePreview.test.ts +42 -25
  64. package/src/preview/resolvePreview.ts +33 -10
  65. package/src/preview/{previewStore.ts → types.ts} +8 -17
  66. package/src/projection/getProjectionState.test.ts +16 -16
  67. package/src/projection/getProjectionState.ts +6 -5
  68. package/src/projection/projectionQuery.ts +2 -3
  69. package/src/projection/projectionStore.test.ts +2 -2
  70. package/src/projection/resolveProjection.ts +2 -2
  71. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  72. package/src/projection/subscribeToStateAndFetchBatches.ts +12 -11
  73. package/src/projection/types.ts +1 -1
  74. package/src/query/queryStore.test.ts +12 -12
  75. package/src/query/queryStore.ts +12 -11
  76. package/src/query/reducers.ts +3 -3
  77. package/src/releases/getPerspectiveState.ts +7 -6
  78. package/src/releases/releasesStore.test.ts +20 -5
  79. package/src/releases/releasesStore.ts +20 -8
  80. package/src/store/createActionBinder.test.ts +31 -31
  81. package/src/store/createActionBinder.ts +43 -38
  82. package/src/store/createSanityInstance.ts +2 -3
  83. package/src/store/createStateSourceAction.test.ts +62 -0
  84. package/src/store/createStateSourceAction.ts +34 -39
  85. package/src/telemetry/__telemetry__/sdk.telemetry.ts +42 -0
  86. package/src/telemetry/devMode.test.ts +52 -0
  87. package/src/telemetry/devMode.ts +40 -0
  88. package/src/telemetry/initTelemetry.test.ts +225 -0
  89. package/src/telemetry/initTelemetry.ts +205 -0
  90. package/src/telemetry/telemetryManager.test.ts +263 -0
  91. package/src/telemetry/telemetryManager.ts +187 -0
  92. package/src/users/reducers.ts +3 -4
  93. package/src/users/usersStore.test.ts +1 -0
  94. package/src/users/usersStore.ts +5 -1
  95. package/src/utils/createFetcherStore.test.ts +6 -4
  96. package/src/utils/createFetcherStore.ts +8 -5
  97. package/src/utils/getStagingApiHost.test.ts +21 -0
  98. package/src/utils/getStagingApiHost.ts +14 -0
  99. package/src/utils/ids.test.ts +1 -29
  100. package/src/utils/ids.ts +0 -10
  101. package/src/utils/isImportError.test.ts +72 -0
  102. package/src/utils/isImportError.ts +34 -0
  103. package/src/utils/object.test.ts +95 -0
  104. package/src/utils/object.ts +142 -0
  105. package/src/utils/setCleanupTimeout.ts +24 -0
  106. package/src/preview/previewQuery.test.ts +0 -236
  107. package/src/preview/previewQuery.ts +0 -153
  108. package/src/preview/previewStore.test.ts +0 -36
  109. package/src/preview/subscribeToStateAndFetchBatches.test.ts +0 -221
  110. package/src/preview/subscribeToStateAndFetchBatches.ts +0 -112
  111. 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 resource = {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
+ resource,
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
+ resource,
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
+ resource,
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
+ resource,
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
+ resource,
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)], resource: 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
+ resource: 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
+ resource: 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)], resource: 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
+ resource: 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
+ resource,
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
+ resource: 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
+ resource: 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)], resource})
335
+ const afterPublish = await applyDocumentActions(instance, {
336
+ actions: [publishDocument(doc)],
337
+ resource,
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)], resource})
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)], resource})
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
+ resource,
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)], resource})
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)], resource})
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
+ resource,
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
+ resource,
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
+ resource,
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
+ resource,
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
+ resource,
403
452
  })
404
453
  // now we'll have to await again
405
454
  expect(getCurrent()?.title).toBe(undefined)
@@ -414,14 +463,18 @@ 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)], resource})
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!'}})], resource})
470
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'name!!'}})], resource})
471
+ applyDocumentActions(instance, {
472
+ actions: [editDocument(doc, {set: {title: 'name!!!'}})],
473
+ resource,
474
+ })
423
475
  const res = await applyDocumentActions(instance, {
424
476
  actions: [editDocument(doc, {set: {title: 'name!!!!'}})],
477
+ resource,
425
478
  })
426
479
  await res.submitted()
427
480
 
@@ -434,6 +487,52 @@ it('batches edit transaction into one outgoing transaction', async () => {
434
487
  unsubscribe()
435
488
  })
436
489
 
490
+ it('submits liveEdit document edits through observable.mutate', async () => {
491
+ const liveDoc = createDocumentHandle({
492
+ documentId: 'live-edit-test-doc',
493
+ documentType: 'article',
494
+ liveEdit: true,
495
+ })
496
+ const state = getDocumentState(instance, liveDoc)
497
+ const unsubscribe = state.subscribe()
498
+
499
+ await firstValueFrom(state.observable.pipe(first((doc) => doc !== undefined)))
500
+ expect(state.getCurrent()).toMatchObject({
501
+ _id: 'live-edit-test-doc',
502
+ title: 'live initial',
503
+ })
504
+
505
+ const callCountBefore = mutateSubmission.mock.calls.length
506
+
507
+ const result = await applyDocumentActions(instance, {
508
+ actions: [editDocument(liveDoc, {set: {title: 'patched via mutate'}})],
509
+ resource,
510
+ })
511
+ await result.submitted()
512
+
513
+ expect(mutateSubmission.mock.calls.length).toBe(callCountBefore + 1)
514
+ const [mutationList, mutateOptions] = mutateSubmission.mock.calls[callCountBefore]!
515
+ expect(mutateOptions).toMatchObject({
516
+ transactionId: result.transactionId,
517
+ visibility: 'async',
518
+ returnDocuments: false,
519
+ returnFirst: false,
520
+ tag: 'document.mutate',
521
+ skipCrossDatasetReferenceValidation: true,
522
+ })
523
+ expect(mutationList.length).toBeGreaterThan(0)
524
+ expect(
525
+ mutationList.every(
526
+ (m) => 'patch' in m && (m as {patch: {id: string}}).patch.id === liveDoc.documentId,
527
+ ),
528
+ ).toBe(true)
529
+
530
+ expect(state.getCurrent()?.title).toBe('patched via mutate')
531
+ expect(client.action).not.toHaveBeenCalled()
532
+
533
+ unsubscribe()
534
+ })
535
+
437
536
  it('provides the consistency status via `getDocumentSyncStatus`', async () => {
438
537
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
439
538
 
@@ -443,7 +542,7 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
443
542
  const unsubscribe = syncStatus.subscribe()
444
543
  expect(syncStatus.getCurrent()).toBe(true)
445
544
 
446
- const applied = applyDocumentActions(instance, {actions: [createDocument(doc)]})
545
+ const applied = applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
447
546
  expect(syncStatus.getCurrent()).toBe(false)
448
547
 
449
548
  const createResult = await applied
@@ -452,11 +551,17 @@ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
452
551
  await createResult.submitted()
453
552
  expect(syncStatus.getCurrent()).toBe(true)
454
553
 
455
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'initial name'}})]})
554
+ applyDocumentActions(instance, {
555
+ actions: [editDocument(doc, {set: {title: 'initial name'}})],
556
+ resource,
557
+ })
456
558
  expect(syncStatus.getCurrent()).toBe(false)
457
559
 
458
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'updated name'}})]})
459
- const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)]})
560
+ applyDocumentActions(instance, {
561
+ actions: [editDocument(doc, {set: {title: 'updated name'}})],
562
+ resource,
563
+ })
564
+ const publishResult = applyDocumentActions(instance, {actions: [publishDocument(doc)], resource})
460
565
  expect(syncStatus.getCurrent()).toBe(false)
461
566
  await publishResult.then((res) => res.submitted())
462
567
  expect(syncStatus.getCurrent()).toBe(true)
@@ -473,35 +578,45 @@ it('reverts failed outgoing transaction locally', async () => {
473
578
  })
474
579
 
475
580
  const revertedEventPromise = new Promise<TransactionRevertedEvent>((resolve) => {
476
- const unsubscribe = subscribeDocumentEvents(instance, (e) => {
477
- if (e.type === 'reverted') {
478
- resolve(e)
479
- unsubscribe()
480
- }
581
+ const unsubscribe = subscribeDocumentEvents(instance, {
582
+ resource,
583
+ eventHandler: (e) => {
584
+ if (e.type === 'reverted') {
585
+ resolve(e)
586
+ unsubscribe()
587
+ }
588
+ },
481
589
  })
482
590
  })
483
591
 
484
- const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
592
+ const documentId = DocumentId(crypto.randomUUID())
593
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
485
594
 
486
595
  const {getCurrent, subscribe} = getDocumentState(instance, doc)
487
596
  const unsubscribe = subscribe()
488
597
 
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'}})]})
598
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
599
+ applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: 'the'}})], resource})
600
+ applyDocumentActions(instance, {
601
+ actions: [editDocument(doc, {set: {title: 'the quick'}})],
602
+ resource,
603
+ })
492
604
 
493
605
  // this edit action is simulated to fail from the backend and will be reverted
494
606
  const revertedActionResult = applyDocumentActions(instance, {
495
607
  actions: [editDocument(doc, {set: {title: 'the quick brown'}})],
496
608
  transactionId: 'force-revert',
497
609
  disableBatching: true,
610
+ resource,
498
611
  })
499
612
 
500
613
  applyDocumentActions(instance, {
501
614
  actions: [editDocument(doc, {set: {title: 'the quick brown fox'}})],
615
+ resource,
502
616
  })
503
617
  await applyDocumentActions(instance, {
504
618
  actions: [editDocument(doc, {set: {title: 'the quick brown fox jumps'}})],
619
+ resource,
505
620
  }).then((e) => e.submitted())
506
621
 
507
622
  await expect(revertedEventPromise).resolves.toMatchObject({
@@ -521,6 +636,7 @@ it('reverts failed outgoing transaction locally', async () => {
521
636
  // check that we can still edit after recovering from the error
522
637
  applyDocumentActions(instance, {
523
638
  actions: [editDocument(doc, {set: {title: 'TEST the quick fox jumps'}})],
639
+ resource,
524
640
  })
525
641
  expect(getCurrent()?.title).toBe('TEST the quick fox jumps')
526
642
 
@@ -530,11 +646,14 @@ it('reverts failed outgoing transaction locally', async () => {
530
646
 
531
647
  it('removes a queued transaction if it fails to apply', async () => {
532
648
  const actionErrorEventPromise = new Promise<ActionErrorEvent>((resolve) => {
533
- const unsubscribe = subscribeDocumentEvents(instance, (e) => {
534
- if (e.type === 'error') {
535
- resolve(e)
536
- unsubscribe()
537
- }
649
+ const unsubscribe = subscribeDocumentEvents(instance, {
650
+ resource,
651
+ eventHandler: (e) => {
652
+ if (e.type === 'error') {
653
+ resolve(e)
654
+ unsubscribe()
655
+ }
656
+ },
538
657
  })
539
658
  })
540
659
 
@@ -543,7 +662,10 @@ it('removes a queued transaction if it fails to apply', async () => {
543
662
  const unsubscribe = state.subscribe()
544
663
 
545
664
  await expect(
546
- applyDocumentActions(instance, {actions: [editDocument(doc, {set: {title: "can't set"}})]}),
665
+ applyDocumentActions(instance, {
666
+ actions: [editDocument(doc, {set: {title: "can't set"}})],
667
+ resource,
668
+ }),
547
669
  ).rejects.toThrowError(/Cannot edit document/)
548
670
 
549
671
  await expect(actionErrorEventPromise).resolves.toMatchObject({
@@ -553,8 +675,11 @@ it('removes a queued transaction if it fails to apply', async () => {
553
675
  })
554
676
 
555
677
  // 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!'}})]})
678
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
679
+ applyDocumentActions(instance, {
680
+ actions: [editDocument(doc, {set: {title: 'can set!'}})],
681
+ resource,
682
+ })
558
683
 
559
684
  expect(state.getCurrent()?.title).toBe('can set!')
560
685
 
@@ -574,7 +699,9 @@ it('returns allowed true when no permission errors occur', async () => {
574
699
  })
575
700
  const state = getDocumentState(instance, doc)
576
701
  const unsubscribe = state.subscribe()
577
- await applyDocumentActions(instance, {actions: [createDocument(doc)]}).then((r) => r.submitted())
702
+ await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
703
+ r.submitted(),
704
+ )
578
705
 
579
706
  // Use an action that includes a patch (so that update permission check is bypassed).
580
707
  const permissionsState = getPermissionsState(instance, {
@@ -596,9 +723,9 @@ it('returns allowed true when no permission errors occur', async () => {
596
723
  it("should reject applying the action if a precondition isn't met", async () => {
597
724
  const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
598
725
 
599
- await expect(applyDocumentActions(instance, {actions: [deleteDocument(doc)]})).rejects.toThrow(
600
- 'The document you are trying to delete does not exist.',
601
- )
726
+ await expect(
727
+ applyDocumentActions(instance, {actions: [deleteDocument(doc)], resource}),
728
+ ).rejects.toThrow('The document you are trying to delete does not exist.')
602
729
  })
603
730
 
604
731
  it("should reject applying the action if a permission isn't met", async () => {
@@ -607,9 +734,9 @@ it("should reject applying the action if a permission isn't met", async () => {
607
734
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
608
735
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
609
736
 
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
- )
737
+ await expect(
738
+ applyDocumentActions(instance, {actions: [createDocument(doc)], resource}),
739
+ ).rejects.toThrow('You do not have permission to create a draft for document "does-not-exist".')
613
740
  })
614
741
 
615
742
  it('returns allowed false with reasons when permission errors occur', async () => {
@@ -647,8 +774,57 @@ it('fetches dataset ACL and updates grants in the document store state', async (
647
774
  })
648
775
  })
649
776
 
650
- it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
777
+ it('fetches ACL for MediaLibraryResource', async () => {
778
+ const mediaLibraryInstance = createSanityInstance({
779
+ projectId: 'p',
780
+ dataset: 'd',
781
+ resources: {
782
+ 'media-library': {mediaLibraryId: 'test-media-library'},
783
+ },
784
+ })
785
+
786
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
787
+ vi.mocked(client.request).mockResolvedValue(datasetAcl)
788
+
789
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
790
+ const mediaLibrarySource = {mediaLibraryId: 'test-media-library'}
791
+
792
+ const result = await resolvePermissions(mediaLibraryInstance, {
793
+ actions: [createDocument(doc)],
794
+ resource: mediaLibrarySource,
795
+ })
796
+
797
+ expect(result).toEqual({allowed: true})
798
+ mediaLibraryInstance.dispose()
799
+ })
800
+
801
+ it('fetches ACL for CanvasResource', async () => {
802
+ const canvasInstance = createSanityInstance({
803
+ projectId: 'p',
804
+ dataset: 'd',
805
+ resources: {
806
+ canvas: {canvasId: 'test-canvas'},
807
+ },
808
+ })
809
+
810
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
811
+ vi.mocked(client.request).mockResolvedValue(datasetAcl)
812
+
651
813
  const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
814
+ const canvasSource = {canvasId: 'test-canvas'}
815
+
816
+ const result = await resolvePermissions(canvasInstance, {
817
+ actions: [createDocument(doc)],
818
+ resource: canvasSource,
819
+ })
820
+
821
+ expect(result).toEqual({allowed: true})
822
+ canvasInstance.dispose()
823
+ })
824
+
825
+ it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
826
+ const documentId = DocumentId(crypto.randomUUID())
827
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
652
828
 
653
829
  expect(await resolveDocument(instance, doc)).toBe(null)
654
830
 
@@ -656,11 +832,12 @@ it('returns a promise that resolves when a document has been loaded in the store
656
832
  const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
657
833
  const result = await applyDocumentActions(oneOffInstance, {
658
834
  actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})],
835
+ resource,
659
836
  })
660
837
  await result.submitted() // wait till submitted to server before resolving
661
838
 
662
839
  await expect(resolveDocument(instance, doc)).resolves.toMatchObject({
663
- _id: getDraftId(doc.documentId),
840
+ _id: getDraftId(documentId),
664
841
  _type: 'article',
665
842
  title: 'initial title',
666
843
  })
@@ -669,9 +846,9 @@ it('returns a promise that resolves when a document has been loaded in the store
669
846
 
670
847
  it('emits an event for each action after an outgoing transaction has been accepted', async () => {
671
848
  const handler = vi.fn()
672
- const unsubscribe = subscribeDocumentEvents(instance, handler)
849
+ const unsubscribe = subscribeDocumentEvents(instance, {resource, eventHandler: handler})
673
850
 
674
- const documentId = crypto.randomUUID()
851
+ const documentId = DocumentId(crypto.randomUUID())
675
852
  const doc = createDocumentHandle({documentId, documentType: 'article'})
676
853
  expect(handler).toHaveBeenCalledTimes(0)
677
854
 
@@ -681,6 +858,7 @@ it('emits an event for each action after an outgoing transaction has been accept
681
858
  editDocument(doc, {set: {title: 'new name'}}),
682
859
  publishDocument(doc),
683
860
  ],
861
+ resource,
684
862
  }).then((e) => e.submitted())
685
863
  expect(handler).toHaveBeenCalledTimes(4)
686
864
 
@@ -691,6 +869,7 @@ it('emits an event for each action after an outgoing transaction has been accept
691
869
  editDocument(doc, {set: {title: 'updated name'}}),
692
870
  discardDocument(doc),
693
871
  ],
872
+ resource,
694
873
  }).then((e) => e.submitted())
695
874
  expect(handler).toHaveBeenCalledTimes(9)
696
875
 
@@ -706,11 +885,127 @@ it('emits an event for each action after an outgoing transaction has been accept
706
885
  [{type: 'accepted', outgoing: {transactionId: tnx2.transactionId}}],
707
886
  ])
708
887
 
709
- await applyDocumentActions(instance, {actions: [deleteDocument(doc)]})
888
+ await applyDocumentActions(instance, {actions: [deleteDocument(doc)], resource})
889
+
890
+ unsubscribe()
891
+ })
892
+
893
+ it('creates and edits a version document with a release perspective', async () => {
894
+ const documentId = DocumentId('doc-release')
895
+ const releaseName = 'test-release'
896
+ const doc = createDocumentHandle({
897
+ documentId,
898
+ documentType: 'article',
899
+ perspective: {releaseName},
900
+ })
901
+ const versionId = `versions.${releaseName}.${documentId}`
902
+
903
+ const documentState = getDocumentState<TestDocument>(instance, doc)
904
+ expect(documentState.getCurrent()).toBeUndefined()
905
+
906
+ const unsubscribe = documentState.subscribe()
907
+
908
+ // Create a version document for the release
909
+ const {appeared} = await applyDocumentActions(instance, {
910
+ actions: [createDocument(doc)],
911
+ })
912
+ expect(appeared).toContain(versionId)
913
+
914
+ let currentDoc = documentState.getCurrent()
915
+ expect(currentDoc?._id).toEqual(versionId)
916
+ expect(currentDoc?._type).toEqual('article')
917
+
918
+ // Edit the version document
919
+ await applyDocumentActions(instance, {
920
+ actions: [editDocument(doc, {set: {title: 'Release Version Title'}})],
921
+ })
922
+ currentDoc = documentState.getCurrent()
923
+ expect(currentDoc?.title).toEqual('Release Version Title')
924
+ expect(currentDoc?._id).toEqual(versionId)
925
+
926
+ unsubscribe()
927
+ })
928
+
929
+ it('creates a version document with initial values and then discards it', async () => {
930
+ const documentId = DocumentId('doc-release-discard')
931
+ const releaseName = 'test-release-discard'
932
+ const doc = createDocumentHandle({
933
+ documentId,
934
+ documentType: 'article',
935
+ perspective: {releaseName},
936
+ })
937
+ const versionId = `versions.${releaseName}.${documentId}`
938
+
939
+ const documentState = getDocumentState<TestDocument>(instance, doc)
940
+ const unsubscribe = documentState.subscribe()
941
+
942
+ // Create a version document with initial values
943
+ await applyDocumentActions(instance, {
944
+ actions: [createDocument(doc, {title: 'Initial Release Title'})],
945
+ })
946
+
947
+ let currentDoc = documentState.getCurrent()
948
+ expect(currentDoc?._id).toEqual(versionId)
949
+ expect(currentDoc?.title).toEqual('Initial Release Title')
950
+
951
+ // Discard the version document
952
+ const {disappeared} = await applyDocumentActions(instance, {
953
+ actions: [discardDocument(doc)],
954
+ })
955
+ expect(disappeared).toContain(versionId)
956
+
957
+ currentDoc = documentState.getCurrent()
958
+ expect(currentDoc).toBeNull()
710
959
 
711
960
  unsubscribe()
712
961
  })
713
962
 
963
+ it('version edits are isolated from draft state', async () => {
964
+ const documentId = DocumentId('doc-version-isolation')
965
+ const releaseName = 'isolation-release'
966
+ const versionDoc = createDocumentHandle({
967
+ documentId,
968
+ documentType: 'article',
969
+ perspective: {releaseName},
970
+ })
971
+ const draftDoc = createDocumentHandle({documentId, documentType: 'article'})
972
+ const versionId = `versions.${releaseName}.${documentId}`
973
+
974
+ const versionState = getDocumentState<TestDocument>(instance, versionDoc)
975
+ const draftState = getDocumentState<TestDocument>(instance, draftDoc)
976
+
977
+ const unsubscribeVersion = versionState.subscribe()
978
+ const unsubscribeDraft = draftState.subscribe()
979
+
980
+ // Create draft and version documents
981
+ await applyDocumentActions(instance, {
982
+ actions: [createDocument(draftDoc)],
983
+ })
984
+ await applyDocumentActions(instance, {
985
+ actions: [editDocument(draftDoc, {set: {title: 'Draft Title'}})],
986
+ })
987
+ await applyDocumentActions(instance, {
988
+ actions: [createDocument(versionDoc, {title: 'Release Title'})],
989
+ })
990
+
991
+ // Version perspective shows the version doc
992
+ expect(versionState.getCurrent()?._id).toEqual(versionId)
993
+ expect(versionState.getCurrent()?.title).toEqual('Release Title')
994
+
995
+ // Draft state shows the draft doc
996
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
997
+
998
+ // Editing the version doc should not affect the draft
999
+ await applyDocumentActions(instance, {
1000
+ actions: [editDocument(versionDoc, {set: {title: 'Updated Release Title'}})],
1001
+ })
1002
+ expect(versionState.getCurrent()?.title).toEqual('Updated Release Title')
1003
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
1004
+
1005
+ unsubscribeVersion()
1006
+ unsubscribeDraft()
1007
+ })
1008
+
714
1009
  vi.mock('../client/clientStore.ts', () => ({
715
1010
  getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
716
1011
  }))
@@ -749,7 +1044,21 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
749
1044
 
750
1045
  let client: SanityClient
751
1046
 
1047
+ /** Mock for `client.observable.mutate` (liveEdit submission path); implementation reset in `beforeEach`. */
1048
+ const mutateSubmission = vi.fn(
1049
+ async (
1050
+ _mutations: Mutation[],
1051
+ _options?: BaseMutationOptions,
1052
+ ): Promise<MultipleMutationResult> => ({
1053
+ transactionId: '',
1054
+ documentIds: [],
1055
+ results: [],
1056
+ }),
1057
+ )
1058
+
752
1059
  beforeEach(() => {
1060
+ mutateSubmission.mockReset()
1061
+
753
1062
  const client$ = (getClientState as () => StateSource<SanityClient>)()
754
1063
  .observable as ReplaySubject<SanityClient>
755
1064
  const sharedListener = (
@@ -760,14 +1069,130 @@ beforeEach(() => {
760
1069
  )()
761
1070
 
762
1071
  let documents: DocumentSet = {
763
- [getDraftId('existing-doc')]: {
764
- _id: getDraftId('existing-doc'),
1072
+ [getDraftId(DocumentId('existing-doc'))]: {
1073
+ _id: getDraftId(DocumentId('existing-doc')),
765
1074
  _createdAt: '2025-02-06T06:43:46.236Z',
766
1075
  _updatedAt: '2025-02-06T06:43:46.236Z',
767
1076
  _rev: 'initial-rev',
768
1077
  _type: 'book',
769
1078
  title: 'existing doc',
770
1079
  },
1080
+ 'live-edit-test-doc': {
1081
+ _id: 'live-edit-test-doc',
1082
+ _type: 'article',
1083
+ _createdAt: '2025-02-06T06:43:46.236Z',
1084
+ _updatedAt: '2025-02-06T06:43:46.236Z',
1085
+ _rev: 'rev-live-initial',
1086
+ title: 'live initial',
1087
+ },
1088
+ }
1089
+
1090
+ const emitDatasetChangeEvents = (
1091
+ prior: DocumentSet,
1092
+ next: DocumentSet,
1093
+ transactionId: string,
1094
+ timestamp: string,
1095
+ ) => {
1096
+ const existingIds = new Set(
1097
+ Object.entries(prior)
1098
+ .filter(([, value]) => !!value)
1099
+ .map(([key]) => key),
1100
+ )
1101
+ const resultingIds = new Set(
1102
+ Object.entries(next)
1103
+ .filter(([, value]) => !!value)
1104
+ .map(([key]) => key),
1105
+ )
1106
+ const allKeys = new Set([...existingIds, ...resultingIds])
1107
+
1108
+ const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1109
+ updated: string[]
1110
+ appeared: string[]
1111
+ disappeared: string[]
1112
+ }>(
1113
+ (acc, id) => {
1114
+ if (existingIds.has(id) && resultingIds.has(id)) {
1115
+ acc.updated.push(id)
1116
+ } else if (!existingIds.has(id) && resultingIds.has(id)) {
1117
+ acc.appeared.push(id)
1118
+ } else if (!resultingIds.has(id) && existingIds.has(id)) {
1119
+ acc.disappeared.push(id)
1120
+ }
1121
+ return acc
1122
+ },
1123
+ {updated: [], appeared: [], disappeared: []},
1124
+ )
1125
+
1126
+ const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1127
+ let transactionCurrentEvent = 0
1128
+
1129
+ const mutationEvents: MutationEvent[] = []
1130
+
1131
+ for (const id of appeared) {
1132
+ transactionCurrentEvent++
1133
+ const nextDoc = next[id]!
1134
+ mutationEvents.push({
1135
+ type: 'mutation',
1136
+ documentId: id,
1137
+ eventId: `${transactionId}#${id}`,
1138
+ identity: 'example-user',
1139
+ mutations: [{create: nextDoc}],
1140
+ timestamp,
1141
+ transactionId,
1142
+ transactionCurrentEvent,
1143
+ transactionTotalEvents,
1144
+ transition: 'appear',
1145
+ visibility: 'query',
1146
+ resultRev: transactionId,
1147
+ })
1148
+ }
1149
+
1150
+ for (const id of updated) {
1151
+ transactionCurrentEvent++
1152
+ const prevDoc = prior[id]!
1153
+ const nextDoc = next[id]!
1154
+
1155
+ mutationEvents.push({
1156
+ type: 'mutation',
1157
+ documentId: id,
1158
+ eventId: `${transactionId}#${id}`,
1159
+ identity: 'example-user',
1160
+ mutations: diffValue(prevDoc, nextDoc).map((patch): Mutation => ({patch: {...patch, id}})),
1161
+ timestamp,
1162
+ transactionCurrentEvent,
1163
+ transactionTotalEvents,
1164
+ transactionId,
1165
+ transition: 'update',
1166
+ visibility: 'query',
1167
+ previousRev: prevDoc._rev,
1168
+ resultRev: transactionId,
1169
+ })
1170
+ }
1171
+
1172
+ for (const id of disappeared) {
1173
+ transactionCurrentEvent++
1174
+ const prevDoc = prior[id]!
1175
+ mutationEvents.push({
1176
+ type: 'mutation',
1177
+ documentId: id,
1178
+ eventId: `${transactionId}#${id}`,
1179
+ identity: 'example-user',
1180
+ mutations: [{delete: {id}}],
1181
+ timestamp,
1182
+ transactionId,
1183
+ transactionCurrentEvent,
1184
+ transactionTotalEvents,
1185
+ transition: 'disappear',
1186
+ visibility: 'query',
1187
+ previousRev: prevDoc._rev,
1188
+ })
1189
+ }
1190
+
1191
+ documents = next
1192
+
1193
+ for (const mutationEvent of mutationEvents) {
1194
+ sharedListener.events.next(mutationEvent)
1195
+ }
771
1196
  }
772
1197
 
773
1198
  vi.mocked(createFetchDocument).mockReturnValue(
@@ -779,6 +1204,33 @@ beforeEach(() => {
779
1204
  ),
780
1205
  )
781
1206
 
1207
+ mutateSubmission.mockImplementation(
1208
+ async (
1209
+ mutations: Mutation[],
1210
+ options: BaseMutationOptions = {},
1211
+ ): Promise<MultipleMutationResult> => {
1212
+ const transactionId = options.transactionId ?? crypto.randomUUID()
1213
+ const timestamp = new Date().toISOString()
1214
+ const prior = {...documents}
1215
+ let next = {...documents}
1216
+ next = processMutations({documents: next, mutations, transactionId, timestamp})
1217
+
1218
+ if (!options.dryRun) {
1219
+ emitDatasetChangeEvents(prior, next, transactionId, timestamp)
1220
+ }
1221
+
1222
+ await new Promise((resolve) => setTimeout(resolve, 0))
1223
+
1224
+ return {
1225
+ transactionId,
1226
+ documentIds: Object.entries(next)
1227
+ .filter(([, v]) => !!v)
1228
+ .map(([k]) => k),
1229
+ results: [],
1230
+ }
1231
+ },
1232
+ )
1233
+
782
1234
  const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t
783
1235
 
784
1236
  const fetch = vi.fn(
@@ -859,8 +1311,8 @@ beforeEach(() => {
859
1311
  continue
860
1312
  }
861
1313
  case 'sanity.action.document.edit': {
862
- const source = next[i.draftId] ?? next[i.publishedId]
863
- if (!source) {
1314
+ const documentSource = (i.draftId && next[i.draftId]) ?? next[i.publishedId]
1315
+ if (!documentSource) {
864
1316
  throw new Error(
865
1317
  `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`,
866
1318
  )
@@ -869,8 +1321,8 @@ beforeEach(() => {
869
1321
  next = processMutations({
870
1322
  documents: next,
871
1323
  mutations: [
872
- {createIfNotExists: {...source, _id: i.draftId}},
873
- {patch: {id: i.draftId, ...i.patch}},
1324
+ {createIfNotExists: {...documentSource, _id: i.draftId!}},
1325
+ {patch: {id: i.draftId!, ...i.patch}},
874
1326
  ],
875
1327
  transactionId,
876
1328
  timestamp,
@@ -951,114 +1403,15 @@ beforeEach(() => {
951
1403
  continue
952
1404
  }
953
1405
  default: {
954
- throw new Error(`Unsupported action for mock backend: ${i.actionType}`)
1406
+ throw new Error(
1407
+ `Unsupported action for mock backend: ${(i as {actionType: string}).actionType}`,
1408
+ )
955
1409
  }
956
1410
  }
957
1411
  }
958
1412
 
959
1413
  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
- }
1414
+ emitDatasetChangeEvents(documents, next, transactionId, timestamp)
1062
1415
  }
1063
1416
 
1064
1417
  // add a tick for realism
@@ -1085,6 +1438,7 @@ beforeEach(() => {
1085
1438
  action: (...args: Parameters<typeof action>) => from(action(...args)),
1086
1439
  fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
1087
1440
  request: (...args: Parameters<typeof request>) => from(request(...args)),
1441
+ mutate: (...args: Parameters<typeof mutateSubmission>) => from(mutateSubmission(...args)),
1088
1442
  },
1089
1443
  } as SanityClient
1090
1444
  client$.next(client)