@sanity/sdk 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +8 -11
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/_internal.js +1 -0
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +8 -4
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/index.d.ts +197 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -123
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/_exports/index.ts +7 -1
- package/src/document/documentStore.concurrency.test.ts +1306 -0
- package/src/document/documentStore.test.ts +11 -0
- package/src/document/documentStore.ts +0 -1
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +65 -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 +9 -2
- package/src/document/processActions/shared.ts +111 -1
- package/src/document/processActions.test.ts +185 -1
- package/src/document/reducers.test.ts +123 -0
- package/src/document/reducers.ts +37 -2
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
|
@@ -0,0 +1,1306 @@
|
|
|
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 {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
|
|
29
|
+
import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
|
|
30
|
+
import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
|
|
31
|
+
import {
|
|
32
|
+
defer,
|
|
33
|
+
delay,
|
|
34
|
+
first,
|
|
35
|
+
firstValueFrom,
|
|
36
|
+
from,
|
|
37
|
+
Observable,
|
|
38
|
+
of,
|
|
39
|
+
ReplaySubject,
|
|
40
|
+
Subject,
|
|
41
|
+
} from 'rxjs'
|
|
42
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
43
|
+
|
|
44
|
+
import {getClientState} from '../client/clientStore'
|
|
45
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
46
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
47
|
+
import {editDocument, publishDocument} from './actions'
|
|
48
|
+
import {applyDocumentActions} from './applyDocumentActions'
|
|
49
|
+
import {getDocumentState, getDocumentSyncStatus} from './documentStore'
|
|
50
|
+
import {type DatasetAcl} from './permissions'
|
|
51
|
+
import {type DocumentSet, getDocumentIds, processMutations} from './processMutations'
|
|
52
|
+
import {type HttpAction} from './reducers'
|
|
53
|
+
import {createFetchDocument, createSharedListener} from './sharedListener'
|
|
54
|
+
|
|
55
|
+
vi.mock('../client/clientStore.ts', () => ({
|
|
56
|
+
getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
vi.mock('./sharedListener.ts', () => ({
|
|
60
|
+
createSharedListener: vi.fn(),
|
|
61
|
+
createFetchDocument: vi.fn(),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
vi.mock('./documentConstants.ts', async (importOriginal) => {
|
|
65
|
+
const original = await importOriginal<typeof import('./documentConstants')>()
|
|
66
|
+
return {
|
|
67
|
+
...original,
|
|
68
|
+
INITIAL_OUTGOING_THROTTLE_TIME: 0,
|
|
69
|
+
DOCUMENT_STATE_CLEAR_DELAY: 25,
|
|
70
|
+
OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
|
|
71
|
+
OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
interface Span {
|
|
76
|
+
_key: string
|
|
77
|
+
_type: 'span'
|
|
78
|
+
text: string
|
|
79
|
+
marks: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface Block {
|
|
83
|
+
_key: string
|
|
84
|
+
_type: 'block'
|
|
85
|
+
style: 'normal'
|
|
86
|
+
children: Span[]
|
|
87
|
+
markDefs: {_key: string; _type: string; href?: string}[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RigDocument extends SanityDocument {
|
|
91
|
+
title?: string
|
|
92
|
+
subtitle?: string
|
|
93
|
+
count?: number
|
|
94
|
+
content?: Block[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface HeldSubmission {
|
|
98
|
+
transactionId: string
|
|
99
|
+
actions: HttpAction[]
|
|
100
|
+
resolve: (result: {transactionId: string}) => void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface MockContentLake {
|
|
104
|
+
connect: () => {
|
|
105
|
+
events: Observable<ListenEvent<SanityDocument>>
|
|
106
|
+
dispose: () => void
|
|
107
|
+
}
|
|
108
|
+
getDocument: (id: string) => SanityDocument | null
|
|
109
|
+
seed: (docs: DocumentSet) => void
|
|
110
|
+
holdSubmissions: () => void
|
|
111
|
+
submitActions: (actions: HttpAction[], transactionId: string) => Promise<{transactionId: string}>
|
|
112
|
+
release: (transactionId: string) => Promise<void>
|
|
113
|
+
pendingTransactionIds: () => string[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createMockContentLake(): MockContentLake {
|
|
117
|
+
let documents: DocumentSet = {}
|
|
118
|
+
const connections: Subject<ListenEvent<SanityDocument>>[] = []
|
|
119
|
+
let holding = false
|
|
120
|
+
const held: HeldSubmission[] = []
|
|
121
|
+
|
|
122
|
+
function convertAction(action: HttpAction, current: DocumentSet): Mutation[] {
|
|
123
|
+
switch (action.actionType) {
|
|
124
|
+
case 'sanity.action.document.edit': {
|
|
125
|
+
const source = (action.draftId && current[action.draftId]) ?? current[action.publishedId]
|
|
126
|
+
if (!source) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Mock backend: no document found for edit (draftId: ${action.draftId}, publishedId: ${action.publishedId})`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return [
|
|
132
|
+
{createIfNotExists: {...source, _id: action.draftId}},
|
|
133
|
+
{patch: {id: action.draftId, ...action.patch}},
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
case 'sanity.action.document.publish': {
|
|
137
|
+
const draft = current[action.draftId]
|
|
138
|
+
if (!draft) {
|
|
139
|
+
throw new Error(`Mock backend: cannot publish, draft ${action.draftId} not found`)
|
|
140
|
+
}
|
|
141
|
+
// NOTE: the real Actions API enforces the optimistic lock
|
|
142
|
+
// (`ifDraftRevisionId`); this mock intentionally ignores it so the
|
|
143
|
+
// matrix can observe client-side conflict behavior in isolation
|
|
144
|
+
return [
|
|
145
|
+
{delete: {id: action.draftId}},
|
|
146
|
+
{createOrReplace: {...draft, _id: action.publishedId}},
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(`Mock backend: unsupported action type ${action.actionType}`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mutationTargets(mutation: Mutation): string[] {
|
|
155
|
+
if ('patch' in mutation) return getDocumentIds(mutation.patch)
|
|
156
|
+
if ('delete' in mutation) return getDocumentIds(mutation.delete)
|
|
157
|
+
if ('create' in mutation && mutation.create._id) return [mutation.create._id]
|
|
158
|
+
if ('createOrReplace' in mutation) return [mutation.createOrReplace._id]
|
|
159
|
+
if ('createIfNotExists' in mutation) return [mutation.createIfNotExists._id]
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function apply(submission: HeldSubmission): Promise<void> {
|
|
164
|
+
const {transactionId, actions} = submission
|
|
165
|
+
const timestamp = new Date().toISOString()
|
|
166
|
+
const prior = {...documents}
|
|
167
|
+
let next = {...documents}
|
|
168
|
+
const allMutations: Mutation[] = []
|
|
169
|
+
|
|
170
|
+
for (const action of actions) {
|
|
171
|
+
const mutations = convertAction(action, next)
|
|
172
|
+
next = processMutations({
|
|
173
|
+
documents: next,
|
|
174
|
+
mutations,
|
|
175
|
+
transactionId,
|
|
176
|
+
timestamp,
|
|
177
|
+
})
|
|
178
|
+
allMutations.push(...mutations)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
documents = next
|
|
182
|
+
|
|
183
|
+
const touchedIds = Array.from(new Set(allMutations.flatMap(mutationTargets)))
|
|
184
|
+
const events: MutationEvent[] = []
|
|
185
|
+
let transactionCurrentEvent = 0
|
|
186
|
+
|
|
187
|
+
for (const id of touchedIds) {
|
|
188
|
+
const before = prior[id]
|
|
189
|
+
const after = next[id]
|
|
190
|
+
if (!before && !after) continue
|
|
191
|
+
transactionCurrentEvent++
|
|
192
|
+
const mutationsForDoc = allMutations.filter((m) => mutationTargets(m).includes(id))
|
|
193
|
+
events.push({
|
|
194
|
+
type: 'mutation',
|
|
195
|
+
documentId: id,
|
|
196
|
+
eventId: `${transactionId}#${id}`,
|
|
197
|
+
identity: 'remote-user',
|
|
198
|
+
mutations: mutationsForDoc,
|
|
199
|
+
timestamp,
|
|
200
|
+
transactionId,
|
|
201
|
+
transactionCurrentEvent,
|
|
202
|
+
transactionTotalEvents: 0, // patched below once we know the count
|
|
203
|
+
transition: before && after ? 'update' : after ? 'appear' : 'disappear',
|
|
204
|
+
visibility: 'query',
|
|
205
|
+
...(before && {previousRev: before._rev}),
|
|
206
|
+
...(after && {resultRev: transactionId}),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
for (const event of events) event.transactionTotalEvents = events.length
|
|
210
|
+
|
|
211
|
+
// let the store's submission bookkeeping settle before the echo arrives
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
213
|
+
|
|
214
|
+
for (const event of events) {
|
|
215
|
+
for (const connection of connections) connection.next(event)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ack after the listener echo, mirroring async visibility
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
220
|
+
submission.resolve({transactionId})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
connect() {
|
|
225
|
+
const subject = new Subject<ListenEvent<SanityDocument>>()
|
|
226
|
+
connections.push(subject)
|
|
227
|
+
const welcome: WelcomeEvent = {
|
|
228
|
+
type: 'welcome',
|
|
229
|
+
listenerName: `mock-listener-${connections.length}`,
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
events: new Observable<ListenEvent<SanityDocument>>((observer) => {
|
|
233
|
+
observer.next(welcome)
|
|
234
|
+
return subject.subscribe(observer)
|
|
235
|
+
}),
|
|
236
|
+
dispose: () => {
|
|
237
|
+
const index = connections.indexOf(subject)
|
|
238
|
+
if (index !== -1) connections.splice(index, 1)
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
getDocument: (id) => documents[id] ?? null,
|
|
243
|
+
seed(docs) {
|
|
244
|
+
documents = {...documents, ...docs}
|
|
245
|
+
},
|
|
246
|
+
holdSubmissions() {
|
|
247
|
+
holding = true
|
|
248
|
+
},
|
|
249
|
+
submitActions(actions, transactionId) {
|
|
250
|
+
return new Promise<{transactionId: string}>((resolve, reject) => {
|
|
251
|
+
const submission: HeldSubmission = {transactionId, actions, resolve}
|
|
252
|
+
if (holding) {
|
|
253
|
+
held.push(submission)
|
|
254
|
+
} else {
|
|
255
|
+
apply(submission).catch(reject)
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
},
|
|
259
|
+
async release(transactionId) {
|
|
260
|
+
const index = held.findIndex((s) => s.transactionId === transactionId)
|
|
261
|
+
if (index === -1) throw new Error(`No held submission with transaction ID ${transactionId}`)
|
|
262
|
+
const [submission] = held.splice(index, 1)
|
|
263
|
+
await apply(submission)
|
|
264
|
+
},
|
|
265
|
+
pendingTransactionIds: () => held.map((s) => s.transactionId),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createMockClient(server: MockContentLake): SanityClient {
|
|
270
|
+
const datasetAcl: DatasetAcl = [
|
|
271
|
+
{filter: 'true', permissions: ['create', 'history', 'read', 'update']},
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
const action = vi.fn(async (input: HttpAction | HttpAction[], options?: BaseActionOptions) => {
|
|
275
|
+
const actions = Array.isArray(input) ? input : [input]
|
|
276
|
+
return server.submitActions(actions, options?.transactionId ?? crypto.randomUUID())
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const request = vi.fn(async () => {
|
|
280
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
281
|
+
return datasetAcl
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const fetch = vi.fn(async () => {
|
|
285
|
+
throw new Error('Mock client: fetch is not supported in the concurrency rig')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
action,
|
|
290
|
+
request,
|
|
291
|
+
fetch,
|
|
292
|
+
observable: {
|
|
293
|
+
action: (...args: Parameters<typeof action>) => from(action(...args)),
|
|
294
|
+
request: (...args: Parameters<typeof request>) => from(request(...args)),
|
|
295
|
+
fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
|
|
296
|
+
},
|
|
297
|
+
} as unknown as SanityClient
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function span(key: string, text: string, marks: string[] = []): Span {
|
|
301
|
+
return {_key: key, _type: 'span', text, marks}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function block(key: string, spanKey: string, text: string): Block {
|
|
305
|
+
return {
|
|
306
|
+
_key: key,
|
|
307
|
+
_type: 'block',
|
|
308
|
+
style: 'normal',
|
|
309
|
+
children: [span(spanKey, text)],
|
|
310
|
+
markDefs: [],
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const DOCUMENT_ID = DocumentId('rig-doc')
|
|
315
|
+
const DRAFT_ID = getDraftId(DOCUMENT_ID)
|
|
316
|
+
const PUBLISHED_ID = getPublishedId(DOCUMENT_ID)
|
|
317
|
+
const handle = {documentId: DOCUMENT_ID, documentType: 'article'}
|
|
318
|
+
|
|
319
|
+
const TITLE = 'The quick brown fox jumps over the lazy dog'
|
|
320
|
+
|
|
321
|
+
function seedDocument(): SanityDocument {
|
|
322
|
+
return {
|
|
323
|
+
_id: DRAFT_ID,
|
|
324
|
+
_type: 'article',
|
|
325
|
+
_rev: 'seed-rev',
|
|
326
|
+
_createdAt: '2026-01-01T00:00:00.000Z',
|
|
327
|
+
_updatedAt: '2026-01-01T00:00:00.000Z',
|
|
328
|
+
title: TITLE,
|
|
329
|
+
subtitle: 'original subtitle',
|
|
330
|
+
count: 0,
|
|
331
|
+
content: [
|
|
332
|
+
block('blkA', 'spA', 'alpha bravo charlie delta'),
|
|
333
|
+
block('blkB', 'spB', 'echo foxtrot golf hotel'),
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface ClientContext {
|
|
339
|
+
instance: SanityInstance
|
|
340
|
+
resource: {projectId: string; dataset: string}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
type Act = (client: ClientContext) => Promise<unknown>
|
|
344
|
+
|
|
345
|
+
interface CollisionResult {
|
|
346
|
+
serverDraft: RigDocument | null
|
|
347
|
+
serverPublished: RigDocument | null
|
|
348
|
+
/** the local view (draft and published) each client settled on */
|
|
349
|
+
local: {
|
|
350
|
+
A: {draft: RigDocument | null; published: RigDocument | null}
|
|
351
|
+
B: {draft: RigDocument | null; published: RigDocument | null}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface Scenario {
|
|
356
|
+
id: string
|
|
357
|
+
title: string
|
|
358
|
+
actA: Act
|
|
359
|
+
actB: Act
|
|
360
|
+
/**
|
|
361
|
+
* Scenario-specific assertions on the settled state. `firstWriter` is which
|
|
362
|
+
* client's transaction the server applied first.
|
|
363
|
+
*/
|
|
364
|
+
verify: (result: CollisionResult, firstWriter: 'A' | 'B') => void
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const editField = (patches: PatchOperations, transactionId: string): Act => {
|
|
368
|
+
return ({instance, resource}) =>
|
|
369
|
+
applyDocumentActions(instance, {
|
|
370
|
+
actions: [editDocument(handle, patches)],
|
|
371
|
+
resource,
|
|
372
|
+
transactionId,
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const editPreserved = (patches: PatchOperations[], transactionId: string): Act => {
|
|
377
|
+
return ({instance, resource}) =>
|
|
378
|
+
applyDocumentActions(instance, {
|
|
379
|
+
actions: [editDocument(handle, patches, {preserveOperations: true})],
|
|
380
|
+
resource,
|
|
381
|
+
transactionId,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const getSpanText = (doc: RigDocument | null, blockKey: string): string | undefined => {
|
|
386
|
+
const blockNode = doc?.content?.find((b) => b._key === blockKey)
|
|
387
|
+
return blockNode?.children[0]?.text
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** concatenated text of every span in a block, in child order */
|
|
391
|
+
const blockText = (doc: RigDocument | null, blockKey: string): string | undefined => {
|
|
392
|
+
const blockNode = doc?.content?.find((b) => b._key === blockKey)
|
|
393
|
+
return blockNode?.children.map((child) => child.text).join('')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** diff-match-patch payload transforming `before` into `after` */
|
|
397
|
+
const dmp = (before: string, after: string): string => stringifyPatches(makePatches(before, after))
|
|
398
|
+
|
|
399
|
+
const getSpanMarks = (doc: RigDocument | null, blockKey: string): string[] | undefined => {
|
|
400
|
+
const blockNode = doc?.content?.find((b) => b._key === blockKey)
|
|
401
|
+
return blockNode?.children[0]?.marks
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Outcomes that are known to lose data with the current snapshot-diff
|
|
406
|
+
* pipeline. Each entry documents the current (lossy) behavior; later phases
|
|
407
|
+
* are expected to remove entries from this list and flip the assertions.
|
|
408
|
+
* Keyed by `scenarioId` with an optional restriction on which apply order
|
|
409
|
+
* exhibits the loss.
|
|
410
|
+
*/
|
|
411
|
+
const KNOWN_LOSS: {
|
|
412
|
+
scenario: string
|
|
413
|
+
firstWriter?: 'A' | 'B'
|
|
414
|
+
reason: string
|
|
415
|
+
}[] = [
|
|
416
|
+
{
|
|
417
|
+
scenario: 'preserved-set-text-vs-typing',
|
|
418
|
+
reason:
|
|
419
|
+
'preserveOperations forwards whole-value sets verbatim, so whichever preserved set lands second overwrites the concurrent one (server-side last-write-wins)',
|
|
420
|
+
},
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
const isKnownLoss = (scenarioId: string, firstWriter: 'A' | 'B'): boolean =>
|
|
424
|
+
KNOWN_LOSS.some(
|
|
425
|
+
(entry) =>
|
|
426
|
+
entry.scenario === scenarioId && (!entry.firstWriter || entry.firstWriter === firstWriter),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Cases where a client's local copy is known to diverge from the server after
|
|
431
|
+
* everything settles. The rig asserts the divergence so the entry must be
|
|
432
|
+
* removed (flipping the assertion to convergence) once a later phase fixes it.
|
|
433
|
+
*/
|
|
434
|
+
const KNOWN_DIVERGENCE: {
|
|
435
|
+
scenario: string
|
|
436
|
+
firstWriter: 'A' | 'B'
|
|
437
|
+
client: 'A' | 'B'
|
|
438
|
+
documentId: string
|
|
439
|
+
reason: string
|
|
440
|
+
}[] = []
|
|
441
|
+
|
|
442
|
+
const isKnownDivergence = (
|
|
443
|
+
scenarioId: string,
|
|
444
|
+
firstWriter: 'A' | 'B',
|
|
445
|
+
client: 'A' | 'B',
|
|
446
|
+
documentId: string,
|
|
447
|
+
): boolean =>
|
|
448
|
+
KNOWN_DIVERGENCE.some(
|
|
449
|
+
(entry) =>
|
|
450
|
+
entry.scenario === scenarioId &&
|
|
451
|
+
entry.firstWriter === firstWriter &&
|
|
452
|
+
entry.client === client &&
|
|
453
|
+
entry.documentId === documentId,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
const scenarios: Scenario[] = [
|
|
457
|
+
{
|
|
458
|
+
id: 'string-disjoint-inserts',
|
|
459
|
+
title: 'A and B insert into the same string at different offsets',
|
|
460
|
+
actA: editField({set: {title: TITLE.replace('quick ', 'quick oneA ')}}, 'txn-a'),
|
|
461
|
+
actB: editField({set: {title: TITLE.replace('lazy ', 'lazy twoB ')}}, 'txn-b'),
|
|
462
|
+
verify: ({serverDraft}) => {
|
|
463
|
+
expect(serverDraft?.title).toContain('oneA')
|
|
464
|
+
expect(serverDraft?.title).toContain('twoB')
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
id: 'string-same-offset-inserts',
|
|
469
|
+
title: 'A and B insert into the same string at the same offset',
|
|
470
|
+
actA: editField({set: {title: TITLE.replace('quick ', 'quick oneA ')}}, 'txn-a'),
|
|
471
|
+
actB: editField({set: {title: TITLE.replace('quick ', 'quick twoB ')}}, 'txn-b'),
|
|
472
|
+
verify: ({serverDraft}) => {
|
|
473
|
+
expect(serverDraft?.title).toContain('oneA')
|
|
474
|
+
expect(serverDraft?.title).toContain('twoB')
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: 'string-delete-vs-insert-inside',
|
|
479
|
+
title: 'A deletes a range while B inserts inside that range',
|
|
480
|
+
actA: editField({set: {title: TITLE.replace('brown fox ', '')}}, 'txn-a'),
|
|
481
|
+
actB: editField({set: {title: TITLE.replace('fox ', 'fox twoB ')}}, 'txn-b'),
|
|
482
|
+
verify: ({serverDraft}) => {
|
|
483
|
+
// deletion vs insert-inside has no universally correct answer; require
|
|
484
|
+
// convergence and that the title is still a single coherent string
|
|
485
|
+
expect(typeof serverDraft?.title).toBe('string')
|
|
486
|
+
expect(serverDraft?.title).toContain('quick')
|
|
487
|
+
expect(serverDraft?.title).toContain('lazy dog')
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: 'counter-inc-inc',
|
|
492
|
+
title: 'A and B both increment the same counter',
|
|
493
|
+
actA: editField({inc: {count: 1}}, 'txn-a'),
|
|
494
|
+
actB: editField({inc: {count: 1}}, 'txn-b'),
|
|
495
|
+
verify: ({serverDraft}, firstWriter) => {
|
|
496
|
+
if (isKnownLoss('counter-inc-inc', firstWriter)) {
|
|
497
|
+
expect(serverDraft?.count).toBe(1)
|
|
498
|
+
} else {
|
|
499
|
+
expect(serverDraft?.count).toBe(2)
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: 'different-fields',
|
|
505
|
+
title: 'A and B set different fields',
|
|
506
|
+
actA: editField({set: {title: 'title set by A'}}, 'txn-a'),
|
|
507
|
+
actB: editField({set: {subtitle: 'subtitle set by B'}}, 'txn-b'),
|
|
508
|
+
verify: ({serverDraft}) => {
|
|
509
|
+
expect(serverDraft?.title).toBe('title set by A')
|
|
510
|
+
expect(serverDraft?.subtitle).toBe('subtitle set by B')
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
id: 'different-blocks-text',
|
|
515
|
+
title: 'A and B type into different blocks of the same array',
|
|
516
|
+
actA: editField(
|
|
517
|
+
{
|
|
518
|
+
set: {
|
|
519
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
'txn-a',
|
|
523
|
+
),
|
|
524
|
+
actB: editField(
|
|
525
|
+
{
|
|
526
|
+
set: {
|
|
527
|
+
'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
'txn-b',
|
|
531
|
+
),
|
|
532
|
+
verify: ({serverDraft}) => {
|
|
533
|
+
expect(getSpanText(serverDraft, 'blkA')).toContain('oneA')
|
|
534
|
+
expect(getSpanText(serverDraft, 'blkB')).toContain('twoB')
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'same-span-disjoint-offsets',
|
|
539
|
+
title: 'A and B type into the same span at different offsets',
|
|
540
|
+
actA: editField(
|
|
541
|
+
{
|
|
542
|
+
set: {
|
|
543
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
'txn-a',
|
|
547
|
+
),
|
|
548
|
+
actB: editField(
|
|
549
|
+
{
|
|
550
|
+
set: {
|
|
551
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
'txn-b',
|
|
555
|
+
),
|
|
556
|
+
verify: ({serverDraft}) => {
|
|
557
|
+
const text = getSpanText(serverDraft, 'blkA')
|
|
558
|
+
expect(text).toContain('oneA')
|
|
559
|
+
expect(text).toContain('twoB')
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'append-block-vs-append-block',
|
|
564
|
+
title: 'A and B each append a different block via whole-array set',
|
|
565
|
+
actA: ({instance, resource}) => {
|
|
566
|
+
const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
|
|
567
|
+
const content = [...(current?.content ?? []), block('blkC', 'spC', 'appended by A')]
|
|
568
|
+
return applyDocumentActions(instance, {
|
|
569
|
+
actions: [editDocument(handle, {set: {content}})],
|
|
570
|
+
resource,
|
|
571
|
+
transactionId: 'txn-a',
|
|
572
|
+
})
|
|
573
|
+
},
|
|
574
|
+
actB: ({instance, resource}) => {
|
|
575
|
+
const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
|
|
576
|
+
const content = [...(current?.content ?? []), block('blkD', 'spD', 'appended by B')]
|
|
577
|
+
return applyDocumentActions(instance, {
|
|
578
|
+
actions: [editDocument(handle, {set: {content}})],
|
|
579
|
+
resource,
|
|
580
|
+
transactionId: 'txn-b',
|
|
581
|
+
})
|
|
582
|
+
},
|
|
583
|
+
verify: ({serverDraft}) => {
|
|
584
|
+
const keys = serverDraft?.content?.map((b) => b._key)
|
|
585
|
+
expect(keys).toContain('blkC')
|
|
586
|
+
expect(keys).toContain('blkD')
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
id: 'delete-block-vs-edit-within',
|
|
591
|
+
title: 'A deletes a block while B types inside it',
|
|
592
|
+
actA: ({instance, resource}) => {
|
|
593
|
+
const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
|
|
594
|
+
const content = (current?.content ?? []).filter((b) => b._key !== 'blkB')
|
|
595
|
+
return applyDocumentActions(instance, {
|
|
596
|
+
actions: [editDocument(handle, {set: {content}})],
|
|
597
|
+
resource,
|
|
598
|
+
transactionId: 'txn-a',
|
|
599
|
+
})
|
|
600
|
+
},
|
|
601
|
+
actB: editField(
|
|
602
|
+
{
|
|
603
|
+
set: {
|
|
604
|
+
'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
'txn-b',
|
|
608
|
+
),
|
|
609
|
+
verify: ({serverDraft}) => {
|
|
610
|
+
// delete-vs-edit has no universally correct answer; require convergence
|
|
611
|
+
// and that surviving blocks are intact
|
|
612
|
+
expect(getSpanText(serverDraft, 'blkA')).toBe('alpha bravo charlie delta')
|
|
613
|
+
const blkB = serverDraft?.content?.find((b) => b._key === 'blkB')
|
|
614
|
+
if (blkB) {
|
|
615
|
+
expect(blkB.children).toHaveLength(1)
|
|
616
|
+
expect(typeof blkB.children[0].text).toBe('string')
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: 'preserved-keyed-inserts',
|
|
622
|
+
title: 'A and B insert different blocks with preserveOperations',
|
|
623
|
+
actA: editPreserved(
|
|
624
|
+
[
|
|
625
|
+
{
|
|
626
|
+
insert: {
|
|
627
|
+
after: 'content[_key=="blkA"]',
|
|
628
|
+
items: [block('blkC', 'spC', 'inserted by A')],
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
'txn-a',
|
|
633
|
+
),
|
|
634
|
+
actB: editPreserved(
|
|
635
|
+
[
|
|
636
|
+
{
|
|
637
|
+
insert: {
|
|
638
|
+
after: 'content[_key=="blkB"]',
|
|
639
|
+
items: [block('blkD', 'spD', 'inserted by B')],
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
'txn-b',
|
|
644
|
+
),
|
|
645
|
+
verify: ({serverDraft}) => {
|
|
646
|
+
const keys = serverDraft?.content?.map((b) => b._key)
|
|
647
|
+
expect(keys).toContain('blkC')
|
|
648
|
+
expect(keys).toContain('blkD')
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
id: 'preserved-set-text-vs-typing',
|
|
653
|
+
title: 'A and B type into the same span via preserveOperations sets (PTE plugin path)',
|
|
654
|
+
actA: editPreserved(
|
|
655
|
+
[
|
|
656
|
+
{
|
|
657
|
+
set: {
|
|
658
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
'txn-a',
|
|
663
|
+
),
|
|
664
|
+
actB: editPreserved(
|
|
665
|
+
[
|
|
666
|
+
{
|
|
667
|
+
set: {
|
|
668
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
],
|
|
672
|
+
'txn-b',
|
|
673
|
+
),
|
|
674
|
+
verify: ({serverDraft}, firstWriter) => {
|
|
675
|
+
const text = getSpanText(serverDraft, 'blkA')
|
|
676
|
+
const winner = firstWriter === 'A' ? 'twoB' : 'oneA'
|
|
677
|
+
const loser = firstWriter === 'A' ? 'oneA' : 'twoB'
|
|
678
|
+
if (isKnownLoss('preserved-set-text-vs-typing', firstWriter)) {
|
|
679
|
+
// whichever set lands second overwrites the whole string
|
|
680
|
+
expect(text).toContain(winner)
|
|
681
|
+
expect(text).not.toContain(loser)
|
|
682
|
+
} else {
|
|
683
|
+
expect(text).toContain('oneA')
|
|
684
|
+
expect(text).toContain('twoB')
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
id: 'format-vs-typing',
|
|
690
|
+
title: 'A formats a span (marks) while B types into it',
|
|
691
|
+
actA: editField(
|
|
692
|
+
{
|
|
693
|
+
set: {
|
|
694
|
+
'content[_key=="blkA"].children[_key=="spA"].marks': ['strong'],
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
'txn-a',
|
|
698
|
+
),
|
|
699
|
+
actB: editField(
|
|
700
|
+
{
|
|
701
|
+
set: {
|
|
702
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo twoB charlie delta',
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
'txn-b',
|
|
706
|
+
),
|
|
707
|
+
verify: ({serverDraft}) => {
|
|
708
|
+
expect(getSpanMarks(serverDraft, 'blkA')).toEqual(['strong'])
|
|
709
|
+
expect(getSpanText(serverDraft, 'blkA')).toContain('twoB')
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
id: 'publish-vs-edit',
|
|
714
|
+
title: 'A publishes while B edits the draft',
|
|
715
|
+
actA: ({instance, resource}) =>
|
|
716
|
+
applyDocumentActions(instance, {
|
|
717
|
+
actions: [publishDocument(handle)],
|
|
718
|
+
resource,
|
|
719
|
+
transactionId: 'txn-a',
|
|
720
|
+
}),
|
|
721
|
+
actB: editField({set: {title: `${TITLE} twoB`}}, 'txn-b'),
|
|
722
|
+
verify: ({serverDraft, serverPublished}, firstWriter) => {
|
|
723
|
+
if (firstWriter === 'A') {
|
|
724
|
+
// publish happened against the pre-edit draft; B's edit then
|
|
725
|
+
// recreated the draft (from the published copy) and applied on top
|
|
726
|
+
expect(serverPublished?.title).toBe(TITLE)
|
|
727
|
+
expect(serverDraft?.title).toBe(`${TITLE} twoB`)
|
|
728
|
+
} else {
|
|
729
|
+
// B's edit landed first, so the publish carried it along and the
|
|
730
|
+
// draft was consumed by the publish
|
|
731
|
+
expect(serverPublished?.title).toBe(`${TITLE} twoB`)
|
|
732
|
+
expect(serverDraft).toBeNull()
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
let server: MockContentLake
|
|
739
|
+
let clientA: ClientContext
|
|
740
|
+
let clientB: ClientContext
|
|
741
|
+
|
|
742
|
+
beforeEach(() => {
|
|
743
|
+
server = createMockContentLake()
|
|
744
|
+
|
|
745
|
+
const client$ = (getClientState as unknown as () => StateSource<SanityClient>)()
|
|
746
|
+
.observable as ReplaySubject<SanityClient>
|
|
747
|
+
client$.next(createMockClient(server))
|
|
748
|
+
|
|
749
|
+
vi.mocked(createSharedListener).mockImplementation(() => server.connect())
|
|
750
|
+
vi.mocked(createFetchDocument).mockImplementation(
|
|
751
|
+
() => (id: string) => defer(() => of(server.getDocument(id))).pipe(delay(0)),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
clientA = {
|
|
755
|
+
instance: createSanityInstance({projectId: 'p', dataset: 'rig-a'}),
|
|
756
|
+
resource: {projectId: 'p', dataset: 'rig-a'},
|
|
757
|
+
}
|
|
758
|
+
clientB = {
|
|
759
|
+
instance: createSanityInstance({projectId: 'p', dataset: 'rig-b'}),
|
|
760
|
+
resource: {projectId: 'p', dataset: 'rig-b'},
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
afterEach(() => {
|
|
765
|
+
clientA.instance.dispose()
|
|
766
|
+
clientB.instance.dispose()
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
const localDoc = (client: ClientContext, id: string): RigDocument | null | undefined =>
|
|
770
|
+
getDocumentState<RigDocument>(client.instance, {
|
|
771
|
+
documentId: id,
|
|
772
|
+
documentType: 'article',
|
|
773
|
+
liveEdit: true,
|
|
774
|
+
}).getCurrent() as RigDocument | null | undefined
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Drops `_updatedAt` before convergence comparison. When a client's own
|
|
778
|
+
* transaction echo fast-forwards, its optimistic `local` keeps the timestamp
|
|
779
|
+
* computed client-side while the server stamped its own time; the content is
|
|
780
|
+
* identical. Only content convergence matters to the matrix.
|
|
781
|
+
*/
|
|
782
|
+
const withoutUpdatedAt = (doc: RigDocument | SanityDocument | null): unknown => {
|
|
783
|
+
if (!doc) return doc
|
|
784
|
+
const {_updatedAt, ...rest} = doc
|
|
785
|
+
return rest
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function runCollision(
|
|
789
|
+
scenario: Scenario,
|
|
790
|
+
order: 'a-first' | 'b-first',
|
|
791
|
+
): Promise<CollisionResult> {
|
|
792
|
+
server.seed({[DRAFT_ID]: seedDocument()})
|
|
793
|
+
|
|
794
|
+
const stateA = getDocumentState<RigDocument>(clientA.instance, handle)
|
|
795
|
+
const stateB = getDocumentState<RigDocument>(clientB.instance, handle)
|
|
796
|
+
const unsubscribeA = stateA.subscribe()
|
|
797
|
+
const unsubscribeB = stateB.subscribe()
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
await firstValueFrom(stateA.observable.pipe(first((doc) => doc !== undefined)))
|
|
801
|
+
await firstValueFrom(stateB.observable.pipe(first((doc) => doc !== undefined)))
|
|
802
|
+
|
|
803
|
+
// hold submissions so both clients edit against the same base revision
|
|
804
|
+
server.holdSubmissions()
|
|
805
|
+
|
|
806
|
+
await scenario.actA(clientA)
|
|
807
|
+
await scenario.actB(clientB)
|
|
808
|
+
|
|
809
|
+
await vi.waitFor(() => {
|
|
810
|
+
expect(server.pendingTransactionIds()).toHaveLength(2)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
const [firstTxn, secondTxn] = order === 'a-first' ? ['txn-a', 'txn-b'] : ['txn-b', 'txn-a']
|
|
814
|
+
await server.release(firstTxn)
|
|
815
|
+
await server.release(secondTxn)
|
|
816
|
+
|
|
817
|
+
const syncA = getDocumentSyncStatus(clientA.instance, handle)
|
|
818
|
+
const syncB = getDocumentSyncStatus(clientB.instance, handle)
|
|
819
|
+
|
|
820
|
+
const firstWriter = order === 'a-first' ? 'A' : 'B'
|
|
821
|
+
const clients = [
|
|
822
|
+
{label: 'A' as const, context: clientA},
|
|
823
|
+
{label: 'B' as const, context: clientB},
|
|
824
|
+
]
|
|
825
|
+
|
|
826
|
+
await vi.waitFor(
|
|
827
|
+
() => {
|
|
828
|
+
expect(syncA.getCurrent()).toBe(true)
|
|
829
|
+
expect(syncB.getCurrent()).toBe(true)
|
|
830
|
+
for (const id of [DRAFT_ID, PUBLISHED_ID]) {
|
|
831
|
+
const serverDoc = withoutUpdatedAt(server.getDocument(id))
|
|
832
|
+
for (const {label, context} of clients) {
|
|
833
|
+
if (isKnownDivergence(scenario.id, firstWriter, label, id)) continue
|
|
834
|
+
expect(withoutUpdatedAt(localDoc(context, id) ?? null)).toEqual(serverDoc)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
{timeout: 3000},
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
// assert the documented divergences still diverge, so fixing one forces
|
|
842
|
+
// its KNOWN_DIVERGENCE entry to be removed
|
|
843
|
+
for (const id of [DRAFT_ID, PUBLISHED_ID]) {
|
|
844
|
+
const serverDoc = withoutUpdatedAt(server.getDocument(id))
|
|
845
|
+
for (const {label, context} of clients) {
|
|
846
|
+
if (!isKnownDivergence(scenario.id, firstWriter, label, id)) continue
|
|
847
|
+
expect(withoutUpdatedAt(localDoc(context, id) ?? null)).not.toEqual(serverDoc)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
serverDraft: server.getDocument(DRAFT_ID) as RigDocument | null,
|
|
853
|
+
serverPublished: server.getDocument(PUBLISHED_ID) as RigDocument | null,
|
|
854
|
+
local: {
|
|
855
|
+
A: {
|
|
856
|
+
draft: localDoc(clientA, DRAFT_ID) ?? null,
|
|
857
|
+
published: localDoc(clientA, PUBLISHED_ID) ?? null,
|
|
858
|
+
},
|
|
859
|
+
B: {
|
|
860
|
+
draft: localDoc(clientB, DRAFT_ID) ?? null,
|
|
861
|
+
published: localDoc(clientB, PUBLISHED_ID) ?? null,
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
}
|
|
865
|
+
} finally {
|
|
866
|
+
unsubscribeA()
|
|
867
|
+
unsubscribeB()
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
describe.each([
|
|
872
|
+
{order: 'a-first' as const, firstWriter: 'A' as const},
|
|
873
|
+
{order: 'b-first' as const, firstWriter: 'B' as const},
|
|
874
|
+
])('collision matrix ($order)', ({order, firstWriter}) => {
|
|
875
|
+
it.each(scenarios)('$id: $title', async (scenario) => {
|
|
876
|
+
const result = await runCollision(scenario, order)
|
|
877
|
+
scenario.verify(result, firstWriter)
|
|
878
|
+
})
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Rebase scenarios: client A has an in-flight transaction (txn-a1, held by
|
|
883
|
+
* the server) plus a second transaction (txn-a2) still in the `applied`
|
|
884
|
+
* queue when client B's foreign transaction lands. This forces
|
|
885
|
+
* `applyRemoteDocument` to rebase txn-a2 over the new remote.
|
|
886
|
+
*
|
|
887
|
+
* These scenarios pin the rebase's 3-way-merge behavior: each pending
|
|
888
|
+
* transaction is re-derived from its original captured base (isolating the
|
|
889
|
+
* user's intent as diff-match-patch and keyed operations) and applied onto
|
|
890
|
+
* the new remote, so B's concurrent edit must survive A's rebased
|
|
891
|
+
* whole-value sets. This was the load-bearing finding that made replacing
|
|
892
|
+
* the rebase engine unnecessary; if it ever regresses to a re-diff against
|
|
893
|
+
* the new remote, these assertions fail.
|
|
894
|
+
*/
|
|
895
|
+
interface RebaseScenario {
|
|
896
|
+
id: string
|
|
897
|
+
title: string
|
|
898
|
+
/** custom seed; defaults to `seedDocument()` */
|
|
899
|
+
seed?: () => SanityDocument
|
|
900
|
+
/** first edit from A; becomes the in-flight (held) txn-a1 */
|
|
901
|
+
actA1: Act
|
|
902
|
+
/** second edit from A; stays in `applied` until txn-a1 clears */
|
|
903
|
+
actA2: Act
|
|
904
|
+
/** optional third edit from A, queued behind txn-a2 (txn-a3) */
|
|
905
|
+
actA3?: Act
|
|
906
|
+
/** concurrent edit from B (txn-b) */
|
|
907
|
+
actB: Act
|
|
908
|
+
/** optional second edit from B, applied after the first releases (txn-b2) */
|
|
909
|
+
actB2?: Act
|
|
910
|
+
verify: (result: CollisionResult, firstWriter: 'A' | 'B') => void
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The exact three transactions a live portabletext/editor client pushed
|
|
915
|
+
* while a user toggled bold on "armadillo" twice (select word, bold,
|
|
916
|
+
* unbold), captured from a two-client harness trace that ended with the
|
|
917
|
+
* store scrambling the fragment order. Each transaction is a span split or
|
|
918
|
+
* merge: dmp-trim a span, insert keyed fragments after it, set marks, and
|
|
919
|
+
* on merge extend a fragment and unset its siblings. Applied in sequence
|
|
920
|
+
* (verified against real Content Lake) they reconstruct the seed sentence
|
|
921
|
+
* exactly.
|
|
922
|
+
*/
|
|
923
|
+
const LIVE_SPLIT_SENTENCE = 'A: There is an armadillo in the restaurant and it wants a taco '
|
|
924
|
+
// prettier-ignore
|
|
925
|
+
const LIVE_SPLIT_PUSHES: PatchOperations[][] = [
|
|
926
|
+
[
|
|
927
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -53,11 +53,4 @@\n nts \n-a taco \n'}},
|
|
928
|
+
{setIfMissing: {'content[_key=="blockA"].children': []}},
|
|
929
|
+
{insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: '31f77137d3fb', _type: 'span', marks: [], text: 'a taco '}]}},
|
|
930
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -44,13 +44,4 @@\n and \n-it wants \n'}},
|
|
931
|
+
{setIfMissing: {'content[_key=="blockA"].children': []}},
|
|
932
|
+
{insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: '6d39d5732d93', _type: 'span', marks: [], text: 'it wants '}]}},
|
|
933
|
+
{set: {'content[_key=="blockA"].children[_key=="6d39d5732d93"].marks': ['strong']}},
|
|
934
|
+
],
|
|
935
|
+
[
|
|
936
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -35,13 +35,4 @@\n stau\n-rant and \n'}},
|
|
937
|
+
{setIfMissing: {'content[_key=="blockA"].children': []}},
|
|
938
|
+
{insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: 'a014aab8b2c6', _type: 'span', marks: [], text: 'rant and '}]}},
|
|
939
|
+
{set: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].marks': ['strong']}},
|
|
940
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].text': '@@ -2,8 +2,17 @@\n ant and \n+it wants \n'}},
|
|
941
|
+
{unset: ['content[_key=="blockA"].children[_key=="6d39d5732d93"]']},
|
|
942
|
+
],
|
|
943
|
+
[
|
|
944
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].text': '@@ -1,18 +1,4 @@\n rant\n- and it wants \n'}},
|
|
945
|
+
{setIfMissing: {'content[_key=="blockA"].children': []}},
|
|
946
|
+
{insert: {after: 'content[_key=="blockA"].children[_key=="a014aab8b2c6"]', items: [{_key: '0ebc53c64b6c', _type: 'span', marks: ['strong'], text: ' and it wants '}]}},
|
|
947
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -30,9 +30,4 @@\n he r\n-estau\n'}},
|
|
948
|
+
{setIfMissing: {'content[_key=="blockA"].children': []}},
|
|
949
|
+
{insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: 'f508bf653099', _type: 'span', marks: [], text: 'estau'}]}},
|
|
950
|
+
{set: {'content[_key=="blockA"].children[_key=="f508bf653099"].marks': ['strong']}},
|
|
951
|
+
{set: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].marks': ['strong']}},
|
|
952
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="f508bf653099"].text': '@@ -1,5 +1,9 @@\n estau\n+rant\n'}},
|
|
953
|
+
{unset: ['content[_key=="blockA"].children[_key=="a014aab8b2c6"]']},
|
|
954
|
+
{diffMatchPatch: {'content[_key=="blockA"].children[_key=="f508bf653099"].text': '@@ -2,8 +2,22 @@\n staurant\n+ and it wants \n'}},
|
|
955
|
+
{unset: ['content[_key=="blockA"].children[_key=="0ebc53c64b6c"]']},
|
|
956
|
+
],
|
|
957
|
+
]
|
|
958
|
+
|
|
959
|
+
function liveSplitSeed(): SanityDocument {
|
|
960
|
+
return {
|
|
961
|
+
...seedDocument(),
|
|
962
|
+
content: [block('blockA', 'spanA', LIVE_SPLIT_SENTENCE), block('blockB', 'spanB', 'B: ')],
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const rebaseScenarios: RebaseScenario[] = [
|
|
967
|
+
{
|
|
968
|
+
id: 'rebase-live-span-split-capture',
|
|
969
|
+
title: 'the captured live bold-toggle transaction chain survives interleaved foreign edits',
|
|
970
|
+
seed: liveSplitSeed,
|
|
971
|
+
actA1: editPreserved(LIVE_SPLIT_PUSHES[0], 'txn-a1'),
|
|
972
|
+
actA2: editPreserved(LIVE_SPLIT_PUSHES[1], 'txn-a2'),
|
|
973
|
+
actA3: editPreserved(LIVE_SPLIT_PUSHES[2], 'txn-a3'),
|
|
974
|
+
actB: editField(
|
|
975
|
+
{
|
|
976
|
+
set: {
|
|
977
|
+
'content[_key=="blockB"].children[_key=="spanB"].text': 'B: coverage ',
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
'txn-b',
|
|
981
|
+
),
|
|
982
|
+
actB2: editField(
|
|
983
|
+
{
|
|
984
|
+
set: {
|
|
985
|
+
'content[_key=="blockB"].children[_key=="spanB"].text': 'B: coverage exclusive ',
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
'txn-b2',
|
|
989
|
+
),
|
|
990
|
+
verify: ({serverDraft, local}) => {
|
|
991
|
+
// Formatting toggles must never change the text, on the server or in
|
|
992
|
+
// either client's local view.
|
|
993
|
+
expect(blockText(serverDraft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
|
|
994
|
+
expect(blockText(local.A.draft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
|
|
995
|
+
expect(blockText(local.B.draft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
|
|
996
|
+
expect(blockText(serverDraft, 'blockB')).toBe('B: coverage exclusive ')
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
id: 'rebase-pending-set-stomps-remote',
|
|
1001
|
+
title: "A's pending whole-string set is re-applied over B's concurrent insert",
|
|
1002
|
+
actA1: editField({set: {title: TITLE.replace('brown ', 'oneA brown ')}}, 'txn-a1'),
|
|
1003
|
+
actA2: ({instance, resource}) => {
|
|
1004
|
+
const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
|
|
1005
|
+
return applyDocumentActions(instance, {
|
|
1006
|
+
actions: [editDocument(handle, {set: {title: `${current?.title} moreA`}})],
|
|
1007
|
+
resource,
|
|
1008
|
+
transactionId: 'txn-a2',
|
|
1009
|
+
})
|
|
1010
|
+
},
|
|
1011
|
+
actB: editField({set: {title: TITLE.replace('lazy ', 'twoB lazy ')}}, 'txn-b'),
|
|
1012
|
+
verify: ({serverDraft}) => {
|
|
1013
|
+
expect(serverDraft?.title).toContain('oneA')
|
|
1014
|
+
expect(serverDraft?.title).toContain('moreA')
|
|
1015
|
+
// the rebase must not re-apply A's whole-title set verbatim; the 3-way
|
|
1016
|
+
// merge re-derives it as a string diff, which preserves B's insert
|
|
1017
|
+
expect(serverDraft?.title).toContain('twoB')
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: 'rebase-pending-span-set-stomps-remote',
|
|
1022
|
+
title: "A's pending span text set is re-applied over B's concurrent typing in the same span",
|
|
1023
|
+
actA1: editField(
|
|
1024
|
+
{
|
|
1025
|
+
set: {
|
|
1026
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
'txn-a1',
|
|
1030
|
+
),
|
|
1031
|
+
actA2: ({instance, resource}) => {
|
|
1032
|
+
const current = getDocumentState<RigDocument>(instance, handle).getCurrent()
|
|
1033
|
+
const text = getSpanText(current ?? null, 'blkA')
|
|
1034
|
+
return applyDocumentActions(instance, {
|
|
1035
|
+
actions: [
|
|
1036
|
+
editDocument(handle, {
|
|
1037
|
+
set: {
|
|
1038
|
+
'content[_key=="blkA"].children[_key=="spA"].text': `${text} moreA`,
|
|
1039
|
+
},
|
|
1040
|
+
}),
|
|
1041
|
+
],
|
|
1042
|
+
resource,
|
|
1043
|
+
transactionId: 'txn-a2',
|
|
1044
|
+
})
|
|
1045
|
+
},
|
|
1046
|
+
actB: editField(
|
|
1047
|
+
{
|
|
1048
|
+
set: {
|
|
1049
|
+
'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
'txn-b',
|
|
1053
|
+
),
|
|
1054
|
+
verify: ({serverDraft}) => {
|
|
1055
|
+
const text = getSpanText(serverDraft, 'blkA')
|
|
1056
|
+
expect(text).toContain('oneA')
|
|
1057
|
+
expect(text).toContain('moreA')
|
|
1058
|
+
// same 3-way-merge guarantee as above, one level deeper in the tree
|
|
1059
|
+
expect(text).toContain('twoB')
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
id: 'rebase-different-fields',
|
|
1064
|
+
title: "A's pending title edit is rebased over B's concurrent subtitle edit",
|
|
1065
|
+
actA1: editField({set: {title: 'title A1'}}, 'txn-a1'),
|
|
1066
|
+
actA2: editField({set: {title: 'title A2'}}, 'txn-a2'),
|
|
1067
|
+
actB: editField({set: {subtitle: 'subtitle from B'}}, 'txn-b'),
|
|
1068
|
+
verify: ({serverDraft}) => {
|
|
1069
|
+
expect(serverDraft?.title).toBe('title A2')
|
|
1070
|
+
expect(serverDraft?.subtitle).toBe('subtitle from B')
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
1073
|
+
{
|
|
1074
|
+
id: 'rebase-pte-span-split',
|
|
1075
|
+
title:
|
|
1076
|
+
"A's pending span-split (bold toggle) is rebased over B's concurrent typing in another block",
|
|
1077
|
+
// Mirrors the patch language portabletext/editor emits when a user
|
|
1078
|
+
// selects a word and toggles bold: dmp-trim the span, insert keyed
|
|
1079
|
+
// fragments anchored after it, set marks — then (second transaction)
|
|
1080
|
+
// merge back: extend a fragment with dmp and unset its sibling. Captured
|
|
1081
|
+
// from a live two-client trace where the store settled on scrambled
|
|
1082
|
+
// fragment order while the editor's local application was correct.
|
|
1083
|
+
actA1: editPreserved(
|
|
1084
|
+
[
|
|
1085
|
+
{
|
|
1086
|
+
diffMatchPatch: {
|
|
1087
|
+
[`content[_key=="blkA"].children[_key=="spA"].text`]: dmp(
|
|
1088
|
+
'alpha bravo charlie delta',
|
|
1089
|
+
'alpha bravo charlie ',
|
|
1090
|
+
),
|
|
1091
|
+
},
|
|
1092
|
+
},
|
|
1093
|
+
{setIfMissing: {[`content[_key=="blkA"].children`]: []}},
|
|
1094
|
+
{
|
|
1095
|
+
insert: {
|
|
1096
|
+
after: `content[_key=="blkA"].children[_key=="spA"]`,
|
|
1097
|
+
items: [span('fragTail', 'delta')],
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
diffMatchPatch: {
|
|
1102
|
+
[`content[_key=="blkA"].children[_key=="spA"].text`]: dmp(
|
|
1103
|
+
'alpha bravo charlie ',
|
|
1104
|
+
'alpha bravo ',
|
|
1105
|
+
),
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
{setIfMissing: {[`content[_key=="blkA"].children`]: []}},
|
|
1109
|
+
{
|
|
1110
|
+
insert: {
|
|
1111
|
+
after: `content[_key=="blkA"].children[_key=="spA"]`,
|
|
1112
|
+
items: [span('fragBold', 'charlie ')],
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
set: {
|
|
1117
|
+
[`content[_key=="blkA"].children[_key=="fragBold"].marks`]: ['strong'],
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
],
|
|
1121
|
+
'txn-a1',
|
|
1122
|
+
),
|
|
1123
|
+
// second toggle: unbold and merge the fragments back
|
|
1124
|
+
actA2: editPreserved(
|
|
1125
|
+
[
|
|
1126
|
+
{
|
|
1127
|
+
set: {
|
|
1128
|
+
[`content[_key=="blkA"].children[_key=="fragBold"].marks`]: [],
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
diffMatchPatch: {
|
|
1133
|
+
[`content[_key=="blkA"].children[_key=="fragBold"].text`]: dmp(
|
|
1134
|
+
'charlie ',
|
|
1135
|
+
'charlie delta',
|
|
1136
|
+
),
|
|
1137
|
+
},
|
|
1138
|
+
},
|
|
1139
|
+
{unset: [`content[_key=="blkA"].children[_key=="fragTail"]`]},
|
|
1140
|
+
],
|
|
1141
|
+
'txn-a2',
|
|
1142
|
+
),
|
|
1143
|
+
actB: editField(
|
|
1144
|
+
{
|
|
1145
|
+
set: {
|
|
1146
|
+
'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
'txn-b',
|
|
1150
|
+
),
|
|
1151
|
+
verify: ({serverDraft}) => {
|
|
1152
|
+
// Formatting toggles must never change the text: block A reads exactly
|
|
1153
|
+
// the seed sentence, in order, regardless of apply order.
|
|
1154
|
+
expect(blockText(serverDraft, 'blkA')).toBe('alpha bravo charlie delta')
|
|
1155
|
+
// and B's concurrent edit in the other block survives
|
|
1156
|
+
expect(blockText(serverDraft, 'blkB')).toBe('echo foxtrot twoB golf hotel')
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
id: 'rebase-preserved-keyed-inserts',
|
|
1161
|
+
title: "A's pending preserveOperations insert is rebased over B's concurrent keyed insert",
|
|
1162
|
+
actA1: editPreserved(
|
|
1163
|
+
[
|
|
1164
|
+
{
|
|
1165
|
+
insert: {
|
|
1166
|
+
after: 'content[_key=="blkA"]',
|
|
1167
|
+
items: [block('blkC', 'spC', 'inserted by A1')],
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
],
|
|
1171
|
+
'txn-a1',
|
|
1172
|
+
),
|
|
1173
|
+
actA2: editPreserved(
|
|
1174
|
+
[
|
|
1175
|
+
{
|
|
1176
|
+
insert: {
|
|
1177
|
+
after: 'content[_key=="blkC"]',
|
|
1178
|
+
items: [block('blkE', 'spE', 'inserted by A2')],
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
'txn-a2',
|
|
1183
|
+
),
|
|
1184
|
+
actB: editPreserved(
|
|
1185
|
+
[
|
|
1186
|
+
{
|
|
1187
|
+
insert: {
|
|
1188
|
+
after: 'content[_key=="blkB"]',
|
|
1189
|
+
items: [block('blkD', 'spD', 'inserted by B')],
|
|
1190
|
+
},
|
|
1191
|
+
},
|
|
1192
|
+
],
|
|
1193
|
+
'txn-b',
|
|
1194
|
+
),
|
|
1195
|
+
verify: ({serverDraft}) => {
|
|
1196
|
+
const keys = serverDraft?.content?.map((b) => b._key)
|
|
1197
|
+
expect(keys).toContain('blkC')
|
|
1198
|
+
expect(keys).toContain('blkD')
|
|
1199
|
+
expect(keys).toContain('blkE')
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
]
|
|
1203
|
+
|
|
1204
|
+
async function runRebaseCollision(
|
|
1205
|
+
scenario: RebaseScenario,
|
|
1206
|
+
order: 'a-first' | 'b-first',
|
|
1207
|
+
): Promise<CollisionResult> {
|
|
1208
|
+
server.seed({[DRAFT_ID]: (scenario.seed ?? seedDocument)()})
|
|
1209
|
+
|
|
1210
|
+
const stateA = getDocumentState<RigDocument>(clientA.instance, handle)
|
|
1211
|
+
const stateB = getDocumentState<RigDocument>(clientB.instance, handle)
|
|
1212
|
+
const unsubscribeA = stateA.subscribe()
|
|
1213
|
+
const unsubscribeB = stateB.subscribe()
|
|
1214
|
+
|
|
1215
|
+
try {
|
|
1216
|
+
await firstValueFrom(stateA.observable.pipe(first((doc) => doc !== undefined)))
|
|
1217
|
+
await firstValueFrom(stateB.observable.pipe(first((doc) => doc !== undefined)))
|
|
1218
|
+
|
|
1219
|
+
server.holdSubmissions()
|
|
1220
|
+
|
|
1221
|
+
await scenario.actA1(clientA)
|
|
1222
|
+
// wait until txn-a1 is submitted (and held) so the follow-up edit stays in
|
|
1223
|
+
// the applied queue instead of batching into the same outgoing transaction
|
|
1224
|
+
await vi.waitFor(() => {
|
|
1225
|
+
expect(server.pendingTransactionIds()).toContain('txn-a1')
|
|
1226
|
+
})
|
|
1227
|
+
await scenario.actA2(clientA)
|
|
1228
|
+
await scenario.actA3?.(clientA)
|
|
1229
|
+
await scenario.actB(clientB)
|
|
1230
|
+
await vi.waitFor(() => {
|
|
1231
|
+
expect(server.pendingTransactionIds()).toContain('txn-b')
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
const [firstTxn, secondTxn] = order === 'a-first' ? ['txn-a1', 'txn-b'] : ['txn-b', 'txn-a1']
|
|
1235
|
+
await server.release(firstTxn)
|
|
1236
|
+
await server.release(secondTxn)
|
|
1237
|
+
|
|
1238
|
+
// txn-a1's ack clears A's outgoing slot; the queued edits submit next,
|
|
1239
|
+
// either individually or batched into one outgoing transaction (queued
|
|
1240
|
+
// applied edits coalesce, and the batch carries the last transactionId)
|
|
1241
|
+
await vi.waitFor(() => {
|
|
1242
|
+
expect(server.pendingTransactionIds().some((id) => id.startsWith('txn-a'))).toBe(true)
|
|
1243
|
+
})
|
|
1244
|
+
// B's second edit (if any) lands while A's follow-up is in flight, so
|
|
1245
|
+
// any remaining rebase happens over a remote that already contains it
|
|
1246
|
+
if (scenario.actB2) {
|
|
1247
|
+
await scenario.actB2(clientB)
|
|
1248
|
+
await vi.waitFor(() => {
|
|
1249
|
+
expect(server.pendingTransactionIds()).toContain('txn-b2')
|
|
1250
|
+
})
|
|
1251
|
+
await server.release('txn-b2')
|
|
1252
|
+
}
|
|
1253
|
+
// release A transactions until none remain pending or queued
|
|
1254
|
+
for (;;) {
|
|
1255
|
+
const pendingA = server.pendingTransactionIds().filter((id) => id.startsWith('txn-a'))
|
|
1256
|
+
if (pendingA.length === 0) break
|
|
1257
|
+
await server.release(pendingA[0])
|
|
1258
|
+
// let the store submit the next queued transaction, if any
|
|
1259
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const syncA = getDocumentSyncStatus(clientA.instance, handle)
|
|
1263
|
+
const syncB = getDocumentSyncStatus(clientB.instance, handle)
|
|
1264
|
+
|
|
1265
|
+
await vi.waitFor(
|
|
1266
|
+
() => {
|
|
1267
|
+
expect(syncA.getCurrent()).toBe(true)
|
|
1268
|
+
expect(syncB.getCurrent()).toBe(true)
|
|
1269
|
+
for (const id of [DRAFT_ID, PUBLISHED_ID]) {
|
|
1270
|
+
const serverDoc = withoutUpdatedAt(server.getDocument(id))
|
|
1271
|
+
expect(withoutUpdatedAt(localDoc(clientA, id) ?? null)).toEqual(serverDoc)
|
|
1272
|
+
expect(withoutUpdatedAt(localDoc(clientB, id) ?? null)).toEqual(serverDoc)
|
|
1273
|
+
}
|
|
1274
|
+
},
|
|
1275
|
+
{timeout: 3000},
|
|
1276
|
+
)
|
|
1277
|
+
|
|
1278
|
+
return {
|
|
1279
|
+
serverDraft: server.getDocument(DRAFT_ID) as RigDocument | null,
|
|
1280
|
+
serverPublished: server.getDocument(PUBLISHED_ID) as RigDocument | null,
|
|
1281
|
+
local: {
|
|
1282
|
+
A: {
|
|
1283
|
+
draft: localDoc(clientA, DRAFT_ID) ?? null,
|
|
1284
|
+
published: localDoc(clientA, PUBLISHED_ID) ?? null,
|
|
1285
|
+
},
|
|
1286
|
+
B: {
|
|
1287
|
+
draft: localDoc(clientB, DRAFT_ID) ?? null,
|
|
1288
|
+
published: localDoc(clientB, PUBLISHED_ID) ?? null,
|
|
1289
|
+
},
|
|
1290
|
+
},
|
|
1291
|
+
}
|
|
1292
|
+
} finally {
|
|
1293
|
+
unsubscribeA()
|
|
1294
|
+
unsubscribeB()
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
describe.each([
|
|
1299
|
+
{order: 'a-first' as const, firstWriter: 'A' as const},
|
|
1300
|
+
{order: 'b-first' as const, firstWriter: 'B' as const},
|
|
1301
|
+
])('rebase matrix ($order)', ({order, firstWriter}) => {
|
|
1302
|
+
it.each(rebaseScenarios)('$id: $title', async (scenario) => {
|
|
1303
|
+
const result = await runRebaseCollision(scenario, order)
|
|
1304
|
+
scenario.verify(result, firstWriter)
|
|
1305
|
+
})
|
|
1306
|
+
})
|