@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.
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Two-client concurrency rig for the document store.
3
+ *
4
+ * Two real store instances (separate optimistic pipelines, like two machines)
5
+ * are connected to a mock Content Lake that:
6
+ *
7
+ * - holds submitted transactions until the test releases them, so both clients
8
+ * compute their edits against the same base revision (true concurrency)
9
+ * - applies released transactions with `processMutations` (the SDK's own
10
+ * content-lake-faithful applier) against the current server state
11
+ * - emits listener mutation events carrying the actual submitted mutations
12
+ * with `transactionId`/`previousRev`/`resultRev`, to every connected client
13
+ *
14
+ * Every scenario runs twice: once with client A's transaction applied first
15
+ * and once with client B's first. After both transactions settle, both
16
+ * clients must converge to the server's state. Scenario-specific assertions
17
+ * then check for data loss (marker survival). Outcomes that are lossy with
18
+ * the current snapshot-diff rebase are documented in `KNOWN_LOSS` so later
19
+ * phases (DMP squashing, operational rebase) can flip them deliberately.
20
+ */
21
+ import {
22
+ type BaseActionOptions,
23
+ type ListenEvent,
24
+ type MutationEvent,
25
+ type SanityClient,
26
+ type WelcomeEvent,
27
+ } from '@sanity/client'
28
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
29
+ import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
30
+ import {
31
+ defer,
32
+ delay,
33
+ first,
34
+ firstValueFrom,
35
+ from,
36
+ Observable,
37
+ of,
38
+ ReplaySubject,
39
+ Subject,
40
+ } from 'rxjs'
41
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
42
+
43
+ import {getClientState} from '../client/clientStore'
44
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
45
+ import {type StateSource} from '../store/createStateSourceAction'
46
+ import {editDocument, publishDocument} from './actions'
47
+ import {applyDocumentActions} from './applyDocumentActions'
48
+ import {getDocumentState, getDocumentSyncStatus} from './documentStore'
49
+ import {type DatasetAcl} from './permissions'
50
+ import {type DocumentSet, getDocumentIds, processMutations} from './processMutations'
51
+ import {type HttpAction} from './reducers'
52
+ import {createFetchDocument, createSharedListener} from './sharedListener'
53
+
54
+ vi.mock('../client/clientStore.ts', () => ({
55
+ getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
56
+ }))
57
+
58
+ vi.mock('./sharedListener.ts', () => ({
59
+ createSharedListener: vi.fn(),
60
+ createFetchDocument: vi.fn(),
61
+ }))
62
+
63
+ vi.mock('./documentConstants.ts', async (importOriginal) => {
64
+ const original = await importOriginal<typeof import('./documentConstants')>()
65
+ return {
66
+ ...original,
67
+ INITIAL_OUTGOING_THROTTLE_TIME: 0,
68
+ DOCUMENT_STATE_CLEAR_DELAY: 25,
69
+ OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
70
+ OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
71
+ }
72
+ })
73
+
74
+ interface Span {
75
+ _key: string
76
+ _type: 'span'
77
+ text: string
78
+ marks: string[]
79
+ }
80
+
81
+ interface Block {
82
+ _key: string
83
+ _type: 'block'
84
+ style: 'normal'
85
+ children: Span[]
86
+ markDefs: {_key: string; _type: string; href?: string}[]
87
+ }
88
+
89
+ interface RigDocument extends SanityDocument {
90
+ title?: string
91
+ subtitle?: string
92
+ count?: number
93
+ content?: Block[]
94
+ }
95
+
96
+ interface HeldSubmission {
97
+ transactionId: string
98
+ actions: HttpAction[]
99
+ resolve: (result: {transactionId: string}) => void
100
+ }
101
+
102
+ interface MockContentLake {
103
+ connect: () => {events: Observable<ListenEvent<SanityDocument>>; dispose: () => void}
104
+ getDocument: (id: string) => SanityDocument | null
105
+ seed: (docs: DocumentSet) => void
106
+ holdSubmissions: () => void
107
+ submitActions: (actions: HttpAction[], transactionId: string) => Promise<{transactionId: string}>
108
+ release: (transactionId: string) => Promise<void>
109
+ pendingTransactionIds: () => string[]
110
+ }
111
+
112
+ function createMockContentLake(): MockContentLake {
113
+ let documents: DocumentSet = {}
114
+ const connections: Subject<ListenEvent<SanityDocument>>[] = []
115
+ let holding = false
116
+ const held: HeldSubmission[] = []
117
+
118
+ function convertAction(action: HttpAction, current: DocumentSet): Mutation[] {
119
+ switch (action.actionType) {
120
+ case 'sanity.action.document.edit': {
121
+ const source = (action.draftId && current[action.draftId]) ?? current[action.publishedId]
122
+ if (!source) {
123
+ throw new Error(
124
+ `Mock backend: no document found for edit (draftId: ${action.draftId}, publishedId: ${action.publishedId})`,
125
+ )
126
+ }
127
+ return [
128
+ {createIfNotExists: {...source, _id: action.draftId}},
129
+ {patch: {id: action.draftId, ...action.patch}},
130
+ ]
131
+ }
132
+ case 'sanity.action.document.publish': {
133
+ const draft = current[action.draftId]
134
+ if (!draft) {
135
+ throw new Error(`Mock backend: cannot publish, draft ${action.draftId} not found`)
136
+ }
137
+ // NOTE: the real Actions API enforces the optimistic lock
138
+ // (`ifDraftRevisionId`); this mock intentionally ignores it so the
139
+ // matrix can observe client-side conflict behavior in isolation
140
+ return [
141
+ {delete: {id: action.draftId}},
142
+ {createOrReplace: {...draft, _id: action.publishedId}},
143
+ ]
144
+ }
145
+ default:
146
+ throw new Error(`Mock backend: unsupported action type ${action.actionType}`)
147
+ }
148
+ }
149
+
150
+ function mutationTargets(mutation: Mutation): string[] {
151
+ if ('patch' in mutation) return getDocumentIds(mutation.patch)
152
+ if ('delete' in mutation) return getDocumentIds(mutation.delete)
153
+ if ('create' in mutation && mutation.create._id) return [mutation.create._id]
154
+ if ('createOrReplace' in mutation) return [mutation.createOrReplace._id]
155
+ if ('createIfNotExists' in mutation) return [mutation.createIfNotExists._id]
156
+ return []
157
+ }
158
+
159
+ async function apply(submission: HeldSubmission): Promise<void> {
160
+ const {transactionId, actions} = submission
161
+ const timestamp = new Date().toISOString()
162
+ const prior = {...documents}
163
+ let next = {...documents}
164
+ const allMutations: Mutation[] = []
165
+
166
+ for (const action of actions) {
167
+ const mutations = convertAction(action, next)
168
+ next = processMutations({documents: next, mutations, transactionId, timestamp})
169
+ allMutations.push(...mutations)
170
+ }
171
+
172
+ documents = next
173
+
174
+ const touchedIds = Array.from(new Set(allMutations.flatMap(mutationTargets)))
175
+ const events: MutationEvent[] = []
176
+ let transactionCurrentEvent = 0
177
+
178
+ for (const id of touchedIds) {
179
+ const before = prior[id]
180
+ const after = next[id]
181
+ if (!before && !after) continue
182
+ transactionCurrentEvent++
183
+ const mutationsForDoc = allMutations.filter((m) => mutationTargets(m).includes(id))
184
+ events.push({
185
+ type: 'mutation',
186
+ documentId: id,
187
+ eventId: `${transactionId}#${id}`,
188
+ identity: 'remote-user',
189
+ mutations: mutationsForDoc,
190
+ timestamp,
191
+ transactionId,
192
+ transactionCurrentEvent,
193
+ transactionTotalEvents: 0, // patched below once we know the count
194
+ transition: before && after ? 'update' : after ? 'appear' : 'disappear',
195
+ visibility: 'query',
196
+ ...(before && {previousRev: before._rev}),
197
+ ...(after && {resultRev: transactionId}),
198
+ })
199
+ }
200
+ for (const event of events) event.transactionTotalEvents = events.length
201
+
202
+ // let the store's submission bookkeeping settle before the echo arrives
203
+ await new Promise((resolve) => setTimeout(resolve, 0))
204
+
205
+ for (const event of events) {
206
+ for (const connection of connections) connection.next(event)
207
+ }
208
+
209
+ // ack after the listener echo, mirroring async visibility
210
+ await new Promise((resolve) => setTimeout(resolve, 0))
211
+ submission.resolve({transactionId})
212
+ }
213
+
214
+ return {
215
+ connect() {
216
+ const subject = new Subject<ListenEvent<SanityDocument>>()
217
+ connections.push(subject)
218
+ const welcome: WelcomeEvent = {
219
+ type: 'welcome',
220
+ listenerName: `mock-listener-${connections.length}`,
221
+ }
222
+ return {
223
+ events: new Observable<ListenEvent<SanityDocument>>((observer) => {
224
+ observer.next(welcome)
225
+ return subject.subscribe(observer)
226
+ }),
227
+ dispose: () => {
228
+ const index = connections.indexOf(subject)
229
+ if (index !== -1) connections.splice(index, 1)
230
+ },
231
+ }
232
+ },
233
+ getDocument: (id) => documents[id] ?? null,
234
+ seed(docs) {
235
+ documents = {...documents, ...docs}
236
+ },
237
+ holdSubmissions() {
238
+ holding = true
239
+ },
240
+ submitActions(actions, transactionId) {
241
+ return new Promise<{transactionId: string}>((resolve, reject) => {
242
+ const submission: HeldSubmission = {transactionId, actions, resolve}
243
+ if (holding) {
244
+ held.push(submission)
245
+ } else {
246
+ apply(submission).catch(reject)
247
+ }
248
+ })
249
+ },
250
+ async release(transactionId) {
251
+ const index = held.findIndex((s) => s.transactionId === transactionId)
252
+ if (index === -1) throw new Error(`No held submission with transaction ID ${transactionId}`)
253
+ const [submission] = held.splice(index, 1)
254
+ await apply(submission)
255
+ },
256
+ pendingTransactionIds: () => held.map((s) => s.transactionId),
257
+ }
258
+ }
259
+
260
+ function createMockClient(server: MockContentLake): SanityClient {
261
+ const datasetAcl: DatasetAcl = [
262
+ {filter: 'true', permissions: ['create', 'history', 'read', 'update']},
263
+ ]
264
+
265
+ const action = vi.fn(async (input: HttpAction | HttpAction[], options?: BaseActionOptions) => {
266
+ const actions = Array.isArray(input) ? input : [input]
267
+ return server.submitActions(actions, options?.transactionId ?? crypto.randomUUID())
268
+ })
269
+
270
+ const request = vi.fn(async () => {
271
+ await new Promise((resolve) => setTimeout(resolve, 10))
272
+ return datasetAcl
273
+ })
274
+
275
+ const fetch = vi.fn(async () => {
276
+ throw new Error('Mock client: fetch is not supported in the concurrency rig')
277
+ })
278
+
279
+ return {
280
+ action,
281
+ request,
282
+ fetch,
283
+ observable: {
284
+ action: (...args: Parameters<typeof action>) => from(action(...args)),
285
+ request: (...args: Parameters<typeof request>) => from(request(...args)),
286
+ fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
287
+ },
288
+ } as unknown as SanityClient
289
+ }
290
+
291
+ function span(key: string, text: string, marks: string[] = []): Span {
292
+ return {_key: key, _type: 'span', text, marks}
293
+ }
294
+
295
+ function block(key: string, spanKey: string, text: string): Block {
296
+ return {
297
+ _key: key,
298
+ _type: 'block',
299
+ style: 'normal',
300
+ children: [span(spanKey, text)],
301
+ markDefs: [],
302
+ }
303
+ }
304
+
305
+ const DOCUMENT_ID = DocumentId('rig-doc')
306
+ const DRAFT_ID = getDraftId(DOCUMENT_ID)
307
+ const PUBLISHED_ID = getPublishedId(DOCUMENT_ID)
308
+ const handle = {documentId: DOCUMENT_ID, documentType: 'article'}
309
+
310
+ const TITLE = 'The quick brown fox jumps over the lazy dog'
311
+
312
+ function seedDocument(): SanityDocument {
313
+ return {
314
+ _id: DRAFT_ID,
315
+ _type: 'article',
316
+ _rev: 'seed-rev',
317
+ _createdAt: '2026-01-01T00:00:00.000Z',
318
+ _updatedAt: '2026-01-01T00:00:00.000Z',
319
+ title: TITLE,
320
+ subtitle: 'original subtitle',
321
+ count: 0,
322
+ content: [
323
+ block('blkA', 'spA', 'alpha bravo charlie delta'),
324
+ block('blkB', 'spB', 'echo foxtrot golf hotel'),
325
+ ],
326
+ }
327
+ }
328
+
329
+ interface ClientContext {
330
+ instance: SanityInstance
331
+ resource: {projectId: string; dataset: string}
332
+ }
333
+
334
+ type Act = (client: ClientContext) => Promise<unknown>
335
+
336
+ interface CollisionResult {
337
+ serverDraft: RigDocument | null
338
+ serverPublished: RigDocument | null
339
+ /** the local view (draft and published) each client settled on */
340
+ local: {
341
+ A: {draft: RigDocument | null; published: RigDocument | null}
342
+ B: {draft: RigDocument | null; published: RigDocument | null}
343
+ }
344
+ }
345
+
346
+ interface Scenario {
347
+ id: string
348
+ title: string
349
+ actA: Act
350
+ actB: Act
351
+ /**
352
+ * Scenario-specific assertions on the settled state. `firstWriter` is which
353
+ * client's transaction the server applied first.
354
+ */
355
+ verify: (result: CollisionResult, firstWriter: 'A' | 'B') => void
356
+ }
357
+
358
+ const editField = (patches: PatchOperations, transactionId: string): Act => {
359
+ return ({instance, resource}) =>
360
+ applyDocumentActions(instance, {
361
+ actions: [editDocument(handle, patches)],
362
+ resource,
363
+ transactionId,
364
+ })
365
+ }
366
+
367
+ const editPreserved = (patches: PatchOperations[], transactionId: string): Act => {
368
+ return ({instance, resource}) =>
369
+ applyDocumentActions(instance, {
370
+ actions: [editDocument(handle, patches, {preserveOperations: true})],
371
+ resource,
372
+ transactionId,
373
+ })
374
+ }
375
+
376
+ const getSpanText = (doc: RigDocument | null, blockKey: string): string | undefined => {
377
+ const blockNode = doc?.content?.find((b) => b._key === blockKey)
378
+ return blockNode?.children[0]?.text
379
+ }
380
+
381
+ const getSpanMarks = (doc: RigDocument | null, blockKey: string): string[] | undefined => {
382
+ const blockNode = doc?.content?.find((b) => b._key === blockKey)
383
+ return blockNode?.children[0]?.marks
384
+ }
385
+
386
+ /**
387
+ * Outcomes that are known to lose data with the current snapshot-diff
388
+ * pipeline. Each entry documents the current (lossy) behavior; later phases
389
+ * are expected to remove entries from this list and flip the assertions.
390
+ * Keyed by `scenarioId` with an optional restriction on which apply order
391
+ * exhibits the loss.
392
+ */
393
+ const KNOWN_LOSS: {scenario: string; firstWriter?: 'A' | 'B'; reason: string}[] = [
394
+ {
395
+ scenario: 'preserved-set-text-vs-typing',
396
+ reason:
397
+ 'preserveOperations forwards whole-value sets verbatim, so whichever preserved set lands second overwrites the concurrent one (server-side last-write-wins)',
398
+ },
399
+ ]
400
+
401
+ const isKnownLoss = (scenarioId: string, firstWriter: 'A' | 'B'): boolean =>
402
+ KNOWN_LOSS.some(
403
+ (entry) =>
404
+ entry.scenario === scenarioId && (!entry.firstWriter || entry.firstWriter === firstWriter),
405
+ )
406
+
407
+ /**
408
+ * Cases where a client's local copy is known to diverge from the server after
409
+ * everything settles. The rig asserts the divergence so the entry must be
410
+ * removed (flipping the assertion to convergence) once a later phase fixes it.
411
+ */
412
+ const KNOWN_DIVERGENCE: {
413
+ scenario: string
414
+ firstWriter: 'A' | 'B'
415
+ client: 'A' | 'B'
416
+ documentId: string
417
+ reason: string
418
+ }[] = []
419
+
420
+ const isKnownDivergence = (
421
+ scenarioId: string,
422
+ firstWriter: 'A' | 'B',
423
+ client: 'A' | 'B',
424
+ documentId: string,
425
+ ): boolean =>
426
+ KNOWN_DIVERGENCE.some(
427
+ (entry) =>
428
+ entry.scenario === scenarioId &&
429
+ entry.firstWriter === firstWriter &&
430
+ entry.client === client &&
431
+ entry.documentId === documentId,
432
+ )
433
+
434
+ const scenarios: Scenario[] = [
435
+ {
436
+ id: 'string-disjoint-inserts',
437
+ title: 'A and B insert into the same string at different offsets',
438
+ actA: editField({set: {title: TITLE.replace('quick ', 'quick oneA ')}}, 'txn-a'),
439
+ actB: editField({set: {title: TITLE.replace('lazy ', 'lazy twoB ')}}, 'txn-b'),
440
+ verify: ({serverDraft}) => {
441
+ expect(serverDraft?.title).toContain('oneA')
442
+ expect(serverDraft?.title).toContain('twoB')
443
+ },
444
+ },
445
+ {
446
+ id: 'string-same-offset-inserts',
447
+ title: 'A and B insert into the same string at the same offset',
448
+ actA: editField({set: {title: TITLE.replace('quick ', 'quick oneA ')}}, 'txn-a'),
449
+ actB: editField({set: {title: TITLE.replace('quick ', 'quick twoB ')}}, 'txn-b'),
450
+ verify: ({serverDraft}) => {
451
+ expect(serverDraft?.title).toContain('oneA')
452
+ expect(serverDraft?.title).toContain('twoB')
453
+ },
454
+ },
455
+ {
456
+ id: 'string-delete-vs-insert-inside',
457
+ title: 'A deletes a range while B inserts inside that range',
458
+ actA: editField({set: {title: TITLE.replace('brown fox ', '')}}, 'txn-a'),
459
+ actB: editField({set: {title: TITLE.replace('fox ', 'fox twoB ')}}, 'txn-b'),
460
+ verify: ({serverDraft}) => {
461
+ // deletion vs insert-inside has no universally correct answer; require
462
+ // convergence and that the title is still a single coherent string
463
+ expect(typeof serverDraft?.title).toBe('string')
464
+ expect(serverDraft?.title).toContain('quick')
465
+ expect(serverDraft?.title).toContain('lazy dog')
466
+ },
467
+ },
468
+ {
469
+ id: 'counter-inc-inc',
470
+ title: 'A and B both increment the same counter',
471
+ actA: editField({inc: {count: 1}}, 'txn-a'),
472
+ actB: editField({inc: {count: 1}}, 'txn-b'),
473
+ verify: ({serverDraft}, firstWriter) => {
474
+ if (isKnownLoss('counter-inc-inc', firstWriter)) {
475
+ expect(serverDraft?.count).toBe(1)
476
+ } else {
477
+ expect(serverDraft?.count).toBe(2)
478
+ }
479
+ },
480
+ },
481
+ {
482
+ id: 'different-fields',
483
+ title: 'A and B set different fields',
484
+ actA: editField({set: {title: 'title set by A'}}, 'txn-a'),
485
+ actB: editField({set: {subtitle: 'subtitle set by B'}}, 'txn-b'),
486
+ verify: ({serverDraft}) => {
487
+ expect(serverDraft?.title).toBe('title set by A')
488
+ expect(serverDraft?.subtitle).toBe('subtitle set by B')
489
+ },
490
+ },
491
+ {
492
+ id: 'different-blocks-text',
493
+ title: 'A and B type into different blocks of the same array',
494
+ actA: editField(
495
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
496
+ 'txn-a',
497
+ ),
498
+ actB: editField(
499
+ {set: {'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel'}},
500
+ 'txn-b',
501
+ ),
502
+ verify: ({serverDraft}) => {
503
+ expect(getSpanText(serverDraft, 'blkA')).toContain('oneA')
504
+ expect(getSpanText(serverDraft, 'blkB')).toContain('twoB')
505
+ },
506
+ },
507
+ {
508
+ id: 'same-span-disjoint-offsets',
509
+ title: 'A and B type into the same span at different offsets',
510
+ actA: editField(
511
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
512
+ 'txn-a',
513
+ ),
514
+ actB: editField(
515
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta'}},
516
+ 'txn-b',
517
+ ),
518
+ verify: ({serverDraft}) => {
519
+ const text = getSpanText(serverDraft, 'blkA')
520
+ expect(text).toContain('oneA')
521
+ expect(text).toContain('twoB')
522
+ },
523
+ },
524
+ {
525
+ id: 'append-block-vs-append-block',
526
+ title: 'A and B each append a different block via whole-array set',
527
+ actA: ({instance, resource}) => {
528
+ const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
529
+ const content = [...(current?.content ?? []), block('blkC', 'spC', 'appended by A')]
530
+ return applyDocumentActions(instance, {
531
+ actions: [editDocument(handle, {set: {content}})],
532
+ resource,
533
+ transactionId: 'txn-a',
534
+ })
535
+ },
536
+ actB: ({instance, resource}) => {
537
+ const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
538
+ const content = [...(current?.content ?? []), block('blkD', 'spD', 'appended by B')]
539
+ return applyDocumentActions(instance, {
540
+ actions: [editDocument(handle, {set: {content}})],
541
+ resource,
542
+ transactionId: 'txn-b',
543
+ })
544
+ },
545
+ verify: ({serverDraft}) => {
546
+ const keys = serverDraft?.content?.map((b) => b._key)
547
+ expect(keys).toContain('blkC')
548
+ expect(keys).toContain('blkD')
549
+ },
550
+ },
551
+ {
552
+ id: 'delete-block-vs-edit-within',
553
+ title: 'A deletes a block while B types inside it',
554
+ actA: ({instance, resource}) => {
555
+ const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
556
+ const content = (current?.content ?? []).filter((b) => b._key !== 'blkB')
557
+ return applyDocumentActions(instance, {
558
+ actions: [editDocument(handle, {set: {content}})],
559
+ resource,
560
+ transactionId: 'txn-a',
561
+ })
562
+ },
563
+ actB: editField(
564
+ {set: {'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel'}},
565
+ 'txn-b',
566
+ ),
567
+ verify: ({serverDraft}) => {
568
+ // delete-vs-edit has no universally correct answer; require convergence
569
+ // and that surviving blocks are intact
570
+ expect(getSpanText(serverDraft, 'blkA')).toBe('alpha bravo charlie delta')
571
+ const blkB = serverDraft?.content?.find((b) => b._key === 'blkB')
572
+ if (blkB) {
573
+ expect(blkB.children).toHaveLength(1)
574
+ expect(typeof blkB.children[0].text).toBe('string')
575
+ }
576
+ },
577
+ },
578
+ {
579
+ id: 'preserved-keyed-inserts',
580
+ title: 'A and B insert different blocks with preserveOperations',
581
+ actA: editPreserved(
582
+ [{insert: {after: 'content[_key=="blkA"]', items: [block('blkC', 'spC', 'inserted by A')]}}],
583
+ 'txn-a',
584
+ ),
585
+ actB: editPreserved(
586
+ [{insert: {after: 'content[_key=="blkB"]', items: [block('blkD', 'spD', 'inserted by B')]}}],
587
+ 'txn-b',
588
+ ),
589
+ verify: ({serverDraft}) => {
590
+ const keys = serverDraft?.content?.map((b) => b._key)
591
+ expect(keys).toContain('blkC')
592
+ expect(keys).toContain('blkD')
593
+ },
594
+ },
595
+ {
596
+ id: 'preserved-set-text-vs-typing',
597
+ title: 'A and B type into the same span via preserveOperations sets (PTE plugin path)',
598
+ actA: editPreserved(
599
+ [
600
+ {
601
+ set: {
602
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
603
+ },
604
+ },
605
+ ],
606
+ 'txn-a',
607
+ ),
608
+ actB: editPreserved(
609
+ [
610
+ {
611
+ set: {
612
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
613
+ },
614
+ },
615
+ ],
616
+ 'txn-b',
617
+ ),
618
+ verify: ({serverDraft}, firstWriter) => {
619
+ const text = getSpanText(serverDraft, 'blkA')
620
+ const winner = firstWriter === 'A' ? 'twoB' : 'oneA'
621
+ const loser = firstWriter === 'A' ? 'oneA' : 'twoB'
622
+ if (isKnownLoss('preserved-set-text-vs-typing', firstWriter)) {
623
+ // whichever set lands second overwrites the whole string
624
+ expect(text).toContain(winner)
625
+ expect(text).not.toContain(loser)
626
+ } else {
627
+ expect(text).toContain('oneA')
628
+ expect(text).toContain('twoB')
629
+ }
630
+ },
631
+ },
632
+ {
633
+ id: 'format-vs-typing',
634
+ title: 'A formats a span (marks) while B types into it',
635
+ actA: editField(
636
+ {set: {'content[_key=="blkA"].children[_key=="spA"].marks': ['strong']}},
637
+ 'txn-a',
638
+ ),
639
+ actB: editField(
640
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo twoB charlie delta'}},
641
+ 'txn-b',
642
+ ),
643
+ verify: ({serverDraft}) => {
644
+ expect(getSpanMarks(serverDraft, 'blkA')).toEqual(['strong'])
645
+ expect(getSpanText(serverDraft, 'blkA')).toContain('twoB')
646
+ },
647
+ },
648
+ {
649
+ id: 'publish-vs-edit',
650
+ title: 'A publishes while B edits the draft',
651
+ actA: ({instance, resource}) =>
652
+ applyDocumentActions(instance, {
653
+ actions: [publishDocument(handle)],
654
+ resource,
655
+ transactionId: 'txn-a',
656
+ }),
657
+ actB: editField({set: {title: `${TITLE} twoB`}}, 'txn-b'),
658
+ verify: ({serverDraft, serverPublished}, firstWriter) => {
659
+ if (firstWriter === 'A') {
660
+ // publish happened against the pre-edit draft; B's edit then
661
+ // recreated the draft (from the published copy) and applied on top
662
+ expect(serverPublished?.title).toBe(TITLE)
663
+ expect(serverDraft?.title).toBe(`${TITLE} twoB`)
664
+ } else {
665
+ // B's edit landed first, so the publish carried it along and the
666
+ // draft was consumed by the publish
667
+ expect(serverPublished?.title).toBe(`${TITLE} twoB`)
668
+ expect(serverDraft).toBeNull()
669
+ }
670
+ },
671
+ },
672
+ ]
673
+
674
+ let server: MockContentLake
675
+ let clientA: ClientContext
676
+ let clientB: ClientContext
677
+
678
+ beforeEach(() => {
679
+ server = createMockContentLake()
680
+
681
+ const client$ = (getClientState as unknown as () => StateSource<SanityClient>)()
682
+ .observable as ReplaySubject<SanityClient>
683
+ client$.next(createMockClient(server))
684
+
685
+ vi.mocked(createSharedListener).mockImplementation(() => server.connect())
686
+ vi.mocked(createFetchDocument).mockImplementation(
687
+ () => (id: string) => defer(() => of(server.getDocument(id))).pipe(delay(0)),
688
+ )
689
+
690
+ clientA = {
691
+ instance: createSanityInstance({projectId: 'p', dataset: 'rig-a'}),
692
+ resource: {projectId: 'p', dataset: 'rig-a'},
693
+ }
694
+ clientB = {
695
+ instance: createSanityInstance({projectId: 'p', dataset: 'rig-b'}),
696
+ resource: {projectId: 'p', dataset: 'rig-b'},
697
+ }
698
+ })
699
+
700
+ afterEach(() => {
701
+ clientA.instance.dispose()
702
+ clientB.instance.dispose()
703
+ })
704
+
705
+ const localDoc = (client: ClientContext, id: string): RigDocument | null | undefined =>
706
+ getDocumentState<RigDocument>(client.instance, {
707
+ documentId: id,
708
+ documentType: 'article',
709
+ liveEdit: true,
710
+ }).getCurrent() as RigDocument | null | undefined
711
+
712
+ /**
713
+ * Drops `_updatedAt` before convergence comparison. When a client's own
714
+ * transaction echo fast-forwards, its optimistic `local` keeps the timestamp
715
+ * computed client-side while the server stamped its own time; the content is
716
+ * identical. Only content convergence matters to the matrix.
717
+ */
718
+ const withoutUpdatedAt = (doc: RigDocument | SanityDocument | null): unknown => {
719
+ if (!doc) return doc
720
+ const {_updatedAt, ...rest} = doc
721
+ return rest
722
+ }
723
+
724
+ async function runCollision(
725
+ scenario: Scenario,
726
+ order: 'a-first' | 'b-first',
727
+ ): Promise<CollisionResult> {
728
+ server.seed({[DRAFT_ID]: seedDocument()})
729
+
730
+ const stateA = getDocumentState<RigDocument>(clientA.instance, handle)
731
+ const stateB = getDocumentState<RigDocument>(clientB.instance, handle)
732
+ const unsubscribeA = stateA.subscribe()
733
+ const unsubscribeB = stateB.subscribe()
734
+
735
+ try {
736
+ await firstValueFrom(stateA.observable.pipe(first((doc) => doc !== undefined)))
737
+ await firstValueFrom(stateB.observable.pipe(first((doc) => doc !== undefined)))
738
+
739
+ // hold submissions so both clients edit against the same base revision
740
+ server.holdSubmissions()
741
+
742
+ await scenario.actA(clientA)
743
+ await scenario.actB(clientB)
744
+
745
+ await vi.waitFor(() => {
746
+ expect(server.pendingTransactionIds()).toHaveLength(2)
747
+ })
748
+
749
+ const [firstTxn, secondTxn] = order === 'a-first' ? ['txn-a', 'txn-b'] : ['txn-b', 'txn-a']
750
+ await server.release(firstTxn)
751
+ await server.release(secondTxn)
752
+
753
+ const syncA = getDocumentSyncStatus(clientA.instance, handle)
754
+ const syncB = getDocumentSyncStatus(clientB.instance, handle)
755
+
756
+ const firstWriter = order === 'a-first' ? 'A' : 'B'
757
+ const clients = [
758
+ {label: 'A' as const, context: clientA},
759
+ {label: 'B' as const, context: clientB},
760
+ ]
761
+
762
+ await vi.waitFor(
763
+ () => {
764
+ expect(syncA.getCurrent()).toBe(true)
765
+ expect(syncB.getCurrent()).toBe(true)
766
+ for (const id of [DRAFT_ID, PUBLISHED_ID]) {
767
+ const serverDoc = withoutUpdatedAt(server.getDocument(id))
768
+ for (const {label, context} of clients) {
769
+ if (isKnownDivergence(scenario.id, firstWriter, label, id)) continue
770
+ expect(withoutUpdatedAt(localDoc(context, id) ?? null)).toEqual(serverDoc)
771
+ }
772
+ }
773
+ },
774
+ {timeout: 3000},
775
+ )
776
+
777
+ // assert the documented divergences still diverge, so fixing one forces
778
+ // its KNOWN_DIVERGENCE entry to be removed
779
+ for (const id of [DRAFT_ID, PUBLISHED_ID]) {
780
+ const serverDoc = withoutUpdatedAt(server.getDocument(id))
781
+ for (const {label, context} of clients) {
782
+ if (!isKnownDivergence(scenario.id, firstWriter, label, id)) continue
783
+ expect(withoutUpdatedAt(localDoc(context, id) ?? null)).not.toEqual(serverDoc)
784
+ }
785
+ }
786
+
787
+ return {
788
+ serverDraft: server.getDocument(DRAFT_ID) as RigDocument | null,
789
+ serverPublished: server.getDocument(PUBLISHED_ID) as RigDocument | null,
790
+ local: {
791
+ A: {
792
+ draft: localDoc(clientA, DRAFT_ID) ?? null,
793
+ published: localDoc(clientA, PUBLISHED_ID) ?? null,
794
+ },
795
+ B: {
796
+ draft: localDoc(clientB, DRAFT_ID) ?? null,
797
+ published: localDoc(clientB, PUBLISHED_ID) ?? null,
798
+ },
799
+ },
800
+ }
801
+ } finally {
802
+ unsubscribeA()
803
+ unsubscribeB()
804
+ }
805
+ }
806
+
807
+ describe.each([
808
+ {order: 'a-first' as const, firstWriter: 'A' as const},
809
+ {order: 'b-first' as const, firstWriter: 'B' as const},
810
+ ])('collision matrix ($order)', ({order, firstWriter}) => {
811
+ it.each(scenarios)('$id: $title', async (scenario) => {
812
+ const result = await runCollision(scenario, order)
813
+ scenario.verify(result, firstWriter)
814
+ })
815
+ })
816
+
817
+ /**
818
+ * Rebase scenarios: client A has an in-flight transaction (txn-a1, held by
819
+ * the server) plus a second transaction (txn-a2) still in the `applied`
820
+ * queue when client B's foreign transaction lands. This forces
821
+ * `applyRemoteDocument` to rebase txn-a2 over the new remote.
822
+ *
823
+ * These scenarios pin the rebase's 3-way-merge behavior: each pending
824
+ * transaction is re-derived from its original captured base (isolating the
825
+ * user's intent as diff-match-patch and keyed operations) and applied onto
826
+ * the new remote, so B's concurrent edit must survive A's rebased
827
+ * whole-value sets. This was the load-bearing finding that made replacing
828
+ * the rebase engine unnecessary; if it ever regresses to a re-diff against
829
+ * the new remote, these assertions fail.
830
+ */
831
+ interface RebaseScenario {
832
+ id: string
833
+ title: string
834
+ /** first edit from A; becomes the in-flight (held) txn-a1 */
835
+ actA1: Act
836
+ /** second edit from A; stays in `applied` until txn-a1 clears */
837
+ actA2: Act
838
+ /** concurrent edit from B (txn-b) */
839
+ actB: Act
840
+ verify: (result: CollisionResult, firstWriter: 'A' | 'B') => void
841
+ }
842
+
843
+ const rebaseScenarios: RebaseScenario[] = [
844
+ {
845
+ id: 'rebase-pending-set-stomps-remote',
846
+ title: "A's pending whole-string set is re-applied over B's concurrent insert",
847
+ actA1: editField({set: {title: TITLE.replace('brown ', 'oneA brown ')}}, 'txn-a1'),
848
+ actA2: ({instance, resource}) => {
849
+ const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
850
+ return applyDocumentActions(instance, {
851
+ actions: [editDocument(handle, {set: {title: `${current?.title} moreA`}})],
852
+ resource,
853
+ transactionId: 'txn-a2',
854
+ })
855
+ },
856
+ actB: editField({set: {title: TITLE.replace('lazy ', 'twoB lazy ')}}, 'txn-b'),
857
+ verify: ({serverDraft}) => {
858
+ expect(serverDraft?.title).toContain('oneA')
859
+ expect(serverDraft?.title).toContain('moreA')
860
+ // the rebase must not re-apply A's whole-title set verbatim; the 3-way
861
+ // merge re-derives it as a string diff, which preserves B's insert
862
+ expect(serverDraft?.title).toContain('twoB')
863
+ },
864
+ },
865
+ {
866
+ id: 'rebase-pending-span-set-stomps-remote',
867
+ title: "A's pending span text set is re-applied over B's concurrent typing in the same span",
868
+ actA1: editField(
869
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
870
+ 'txn-a1',
871
+ ),
872
+ actA2: ({instance, resource}) => {
873
+ const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
874
+ const text = getSpanText(current ?? null, 'blkA')
875
+ return applyDocumentActions(instance, {
876
+ actions: [
877
+ editDocument(handle, {
878
+ set: {'content[_key=="blkA"].children[_key=="spA"].text': `${text} moreA`},
879
+ }),
880
+ ],
881
+ resource,
882
+ transactionId: 'txn-a2',
883
+ })
884
+ },
885
+ actB: editField(
886
+ {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta'}},
887
+ 'txn-b',
888
+ ),
889
+ verify: ({serverDraft}) => {
890
+ const text = getSpanText(serverDraft, 'blkA')
891
+ expect(text).toContain('oneA')
892
+ expect(text).toContain('moreA')
893
+ // same 3-way-merge guarantee as above, one level deeper in the tree
894
+ expect(text).toContain('twoB')
895
+ },
896
+ },
897
+ {
898
+ id: 'rebase-different-fields',
899
+ title: "A's pending title edit is rebased over B's concurrent subtitle edit",
900
+ actA1: editField({set: {title: 'title A1'}}, 'txn-a1'),
901
+ actA2: editField({set: {title: 'title A2'}}, 'txn-a2'),
902
+ actB: editField({set: {subtitle: 'subtitle from B'}}, 'txn-b'),
903
+ verify: ({serverDraft}) => {
904
+ expect(serverDraft?.title).toBe('title A2')
905
+ expect(serverDraft?.subtitle).toBe('subtitle from B')
906
+ },
907
+ },
908
+ {
909
+ id: 'rebase-preserved-keyed-inserts',
910
+ title: "A's pending preserveOperations insert is rebased over B's concurrent keyed insert",
911
+ actA1: editPreserved(
912
+ [{insert: {after: 'content[_key=="blkA"]', items: [block('blkC', 'spC', 'inserted by A1')]}}],
913
+ 'txn-a1',
914
+ ),
915
+ actA2: editPreserved(
916
+ [{insert: {after: 'content[_key=="blkC"]', items: [block('blkE', 'spE', 'inserted by A2')]}}],
917
+ 'txn-a2',
918
+ ),
919
+ actB: editPreserved(
920
+ [{insert: {after: 'content[_key=="blkB"]', items: [block('blkD', 'spD', 'inserted by B')]}}],
921
+ 'txn-b',
922
+ ),
923
+ verify: ({serverDraft}) => {
924
+ const keys = serverDraft?.content?.map((b) => b._key)
925
+ expect(keys).toContain('blkC')
926
+ expect(keys).toContain('blkD')
927
+ expect(keys).toContain('blkE')
928
+ },
929
+ },
930
+ ]
931
+
932
+ async function runRebaseCollision(
933
+ scenario: RebaseScenario,
934
+ order: 'a-first' | 'b-first',
935
+ ): Promise<CollisionResult> {
936
+ server.seed({[DRAFT_ID]: seedDocument()})
937
+
938
+ const stateA = getDocumentState<RigDocument>(clientA.instance, handle)
939
+ const stateB = getDocumentState<RigDocument>(clientB.instance, handle)
940
+ const unsubscribeA = stateA.subscribe()
941
+ const unsubscribeB = stateB.subscribe()
942
+
943
+ try {
944
+ await firstValueFrom(stateA.observable.pipe(first((doc) => doc !== undefined)))
945
+ await firstValueFrom(stateB.observable.pipe(first((doc) => doc !== undefined)))
946
+
947
+ server.holdSubmissions()
948
+
949
+ await scenario.actA1(clientA)
950
+ // wait until txn-a1 is submitted (and held) so the follow-up edit stays in
951
+ // the applied queue instead of batching into the same outgoing transaction
952
+ await vi.waitFor(() => {
953
+ expect(server.pendingTransactionIds()).toContain('txn-a1')
954
+ })
955
+ await scenario.actA2(clientA)
956
+ await scenario.actB(clientB)
957
+ await vi.waitFor(() => {
958
+ expect(server.pendingTransactionIds()).toContain('txn-b')
959
+ })
960
+
961
+ const [firstTxn, secondTxn] = order === 'a-first' ? ['txn-a1', 'txn-b'] : ['txn-b', 'txn-a1']
962
+ await server.release(firstTxn)
963
+ await server.release(secondTxn)
964
+
965
+ // txn-a1's ack clears A's outgoing slot; txn-a2 (rebased) submits next
966
+ await vi.waitFor(() => {
967
+ expect(server.pendingTransactionIds()).toContain('txn-a2')
968
+ })
969
+ await server.release('txn-a2')
970
+
971
+ const syncA = getDocumentSyncStatus(clientA.instance, handle)
972
+ const syncB = getDocumentSyncStatus(clientB.instance, handle)
973
+
974
+ await vi.waitFor(
975
+ () => {
976
+ expect(syncA.getCurrent()).toBe(true)
977
+ expect(syncB.getCurrent()).toBe(true)
978
+ for (const id of [DRAFT_ID, PUBLISHED_ID]) {
979
+ const serverDoc = withoutUpdatedAt(server.getDocument(id))
980
+ expect(withoutUpdatedAt(localDoc(clientA, id) ?? null)).toEqual(serverDoc)
981
+ expect(withoutUpdatedAt(localDoc(clientB, id) ?? null)).toEqual(serverDoc)
982
+ }
983
+ },
984
+ {timeout: 3000},
985
+ )
986
+
987
+ return {
988
+ serverDraft: server.getDocument(DRAFT_ID) as RigDocument | null,
989
+ serverPublished: server.getDocument(PUBLISHED_ID) as RigDocument | null,
990
+ local: {
991
+ A: {
992
+ draft: localDoc(clientA, DRAFT_ID) ?? null,
993
+ published: localDoc(clientA, PUBLISHED_ID) ?? null,
994
+ },
995
+ B: {
996
+ draft: localDoc(clientB, DRAFT_ID) ?? null,
997
+ published: localDoc(clientB, PUBLISHED_ID) ?? null,
998
+ },
999
+ },
1000
+ }
1001
+ } finally {
1002
+ unsubscribeA()
1003
+ unsubscribeB()
1004
+ }
1005
+ }
1006
+
1007
+ describe.each([
1008
+ {order: 'a-first' as const, firstWriter: 'A' as const},
1009
+ {order: 'b-first' as const, firstWriter: 'B' as const},
1010
+ ])('rebase matrix ($order)', ({order, firstWriter}) => {
1011
+ it.each(rebaseScenarios)('$id: $title', async (scenario) => {
1012
+ const result = await runRebaseCollision(scenario, order)
1013
+ scenario.verify(result, firstWriter)
1014
+ })
1015
+ })