@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
|
@@ -4,7 +4,7 @@ import {describe, expect, it} from 'vitest'
|
|
|
4
4
|
|
|
5
5
|
import {type Action, type DocumentAction} from './actions'
|
|
6
6
|
import {ActionError, processActions} from './processActions/processActions'
|
|
7
|
-
import {type DocumentSet} from './processMutations'
|
|
7
|
+
import {type DocumentSet, processMutations} from './processMutations'
|
|
8
8
|
|
|
9
9
|
// Helper: Create a sample document that conforms to SanityDocument.
|
|
10
10
|
const createDoc = (id: string, title: string, rev: string = 'initial'): SanityDocument => ({
|
|
@@ -443,6 +443,190 @@ describe('processActions', () => {
|
|
|
443
443
|
expect(result.outgoingActions[0].actionType).toBe('sanity.action.document.edit')
|
|
444
444
|
})
|
|
445
445
|
|
|
446
|
+
it('preserves inc/dec operations instead of collapsing them into sets', () => {
|
|
447
|
+
const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5, score: 10}
|
|
448
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
449
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
450
|
+
const actions: DocumentAction[] = [
|
|
451
|
+
{
|
|
452
|
+
documentId: 'doc1',
|
|
453
|
+
documentType: 'article',
|
|
454
|
+
type: 'document.edit',
|
|
455
|
+
patches: [{inc: {count: 1}, dec: {score: 3}}],
|
|
456
|
+
},
|
|
457
|
+
]
|
|
458
|
+
const result = processActions({
|
|
459
|
+
actions,
|
|
460
|
+
transactionId,
|
|
461
|
+
base,
|
|
462
|
+
working,
|
|
463
|
+
timestamp,
|
|
464
|
+
grants: defaultGrants,
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// the local working copy reflects the applied increments
|
|
468
|
+
expect(result.working['drafts.doc1']).toMatchObject({count: 6, score: 7})
|
|
469
|
+
// the outgoing patch keeps the operational inc/dec instead of a set.
|
|
470
|
+
// the setIfMissing seeds the base value in case the field was removed
|
|
471
|
+
// concurrently: the server runs setIfMissing before inc/dec, and inc on
|
|
472
|
+
// a missing path would otherwise fail the whole transaction
|
|
473
|
+
const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
|
|
474
|
+
expect(patches).toEqual([
|
|
475
|
+
{setIfMissing: {count: 5, score: 10}, inc: {count: 1}, dec: {score: 3}},
|
|
476
|
+
])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('accumulates multiple inc/dec records on the same path into one operation', () => {
|
|
480
|
+
const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
|
|
481
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
482
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
483
|
+
const actions: DocumentAction[] = [
|
|
484
|
+
{
|
|
485
|
+
documentId: 'doc1',
|
|
486
|
+
documentType: 'article',
|
|
487
|
+
type: 'document.edit',
|
|
488
|
+
patches: [{inc: {count: 1}}, {inc: {count: 2}}, {dec: {count: 4}}],
|
|
489
|
+
},
|
|
490
|
+
]
|
|
491
|
+
const result = processActions({
|
|
492
|
+
actions,
|
|
493
|
+
transactionId,
|
|
494
|
+
base,
|
|
495
|
+
working,
|
|
496
|
+
timestamp,
|
|
497
|
+
grants: defaultGrants,
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// 5 + 1 + 2 - 4 = 4 locally
|
|
501
|
+
expect(result.working['drafts.doc1']).toMatchObject({count: 4})
|
|
502
|
+
// the three records collapse into one accumulated dec (net -1) with the
|
|
503
|
+
// base value seeded for the concurrent-removal degradation path
|
|
504
|
+
const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
|
|
505
|
+
expect(patches).toEqual([{setIfMissing: {count: 5}, dec: {count: 1}}])
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('keeps the diffed set when an inc is combined with a set of the same field', () => {
|
|
509
|
+
const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
|
|
510
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
511
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
512
|
+
const actions: DocumentAction[] = [
|
|
513
|
+
{
|
|
514
|
+
documentId: 'doc1',
|
|
515
|
+
documentType: 'article',
|
|
516
|
+
type: 'document.edit',
|
|
517
|
+
// set to 100 first, then inc: the final value (101) is not
|
|
518
|
+
// explainable by the inc alone, so it must remain a set
|
|
519
|
+
patches: [{set: {count: 100}}, {inc: {count: 1}}],
|
|
520
|
+
},
|
|
521
|
+
]
|
|
522
|
+
const result = processActions({
|
|
523
|
+
actions,
|
|
524
|
+
transactionId,
|
|
525
|
+
base,
|
|
526
|
+
working,
|
|
527
|
+
timestamp,
|
|
528
|
+
grants: defaultGrants,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
expect(result.working['drafts.doc1']).toMatchObject({count: 101})
|
|
532
|
+
const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
|
|
533
|
+
expect(patches).toEqual([{set: {count: 101}}])
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('preserves inc operations targeting keyed array items', () => {
|
|
537
|
+
const draft = {
|
|
538
|
+
...createDoc('drafts.doc1', 'Title', '1'),
|
|
539
|
+
items: [{_key: 'itemA', qty: 2}],
|
|
540
|
+
}
|
|
541
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
542
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
543
|
+
const actions: DocumentAction[] = [
|
|
544
|
+
{
|
|
545
|
+
documentId: 'doc1',
|
|
546
|
+
documentType: 'article',
|
|
547
|
+
type: 'document.edit',
|
|
548
|
+
patches: [{inc: {'items[_key=="itemA"].qty': 3}}],
|
|
549
|
+
},
|
|
550
|
+
]
|
|
551
|
+
const result = processActions({
|
|
552
|
+
actions,
|
|
553
|
+
transactionId,
|
|
554
|
+
base,
|
|
555
|
+
working,
|
|
556
|
+
timestamp,
|
|
557
|
+
grants: defaultGrants,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
expect(result.working['drafts.doc1']?.['items']).toEqual([{_key: 'itemA', qty: 5}])
|
|
561
|
+
const patches = result.outgoingActions.map((action) => 'patch' in action && action.patch)
|
|
562
|
+
expect(patches).toEqual([
|
|
563
|
+
{setIfMissing: {'items[_key=="itemA"].qty': 2}, inc: {'items[_key=="itemA"].qty': 3}},
|
|
564
|
+
])
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('preserves inc operations through the liveEdit mutation path', () => {
|
|
568
|
+
const liveDoc = {...createLiveEditDoc('live-doc', 'Title', '1'), count: 5}
|
|
569
|
+
const base: DocumentSet = {'live-doc': liveDoc}
|
|
570
|
+
const working: DocumentSet = {'live-doc': liveDoc}
|
|
571
|
+
const actions: DocumentAction[] = [
|
|
572
|
+
{
|
|
573
|
+
documentId: 'live-doc',
|
|
574
|
+
documentType: 'liveArticle',
|
|
575
|
+
type: 'document.edit',
|
|
576
|
+
liveEdit: true,
|
|
577
|
+
patches: [{inc: {count: 2}}],
|
|
578
|
+
},
|
|
579
|
+
]
|
|
580
|
+
const result = processActions({
|
|
581
|
+
actions,
|
|
582
|
+
transactionId,
|
|
583
|
+
base,
|
|
584
|
+
working,
|
|
585
|
+
timestamp,
|
|
586
|
+
grants: defaultGrants,
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
expect(result.working['live-doc']).toMatchObject({count: 7})
|
|
590
|
+
expect(result.outgoingMutations).toEqual([
|
|
591
|
+
{patch: {id: 'live-doc', setIfMissing: {count: 5}, inc: {count: 2}}},
|
|
592
|
+
])
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('degrades to the diffed set result when the inc target was removed concurrently', () => {
|
|
596
|
+
const draft = {...createDoc('drafts.doc1', 'Title', '1'), count: 5}
|
|
597
|
+
const base: DocumentSet = {'drafts.doc1': draft}
|
|
598
|
+
const working: DocumentSet = {'drafts.doc1': draft}
|
|
599
|
+
const actions: DocumentAction[] = [
|
|
600
|
+
{
|
|
601
|
+
documentId: 'doc1',
|
|
602
|
+
documentType: 'article',
|
|
603
|
+
type: 'document.edit',
|
|
604
|
+
patches: [{inc: {count: 1}}],
|
|
605
|
+
},
|
|
606
|
+
]
|
|
607
|
+
const result = processActions({
|
|
608
|
+
actions,
|
|
609
|
+
transactionId,
|
|
610
|
+
base,
|
|
611
|
+
working,
|
|
612
|
+
timestamp,
|
|
613
|
+
grants: defaultGrants,
|
|
614
|
+
})
|
|
615
|
+
const patch = result.outgoingActions.map((action) => 'patch' in action && action.patch)[0]
|
|
616
|
+
|
|
617
|
+
// simulate the server applying the emitted patch to a document where
|
|
618
|
+
// another client removed the counter in the meantime: setIfMissing
|
|
619
|
+
// seeds the base value, then inc applies, matching what the collapsed
|
|
620
|
+
// set would have written (instead of inc failing the transaction)
|
|
621
|
+
const {count: _count, ...draftWithoutCount} = draft
|
|
622
|
+
const serverResult = processMutations({
|
|
623
|
+
documents: {'drafts.doc1': draftWithoutCount as SanityDocument},
|
|
624
|
+
mutations: [{patch: {id: 'drafts.doc1', ...(patch as object)}}],
|
|
625
|
+
transactionId: 'txn-server',
|
|
626
|
+
})
|
|
627
|
+
expect(serverResult['drafts.doc1']).toMatchObject({count: 6})
|
|
628
|
+
})
|
|
629
|
+
|
|
446
630
|
it('should edit a document when base and working diverge', () => {
|
|
447
631
|
const publishedBase = createDoc('doc1', 'Original Boring Title')
|
|
448
632
|
const publishedWorking = createDoc('doc1', 'Local Boring Title')
|
|
@@ -696,6 +696,129 @@ describe('applyRemoteDocument', () => {
|
|
|
696
696
|
expect(newDocState?.unverifiedRevisions?.['txn10']).toBeUndefined()
|
|
697
697
|
})
|
|
698
698
|
|
|
699
|
+
describe('fast-forward convergence of local', () => {
|
|
700
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
701
|
+
const otherDocId = getDraftId(DocumentId('doc2'))
|
|
702
|
+
|
|
703
|
+
const makeApplied = (overrides: Partial<AppliedTransaction>): AppliedTransaction => ({
|
|
704
|
+
transactionId: 'txnPending',
|
|
705
|
+
actions: [],
|
|
706
|
+
working: {},
|
|
707
|
+
previous: {},
|
|
708
|
+
base: {},
|
|
709
|
+
previousRevs: {},
|
|
710
|
+
timestamp: '2025-02-06T00:06:30.000Z',
|
|
711
|
+
outgoingActions: [],
|
|
712
|
+
outgoingMutations: [],
|
|
713
|
+
...overrides,
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
const makeState = (applied: AppliedTransaction[]): SyncTransactionState => ({
|
|
717
|
+
queued: [],
|
|
718
|
+
applied,
|
|
719
|
+
outgoing: undefined,
|
|
720
|
+
grants,
|
|
721
|
+
documentStates: {
|
|
722
|
+
[docId]: {
|
|
723
|
+
id: docId,
|
|
724
|
+
subscriptions: ['sub1'],
|
|
725
|
+
local: {...exampleDoc, _id: docId, foo: 'optimistic'},
|
|
726
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
727
|
+
unverifiedRevisions: {
|
|
728
|
+
txn10: {
|
|
729
|
+
transactionId: 'txn10',
|
|
730
|
+
documentId: docId,
|
|
731
|
+
previousRev: 'revOld',
|
|
732
|
+
timestamp: '2025-02-06T00:06:00.000Z',
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const remote: RemoteDocument = {
|
|
740
|
+
type: 'sync',
|
|
741
|
+
documentId: docId,
|
|
742
|
+
document: {...exampleDoc, _id: docId, foo: 'server-truth'},
|
|
743
|
+
revision: 'txn10',
|
|
744
|
+
previousRev: 'revOld',
|
|
745
|
+
timestamp: '2025-02-06T00:07:00.000Z',
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
it('converges local to the server document when nothing else is pending', () => {
|
|
749
|
+
const events = new Subject<DocumentEvent>()
|
|
750
|
+
const newState = applyRemoteDocument(makeState([]), remote, events)
|
|
751
|
+
expect(newState.documentStates[docId]?.local).toEqual(remote.document)
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it('converges local when pending work modifies only other documents', () => {
|
|
755
|
+
// a pending applied transaction on doc2 must not block convergence of
|
|
756
|
+
// doc1: nothing would ever revisit doc1's local otherwise
|
|
757
|
+
const otherDoc = {...exampleDoc, _id: otherDocId}
|
|
758
|
+
const pendingOnOtherDoc = makeApplied({
|
|
759
|
+
working: {[otherDocId]: {...otherDoc, _rev: 'txnPending'}},
|
|
760
|
+
previous: {[otherDocId]: otherDoc},
|
|
761
|
+
base: {[otherDocId]: otherDoc},
|
|
762
|
+
previousRevs: {[otherDocId]: 'txn0'},
|
|
763
|
+
})
|
|
764
|
+
const events = new Subject<DocumentEvent>()
|
|
765
|
+
const newState = applyRemoteDocument(makeState([pendingOnOtherDoc]), remote, events)
|
|
766
|
+
expect(newState.documentStates[docId]?.local).toEqual(remote.document)
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it('converges local when pending work carries this document unmodified', () => {
|
|
770
|
+
// a draft edit carries the published document in its working set
|
|
771
|
+
// without changing it; presence alone must not block convergence
|
|
772
|
+
const thisDoc = {...exampleDoc, _id: docId}
|
|
773
|
+
const pendingCarryingUnmodified = makeApplied({
|
|
774
|
+
working: {
|
|
775
|
+
[docId]: thisDoc,
|
|
776
|
+
[otherDocId]: {...exampleDoc, _id: otherDocId, _rev: 'txnPending'},
|
|
777
|
+
},
|
|
778
|
+
previous: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
|
|
779
|
+
base: {[docId]: thisDoc, [otherDocId]: {...exampleDoc, _id: otherDocId}},
|
|
780
|
+
previousRevs: {[docId]: thisDoc._rev, [otherDocId]: 'txn0'},
|
|
781
|
+
})
|
|
782
|
+
const events = new Subject<DocumentEvent>()
|
|
783
|
+
const newState = applyRemoteDocument(makeState([pendingCarryingUnmodified]), remote, events)
|
|
784
|
+
expect(newState.documentStates[docId]?.local).toEqual(remote.document)
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('keeps the optimistic local when pending work modifies this document', () => {
|
|
788
|
+
const optimistic = {...exampleDoc, _id: docId, _rev: 'txnPending', foo: 'optimistic'}
|
|
789
|
+
const pendingOnThisDoc = makeApplied({
|
|
790
|
+
working: {[docId]: optimistic},
|
|
791
|
+
previous: {[docId]: {...exampleDoc, _id: docId}},
|
|
792
|
+
base: {[docId]: {...exampleDoc, _id: docId}},
|
|
793
|
+
previousRevs: {[docId]: 'txn0'},
|
|
794
|
+
})
|
|
795
|
+
const state = makeState([pendingOnThisDoc])
|
|
796
|
+
const events = new Subject<DocumentEvent>()
|
|
797
|
+
const newState = applyRemoteDocument(state, remote, events)
|
|
798
|
+
expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
|
|
799
|
+
expect(newState.documentStates[docId]?.remote).toEqual(remote.document)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it('keeps the optimistic local when a different in-flight transaction modifies this document', () => {
|
|
803
|
+
const optimistic = {...exampleDoc, _id: docId, _rev: 'txnOutgoing', foo: 'optimistic'}
|
|
804
|
+
const outgoing = {
|
|
805
|
+
...makeApplied({
|
|
806
|
+
transactionId: 'txnOutgoing',
|
|
807
|
+
working: {[docId]: optimistic},
|
|
808
|
+
previous: {[docId]: {...exampleDoc, _id: docId}},
|
|
809
|
+
base: {[docId]: {...exampleDoc, _id: docId}},
|
|
810
|
+
previousRevs: {[docId]: 'txn0'},
|
|
811
|
+
}),
|
|
812
|
+
disableBatching: false,
|
|
813
|
+
batchedTransactionIds: ['txnOutgoing'],
|
|
814
|
+
}
|
|
815
|
+
const state = {...makeState([]), outgoing}
|
|
816
|
+
const events = new Subject<DocumentEvent>()
|
|
817
|
+
const newState = applyRemoteDocument(state, remote, events)
|
|
818
|
+
expect(newState.documentStates[docId]?.local).toBe(state.documentStates[docId]?.local)
|
|
819
|
+
})
|
|
820
|
+
})
|
|
821
|
+
|
|
699
822
|
it('rebases local changes when no matching unverified revision is found', () => {
|
|
700
823
|
// In this branch we simply let processActions rebase so that the local becomes the remote.
|
|
701
824
|
const docId = getDraftId(DocumentId('doc1'))
|
package/src/document/reducers.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {type DocumentHandle} from '../config/sanityConfig'
|
|
|
5
5
|
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
6
6
|
import {type StoreContext} from '../store/defineStore'
|
|
7
7
|
import {insecureRandomId} from '../utils/ids'
|
|
8
|
-
import {omitProperty} from '../utils/object'
|
|
8
|
+
import {isDeepEqual, omitProperty} from '../utils/object'
|
|
9
9
|
import {setCleanupTimeout} from '../utils/setCleanupTimeout'
|
|
10
10
|
import {type Action} from './actions'
|
|
11
11
|
import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
|
|
@@ -534,6 +534,34 @@ export function applyRemoteDocument(
|
|
|
534
534
|
// matches the previous revision we expected, we can "fast-forward" and skip
|
|
535
535
|
// rebasing local changes on top of this new base
|
|
536
536
|
if (revisionToVerify && revisionToVerify.previousRev === previousRev) {
|
|
537
|
+
// even on a clean fast-forward, the server's result can differ from our
|
|
538
|
+
// optimistic prediction: a publish derives the published content from the
|
|
539
|
+
// server's copy of the draft, which may carry concurrent edits our
|
|
540
|
+
// prediction never saw. once nothing else is pending locally for this
|
|
541
|
+
// document (no applied transaction and no in-flight transaction, other
|
|
542
|
+
// than the one this event just confirmed, modifies it), converge `local`
|
|
543
|
+
// to the server's document. while local work that modifies this document
|
|
544
|
+
// is pending, keep the optimistic `local` so those changes stay visible;
|
|
545
|
+
// that work's own echo (or rebase) recomputes `local` later.
|
|
546
|
+
//
|
|
547
|
+
// "modifies" matters: pending work on other documents must not block
|
|
548
|
+
// convergence here (nothing would ever revisit this document's `local`),
|
|
549
|
+
// and a pending draft edit carries the published document in its working
|
|
550
|
+
// set without changing it, so a presence check would block published-doc
|
|
551
|
+
// convergence whenever a draft edit is pending. this is the same
|
|
552
|
+
// modified-test `transitionAppliedTransactionsToOutgoing` uses
|
|
553
|
+
const modifiesDocument = (transaction: AppliedTransaction) =>
|
|
554
|
+
transaction.working[documentId]?._rev !== transaction.previousRevs[documentId]
|
|
555
|
+
const nothingElsePending =
|
|
556
|
+
!prev.applied.some(modifiesDocument) &&
|
|
557
|
+
(!prev.outgoing ||
|
|
558
|
+
prev.outgoing.transactionId === revision ||
|
|
559
|
+
!modifiesDocument(prev.outgoing))
|
|
560
|
+
const local =
|
|
561
|
+
nothingElsePending && !isDeepEqual(prevDocState.local, document)
|
|
562
|
+
? document
|
|
563
|
+
: prevDocState.local
|
|
564
|
+
|
|
537
565
|
return {
|
|
538
566
|
...prev,
|
|
539
567
|
documentStates: {
|
|
@@ -542,6 +570,7 @@ export function applyRemoteDocument(
|
|
|
542
570
|
...prevDocState,
|
|
543
571
|
remote: document,
|
|
544
572
|
remoteRev: revision,
|
|
573
|
+
local,
|
|
545
574
|
unverifiedRevisions,
|
|
546
575
|
},
|
|
547
576
|
},
|
|
@@ -592,6 +621,12 @@ export function applyRemoteDocument(
|
|
|
592
621
|
}
|
|
593
622
|
}
|
|
594
623
|
|
|
624
|
+
// when the recompute lands on a value deep-equal to the current local
|
|
625
|
+
// (the common case for echoes of our own transactions), keep the previous
|
|
626
|
+
// reference so subscribers don't re-render for an identical value
|
|
627
|
+
const nextLocal = working[documentId]
|
|
628
|
+
const local = isDeepEqual(prevDocState.local, nextLocal) ? prevDocState.local : nextLocal
|
|
629
|
+
|
|
595
630
|
return {
|
|
596
631
|
...prev,
|
|
597
632
|
applied: nextApplied,
|
|
@@ -601,7 +636,7 @@ export function applyRemoteDocument(
|
|
|
601
636
|
...prevDocState,
|
|
602
637
|
remote: document,
|
|
603
638
|
remoteRev: revision,
|
|
604
|
-
local
|
|
639
|
+
local,
|
|
605
640
|
unverifiedRevisions,
|
|
606
641
|
},
|
|
607
642
|
},
|
|
@@ -49,7 +49,8 @@ describe('createSharedListener', () => {
|
|
|
49
49
|
'*',
|
|
50
50
|
{},
|
|
51
51
|
{
|
|
52
|
-
events: ['mutation', 'welcome', 'reconnect'],
|
|
52
|
+
events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
|
|
53
|
+
enableResume: true,
|
|
53
54
|
includeResult: false,
|
|
54
55
|
includeAllVersions: true,
|
|
55
56
|
tag: 'document-listener',
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {getClientState} from '../client/clientStore'
|
|
17
17
|
import {type DocumentResource} from '../config/sanityConfig'
|
|
18
18
|
import {type SanityInstance} from '../store/createSanityInstance'
|
|
19
|
+
import {dedupeListenerEvents, groupTransactionEvents} from './listenerEventOperators'
|
|
19
20
|
|
|
20
21
|
const API_VERSION = 'v2025-05-06'
|
|
21
22
|
|
|
@@ -41,7 +42,12 @@ export function createSharedListener(
|
|
|
41
42
|
'*',
|
|
42
43
|
{},
|
|
43
44
|
{
|
|
44
|
-
|
|
45
|
+
// with `enableResume`, a reconnect replays the events missed while
|
|
46
|
+
// offline (emitting `welcomeback`) instead of forcing a refetch of
|
|
47
|
+
// every subscribed document. when the backend cannot resume, it
|
|
48
|
+
// emits `welcome` or `reset`, both of which trigger a refetch
|
|
49
|
+
events: ['mutation', 'welcome', 'welcomeback', 'reconnect', 'reset'],
|
|
50
|
+
enableResume: true,
|
|
45
51
|
includeResult: false,
|
|
46
52
|
// the document store handles version documents, so we need to include all versions
|
|
47
53
|
includeAllVersions: true,
|
|
@@ -53,6 +59,11 @@ export function createSharedListener(
|
|
|
53
59
|
},
|
|
54
60
|
),
|
|
55
61
|
),
|
|
62
|
+
// resumed events are delivered at-least-once, so drop replayed duplicates
|
|
63
|
+
dedupeListenerEvents(),
|
|
64
|
+
// hold multi-document transactions (e.g. publish) until complete so
|
|
65
|
+
// per-document processing never observes half a transaction
|
|
66
|
+
groupTransactionEvents(),
|
|
56
67
|
takeUntil(dispose$),
|
|
57
68
|
share(),
|
|
58
69
|
)
|