@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,31 +1,54 @@
1
- import {getPublishedId} from '@sanity/client/csm'
1
+ import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
2
2
  import {type Mutation, type PatchOperations, type SanityDocumentLike} from '@sanity/types'
3
- import {omit} from 'lodash-es'
4
3
 
4
+ import {type DocumentHandle} from '../config/sanityConfig'
5
+ import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
5
6
  import {type StoreContext} from '../store/defineStore'
6
- import {getDraftId, insecureRandomId} from '../utils/ids'
7
- import {type DocumentAction} from './actions'
7
+ import {insecureRandomId} from '../utils/ids'
8
+ import {isDeepEqual, omitProperty} from '../utils/object'
9
+ import {setCleanupTimeout} from '../utils/setCleanupTimeout'
10
+ import {type Action} from './actions'
8
11
  import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
9
12
  import {type DocumentState, type DocumentStoreState} from './documentStore'
10
13
  import {type RemoteDocument} from './listen'
11
- import {ActionError, processActions} from './processActions'
14
+ import {ActionError, processActions} from './processActions/processActions'
15
+ import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
12
16
  import {type DocumentSet} from './processMutations'
13
17
 
14
18
  const EMPTY_REVISIONS: NonNullable<Required<DocumentState['unverifiedRevisions']>> = {}
15
19
 
20
+ /**
21
+ * How many of this client's own transaction IDs to remember per document for
22
+ * `remote-patches` origin labeling. Sized to comfortably outlast the window
23
+ * between submitting a transaction and observing its listener echo.
24
+ */
25
+ const MAX_RECENT_OWN_TRANSACTION_IDS = 50
26
+
16
27
  export type SyncTransactionState = Pick<
17
28
  DocumentStoreState,
18
- 'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants'
29
+ 'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants' | 'identity'
19
30
  >
20
31
 
32
+ type DocumentHandleLike = Pick<DocumentHandle, 'perspective'> & {
33
+ documentId?: string
34
+ liveEdit?: boolean
35
+ }
36
+
21
37
  type ActionMap = {
22
38
  create: 'sanity.action.document.version.create'
23
- createLiveEdit: 'sanity.action.document.create'
24
39
  discard: 'sanity.action.document.version.discard'
25
40
  unpublish: 'sanity.action.document.unpublish'
26
41
  delete: 'sanity.action.document.delete'
27
42
  edit: 'sanity.action.document.edit'
28
43
  publish: 'sanity.action.document.publish'
44
+ releaseCreate: 'sanity.action.release.create'
45
+ releaseEdit: 'sanity.action.release.edit'
46
+ releasePublish: 'sanity.action.release.publish'
47
+ releaseSchedule: 'sanity.action.release.schedule'
48
+ releaseUnschedule: 'sanity.action.release.unschedule'
49
+ releaseArchive: 'sanity.action.release.archive'
50
+ releaseUnarchive: 'sanity.action.release.unarchive'
51
+ releaseDelete: 'sanity.action.release.delete'
29
52
  }
30
53
 
