@sanity/sdk 3.0.0-rc.0 → 3.0.0-rc.2

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.
Files changed (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -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
+ })