@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
@@ -205,4 +205,60 @@ describe('sortListenerEvents operator', () => {
205
205
  const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
206
206
  expect(events).toEqual([other])
207
207
  })
208
+
209
+ it('should discard replayed events already reflected in the base', async () => {
210
+ // The snapshot is at rev2; a resumed listener replays the event that
211
+ // produced rev2. It should be discarded instead of clogging the buffer.
212
+ const sync = createSyncEvent('rev2')
213
+ const replayed = createMutationEvent({
214
+ id: 'replayed',
215
+ previousRev: 'rev1',
216
+ resultRev: 'rev2',
217
+ })
218
+ const source$ = of(sync, replayed)
219
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
220
+ expect(events).toEqual([sync])
221
+ })
222
+
223
+ it('should discard the replayed part of a chain and apply the rest', async () => {
224
+ // The snapshot is at rev2; the listener replays rev1->rev2 followed by a
225
+ // genuinely new rev2->rev3. Only the new event should be emitted.
226
+ const sync = createSyncEvent('rev2')
227
+ const replayed = createMutationEvent({
228
+ id: 'replayed',
229
+ previousRev: 'rev1',
230
+ resultRev: 'rev2',
231
+ })
232
+ const fresh = createMutationEvent({
233
+ id: 'fresh',
234
+ previousRev: 'rev2',
235
+ resultRev: 'rev3',
236
+ })
237
+ const source$ = of(sync, replayed, fresh)
238
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
239
+ expect(events).toEqual([sync, fresh])
240
+ })
241
+
242
+ it('should discard a multi-event replayed chain that leads up to the base', async () => {
243
+ const sync = createSyncEvent('rev3')
244
+ // two replayed events that together lead up to the base revision
245
+ const replayedA = createMutationEvent({
246
+ id: 'replayedA',
247
+ previousRev: 'rev1',
248
+ resultRev: 'rev2',
249
+ })
250
+ const replayedB = createMutationEvent({
251
+ id: 'replayedB',
252
+ previousRev: 'rev2',
253
+ resultRev: 'rev3',
254
+ })
255
+ const fresh = createMutationEvent({
256
+ id: 'fresh',
257
+ previousRev: 'rev3',
258
+ resultRev: 'rev4',
259
+ })
260
+ const source$ = of(sync, replayedA, replayedB, fresh)
261
+ const events = await lastValueFrom(source$.pipe(sortListenerEvents(), toArray()))
262
+ expect(events).toEqual([sync, fresh])
263
+ })
208
264
  })
@@ -20,7 +20,7 @@ import {type StoreContext} from '../store/defineStore'
20
20
  import {type DocumentStoreState} from './documentStore'
21
21
  import {processMutations} from './processMutations'
22
22
 
23
- const DEFAULT_MAX_BUFFER_SIZE = 20
23
+ export const DEFAULT_MAX_BUFFER_SIZE = 20
24
24
  const DEFAULT_DEADLINE_MS = 30000
25
25
 
