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

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 (197) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
  3. package/dist/_chunks-es/_internal.js +134 -0
  4. package/dist/_chunks-es/_internal.js.map +1 -0
  5. package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
  6. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
  7. package/dist/_chunks-es/telemetryManager.js +93 -0
  8. package/dist/_chunks-es/telemetryManager.js.map +1 -0
  9. package/dist/_exports/_internal.d.ts +95 -0
  10. package/dist/_exports/_internal.d.ts.map +1 -0
  11. package/dist/_exports/_internal.js +24 -0
  12. package/dist/_exports/_internal.js.map +1 -0
  13. package/dist/index.d.ts +1079 -1076
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +1967 -2302
  16. package/dist/index.js.map +1 -1
  17. package/package.json +48 -47
  18. package/src/_exports/_internal.ts +26 -0
  19. package/src/_exports/index.ts +120 -11
  20. package/src/access/checkPermissions.test.ts +55 -0
  21. package/src/access/checkPermissions.ts +54 -0
  22. package/src/agent/agentActions.test.ts +16 -60
  23. package/src/agent/agentActions.ts +25 -38
  24. package/src/applications/applications.test-d.ts +83 -0
  25. package/src/applications/applications.test.ts +130 -0
  26. package/src/applications/applications.ts +364 -0
  27. package/src/applications/userApplications.test.ts +126 -0
  28. package/src/applications/userApplications.ts +261 -0
  29. package/src/auth/authLogger.ts +30 -0
  30. package/src/auth/authMode.test.ts +25 -0
  31. package/src/auth/authMode.ts +6 -3
  32. package/src/auth/authStore.test.ts +194 -13
  33. package/src/auth/authStore.ts +59 -26
  34. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  35. package/src/auth/getOrganizationVerificationState.ts +4 -4
  36. package/src/auth/handleAuthCallback.test.ts +35 -1
  37. package/src/auth/handleAuthCallback.ts +25 -6
  38. package/src/auth/logout.test.ts +75 -2
  39. package/src/auth/logout.ts +23 -4
  40. package/src/auth/refreshStampedToken.test.ts +219 -469
  41. package/src/auth/refreshStampedToken.ts +79 -195
  42. package/src/auth/studioAuth.ts +1 -1
  43. package/src/auth/studioModeAuth.ts +2 -1
  44. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  45. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  46. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  47. package/src/client/clientStore.test.ts +62 -37
  48. package/src/client/clientStore.ts +45 -75
  49. package/src/client/liveEvents.test.ts +151 -0
  50. package/src/client/liveEvents.ts +107 -0
  51. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  52. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  53. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  54. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  55. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  56. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  57. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  58. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  59. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  60. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  61. package/src/comlink/node/getNodeState.test.ts +1 -1
  62. package/src/comlink/node/getNodeState.ts +2 -1
  63. package/src/config/__tests__/handles.test.ts +18 -12
  64. package/src/config/handles.ts +25 -7
  65. package/src/config/sanityConfig.ts +109 -79
  66. package/src/datasets/datasets.test.ts +2 -2
  67. package/src/datasets/datasets.ts +10 -4
  68. package/src/document/actions.test.ts +115 -33
  69. package/src/document/actions.ts +212 -15
  70. package/src/document/applyDocumentActions.test.ts +38 -12
  71. package/src/document/applyDocumentActions.ts +30 -9
  72. package/src/document/documentConstants.ts +7 -0
  73. package/src/document/documentStore.concurrency.test.ts +1306 -0
  74. package/src/document/documentStore.test.ts +673 -278
  75. package/src/document/documentStore.ts +162 -71
  76. package/src/document/events.test.ts +57 -2
  77. package/src/document/events.ts +82 -26
  78. package/src/document/listen.test.ts +56 -0
  79. package/src/document/listen.ts +74 -21
  80. package/src/document/listenerEventOperators.test.ts +311 -0
  81. package/src/document/listenerEventOperators.ts +217 -0
  82. package/src/document/patchOperations.test.ts +49 -2
  83. package/src/document/patchOperations.ts +50 -0
  84. package/src/document/permissions.test.ts +105 -60
  85. package/src/document/permissions.ts +44 -19
  86. package/src/document/processActions/create.ts +138 -0
  87. package/src/document/processActions/delete.ts +100 -0
  88. package/src/document/processActions/discard.ts +63 -0
  89. package/src/document/processActions/edit.ts +154 -0
  90. package/src/document/processActions/processActions.ts +218 -0
  91. package/src/document/processActions/publish.ts +120 -0
  92. package/src/document/processActions/releaseArchive.ts +77 -0
  93. package/src/document/processActions/releaseCreate.ts +59 -0
  94. package/src/document/processActions/releaseDelete.ts +65 -0
  95. package/src/document/processActions/releaseEdit.ts +37 -0
  96. package/src/document/processActions/releasePublish.ts +45 -0
  97. package/src/document/processActions/releaseSchedule.ts +87 -0
  98. package/src/document/processActions/releaseUtil.ts +31 -0
  99. package/src/document/processActions/shared.ts +308 -0
  100. package/src/document/processActions/unpublish.ts +85 -0
  101. package/src/document/processActions.test.ts +1129 -121
  102. package/src/document/reducers.test.ts +639 -64
  103. package/src/document/reducers.ts +187 -48
  104. package/src/document/resourceRules.test.ts +178 -0
  105. package/src/document/resourceRules.ts +117 -0
  106. package/src/document/sharedListener.test.ts +16 -14
  107. package/src/document/sharedListener.ts +24 -8
  108. package/src/document/util.ts +14 -0
  109. package/src/favorites/favorites.test.ts +5 -6
  110. package/src/installations/installations.test-d.ts +68 -0
  111. package/src/installations/installations.test.ts +62 -0
  112. package/src/installations/installations.ts +202 -0
  113. package/src/organization/organization.test-d.ts +36 -0
  114. package/src/organization/organization.test.ts +138 -0
  115. package/src/organization/organization.ts +138 -0
  116. package/src/organizations/organizations.test-d.ts +56 -0
  117. package/src/organizations/organizations.test.ts +150 -0
  118. package/src/organizations/organizations.ts +107 -0
  119. package/src/presence/bifurTransport.test.ts +46 -6
  120. package/src/presence/bifurTransport.ts +19 -2
  121. package/src/presence/presenceStore.test.ts +94 -23
  122. package/src/presence/presenceStore.ts +85 -29
  123. package/src/preview/getPreviewState.test.ts +137 -0
  124. package/src/preview/getPreviewState.ts +69 -0
  125. package/src/preview/previewProjectionUtils.test.ts +9 -22
  126. package/src/preview/previewProjectionUtils.ts +7 -3
  127. package/src/preview/resolvePreview.test.ts +64 -0
  128. package/src/preview/resolvePreview.ts +43 -0
  129. package/src/preview/types.ts +10 -0
  130. package/src/project/project.test-d.ts +37 -0
  131. package/src/project/project.test.ts +157 -12
  132. package/src/project/project.ts +138 -25
  133. package/src/projection/getProjectionState.test.ts +18 -20
  134. package/src/projection/getProjectionState.ts +23 -3
  135. package/src/projection/projectionQuery.ts +5 -5
  136. package/src/projection/projectionStore.test.ts +1 -1
  137. package/src/projection/resolveProjection.test.ts +1 -2
  138. package/src/projection/resolveProjection.ts +16 -0
  139. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  140. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  141. package/src/projection/types.ts +8 -0
  142. package/src/projects/projects.test-d.ts +32 -0
  143. package/src/projects/projects.test.ts +108 -42
  144. package/src/projects/projects.ts +63 -27
  145. package/src/query/queryStore.test.ts +259 -123
  146. package/src/query/queryStore.ts +78 -63
  147. package/src/query/reducers.test.ts +0 -42
  148. package/src/query/reducers.ts +2 -18
  149. package/src/releases/getPerspectiveState.test.ts +17 -23
  150. package/src/releases/getPerspectiveState.ts +8 -20
  151. package/src/releases/observeReleases.test.ts +127 -0
  152. package/src/releases/observeReleases.ts +102 -0
  153. package/src/releases/releasesStore.test.ts +100 -53
  154. package/src/releases/releasesStore.ts +69 -44
  155. package/src/releases/utils/sortReleases.test.ts +38 -2
  156. package/src/releases/utils/sortReleases.ts +15 -10
  157. package/src/store/createActionBinder.test.ts +87 -90
  158. package/src/store/createActionBinder.ts +79 -25
  159. package/src/store/createSanityInstance.test.ts +83 -13
  160. package/src/store/createSanityInstance.ts +77 -2
  161. package/src/store/createStateSourceAction.test.ts +64 -2
  162. package/src/store/createStateSourceAction.ts +39 -44
  163. package/src/store/createStoreInstance.test.ts +4 -2
  164. package/src/store/fetcherStore.test-d.ts +121 -0
  165. package/src/store/fetcherStore.test.ts +299 -0
  166. package/src/store/fetcherStore.ts +801 -0
  167. package/src/telemetry/environment.test.ts +119 -0
  168. package/src/telemetry/environment.ts +92 -0
  169. package/src/telemetry/events.ts +42 -0
  170. package/src/telemetry/initTelemetry.test.ts +432 -0
  171. package/src/telemetry/initTelemetry.ts +218 -0
  172. package/src/telemetry/telemetryManager.test.ts +327 -0
  173. package/src/telemetry/telemetryManager.ts +199 -0
  174. package/src/users/reducers.test.ts +6 -1
  175. package/src/users/reducers.ts +5 -6
  176. package/src/users/types.ts +4 -4
  177. package/src/users/usersStore.test.ts +16 -12
  178. package/src/users/usersStore.ts +5 -1
  179. package/src/utils/buildQuery.test.ts +14 -0
  180. package/src/utils/buildQuery.ts +17 -0
  181. package/src/utils/createFetcherStore.test.ts +7 -5
  182. package/src/utils/createFetcherStore.ts +8 -5
  183. package/src/utils/getStagingApiHost.test.ts +21 -0
  184. package/src/utils/getStagingApiHost.ts +14 -0
  185. package/src/utils/ids.test.ts +1 -29
  186. package/src/utils/ids.ts +0 -10
  187. package/src/utils/includeShape.ts +36 -0
  188. package/src/utils/isImportError.test.ts +72 -0
  189. package/src/utils/isImportError.ts +34 -0
  190. package/src/utils/logger.test.ts +12 -0
  191. package/src/utils/logger.ts +8 -3
  192. package/src/utils/object.test.ts +95 -0
  193. package/src/utils/object.ts +142 -0
  194. package/src/utils/setCleanupTimeout.ts +37 -0
  195. package/src/document/processActions.ts +0 -707
  196. package/src/utils/defineIntent.test.ts +0 -477
  197. package/src/utils/defineIntent.ts +0 -244
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  type BaseActionOptions,
3
+ type BaseMutationOptions,
3
4
  type FilteredResponseQueryOptions,
