@sanity/sdk 2.15.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-dts/createGroqSearchFilter.d.ts +5 -4
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- 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 +79 -32
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/_exports/index.ts +2 -0
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/handleAuthCallback.test.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +199 -477
- package/src/auth/refreshStampedToken.ts +67 -195
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -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/src/document/sharedListener.test.ts +1 -0
- package/src/document/sharedListener.ts +2 -0
- package/src/query/queryStore.test.ts +232 -38
- package/src/query/queryStore.ts +47 -47
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +0 -16
- package/src/releases/getPerspectiveState.test.ts +3 -8
- package/src/releases/getPerspectiveState.ts +6 -19
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +51 -53
- package/src/releases/releasesStore.ts +32 -32
- package/src/releases/utils/sortReleases.test.ts +36 -0
- package/src/releases/utils/sortReleases.ts +14 -9
|
@@ -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
|
}
|
|
@@ -527,6 +527,151 @@ describe('processActions', () => {
|
|
|
527
527
|
})
|
|
528
528
|
})
|
|
529
529
|
|
|
530
|
+
describe('document.edit with preserveOperations', () => {
|
|
531
|
+
const createKeyedDoc = (): SanityDocument => ({
|
|
532
|
+
_id: 'drafts.doc1',
|
|
533
|
+
_type: 'article',
|
|
534
|
+
_createdAt: '2025-01-01T00:00:00.000Z',
|
|
535
|
+
_updatedAt: '2025-01-01T00:00:00.000Z',
|
|
536
|
+
_rev: 'initial',
|
|
537
|
+
items: [
|
|
538
|
+
{_key: 'a', value: 'first'},
|
|
539
|
+
{_key: 'b', value: 'second'},
|
|
540
|
+
],
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('forwards the given patches verbatim instead of re-diffing', () => {
|
|
544
|
+
const draft = createKeyedDoc()
|
|
545
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
546
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
547
|
+
const patches = [
|
|
548
|
+
{insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
|
|
549
|
+
]
|
|
550
|
+
const actions: DocumentAction[] = [
|
|
551
|
+
{
|
|
552
|
+
documentId: 'doc1',
|
|
553
|
+
documentType: 'article',
|
|
554
|
+
type: 'document.edit',
|
|
555
|
+
patches,
|
|
556
|
+
preserveOperations: true,
|
|
557
|
+
},
|
|
558
|
+
]
|
|
559
|
+
const result = processActions({
|
|
560
|
+
actions,
|
|
561
|
+
transactionId,
|
|
562
|
+
base,
|
|
563
|
+
working,
|
|
564
|
+
timestamp,
|
|
565
|
+
grants: defaultGrants,
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// the outgoing action carries the caller's keyed patch untouched
|
|
569
|
+
expect(result.outgoingActions).toEqual([
|
|
570
|
+
{
|
|
571
|
+
actionType: 'sanity.action.document.edit',
|
|
572
|
+
draftId: 'drafts.doc1',
|
|
573
|
+
publishedId: 'doc1',
|
|
574
|
+
patch: patches[0],
|
|
575
|
+
},
|
|
576
|
+
])
|
|
577
|
+
|
|
578
|
+
// and it still applies to the local working copy
|
|
579
|
+
expect(result.working['drafts.doc1']?.['items']).toEqual([
|
|
580
|
+
{_key: 'a', value: 'first'},
|
|
581
|
+
{_key: 'c', value: 'in between'},
|
|
582
|
+
{_key: 'b', value: 'second'},
|
|
583
|
+
])
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
it('applies keyed unsets and sets to the local working copy', () => {
|
|
587
|
+
const draft = createKeyedDoc()
|
|
588
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
589
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
590
|
+
const actions: DocumentAction[] = [
|
|
591
|
+
{
|
|
592
|
+
documentId: 'doc1',
|
|
593
|
+
documentType: 'article',
|
|
594
|
+
type: 'document.edit',
|
|
595
|
+
patches: [
|
|
596
|
+
{unset: ['items[_key=="a"]']},
|
|
597
|
+
{set: {'items[_key=="b"].value': 'updated second'}},
|
|
598
|
+
],
|
|
599
|
+
preserveOperations: true,
|
|
600
|
+
},
|
|
601
|
+
]
|
|
602
|
+
const result = processActions({
|
|
603
|
+
actions,
|
|
604
|
+
transactionId,
|
|
605
|
+
base,
|
|
606
|
+
working,
|
|
607
|
+
timestamp,
|
|
608
|
+
grants: defaultGrants,
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
expect(result.working['drafts.doc1']?.['items']).toEqual([
|
|
612
|
+
{_key: 'b', value: 'updated second'},
|
|
613
|
+
])
|
|
614
|
+
expect(result.outgoingActions.map((action) => 'patch' in action && action.patch)).toEqual([
|
|
615
|
+
{unset: ['items[_key=="a"]']},
|
|
616
|
+
{set: {'items[_key=="b"].value': 'updated second'}},
|
|
617
|
+
])
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('surfaces patch application failures as ActionError', () => {
|
|
621
|
+
const draft = {...createKeyedDoc(), count: 5}
|
|
622
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
623
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
624
|
+
const actions: DocumentAction[] = [
|
|
625
|
+
{
|
|
626
|
+
documentId: 'doc1',
|
|
627
|
+
documentType: 'article',
|
|
628
|
+
type: 'document.edit',
|
|
629
|
+
// diff-match-patch on a non-string value cannot apply
|
|
630
|
+
patches: [{diffMatchPatch: {count: '@@ -1,3 +1,3 @@\n-foo\n+bar\n'}}],
|
|
631
|
+
preserveOperations: true,
|
|
632
|
+
},
|
|
633
|
+
]
|
|
634
|
+
expect(() =>
|
|
635
|
+
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
|
|
636
|
+
).toThrow(ActionError)
|
|
637
|
+
expect(() =>
|
|
638
|
+
processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
|
|
639
|
+
).toThrow(/Failed to apply patches to the base document/)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('forwards patches verbatim through the liveEdit mutation path', () => {
|
|
643
|
+
const liveDoc = {...createKeyedDoc(), _id: 'live-doc'}
|
|
644
|
+
const base: DocumentSet = {'live-doc': liveDoc}
|
|
645
|
+
const working: DocumentSet = {'live-doc': liveDoc}
|
|
646
|
+
const patches = [{insert: {before: 'items[_key=="a"]', items: [{_key: 'z', value: 'start'}]}}]
|
|
647
|
+
const actions: DocumentAction[] = [
|
|
648
|
+
{
|
|
649
|
+
documentId: 'live-doc',
|
|
650
|
+
documentType: 'liveArticle',
|
|
651
|
+
type: 'document.edit',
|
|
652
|
+
liveEdit: true,
|
|
653
|
+
patches,
|
|
654
|
+
preserveOperations: true,
|
|
655
|
+
},
|
|
656
|
+
]
|
|
657
|
+
const result = processActions({
|
|
658
|
+
actions,
|
|
659
|
+
transactionId,
|
|
660
|
+
base,
|
|
661
|
+
working,
|
|
662
|
+
timestamp,
|
|
663
|
+
grants: defaultGrants,
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
expect(result.outgoingMutations).toEqual([{patch: {id: 'live-doc', ...patches[0]}}])
|
|
667
|
+
expect(result.working['live-doc']?.['items']).toEqual([
|
|
668
|
+
{_key: 'z', value: 'start'},
|
|
669
|
+
{_key: 'a', value: 'first'},
|
|
670
|
+
{_key: 'b', value: 'second'},
|
|
671
|
+
])
|
|
672
|
+
})
|
|
673
|
+
})
|
|
674
|
+
|
|
530
675
|
describe('document.publish', () => {
|
|
531
676
|
it('should publish a draft document', () => {
|
|
532
677
|
const draft = createDoc('drafts.doc1', 'Draft Title', '1')
|