31
54
  type OptimisticLock = {
@@ -33,14 +56,33 @@ type OptimisticLock = {
33
56
  ifPublishedRevisionId?: string
34
57
  }
35
58
 
59
+ interface ReleaseMetadataPayload {
60
+ title?: string
61
+ description?: string
62
+ intendedPublishAt?: string
63
+ releaseType?: 'asap' | 'scheduled' | 'undecided'
64
+ cardinality?: 'one' | 'many'
65
+ }
66
+
36
67
  export type HttpAction =
37
68
  | {actionType: ActionMap['create']; publishedId: string; attributes: SanityDocumentLike}
38
- | {actionType: ActionMap['createLiveEdit']; publishedId: string; attributes: SanityDocumentLike}
39
69
  | {actionType: ActionMap['discard']; versionId: string; purge?: boolean}
40
70
  | {actionType: ActionMap['unpublish']; draftId: string; publishedId: string}
41
71
  | {actionType: ActionMap['delete']; publishedId: string; includeDrafts?: string[]}
42
72
  | {actionType: ActionMap['edit']; draftId: string; publishedId: string; patch: PatchOperations}
43
73
  | ({actionType: ActionMap['publish']; draftId: string; publishedId: string} & OptimisticLock)
74
+ | {
75
+ actionType: ActionMap['releaseCreate']
76
+ releaseId: string
77
+ metadata?: ReleaseMetadataPayload
78
+ }
79
+ | {actionType: ActionMap['releaseEdit']; releaseId: string; patch: PatchOperations}
80
+ | {actionType: ActionMap['releasePublish']; releaseId: string}
81
+ | {actionType: ActionMap['releaseSchedule']; releaseId: string; publishAt: string}
82
+ | {actionType: ActionMap['releaseUnschedule']; releaseId: string}
83
+ | {actionType: ActionMap['releaseArchive']; releaseId: string}
84
+ | {actionType: ActionMap['releaseUnarchive']; releaseId: string}
85
+ | {actionType: ActionMap['releaseDelete']; releaseId: string}
44
86
 
45
87
  /**
46
88
  * Represents a transaction that is queued to be applied but has not yet been
@@ -57,7 +99,7 @@ export interface QueuedTransaction {
57
99
  * actions don't mention draft IDs and is meant to abstract away the draft
58
100
  * model from users.
59
101
  */
60
- actions: DocumentAction[]
102
+ actions: Action[]
61
103
  /**
62
104
  * An optional flag set to disable this transaction from being batched with
63
105
  * other transactions.
@@ -115,9 +157,9 @@ export interface AppliedTransaction extends QueuedTransaction {
115
157
  outgoingActions: HttpAction[]
116
158
 
117
159
  /**
118
- * similar to `outgoingActions` but comprised of mutations instead of action.
119
- * this left here for debugging purposes but could be used to send mutations
120
- * to Content Lake instead of actions.
160
+ * similar to `outgoingActions` but comprised of mutations instead of actions.
161
+ * Useful for debugging, and is also used by liveEdit documents to send mutations,
162
+ * since they can't use the Actions API which is pretty dependent on the draft model.
121
163
  */
122
164
  outgoingMutations: Mutation[]
123
165
  }