4
5
  type ListenEvent,
5
6
  type MultipleActionResult,
7
+ type MultipleMutationResult,
6
8
  type MutationEvent,
7
9
  type RawQueryResponse,
8
10
  type ResponseQueryOptions,
@@ -12,6 +14,7 @@ import {
12
14
  type WelcomeEvent,
13
15
  } from '@sanity/client'
14
16
  import {diffValue} from '@sanity/diff-patch'
17
+ import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
15
18
  import {type Mutation, type SanityDocument} from '@sanity/types'
16
19
  import {evaluate, parse} from 'groq-js'
17
20
  import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs'
@@ -21,7 +24,6 @@ import {getClientState} from '../client/clientStore'
21
24
  import {createDocumentHandle} from '../config/handles'
22
25
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
23
26
  import {type StateSource} from '../store/createStateSourceAction'
24
- import {getDraftId, getPublishedId} from '../utils/ids'
25
27
  import {
26
28
  createDocument,
27
29
  deleteDocument,
@@ -39,7 +41,12 @@ import {
39
41
  resolvePermissions,
40
42
  subscribeDocumentEvents,
41
43
  } from './documentStore'
42
- import {type ActionErrorEvent, type TransactionRevertedEvent} from './events'
44
+ import {
45
+ type ActionErrorEvent,
46
+ type DocumentRemotePatchesEvent,
47
+ type TransactionRevertedEvent,
48
+ } from './events'
49
+ import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
43
50
  import {type DatasetAcl} from './permissions'
44
51
  import {type DocumentSet, processMutations} from './processMutations'
45
52
  import {type HttpAction} from './reducers'
@@ -51,23 +58,42 @@ interface TestDocument extends SanityDocument {
51
58
  title?: string
52
59
  }
53
60
 
61
+ // Scope the TestDocument type to the project/datasets used in tests
62
+ type AllTestSchemaTypes = TestDocument
63
+
64
+ // Augment the 'groq' module
65
+ declare module 'groq' {
66
+ interface SanitySchemas {
67
+ default: AllTestSchemaTypes
68
+ }
69
+ }
70
+
54
71
  let instance: SanityInstance
72
+ let instance1: SanityInstance
73
+ let instance2: SanityInstance
55
74
 
56
- // bindActionByResource should ensure docs from different resources are isolated
57
75
  const resource = {projectId: 'p', dataset: 'd'}
58
- const resource1 = {projectId: 'p', dataset: 'd1'}
59
- const resource2 = {projectId: 'p', dataset: 'd2'}
76
+ const source1 = {projectId: 'p', dataset: 'd1'}
77
+ const source2 = {projectId: 'p', dataset: 'd2'}
60
78
 
61
79
  beforeEach(() => {
62
- instance = createSanityInstance()
80
+ instance = createSanityInstance({projectId: 'p', dataset: 'd'})
81
+ // test uses two instances that share the same in-memory dataset, but separate
82
+ // store instances. in real scenarios, this would be separate machines but with
83
+ // the same project + dataset
84
+ instance1 = createSanityInstance({projectId: 'p', dataset: 'd1'})
85
+ instance2 = createSanityInstance({projectId: 'p', dataset: 'd2'})
63
86
  })
64
87
 
65
88
  afterEach(() => {
66
89
  instance?.dispose()
90
+ instance1?.dispose()
91
+ instance2?.dispose()
67
92
  })
68
93
 
69
94
  it('creates, edits, and publishes a document', async () => {
70
- const doc = createDocumentHandle({documentId: 'doc-single', documentType: 'article', resource})
95
+ const documentId = DocumentId('doc-single')
96
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
71
97
  const documentState = getDocumentState<TestDocument>(instance, doc)
72
98
 
73
99
  // Initially the document is undefined
@@ -80,10 +106,10 @@ it('creates, edits, and publishes a document', async () => {
80
106
  actions: [createDocument(doc)],
81
107
  resource,
82
108
  })
83
- expect(appeared).toContain(getDraftId(doc.documentId))
109
+ expect(appeared).toContain(getDraftId(documentId))
84
110
 
85
111
  let currentDoc = documentState.getCurrent()
86
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
112
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
87
113
 
88
114
  // Edit the document – add a title
89
115
  await applyDocumentActions(instance, {
@@ -101,16 +127,13 @@ it('creates, edits, and publishes a document', async () => {
101
127
  await submitted()
102
128
  currentDoc = documentState.getCurrent()
103
129
 
104
- expect(currentDoc).toMatchObject({_id: doc.documentId, _rev: transactionId})
130
+ expect(currentDoc).toMatchObject({_id: documentId, _rev: transactionId})
105
131
  unsubscribe()
106
132
  })
107
133
 
108
134
  it('creates a document with initial values', async () => {
109
- const doc = createDocumentHandle({
110
- documentId: 'doc-with-initial',
111
- documentType: 'article',
112
- resource,
113
- })
135
+ const documentId = DocumentId('doc-with-initial')
136
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
114
137
  const documentState = getDocumentState<TestDocument>(instance, doc)
115
138
 
116
139
  expect(documentState.getCurrent()).toBeUndefined()
@@ -128,10 +151,10 @@ it('creates a document with initial values', async () => {
128
151
  ],
129
152
  resource,
130
153
  })
131
- expect(appeared).toContain(getDraftId(doc.documentId))
154
+ expect(appeared).toContain(getDraftId(documentId))
132
155
 
133
156
  const currentDoc = documentState.getCurrent()
134
- expect(currentDoc?._id).toEqual(getDraftId(doc.documentId))
157
+ expect(currentDoc?._id).toEqual(getDraftId(documentId))
135
158
  expect(currentDoc?.title).toEqual('Article with Initial Values')
136
159
  expect(currentDoc?.['author']).toEqual('Jane Doe')
137
160
  expect(currentDoc?.['count']).toEqual(42)
@@ -140,7 +163,8 @@ it('creates a document with initial values', async () => {
140
163
  })
141
164
 
142
165
  it('edits existing documents', async () => {
143
- const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article', resource})
166
+ const documentId = DocumentId('existing-doc')
167
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
144
168
  const state = getDocumentState<TestDocument>(instance, doc)
145
169
 
146
170
  // not subscribed yet so the value is undefined
@@ -152,7 +176,7 @@ it('edits existing documents', async () => {
152
176
  await firstValueFrom(state.observable.pipe(first((i) => !!i)))
153
177
 
154
178
  expect(state.getCurrent()).toMatchObject({
155
- _id: getDraftId(doc.documentId),
179
+ _id: getDraftId(documentId),
156
180
  title: 'existing doc',
157
181
  })
158
182
 
@@ -161,7 +185,7 @@ it('edits existing documents', async () => {
161
185
  resource,
162
186
  })
163
187
  expect(state.getCurrent()).toMatchObject({
164
- _id: getDraftId(doc.documentId),
188
+ _id: getDraftId(documentId),
165
189
  title: 'updated title',
166
190
  })
167
191
 
@@ -169,33 +193,31 @@ it('edits existing documents', async () => {
169
193
  })
170
194
 
171
195
  it('sets optimistic changes synchronously', async () => {
172
- const doc1 = createDocumentHandle({
173
- documentId: 'optimistic',
196
+ const doc1 = {
197
+ documentId: DocumentId('optimistic'),
174
198
  documentType: 'article',
175
- resource: resource1,
176
- })
177
- const doc2 = createDocumentHandle({
178
- documentId: 'optimistic',
199
+ }
200
+ const doc2 = {
201
+ documentId: DocumentId('optimistic'),
179
202
  documentType: 'article',
180
- resource: resource2,
181
- })
203
+ }
182
204
 
183
- const state1 = getDocumentState<TestDocument>(instance, doc1)
184
- const state2 = getDocumentState<TestDocument>(instance, doc2)
205
+ const state1 = getDocumentState(instance1, doc1)
206
+ const state2 = getDocumentState(instance2, doc2)
185
207
 
186
208
  const unsubscribe1 = state1.subscribe()
187
209
  const unsubscribe2 = state2.subscribe()
188
210
 
189
211
  // wait until the value is primed in the store
190
- await resolveDocument(instance, doc1)
212
+ await resolveDocument(instance1, doc1)
191
213
 
192
214
  // then the actions are synchronous
193
215
  expect(state1.getCurrent()).toBeNull()
194
- applyDocumentActions(instance, {actions: [createDocument(doc1)], resource: resource1})
216
+ applyDocumentActions(instance1, {actions: [createDocument(doc1)], resource: source1})
195
217
  expect(state1.getCurrent()).toMatchObject({_id: getDraftId(doc1.documentId)})
196
- const actionResult1Promise = applyDocumentActions(instance, {
218
+ const actionResult1Promise = applyDocumentActions(instance1, {
197
219
  actions: [editDocument(doc1, {set: {title: 'initial title'}})],
198
- resource: resource1,
220
+ resource: source1,
199
221
  })
200
222
  expect(state1.getCurrent()?.title).toBe('initial title')
201
223
 
@@ -214,9 +236,9 @@ it('sets optimistic changes synchronously', async () => {
214
236
  expect(state2.getCurrent()?.title).toBe('initial title')
215
237
 
216
238
  // synchronous for state 2
217
- const actionResult2Promise = applyDocumentActions(instance, {
239
+ const actionResult2Promise = applyDocumentActions(instance2, {
218
240
  actions: [editDocument(doc2, {set: {title: 'updated title'}})],
219
- resource: resource2,
241
+ resource: source2,
220
242
  })
221
243
  expect(state2.getCurrent()?.title).toBe('updated title')
222
244
  // async for state 1
@@ -231,37 +253,27 @@ it('sets optimistic changes synchronously', async () => {
231
253
  })
232
254
 
233
255
  it('propagates changes between two instances', async () => {
234
- const doc1 = createDocumentHandle({
235
- documentId: 'doc-collab',
236
- documentType: 'article',
237
- resource: resource1,
238
- })
239
- const doc2 = createDocumentHandle({
240
- documentId: 'doc-collab',
241
- documentType: 'article',
242
- resource: resource2,
243
- })
244
- const state1 = getDocumentState<TestDocument>(instance, doc1)
245
- const state2 = getDocumentState<TestDocument>(instance, doc2)
256
+ const doc = createDocumentHandle({documentId: 'doc-collab', documentType: 'article'})
257
+ const state1 = getDocumentState(instance1, doc)
258
+ const state2 = getDocumentState(instance2, doc)
246
259
 
247
260
  const state1Unsubscribe = state1.subscribe()
248
261
  const state2Unsubscribe = state2.subscribe()
249
262
 
250
- // Create the document from the store instance.
251
- await applyDocumentActions(instance, {
252
- actions: [createDocument(doc1)],
253
- resource: resource1,
254
- }).then((r) => r.submitted())
263
+ // Create the document from instance1.
264
+ await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
265
+ (r) => r.submitted(),
266
+ )
255
267
 
256
- const docState1 = state1.getCurrent()
257
- const docState2 = state2.getCurrent()
258
- expect(docState1?._id).toEqual(getDraftId(doc1.documentId))
259
- expect(docState2?._id).toEqual(getDraftId(doc1.documentId))
268
+ const doc1 = state1.getCurrent()
269
+ const doc2 = state2.getCurrent()
270
+ expect(doc1?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
271
+ expect(doc2?._id).toEqual(getDraftId(DocumentId(doc.documentId)))
260
272
 
261
- // Now, edit the document from the other store instance.
262
- await applyDocumentActions(instance, {
263
- actions: [editDocument(doc2, {set: {title: 'Hello world!'}})],
264
- resource: resource2,
273
+ // Now, edit the document from instance2.
274
+ await applyDocumentActions(instance2, {
275
+ actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
276
+ resource: source2,
265
277
  }).then((r) => r.submitted())
266
278
 
267
279
  const updated1 = state1.getCurrent()
@@ -273,44 +285,95 @@ it('propagates changes between two instances', async () => {
273
285
  state2Unsubscribe()
274
286
  })
275
287
 
276
- it('handles concurrent edits and resolves conflicts', async () => {
277
- const doc1 = createDocumentHandle({
278
- documentId: 'doc-concurrent',
279
- documentType: 'article',
280
- resource: resource1,
288
+ it('emits remote-patches events distinguishing own and foreign transactions', async () => {
289
+ const doc = createDocumentHandle({documentId: 'doc-remote-patches', documentType: 'article'})
290
+ const state1 = getDocumentState(instance1, doc)
291
+ const state2 = getDocumentState(instance2, doc)
292
+
293
+ const state1Unsubscribe = state1.subscribe()
294
+ const state2Unsubscribe = state2.subscribe()
295
+
296
+ await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
297
+ (r) => r.submitted(),
298
+ )
299
+
300
+ const events1: DocumentRemotePatchesEvent[] = []
301
+ const events2: DocumentRemotePatchesEvent[] = []
302
+ const unsubscribeEvents1 = subscribeDocumentEvents(instance1, {
303
+ resource: source1,
304
+ eventHandler: (e) => {
305
+ if (e.type === 'remote-patches') events1.push(e)
306
+ },
281
307
  })
282
- const doc2 = createDocumentHandle({
283
- documentId: 'doc-concurrent',
284
- documentType: 'article',
285
- resource: resource2,
308
+ const unsubscribeEvents2 = subscribeDocumentEvents(instance2, {
309
+ resource: source2,
310
+ eventHandler: (e) => {
311
+ if (e.type === 'remote-patches') events2.push(e)
312
+ },
286
313
  })
287
- const state1 = getDocumentState<TestDocument>(instance, doc1)
288
- const state2 = getDocumentState<TestDocument>(instance, doc2)
314
+
315
+ // edit from instance2: an own transaction for instance2, a foreign one for instance1
316
+ const result = await applyDocumentActions(instance2, {
317
+ actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
318
+ resource: source2,
319
+ }).then((r) => r.submitted())
320
+
321
+ const draftId = getDraftId(DocumentId(doc.documentId))
322
+
323
+ expect(events1).toHaveLength(1)
324
+ expect(events1[0]).toMatchObject({
325
+ type: 'remote-patches',
326
+ documentId: draftId,
327
+ transactionId: result.transactionId,
328
+ origin: 'remote',
329
+ })
330
+ expect(events1[0].patches).toEqual(
331
+ expect.arrayContaining([{set: expect.objectContaining({title: 'Hello world!'})}]),
332
+ )
333
+
334
+ expect(events2).toHaveLength(1)
335
+ expect(events2[0]).toMatchObject({
336
+ type: 'remote-patches',
337
+ documentId: draftId,
338
+ transactionId: result.transactionId,
339
+ origin: 'local',
340
+ })
341
+
342
+ unsubscribeEvents1()
343
+ unsubscribeEvents2()
344
+ state1Unsubscribe()
345
+ state2Unsubscribe()
346
+ })
347
+
348
+ it('handles concurrent edits and resolves conflicts', async () => {
349
+ const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
350
+ const state1 = getDocumentState(instance1, doc)
351
+ const state2 = getDocumentState(instance2, doc)
289
352
 
290
353
  const state1Unsubscribe = state1.subscribe()
291
354
  const state2Unsubscribe = state2.subscribe()
292
355
 
293
- const oneOffInstance = createSanityInstance()
356
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
294
357
 
295
358
  // Create the initial document from a one-off instance.
296
359
  await applyDocumentActions(oneOffInstance, {
297
360
  actions: [
298
- createDocument(doc1),
299
- editDocument(doc1, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
361
+ createDocument(doc),
362
+ editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy dog'}}),
300
363
  ],
301
364
  resource,
302
365
  }).then((res) => res.submitted())
303
366
 
304
- // Both store instances now issue an edit simultaneously.
305
- const p1 = applyDocumentActions(instance, {
306
- actions: [editDocument(doc1, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
307
- resource: resource1,
367
+ // Both instances now issue an edit simultaneously.
368
+ const p1 = applyDocumentActions(instance1, {
369
+ actions: [editDocument(doc, {set: {title: 'The quick brown fox jumps over the lazy cat'}})],
370
+ resource: source1,
308
371
  }).then((r) => r.submitted())
309
- const p2 = applyDocumentActions(instance, {
372
+ const p2 = applyDocumentActions(instance2, {
310
373
  actions: [
311
- editDocument(doc2, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
374
+ editDocument(doc, {set: {title: 'The quick brown elephant jumps over the lazy dog'}}),
312
375
  ],
313
- resource: resource2,
376
+ resource: source2,
314
377
  }).then((r) => r.submitted())
315
378
 
316
379
  // Wait for both actions to complete (or reject).
@@ -326,8 +389,76 @@ it('handles concurrent edits and resolves conflicts', async () => {
326
389
  oneOffInstance.dispose()
327
390
  })
328
391
 
392
+ it('interleaves concurrent keyed-array edits when operations are preserved', async () => {
393
+ const doc = createDocumentHandle({documentId: 'doc-preserve-ops', documentType: 'article'})
394
+ const state1 = getDocumentState(instance1, doc)
395
+ const state2 = getDocumentState(instance2, doc)
396
+
397
+ const state1Unsubscribe = state1.subscribe()
398
+ const state2Unsubscribe = state2.subscribe()
399
+
400
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
401
+ await applyDocumentActions(oneOffInstance, {
402
+ actions: [
403
+ createDocument(doc),
404
+ editDocument(doc, {
405
+ set: {
406
+ items: [
407
+ {_key: 'a', value: 'first'},
408
+ {_key: 'b', value: 'second'},
409
+ ],
410
+ },
411
+ }),
412
+ ],
413
+ resource,
414
+ }).then((r) => r.submitted())
415
+
416
+ // both instances insert into the same keyed array simultaneously, each
417
+ // preserving their operational patches instead of re-diffing snapshots
418
+ const p1 = applyDocumentActions(instance1, {
419
+ actions: [
420
+ editDocument(
421
+ doc,
422
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'from instance1'}]}},
423
+ {preserveOperations: true},
424
+ ),
425
+ ],
426
+ resource: source1,
427
+ }).then((r) => r.submitted())
428
+ const p2 = applyDocumentActions(instance2, {
429
+ actions: [
430
+ editDocument(
431
+ doc,
432
+ {insert: {after: 'items[_key=="b"]', items: [{_key: 'd', value: 'from instance2'}]}},
433
+ {preserveOperations: true},
434
+ ),
435
+ ],
436
+ resource: source2,
437
+ }).then((r) => r.submitted())
438
+
439
+ await Promise.allSettled([p1, p2])
440
+
441
+ const finalDoc1 = state1.getCurrent()
442
+ const finalDoc2 = state2.getCurrent()
443
+
444
+ // neither edit overwrote the other: both keyed inserts landed and both
445
+ // instances converged on the same interleaved result
446
+ expect(finalDoc1?.['items']).toEqual(finalDoc2?.['items'])
447
+ expect(finalDoc1?.['items']).toEqual([
448
+ {_key: 'a', value: 'first'},
449
+ {_key: 'c', value: 'from instance1'},
450
+ {_key: 'b', value: 'second'},
451
+ {_key: 'd', value: 'from instance2'},
452
+ ])
453
+
454
+ state1Unsubscribe()
455
+ state2Unsubscribe()
456
+ oneOffInstance.dispose()
457
+ })
458
+
329
459
  it('unpublishes and discards a document', async () => {
330
- const doc = createDocumentHandle({documentId: 'doc-pub-unpub', documentType: 'article', resource})
460
+ const documentId = DocumentId('doc-pub-unpub')
461
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
331
462
  const documentState = getDocumentState<TestDocument>(instance, doc)
332
463
  const unsubscribe = documentState.subscribe()
333
464
 
@@ -339,7 +470,7 @@ it('unpublishes and discards a document', async () => {
339
470
  })
340
471
  const publishedDoc = documentState.getCurrent()
341
472
  expect(publishedDoc).toMatchObject({
342
- _id: getPublishedId(doc.documentId),
473
+ _id: getPublishedId(documentId),
343
474
  _rev: afterPublish.transactionId,
344
475
  })
345
476
 
@@ -347,7 +478,7 @@ it('unpublishes and discards a document', async () => {
347
478
  await applyDocumentActions(instance, {actions: [unpublishDocument(doc)], resource})
348
479
  const afterUnpublish = documentState.getCurrent()
349
480
  // In our mock implementation the _id remains the same but the published copy is removed.
350
- expect(afterUnpublish?._id).toEqual(getDraftId(doc.documentId))
481
+ expect(afterUnpublish?._id).toEqual(getDraftId(documentId))
351
482
 
352
483
  // Discard the draft (which deletes the draft version).
353
484
  await applyDocumentActions(instance, {actions: [discardDocument(doc)], resource})
@@ -358,9 +489,10 @@ it('unpublishes and discards a document', async () => {
358
489
  })
359
490
 
360
491
  it('deletes a document', async () => {
361
- const doc = createDocumentHandle({documentId: 'doc-delete', documentType: 'article', resource})
492
+ const documentId = DocumentId('doc-delete')
493
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
362
494
 
363
- const documentState = getDocumentState<TestDocument>(instance, doc)
495
+ const documentState = getDocumentState(instance, doc)
364
496
  const unsubscribe = documentState.subscribe()
365
497
 
366
498
  await applyDocumentActions(instance, {
@@ -379,7 +511,8 @@ it('deletes a document', async () => {
379
511
  })
380
512
 
381
513
  it('cleans up document state when there are no subscribers', async () => {
382
- const doc = createDocumentHandle({documentId: 'doc-cleanup', documentType: 'article', resource})
514
+ const documentId = DocumentId('doc-cleanup')
515
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
383
516
  const documentState = getDocumentState<TestDocument>(instance, doc)
384
517
 
385
518
  // Subscribe to the document state.
@@ -397,14 +530,14 @@ it('cleans up document state when there are no subscribers', async () => {
397
530
  await new Promise((resolve) => setTimeout(resolve, 30))
398
531
 
399
532
  // When a new subscriber is created, if the state was cleared it should return undefined.
400
- const newDocumentState = getDocumentState<TestDocument>(instance, doc)
533
+ const newDocumentState = getDocumentState(instance, doc)
401
534
  expect(newDocumentState.getCurrent()).toBeUndefined()
402
535
  })
403
536
 
404
537
  it('fetches documents if there are no active subscriptions for the actions applied', async () => {
405
- const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article', resource})
538
+ const doc = createDocumentHandle({documentId: 'existing-doc', documentType: 'article'})
406
539
 
407
- const {getCurrent} = getDocumentState<TestDocument>(instance, doc)
540
+ const {getCurrent} = getDocumentState(instance, doc)
408
541
  expect(getCurrent()).toBeUndefined()
409
542
  expect(getDocumentSyncStatus(instance, doc).getCurrent()).toBeUndefined()
410
543
 
@@ -457,13 +590,9 @@ it('fetches documents if there are no active subscriptions for the actions appli
457
590
  })
458
591
 
459
592
  it('batches edit transaction into one outgoing transaction', async () => {
460
- const doc = createDocumentHandle({
461
- documentId: crypto.randomUUID(),
462
- documentType: 'article',
463
- resource,
464
- })
593
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
465
594
 
466
- const unsubscribe = getDocumentState<TestDocument>(instance, doc).subscribe()
595
+ const unsubscribe = getDocumentState(instance, doc).subscribe()
467
596
 
468
597
  // this creates its own transaction
469
598
  applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
@@ -490,12 +619,54 @@ it('batches edit transaction into one outgoing transaction', async () => {
490
619
  unsubscribe()
491
620
  })
492
621
 
493
- it('provides the consistency status via `getDocumentSyncStatus`', async () => {
494
- const doc = createDocumentHandle({
495
- documentId: crypto.randomUUID(),
622
+ it('submits liveEdit document edits through observable.mutate', async () => {
623
+ const liveDoc = createDocumentHandle({
624
+ documentId: 'live-edit-test-doc',
496
625
  documentType: 'article',
626
+ liveEdit: true,
627
+ })
628
+ const state = getDocumentState(instance, liveDoc)
629
+ const unsubscribe = state.subscribe()
630
+
631
+ await firstValueFrom(state.observable.pipe(first((doc) => doc !== undefined)))
632
+ expect(state.getCurrent()).toMatchObject({
633
+ _id: 'live-edit-test-doc',
634
+ title: 'live initial',
635
+ })
636
+
637
+ const callCountBefore = mutateSubmission.mock.calls.length
638
+
639
+ const result = await applyDocumentActions(instance, {
640
+ actions: [editDocument(liveDoc, {set: {title: 'patched via mutate'}})],
497
641
  resource,
498
642
  })
643
+ await result.submitted()
644
+
645
+ expect(mutateSubmission.mock.calls.length).toBe(callCountBefore + 1)
646
+ const [mutationList, mutateOptions] = mutateSubmission.mock.calls[callCountBefore]!
647
+ expect(mutateOptions).toMatchObject({
648
+ transactionId: result.transactionId,
649
+ visibility: 'async',
650
+ returnDocuments: false,
651
+ returnFirst: false,
652
+ tag: 'document.mutate',
653
+ skipCrossDatasetReferenceValidation: true,
654
+ })
655
+ expect(mutationList.length).toBeGreaterThan(0)
656
+ expect(
657
+ mutationList.every(
658
+ (m) => 'patch' in m && (m as {patch: {id: string}}).patch.id === liveDoc.documentId,
659
+ ),
660
+ ).toBe(true)
661
+
662
+ expect(state.getCurrent()?.title).toBe('patched via mutate')
663
+ expect(client.action).not.toHaveBeenCalled()
664
+
665
+ unsubscribe()
666
+ })
667
+
668
+ it('provides the consistency status via `getDocumentSyncStatus`', async () => {
669
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
499
670
 
500
671
  const syncStatus = getDocumentSyncStatus(instance, doc)
501
672
  expect(syncStatus.getCurrent()).toBeUndefined()
@@ -550,13 +721,10 @@ it('reverts failed outgoing transaction locally', async () => {
550
721
  })
551
722
  })
552
723
 
553
- const doc = createDocumentHandle({
554
- documentId: crypto.randomUUID(),
555
- documentType: 'article',
556
- resource,
557
- })
724
+ const documentId = DocumentId(crypto.randomUUID())
725
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
558
726
 
559
- const {getCurrent, subscribe} = getDocumentState<TestDocument>(instance, doc)
727
+ const {getCurrent, subscribe} = getDocumentState(instance, doc)
560
728
  const unsubscribe = subscribe()
561
729
 
562
730
  await applyDocumentActions(instance, {actions: [createDocument(doc)], resource})
@@ -621,12 +789,8 @@ it('removes a queued transaction if it fails to apply', async () => {
621
789
  })
622
790
  })
623
791
 
624
- const doc = createDocumentHandle({
625
- documentId: crypto.randomUUID(),
626
- documentType: 'article',
627
- resource,
628
- })
629
- const state = getDocumentState<TestDocument>(instance, doc)
792
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
793
+ const state = getDocumentState(instance, doc)
630
794
  const unsubscribe = state.subscribe()
631
795
 
632
796
  await expect(
@@ -664,9 +828,8 @@ it('returns allowed true when no permission errors occur', async () => {
664
828
  const doc = createDocumentHandle({
665
829
  documentId: 'doc-perm-allowed',
666
830
  documentType: 'article',
667
- resource,
668
831
  })
669
- const state = getDocumentState<TestDocument>(instance, doc)
832
+ const state = getDocumentState(instance, doc)
670
833
  const unsubscribe = state.subscribe()
671
834
  await applyDocumentActions(instance, {actions: [createDocument(doc)], resource}).then((r) =>
672
835
  r.submitted(),
@@ -674,7 +837,6 @@ it('returns allowed true when no permission errors occur', async () => {
674
837
 
675
838
  // Use an action that includes a patch (so that update permission check is bypassed).
676
839
  const permissionsState = getPermissionsState(instance, {
677
- resource,
678
840
  actions: [
679
841
  {
680
842
  ...doc,
@@ -691,11 +853,7 @@ it('returns allowed true when no permission errors occur', async () => {
691
853
  })
692
854
 
693
855
  it("should reject applying the action if a precondition isn't met", async () => {
694
- const doc = createDocumentHandle({
695
- documentId: 'does-not-exist',
696
- documentType: 'article',
697
- resource,
698
- })
856
+ const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
699
857
 
700
858
  await expect(
701
859
  applyDocumentActions(instance, {actions: [deleteDocument(doc)], resource}),
@@ -703,11 +861,7 @@ it("should reject applying the action if a precondition isn't met", async () =>
703
861
  })
704
862
 
705
863
  it("should reject applying the action if a permission isn't met", async () => {
706
- const doc = createDocumentHandle({
707
- documentId: 'does-not-exist',
708
- documentType: 'article',
709
- resource,
710
- })
864
+ const doc = createDocumentHandle({documentId: 'does-not-exist', documentType: 'article'})
711
865
 
712
866
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
713
867
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
@@ -721,12 +875,8 @@ it('returns allowed false with reasons when permission errors occur', async () =
721
875
  const datasetAcl = [{filter: 'false', permissions: ['create']}]
722
876
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
723
877
 
724
- const doc = createDocumentHandle({
725
- documentId: 'doc-perm-denied',
726
- documentType: 'article',
727
- resource,
728
- })
729
- const result = await resolvePermissions(instance, {actions: [createDocument(doc)], resource})
878
+ const doc = createDocumentHandle({documentId: 'doc-perm-denied', documentType: 'article'})
879
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
730
880
 
731
881
  const message = 'You do not have permission to create a draft for document "doc-perm-denied".'
732
882
  expect(result).toMatchObject({
@@ -744,44 +894,47 @@ it('fetches dataset ACL and updates grants in the document store state', async (
744
894
  ]
745
895
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
746
896
 
747
- const book = createDocumentHandle({
748
- documentId: crypto.randomUUID(),
749
- documentType: 'book',
750
- resource,
751
- })
752
- const author = createDocumentHandle({
753
- documentId: crypto.randomUUID(),
754
- documentType: 'author',
755
- resource,
756
- })
897
+ const book = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'book'})
898
+ const author = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'author'})
757
899
 
758
- expect(await resolvePermissions(instance, {actions: [createDocument(book)], resource})).toEqual({
900
+ expect(await resolvePermissions(instance, {actions: [createDocument(book)]})).toEqual({
759
901
  allowed: true,
760
902
  })
761
- expect(
762
- await resolvePermissions(instance, {actions: [createDocument(author)], resource}),
763
- ).toMatchObject({
903
+ expect(await resolvePermissions(instance, {actions: [createDocument(author)]})).toMatchObject({
764
904
  allowed: false,
765
905
  message: expect.stringContaining('You do not have permission to create a draft for document'),
766
906
  })
767
907
  })
768
908
 
909
+ it('does not send credentials with the dataset ACL request', async () => {
910
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
911
+ await resolvePermissions(instance, {actions: [createDocument(doc)]})
912
+
913
+ const aclCall = vi
914
+ .mocked(client.request)
915
+ .mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
916
+ expect(aclCall).toBeDefined()
917
+ expect(aclCall![0]).not.toHaveProperty('withCredentials')
918
+ })
919
+
769
920
  it('fetches ACL for MediaLibraryResource', async () => {
770
- const mediaLibraryInstance = createSanityInstance()
921
+ const mediaLibraryInstance = createSanityInstance({
922
+ projectId: 'p',
923
+ dataset: 'd',
924
+ resources: {
925
+ 'media-library': {mediaLibraryId: 'test-media-library'},
926
+ },
927
+ })
771
928
 
772
929
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
773
930
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
774
931
 
775
- const doc = createDocumentHandle({
776
- documentId: crypto.randomUUID(),
777
- documentType: 'article',
778
- resource,
779
- })
780
- const mediaLibraryResource = {mediaLibraryId: 'test-media-library'}
932
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
933
+ const mediaLibrarySource = {mediaLibraryId: 'test-media-library'}
781
934
 
782
935
  const result = await resolvePermissions(mediaLibraryInstance, {
783
936
  actions: [createDocument(doc)],
784
- resource: mediaLibraryResource,
937
+ resource: mediaLibrarySource,
785
938
  })
786
939
 
787
940
  expect(result).toEqual({allowed: true})
@@ -789,21 +942,23 @@ it('fetches ACL for MediaLibraryResource', async () => {
789
942
  })
790
943
 
791
944
  it('fetches ACL for CanvasResource', async () => {
792
- const canvasInstance = createSanityInstance()
945
+ const canvasInstance = createSanityInstance({
946
+ projectId: 'p',
947
+ dataset: 'd',
948
+ resources: {
949
+ canvas: {canvasId: 'test-canvas'},
950
+ },
951
+ })
793
952
 
794
953
  const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
795
954
  vi.mocked(client.request).mockResolvedValue(datasetAcl)
796
955
 
797
- const doc = createDocumentHandle({
798
- documentId: crypto.randomUUID(),
799
- documentType: 'article',
800
- resource,
801
- })
802
- const canvasResource = {canvasId: 'test-canvas'}
956
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
957
+ const canvasSource = {canvasId: 'test-canvas'}
803
958
 
804
959
  const result = await resolvePermissions(canvasInstance, {
805
960
  actions: [createDocument(doc)],
806
- resource: canvasResource,
961
+ resource: canvasSource,
807
962
  })
808
963
 
809
964
  expect(result).toEqual({allowed: true})
@@ -811,16 +966,13 @@ it('fetches ACL for CanvasResource', async () => {
811
966
  })
812
967
 
813
968
  it('returns a promise that resolves when a document has been loaded in the store (useful for suspense)', async () => {
814
- const doc = createDocumentHandle({
815
- documentId: crypto.randomUUID(),
816
- documentType: 'article',
817
- resource,
818
- })
969
+ const documentId = DocumentId(crypto.randomUUID())
970
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
819
971
 
820
972
  expect(await resolveDocument(instance, doc)).toBe(null)
821
973
 
822
974
  // use one-off instance to create the document in the mock backend
823
- const oneOffInstance = createSanityInstance()
975
+ const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
824
976
  const result = await applyDocumentActions(oneOffInstance, {
825
977
  actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial title'}})],
826
978
  resource,
@@ -828,7 +980,7 @@ it('returns a promise that resolves when a document has been loaded in the store
828
980
  await result.submitted() // wait till submitted to server before resolving
829
981
 
830
982
  await expect(resolveDocument(instance, doc)).resolves.toMatchObject({
831
- _id: getDraftId(doc.documentId),
983
+ _id: getDraftId(documentId),
832
984
  _type: 'article',
833
985
  title: 'initial title',
834
986
  })
@@ -839,8 +991,8 @@ it('emits an event for each action after an outgoing transaction has been accept
839
991
  const handler = vi.fn()
840
992
  const unsubscribe = subscribeDocumentEvents(instance, {resource, eventHandler: handler})
841
993
 
842
- const documentId = crypto.randomUUID()
843
- const doc = createDocumentHandle({documentId, documentType: 'article', resource})
994
+ const documentId = DocumentId(crypto.randomUUID())
995
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
844
996
  expect(handler).toHaveBeenCalledTimes(0)
845
997
 
846
998
  const tnx1 = await applyDocumentActions(instance, {
@@ -881,6 +1033,188 @@ it('emits an event for each action after an outgoing transaction has been accept
881
1033
  unsubscribe()
882
1034
  })
883
1035
 
1036
+ it('creates and edits a version document with a release perspective', async () => {
1037
+ const documentId = DocumentId('doc-release')
1038
+ const releaseName = 'test-release'
1039
+ const doc = createDocumentHandle({
1040
+ documentId,
1041
+ documentType: 'article',
1042
+ perspective: {releaseName},
1043
+ })
1044
+ const versionId = `versions.${releaseName}.${documentId}`
1045
+
1046
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1047
+ expect(documentState.getCurrent()).toBeUndefined()
1048
+
1049
+ const unsubscribe = documentState.subscribe()
1050
+
1051
+ // Create a version document for the release
1052
+ const {appeared} = await applyDocumentActions(instance, {
1053
+ actions: [createDocument(doc)],
1054
+ })
1055
+ expect(appeared).toContain(versionId)
1056
+
1057
+ let currentDoc = documentState.getCurrent()
1058
+ expect(currentDoc?._id).toEqual(versionId)
1059
+ expect(currentDoc?._type).toEqual('article')
1060
+
1061
+ // Edit the version document
1062
+ await applyDocumentActions(instance, {
1063
+ actions: [editDocument(doc, {set: {title: 'Release Version Title'}})],
1064
+ })
1065
+ currentDoc = documentState.getCurrent()
1066
+ expect(currentDoc?.title).toEqual('Release Version Title')
1067
+ expect(currentDoc?._id).toEqual(versionId)
1068
+
1069
+ unsubscribe()
1070
+ })
1071
+
1072
+ it('creates a version document with initial values and then discards it', async () => {
1073
+ const documentId = DocumentId('doc-release-discard')
1074
+ const releaseName = 'test-release-discard'
1075
+ const doc = createDocumentHandle({
1076
+ documentId,
1077
+ documentType: 'article',
1078
+ perspective: {releaseName},
1079
+ })
1080
+ const versionId = `versions.${releaseName}.${documentId}`
1081
+
1082
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1083
+ const unsubscribe = documentState.subscribe()
1084
+
1085
+ // Create a version document with initial values
1086
+ await applyDocumentActions(instance, {
1087
+ actions: [createDocument(doc, {title: 'Initial Release Title'})],
1088
+ })
1089
+
1090
+ let currentDoc = documentState.getCurrent()
1091
+ expect(currentDoc?._id).toEqual(versionId)
1092
+ expect(currentDoc?.title).toEqual('Initial Release Title')
1093
+
1094
+ // Discard the version document
1095
+ const {disappeared} = await applyDocumentActions(instance, {
1096
+ actions: [discardDocument(doc)],
1097
+ })
1098
+ expect(disappeared).toContain(versionId)
1099
+
1100
+ currentDoc = documentState.getCurrent()
1101
+ expect(currentDoc).toBeNull()
1102
+
1103
+ unsubscribe()
1104
+ })
1105
+
1106
+ it('version edits are isolated from draft state', async () => {
1107
+ const documentId = DocumentId('doc-version-isolation')
1108
+ const releaseName = 'isolation-release'
1109
+ const versionDoc = createDocumentHandle({
1110
+ documentId,
1111
+ documentType: 'article',
1112
+ perspective: {releaseName},
1113
+ })
1114
+ const draftDoc = createDocumentHandle({documentId, documentType: 'article'})
1115
+ const versionId = `versions.${releaseName}.${documentId}`
1116
+
1117
+ const versionState = getDocumentState<TestDocument>(instance, versionDoc)
1118
+ const draftState = getDocumentState<TestDocument>(instance, draftDoc)
1119
+
1120
+ const unsubscribeVersion = versionState.subscribe()
1121
+ const unsubscribeDraft = draftState.subscribe()
1122
+
1123
+ // Create draft and version documents
1124
+ await applyDocumentActions(instance, {
1125
+ actions: [createDocument(draftDoc)],
1126
+ })
1127
+ await applyDocumentActions(instance, {
1128
+ actions: [editDocument(draftDoc, {set: {title: 'Draft Title'}})],
1129
+ })
1130
+ await applyDocumentActions(instance, {
1131
+ actions: [createDocument(versionDoc, {title: 'Release Title'})],
1132
+ })
1133
+
1134
+ // Version perspective shows the version doc
1135
+ expect(versionState.getCurrent()?._id).toEqual(versionId)
1136
+ expect(versionState.getCurrent()?.title).toEqual('Release Title')
1137
+
1138
+ // Draft state shows the draft doc
1139
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
1140
+
1141
+ // Editing the version doc should not affect the draft
1142
+ await applyDocumentActions(instance, {
1143
+ actions: [editDocument(versionDoc, {set: {title: 'Updated Release Title'}})],
1144
+ })
1145
+ expect(versionState.getCurrent()?.title).toEqual('Updated Release Title')
1146
+ expect(draftState.getCurrent()?.title).toEqual('Draft Title')
1147
+
1148
+ unsubscribeVersion()
1149
+ unsubscribeDraft()
1150
+ })
1151
+
1152
+ it('subscribeToSubscriptionsAndListenToDocuments recovers from an OutOfSyncError instead of failing the document store', async () => {
1153
+ const documentId = DocumentId('doc-out-of-sync-recovery')
1154
+ const doc = createDocumentHandle({documentId, documentType: 'article'})
1155
+ const documentState = getDocumentState<TestDocument>(instance, doc)
1156
+ const unsubscribe = documentState.subscribe()
1157
+ const draftId = getDraftId(documentId)
1158
+
1159
+ // Establish a synced document so the listener has a base revision.
1160
+ const created = await applyDocumentActions(instance, {
1161
+ actions: [createDocument(doc), editDocument(doc, {set: {title: 'initial'}})],
1162
+ })
1163
+ await created.submitted()
1164
+ expect(documentState.getCurrent()?.title).toBe('initial')
1165
+
1166
+ // Capture the fetchDocument spy so we can detect when the listener
1167
+ // re-subscribes. (every frest subscription re-runs fetchDocument)
1168
+ const fetchDocumentSpy = vi.mocked(createFetchDocument).mock.results.at(-1)?.value as ReturnType<
1169
+ typeof vi.fn
1170
+ >
1171
+ const fetchCallsBefore = fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length
1172
+
1173
+ // Inject DEFAULT_MAX_BUFFER_SIZE mutations whose previousRev doesn't chain
1174
+ // to the base revision (basically forcing the error)
1175
+ const sharedListener = (
1176
+ createSharedListener as unknown as () => {events: Subject<ListenEvent<SanityDocument>>}
1177
+ )()
1178
+ for (let i = 0; i < DEFAULT_MAX_BUFFER_SIZE; i++) {
1179
+ sharedListener.events.next({
1180
+ type: 'mutation',
1181
+ documentId: draftId,
1182
+ eventId: `bad-${i}`,
1183
+ identity: 'user',
1184
+ mutations: [],
1185
+ timestamp: new Date().toISOString(),
1186
+ transactionId: `bad-tx-${i}`,
1187
+ transactionCurrentEvent: 0,
1188
+ transactionTotalEvents: 1,
1189
+ previousRev: `dangling-rev-${i}`,
1190
+ resultRev: `bad-rev-${i}`,
1191
+ transition: 'update',
1192
+ visibility: 'query',
1193
+ })
1194
+ }
1195
+
1196
+ // The above should have triggered a retry and re-subscribed to the listener
1197
+ await vi.waitFor(() => {
1198
+ expect(fetchDocumentSpy.mock.calls.filter(([id]) => id === draftId).length).toBeGreaterThan(
1199
+ fetchCallsBefore,
1200
+ )
1201
+ })
1202
+
1203
+ // The store stayed healthy through the OutOfSyncError.
1204
+ expect(() => documentState.getCurrent()).not.toThrow()
1205
+ expect(() => getDocumentSyncStatus(instance, doc).getCurrent()).not.toThrow()
1206
+
1207
+ // A follow-up edit still propagates through the recovered listener.
1208
+ const edited = await applyDocumentActions(instance, {
1209
+ actions: [editDocument(doc, {set: {title: 'after-recovery'}})],
1210
+ resource,
1211
+ })
1212
+ await edited.submitted()
1213
+ expect(documentState.getCurrent()?.title).toBe('after-recovery')
1214
+
1215
+ unsubscribe()
1216
+ })
1217
+
884
1218
  vi.mock('../client/clientStore.ts', () => ({
885
1219
  getClientState: vi.fn().mockReturnValue({observable: new ReplaySubject(1)}),
886
1220
  }))
@@ -914,12 +1248,28 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
914
1248
  ...original,
915
1249
  INITIAL_OUTGOING_THROTTLE_TIME: 0,
916
1250
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1251
+ OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1252
+ OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
917
1253
  }
918
1254
  })
919
1255
 
920
1256
  let client: SanityClient
921
1257
 
1258
+ /** Mock for `client.observable.mutate` (liveEdit submission path); implementation reset in `beforeEach`. */
1259
+ const mutateSubmission = vi.fn(
1260
+ async (
1261
+ _mutations: Mutation[],
1262
+ _options?: BaseMutationOptions,
1263
+ ): Promise<MultipleMutationResult> => ({
1264
+ transactionId: '',
1265
+ documentIds: [],
1266
+ results: [],
1267
+ }),
1268
+ )
1269
+
922
1270
  beforeEach(() => {
1271
+ mutateSubmission.mockReset()
1272
+
923
1273
  const client$ = (getClientState as () => StateSource<SanityClient>)()
924
1274
  .observable as ReplaySubject<SanityClient>
925
1275
  const sharedListener = (
@@ -930,14 +1280,130 @@ beforeEach(() => {
930
1280
  )()
931
1281
 
932
1282
  let documents: DocumentSet = {
933
- [getDraftId('existing-doc')]: {
934
- _id: getDraftId('existing-doc'),
1283
+ [getDraftId(DocumentId('existing-doc'))]: {
1284
+ _id: getDraftId(DocumentId('existing-doc')),
935
1285
  _createdAt: '2025-02-06T06:43:46.236Z',
936
1286
  _updatedAt: '2025-02-06T06:43:46.236Z',
937
1287
  _rev: 'initial-rev',
938
1288
  _type: 'book',
939
1289
  title: 'existing doc',
940
1290
  },
1291
+ 'live-edit-test-doc': {
1292
+ _id: 'live-edit-test-doc',
1293
+ _type: 'article',
1294
+ _createdAt: '2025-02-06T06:43:46.236Z',
1295
+ _updatedAt: '2025-02-06T06:43:46.236Z',
1296
+ _rev: 'rev-live-initial',
1297
+ title: 'live initial',
1298
+ },
1299
+ }
1300
+
1301
+ const emitDatasetChangeEvents = (
1302
+ prior: DocumentSet,
1303
+ next: DocumentSet,
1304
+ transactionId: string,
1305
+ timestamp: string,
1306
+ ) => {
1307
+ const existingIds = new Set(
1308
+ Object.entries(prior)
1309
+ .filter(([, value]) => !!value)
1310
+ .map(([key]) => key),
1311
+ )
1312
+ const resultingIds = new Set(
1313
+ Object.entries(next)
1314
+ .filter(([, value]) => !!value)
1315
+ .map(([key]) => key),
1316
+ )
1317
+ const allKeys = new Set([...existingIds, ...resultingIds])
1318
+
1319
+ const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1320
+ updated: string[]
1321
+ appeared: string[]
1322
+ disappeared: string[]
1323
+ }>(
1324
+ (acc, id) => {
1325
+ if (existingIds.has(id) && resultingIds.has(id)) {
1326
+ acc.updated.push(id)
1327
+ } else if (!existingIds.has(id) && resultingIds.has(id)) {
1328
+ acc.appeared.push(id)
1329
+ } else if (!resultingIds.has(id) && existingIds.has(id)) {
1330
+ acc.disappeared.push(id)
1331
+ }
1332
+ return acc
1333
+ },
1334
+ {updated: [], appeared: [], disappeared: []},
1335
+ )
1336
+
1337
+ const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1338
+ let transactionCurrentEvent = 0
1339
+
1340
+ const mutationEvents: MutationEvent[] = []
1341
+
1342
+ for (const id of appeared) {
1343
+ transactionCurrentEvent++
1344
+ const nextDoc = next[id]!
1345
+ mutationEvents.push({
1346
+ type: 'mutation',
1347
+ documentId: id,
1348
+ eventId: `${transactionId}#${id}`,
1349
+ identity: 'example-user',
1350
+ mutations: [{create: nextDoc}],
1351
+ timestamp,
1352
+ transactionId,
1353
+ transactionCurrentEvent,
1354
+ transactionTotalEvents,
1355
+ transition: 'appear',
1356
+ visibility: 'query',
1357
+ resultRev: transactionId,
1358
+ })
1359
+ }
1360
+
1361
+ for (const id of updated) {
1362
+ transactionCurrentEvent++
1363
+ const prevDoc = prior[id]!
1364
+ const nextDoc = next[id]!
1365
+
1366
+ mutationEvents.push({
1367
+ type: 'mutation',
1368
+ documentId: id,
1369
+ eventId: `${transactionId}#${id}`,
1370
+ identity: 'example-user',
1371
+ mutations: diffValue(prevDoc, nextDoc).map((patch): Mutation => ({patch: {...patch, id}})),
1372
+ timestamp,
1373
+ transactionCurrentEvent,
1374
+ transactionTotalEvents,
1375
+ transactionId,
1376
+ transition: 'update',
1377
+ visibility: 'query',
1378
+ previousRev: prevDoc._rev,
1379
+ resultRev: transactionId,
1380
+ })
1381
+ }
1382
+
1383
+ for (const id of disappeared) {
1384
+ transactionCurrentEvent++
1385
+ const prevDoc = prior[id]!
1386
+ mutationEvents.push({
1387
+ type: 'mutation',
1388
+ documentId: id,
1389
+ eventId: `${transactionId}#${id}`,
1390
+ identity: 'example-user',
1391
+ mutations: [{delete: {id}}],
1392
+ timestamp,
1393
+ transactionId,
1394
+ transactionCurrentEvent,
1395
+ transactionTotalEvents,
1396
+ transition: 'disappear',
1397
+ visibility: 'query',
1398
+ previousRev: prevDoc._rev,
1399
+ })
1400
+ }
1401
+
1402
+ documents = next
1403
+
1404
+ for (const mutationEvent of mutationEvents) {
1405
+ sharedListener.events.next(mutationEvent)
1406
+ }
941
1407
  }
942
1408
 
943
1409
  vi.mocked(createFetchDocument).mockReturnValue(
@@ -949,6 +1415,33 @@ beforeEach(() => {
949
1415
  ),
950
1416
  )
951
1417
 
1418
+ mutateSubmission.mockImplementation(
1419
+ async (
1420
+ mutations: Mutation[],
1421
+ options: BaseMutationOptions = {},
1422
+ ): Promise<MultipleMutationResult> => {
1423
+ const transactionId = options.transactionId ?? crypto.randomUUID()
1424
+ const timestamp = new Date().toISOString()
1425
+ const prior = {...documents}
1426
+ let next = {...documents}
1427
+ next = processMutations({documents: next, mutations, transactionId, timestamp})
1428
+
1429
+ if (!options.dryRun) {
1430
+ emitDatasetChangeEvents(prior, next, transactionId, timestamp)
1431
+ }
1432
+
1433
+ await new Promise((resolve) => setTimeout(resolve, 0))
1434
+
1435
+ return {
1436
+ transactionId,
1437
+ documentIds: Object.entries(next)
1438
+ .filter(([, v]) => !!v)
1439
+ .map(([k]) => k),
1440
+ results: [],
1441
+ }
1442
+ },
1443
+ )
1444
+
952
1445
  const isNonNullable = <T>(t: T): t is NonNullable<T> => !!t
953
1446
 
954
1447
  const fetch = vi.fn(
@@ -1029,7 +1522,7 @@ beforeEach(() => {
1029
1522
  continue
1030
1523
  }
1031
1524
  case 'sanity.action.document.edit': {
1032
- const documentSource = next[i.draftId] ?? next[i.publishedId]
1525
+ const documentSource = (i.draftId && next[i.draftId]) ?? next[i.publishedId]
1033
1526
  if (!documentSource) {
1034
1527
  throw new Error(
1035
1528
  `Could not find a document to edit from \`draftId\` \`${i.draftId}\` or \`publishedId\` ${i.publishedId}`,
@@ -1039,8 +1532,8 @@ beforeEach(() => {
1039
1532
  next = processMutations({
1040
1533
  documents: next,
1041
1534
  mutations: [
1042
- {createIfNotExists: {...documentSource, _id: i.draftId}},
1043
- {patch: {id: i.draftId, ...i.patch}},
1535
+ {createIfNotExists: {...documentSource, _id: i.draftId!}},
1536
+ {patch: {id: i.draftId!, ...i.patch}},
1044
1537
  ],
1045
1538
  transactionId,
1046
1539
  timestamp,
@@ -1121,114 +1614,15 @@ beforeEach(() => {
1121
1614
  continue
1122
1615
  }
1123
1616
  default: {
1124
- throw new Error(`Unsupported action for mock backend: ${i.actionType}`)
1617
+ throw new Error(
1618
+ `Unsupported action for mock backend: ${(i as {actionType: string}).actionType}`,
1619
+ )
1125
1620
  }
1126
1621
  }
1127
1622
  }
1128
1623
 
1129
1624
  if (!dryRun) {
1130
- const existingIds = new Set(
1131
- Object.entries(documents)
1132
- .filter(([, value]) => !!value)
1133
- .map(([key]) => key),
1134
- )
1135
- const resultingIds = new Set(
1136
- Object.entries(next)
1137
- .filter(([, value]) => !!value)
1138
- .map(([key]) => key),
1139
- )
1140
- const allKeys = new Set([...existingIds, ...resultingIds])
1141
-
1142
- const {appeared, disappeared, updated} = Array.from(allKeys).reduce<{
1143
- updated: string[]
1144
- appeared: string[]
1145
- disappeared: string[]
1146
- }>(
1147
- (acc, id) => {
1148
- if (existingIds.has(id) && resultingIds.has(id)) {
1149
- acc.updated.push(id)
1150
- } else if (!existingIds.has(id) && resultingIds.has(id)) {
1151
- acc.appeared.push(id)
1152
- } else if (!resultingIds.has(id) && existingIds.has(id)) {
1153
- acc.disappeared.push(id)
1154
- }
1155
- return acc
1156
- },
1157
- {updated: [], appeared: [], disappeared: []},
1158
- )
1159
-
1160
- const transactionTotalEvents = appeared.length + disappeared.length + updated.length
1161
- let transactionCurrentEvent = 0
1162
-
1163
- const mutationEvents: MutationEvent[] = []
1164
-
1165
- for (const id of appeared) {
1166
- transactionCurrentEvent++ // index seems to start at 1
1167
- const nextDoc = next[id]!
1168
- mutationEvents.push({
1169
- type: 'mutation',
1170
- documentId: id,
1171
- eventId: `${transactionId}#${id}`,
1172
- identity: 'example-user',
1173
- mutations: [{create: nextDoc}],
1174
- timestamp,
1175
- transactionId,
1176
- transactionCurrentEvent,
1177
- transactionTotalEvents,
1178
- transition: 'appear',
1179
- visibility: 'query',
1180
- resultRev: transactionId,
1181
- })
1182
- }
1183
-
1184
- for (const id of updated) {
1185
- transactionCurrentEvent++
1186
- const prevDoc = documents[id]!
1187
- const nextDoc = next[id]!
1188
-
1189
- mutationEvents.push({
1190
- type: 'mutation',
1191
- documentId: id,
1192
- eventId: `${transactionId}#${id}`,
1193
- identity: 'example-user',
1194
- mutations: diffValue(prevDoc, nextDoc).map(
1195
- (patch): Mutation => ({patch: {...patch, id}}),
1196
- ),
1197
- timestamp,
1198
- transactionCurrentEvent,
1199
- transactionTotalEvents,
1200
- transactionId,
1201
- transition: 'update',
1202
- visibility: 'query',
1203
- previousRev: prevDoc._rev,
1204
- resultRev: transactionId,
1205
- })
1206
- }
1207
-
1208
- for (const id of disappeared) {
1209
- transactionCurrentEvent++
1210
- const prevDoc = documents[id]!
1211
- mutationEvents.push({
1212
- type: 'mutation',
1213
- documentId: id,
1214
- eventId: `${transactionId}#${id}`,
1215
- identity: 'example-user',
1216
- mutations: [{delete: {id}}],
1217
- timestamp,
1218
- transactionId,
1219
- transactionCurrentEvent,
1220
- transactionTotalEvents,
1221
- transition: 'disappear',
1222
- visibility: 'query',
1223
- previousRev: prevDoc._rev,
1224
- })
1225
- }
1226
-
1227
- documents = next
1228
-
1229
- for (const mutationEvent of mutationEvents) {
1230
- sharedListener.events.next(mutationEvent)
1231
- }
1625
+ emitDatasetChangeEvents(documents, next, transactionId, timestamp)
1232
1626
  }
1233
1627
 
1234
1628
  // add a tick for realism
@@ -1255,6 +1649,7 @@ beforeEach(() => {
1255
1649
  action: (...args: Parameters<typeof action>) => from(action(...args)),
1256
1650
  fetch: (...args: Parameters<typeof fetch>) => from(fetch(...args)),
1257
1651
  request: (...args: Parameters<typeof request>) => from(request(...args)),
1652
+ mutate: (...args: Parameters<typeof mutateSubmission>) => from(mutateSubmission(...args)),
1258
1653
  },
1259
1654
  } as SanityClient
1260
1655
  client$.next(client)