@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.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +1147 -0
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -0
- package/dist/_chunks-es/_internal.js +134 -0
- package/dist/_chunks-es/_internal.js.map +1 -0
- package/dist/_chunks-es/createGroqSearchFilter.js +1864 -0
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -0
- package/dist/_chunks-es/telemetryManager.js +93 -0
- package/dist/_chunks-es/telemetryManager.js.map +1 -0
- package/dist/_exports/_internal.d.ts +95 -0
- package/dist/_exports/_internal.d.ts.map +1 -0
- package/dist/_exports/_internal.js +24 -0
- package/dist/_exports/_internal.js.map +1 -0
- package/dist/index.d.ts +1079 -1076
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1967 -2302
- package/dist/index.js.map +1 -1
- package/package.json +48 -47
- package/src/_exports/_internal.ts +26 -0
- package/src/_exports/index.ts +120 -11
- package/src/access/checkPermissions.test.ts +55 -0
- package/src/access/checkPermissions.ts +54 -0
- package/src/agent/agentActions.test.ts +16 -60
- package/src/agent/agentActions.ts +25 -38
- package/src/applications/applications.test-d.ts +83 -0
- package/src/applications/applications.test.ts +130 -0
- package/src/applications/applications.ts +364 -0
- package/src/applications/userApplications.test.ts +126 -0
- package/src/applications/userApplications.ts +261 -0
- package/src/auth/authLogger.ts +30 -0
- package/src/auth/authMode.test.ts +25 -0
- package/src/auth/authMode.ts +6 -3
- package/src/auth/authStore.test.ts +194 -13
- package/src/auth/authStore.ts +59 -26
- package/src/auth/getOrganizationVerificationState.test.ts +18 -17
- package/src/auth/getOrganizationVerificationState.ts +4 -4
- package/src/auth/handleAuthCallback.test.ts +35 -1
- package/src/auth/handleAuthCallback.ts +25 -6
- package/src/auth/logout.test.ts +75 -2
- package/src/auth/logout.ts +23 -4
- package/src/auth/refreshStampedToken.test.ts +219 -469
- package/src/auth/refreshStampedToken.ts +79 -195
- package/src/auth/studioAuth.ts +1 -1
- package/src/auth/studioModeAuth.ts +2 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +146 -13
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +37 -6
- package/src/auth/subscribeToStorageEventsAndSetToken.test.ts +2 -0
- package/src/client/clientStore.test.ts +62 -37
- package/src/client/clientStore.ts +45 -75
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/comlink/controller/actions/destroyController.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.test.ts +4 -1
- package/src/comlink/controller/actions/getOrCreateChannel.ts +2 -2
- package/src/comlink/controller/actions/getOrCreateController.test.ts +4 -1
- package/src/comlink/controller/actions/releaseChannel.test.ts +1 -1
- package/src/comlink/controller/comlinkControllerStore.test.ts +4 -1
- package/src/comlink/node/actions/getOrCreateNode.test.ts +9 -3
- package/src/comlink/node/actions/getOrCreateNode.ts +2 -2
- package/src/comlink/node/actions/releaseNode.test.ts +7 -4
- package/src/comlink/node/comlinkNodeStore.test.ts +2 -2
- package/src/comlink/node/getNodeState.test.ts +1 -1
- package/src/comlink/node/getNodeState.ts +2 -1
- package/src/config/__tests__/handles.test.ts +18 -12
- package/src/config/handles.ts +25 -7
- package/src/config/sanityConfig.ts +109 -79
- package/src/datasets/datasets.test.ts +2 -2
- package/src/datasets/datasets.ts +10 -4
- package/src/document/actions.test.ts +115 -33
- package/src/document/actions.ts +212 -15
- package/src/document/applyDocumentActions.test.ts +38 -12
- package/src/document/applyDocumentActions.ts +30 -9
- package/src/document/documentConstants.ts +7 -0
- package/src/document/documentStore.concurrency.test.ts +1306 -0
- package/src/document/documentStore.test.ts +673 -278
- package/src/document/documentStore.ts +162 -71
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +82 -26
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +74 -21
- package/src/document/listenerEventOperators.test.ts +311 -0
- package/src/document/listenerEventOperators.ts +217 -0
- package/src/document/patchOperations.test.ts +49 -2
- package/src/document/patchOperations.ts +50 -0
- package/src/document/permissions.test.ts +105 -60
- package/src/document/permissions.ts +44 -19
- package/src/document/processActions/create.ts +138 -0
- package/src/document/processActions/delete.ts +100 -0
- package/src/document/processActions/discard.ts +63 -0
- package/src/document/processActions/edit.ts +154 -0
- package/src/document/processActions/processActions.ts +218 -0
- package/src/document/processActions/publish.ts +120 -0
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +37 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +308 -0
- package/src/document/processActions/unpublish.ts +85 -0
- package/src/document/processActions.test.ts +1129 -121
- package/src/document/reducers.test.ts +639 -64
- package/src/document/reducers.ts +187 -48
- package/src/document/resourceRules.test.ts +178 -0
- package/src/document/resourceRules.ts +117 -0
- package/src/document/sharedListener.test.ts +16 -14
- package/src/document/sharedListener.ts +24 -8
- package/src/document/util.ts +14 -0
- package/src/favorites/favorites.test.ts +5 -6
- package/src/installations/installations.test-d.ts +68 -0
- package/src/installations/installations.test.ts +62 -0
- package/src/installations/installations.ts +202 -0
- package/src/organization/organization.test-d.ts +36 -0
- package/src/organization/organization.test.ts +138 -0
- package/src/organization/organization.ts +138 -0
- package/src/organizations/organizations.test-d.ts +56 -0
- package/src/organizations/organizations.test.ts +150 -0
- package/src/organizations/organizations.ts +107 -0
- package/src/presence/bifurTransport.test.ts +46 -6
- package/src/presence/bifurTransport.ts +19 -2
- package/src/presence/presenceStore.test.ts +94 -23
- package/src/presence/presenceStore.ts +85 -29
- package/src/preview/getPreviewState.test.ts +137 -0
- package/src/preview/getPreviewState.ts +69 -0
- package/src/preview/previewProjectionUtils.test.ts +9 -22
- package/src/preview/previewProjectionUtils.ts +7 -3
- package/src/preview/resolvePreview.test.ts +64 -0
- package/src/preview/resolvePreview.ts +43 -0
- package/src/preview/types.ts +10 -0
- package/src/project/project.test-d.ts +37 -0
- package/src/project/project.test.ts +157 -12
- package/src/project/project.ts +138 -25
- package/src/projection/getProjectionState.test.ts +18 -20
- package/src/projection/getProjectionState.ts +23 -3
- package/src/projection/projectionQuery.ts +5 -5
- package/src/projection/projectionStore.test.ts +1 -1
- package/src/projection/resolveProjection.test.ts +1 -2
- package/src/projection/resolveProjection.ts +16 -0
- package/src/projection/subscribeToStateAndFetchBatches.test.ts +1 -1
- package/src/projection/subscribeToStateAndFetchBatches.ts +32 -23
- package/src/projection/types.ts +8 -0
- package/src/projects/projects.test-d.ts +32 -0
- package/src/projects/projects.test.ts +108 -42
- package/src/projects/projects.ts +63 -27
- package/src/query/queryStore.test.ts +259 -123
- package/src/query/queryStore.ts +78 -63
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +2 -18
- package/src/releases/getPerspectiveState.test.ts +17 -23
- package/src/releases/getPerspectiveState.ts +8 -20
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +100 -53
- package/src/releases/releasesStore.ts +69 -44
- package/src/releases/utils/sortReleases.test.ts +38 -2
- package/src/releases/utils/sortReleases.ts +15 -10
- package/src/store/createActionBinder.test.ts +87 -90
- package/src/store/createActionBinder.ts +79 -25
- package/src/store/createSanityInstance.test.ts +83 -13
- package/src/store/createSanityInstance.ts +77 -2
- package/src/store/createStateSourceAction.test.ts +64 -2
- package/src/store/createStateSourceAction.ts +39 -44
- package/src/store/createStoreInstance.test.ts +4 -2
- package/src/store/fetcherStore.test-d.ts +121 -0
- package/src/store/fetcherStore.test.ts +299 -0
- package/src/store/fetcherStore.ts +801 -0
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/events.ts +42 -0
- package/src/telemetry/initTelemetry.test.ts +432 -0
- package/src/telemetry/initTelemetry.ts +218 -0
- package/src/telemetry/telemetryManager.test.ts +327 -0
- package/src/telemetry/telemetryManager.ts +199 -0
- package/src/users/reducers.test.ts +6 -1
- package/src/users/reducers.ts +5 -6
- package/src/users/types.ts +4 -4
- package/src/users/usersStore.test.ts +16 -12
- package/src/users/usersStore.ts +5 -1
- package/src/utils/buildQuery.test.ts +14 -0
- package/src/utils/buildQuery.ts +17 -0
- package/src/utils/createFetcherStore.test.ts +7 -5
- package/src/utils/createFetcherStore.ts +8 -5
- package/src/utils/getStagingApiHost.test.ts +21 -0
- package/src/utils/getStagingApiHost.ts +14 -0
- package/src/utils/ids.test.ts +1 -29
- package/src/utils/ids.ts +0 -10
- package/src/utils/includeShape.ts +36 -0
- package/src/utils/isImportError.test.ts +72 -0
- package/src/utils/isImportError.ts +34 -0
- package/src/utils/logger.test.ts +12 -0
- package/src/utils/logger.ts +8 -3
- package/src/utils/object.test.ts +95 -0
- package/src/utils/object.ts +142 -0
- package/src/utils/setCleanupTimeout.ts +37 -0
- package/src/document/processActions.ts +0 -707
- package/src/utils/defineIntent.test.ts +0 -477
- package/src/utils/defineIntent.ts +0 -244
package/src/document/listen.ts
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
+
})
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {type ListenEvent, type MutationEvent} from '@sanity/client'
|
|
2
|
+
import {type SanityDocument} from '@sanity/types'
|
|
3
|
+
import {defer, filter, type MonoTypeOperatorFunction, Observable} from 'rxjs'
|
|
4
|
+
|
|
5
|
+
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
6
|
+
|
|
7
|
+
const DEDUPE_TTL = 120_000
|
|
8
|
+
const DEDUPE_MAX_ENTRIES = 1_000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long to hold an incomplete multi-document transaction (and the events
|
|
12
|
+
* held behind it) before releasing everything anyway. Only reached when part
|
|
13
|
+
* of a transaction is lost.
|
|
14
|
+
*/
|
|
15
|
+
const GROUP_FLUSH_DEADLINE = 30_000
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How many intervening events to hold behind an incomplete multi-document
|
|
19
|
+
* transaction before giving up on grouping and releasing everything in
|
|
20
|
+
* arrival order.
|
|
21
|
+
*/
|
|
22
|
+
const GROUP_MAX_HELD_EVENTS = 100
|
|
23
|
+
|
|
24
|
+
interface DedupeOptions {
|
|
25
|
+
ttl?: number
|
|
26
|
+
maxEntries?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drops mutation events that have already been observed, keyed by
|
|
31
|
+
* `transactionId#documentId`. With `enableResume`, the listener replays
|
|
32
|
+
* missed events after a reconnect on an at-least-once basis, so the same
|
|
33
|
+
* event can be delivered twice. A duplicate would never chain onto the
|
|
34
|
+
* current base revision and would clog the sequencing buffer until it
|
|
35
|
+
* overflows into an `OutOfSyncError`.
|
|
36
|
+
*
|
|
37
|
+
* Seen keys expire after `ttl` milliseconds and the cache is capped at
|
|
38
|
+
* `maxEntries`, evicting the oldest entries first.
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function dedupeListenerEvents(
|
|
43
|
+
options?: DedupeOptions,
|
|
44
|
+
): MonoTypeOperatorFunction<ListenEvent<SanityDocument>> {
|
|
45
|
+
const {ttl = DEDUPE_TTL, maxEntries = DEDUPE_MAX_ENTRIES} = options ?? {}
|
|
46
|
+
|
|
47
|
+
return (source) =>
|
|
48
|
+
defer(() => {
|
|
49
|
+
// insertion-ordered map of dedupe key -> expiry timestamp
|
|
50
|
+
const seen = new Map<string, number>()
|
|
51
|
+
|
|
52
|
+
return source.pipe(
|
|
53
|
+
filter((event) => {
|
|
54
|
+
if (event.type !== 'mutation') return true
|
|
55
|
+
|
|
56
|
+
const key = `${event.transactionId}#${event.documentId}`
|
|
57
|
+
const now = Date.now()
|
|
58
|
+
|
|
59
|
+
const expiry = seen.get(key)
|
|
60
|
+
if (expiry !== undefined && expiry > now) return false
|
|
61
|
+
|
|
62
|
+
seen.delete(key)
|
|
63
|
+
seen.set(key, now + ttl)
|
|
64
|
+
|
|
65
|
+
for (const [existingKey, existingExpiry] of seen) {
|
|
66
|
+
if (seen.size <= maxEntries && existingExpiry > now) break
|
|
67
|
+
seen.delete(existingKey)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return true
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface GroupTransactionEventsOptions {
|
|
77
|
+
flushDeadline?: number
|
|
78
|
+
maxHeldEvents?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Buffers the mutation events of multi-document transactions (e.g. a publish
|
|
83
|
+
* touching both the draft and the published document) until every event of
|
|
84
|
+
* the transaction has arrived, then emits them contiguously. Without this,
|
|
85
|
+
* a rebase can run between the two halves of a transaction and observe the
|
|
86
|
+
* dataset in a state that never existed on the server (draft deleted, but
|
|
87
|
+
* published not yet updated).
|
|
88
|
+
*
|
|
89
|
+
* The backend's streaming listener pipeline delivers a transaction's events
|
|
90
|
+
* contiguously and in order, but the legacy pipeline can interleave
|
|
91
|
+
* concurrent transactions. So while a multi-document transaction is
|
|
92
|
+
* incomplete, mutation events from other transactions are held (in arrival
|
|
93
|
+
* order) rather than emitted, and released right after the transaction
|
|
94
|
+
* completes; released events are re-processed so a held multi-document
|
|
95
|
+
* transaction is grouped too. Per-document `previousRev` sequencing
|
|
96
|
+
* downstream tolerates the reordering this introduces.
|
|
97
|
+
*
|
|
98
|
+
* Escape hatches so a lost event degrades to ungrouped delivery instead of
|
|
99
|
+
* stalling the stream: everything is released in arrival order when a
|
|
100
|
+
* non-mutation event (e.g. reconnect/welcome) arrives, when more than
|
|
101
|
+
* `maxHeldEvents` are held, or after `flushDeadline` milliseconds.
|
|
102
|
+
* Single-event transactions pass through untouched when nothing is buffered.
|
|
103
|
+
*
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
106
|
+
export function groupTransactionEvents(
|
|
107
|
+
options?: GroupTransactionEventsOptions,
|
|
108
|
+
): MonoTypeOperatorFunction<ListenEvent<SanityDocument>> {
|
|
109
|
+
const {flushDeadline = GROUP_FLUSH_DEADLINE, maxHeldEvents = GROUP_MAX_HELD_EVENTS} =
|
|
110
|
+
options ?? {}
|
|
111
|
+
|
|
112
|
+
return (source) =>
|
|
113
|
+
new Observable<ListenEvent<SanityDocument>>((subscriber) => {
|
|
114
|
+
interface OpenTransaction {
|
|
115
|
+
transactionId: string
|
|
116
|
+
events: MutationEvent[]
|
|
117
|
+
total: number
|
|
118
|
+
timer: ReturnType<typeof setTimeout>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let open: OpenTransaction | null = null
|
|
122
|
+
let held: MutationEvent[] = []
|
|
123
|
+
|
|
124
|
+
// emits the open transaction's events (complete or not) without
|
|
125
|
+
// releasing the held queue; callers decide what happens to `held`
|
|
126
|
+
const closeOpen = () => {
|
|
127
|
+
if (!open) return
|
|
128
|
+
clearTimeout(open.timer)
|
|
129
|
+
const {events} = open
|
|
130
|
+
open = null
|
|
131
|
+
for (const event of events) subscriber.next(event)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// re-processes the held queue so a held multi-document transaction
|
|
135
|
+
// gets grouped in turn. processing may open a new transaction and top
|
|
136
|
+
// up a new held queue; each drain only touches its own snapshot, so
|
|
137
|
+
// this terminates
|
|
138
|
+
const releaseHeld = () => {
|
|
139
|
+
const queue = held
|
|
140
|
+
held = []
|
|
141
|
+
for (const event of queue) processEvent(event)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const releaseEverything = () => {
|
|
145
|
+
closeOpen()
|
|
146
|
+
const queue = held
|
|
147
|
+
held = []
|
|
148
|
+
for (const event of queue) subscriber.next(event)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const processEvent = (event: ListenEvent<SanityDocument>) => {
|
|
152
|
+
if (event.type !== 'mutation') {
|
|
153
|
+
// connection-level events (welcome/welcomeback/reset/reconnect)
|
|
154
|
+
// reset the world downstream; release everything before them
|
|
155
|
+
releaseEverything()
|
|
156
|
+
subscriber.next(event)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isMultiDocument = (event.transactionTotalEvents ?? 0) > 1
|
|
161
|
+
|
|
162
|
+
if (!open) {
|
|
163
|
+
if (!isMultiDocument) {
|
|
164
|
+
subscriber.next(event)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
// isMultiDocument guarantees total >= 2, so a freshly opened
|
|
168
|
+
// transaction is always incomplete; completion is checked when
|
|
169
|
+
// subsequent events of the same transaction arrive below
|
|
170
|
+
open = {
|
|
171
|
+
transactionId: event.transactionId,
|
|
172
|
+
events: [event],
|
|
173
|
+
total: event.transactionTotalEvents,
|
|
174
|
+
timer: setCleanupTimeout(() => {
|
|
175
|
+
closeOpen()
|
|
176
|
+
releaseHeld()
|
|
177
|
+
}, flushDeadline),
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (event.transactionId === open.transactionId) {
|
|
183
|
+
open.events.push(event)
|
|
184
|
+
if (open.events.length >= open.total) {
|
|
185
|
+
closeOpen()
|
|
186
|
+
releaseHeld()
|
|
187
|
+
}
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// an event from another transaction while one is incomplete: hold it
|
|
192
|
+
// (legacy listener pipeline interleaving) instead of giving up on the
|
|
193
|
+
// open transaction
|
|
194
|
+
held.push(event)
|
|
195
|
+
if (held.length > maxHeldEvents) releaseEverything()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const subscription = source.subscribe({
|
|
199
|
+
next: processEvent,
|
|
200
|
+
error: (error) => {
|
|
201
|
+
releaseEverything()
|
|
202
|
+
subscriber.error(error)
|
|
203
|
+
},
|
|
204
|
+
complete: () => {
|
|
205
|
+
releaseEverything()
|
|
206
|
+
subscriber.complete()
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return () => {
|
|
211
|
+
if (open) clearTimeout(open.timer)
|
|
212
|
+
open = null
|
|
213
|
+
held = []
|
|
214
|
+
subscription.unsubscribe()
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|