@sanity/sdk 2.17.0 → 2.18.0
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-es/version.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +207 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/document/documentStore.concurrency.test.ts +1015 -0
- package/src/document/documentStore.test.ts +11 -0
- package/src/document/documentStore.ts +0 -1
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +65 -20
- 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/processActions/edit.ts +9 -2
- package/src/document/processActions/shared.ts +111 -1
- package/src/document/processActions.test.ts +185 -1
- package/src/document/reducers.test.ts +123 -0
- package/src/document/reducers.ts +37 -2
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
|
@@ -906,6 +906,17 @@ it('fetches dataset ACL and updates grants in the document store state', async (
|
|
|
906
906
|
})
|
|
907
907
|
})
|
|
908
908
|
|
|
909
|
+
it('does not send credentials with the dataset ACL request', async () => {
|
|
910
|
+
const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
|
|
911
|
+
await resolvePermissions(instance, {actions: [createDocument(doc)]})
|
|
912
|
+
|
|
913
|
+
const aclCall = vi
|
|
914
|
+
.mocked(client.request)
|
|
915
|
+
.mock.calls.find(([options]) => options.uri?.endsWith('/acl'))
|
|
916
|
+
expect(aclCall).toBeDefined()
|
|
917
|
+
expect(aclCall![0]).not.toHaveProperty('withCredentials')
|
|
918
|
+
})
|
|
919
|
+
|
|
909
920
|
it('fetches ACL for MediaLibraryResource', async () => {
|
|
910
921
|
const mediaLibraryInstance = createSanityInstance({
|
|
911
922
|
projectId: 'p',
|
|
@@ -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
|
})
|
package/src/document/listen.ts
CHANGED
|
@@ -97,11 +97,50 @@ interface SortListenerEventsOptions {
|
|
|
97
97
|
resolveChainDeadline?: number
|
|
98
98
|
}
|
|
99
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
|
+
|
|
100
135
|
/**
|
|
101
136
|
* Takes an input observable of listener events that might arrive out of order, and emits them in sequence
|
|
102
137
|
* If we receive mutation events that doesn't line up in [previousRev, resultRev] pairs we'll put them in a buffer and
|
|
103
138
|
* check if we have an unbroken chain every time we receive a new event
|
|
104
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
|
+
*
|
|
105
144
|
* If the buffer grows beyond `maxBufferSize`, or if `resolveChainDeadline` milliseconds passes before the chain resolves
|
|
106
145
|
* an OutOfSyncError will be thrown on the stream
|
|
107
146
|
*
|
|
@@ -132,38 +171,40 @@ export function sortListenerEvents(options?: SortListenerEventsOptions) {
|
|
|
132
171
|
'Invalid state. Cannot process mutation event without a base sync event',
|
|
133
172
|
)
|
|
134
173
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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()
|
|
140
186
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const [next] = buffer.splice(idx, 1)
|
|
149
|
-
emitEvents.push(next)
|
|
150
|
-
// If the mutation is a deletion, the new base revision is undefined.
|
|
151
|
-
baseRevision = next.transition === 'disappear' ? undefined : next.resultRev
|
|
152
|
-
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,
|
|
153
194
|
}
|
|
154
195
|
}
|
|
155
196
|
|
|
156
197
|
if (buffer.length >= maxBufferSize) {
|
|
157
198
|
throw new MaxBufferExceededError(
|
|
158
199
|
`Too many unchainable mutation events (${buffer.length}) waiting to resolve.`,
|
|
159
|
-
{base: {revision: baseRevision}, buffer, emitEvents},
|
|
200
|
+
{base: {revision: baseRevision}, buffer, emitEvents: []},
|
|
160
201
|
)
|
|
161
202
|
}
|
|
162
203
|
|
|
163
204
|
return {
|
|
164
205
|
base: {revision: baseRevision},
|
|
165
206
|
buffer,
|
|
166
|
-
emitEvents,
|
|
207
|
+
emitEvents: [],
|
|
167
208
|
}
|
|
168
209
|
}
|
|
169
210
|
// Any other event is simply forwarded.
|
|
@@ -208,7 +249,11 @@ export const listen = (
|
|
|
208
249
|
|
|
209
250
|
return sharedListener.events.pipe(
|
|
210
251
|
concatMap((e) => {
|
|
211
|
-
|
|
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') {
|
|
212
257
|
return fetchDocument(documentId).pipe(
|
|
213
258
|
map((document): SyncEvent => ({type: 'sync', document})),
|
|
214
259
|
)
|
|
@@ -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
|
+
})
|