@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.
- 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 +282 -52
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/_exports/index.ts +2 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.concurrency.test.ts +1015 -0
- package/src/document/documentStore.test.ts +143 -1
- package/src/document/documentStore.ts +8 -1
- package/src/document/events.ts +32 -0
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +73 -20
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +49 -2
- package/src/document/patchOperations.ts +50 -0
- package/src/document/processActions/edit.ts +26 -14
- package/src/document/processActions/shared.ts +165 -8
- package/src/document/processActions.test.ts +330 -1
- package/src/document/reducers.test.ts +519 -0
- package/src/document/reducers.ts +99 -3
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
286
|
+
base = applyMutations(base, userPatches, 'base')
|
|
131
287
|
const baseAfter = base[documentId]
|
|
132
|
-
const diffedPatches =
|
|
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 =
|
|
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')
|