@sanity/sdk 2.16.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 +69 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +282 -52
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/_exports/index.ts +2 -0
- package/src/document/actions.ts +36 -0
- package/src/document/documentStore.concurrency.test.ts +1015 -0
- package/src/document/documentStore.test.ts +143 -1
- package/src/document/documentStore.ts +8 -1
- package/src/document/events.ts +32 -0
- package/src/document/listen.test.ts +56 -0
- package/src/document/listen.ts +73 -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 +26 -14
- package/src/document/processActions/shared.ts +165 -8
- package/src/document/processActions.test.ts +330 -1
- package/src/document/reducers.test.ts +519 -0
- package/src/document/reducers.ts +99 -3
- package/src/document/sharedListener.test.ts +2 -1
- package/src/document/sharedListener.ts +12 -1
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
resolvePermissions,
|
|
42
42
|
subscribeDocumentEvents,
|
|
43
43
|
} from './documentStore'
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type ActionErrorEvent,
|
|
46
|
+
type DocumentRemotePatchesEvent,
|
|
47
|
+
type TransactionRevertedEvent,
|
|
48
|
+
} from './events'
|
|
45
49
|
import {DEFAULT_MAX_BUFFER_SIZE} from './listen'
|
|
46
50
|
import {type DatasetAcl} from './permissions'
|
|
47
51
|
import {type DocumentSet, processMutations} from './processMutations'
|
|
@@ -281,6 +285,66 @@ it('propagates changes between two instances', async () => {
|
|
|
281
285
|
state2Unsubscribe()
|
|
282
286
|
})
|
|
283
287
|
|
|
288
|
+
it('emits remote-patches events distinguishing own and foreign transactions', async () => {
|
|
289
|
+
const doc = createDocumentHandle({documentId: 'doc-remote-patches', documentType: 'article'})
|
|
290
|
+
const state1 = getDocumentState(instance1, doc)
|
|
291
|
+
const state2 = getDocumentState(instance2, doc)
|
|
292
|
+
|
|
293
|
+
const state1Unsubscribe = state1.subscribe()
|
|
294
|
+
const state2Unsubscribe = state2.subscribe()
|
|
295
|
+
|
|
296
|
+
await applyDocumentActions(instance1, {actions: [createDocument(doc)], resource: source1}).then(
|
|
297
|
+
(r) => r.submitted(),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
const events1: DocumentRemotePatchesEvent[] = []
|
|
301
|
+
const events2: DocumentRemotePatchesEvent[] = []
|
|
302
|
+
const unsubscribeEvents1 = subscribeDocumentEvents(instance1, {
|
|
303
|
+
resource: source1,
|
|
304
|
+
eventHandler: (e) => {
|
|
305
|
+
if (e.type === 'remote-patches') events1.push(e)
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
const unsubscribeEvents2 = subscribeDocumentEvents(instance2, {
|
|
309
|
+
resource: source2,
|
|
310
|
+
eventHandler: (e) => {
|
|
311
|
+
if (e.type === 'remote-patches') events2.push(e)
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// edit from instance2: an own transaction for instance2, a foreign one for instance1
|
|
316
|
+
const result = await applyDocumentActions(instance2, {
|
|
317
|
+
actions: [editDocument(doc, {set: {title: 'Hello world!'}})],
|
|
318
|
+
resource: source2,
|
|
319
|
+
}).then((r) => r.submitted())
|
|
320
|
+
|
|
321
|
+
const draftId = getDraftId(DocumentId(doc.documentId))
|
|
322
|
+
|
|
323
|
+
expect(events1).toHaveLength(1)
|
|
324
|
+
expect(events1[0]).toMatchObject({
|
|
325
|
+
type: 'remote-patches',
|
|
326
|
+
documentId: draftId,
|
|
327
|
+
transactionId: result.transactionId,
|
|
328
|
+
origin: 'remote',
|
|
329
|
+
})
|
|
330
|
+
expect(events1[0].patches).toEqual(
|
|
331
|
+
expect.arrayContaining([{set: expect.objectContaining({title: 'Hello world!'})}]),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
expect(events2).toHaveLength(1)
|
|
335
|
+
expect(events2[0]).toMatchObject({
|
|
336
|
+
type: 'remote-patches',
|
|
337
|
+
documentId: draftId,
|
|
338
|
+
transactionId: result.transactionId,
|
|
339
|
+
origin: 'local',
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
unsubscribeEvents1()
|
|
343
|
+
unsubscribeEvents2()
|
|
344
|
+
state1Unsubscribe()
|
|
345
|
+
state2Unsubscribe()
|
|
346
|
+
})
|
|
347
|
+
|
|
284
348
|
it('handles concurrent edits and resolves conflicts', async () => {
|
|
285
349
|
const doc = createDocumentHandle({documentId: 'doc-concurrent', documentType: 'article'})
|
|
286
350
|
const state1 = getDocumentState(instance1, doc)
|
|
@@ -325,6 +389,73 @@ it('handles concurrent edits and resolves conflicts', async () => {
|
|
|
325
389
|
oneOffInstance.dispose()
|
|
326
390
|
})
|
|
327
391
|
|
|
392
|
+
it('interleaves concurrent keyed-array edits when operations are preserved', async () => {
|
|
393
|
+
const doc = createDocumentHandle({documentId: 'doc-preserve-ops', documentType: 'article'})
|
|
394
|
+
const state1 = getDocumentState(instance1, doc)
|
|
395
|
+
const state2 = getDocumentState(instance2, doc)
|
|
396
|
+
|
|
397
|
+
const state1Unsubscribe = state1.subscribe()
|
|
398
|
+
const state2Unsubscribe = state2.subscribe()
|
|
399
|
+
|
|
400
|
+
const oneOffInstance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
401
|
+
await applyDocumentActions(oneOffInstance, {
|
|
402
|
+
actions: [
|
|
403
|
+
createDocument(doc),
|
|
404
|
+
editDocument(doc, {
|
|
405
|
+
set: {
|
|
406
|
+
items: [
|
|
407
|
+
{_key: 'a', value: 'first'},
|
|
408
|
+
{_key: 'b', value: 'second'},
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
],
|
|
413
|
+
resource,
|
|
414
|
+
}).then((r) => r.submitted())
|
|
415
|
+
|
|
416
|
+
// both instances insert into the same keyed array simultaneously, each
|
|
417
|
+
// preserving their operational patches instead of re-diffing snapshots
|
|
418
|
+
const p1 = applyDocumentActions(instance1, {
|
|
419
|
+
actions: [
|
|
420
|
+
editDocument(
|
|
421
|
+
doc,
|
|
422
|
+
{insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'from instance1'}]}},
|
|
423
|
+
{preserveOperations: true},
|
|
424
|
+
),
|
|
425
|
+
],
|
|
426
|
+
resource: source1,
|
|
427
|
+
}).then((r) => r.submitted())
|
|
428
|
+
const p2 = applyDocumentActions(instance2, {
|
|
429
|
+
actions: [
|
|
430
|
+
editDocument(
|
|
431
|
+
doc,
|
|
432
|
+
{insert: {after: 'items[_key=="b"]', items: [{_key: 'd', value: 'from instance2'}]}},
|
|
433
|
+
{preserveOperations: true},
|
|
434
|
+
),
|
|
435
|
+
],
|
|
436
|
+
resource: source2,
|
|
437
|
+
}).then((r) => r.submitted())
|
|
438
|
+
|
|
439
|
+
await Promise.allSettled([p1, p2])
|
|
440
|
+
|
|
441
|
+
const finalDoc1 = state1.getCurrent()
|
|
442
|
+
const finalDoc2 = state2.getCurrent()
|
|
443
|
+
|
|
444
|
+
// neither edit overwrote the other: both keyed inserts landed and both
|
|
445
|
+
// instances converged on the same interleaved result
|
|
446
|
+
expect(finalDoc1?.['items']).toEqual(finalDoc2?.['items'])
|
|
447
|
+
expect(finalDoc1?.['items']).toEqual([
|
|
448
|
+
{_key: 'a', value: 'first'},
|
|
449
|
+
{_key: 'c', value: 'from instance1'},
|
|
450
|
+
{_key: 'b', value: 'second'},
|
|
451
|
+
{_key: 'd', value: 'from instance2'},
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
state1Unsubscribe()
|
|
455
|
+
state2Unsubscribe()
|
|
456
|
+
oneOffInstance.dispose()
|
|
457
|
+
})
|
|
458
|
+
|
|
328
459
|
it('unpublishes and discards a document', async () => {
|
|
329
460
|
const documentId = DocumentId('doc-pub-unpub')
|
|
330
461
|
const doc = createDocumentHandle({documentId, documentType: 'article'})
|
|
@@ -775,6 +906,17 @@ it('fetches dataset ACL and updates grants in the document store state', async (
|
|
|
775
906
|
})
|
|
776
907
|
})
|
|
777
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
|
+
|
|
778
920
|
it('fetches ACL for MediaLibraryResource', async () => {
|
|
779
921
|
const mediaLibraryInstance = createSanityInstance({
|
|
780
922
|
projectId: 'p',
|
|
@@ -133,6 +133,14 @@ export interface DocumentState {
|
|
|
133
133
|
* local transactions on top of this new remote.
|
|
134
134
|
*/
|
|
135
135
|
unverifiedRevisions?: {[TTransactionId in string]?: UnverifiedDocumentRevision}
|
|
136
|
+
/**
|
|
137
|
+
* transaction IDs recently submitted by this client for this document,
|
|
138
|
+
* newest last. used to label `remote-patches` events with the correct
|
|
139
|
+
* `origin` even after the corresponding `unverifiedRevisions` entry has
|
|
140
|
+
* been pruned (e.g. by a sync event that raced the listener echo). capped
|
|
141
|
+
* to the most recent entries.
|
|
142
|
+
*/
|
|
143
|
+
recentOwnTransactionIds?: string[]
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
export const documentStore = defineStore<DocumentStoreState, BoundResourceKey>({
|
|
@@ -557,7 +565,6 @@ const subscribeToClientAndFetchDatasetAcl = ({
|
|
|
557
565
|
client.observable.request<DatasetAcl>({
|
|
558
566
|
uri,
|
|
559
567
|
tag: 'acl.get',
|
|
560
|
-
withCredentials: true,
|
|
561
568
|
}),
|
|
562
569
|
),
|
|
563
570
|
tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
|
package/src/document/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {type MultipleMutationResult, type SanityClient} from '@sanity/client'
|
|
2
|
+
import {type PatchOperations} from '@sanity/types'
|
|
2
3
|
|
|
3
4
|
import {type DocumentAction, type ReleaseAction} from './actions'
|
|
4
5
|
import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
|
|
@@ -21,6 +22,7 @@ export type DocumentEvent =
|
|
|
21
22
|
| DocumentPublishedEvent
|
|
22
23
|
| DocumentUnpublishedEvent
|
|
23
24
|
| DocumentDiscardedEvent
|
|
25
|
+
| DocumentRemotePatchesEvent
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @beta
|
|
@@ -119,6 +121,36 @@ export interface DocumentDiscardedEvent {
|
|
|
119
121
|
outgoing: OutgoingTransaction
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* @beta
|
|
126
|
+
* Event emitted when patches for a document are observed through the listener.
|
|
127
|
+
*
|
|
128
|
+
* Unlike the whole-document snapshots the store keeps, these are the raw patch
|
|
129
|
+
* operations from the transaction that produced the change, preserving the
|
|
130
|
+
* operational intent of the edit (e.g. keyed array inserts/unsets). Consumers
|
|
131
|
+
* that maintain their own local state, like collaborative text editors, can
|
|
132
|
+
* apply these directly instead of diffing document snapshots.
|
|
133
|
+
*
|
|
134
|
+
* `origin` is `'local'` when the transaction was submitted by this document
|
|
135
|
+
* store instance (an own edit observed back through the listener) and
|
|
136
|
+
* `'remote'` when it came from another client.
|
|
137
|
+
*/
|
|
138
|
+
export interface DocumentRemotePatchesEvent {
|
|
139
|
+
type: 'remote-patches'
|
|
140
|
+
/** The ID of the document version the patches apply to (e.g. a draft ID). */
|
|
141
|
+
documentId: string
|
|
142
|
+
/** The transaction ID that carried these patches. */
|
|
143
|
+
transactionId: string
|
|
144
|
+
/** The revision the document was at before this transaction. */
|
|
145
|
+
previousRev?: string
|
|
146
|
+
/** The timestamp of the transaction. */
|
|
147
|
+
timestamp: string
|
|
148
|
+
/** The patch operations, rooted at the document. */
|
|
149
|
+
patches: PatchOperations[]
|
|
150
|
+
/** Whether the transaction originated from this client or another one. */
|
|
151
|
+
origin: 'local' | 'remote'
|
|
152
|
+
}
|
|
153
|
+
|
|
122
154
|
// Release actions that write a mutation to the local release doc map onto
|
|
123
155
|
// the regular per-document events with `documentId = '_.releases.<releaseId>'`.
|
|
124
156
|
// The other release actions (publish/schedule/unschedule/archive/unarchive)
|
|
@@ -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
|
@@ -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
|
}),
|