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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +12 -0
  2. package/dist/_exports/_internal.d.ts +163 -0
  3. package/dist/_exports/_internal.d.ts.map +1 -0
  4. package/dist/_exports/_internal.js +332 -0
  5. package/dist/_exports/_internal.js.map +1 -0
  6. package/dist/_exports/agent.d.ts +65 -0
  7. package/dist/_exports/agent.d.ts.map +1 -0
  8. package/dist/_exports/agent.js +71 -0
  9. package/dist/_exports/agent.js.map +1 -0
  10. package/dist/_exports/comlink.d.ts +118 -0
  11. package/dist/_exports/comlink.d.ts.map +1 -0
  12. package/dist/_exports/comlink.js +69 -0
  13. package/dist/_exports/comlink.js.map +1 -0
  14. package/dist/authStore-CtE9bLHW.js +754 -0
  15. package/dist/authStore-CtE9bLHW.js.map +1 -0
  16. package/dist/authStore-DKFX8fn7.d.ts +3179 -0
  17. package/dist/authStore-DKFX8fn7.d.ts.map +1 -0
  18. package/dist/clientStore-BtE2-q2i.js +90 -0
  19. package/dist/clientStore-BtE2-q2i.js.map +1 -0
  20. package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
  21. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
  22. package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
  23. package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
  24. package/dist/getEnv-D5RGkQLu.js +8 -0
  25. package/dist/getEnv-D5RGkQLu.js.map +1 -0
  26. package/dist/getNodeState-CR3__B2D.js +67 -0
  27. package/dist/getNodeState-CR3__B2D.js.map +1 -0
  28. package/dist/index.d.ts +3 -2332
  29. package/dist/index.js +4171 -4332
  30. package/dist/index.js.map +1 -1
  31. package/dist/logger-DAtJb5RQ.js +173 -0
  32. package/dist/logger-DAtJb5RQ.js.map +1 -0
  33. package/dist/object-CxXlSVg7.js +83 -0
  34. package/dist/object-CxXlSVg7.js.map +1 -0
  35. package/dist/queryStore-DPf9sN3V.js +1346 -0
  36. package/dist/queryStore-DPf9sN3V.js.map +1 -0
  37. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  38. package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
  39. package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
  40. package/dist/telemetryManager-Dq_qCkeI.js +127 -0
  41. package/dist/telemetryManager-Dq_qCkeI.js.map +1 -0
  42. package/dist/version-BChjsi4u.js +9 -0
  43. package/dist/version-BChjsi4u.js.map +1 -0
  44. package/package.json +47 -52
  45. package/src/_exports/_internal.ts +27 -0
  46. package/src/_exports/agent.ts +19 -0
  47. package/src/_exports/comlink.ts +16 -0
  48. package/src/_exports/index.ts +125 -64
  49. package/src/access/checkPermissions.test.ts +55 -0
  50. package/src/access/checkPermissions.ts +54 -0
  51. package/src/agent/agentActions.test.ts +16 -60
  52. package/src/agent/agentActions.ts +25 -38
  53. package/src/applications/applications.test-d.ts +88 -0
  54. package/src/applications/applications.test.ts +130 -0
  55. package/src/applications/applications.ts +367 -0
  56. package/src/auth/authLogger.ts +30 -0
  57. package/src/auth/authStore.test.ts +194 -13
  58. package/src/auth/authStore.ts +58 -26
  59. package/src/auth/getOrganizationVerificationState.test.ts +18 -17
  60. package/src/auth/getOrganizationVerificationState.ts +4 -4
  61. package/src/auth/handleAuthCallback.test.ts +35 -1
  62. package/src/auth/handleAuthCallback.ts +25 -6
  63. package/src/auth/logout.test.ts +75 -2
  64. package/src/auth/logout.ts +23 -4
  65. package/src/auth/refreshStampedToken.test.ts +219 -469
  66. package/src/auth/refreshStampedToken.ts +79 -195
  67. package/src/auth/studioAuth.ts +1 -1
  68. package/src/auth/studioModeAuth.ts +2 -1
  69. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
  70. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
  71. package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
  72. package/src/client/clientStore.test.ts +62 -37
  73. package/src/client/clientStore.ts +45 -75
  74. package/src/client/liveEvents.test.ts +151 -0
  75. package/src/client/liveEvents.ts +107 -0
  76. package/src/comlink/controller/actions/destroyController.test.ts +4 -1
  77. package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
  78. package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
  79. package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
  80. package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
  81. package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
  82. package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
  83. package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
  84. package/src/comlink/node/actions/releaseNode.test.ts +7 -4
  85. package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
  86. package/src/comlink/node/getNodeState.test.ts +1 -1
  87. package/src/comlink/node/getNodeState.ts +2 -1
  88. package/src/comments/addonDatasetStore.test.ts +215 -0
  89. package/src/comments/addonDatasetStore.ts +249 -0
  90. package/src/comments/buildCommentThreads.test.ts +83 -0
  91. package/src/comments/buildCommentThreads.ts +54 -0
  92. package/src/comments/commentActions.test.ts +540 -0
  93. package/src/comments/commentActions.ts +555 -0
  94. package/src/comments/commentFieldPath.test.ts +40 -0
  95. package/src/comments/commentFieldPath.ts +44 -0
  96. package/src/comments/commentsConstants.ts +80 -0
  97. package/src/comments/commentsStore.test.ts +419 -0
  98. package/src/comments/commentsStore.ts +483 -0
  99. package/src/comments/normalizeComment.test.ts +116 -0
  100. package/src/comments/normalizeComment.ts +45 -0
  101. package/src/comments/observeComments.test.ts +193 -0
  102. package/src/comments/observeComments.ts +113 -0
  103. package/src/comments/reducers.test.ts +277 -0
  104. package/src/comments/reducers.ts +316 -0
  105. package/src/comments/types.ts +249 -0
  106. package/src/comments/weakenReferences.test.ts +47 -0
  107. package/src/comments/weakenReferences.ts +29 -0
  108. package/src/config/__tests__/handles.test.ts +18 -12
  109. package/src/config/handles.ts +25 -7
  110. package/src/config/sanityConfig.ts +40 -82
  111. package/src/datasets/datasets.test.ts +2 -2
  112. package/src/datasets/datasets.ts +10 -4
  113. package/src/document/actions.test.ts +115 -33
  114. package/src/document/actions.ts +212 -15
  115. package/src/document/applyDocumentActions.test.ts +35 -13
  116. package/src/document/applyDocumentActions.ts +30 -9
  117. package/src/document/documentConstants.ts +15 -0
  118. package/src/document/documentStore.concurrency.test.ts +1306 -0
  119. package/src/document/documentStore.test.ts +753 -273
  120. package/src/document/documentStore.ts +197 -75
  121. package/src/document/events.test.ts +57 -2
  122. package/src/document/events.ts +82 -26
  123. package/src/document/listen.test.ts +56 -0
  124. package/src/document/listen.ts +74 -21
  125. package/src/document/listenerEventOperators.test.ts +311 -0
  126. package/src/document/listenerEventOperators.ts +217 -0
  127. package/src/document/patchOperations.test.ts +76 -2
  128. package/src/document/patchOperations.ts +60 -1
  129. package/src/document/permissions.test.ts +107 -60
  130. package/src/document/permissions.ts +44 -19
  131. package/src/document/processActions/create.ts +138 -0
  132. package/src/document/processActions/delete.ts +100 -0
  133. package/src/document/processActions/discard.ts +63 -0
  134. package/src/document/processActions/edit.ts +154 -0
  135. package/src/document/processActions/processActions.ts +218 -0
  136. package/src/document/processActions/publish.ts +120 -0
  137. package/src/document/processActions/releaseArchive.ts +77 -0
  138. package/src/document/processActions/releaseCreate.ts +59 -0
  139. package/src/document/processActions/releaseDelete.ts +65 -0
  140. package/src/document/processActions/releaseEdit.ts +37 -0
  141. package/src/document/processActions/releasePublish.ts +45 -0
  142. package/src/document/processActions/releaseSchedule.ts +87 -0
  143. package/src/document/processActions/releaseUtil.ts +31 -0
  144. package/src/document/processActions/shared.ts +308 -0
  145. package/src/document/processActions/unpublish.ts +85 -0
  146. package/src/document/processActions.test.ts +1129 -121
  147. package/src/document/reducers.test.ts +772 -64
  148. package/src/document/reducers.ts +205 -51
  149. package/src/document/resourceRules.test.ts +178 -0
  150. package/src/document/resourceRules.ts +117 -0
  151. package/src/document/sharedListener.test.ts +16 -14
  152. package/src/document/sharedListener.ts +24 -8
  153. package/src/document/util.test.ts +46 -0
  154. package/src/document/util.ts +50 -0
  155. package/src/favorites/favorites.test.ts +5 -6
  156. package/src/installations/installations.test-d.ts +73 -0
  157. package/src/installations/installations.test.ts +62 -0
  158. package/src/installations/installations.ts +206 -0
  159. package/src/organization/organization.test-d.ts +36 -0
  160. package/src/organization/organization.test.ts +138 -0
  161. package/src/organization/organization.ts +138 -0
  162. package/src/organizations/organizations.test-d.ts +56 -0
  163. package/src/organizations/organizations.test.ts +150 -0
  164. package/src/organizations/organizations.ts +107 -0
  165. package/src/presence/bifurTransport.test.ts +201 -9
  166. package/src/presence/bifurTransport.ts +98 -14
  167. package/src/presence/paths.test.ts +70 -0
  168. package/src/presence/paths.ts +41 -0
  169. package/src/presence/presenceStore.test.ts +915 -24
  170. package/src/presence/presenceStore.ts +576 -104
  171. package/src/presence/types.ts +175 -3
  172. package/src/preview/previewProjectionUtils.test.ts +9 -22
  173. package/src/preview/previewProjectionUtils.ts +7 -3
  174. package/src/preview/types.ts +0 -15
  175. package/src/project/project.test-d.ts +37 -0
  176. package/src/project/project.test.ts +157 -12
  177. package/src/project/project.ts +138 -25
  178. package/src/projection/getProjectionState.test.ts +18 -20
  179. package/src/projection/getProjectionState.ts +23 -3
  180. package/src/projection/projectionQuery.ts +5 -5
  181. package/src/projection/projectionStore.test.ts +1 -9
  182. package/src/projection/resolveProjection.test.ts +1 -2
  183. package/src/projection/resolveProjection.ts +16 -0
  184. package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
  185. package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
  186. package/src/projects/projects.test-d.ts +32 -0
  187. package/src/projects/projects.test.ts +108 -42
  188. package/src/projects/projects.ts +63 -27
  189. package/src/query/queryStore.test.ts +259 -123
  190. package/src/query/queryStore.ts +85 -69
  191. package/src/query/reducers.test.ts +0 -42
  192. package/src/query/reducers.ts +2 -18
  193. package/src/releases/getPerspectiveState.test.ts +17 -23
  194. package/src/releases/getPerspectiveState.ts +8 -20
  195. package/src/releases/observeReleases.test.ts +127 -0
  196. package/src/releases/observeReleases.ts +102 -0
  197. package/src/releases/releasesStore.test.ts +100 -53
  198. package/src/releases/releasesStore.ts +69 -44
  199. package/src/releases/utils/sortReleases.test.ts +38 -2
  200. package/src/releases/utils/sortReleases.ts +15 -10
  201. package/src/store/createActionBinder.test.ts +109 -90
  202. package/src/store/createActionBinder.ts +36 -28
  203. package/src/store/createSanityInstance.test.ts +15 -13
  204. package/src/store/createSanityInstance.ts +14 -2
  205. package/src/store/createStateSourceAction.test.ts +64 -2
  206. package/src/store/createStateSourceAction.ts +38 -43
  207. package/src/store/createStoreInstance.test.ts +4 -2
  208. package/src/store/fetcherStore.test-d.ts +121 -0
  209. package/src/store/fetcherStore.test.ts +299 -0
  210. package/src/store/fetcherStore.ts +801 -0
  211. package/src/telemetry/environment.test.ts +119 -0
  212. package/src/telemetry/environment.ts +92 -0
  213. package/src/telemetry/events.ts +42 -0
  214. package/src/telemetry/initTelemetry.test.ts +432 -0
  215. package/src/telemetry/initTelemetry.ts +218 -0
  216. package/src/telemetry/telemetryManager.test.ts +327 -0
  217. package/src/telemetry/telemetryManager.ts +199 -0
  218. package/src/users/reducers.test.ts +3 -1
  219. package/src/users/reducers.ts +5 -6
  220. package/src/users/types.ts +4 -4
  221. package/src/users/usersStore.test.ts +16 -12
  222. package/src/users/usersStore.ts +5 -1
  223. package/src/utils/buildQuery.test.ts +14 -0
  224. package/src/utils/buildQuery.ts +17 -0
  225. package/src/utils/createFetcherStore.test.ts +7 -5
  226. package/src/utils/createFetcherStore.ts +8 -5
  227. package/src/utils/getStagingApiHost.test.ts +21 -0
  228. package/src/utils/getStagingApiHost.ts +14 -0
  229. package/src/utils/ids.test.ts +1 -29
  230. package/src/utils/ids.ts +0 -10
  231. package/src/utils/includeShape.ts +36 -0
  232. package/src/utils/isImportError.test.ts +72 -0
  233. package/src/utils/isImportError.ts +34 -0
  234. package/src/utils/logger.test.ts +12 -0
  235. package/src/utils/logger.ts +8 -3
  236. package/src/utils/object.test.ts +95 -0
  237. package/src/utils/object.ts +142 -0
  238. package/src/utils/setCleanupTimeout.ts +37 -0
  239. package/src/document/processActions.ts +0 -707
  240. package/src/utils/defineIntent.test.ts +0 -477
  241. package/src/utils/defineIntent.ts +0 -244
