@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "2.16.0",
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-test": "0.0.1"
85
+ "@repo/package.config": "0.0.1"
86
86
  },
87
87
  "browserslist": "extends @sanity/browserslist-config",
88
88
  "scripts": {
@@ -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,
@@ -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 {type ActionErrorEvent, type TransactionRevertedEvent} from './events'
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>({
@@ -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)
@@ -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, 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,
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 = processMutations({
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
- const patches = diffValue(baseBefore, baseAfter)
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 = processMutations({
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 = processMutations({documents: base, transactionId, mutations: userPatches, timestamp})
180
+ base = applyMutations(base, userPatches, 'base')
131
181
  const baseAfter = base[documentId]
132
- const diffedPatches = diffValue(baseBefore, baseAfter) as PatchOperations[]
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 = processMutations({
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
  }