@@ -145,7 +187,7 @@ export function queueTransaction(
145
187
  transaction: QueuedTransaction,
146
188
  ): SyncTransactionState {
147
189
  const {transactionId, actions} = transaction
148
- const prevWithSubscriptionIds = getDocumentIdsFromActions(actions).reduce(
190
+ const prevWithSubscriptionIds = getDocumentIdsFromHandleLikes(actions).reduce(
149
191
  (acc, id) => addSubscriptionIdToDocument(acc, id, transactionId),
150
192
  prev,
151
193
  )
@@ -163,7 +205,7 @@ export function removeQueuedTransaction(
163
205
  const transaction = prev.queued.find((t) => t.transactionId === transactionId)
164
206
  if (!transaction) return prev
165
207
 
166
- const prevWithSubscriptionIds = getDocumentIdsFromActions(transaction.actions).reduce(
208
+ const prevWithSubscriptionIds = getDocumentIdsFromHandleLikes(transaction.actions).reduce(
167
209
  (acc, id) => removeSubscriptionIdFromDocument(acc, id, transactionId),
168
210
  prev,
169
211
  )
@@ -179,7 +221,7 @@ export function applyFirstQueuedTransaction(prev: SyncTransactionState): SyncTra
179
221
  if (!queued) return prev
180
222
  if (!prev.grants) return prev
181
223
 
182
- const ids = getDocumentIdsFromActions(queued.actions)
224
+ const ids = getDocumentIdsFromHandleLikes(queued.actions)
183
225
  // the local value is only ever `undefined` if it has not been loaded yet
184
226
  // we can't get the next applied state unless all relevant documents are ready
185
227
  if (ids.some((id) => prev.documentStates[id]?.local === undefined)) return prev
@@ -197,6 +239,7 @@ export function applyFirstQueuedTransaction(prev: SyncTransactionState): SyncTra
197
239
  base: working,
198
240
  timestamp,
199
241
  grants: prev.grants,
242
+ identity: prev.identity,
200
243
  })
201
244
  const applied: AppliedTransaction = {
202
245
  ...queued,
@@ -265,6 +308,11 @@ export function batchAppliedTransactions([curr, ...rest]: AppliedTransaction[]):
265
308
  if (!next) return undefined
266
309
  if (next.disableBatching) return editAction
267
310
 
311
+ // Don't batch a liveEdit edit with a non-liveEdit edit — they route to different APIs
312
+ const nextFirst = next.actions[0]
313
+ const nextLiveEdit = nextFirst && 'liveEdit' in nextFirst ? nextFirst.liveEdit : false
314
+ if (!!action.liveEdit !== !!nextLiveEdit) return editAction
315
+
268
316
  return {
269
317
  disableBatching: false,
270
318
  // Use the transactionId from the later (next) transaction.
@@ -323,6 +371,15 @@ export function transitionAppliedTransactionsToOutgoing(
323
371
  // add unverified revision
324
372
  [transactionId]: {documentId, previousRev, transactionId, timestamp},
325
373
  },
374
+ // also track the transaction ID durably (unverified revisions can be
375
+ // pruned by sync events before the listener echo arrives) so
376
+ // `remote-patches` events label our own transactions correctly
377
+ recentOwnTransactionIds: [
378
+ ...(documentState.recentOwnTransactionIds ?? []).slice(
379
+ -(MAX_RECENT_OWN_TRANSACTION_IDS - 1),
380
+ ),
381
+ transactionId,
382
+ ],
326
383
  }
327
384
 
328
385
  return acc
@@ -337,7 +394,7 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
337
394
  if (!outgoing) return prev
338
395
 
339
396
  let next = prev
340
- const ids = getDocumentIdsFromActions(outgoing.actions)
397
+ const ids = getDocumentIdsFromHandleLikes(outgoing.actions)
341
398
  for (const transactionId of outgoing.batchedTransactionIds) {
342
399
  for (const documentId of ids) {
343
400
  next = removeSubscriptionIdFromDocument(next, documentId, transactionId)
@@ -359,7 +416,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
359
416
 
360
417
  for (const t of prev.applied) {
361
418
  try {
362
- const next = processActions({...t, working, grants: prev.grants})
419
+ const next = processActions({...t, working, grants: prev.grants, identity: prev.identity})
363
420
  working = next.working
364
421
  nextApplied.push({...t, ...next})
365
422
  } catch (error) {
@@ -383,7 +440,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
383
440
  local: documentId in working ? working[documentId] : local,
384
441
  unverifiedRevisions:
385
442
  prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
386
- ? omit(unverifiedRevisions, prev.outgoing.transactionId)
443
+ ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
387
444
  : unverifiedRevisions,
388
445
  }
389
446
  return [documentId, next] as const
@@ -392,9 +449,29 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
392
449
  }
393
450
  }
394
451
 
452
+ /**
453
+ * Extracts the patch operations addressed to the given document from a set of
454
+ * raw listener mutations, stripping the mutation-level `id` addressing so the
455
+ * patches are rooted at the document. A transaction can carry mutations for
456
+ * other documents, so anything not explicitly id-addressed to this document
457
+ * (including query-addressed patches) is dropped.
458
+ */
459
+ function extractPatchOperations(
460
+ mutations: Mutation[] | undefined,
461
+ documentId: string,
462
+ ): PatchOperations[] {
463
+ if (!mutations) return []
464
+ return mutations.flatMap((mutation) => {
465
+ if (!('patch' in mutation) || !mutation.patch) return []
466
+ const {id, ...operations} = mutation.patch as PatchOperations & {id?: string}
467
+ if (id !== documentId) return []
468
+ return [operations]
469
+ })
470
+ }
471
+
395
472
  export function applyRemoteDocument(
396
473
  prev: SyncTransactionState,
397
- {document, documentId, previousRev, revision, timestamp, type}: RemoteDocument,
474
+ {document, documentId, previousRev, revision, timestamp, type, mutations}: RemoteDocument,
398
475
  events: DocumentStoreState['events'],
399
476
  ): SyncTransactionState {
400
477
  if (!prev.grants) return prev
@@ -411,7 +488,32 @@ export function applyRemoteDocument(
411
488
  const revisionToVerify = revision ? prevUnverifiedRevisions?.[revision] : undefined
412
489
  let unverifiedRevisions = prevUnverifiedRevisions ?? EMPTY_REVISIONS
413
490
  if (revision && revisionToVerify) {
414
- unverifiedRevisions = omit(prevUnverifiedRevisions, revision)
491
+ unverifiedRevisions = omitProperty(prevUnverifiedRevisions, revision)
492
+ }
493
+
494
+ // surface the operational patches from this transaction before they are
495
+ // collapsed into the whole-document snapshot below. consumers that keep
496
+ // their own state (e.g. collaborative text editors) rely on these to apply
497
+ // remote changes without re-diffing document snapshots
498
+ if (type === 'mutation' && revision) {
499
+ const patches = extractPatchOperations(mutations, documentId)
500
+ if (patches.length) {
501
+ // `unverifiedRevisions` alone isn't a reliable origin marker: a sync
502
+ // event can prune an in-flight transaction's entry before its listener
503
+ // echo arrives. `recentOwnTransactionIds` survives that race
504
+ const isOwnTransaction =
505
+ Boolean(revisionToVerify) ||
506
+ (prevDocState.recentOwnTransactionIds?.includes(revision) ?? false)
507
+ events.next({
508
+ type: 'remote-patches',
509
+ documentId,
510
+ transactionId: revision,
511
+ previousRev,
512
+ timestamp,
513
+ patches,
514
+ origin: isOwnTransaction ? 'local' : 'remote',
515
+ })
516
+ }
415
517
  }
416
518
 
417
519
  // if this remote document is from a `'sync'` event (meaning that the whole
@@ -432,6 +534,34 @@ export function applyRemoteDocument(
432
534
  // matches the previous revision we expected, we can "fast-forward" and skip
433
535
  // rebasing local changes on top of this new base
434
536
  if (revisionToVerify && revisionToVerify.previousRev === previousRev) {
537
+ // even on a clean fast-forward, the server's result can differ from our
538
+ // optimistic prediction: a publish derives the published content from the
539
+ // server's copy of the draft, which may carry concurrent edits our
540
+ // prediction never saw. once nothing else is pending locally for this
541
+ // document (no applied transaction and no in-flight transaction, other
542
+ // than the one this event just confirmed, modifies it), converge `local`
543
+ // to the server's document. while local work that modifies this document
544
+ // is pending, keep the optimistic `local` so those changes stay visible;
545
+ // that work's own echo (or rebase) recomputes `local` later.
546
+ //
547
+ // "modifies" matters: pending work on other documents must not block
548
+ // convergence here (nothing would ever revisit this document's `local`),
549
+ // and a pending draft edit carries the published document in its working
550
+ // set without changing it, so a presence check would block published-doc
551
+ // convergence whenever a draft edit is pending. this is the same
552
+ // modified-test `transitionAppliedTransactionsToOutgoing` uses
553
+ const modifiesDocument = (transaction: AppliedTransaction) =>
554
+ transaction.working[documentId]?._rev !== transaction.previousRevs[documentId]
555
+ const nothingElsePending =
556
+ !prev.applied.some(modifiesDocument) &&
557
+ (!prev.outgoing ||
558
+ prev.outgoing.transactionId === revision ||
559
+ !modifiesDocument(prev.outgoing))
560
+ const local =
561
+ nothingElsePending && !isDeepEqual(prevDocState.local, document)
562
+ ? document
563
+ : prevDocState.local
564
+
435
565
  return {
436
566
  ...prev,
437
567
  documentStates: {
@@ -440,6 +570,7 @@ export function applyRemoteDocument(
440
570
  ...prevDocState,
441
571
  remote: document,
442
572
  remoteRev: revision,
573
+ local,
443
574
  unverifiedRevisions,
444
575
  },
445
576
  },
@@ -466,7 +597,7 @@ export function applyRemoteDocument(
466
597
  // transaction again through the listener and this same flow will run then
467
598
  for (const curr of prev.applied) {
468
599
  try {
469
- const next = processActions({...curr, working, grants: prev.grants})
600
+ const next = processActions({...curr, working, grants: prev.grants, identity: prev.identity})
470
601
  working = next.working
471
602
  // next includes an updated `previous` set and `working` set and updates
472
603
  // the `outgoingAction` and `outgoingMutations`. the `base` set from the
@@ -490,6 +621,12 @@ export function applyRemoteDocument(
490
621
  }
491
622
  }
492
623
 
624
+ // when the recompute lands on a value deep-equal to the current local
625
+ // (the common case for echoes of our own transactions), keep the previous
626
+ // reference so subscribers don't re-render for an identical value
627
+ const nextLocal = working[documentId]
628
+ const local = isDeepEqual(prevDocState.local, nextLocal) ? prevDocState.local : nextLocal
629
+
493
630
  return {
494
631
  ...prev,
495
632
  applied: nextApplied,
@@ -499,7 +636,7 @@ export function applyRemoteDocument(
499
636
  ...prevDocState,
500
637
  remote: document,
501
638
  remoteRev: revision,
502
- local: working[documentId],
639
+ local,
503
640
  unverifiedRevisions,
504
641
  },
505
642
  },
@@ -538,7 +675,7 @@ export function removeSubscriptionIdFromDocument(
538
675
 
539
676
  if (!prevDocState) return prev
540
677
  if (!subscriptions.length) {
541
- return {...prev, documentStates: omit(prev.documentStates, documentId)}
678
+ return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
542
679
  }
543
680
  return {
544
681
  ...prev,
@@ -551,22 +688,10 @@ export function removeSubscriptionIdFromDocument(
551
688
 
552
689
  export function manageSubscriberIds(
553
690
  {state}: StoreContext<SyncTransactionState>,
554
- documentId: string | string[],
555
- options?: {expandDraftPublished?: boolean},
691
+ handles: DocumentHandleLike[],
556
692
  ): () => void {
557
- const expandDraftPublished = options?.expandDraftPublished ?? true
558
- const documentIds = Array.from(
559
- new Set(
560
- expandDraftPublished
561
- ? (Array.isArray(documentId) ? documentId : [documentId]).flatMap((id) => [
562
- getPublishedId(id),
563
- getDraftId(id),
564
- ])
565
- : Array.isArray(documentId)
566
- ? documentId
567
- : [documentId],
568
- ),
569
- )
693
+ const documentIds = getDocumentIdsFromHandleLikes(handles)
694
+
570
695
  const subscriptionId = insecureRandomId()
571
696
  state.set('addSubscribers', (prev) =>
572
697
  documentIds.reduce(
@@ -576,7 +701,7 @@ export function manageSubscriberIds(
576
701
  )
577
702
 
578
703
  return () => {
579
- setTimeout(() => {
704
+ setCleanupTimeout(() => {
580
705
  state.set('removeSubscribers', (prev) =>
581
706
  documentIds.reduce(
582
707
  (acc, id) => removeSubscriptionIdFromDocument(acc, id, subscriptionId),
@@ -587,13 +712,27 @@ export function manageSubscriberIds(
587
712
  }
588
713
  }
589
714
 
590
- export function getDocumentIdsFromActions(actions: DocumentAction[]): string[] {
591
- return Array.from(
592
- new Set(
593
- actions
594
- .map((i) => i.documentId)
595
- .filter((i) => typeof i === 'string')
596
- .flatMap((documentId) => [getPublishedId(documentId), getDraftId(documentId)]),
597
- ),
598
- )
715
+ // document handles are passed in via the public facing API, but we also need to
716
+ // pull the correct document ids from action bodies, which have similar but not
717
+ // identical shapes to the document handles. release actions also flow through
718
+ // here, and resolve to the underlying release document id.
719
+ function getDocumentIdsFromHandleLikes(handles: (DocumentHandleLike | Action)[]): string[] {
720
+ return handles.flatMap((handle) => {
721
+ if ('type' in handle && isReleaseAction(handle)) {
722
+ return [getReleaseDocumentId(handle.releaseId)]
723
+ }
724
+ const idsForDocument = []
725
+ if (!handle.documentId) return []
726
+ if (handle.liveEdit) {
727
+ return [handle.documentId]
728
+ }
729
+ if (isReleasePerspective(handle.perspective)) {
730
+ idsForDocument.push(
731
+ getVersionId(DocumentId(handle.documentId), handle.perspective.releaseName),
732
+ )
733
+ }
734
+ idsForDocument.push(getPublishedId(DocumentId(handle.documentId)))
735
+ idsForDocument.push(getDraftId(DocumentId(handle.documentId)))
736
+ return idsForDocument
737
+ })
599
738
  }
@@ -0,0 +1,178 @@
1
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {type EditDocumentAction} from './actions'
4
+ import {getEffectiveDocumentModel, normalizeActionsForResource} from './resourceRules'
5
+
6
+ const datasetResource = {projectId: 'p', dataset: 'd'}
7
+ const canvasResource = {canvasId: 'canvas-1'}
8
+ const mediaLibraryResource = {mediaLibraryId: 'ml-1'}
9
+
10
+ function editAction(
11
+ overrides: Partial<EditDocumentAction> & Pick<EditDocumentAction, 'documentId' | 'documentType'>,
12
+ ): EditDocumentAction {
13
+ return {
14
+ type: 'document.edit',
15
+ patches: [{set: {foo: 'bar'}}],
16
+ ...overrides,
17
+ }
18
+ }
19
+
20
+ describe('getEffectiveDocumentModel', () => {
21
+ it('returns passthrough for no resource', () => {
22
+ expect(getEffectiveDocumentModel(undefined, 'anything')).toEqual({
23
+ liveEdit: undefined,
24
+ supportsReleases: true,
25
+ })
26
+ })
27
+
28
+ it('returns passthrough for dataset resources', () => {
29
+ expect(getEffectiveDocumentModel(datasetResource, 'author')).toEqual({
30
+ liveEdit: undefined,
31
+ supportsReleases: true,
32
+ })
33
+ })
34
+
35
+ it('forces liveEdit and disallows releases for canvas resources', () => {
36
+ expect(getEffectiveDocumentModel(canvasResource, 'page')).toEqual({
37
+ liveEdit: true,
38
+ supportsReleases: false,
39
+ })
40
+ })
41
+
42
+ it('forces liveEdit for non-asset media library types', () => {
43
+ expect(getEffectiveDocumentModel(mediaLibraryResource, 'sanity.imageAsset')).toEqual({
44
+ liveEdit: true,
45
+ supportsReleases: false,
46
+ })
47
+ })
48
+
49
+ it('keeps draft/published model for sanity.asset in media library', () => {
50
+ expect(getEffectiveDocumentModel(mediaLibraryResource, 'sanity.asset')).toEqual({
51
+ liveEdit: false,
52
+ supportsReleases: false,
53
+ })
54
+ })
55
+ })
56
+
57
+ describe('normalizeActionsForResource', () => {
58
+ let warnSpy: ReturnType<typeof vi.spyOn>
59
+
60
+ beforeEach(() => {
61
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
62
+ })
63
+
64
+ afterEach(() => {
65
+ warnSpy.mockRestore()
66
+ })
67
+
68
+ it('leaves dataset edits with a release perspective alone', () => {
69
+ const action = editAction({
70
+ documentId: 'versions.relA.doc1',
71
+ documentType: 'author',
72
+ perspective: {releaseName: 'relA'},
73
+ })
74
+
75
+ const result = normalizeActionsForResource([action], datasetResource)
76
+
77
+ expect(result[0]).toBe(action)
78
+ expect(warnSpy).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('strips release perspective and forces liveEdit for canvas, warning once', () => {
82
+ const action = editAction({
83
+ documentId: 'versions.relA.doc1',
84
+ documentType: 'page',
85
+ perspective: {releaseName: 'relA'},
86
+ })
87
+
88
+ const [result] = normalizeActionsForResource([action], canvasResource) as [EditDocumentAction]
89
+
90
+ expect(result.liveEdit).toBe(true)
91
+ expect(result.perspective).toBeUndefined()
92
+ expect(result.documentId).toBe('doc1')
93
+ expect(warnSpy).toHaveBeenCalledTimes(1)
94
+ expect(warnSpy.mock.calls[0][0]).toContain('Canvas')
95
+ expect(warnSpy.mock.calls[0][0]).toContain('page (doc1)')
96
+ })
97
+
98
+ it('strips release perspective and forces liveEdit for non-asset media library types', () => {
99
+ const action = editAction({
100
+ documentId: 'versions.relA.doc1',
101
+ documentType: 'sanity.imageAsset',
102
+ perspective: {releaseName: 'relA'},
103
+ })
104
+
105
+ const [result] = normalizeActionsForResource([action], mediaLibraryResource) as [
106
+ EditDocumentAction,
107
+ ]
108
+
109
+ expect(result.liveEdit).toBe(true)
110
+ expect(result.perspective).toBeUndefined()
111
+ expect(result.documentId).toBe('doc1')
112
+ expect(warnSpy).toHaveBeenCalledTimes(1)
113
+ expect(warnSpy.mock.calls[0][0]).toContain('Media Library')
114
+ })
115
+
116
+ it('strips release perspective but keeps draft/publish model for sanity.asset', () => {
117
+ const action = editAction({
118
+ documentId: 'versions.relA.doc1',
119
+ documentType: 'sanity.asset',
120
+ perspective: {releaseName: 'relA'},
121
+ })
122
+
123
+ const [result] = normalizeActionsForResource([action], mediaLibraryResource) as [
124
+ EditDocumentAction,
125
+ ]
126
+
127
+ expect(result.liveEdit).toBeUndefined()
128
+ expect(result.perspective).toBeUndefined()
129
+ expect(result.documentId).toBe('doc1')
130
+ expect(warnSpy).toHaveBeenCalledTimes(1)
131
+ })
132
+
133
+ it('silently forces liveEdit for canvas with no release perspective', () => {
134
+ const action = editAction({
135
+ documentId: 'drafts.doc1',
136
+ documentType: 'page',
137
+ perspective: 'drafts',
138
+ })
139
+
140
+ const [result] = normalizeActionsForResource([action], canvasResource) as [EditDocumentAction]
141
+
142
+ expect(result.liveEdit).toBe(true)
143
+ expect(result.documentId).toBe('doc1')
144
+ expect(warnSpy).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('emits a single warning for multiple stripped actions in one call', () => {
148
+ const a = editAction({
149
+ documentId: 'versions.relA.doc1',
150
+ documentType: 'page',
151
+ perspective: {releaseName: 'relA'},
152
+ })
153
+ const b = editAction({
154
+ documentId: 'versions.relA.doc2',
155
+ documentType: 'page',
156
+ perspective: {releaseName: 'relA'},
157
+ })
158
+
159
+ normalizeActionsForResource([a, b], canvasResource)
160
+
161
+ expect(warnSpy).toHaveBeenCalledTimes(1)
162
+ expect(warnSpy.mock.calls[0][0]).toContain('doc1')
163
+ expect(warnSpy.mock.calls[0][0]).toContain('doc2')
164
+ })
165
+
166
+ it('passes non-edit actions through unchanged', () => {
167
+ const action = {
168
+ type: 'document.publish' as const,
169
+ documentId: 'doc1',
170
+ documentType: 'page',
171
+ }
172
+
173
+ const result = normalizeActionsForResource([action], canvasResource)
174
+
175
+ expect(result[0]).toBe(action)
176
+ expect(warnSpy).not.toHaveBeenCalled()
177
+ })
178
+ })