@sanity/sdk 2.16.0 → 2.17.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.
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts +69 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +77 -32
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/_exports/index.ts +2 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.test.ts +132 -1
- package/src/document/documentStore.ts +8 -0
- package/src/document/events.ts +32 -0
- package/src/document/listen.ts +8 -0
- package/src/document/processActions/edit.ts +19 -14
- package/src/document/processActions/shared.ts +55 -8
- package/src/document/processActions.test.ts +145 -0
- package/src/document/reducers.test.ts +396 -0
- package/src/document/reducers.ts +62 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK",
|
|
6
6
|
"keywords": [
|
|
@@ -78,11 +78,11 @@
|
|
|
78
78
|
"typescript": "^6.0.3",
|
|
79
79
|
"vite": "^8.1.4",
|
|
80
80
|
"vitest": "^4.1.10",
|
|
81
|
+
"@repo/config-test": "0.0.1",
|
|
81
82
|
"@repo/config-eslint": "0.0.0",
|
|
82
83
|
"@repo/package.bundle": "3.82.0",
|
|
83
|
-
"@repo/package.config": "0.0.1",
|
|
84
84
|
"@repo/tsconfig": "0.0.1",
|
|
85
|
-
"@repo/config
|
|
85
|
+
"@repo/package.config": "0.0.1"
|
|
86
86
|
},
|
|
87
87
|
"browserslist": "extends @sanity/browserslist-config",
|
|
88
88
|
"scripts": {
|
package/src/_exports/index.ts
CHANGED
|
@@ -129,6 +129,7 @@ export {
|
|
|
129
129
|
type DocumentAction,
|
|
130
130
|
editDocument,
|
|
131
131
|
type EditDocumentAction,
|
|
132
|
+
type EditDocumentOptions,
|
|
132
133
|
editRelease,
|
|
133
134
|
type EditReleaseAction,
|
|
134
135
|
publishDocument,
|
|
@@ -167,6 +168,7 @@ export {
|
|
|
167
168
|
type DocumentEditedEvent,
|
|
168
169
|
type DocumentEvent,
|
|
169
170
|
type DocumentPublishedEvent,
|
|
171
|
+
type DocumentRemotePatchesEvent,
|
|
170
172
|
type DocumentTransactionSubmissionResult,
|
|
171
173
|
type DocumentUnpublishedEvent,
|
|
172
174
|
type TransactionAcceptedEvent,
|
package/src/document/actions.ts
CHANGED
|
@@ -68,6 +68,21 @@ export interface EditDocumentAction<
|
|
|
68
68
|
> extends DocumentHandle<TDocumentType, TDataset, TProjectId> {
|
|
69
69
|
type: 'document.edit'
|
|
70
70
|
patches?: PatchOperations[]
|
|
71
|
+
/**
|
|
72
|
+
* When `true`, the given `patches` are forwarded verbatim to the server and
|
|
73
|
+
* applied as-is locally, instead of being re-derived by diffing document
|
|
74
|
+
* snapshots. This preserves the operational intent of the patches (e.g.
|
|
75
|
+
* keyed array inserts/unsets), which lets concurrent edits from other
|
|
76
|
+
* clients interleave coherently instead of being overwritten by
|
|
77
|
+
* position-based patches computed from a stale snapshot.
|
|
78
|
+
*
|
|
79
|
+
* Use this when the patches were produced by an editor that tracks its own
|
|
80
|
+
* operations (e.g. a collaborative text editor). During a rebase onto a
|
|
81
|
+
* changed remote document, preserved patches are re-applied operationally;
|
|
82
|
+
* if a patch can no longer apply, the transaction is skipped and reported
|
|
83
|
+
* as a `rebase-error` document event.
|
|
84
|
+
*/
|
|
85
|
+
preserveOperations?: boolean
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/**
|
|
@@ -201,12 +216,25 @@ function convertSanityMutatePatch(
|
|
|
201
216
|
})
|
|
202
217
|
}
|
|
203
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Options for creating an `EditDocumentAction`.
|
|
221
|
+
* @beta
|
|
222
|
+
*/
|
|
223
|
+
export interface EditDocumentOptions {
|
|
224
|
+
/**
|
|
225
|
+
* Forward the given patches verbatim instead of re-deriving them by
|
|
226
|
+
* diffing document snapshots. See {@link EditDocumentAction.preserveOperations}.
|
|
227
|
+
*/
|
|
228
|
+
preserveOperations?: boolean
|
|
229
|
+
}
|
|
230
|
+
|
|
204
231
|
/**
|
|
205
232
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
206
233
|
* Accepts patches in either the standard `PatchOperations` format or as a `SanityMutatePatchMutation` from `@sanity/mutate`.
|
|
207
234
|
*
|
|
208
235
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
209
236
|
* @param sanityMutatePatch - A patch mutation object from `@sanity/mutate`.
|
|
237
|
+
* @param options - Options controlling how the patches are processed.
|
|
210
238
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
211
239
|
* @beta
|
|
212
240
|
*/
|
|
@@ -217,12 +245,14 @@ export function editDocument<
|
|
|
217
245
|
>(
|
|
218
246
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
219
247
|
sanityMutatePatch: SanityMutatePatchMutation,
|
|
248
|
+
options?: EditDocumentOptions,
|
|
220
249
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId>
|
|
221
250
|
/**
|
|
222
251
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
223
252
|
*
|
|
224
253
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
225
254
|
* @param patches - A single patch operation or an array of patch operations.
|
|
255
|
+
* @param options - Options controlling how the patches are processed.
|
|
226
256
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
227
257
|
* @beta
|
|
228
258
|
*/
|
|
@@ -233,6 +263,7 @@ export function editDocument<
|
|
|
233
263
|
>(
|
|
234
264
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
235
265
|
patches?: PatchOperations | PatchOperations[],
|
|
266
|
+
options?: EditDocumentOptions,
|
|
236
267
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId>
|
|
237
268
|
/**
|
|
238
269
|
* Creates an `EditDocumentAction` object with patches for modifying a document.
|
|
@@ -240,6 +271,7 @@ export function editDocument<
|
|
|
240
271
|
*
|
|
241
272
|
* @param doc - A handle uniquely identifying the document to be edited.
|
|
242
273
|
* @param patches - Patches in various formats (`PatchOperations`, `PatchOperations[]`, or `SanityMutatePatchMutation`).
|
|
274
|
+
* @param options - Options controlling how the patches are processed.
|
|
243
275
|
* @returns An `EditDocumentAction` object ready for dispatch.
|
|
244
276
|
*/
|
|
245
277
|
export function editDocument<
|
|
@@ -249,8 +281,10 @@ export function editDocument<
|
|
|
249
281
|
>(
|
|
250
282
|
doc: DocumentHandle<TDocumentType, TDataset, TProjectId>,
|
|
251
283
|
patches?: PatchOperations | PatchOperations[] | SanityMutatePatchMutation,
|
|
284
|
+
options?: EditDocumentOptions,
|
|
252
285
|
): EditDocumentAction<TDocumentType, TDataset, TProjectId> {
|
|
253
286
|
const effectiveDocumentId = getEffectiveDocumentId(doc)
|
|
287
|
+
const preserveOperations = options?.preserveOperations && {preserveOperations: true as const}
|
|
254
288
|
|
|
255
289
|
if (isSanityMutatePatch(patches)) {
|
|
256
290
|
const converted = convertSanityMutatePatch(patches) ?? []
|
|
@@ -259,6 +293,7 @@ export function editDocument<
|
|
|
259
293
|
type: 'document.edit',
|
|
260
294
|
documentId: effectiveDocumentId,
|
|
261
295
|
patches: converted,
|
|
296
|
+
...preserveOperations,
|
|
262
297
|
}
|
|
263
298
|
}
|
|
264
299
|
|
|
@@ -267,6 +302,7 @@ export function editDocument<
|
|
|
267
302
|
type: 'document.edit',
|
|
268
303
|
documentId: effectiveDocumentId,
|
|
269
304
|
...(patches && {patches: Array.isArray(patches) ? patches : [patches]}),
|
|
305
|
+
...preserveOperations,
|
|
270
306
|
}
|
|
271
307
|
}
|
|
272
308
|
|
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
resolvePermissions,
|
|
42
42
|
subscribeDocumentEvents,
|
|
43
43
|
} from './documentStore'
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type ActionErrorEvent,
|
|
46
|
+
type DocumentRemotePatchesEvent,
|
|
47
|
+
type TransactionRevertedEvent,
|
|
48
|
+
} from './events'
|
|
45
49
|
import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
|
|
46
50
|
import {type DatasetAcl} from './permissions'
|
|
47
51
|
import {type DocumentSet, processMutations} from './processMutations'
|
|
@@ -281,6 +285,66 @@ it('propagates changes between two instances', async () => {
|
|
|
281
285
|
state2Unsubscribe()
|
|
282
286
|
})
|
|
283
287
|
|
|
288
|
+
it('emits remote-patches events distinguishing own and foreign transactions', async () => {
|
|
289
|
+
const doc = createDocumentHandle({documentId: 'doc-remote-patches', documentType: 'article'})
|
|
290
|
+
const state1 = getDocumentState(instance1, doc)
|
|
291
|
+
const state2 = getDocumentState(instance2, doc)
|
|
292
|
+
|
|
293
|
+
const state1Unsubscribe = state1.subscribe()
|
|
294
|
+
const state2Unsubscribe = state2.subscribe()
|
|
295
|
+
|
|
296
|
+
await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
|
|
297
|
+
(r) => r.submitted(),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
const events1: DocumentRemotePatchesEvent[] = []
|
|
301
|
+
const events2: DocumentRemotePatchesEvent[] = []
|
|
302
|
+
const unsubscribeEvents1 = subscribeDocumentEvents(instance1, {
|
|
303
|
+
resource: source1,
|
|
304
|
+
eventHandler: (e) => {
|
|
305
|
+
if (e.type === 'remote-patches') events1.push(e)
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
const unsubscribeEvents2 = subscribeDocumentEvents(instance2, {
|
|
309
|
+
resource: source2,
|
|
310
|
+
eventHandler: (e) => {
|
|
311
|
+
if (e.type === 'remote-patches') events2.push(e)
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// edit from instance2: an own transaction for instance2, a foreign one for instance1
|
|
316
|
+
const result = await applyDocumentActions(instance2, {
|
|
317
|
+
actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
|
|
318
|
+
resource: source2,
|
|
319
|
+
}).then((r) => r.submitted())
|
|
320
|
+
|
|
321
|
+
const draftId = getDraftId(DocumentId(doc.documentId))
|
|
322
|
+
|
|
323
|
+
expect(events1).toHaveLength(1)
|
|
324
|
+
expect(events1[0]).toMatchObject({
|
|
325
|
+
type: 'remote-patches',
|
|
326
|
+
documentId: draftId,
|
|
327
|
+
transactionId: result.transactionId,
|
|
328
|
+
origin: 'remote',
|
|
329
|
+
})
|
|
330
|
+
expect(events1[0].patches).toEqual(
|
|
331
|
+
expect.arrayContaining([{set: expect.objectContaining({title: 'Hello world!'})}]),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
expect(events2).toHaveLength(1)
|
|
335
|
+
expect(events2[0]).toMatchObject({
|
|
336
|
+
type: 'remote-patches',
|
|
337
|
+
documentId: draftId,
|
|
338
|
+
transactionId: result.transactionId,
|
|
339
|
+
origin: 'local',
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
unsubscribeEvents1()
|
|
343
|
+
unsubscribeEvents2()
|
|
344
|
+
state1Unsubscribe()
|
|
345
|
+
state2Unsubscribe()
|
|
346
|
+
})
|
|
347
|
+
|
|
284
348
|
it('handles concurrent edits and resolves conflicts', async () => {
|
|
285
349
|
const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
|
|
286
350
|
const state1 = getDocumentState(instance1, doc)
|
|
@@ -325,6 +389,73 @@ it('handles concurrent edits and resolves conflicts', async () => {
|
|
|
325
389
|
oneOffInstance.dispose()
|
|
326
390
|
})
|
|
327
391
|
|
|
392
|
+
it('interleaves concurrent keyed-array edits when operations are preserved', async () => {
|
|
393
|
+
const doc = createDocumentHandle({documentId: 'doc-preserve-ops', documentType: 'article'})
|
|
394
|
+
const state1 = getDocumentState(instance1, doc)
|
|
395
|
+
const state2 = getDocumentState(instance2, doc)
|
|
396
|
+
|
|
397
|
+
const state1Unsubscribe = state1.subscribe()
|
|
398
|
+
const state2Unsubscribe = state2.subscribe()
|
|
399
|
+
|
|
400
|
+
const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
401
|
+
await applyDocumentActions(oneOffInstance, {
|
|
402
|
+
actions: [
|
|
403
|
+
createDocument(doc),
|
|
404
|
+
editDocument(doc, {
|
|
405
|
+
set: {
|
|
406
|
+
items: [
|
|
407
|
+
{_key: 'a', value: 'first'},
|
|
408
|
+
{_key: 'b', value: 'second'},
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
],
|
|
413
|
+
resource,
|
|
414
|
+
}).then((r) => r.submitted())
|
|
415
|
+
|
|
416
|
+
// both instances insert into the same keyed array simultaneously, each
|
|
417
|
+
// preserving their operational patches instead of re-diffing snapshots
|
|
418
|
+
const p1 = applyDocumentActions(instance1, {
|
|
419
|
+
actions: [
|
|
420
|
+
editDocument(
|
|
421
|
+
doc,
|
|
422
|
+
{insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'from instance1'}]}},
|
|
423
|
+
{preserveOperations: true},
|
|
424
|
+
),
|
|
425
|
+
],
|
|
426
|
+
resource: source1,
|
|
427
|
+
}).then((r) => r.submitted())
|
|
428
|
+
const p2 = applyDocumentActions(instance2, {
|
|
429
|
+
actions: [
|
|
430
|
+
editDocument(
|
|
431
|
+
doc,
|
|
432
|
+
{insert: {after: 'items[_key=="b"]', items: [{_key: 'd', value: 'from instance2'}]}},
|
|
433
|
+
{preserveOperations: true},
|
|
434
|
+
),
|
|
435
|
+
],
|
|
436
|
+
resource: source2,
|
|
437
|
+
}).then((r) => r.submitted())
|
|
438
|
+
|
|
439
|
+
await Promise.allSettled([p1, p2])
|
|
440
|
+
|
|
441
|
+
const finalDoc1 = state1.getCurrent()
|
|
442
|
+
const finalDoc2 = state2.getCurrent()
|
|
443
|
+
|
|
444
|
+
// neither edit overwrote the other: both keyed inserts landed and both
|
|
445
|
+
// instances converged on the same interleaved result
|
|
446
|
+
expect(finalDoc1?.['items']).toEqual(finalDoc2?.['items'])
|
|
447
|
+
expect(finalDoc1?.['items']).toEqual([
|
|
448
|
+
{_key: 'a', value: 'first'},
|
|
449
|
+
{_key: 'c', value: 'from instance1'},
|
|
450
|
+
{_key: 'b', value: 'second'},
|
|
451
|
+
{_key: 'd', value: 'from instance2'},
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
state1Unsubscribe()
|
|
455
|
+
state2Unsubscribe()
|
|
456
|
+
oneOffInstance.dispose()
|
|
457
|
+
})
|
|
458
|
+
|
|
328
459
|
it('unpublishes and discards a document', async () => {
|
|
329
460
|
const documentId = DocumentId('doc-pub-unpub')
|
|
330
461
|
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
@@ -133,6 +133,14 @@ export interface DocumentState {
|
|
|
133
133
|
* local transactions on top of this new remote.
|
|
134
134
|
*/
|
|
135
135
|
unverifiedRevisions?: {[TTransactionId in string]?: UnverifiedDocumentRevision}
|
|
136
|
+
/**
|
|
137
|
+
* transaction IDs recently submitted by this client for this document,
|
|
138
|
+
* newest last. used to label `remote-patches` events with the correct
|
|
139
|
+
* `origin` even after the corresponding `unverifiedRevisions` entry has
|
|
140
|
+
* been pruned (e.g. by a sync event that raced the listener echo). capped
|
|
141
|
+
* to the most recent entries.
|
|
142
|
+
*/
|
|
143
|
+
recentOwnTransactionIds?: string[]
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
|
package/src/document/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {type PatchOperations} from '@sanity/types'
|
|
2
3
|
|
|
3
4
|
import {type DocumentAction, type ReleaseAction} from './actions'
|
|
4
5
|
import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
|
|
@@ -21,6 +22,7 @@ export type DocumentEvent =
|
|
|
21
22
|
| DocumentPublishedEvent
|
|
22
23
|
| DocumentUnpublishedEvent
|
|
23
24
|
| DocumentDiscardedEvent
|
|
25
|
+
| DocumentRemotePatchesEvent
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @beta
|
|
@@ -119,6 +121,36 @@ export interface DocumentDiscardedEvent {
|
|
|
119
121
|
outgoing: OutgoingTransaction
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* @beta
|
|
126
|
+
* Event emitted when patches for a document are observed through the listener.
|
|
127
|
+
*
|
|
128
|
+
* Unlike the whole-document snapshots the store keeps, these are the raw patch
|
|
129
|
+
* operations from the transaction that produced the change, preserving the
|
|
130
|
+
* operational intent of the edit (e.g. keyed array inserts/unsets). Consumers
|
|
131
|
+
* that maintain their own local state, like collaborative text editors, can
|
|
132
|
+
* apply these directly instead of diffing document snapshots.
|
|
133
|
+
*
|
|
134
|
+
* `origin` is `'local'` when the transaction was submitted by this document
|
|
135
|
+
* store instance (an own edit observed back through the listener) and
|
|
136
|
+
* `'remote'` when it came from another client.
|
|
137
|
+
*/
|
|
138
|
+
export interface DocumentRemotePatchesEvent {
|
|
139
|
+
type: 'remote-patches'
|
|
140
|
+
/** The ID of the document version the patches apply to (e.g. a draft ID). */
|
|
141
|
+
documentId: string
|
|
142
|
+
/** The transaction ID that carried these patches. */
|
|
143
|
+
transactionId: string
|
|
144
|
+
/** The revision the document was at before this transaction. */
|
|
145
|
+
previousRev?: string
|
|
146
|
+
/** The timestamp of the transaction. */
|
|
147
|
+
timestamp: string
|
|
148
|
+
/** The patch operations, rooted at the document. */
|
|
149
|
+
patches: PatchOperations[]
|
|
150
|
+
/** Whether the transaction originated from this client or another one. */
|
|
151
|
+
origin: 'local' | 'remote'
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
// Release actions that write a mutation to the local release doc map onto
|
|
123
155
|
// the regular per-document events with `documentId = '_.releases.<releaseId>'`.
|
|
124
156
|
// The other release actions (publish/schedule/unschedule/archive/unarchive)
|
package/src/document/listen.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface RemoteDocument {
|
|
|
30
30
|
revision?: string
|
|
31
31
|
previousRev?: string
|
|
32
32
|
timestamp: string
|
|
33
|
+
/**
|
|
34
|
+
* The raw mutations from the listener event that produced this document.
|
|
35
|
+
* Only present for `'mutation'` events. These carry the operational intent
|
|
36
|
+
* of the remote change (e.g. keyed patches) before it is collapsed into a
|
|
37
|
+
* whole document snapshot.
|
|
38
|
+
*/
|
|
39
|
+
mutations?: Mutation[]
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
export interface SyncEvent {
|
|
@@ -251,6 +258,7 @@ export const listen = (
|
|
|
251
258
|
document: document ?? null,
|
|
252
259
|
revision: transactionId,
|
|
253
260
|
timestamp,
|
|
261
|
+
mutations: next.mutations as Mutation[],
|
|
254
262
|
...(previousRev && {previousRev}),
|
|
255
263
|
}
|
|
256
264
|
}),
|
|
@@ -4,13 +4,14 @@ import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/
|
|
|
4
4
|
|
|
5
5
|
import {isReleasePerspective} from '../../releases/utils/isReleasePerspective'
|
|
6
6
|
import {type EditDocumentAction} from '../actions'
|
|
7
|
-
import {getId
|
|
7
|
+
import {getId} from '../processMutations'
|
|
8
8
|
import {
|
|
9
9
|
ActionError,
|
|
10
10
|
type ActionHandlerContext,
|
|
11
11
|
type ActionHandlerResult,
|
|
12
12
|
applySingleDocPatch,
|
|
13
13
|
checkGrant,
|
|
14
|
+
createMutationApplier,
|
|
14
15
|
PermissionActionError,
|
|
15
16
|
} from './shared'
|
|
16
17
|
|
|
@@ -33,6 +34,7 @@ export function handleEdit(
|
|
|
33
34
|
timestamp,
|
|
34
35
|
grants,
|
|
35
36
|
identity,
|
|
37
|
+
preserveOperations: action.preserveOperations,
|
|
36
38
|
})
|
|
37
39
|
// liveEdit documents use the mutation endpoint directly -- we don't send actions
|
|
38
40
|
outgoingMutations.push(...result.workingMutations)
|
|
@@ -71,6 +73,13 @@ export function handleEdit(
|
|
|
71
73
|
})
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
const applyMutations = createMutationApplier({
|
|
77
|
+
documentId,
|
|
78
|
+
transactionId,
|
|
79
|
+
timestamp,
|
|
80
|
+
preserveOperations: action.preserveOperations,
|
|
81
|
+
})
|
|
82
|
+
|
|
74
83
|
const baseMutations: Mutation[] = []
|
|
75
84
|
// don't create a draft from the published version in a release perspective
|
|
76
85
|
if (!isReleasePerspective(action.perspective) && !base[draftId] && base[publishedId]) {
|
|
@@ -84,16 +93,17 @@ export function handleEdit(
|
|
|
84
93
|
baseMutations.push(...userPatches)
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
base =
|
|
88
|
-
documents: base,
|
|
89
|
-
transactionId,
|
|
90
|
-
mutations: baseMutations,
|
|
91
|
-
timestamp,
|
|
92
|
-
})
|
|
96
|
+
base = applyMutations(base, baseMutations, 'base')
|
|
93
97
|
// this one will always be defined because a patch mutation will never
|
|
94
98
|
// delete an input document
|
|
95
99
|
const baseAfter = base[patchDocumentId] as SanityDocument
|
|
96
|
-
|
|
100
|
+
// when the caller asked us to preserve their operations, forward their
|
|
101
|
+
// patches verbatim instead of re-deriving them from a snapshot diff. this
|
|
102
|
+
// keeps keyed array operations addressable so concurrent edits from other
|
|
103
|
+
// clients interleave instead of being overwritten
|
|
104
|
+
const patches = action.preserveOperations
|
|
105
|
+
? (action.patches ?? [])
|
|
106
|
+
: diffValue(baseBefore, baseAfter)
|
|
97
107
|
|
|
98
108
|
const workingMutations: Mutation[] = []
|
|
99
109
|
if (!isReleasePerspective(action.perspective) && !working[draftId] && working[publishedId]) {
|
|
@@ -121,12 +131,7 @@ export function handleEdit(
|
|
|
121
131
|
}
|
|
122
132
|
workingMutations.push(...patches.map((patch) => ({patch: {id: patchDocumentId, ...patch}})))
|
|
123
133
|
|
|
124
|
-
working =
|
|
125
|
-
documents: working,
|
|
126
|
-
transactionId,
|
|
127
|
-
mutations: workingMutations,
|
|
128
|
-
timestamp,
|
|
129
|
-
})
|
|
134
|
+
working = applyMutations(working, workingMutations, 'working')
|
|
130
135
|
|
|
131
136
|
outgoingMutations.push(...workingMutations)
|
|
132
137
|
outgoingActions.push(
|
|
@@ -57,6 +57,41 @@ export class ActionError extends Error implements ActionErrorOptions {
|
|
|
57
57
|
|
|
58
58
|
export class PermissionActionError extends ActionError {}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Creates a `processMutations` wrapper that surfaces application failures as
|
|
62
|
+
* `ActionError`s when the caller asked to preserve their patch operations.
|
|
63
|
+
* With preserved operations, patches can legitimately fail to apply (e.g.
|
|
64
|
+
* re-applied onto a diverged document during a rebase), so wrapping lets a
|
|
65
|
+
* rebase skip the transaction instead of failing the store. Without
|
|
66
|
+
* `preserveOperations`, errors are rethrown untouched.
|
|
67
|
+
*/
|
|
68
|
+
export function createMutationApplier(options: {
|
|
69
|
+
documentId: string
|
|
70
|
+
transactionId: string
|
|
71
|
+
timestamp: string
|
|
72
|
+
preserveOperations: boolean | undefined
|
|
73
|
+
}): (
|
|
74
|
+
documents: DocumentSet,
|
|
75
|
+
mutations: Mutation[],
|
|
76
|
+
documentSetName: 'base' | 'working',
|
|
77
|
+
) => DocumentSet {
|
|
78
|
+
const {documentId, transactionId, timestamp, preserveOperations} = options
|
|
79
|
+
return (documents, mutations, documentSetName) => {
|
|
80
|
+
try {
|
|
81
|
+
return processMutations({documents, transactionId, mutations, timestamp})
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!preserveOperations) throw error
|
|
84
|
+
throw new ActionError({
|
|
85
|
+
documentId,
|
|
86
|
+
transactionId,
|
|
87
|
+
message: `Failed to apply patches to the ${documentSetName} document: ${
|
|
88
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
89
|
+
}`,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
60
95
|
interface ApplySingleDocPatchOptions {
|
|
61
96
|
base: DocumentSet
|
|
62
97
|
working: DocumentSet
|
|
@@ -75,6 +110,13 @@ interface ApplySingleDocPatchOptions {
|
|
|
75
110
|
* Error message thrown when the working document fails the `update` grant.
|
|
76
111
|
*/
|
|
77
112
|
permissionMessage?: string
|
|
113
|
+
/**
|
|
114
|
+
* When `true`, the given patches are used verbatim instead of being
|
|
115
|
+
* re-derived by diffing the base document before and after application.
|
|
116
|
+
* Patch application failures are surfaced as `ActionError`s so a rebase
|
|
117
|
+
* can skip the transaction instead of failing the store.
|
|
118
|
+
*/
|
|
119
|
+
preserveOperations?: boolean
|
|
78
120
|
}
|
|
79
121
|
|
|
80
122
|
interface ApplySingleDocPatchResult {
|
|
@@ -112,6 +154,7 @@ export function applySingleDocPatch({
|
|
|
112
154
|
identity,
|
|
113
155
|
notFoundMessage = 'Cannot edit document because it does not exist.',
|
|
114
156
|
permissionMessage = `You do not have permission to edit document "${documentId}".`,
|
|
157
|
+
preserveOperations,
|
|
115
158
|
}: ApplySingleDocPatchOptions): ApplySingleDocPatchResult {
|
|
116
159
|
let base = initialBase
|
|
117
160
|
let working = initialWorking
|
|
@@ -126,10 +169,19 @@ export function applySingleDocPatch({
|
|
|
126
169
|
throw new ActionError({documentId, transactionId, message: notFoundMessage})
|
|
127
170
|
}
|
|
128
171
|
|
|
172
|
+
const applyMutations = createMutationApplier({
|
|
173
|
+
documentId,
|
|
174
|
+
transactionId,
|
|
175
|
+
timestamp,
|
|
176
|
+
preserveOperations,
|
|
177
|
+
})
|
|
178
|
+
|
|
129
179
|
const baseBefore = base[documentId]
|
|
130
|
-
base =
|
|
180
|
+
base = applyMutations(base, userPatches, 'base')
|
|
131
181
|
const baseAfter = base[documentId]
|
|
132
|
-
const diffedPatches =
|
|
182
|
+
const diffedPatches = preserveOperations
|
|
183
|
+
? (patches as PatchOperations[])
|
|
184
|
+
: (diffValue(baseBefore, baseAfter) as PatchOperations[])
|
|
133
185
|
|
|
134
186
|
const workingBefore = working[documentId] as SanityDocument
|
|
135
187
|
if (!checkGrant(grants.update, workingBefore, identity)) {
|
|
@@ -140,12 +192,7 @@ export function applySingleDocPatch({
|
|
|
140
192
|
patch: {id: documentId, ...patch},
|
|
141
193
|
}))
|
|
142
194
|
|
|
143
|
-
working =
|
|
144
|
-
documents: working,
|
|
145
|
-
transactionId,
|
|
146
|
-
mutations: workingMutations,
|
|
147
|
-
timestamp,
|
|
148
|
-
})
|
|
195
|
+
working = applyMutations(working, workingMutations, 'working')
|
|
149
196
|
|
|
150
197
|
return {base, working, diffedPatches, workingMutations}
|
|
151
198
|
}
|