@sanity/sdk 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts +69 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +282 -52
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/_exports/index.ts +2 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.concurrency.test.ts +1015 -0
- package/src/document/documentStore.test.ts +143 -1
- package/src/document/documentStore.ts +8 -1
- package/src/document/events.ts +32 -0
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +73 -20
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +49 -2
- package/src/document/patchOperations.ts +50 -0
- package/src/document/processActions/edit.ts +26 -14
- package/src/document/processActions/shared.ts +165 -8
- package/src/document/processActions.test.ts +330 -1
- package/src/document/reducers.test.ts +519 -0
- package/src/document/reducers.ts +99 -3
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
|
@@ -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
|
+
})
|