@@ -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)
@@ -349,8 +406,23 @@ export function cleanupOutgoingTransaction(prev: SyncTransactionState): SyncTran
349
406
 
350
407
  export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTransactionState {
351
408
  if (!prev.grants) return prev
409
+
410
+ // unregister the reverted batch's transaction-held subscriptions, symmetric
411
+ // with `cleanupOutgoingTransaction` on ack and `removeQueuedTransaction` on
412
+ // apply failure. without this, a reverted document's subscription IDs are
413
+ // never released and it can never be evicted
414
+ let base = prev
415
+ if (prev.outgoing) {
416
+ const ids = getDocumentIdsFromHandleLikes(prev.outgoing.actions)
417
+ for (const transactionId of prev.outgoing.batchedTransactionIds) {
418
+ for (const documentId of ids) {
419
+ base = removeSubscriptionIdFromDocument(base, documentId, transactionId)
420
+ }
421
+ }
422
+ }
423
+
352
424
  let working = Object.fromEntries(
353
- Object.entries(prev.documentStates).map(([documentId, documentState]) => [
425
+ Object.entries(base.documentStates).map(([documentId, documentState]) => [
354
426
  documentId,
355
427
  documentState?.remote,
356
428
  ]),
@@ -359,7 +431,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
359
431
 
360
432
  for (const t of prev.applied) {
361
433
  try {
362
- const next = processActions({...t, working, grants: prev.grants})
434
+ const next = processActions({...t, working, grants: prev.grants, identity: prev.identity})
363
435
  working = next.working
364
436
  nextApplied.push({...t, ...next})
365
437
  } catch (error) {
@@ -371,11 +443,11 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
371
443
  }
372
444
 
373
445
  return {
374
- ...prev,
446
+ ...base,
375
447
  applied: nextApplied,
376
448
  outgoing: undefined,
377
449
  documentStates: Object.fromEntries(
378
- Object.entries(prev.documentStates)
450
+ Object.entries(base.documentStates)
379
451
  .filter((e): e is [string, DocumentState] => !!e[1])
380
452
  .map(([documentId, {unverifiedRevisions = {}, local, ...documentState}]) => {
381
453
  const next: DocumentState = {
@@ -383,7 +455,7 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
383
455
  local: documentId in working ? working[documentId] : local,
384
456
  unverifiedRevisions:
385
457
  prev.outgoing && prev.outgoing.transactionId in unverifiedRevisions
386
- ? omit(unverifiedRevisions, prev.outgoing.transactionId)
458
+ ? omitProperty(unverifiedRevisions, prev.outgoing.transactionId)
387
459
  : unverifiedRevisions,
388
460
  }
389
461
  return [documentId, next] as const
@@ -392,9 +464,29 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
392
464
  }
393
465
  }
394
466
 
467
+ /**
468
+ * Extracts the patch operations addressed to the given document from a set of
469
+ * raw listener mutations, stripping the mutation-level `id` addressing so the
470
+ * patches are rooted at the document. A transaction can carry mutations for
471
+ * other documents, so anything not explicitly id-addressed to this document
472
+ * (including query-addressed patches) is dropped.
473
+ */
474
+ function extractPatchOperations(
475
+ mutations: Mutation[] | undefined,
476
+ documentId: string,
477
+ ): PatchOperations[] {
478
+ if (!mutations) return []
479
+ return mutations.flatMap((mutation) => {
480
+ if (!('patch' in mutation) || !mutation.patch) return []
481
+ const {id, ...operations} = mutation.patch as PatchOperations & {id?: string}
482
+ if (id !== documentId) return []
483
+ return [operations]
484
+ })
485
+ }
486
+
395
487
  export function applyRemoteDocument(
396
488
  prev: SyncTransactionState,
397
- {document, documentId, previousRev, revision, timestamp, type}: RemoteDocument,
489
+ {document, documentId, previousRev, revision, timestamp, type, mutations}: RemoteDocument,
398
490
  events: DocumentStoreState['events'],
399
491
  ): SyncTransactionState {
400
492
  if (!prev.grants) return prev
@@ -411,7 +503,32 @@ export function applyRemoteDocument(
411
503
  const revisionToVerify = revision ? prevUnverifiedRevisions?.[revision] : undefined
412
504
  let unverifiedRevisions = prevUnverifiedRevisions ?? EMPTY_REVISIONS
413
505
  if (revision && revisionToVerify) {
414
- unverifiedRevisions = omit(prevUnverifiedRevisions, revision)
506
+ unverifiedRevisions = omitProperty(prevUnverifiedRevisions, revision)
507
+ }
508
+
509
+ // surface the operational patches from this transaction before they are
510
+ // collapsed into the whole-document snapshot below. consumers that keep
511
+ // their own state (e.g. collaborative text editors) rely on these to apply
512
+ // remote changes without re-diffing document snapshots
513
+ if (type === 'mutation' && revision) {
514
+ const patches = extractPatchOperations(mutations, documentId)
515
+ if (patches.length) {
516
+ // `unverifiedRevisions` alone isn't a reliable origin marker: a sync
517
+ // event can prune an in-flight transaction's entry before its listener
518
+ // echo arrives. `recentOwnTransactionIds` survives that race
519
+ const isOwnTransaction =
520
+ Boolean(revisionToVerify) ||
521
+ (prevDocState.recentOwnTransactionIds?.includes(revision) ?? false)
522
+ events.next({
523
+ type: 'remote-patches',
524
+ documentId,
525
+ transactionId: revision,
526
+ previousRev,
527
+ timestamp,
528
+ patches,
529
+ origin: isOwnTransaction ? 'local' : 'remote',
530
+ })
531
+ }
415
532
  }
416
533
 
417
534
  // if this remote document is from a `'sync'` event (meaning that the whole
@@ -432,6 +549,34 @@ export function applyRemoteDocument(
432
549
  // matches the previous revision we expected, we can "fast-forward" and skip
433
550
  // rebasing local changes on top of this new base
434
551
  if (revisionToVerify && revisionToVerify.previousRev === previousRev) {
552
+ // even on a clean fast-forward, the server's result can differ from our
553
+ // optimistic prediction: a publish derives the published content from the
554
+ // server's copy of the draft, which may carry concurrent edits our
555
+ // prediction never saw. once nothing else is pending locally for this
556
+ // document (no applied transaction and no in-flight transaction, other
557
+ // than the one this event just confirmed, modifies it), converge `local`
558
+ // to the server's document. while local work that modifies this document
559
+ // is pending, keep the optimistic `local` so those changes stay visible;
560
+ // that work's own echo (or rebase) recomputes `local` later.
561
+ //
562
+ // "modifies" matters: pending work on other documents must not block
563
+ // convergence here (nothing would ever revisit this document's `local`),
564
+ // and a pending draft edit carries the published document in its working
565
+ // set without changing it, so a presence check would block published-doc
566
+ // convergence whenever a draft edit is pending. this is the same
567
+ // modified-test `transitionAppliedTransactionsToOutgoing` uses
568
+ const modifiesDocument = (transaction: AppliedTransaction) =>
569
+ transaction.working[documentId]?._rev !== transaction.previousRevs[documentId]
570
+ const nothingElsePending =
571
+ !prev.applied.some(modifiesDocument) &&
572
+ (!prev.outgoing ||
573
+ prev.outgoing.transactionId === revision ||
574
+ !modifiesDocument(prev.outgoing))
575
+ const local =
576
+ nothingElsePending && !isDeepEqual(prevDocState.local, document)
577
+ ? document
578
+ : prevDocState.local
579
+
435
580
  return {
436
581
  ...prev,
437
582
  documentStates: {
@@ -440,6 +585,7 @@ export function applyRemoteDocument(
440
585
  ...prevDocState,
441
586
  remote: document,
442
587
  remoteRev: revision,
588
+ local,
443
589
  unverifiedRevisions,
444
590
  },
445
591
  },
@@ -466,7 +612,7 @@ export function applyRemoteDocument(
466
612
  // transaction again through the listener and this same flow will run then
467
613
  for (const curr of prev.applied) {
468
614
  try {
469
- const next = processActions({...curr, working, grants: prev.grants})
615
+ const next = processActions({...curr, working, grants: prev.grants, identity: prev.identity})
470
616
  working = next.working
471
617
  // next includes an updated `previous` set and `working` set and updates
472
618
  // the `outgoingAction` and `outgoingMutations`. the `base` set from the
@@ -490,6 +636,12 @@ export function applyRemoteDocument(
490
636
  }
491
637
  }
492
638
 
639
+ // when the recompute lands on a value deep-equal to the current local
640
+ // (the common case for echoes of our own transactions), keep the previous
641
+ // reference so subscribers don't re-render for an identical value
642
+ const nextLocal = working[documentId]
643
+ const local = isDeepEqual(prevDocState.local, nextLocal) ? prevDocState.local : nextLocal
644
+
493
645
  return {
494
646
  ...prev,
495
647
  applied: nextApplied,
@@ -499,7 +651,7 @@ export function applyRemoteDocument(
499
651
  ...prevDocState,
500
652
  remote: document,
501
653
  remoteRev: revision,
502
- local: working[documentId],
654
+ local,
503
655
  unverifiedRevisions,
504
656
  },
505
657
  },
@@ -538,7 +690,7 @@ export function removeSubscriptionIdFromDocument(
538
690
 
539
691
  if (!prevDocState) return prev
540
692
  if (!subscriptions.length) {
541
- return {...prev, documentStates: omit(prev.documentStates, documentId)}
693
+ return {...prev, documentStates: omitProperty(prev.documentStates, documentId)}
542
694
  }
543
695
  return {
544
696
  ...prev,
@@ -551,22 +703,10 @@ export function removeSubscriptionIdFromDocument(
551
703
 
552
704
  export function manageSubscriberIds(
553
705
  {state}: StoreContext<SyncTransactionState>,
554
- documentId: string | string[],
555
- options?: {expandDraftPublished?: boolean},
706
+ handles: DocumentHandleLike[],
556
707
  ): () => 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
- )
708
+ const documentIds = getDocumentIdsFromHandleLikes(handles)
709
+
570
710
  const subscriptionId = insecureRandomId()
571
711
  state.set('addSubscribers', (prev) =>
572
712
  documentIds.reduce(
@@ -576,7 +716,7 @@ export function manageSubscriberIds(
576
716
  )
577
717
 
578
718
  return () => {
579
- setTimeout(() => {
719
+ setCleanupTimeout(() => {
580
720
  state.set('removeSubscribers', (prev) =>
581
721
  documentIds.reduce(
582
722
  (acc, id) => removeSubscriptionIdFromDocument(acc, id, subscriptionId),
@@ -587,13 +727,27 @@ export function manageSubscriberIds(
587
727
  }
588
728
  }
589
729
 
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
- )
730
+ // document handles are passed in via the public facing API, but we also need to
731
+ // pull the correct document ids from action bodies, which have similar but not
732
+ // identical shapes to the document handles. release actions also flow through
733
+ // here, and resolve to the underlying release document id.
734
+ function getDocumentIdsFromHandleLikes(handles: (DocumentHandleLike | Action)[]): string[] {
735
+ return handles.flatMap((handle) => {
736
+ if ('type' in handle && isReleaseAction(handle)) {
737
+ return [getReleaseDocumentId(handle.releaseId)]
738
+ }
739
+ const idsForDocument = []
740
+ if (!handle.documentId) return []
741
+ if (handle.liveEdit) {
742
+ return [handle.documentId]
743
+ }
744
+ if (isReleasePerspective(handle.perspective)) {
745
+ idsForDocument.push(
746
+ getVersionId(DocumentId(handle.documentId), handle.perspective.releaseName),
747
+ )
748
+ }
749
+ idsForDocument.push(getPublishedId(DocumentId(handle.documentId)))
750
+ idsForDocument.push(getDraftId(DocumentId(handle.documentId)))
751
+ return idsForDocument
752
+ })
599
753
  }
@@ -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
+ })