@sanity/sdk 2.16.0 → 2.18.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.
@@ -4,14 +4,16 @@ 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, processMutations} from '../processMutations'
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,
16
+ preserveNumericOperations,
15
17
  } from './shared'
16
18
 
17
19
  export function handleEdit(
@@ -33,6 +35,7 @@ export function handleEdit(
33
35
  timestamp,
34
36
  grants,
35
37
  identity,
38
+ preserveOperations: action.preserveOperations,
36
39
  })
37
40
  // liveEdit documents use the mutation endpoint directly -- we don't send actions
38
41
  outgoingMutations.push(...result.workingMutations)
@@ -71,6 +74,13 @@ export function handleEdit(
71
74
  })
72
75
  }
73
76
 
77
+ const applyMutations = createMutationApplier({
78
+ documentId,
79
+ transactionId,
80
+ timestamp,
81
+ preserveOperations: action.preserveOperations,
82
+ })
83
+
74
84
  const baseMutations: Mutation[] = []
75
85
  // don't create a draft from the published version in a release perspective
76
86
  if (!isReleasePerspective(action.perspective) && !base[draftId] && base[publishedId]) {
@@ -84,16 +94,23 @@ export function handleEdit(
84
94
  baseMutations.push(...userPatches)
85
95
  }
86
96
 
87
- base = processMutations({
88
- documents: base,
89
- transactionId,
90
- mutations: baseMutations,
91
- timestamp,
92
- })
97
+ base = applyMutations(base, baseMutations, 'base')
93
98
  // this one will always be defined because a patch mutation will never
94
99
  // delete an input document
95
100
  const baseAfter = base[patchDocumentId] as SanityDocument
96
- const patches = diffValue(baseBefore, baseAfter)
101
+ // when the caller asked us to preserve their operations, forward their
102
+ // patches verbatim instead of re-deriving them from a snapshot diff. this
103
+ // keeps keyed array operations addressable so concurrent edits from other
104
+ // clients interleave instead of being overwritten. otherwise, diff the
105
+ // snapshots but swap back any `inc`/`dec` the diff collapsed into a `set`,
106
+ // so concurrent increments accumulate instead of last-write-winning
107
+ const patches = action.preserveOperations
108
+ ? (action.patches ?? [])
109
+ : preserveNumericOperations(
110
+ baseBefore,
111
+ action.patches,
112
+ diffValue(baseBefore, baseAfter) as PatchOperations[],
113
+ )
97
114
 
98
115
  const workingMutations: Mutation[] = []
99
116
  if (!isReleasePerspective(action.perspective) && !working[draftId] && working[publishedId]) {
@@ -121,12 +138,7 @@ export function handleEdit(
121
138
  }
122
139
  workingMutations.push(...patches.map((patch) => ({patch: {id: patchDocumentId, ...patch}})))
123
140
 
124
- working = processMutations({
125
- documents: working,
126
- transactionId,
127
- mutations: workingMutations,
128
- timestamp,
129
- })
141
+ working = applyMutations(working, workingMutations, 'working')
130
142
 
131
143
  outgoingMutations.push(...workingMutations)
132
144
  outgoingActions.push(
@@ -1,4 +1,5 @@
1
1
  import {diffValue} from '@sanity/diff-patch'
2
+ import {jsonMatch, stringifyPath} from '@sanity/json-match'
2
3
  import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
3
4
  import {evaluateSync, type ExprNode} from 'groq-js'
4
5
 
@@ -57,6 +58,146 @@ export class ActionError extends Error implements ActionErrorOptions {
57
58
 
58
59
  export class PermissionActionError extends ActionError {}
59
60
 
61
+ /**
62
+ * Creates a `processMutations` wrapper that surfaces application failures as
63
+ * `ActionError`s when the caller asked to preserve their patch operations.
64
+ * With preserved operations, patches can legitimately fail to apply (e.g.
65
+ * re-applied onto a diverged document during a rebase), so wrapping lets a
66
+ * rebase skip the transaction instead of failing the store. Without
67
+ * `preserveOperations`, errors are rethrown untouched.
68
+ */
69
+ export function createMutationApplier(options: {
70
+ documentId: string
71
+ transactionId: string
72
+ timestamp: string
73
+ preserveOperations: boolean | undefined
74
+ }): (
75
+ documents: DocumentSet,
76
+ mutations: Mutation[],
77
+ documentSetName: 'base' | 'working',
78
+ ) => DocumentSet {
79
+ const {documentId, transactionId, timestamp, preserveOperations} = options
80
+ return (documents, mutations, documentSetName) => {
81
+ try {
82
+ return processMutations({documents, transactionId, mutations, timestamp})
83
+ } catch (error) {
84
+ if (!preserveOperations) throw error
85
+ throw new ActionError({
86
+ documentId,
87
+ transactionId,
88
+ message: `Failed to apply patches to the ${documentSetName} document: ${
89
+ error instanceof Error ? error.message : 'Unknown error'
90
+ }`,
91
+ })
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Re-applies the operational intent of user-supplied `inc`/`dec` patches to a
98
+ * set of snapshot-diffed patches.
99
+ *
100
+ * The outgoing patches sent to Content Lake are re-derived by diffing the
101
+ * base document before and after the user's patches were applied. That diff
102
+ * collapses an `inc` into a `set` of the resulting number, which turns
103
+ * concurrent increments from different clients into last-write-wins. This
104
+ * helper finds diffed `set` operations that are exactly explained by the
105
+ * user's `inc`/`dec` operations and swaps them back, so the server applies
106
+ * the increment against whatever value is current at commit time.
107
+ *
108
+ * A `set` is only swapped when its value equals the base value plus the
109
+ * accumulated user deltas for that exact path; anything else (e.g. an `inc`
110
+ * combined with a `set` of the same field) keeps the diffed `set`.
111
+ *
112
+ * Content Lake fails the whole transaction when `inc`/`dec` targets a missing
113
+ * path (unlike `set`, which always succeeds). To avoid turning a concurrent
114
+ * field removal into a transaction revert, the emitted patch seeds the target
115
+ * with a `setIfMissing` of the base value; the server executes `setIfMissing`
116
+ * before `inc`/`dec` within a single patch, so a concurrently removed field
117
+ * degrades to the same result the diffed `set` would have produced. A path
118
+ * into a concurrently removed keyed array item can still fail the
119
+ * transaction (`setIfMissing` cannot create array items); the transaction is
120
+ * then reverted and reported, which is preferable to silently resurrecting
121
+ * the item.
122
+ */
123
+ export function preserveNumericOperations(
124
+ baseBefore: SanityDocument | null | undefined,
125
+ userPatches: PatchOperations[] | undefined,
126
+ diffedPatches: PatchOperations[],
127
+ ): PatchOperations[] {
128
+ if (!baseBefore || !userPatches?.length) return diffedPatches
129
+
130
+ // accumulate the user's inc/dec deltas per concrete (stringified) path,
131
+ // tracking the expected final value by replaying the additions in order
132
+ const targets = new Map<string, {baseValue: number; expectedValue: number; delta: number}>()
133
+ for (const patch of userPatches) {
134
+ for (const [op, sign] of [
135
+ ['inc', 1],
136
+ ['dec', -1],
137
+ ] as const) {
138
+ const record = patch[op]
139
+ if (!record) continue
140
+ for (const [pathExpression, amount] of Object.entries(record)) {
141
+ if (typeof amount !== 'number') continue
142
+ for (const match of jsonMatch(baseBefore, pathExpression)) {
143
+ const path = stringifyPath(match.path)
144
+ const existing = targets.get(path)
145
+ if (existing) {
146
+ existing.expectedValue += sign * amount
147
+ existing.delta += sign * amount
148
+ } else if (typeof match.value === 'number') {
149
+ targets.set(path, {
150
+ baseValue: match.value,
151
+ expectedValue: match.value + sign * amount,
152
+ delta: sign * amount,
153
+ })
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ if (!targets.size) return diffedPatches
160
+
161
+ const preserved = new Map<string, {delta: number; baseValue: number}>()
162
+ const next = diffedPatches.flatMap((patch) => {
163
+ if (!patch.set) return [patch]
164
+
165
+ const keptSet: Record<string, unknown> = {}
166
+ for (const [path, value] of Object.entries(patch.set)) {
167
+ const target = targets.get(path)
168
+ if (target && typeof value === 'number' && value === target.expectedValue) {
169
+ preserved.set(path, {delta: target.delta, baseValue: target.baseValue})
170
+ } else {
171
+ keptSet[path] = value
172
+ }
173
+ }
174
+
175
+ if (Object.keys(keptSet).length === Object.keys(patch.set).length) return [patch]
176
+ const {set: _set, ...rest} = patch
177
+ const withKept = Object.keys(keptSet).length ? {...rest, set: keptSet} : rest
178
+ return Object.keys(withKept).length ? [withKept] : []
179
+ })
180
+
181
+ if (!preserved.size) return next
182
+
183
+ const setIfMissing: Record<string, unknown> = {}
184
+ const inc: Record<string, number> = {}
185
+ const dec: Record<string, number> = {}
186
+ for (const [path, {delta, baseValue}] of preserved) {
187
+ setIfMissing[path] = baseValue
188
+ if (delta >= 0) inc[path] = delta
189
+ else dec[path] = -delta
190
+ }
191
+ return [
192
+ ...next,
193
+ {
194
+ setIfMissing,
195
+ ...(Object.keys(inc).length && {inc}),
196
+ ...(Object.keys(dec).length && {dec}),
197
+ },
198
+ ]
199
+ }
200
+
60
201
  interface ApplySingleDocPatchOptions {
61
202
  base: DocumentSet
62
203
  working: DocumentSet
@@ -75,6 +216,13 @@ interface ApplySingleDocPatchOptions {
75
216
  * Error message thrown when the working document fails the `update` grant.
76
217
  */
77
218
  permissionMessage?: string
219
+ /**
220
+ * When `true`, the given patches are used verbatim instead of being
221
+ * re-derived by diffing the base document before and after application.
222
+ * Patch application failures are surfaced as `ActionError`s so a rebase
223
+ * can skip the transaction instead of failing the store.
224
+ */
225
+ preserveOperations?: boolean
78
226
  }
79
227
 
80
228
  interface ApplySingleDocPatchResult {
@@ -112,6 +260,7 @@ export function applySingleDocPatch({
112
260
  identity,
113
261
  notFoundMessage = 'Cannot edit document because it does not exist.',
114
262
  permissionMessage = `You do not have permission to edit document "${documentId}".`,
263
+ preserveOperations,
115
264
  }: ApplySingleDocPatchOptions): ApplySingleDocPatchResult {
116
265
  let base = initialBase
117
266
  let working = initialWorking
@@ -126,10 +275,23 @@ export function applySingleDocPatch({
126
275
  throw new ActionError({documentId, transactionId, message: notFoundMessage})
127
276
  }
128
277
 
278
+ const applyMutations = createMutationApplier({
279
+ documentId,
280
+ transactionId,
281
+ timestamp,
282
+ preserveOperations,
283
+ })
284
+
129
285
  const baseBefore = base[documentId]
130
- base = processMutations({documents: base, transactionId, mutations: userPatches, timestamp})
286
+ base = applyMutations(base, userPatches, 'base')
131
287
  const baseAfter = base[documentId]
132
- const diffedPatches = diffValue(baseBefore, baseAfter) as PatchOperations[]
288
+ const diffedPatches = preserveOperations
289
+ ? (patches as PatchOperations[])
290
+ : preserveNumericOperations(
291
+ baseBefore,
292
+ patches,
293
+ diffValue(baseBefore, baseAfter) as PatchOperations[],
294
+ )
133
295
 
134
296
  const workingBefore = working[documentId] as SanityDocument
135
297
  if (!checkGrant(grants.update, workingBefore, identity)) {
@@ -140,12 +302,7 @@ export function applySingleDocPatch({
140
302
  patch: {id: documentId, ...patch},
141
303
  }))
142
304
 
143
- working = processMutations({
144
- documents: working,
145
- transactionId,
146
- mutations: workingMutations,
147
- timestamp,
148
- })
305
+ working = applyMutations(working, workingMutations, 'working')
149
306
 
150
307
  return {base, working, diffedPatches, workingMutations}
151
308
  }
@@ -4,7 +4,7 @@ import {describe, expect, it} from 'vitest'
4
4
 
5
5
  import {type Action, type DocumentAction} from './actions'
6
6
  import {ActionError, processActions} from './processActions/processActions'
7
- import {type DocumentSet} from './processMutations'
7
+ import {type DocumentSet, processMutations} from './processMutations'
8
8
 
9
9
  // Helper: Create a sample document that conforms to SanityDocument.
10
10
  const createDoc = (id: string, title: string, rev: string = 'initial'): SanityDocument => ({
@@ -443,6 +443,190 @@ describe('processActions', () => {
443
443
  expect(result.outgoingActions[0].actionType).toBe('sanity.action.document.edit')
444
444
  })
445
445
 
446
+ it('preserves inc/dec operations instead of collapsing them into sets', () => {
447
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5, score: 10}
448
+ const base: DocumentSet = {'drafts.doc1': draft}
449
+ const working: DocumentSet = {'drafts.doc1': draft}
450
+ const actions: DocumentAction[] = [
451
+ {
452
+ documentId: 'doc1',
453
+ documentType: 'article',
454
+ type: 'document.edit',
455
+ patches: [{inc: {count: 1}, dec: {score: 3}}],
456
+ },
457
+ ]
458
+ const result = processActions({
459
+ actions,
460
+ transactionId,
461
+ base,
462
+ working,
463
+ timestamp,
464
+ grants: defaultGrants,
465
+ })
466
+
467
+ // the local working copy reflects the applied increments
468
+ expect(result.working['drafts.doc1']).toMatchObject({count: 6, score: 7})
469
+ // the outgoing patch keeps the operational inc/dec instead of a set.
470
+ // the setIfMissing seeds the base value in case the field was removed
471
+ // concurrently: the server runs setIfMissing before inc/dec, and inc on
472
+ // a missing path would otherwise fail the whole transaction
473
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
474
+ expect(patches).toEqual([
475
+ {setIfMissing: {count: 5, score: 10}, inc: {count: 1}, dec: {score: 3}},
476
+ ])
477
+ })
478
+
479
+ it('accumulates multiple inc/dec records on the same path into one operation', () => {
480
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
481
+ const base: DocumentSet = {'drafts.doc1': draft}
482
+ const working: DocumentSet = {'drafts.doc1': draft}
483
+ const actions: DocumentAction[] = [
484
+ {
485
+ documentId: 'doc1',
486
+ documentType: 'article',
487
+ type: 'document.edit',
488
+ patches: [{inc: {count: 1}}, {inc: {count: 2}}, {dec: {count: 4}}],
489
+ },
490
+ ]
491
+ const result = processActions({
492
+ actions,
493
+ transactionId,
494
+ base,
495
+ working,
496
+ timestamp,
497
+ grants: defaultGrants,
498
+ })
499
+
500
+ // 5 + 1 + 2 - 4 = 4 locally
501
+ expect(result.working['drafts.doc1']).toMatchObject({count: 4})
502
+ // the three records collapse into one accumulated dec (net -1) with the
503
+ // base value seeded for the concurrent-removal degradation path
504
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
505
+ expect(patches).toEqual([{setIfMissing: {count: 5}, dec: {count: 1}}])
506
+ })
507
+
508
+ it('keeps the diffed set when an inc is combined with a set of the same field', () => {
509
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
510
+ const base: DocumentSet = {'drafts.doc1': draft}
511
+ const working: DocumentSet = {'drafts.doc1': draft}
512
+ const actions: DocumentAction[] = [
513
+ {
514
+ documentId: 'doc1',
515
+ documentType: 'article',
516
+ type: 'document.edit',
517
+ // set to 100 first, then inc: the final value (101) is not
518
+ // explainable by the inc alone, so it must remain a set
519
+ patches: [{set: {count: 100}}, {inc: {count: 1}}],
520
+ },
521
+ ]
522
+ const result = processActions({
523
+ actions,
524
+ transactionId,
525
+ base,
526
+ working,
527
+ timestamp,
528
+ grants: defaultGrants,
529
+ })
530
+
531
+ expect(result.working['drafts.doc1']).toMatchObject({count: 101})
532
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
533
+ expect(patches).toEqual([{set: {count: 101}}])
534
+ })
535
+
536
+ it('preserves inc operations targeting keyed array items', () => {
537
+ const draft = {
538
+ ...createDoc('drafts.doc1', 'Title', '1'),
539
+ items: [{_key: 'itemA', qty: 2}],
540
+ }
541
+ const base: DocumentSet = {'drafts.doc1': draft}
542
+ const working: DocumentSet = {'drafts.doc1': draft}
543
+ const actions: DocumentAction[] = [
544
+ {
545
+ documentId: 'doc1',
546
+ documentType: 'article',
547
+ type: 'document.edit',
548
+ patches: [{inc: {'items[_key=="itemA"].qty': 3}}],
549
+ },
550
+ ]
551
+ const result = processActions({
552
+ actions,
553
+ transactionId,
554
+ base,
555
+ working,
556
+ timestamp,
557
+ grants: defaultGrants,
558
+ })
559
+
560
+ expect(result.working['drafts.doc1']?.['items']).toEqual([{_key: 'itemA', qty: 5}])
561
+ const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
562
+ expect(patches).toEqual([
563
+ {setIfMissing: {'items[_key=="itemA"].qty': 2}, inc: {'items[_key=="itemA"].qty': 3}},
564
+ ])
565
+ })
566
+
567
+ it('preserves inc operations through the liveEdit mutation path', () => {
568
+ const liveDoc = {...createLiveEditDoc('live-doc', 'Title', '1'), count: 5}
569
+ const base: DocumentSet = {'live-doc': liveDoc}
570
+ const working: DocumentSet = {'live-doc': liveDoc}
571
+ const actions: DocumentAction[] = [
572
+ {
573
+ documentId: 'live-doc',
574
+ documentType: 'liveArticle',
575
+ type: 'document.edit',
576
+ liveEdit: true,
577
+ patches: [{inc: {count: 2}}],
578
+ },
579
+ ]
580
+ const result = processActions({
581
+ actions,
582
+ transactionId,
583
+ base,
584
+ working,
585
+ timestamp,
586
+ grants: defaultGrants,
587
+ })
588
+
589
+ expect(result.working['live-doc']).toMatchObject({count: 7})
590
+ expect(result.outgoingMutations).toEqual([
591
+ {patch: {id: 'live-doc', setIfMissing: {count: 5}, inc: {count: 2}}},
592
+ ])
593
+ })
594
+
595
+ it('degrades to the diffed set result when the inc target was removed concurrently', () => {
596
+ const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
597
+ const base: DocumentSet = {'drafts.doc1': draft}
598
+ const working: DocumentSet = {'drafts.doc1': draft}
599
+ const actions: DocumentAction[] = [
600
+ {
601
+ documentId: 'doc1',
602
+ documentType: 'article',
603
+ type: 'document.edit',
604
+ patches: [{inc: {count: 1}}],
605
+ },
606
+ ]
607
+ const result = processActions({
608
+ actions,
609
+ transactionId,
610
+ base,
611
+ working,
612
+ timestamp,
613
+ grants: defaultGrants,
614
+ })
615
+ const patch = result.outgoingActions.map((action) => 'patch' in action && action.patch)[0]
616
+
617
+ // simulate the server applying the emitted patch to a document where
618
+ // another client removed the counter in the meantime: setIfMissing
619
+ // seeds the base value, then inc applies, matching what the collapsed
620
+ // set would have written (instead of inc failing the transaction)
621
+ const {count: _count, ...draftWithoutCount} = draft
622
+ const serverResult = processMutations({
623
+ documents: {'drafts.doc1': draftWithoutCount as SanityDocument},
624
+ mutations: [{patch: {id: 'drafts.doc1', ...(patch as object)}}],
625
+ transactionId: 'txn-server',
626
+ })
627
+ expect(serverResult['drafts.doc1']).toMatchObject({count: 6})
628
+ })
629
+
446
630
  it('should edit a document when base and working diverge', () => {
447
631
  const publishedBase = createDoc('doc1', 'Original Boring Title')
448
632
  const publishedWorking = createDoc('doc1', 'Local Boring Title')
@@ -527,6 +711,151 @@ describe('processActions', () => {
527
711
  })
528
712
  })
529
713
 
714
+ describe('document.edit with preserveOperations', () => {
715
+ const createKeyedDoc = (): SanityDocument => ({
716
+ _id: 'drafts.doc1',
717
+ _type: 'article',
718
+ _createdAt: '2025-01-01T00:00:00.000Z',
719
+ _updatedAt: '2025-01-01T00:00:00.000Z',
720
+ _rev: 'initial',
721
+ items: [
722
+ {_key: 'a', value: 'first'},
723
+ {_key: 'b', value: 'second'},
724
+ ],
725
+ })
726
+
727
+ it('forwards the given patches verbatim instead of re-diffing', () => {
728
+ const draft = createKeyedDoc()
729
+ const base: DocumentSet = {'drafts.doc1': draft}
730
+ const working: DocumentSet = {'drafts.doc1': draft}
731
+ const patches = [
732
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
733
+ ]
734
+ const actions: DocumentAction[] = [
735
+ {
736
+ documentId: 'doc1',
737
+ documentType: 'article',
738
+ type: 'document.edit',
739
+ patches,
740
+ preserveOperations: true,
741
+ },
742
+ ]
743
+ const result = processActions({
744
+ actions,
745
+ transactionId,
746
+ base,
747
+ working,
748
+ timestamp,
749
+ grants: defaultGrants,
750
+ })
751
+
752
+ // the outgoing action carries the caller's keyed patch untouched
753
+ expect(result.outgoingActions).toEqual([
754
+ {
755
+ actionType: 'sanity.action.document.edit',
756
+ draftId: 'drafts.doc1',
757
+ publishedId: 'doc1',
758
+ patch: patches[0],
759
+ },
760
+ ])
761
+
762
+ // and it still applies to the local working copy
763
+ expect(result.working['drafts.doc1']?.['items']).toEqual([
764
+ {_key: 'a', value: 'first'},
765
+ {_key: 'c', value: 'in between'},
766
+ {_key: 'b', value: 'second'},
767
+ ])
768
+ })
769
+
770
+ it('applies keyed unsets and sets to the local working copy', () => {
771
+ const draft = createKeyedDoc()
772
+ const base: DocumentSet = {'drafts.doc1': draft}
773
+ const working: DocumentSet = {'drafts.doc1': draft}
774
+ const actions: DocumentAction[] = [
775
+ {
776
+ documentId: 'doc1',
777
+ documentType: 'article',
778
+ type: 'document.edit',
779
+ patches: [
780
+ {unset: ['items[_key=="a"]']},
781
+ {set: {'items[_key=="b"].value': 'updated second'}},
782
+ ],
783
+ preserveOperations: true,
784
+ },
785
+ ]
786
+ const result = processActions({
787
+ actions,
788
+ transactionId,
789
+ base,
790
+ working,
791
+ timestamp,
792
+ grants: defaultGrants,
793
+ })
794
+
795
+ expect(result.working['drafts.doc1']?.['items']).toEqual([
796
+ {_key: 'b', value: 'updated second'},
797
+ ])
798
+ expect(result.outgoingActions.map((action) => 'patch' in action && action.patch)).toEqual([
799
+ {unset: ['items[_key=="a"]']},
800
+ {set: {'items[_key=="b"].value': 'updated second'}},
801
+ ])
802
+ })
803
+
804
+ it('surfaces patch application failures as ActionError', () => {
805
+ const draft = {...createKeyedDoc(), count: 5}
806
+ const base: DocumentSet = {'drafts.doc1': draft}
807
+ const working: DocumentSet = {'drafts.doc1': draft}
808
+ const actions: DocumentAction[] = [
809
+ {
810
+ documentId: 'doc1',
811
+ documentType: 'article',
812
+ type: 'document.edit',
813
+ // diff-match-patch on a non-string value cannot apply
814
+ patches: [{diffMatchPatch: {count: '@@ -1,3 +1,3 @@\n-foo\n+bar\n'}}],
815
+ preserveOperations: true,
816
+ },
817
+ ]
818
+ expect(() =>
819
+ processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
820
+ ).toThrow(ActionError)
821
+ expect(() =>
822
+ processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
823
+ ).toThrow(/Failed to apply patches to the base document/)
824
+ })
825
+
826
+ it('forwards patches verbatim through the liveEdit mutation path', () => {
827
+ const liveDoc = {...createKeyedDoc(), _id: 'live-doc'}
828
+ const base: DocumentSet = {'live-doc': liveDoc}
829
+ const working: DocumentSet = {'live-doc': liveDoc}
830
+ const patches = [{insert: {before: 'items[_key=="a"]', items: [{_key: 'z', value: 'start'}]}}]
831
+ const actions: DocumentAction[] = [
832
+ {
833
+ documentId: 'live-doc',
834
+ documentType: 'liveArticle',
835
+ type: 'document.edit',
836
+ liveEdit: true,
837
+ patches,
838
+ preserveOperations: true,
839
+ },
840
+ ]
841
+ const result = processActions({
842
+ actions,
843
+ transactionId,
844
+ base,
845
+ working,
846
+ timestamp,
847
+ grants: defaultGrants,
848
+ })
849
+
850
+ expect(result.outgoingMutations).toEqual([{patch: {id: 'live-doc', ...patches[0]}}])
851
+ expect(result.working['live-doc']?.['items']).toEqual([
852
+ {_key: 'z', value: 'start'},
853
+ {_key: 'a', value: 'first'},
854
+ {_key: 'b', value: 'second'},
855
+ ])
856
+ })
857
+ })
858
+
530
859
  describe('document.publish', () => {
531
860
  it('should publish a draft document', () => {
532
861
  const draft = createDoc('drafts.doc1', 'Draft Title', '1')