@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.
@@ -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'})
@@ -775,6 +906,17 @@ it('fetches dataset ACL and updates grants in the document store state', async (
775
906
  })
776
907
  })
777
908
 
909
+ it('does not send credentials with the dataset ACL request', async () => {
910
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
911
+ await resolvePermissions(instance, {actions: [createDocument(doc)]})
912
+
913
+ const aclCall = vi
914
+ .mocked(client.request)
915
+ .mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
916
+ expect(aclCall).toBeDefined()
917
+ expect(aclCall![0]).not.toHaveProperty('withCredentials')
918
+ })
919
+
778
920
  it('fetches ACL for MediaLibraryResource', async () => {
779
921
  const mediaLibraryInstance = createSanityInstance({
780
922
  projectId: 'p',
@@ -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>({
@@ -557,7 +565,6 @@ const subscribeToClientAndFetchDatasetAcl = ({
557
565
  client.observable.request<DatasetAcl>({
558
566
  uri,
559
567
  tag: 'acl.get',
560
- withCredentials: true,
561
568
  }),
562
569
  ),
563
570
  tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
@@ -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)
@@ -205,4 +205,60 @@ describe('sortListenerEvents operator', () => {
205
205
  const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
206
206
  expect(events).toEqual([other])
207
207
  })
208
+
209
+ it('should discard replayed events already reflected in the base', async () => {
210
+ // The snapshot is at rev2; a resumed listener replays the event that
211
+ // produced rev2. It should be discarded instead of clogging the buffer.
212
+ const sync = createSyncEvent('rev2')
213
+ const replayed = createMutationEvent({
214
+ id: 'replayed',
215
+ previousRev: 'rev1',
216
+ resultRev: 'rev2',
217
+ })
218
+ const source$ = of(sync, replayed)
219
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
220
+ expect(events).toEqual([sync])
221
+ })
222
+
223
+ it('should discard the replayed part of a chain and apply the rest', async () => {
224
+ // The snapshot is at rev2; the listener replays rev1->rev2 followed by a
225
+ // genuinely new rev2->rev3. Only the new event should be emitted.
226
+ const sync = createSyncEvent('rev2')
227
+ const replayed = createMutationEvent({
228
+ id: 'replayed',
229
+ previousRev: 'rev1',
230
+ resultRev: 'rev2',
231
+ })
232
+ const fresh = createMutationEvent({
233
+ id: 'fresh',
234
+ previousRev: 'rev2',
235
+ resultRev: 'rev3',
236
+ })
237
+ const source$ = of(sync, replayed, fresh)
238
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
239
+ expect(events).toEqual([sync, fresh])
240
+ })
241
+
242
+ it('should discard a multi-event replayed chain that leads up to the base', async () => {
243
+ const sync = createSyncEvent('rev3')
244
+ // two replayed events that together lead up to the base revision
245
+ const replayedA = createMutationEvent({
246
+ id: 'replayedA',
247
+ previousRev: 'rev1',
248
+ resultRev: 'rev2',
249
+ })
250
+ const replayedB = createMutationEvent({
251
+ id: 'replayedB',
252
+ previousRev: 'rev2',
253
+ resultRev: 'rev3',
254
+ })
255
+ const fresh = createMutationEvent({
256
+ id: 'fresh',
257
+ previousRev: 'rev3',
258
+ resultRev: 'rev4',
259
+ })
260
+ const source$ = of(sync, replayedA, replayedB, fresh)
261
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
262
+ expect(events).toEqual([sync, fresh])
263
+ })
208
264
  })
@@ -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 {
@@ -90,11 +97,50 @@ interface SortListenerEventsOptions {
90
97
  resolveChainDeadline?: number
91
98
  }
92
99
 
100
+ /**
101
+ * Orders the given mutation events into chains where each event's
102
+ * `previousRev` points at another event's `resultRev`. Events whose
103
+ * `previousRev` matches no other event start a new chain. The buffer may have
104
+ * multiple holes in it, so multiple chains can exist at once.
105
+ */
106
+ function toOrderedChains(events: MutationEvent[]): MutationEvent[][] {
107
+ const chainStarts = events.filter(
108
+ (event) => !events.some((other) => other.resultRev === event.previousRev),
109
+ )
110
+
111
+ return chainStarts.map((start) => {
112
+ const chain: MutationEvent[] = []
113
+ let current: MutationEvent | undefined = start
114
+ while (current) {
115
+ chain.push(current)
116
+ const currentResultRev: string | undefined = current.resultRev
117
+ current = events.find((event) => event.previousRev === currentResultRev)
118
+ }
119
+ return chain
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Discards the leading events of a chain up to and including the event whose
125
+ * `resultRev` equals the given revision. These events describe changes that
126
+ * are already reflected in the current base (e.g. events replayed by a
127
+ * resumed listener after the snapshot was refetched).
128
+ */
129
+ function discardChainTo(chain: MutationEvent[], revision: string | undefined): MutationEvent[] {
130
+ const revisionIndex = chain.findIndex((event) => event.resultRev === revision)
131
+ if (revisionIndex === -1) return chain
132
+ return chain.slice(revisionIndex + 1)
133
+ }
134
+
93
135
  /**
94
136
  * Takes an input observable of listener events that might arrive out of order, and emits them in sequence
95
137
  * If we receive mutation events that doesn't line up in [previousRev, resultRev] pairs we'll put them in a buffer and
96
138
  * check if we have an unbroken chain every time we receive a new event
97
139
  *
140
+ * Buffered chains that lead up to the current base revision are discarded:
141
+ * they describe changes already reflected in the base (e.g. events replayed
142
+ * by a resumed listener after the snapshot was refetched)
143
+ *
98
144
  * If the buffer grows beyond `maxBufferSize`, or if `resolveChainDeadline` milliseconds passes before the chain resolves
99
145
  * an OutOfSyncError will be thrown on the stream
100
146
  *
@@ -125,38 +171,40 @@ export function sortListenerEvents(options?: SortListenerEventsOptions) {
125
171
  'Invalid state. Cannot process mutation event without a base sync event',
126
172
  )
127
173
  }
128
- // Add the new mutation event into the buffer
129
- const buffer = state.buffer.concat(event)
130
- const emitEvents: MutationEvent[] = []
131
- let baseRevision = state.base.revision
132
- let progress = true
174
+ const baseRevision = state.base.revision
175
+
176
+ // Order the buffered events (plus the new one) into chains, then
177
+ // drop the parts of each chain the base already reflects.
178
+ const chains = toOrderedChains(state.buffer.concat(event))
179
+ .map((chain) => discardChainTo(chain, baseRevision))
180
+ .filter((chain) => chain.length > 0)
181
+
182
+ // A chain whose head continues the current base revision can be
183
+ // applied. There can be at most one; anything else stays buffered.
184
+ const applicable = chains.find((chain) => chain[0].previousRev === baseRevision)
185
+ const buffer = chains.filter((chain) => chain !== applicable).flat()
133
186
 
134
- // Try to apply as many buffered mutations as possible.
135
- while (progress) {
136
- progress = false
137
- // Look for a mutation whose previousRev matches the current base.
138
- const idx = buffer.findIndex((e) => e.previousRev === baseRevision)
139
- if (idx !== -1) {
140
- // Remove the event from the buffer and “apply” it.
141
- const [next] = buffer.splice(idx, 1)
142
- emitEvents.push(next)
143
- // If the mutation is a deletion, the new base revision is undefined.
144
- baseRevision = next.transition === 'disappear' ? undefined : next.resultRev
145
- progress = true
187
+ if (applicable) {
188
+ const last = applicable[applicable.length - 1]
189
+ return {
190
+ // If the chain ends in a deletion, the new base revision is undefined.
191
+ base: {revision: last.transition === 'disappear' ? undefined : last.resultRev},
192
+ buffer,
193
+ emitEvents: applicable,
146
194
  }
147
195
  }
148
196
 
149
197
  if (buffer.length >= maxBufferSize) {
150
198
  throw new MaxBufferExceededError(
151
199
  `Too many unchainable mutation events (${buffer.length}) waiting to resolve.`,
152
- {base: {revision: baseRevision}, buffer, emitEvents},
200
+ {base: {revision: baseRevision}, buffer, emitEvents: []},
153
201
  )
154
202
  }
155
203
 
156
204
  return {
157
205
  base: {revision: baseRevision},
158
206
  buffer,
159
- emitEvents,
207
+ emitEvents: [],
160
208
  }
161
209
  }
162
210
  // Any other event is simply forwarded.
@@ -201,7 +249,11 @@ export const listen = (
201
249
 
202
250
  return sharedListener.events.pipe(
203
251
  concatMap((e) => {
204
- if (e.type === 'welcome') {
252
+ // `welcome` means a fresh listener connection and `reset` means a
253
+ // resumed listener could not replay what was missed; both require
254
+ // refetching the snapshot. a `welcomeback` means the resume succeeded
255
+ // and the missed events were replayed, so no refetch is needed
256
+ if (e.type === 'welcome' || e.type === 'reset') {
205
257
  return fetchDocument(documentId).pipe(
206
258
  map((document): SyncEvent => ({type: 'sync', document})),
207
259
  )
@@ -251,6 +303,7 @@ export const listen = (
251
303
  document: document ?? null,
252
304
  revision: transactionId,
253
305
  timestamp,
306
+ mutations: next.mutations as Mutation[],
254
307
  ...(previousRev && {previousRev}),
255
308
  }
256
309
  }),