26
26
  export interface RemoteDocument {
@@ -30,6 +30,13 @@ export interface RemoteDocument {
30
30
  revision?: string
31
31
  previousRev?: string
32
32
  timestamp: string
33
+ /**
34
+ * The raw mutations from the listener event that produced this document.
35
+ * Only present for `'mutation'` events. These carry the operational intent
36
+ * of the remote change (e.g. keyed patches) before it is collapsed into a
37
+ * whole document snapshot.
38
+ */
39
+ mutations?: Mutation[]
33
40
  }
34
41
 
35
42
  export interface SyncEvent {
@@ -90,11 +97,50 @@ interface SortListenerEventsOptions {
90
97
  resolveChainDeadline?: number
91
98
  }
92
99
 
100
+ /**
101
+ * Orders the given mutation events into chains where each event's
102
+ * `previousRev` points at another event's `resultRev`. Events whose
103
+ * `previousRev` matches no other event start a new chain. The buffer may have
104
+ * multiple holes in it, so multiple chains can exist at once.
105
+ */
106
+ function toOrderedChains(events: MutationEvent[]): MutationEvent[][] {
107
+ const chainStarts = events.filter(
108
+ (event) => !events.some((other) => other.resultRev === event.previousRev),
109
+ )
110
+
111
+ return chainStarts.map((start) => {
112
+ const chain: MutationEvent[] = []
113
+ let current: MutationEvent | undefined = start
114
+ while (current) {
115
+ chain.push(current)
116
+ const currentResultRev: string | undefined = current.resultRev
117
+ current = events.find((event) => event.previousRev === currentResultRev)
118
+ }
119
+ return chain
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Discards the leading events of a chain up to and including the event whose
125
+ * `resultRev` equals the given revision. These events describe changes that
126
+ * are already reflected in the current base (e.g. events replayed by a
127
+ * resumed listener after the snapshot was refetched).
128
+ */
129
+ function discardChainTo(chain: MutationEvent[], revision: string | undefined): MutationEvent[] {
130
+ const revisionIndex = chain.findIndex((event) => event.resultRev === revision)
131
+ if (revisionIndex === -1) return chain
132
+ return chain.slice(revisionIndex + 1)
133
+ }
134
+
93
135
  /**
94
136
  * Takes an input observable of listener events that might arrive out of order, and emits them in sequence
95
137
  * If we receive mutation events that doesn't line up in [previousRev, resultRev] pairs we'll put them in a buffer and
96
138
  * check if we have an unbroken chain every time we receive a new event
97
139
  *
140
+ * Buffered chains that lead up to the current base revision are discarded:
141
+ * they describe changes already reflected in the base (e.g. events replayed
142
+ * by a resumed listener after the snapshot was refetched)
143
+ *
98
144
  * If the buffer grows beyond `maxBufferSize`, or if `resolveChainDeadline` milliseconds passes before the chain resolves
99
145
  * an OutOfSyncError will be thrown on the stream
100
146
  *
@@ -125,38 +171,40 @@ export function sortListenerEvents(options?: SortListenerEventsOptions) {
125
171
  'Invalid state. Cannot process mutation event without a base sync event',
126
172
  )
127
173
  }
128
- // Add the new mutation event into the buffer
129
- const buffer = state.buffer.concat(event)
130
- const emitEvents: MutationEvent[] = []
131
- let baseRevision = state.base.revision
132
- let progress = true
174
+ const baseRevision = state.base.revision
175
+
176
+ // Order the buffered events (plus the new one) into chains, then
177
+ // drop the parts of each chain the base already reflects.
178
+ const chains = toOrderedChains(state.buffer.concat(event))
179
+ .map((chain) => discardChainTo(chain, baseRevision))
180
+ .filter((chain) => chain.length > 0)
181
+
182
+ // A chain whose head continues the current base revision can be
183
+ // applied. There can be at most one; anything else stays buffered.
184
+ const applicable = chains.find((chain) => chain[0].previousRev === baseRevision)
185
+ const buffer = chains.filter((chain) => chain !== applicable).flat()
133
186
 
134
- // Try to apply as many buffered mutations as possible.
135
- while (progress) {
136
- progress = false
137
- // Look for a mutation whose previousRev matches the current base.
138
- const idx = buffer.findIndex((e) => e.previousRev === baseRevision)
139
- if (idx !== -1) {
140
- // Remove the event from the buffer and “apply” it.
141
- const [next] = buffer.splice(idx, 1)
142
- emitEvents.push(next)
143
- // If the mutation is a deletion, the new base revision is undefined.
144
- baseRevision = next.transition === 'disappear' ? undefined : next.resultRev
145
- progress = true
187
+ if (applicable) {
188
+ const last = applicable[applicable.length - 1]
189
+ return {
190
+ // If the chain ends in a deletion, the new base revision is undefined.
191
+ base: {revision: last.transition === 'disappear' ? undefined : last.resultRev},
192
+ buffer,
193
+ emitEvents: applicable,
146
194
  }
147
195
  }
148
196
 
149
197
  if (buffer.length >= maxBufferSize) {
150
198
  throw new MaxBufferExceededError(
151
199
  `Too many unchainable mutation events (${buffer.length}) waiting to resolve.`,
152
- {base: {revision: baseRevision}, buffer, emitEvents},
200
+ {base: {revision: baseRevision}, buffer, emitEvents: []},
153
201
  )
154
202
  }
155
203
 
156
204
  return {
157
205
  base: {revision: baseRevision},
158
206
  buffer,
159
- emitEvents,
207
+ emitEvents: [],
160
208
  }
161
209
  }
162
210
  // Any other event is simply forwarded.
@@ -201,7 +249,11 @@ export const listen = (
201
249
 
202
250
  return sharedListener.events.pipe(
203
251
  concatMap((e) => {
204
- if (e.type === 'welcome') {
252
+ // `welcome` means a fresh listener connection and `reset` means a
253
+ // resumed listener could not replay what was missed; both require
254
+ // refetching the snapshot. a `welcomeback` means the resume succeeded
255
+ // and the missed events were replayed, so no refetch is needed
256
+ if (e.type === 'welcome' || e.type === 'reset') {
205
257
  return fetchDocument(documentId).pipe(
206
258
  map((document): SyncEvent => ({type: 'sync', document})),
207
259
  )
@@ -251,6 +303,7 @@ export const listen = (
251
303
  document: document ?? null,
252
304
  revision: transactionId,
253
305
  timestamp,
306
+ mutations: next.mutations as Mutation[],
254
307
  ...(previousRev && {previousRev}),
255
308
  }
256
309
  }),
@@ -0,0 +1,311 @@
1
+ import {
2
+ type ListenEvent,
3
+ type MutationEvent,
4
+ type ReconnectEvent,
5
+ type WelcomeEvent,
6
+ } from '@sanity/client'
7
+ import {type SanityDocument} from '@sanity/types'
8
+ import {from, lastValueFrom, type Observable, Subject} from 'rxjs'
9
+ import {toArray} from 'rxjs/operators'
10
+ import {afterEach, describe, expect, it, vi} from 'vitest'
11
+
12
+ import {dedupeListenerEvents, groupTransactionEvents} from './listenerEventOperators'
13
+
14
+ function listenEventsOf(
15
+ ...events: ListenEvent<SanityDocument>[]
16
+ ): Observable<ListenEvent<SanityDocument>> {
17
+ return from(events)
18
+ }
19
+
20
+ function createMutationEvent({
21
+ transactionId,
22
+ documentId = 'doc1',
23
+ transactionCurrentEvent = 1,
24
+ transactionTotalEvents = 1,
25
+ }: {
26
+ transactionId: string
27
+ documentId?: string
28
+ transactionCurrentEvent?: number
29
+ transactionTotalEvents?: number
30
+ }): MutationEvent {
31
+ return {
32
+ type: 'mutation',
33
+ documentId,
34
+ eventId: `${transactionId}#${documentId}`,
35
+ identity: 'user',
36
+ mutations: [],
37
+ timestamp: new Date().toISOString(),
38
+ transactionId,
39
+ transactionCurrentEvent,
40
+ transactionTotalEvents,
41
+ transition: 'update',
42
+ visibility: 'query',
43
+ resultRev: transactionId,
44
+ }
45
+ }
46
+
47
+ const welcomeEvent: WelcomeEvent = {type: 'welcome', listenerName: 'listener'}
48
+ const reconnectEvent = {type: 'reconnect'} as ReconnectEvent
49
+
50
+ afterEach(() => {
51
+ vi.useRealTimers()
52
+ })
53
+
54
+ describe('dedupeListenerEvents', () => {
55
+ it('passes unique mutation events through', async () => {
56
+ const m1 = createMutationEvent({transactionId: 'txn1'})
57
+ const m2 = createMutationEvent({transactionId: 'txn2'})
58
+ const events = await lastValueFrom(
59
+ listenEventsOf(m1, m2).pipe(dedupeListenerEvents(), toArray()),
60
+ )
61
+ expect(events).toEqual([m1, m2])
62
+ })
63
+
64
+ it('drops replayed duplicates of the same transaction and document', async () => {
65
+ const m1 = createMutationEvent({transactionId: 'txn1'})
66
+ const replay = createMutationEvent({transactionId: 'txn1'})
67
+ const events = await lastValueFrom(
68
+ listenEventsOf(m1, replay).pipe(dedupeListenerEvents(), toArray()),
69
+ )
70
+ expect(events).toEqual([m1])
71
+ })
72
+
73
+ it('does not dedupe events of the same transaction for different documents', async () => {
74
+ const draft = createMutationEvent({transactionId: 'txn1', documentId: 'drafts.doc1'})
75
+ const published = createMutationEvent({transactionId: 'txn1', documentId: 'doc1'})
76
+ const events = await lastValueFrom(
77
+ listenEventsOf(draft, published).pipe(dedupeListenerEvents(), toArray()),
78
+ )
79
+ expect(events).toEqual([draft, published])
80
+ })
81
+
82
+ it('passes non-mutation events through untouched', async () => {
83
+ const events = await lastValueFrom(
84
+ listenEventsOf(welcomeEvent, reconnectEvent).pipe(dedupeListenerEvents(), toArray()),
85
+ )
86
+ expect(events).toEqual([welcomeEvent, reconnectEvent])
87
+ })
88
+
89
+ it('allows a key again after its TTL has passed', async () => {
90
+ vi.useFakeTimers()
91
+ const source = new Subject<ListenEvent<SanityDocument>>()
92
+ const received: ListenEvent<SanityDocument>[] = []
93
+ const subscription = source
94
+ .pipe(dedupeListenerEvents({ttl: 1000}))
95
+ .subscribe((e) => received.push(e))
96
+
97
+ source.next(createMutationEvent({transactionId: 'txn1'}))
98
+ source.next(createMutationEvent({transactionId: 'txn1'}))
99
+ expect(received).toHaveLength(1)
100
+
101
+ vi.advanceTimersByTime(1001)
102
+ source.next(createMutationEvent({transactionId: 'txn1'}))
103
+ expect(received).toHaveLength(2)
104
+
105
+ subscription.unsubscribe()
106
+ })
107
+
108
+ it('evicts the oldest entries when the cache exceeds maxEntries', async () => {
109
+ const source = new Subject<ListenEvent<SanityDocument>>()
110
+ const received: ListenEvent<SanityDocument>[] = []
111
+ const subscription = source
112
+ .pipe(dedupeListenerEvents({maxEntries: 2}))
113
+ .subscribe((e) => received.push(e))
114
+
115
+ source.next(createMutationEvent({transactionId: 'txn1'}))
116
+ source.next(createMutationEvent({transactionId: 'txn2'}))
117
+ source.next(createMutationEvent({transactionId: 'txn3'}))
118
+ // txn1 was evicted to make room, so a replay of it passes through again
119
+ source.next(createMutationEvent({transactionId: 'txn1'}))
120
+ expect(received).toHaveLength(4)
121
+
122
+ subscription.unsubscribe()
123
+ })
124
+ })
125
+
126
+ describe('groupTransactionEvents', () => {
127
+ it('passes single-event transactions through immediately', async () => {
128
+ const m1 = createMutationEvent({transactionId: 'txn1'})
129
+ const events = await lastValueFrom(listenEventsOf(m1).pipe(groupTransactionEvents(), toArray()))
130
+ expect(events).toEqual([m1])
131
+ })
132
+
133
+ it('holds multi-document transaction events until the transaction is complete', () => {
134
+ const source = new Subject<ListenEvent<SanityDocument>>()
135
+ const received: ListenEvent<SanityDocument>[] = []
136
+ const subscription = source.pipe(groupTransactionEvents()).subscribe((e) => received.push(e))
137
+
138
+ const draftEvent = createMutationEvent({
139
+ transactionId: 'txn-publish',
140
+ documentId: 'drafts.doc1',
141
+ transactionCurrentEvent: 1,
142
+ transactionTotalEvents: 2,
143
+ })
144
+ const publishedEvent = createMutationEvent({
145
+ transactionId: 'txn-publish',
146
+ documentId: 'doc1',
147
+ transactionCurrentEvent: 2,
148
+ transactionTotalEvents: 2,
149
+ })
150
+
151
+ source.next(draftEvent)
152
+ expect(received).toEqual([])
153
+
154
+ source.next(publishedEvent)
155
+ expect(received).toEqual([draftEvent, publishedEvent])
156
+
157
+ subscription.unsubscribe()
158
+ })
159
+
160
+ it('holds intervening events until the open transaction completes, then releases them after it', () => {
161
+ const source = new Subject<ListenEvent<SanityDocument>>()
162
+ const received: ListenEvent<SanityDocument>[] = []
163
+ const subscription = source.pipe(groupTransactionEvents()).subscribe((e) => received.push(e))
164
+
165
+ const draftEvent = createMutationEvent({
166
+ transactionId: 'txn-publish',
167
+ documentId: 'drafts.doc1',
168
+ transactionCurrentEvent: 1,
169
+ transactionTotalEvents: 2,
170
+ })
171
+ // an unrelated single-document mutation interleaved between the two
172
+ // halves of the publish (legacy listener pipeline behavior)
173
+ const intervening = createMutationEvent({transactionId: 'txn-other', documentId: 'doc9'})
174
+ const publishedEvent = createMutationEvent({
175
+ transactionId: 'txn-publish',
176
+ documentId: 'doc1',
177
+ transactionCurrentEvent: 2,
178
+ transactionTotalEvents: 2,
179
+ })
180
+
181
+ source.next(draftEvent)
182
+ source.next(intervening)
183
+ expect(received).toEqual([])
184
+
185
+ source.next(publishedEvent)
186
+ expect(received).toEqual([draftEvent, publishedEvent, intervening])
187
+
188
+ subscription.unsubscribe()
189
+ })
190
+
191
+ it('groups a held multi-document transaction after the open one completes', () => {
192
+ const source = new Subject<ListenEvent<SanityDocument>>()
193
+ const received: ListenEvent<SanityDocument>[] = []
194
+ const subscription = source.pipe(groupTransactionEvents()).subscribe((e) => received.push(e))
195
+
196
+ const a1 = createMutationEvent({
197
+ transactionId: 'txn-a',
198
+ documentId: 'drafts.doc1',
199
+ transactionTotalEvents: 2,
200
+ })
201
+ const b1 = createMutationEvent({
202
+ transactionId: 'txn-b',
203
+ documentId: 'drafts.doc2',
204
+ transactionTotalEvents: 2,
205
+ })
206
+ const b2 = createMutationEvent({
207
+ transactionId: 'txn-b',
208
+ documentId: 'doc2',
209
+ transactionTotalEvents: 2,
210
+ })
211
+ const a2 = createMutationEvent({
212
+ transactionId: 'txn-a',
213
+ documentId: 'doc1',
214
+ transactionTotalEvents: 2,
215
+ })
216
+
217
+ source.next(a1)
218
+ source.next(b1)
219
+ source.next(b2)
220
+ expect(received).toEqual([])
221
+
222
+ source.next(a2)
223
+ // txn-a completes and is emitted contiguously; the held txn-b events are
224
+ // re-processed and, being complete, emitted contiguously right after
225
+ expect(received).toEqual([a1, a2, b1, b2])
226
+
227
+ subscription.unsubscribe()
228
+ })
229
+
230
+ it('releases everything in arrival order when the held queue exceeds its cap', () => {
231
+ const source = new Subject<ListenEvent<SanityDocument>>()
232
+ const received: ListenEvent<SanityDocument>[] = []
233
+ const subscription = source
234
+ .pipe(groupTransactionEvents({maxHeldEvents: 2}))
235
+ .subscribe((e) => received.push(e))
236
+
237
+ const partial = createMutationEvent({
238
+ transactionId: 'txn-incomplete',
239
+ documentId: 'drafts.doc1',
240
+ transactionTotalEvents: 2,
241
+ })
242
+ const held1 = createMutationEvent({transactionId: 'txn-h1', documentId: 'doc8'})
243
+ const held2 = createMutationEvent({transactionId: 'txn-h2', documentId: 'doc9'})
244
+ const overflow = createMutationEvent({transactionId: 'txn-h3', documentId: 'doc10'})
245
+
246
+ source.next(partial)
247
+ source.next(held1)
248
+ source.next(held2)
249
+ expect(received).toEqual([])
250
+
251
+ source.next(overflow)
252
+ expect(received).toEqual([partial, held1, held2, overflow])
253
+
254
+ subscription.unsubscribe()
255
+ })
256
+
257
+ it('flushes buffered events before non-mutation events', () => {
258
+ const source = new Subject<ListenEvent<SanityDocument>>()
259
+ const received: ListenEvent<SanityDocument>[] = []
260
+ const subscription = source.pipe(groupTransactionEvents()).subscribe((e) => received.push(e))
261
+
262
+ const partial = createMutationEvent({
263
+ transactionId: 'txn-incomplete',
264
+ documentId: 'drafts.doc1',
265
+ transactionTotalEvents: 2,
266
+ })
267
+
268
+ source.next(partial)
269
+ source.next(reconnectEvent)
270
+ expect(received).toEqual([partial, reconnectEvent])
271
+
272
+ subscription.unsubscribe()
273
+ })
274
+
275
+ it('flushes an incomplete transaction and its held events after the deadline', () => {
276
+ vi.useFakeTimers()
277
+ const source = new Subject<ListenEvent<SanityDocument>>()
278
+ const received: ListenEvent<SanityDocument>[] = []
279
+ const subscription = source
280
+ .pipe(groupTransactionEvents({flushDeadline: 500}))
281
+ .subscribe((e) => received.push(e))
282
+
283
+ const partial = createMutationEvent({
284
+ transactionId: 'txn-incomplete',
285
+ documentId: 'drafts.doc1',
286
+ transactionTotalEvents: 2,
287
+ })
288
+ const heldBehind = createMutationEvent({transactionId: 'txn-held', documentId: 'doc9'})
289
+
290
+ source.next(partial)
291
+ source.next(heldBehind)
292
+ expect(received).toEqual([])
293
+
294
+ vi.advanceTimersByTime(500)
295
+ expect(received).toEqual([partial, heldBehind])
296
+
297
+ subscription.unsubscribe()
298
+ })
299
+
300
+ it('flushes buffered events on completion', async () => {
301
+ const partial = createMutationEvent({
302
+ transactionId: 'txn-incomplete',
303
+ documentId: 'drafts.doc1',
304
+ transactionTotalEvents: 2,
305
+ })
306
+ const events = await lastValueFrom(
307
+ listenEventsOf(partial).pipe(groupTransactionEvents(), toArray()),
308
+ )
309
+ expect(events).toEqual([partial])
310
+ })
311
+ })