@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
|
@@ -497,6 +497,9 @@ describe('transitionAppliedTransactionsToOutgoing', () => {
|
|
|
497
497
|
expect(docState?.unverifiedRevisions).toBeDefined()
|
|
498
498
|
expect(docState?.unverifiedRevisions?.['txn7']).toBeDefined()
|
|
499
499
|
expect(docState?.unverifiedRevisions?.['txn7']?.previousRev).toEqual('rev1')
|
|
500
|
+
// the transaction ID is also tracked durably for remote-patches origin
|
|
501
|
+
// labeling, surviving unverified-revision pruning
|
|
502
|
+
expect(docState?.recentOwnTransactionIds).toEqual(['txn7'])
|
|
500
503
|
})
|
|
501
504
|
})
|
|
502
505
|
|
|
@@ -693,6 +696,129 @@ describe('applyRemoteDocument', () => {
|
|
|
693
696
|
expect(newDocState?.unverifiedRevisions?.['txn10']).toBeUndefined()
|
|
694
697
|
})
|
|
695
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
|
+
|
|
696
822
|
it('rebases local changes when no matching unverified revision is found', () => {
|
|
697
823
|
// In this branch we simply let processActions rebase so that the local becomes the remote.
|
|
698
824
|
const docId = getDraftId(DocumentId('doc1'))
|
|
@@ -794,6 +920,399 @@ describe('applyRemoteDocument', () => {
|
|
|
794
920
|
expect(newDocState?.remote).toEqual(remote.document)
|
|
795
921
|
expect(newDocState?.remoteRev).toBe('currentRev')
|
|
796
922
|
})
|
|
923
|
+
|
|
924
|
+
describe('remote-patches events', () => {
|
|
925
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
926
|
+
|
|
927
|
+
function createInitialState(
|
|
928
|
+
unverifiedRevisions: NonNullable<
|
|
929
|
+
SyncTransactionState['documentStates'][string]
|
|
930
|
+
>['unverifiedRevisions'] = {},
|
|
931
|
+
recentOwnTransactionIds?: string[],
|
|
932
|
+
): SyncTransactionState {
|
|
933
|
+
return {
|
|
934
|
+
queued: [],
|
|
935
|
+
applied: [],
|
|
936
|
+
outgoing: undefined,
|
|
937
|
+
grants,
|
|
938
|
+
documentStates: {
|
|
939
|
+
[docId]: {
|
|
940
|
+
id: docId,
|
|
941
|
+
subscriptions: ['sub1'],
|
|
942
|
+
local: {...exampleDoc, _id: docId, foo: 'old'},
|
|
943
|
+
remote: {...exampleDoc, _id: docId, foo: 'old'},
|
|
944
|
+
unverifiedRevisions,
|
|
945
|
+
...(recentOwnTransactionIds && {recentOwnTransactionIds}),
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
it('emits a remote-patches event with origin "remote" for foreign transactions', () => {
|
|
952
|
+
const events = new Subject<DocumentEvent>()
|
|
953
|
+
const received: DocumentEvent[] = []
|
|
954
|
+
events.subscribe((e) => received.push(e))
|
|
955
|
+
|
|
956
|
+
const remote: RemoteDocument = {
|
|
957
|
+
type: 'mutation',
|
|
958
|
+
documentId: docId,
|
|
959
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
960
|
+
revision: 'txnForeign',
|
|
961
|
+
previousRev: 'txn0',
|
|
962
|
+
timestamp: '2025-02-06T00:12:00.000Z',
|
|
963
|
+
mutations: [
|
|
964
|
+
{
|
|
965
|
+
patch: {
|
|
966
|
+
id: docId,
|
|
967
|
+
unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]'],
|
|
968
|
+
},
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
patch: {
|
|
972
|
+
id: docId,
|
|
973
|
+
insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]},
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
],
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
applyRemoteDocument(createInitialState(), remote, events)
|
|
980
|
+
|
|
981
|
+
expect(received).toEqual([
|
|
982
|
+
{
|
|
983
|
+
type: 'remote-patches',
|
|
984
|
+
documentId: docId,
|
|
985
|
+
transactionId: 'txnForeign',
|
|
986
|
+
previousRev: 'txn0',
|
|
987
|
+
timestamp: '2025-02-06T00:12:00.000Z',
|
|
988
|
+
origin: 'remote',
|
|
989
|
+
patches: [
|
|
990
|
+
{unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]']},
|
|
991
|
+
{insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]}},
|
|
992
|
+
],
|
|
993
|
+
},
|
|
994
|
+
])
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
it('emits origin "local" when the transaction is one of our own unverified revisions', () => {
|
|
998
|
+
const events = new Subject<DocumentEvent>()
|
|
999
|
+
const received: DocumentEvent[] = []
|
|
1000
|
+
events.subscribe((e) => received.push(e))
|
|
1001
|
+
|
|
1002
|
+
const remote: RemoteDocument = {
|
|
1003
|
+
type: 'mutation',
|
|
1004
|
+
documentId: docId,
|
|
1005
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
1006
|
+
revision: 'txnOwn',
|
|
1007
|
+
previousRev: 'txn0',
|
|
1008
|
+
timestamp: '2025-02-06T00:13:00.000Z',
|
|
1009
|
+
mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
applyRemoteDocument(
|
|
1013
|
+
createInitialState({
|
|
1014
|
+
txnOwn: {
|
|
1015
|
+
transactionId: 'txnOwn',
|
|
1016
|
+
documentId: docId,
|
|
1017
|
+
previousRev: 'txn0',
|
|
1018
|
+
timestamp: '2025-02-06T00:13:00.000Z',
|
|
1019
|
+
},
|
|
1020
|
+
}),
|
|
1021
|
+
remote,
|
|
1022
|
+
events,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
expect(received).toEqual([
|
|
1026
|
+
{
|
|
1027
|
+
type: 'remote-patches',
|
|
1028
|
+
documentId: docId,
|
|
1029
|
+
transactionId: 'txnOwn',
|
|
1030
|
+
previousRev: 'txn0',
|
|
1031
|
+
timestamp: '2025-02-06T00:13:00.000Z',
|
|
1032
|
+
origin: 'local',
|
|
1033
|
+
patches: [{set: {foo: 'new'}}],
|
|
1034
|
+
},
|
|
1035
|
+
])
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
it('does not emit for sync events', () => {
|
|
1039
|
+
const events = new Subject<DocumentEvent>()
|
|
1040
|
+
const received: DocumentEvent[] = []
|
|
1041
|
+
events.subscribe((e) => received.push(e))
|
|
1042
|
+
|
|
1043
|
+
const remote: RemoteDocument = {
|
|
1044
|
+
type: 'sync',
|
|
1045
|
+
documentId: docId,
|
|
1046
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
1047
|
+
revision: 'revSync',
|
|
1048
|
+
timestamp: '2025-02-06T00:14:00.000Z',
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
applyRemoteDocument(createInitialState(), remote, events)
|
|
1052
|
+
expect(received).toEqual([])
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
it('does not emit when the mutations contain no patches', () => {
|
|
1056
|
+
const events = new Subject<DocumentEvent>()
|
|
1057
|
+
const received: DocumentEvent[] = []
|
|
1058
|
+
events.subscribe((e) => received.push(e))
|
|
1059
|
+
|
|
1060
|
+
const remote: RemoteDocument = {
|
|
1061
|
+
type: 'mutation',
|
|
1062
|
+
documentId: docId,
|
|
1063
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
1064
|
+
revision: 'txnCreate',
|
|
1065
|
+
previousRev: 'txn0',
|
|
1066
|
+
timestamp: '2025-02-06T00:15:00.000Z',
|
|
1067
|
+
mutations: [{createOrReplace: {...exampleDoc, _id: docId, foo: 'new'}}],
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
applyRemoteDocument(createInitialState(), remote, events)
|
|
1071
|
+
expect(received).toEqual([])
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
it('excludes patches addressed to other documents in the same transaction', () => {
|
|
1075
|
+
const events = new Subject<DocumentEvent>()
|
|
1076
|
+
const received: DocumentEvent[] = []
|
|
1077
|
+
events.subscribe((e) => received.push(e))
|
|
1078
|
+
|
|
1079
|
+
const remote: RemoteDocument = {
|
|
1080
|
+
type: 'mutation',
|
|
1081
|
+
documentId: docId,
|
|
1082
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
1083
|
+
revision: 'txnMulti',
|
|
1084
|
+
previousRev: 'txn0',
|
|
1085
|
+
timestamp: '2025-02-06T00:20:00.000Z',
|
|
1086
|
+
mutations: [
|
|
1087
|
+
{patch: {id: docId, set: {foo: 'new'}}},
|
|
1088
|
+
{patch: {id: 'some-other-doc', set: {foo: 'other'}}},
|
|
1089
|
+
{patch: {query: '*[_type == "author"]', set: {foo: 'queried'}}},
|
|
1090
|
+
],
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
applyRemoteDocument(createInitialState(), remote, events)
|
|
1094
|
+
|
|
1095
|
+
expect(received).toHaveLength(1)
|
|
1096
|
+
expect((received[0] as {patches: unknown}).patches).toEqual([{set: {foo: 'new'}}])
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it('labels origin "local" via recentOwnTransactionIds when the unverified revision was pruned', () => {
|
|
1100
|
+
const events = new Subject<DocumentEvent>()
|
|
1101
|
+
const received: DocumentEvent[] = []
|
|
1102
|
+
events.subscribe((e) => received.push(e))
|
|
1103
|
+
|
|
1104
|
+
const remote: RemoteDocument = {
|
|
1105
|
+
type: 'mutation',
|
|
1106
|
+
documentId: docId,
|
|
1107
|
+
document: {...exampleDoc, _id: docId, foo: 'new'},
|
|
1108
|
+
revision: 'txnPruned',
|
|
1109
|
+
previousRev: 'txn0',
|
|
1110
|
+
timestamp: '2025-02-06T00:21:00.000Z',
|
|
1111
|
+
mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// a sync event racing the listener echo pruned the unverified revision,
|
|
1115
|
+
// but the transaction ID is still tracked as one of our own
|
|
1116
|
+
applyRemoteDocument(createInitialState({}, ['txnPruned']), remote, events)
|
|
1117
|
+
|
|
1118
|
+
expect(received).toHaveLength(1)
|
|
1119
|
+
expect(received[0]).toMatchObject({
|
|
1120
|
+
type: 'remote-patches',
|
|
1121
|
+
transactionId: 'txnPruned',
|
|
1122
|
+
origin: 'local',
|
|
1123
|
+
})
|
|
1124
|
+
})
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
describe('rebase with preserved operations', () => {
|
|
1128
|
+
it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
|
|
1129
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
1130
|
+
const originalDoc: SanityDocument = {
|
|
1131
|
+
...exampleDoc,
|
|
1132
|
+
_id: docId,
|
|
1133
|
+
_rev: 'rev1',
|
|
1134
|
+
title: 'The quick brown fox',
|
|
1135
|
+
}
|
|
1136
|
+
const locallyEditedDoc: SanityDocument = {
|
|
1137
|
+
...originalDoc,
|
|
1138
|
+
title: 'The quick brown cat',
|
|
1139
|
+
_rev: 'txnLocal',
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const appliedTransaction: AppliedTransaction = {
|
|
1143
|
+
transactionId: 'txnLocal',
|
|
1144
|
+
actions: [
|
|
1145
|
+
{
|
|
1146
|
+
type: 'document.edit',
|
|
1147
|
+
documentId: 'doc1',
|
|
1148
|
+
documentType: 'author',
|
|
1149
|
+
patches: [{diffMatchPatch: {title: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n'}}],
|
|
1150
|
+
preserveOperations: true,
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
previous: {[docId]: originalDoc},
|
|
1154
|
+
base: {[docId]: originalDoc},
|
|
1155
|
+
working: {[docId]: locallyEditedDoc},
|
|
1156
|
+
previousRevs: {[docId]: 'rev1'},
|
|
1157
|
+
timestamp: '2025-02-06T00:16:00.000Z',
|
|
1158
|
+
outgoingActions: [],
|
|
1159
|
+
outgoingMutations: [],
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const initialState: SyncTransactionState = {
|
|
1163
|
+
queued: [],
|
|
1164
|
+
applied: [appliedTransaction],
|
|
1165
|
+
outgoing: undefined,
|
|
1166
|
+
grants,
|
|
1167
|
+
documentStates: {
|
|
1168
|
+
[docId]: {
|
|
1169
|
+
id: docId,
|
|
1170
|
+
subscriptions: ['sub1'],
|
|
1171
|
+
local: locallyEditedDoc,
|
|
1172
|
+
remote: originalDoc,
|
|
1173
|
+
unverifiedRevisions: {},
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// a foreign transaction replaced the title with a non-string, so our
|
|
1179
|
+
// diffMatchPatch can no longer re-apply during the rebase
|
|
1180
|
+
const remoteDoc: SanityDocument = {
|
|
1181
|
+
...originalDoc,
|
|
1182
|
+
title: 42 as unknown as string,
|
|
1183
|
+
_rev: 'txnForeign',
|
|
1184
|
+
}
|
|
1185
|
+
const remote: RemoteDocument = {
|
|
1186
|
+
type: 'mutation',
|
|
1187
|
+
documentId: docId,
|
|
1188
|
+
document: remoteDoc,
|
|
1189
|
+
revision: 'txnForeign',
|
|
1190
|
+
previousRev: 'rev1',
|
|
1191
|
+
timestamp: '2025-02-06T00:17:00.000Z',
|
|
1192
|
+
mutations: [{patch: {id: docId, set: {title: 42}}}],
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const events = new Subject<DocumentEvent>()
|
|
1196
|
+
const received: DocumentEvent[] = []
|
|
1197
|
+
events.subscribe((e) => received.push(e))
|
|
1198
|
+
|
|
1199
|
+
const newState = applyRemoteDocument(initialState, remote, events)
|
|
1200
|
+
|
|
1201
|
+
const rebaseErrors = received.filter((e) => e.type === 'rebase-error')
|
|
1202
|
+
expect(rebaseErrors).toHaveLength(1)
|
|
1203
|
+
expect(rebaseErrors[0]).toMatchObject({
|
|
1204
|
+
type: 'rebase-error',
|
|
1205
|
+
transactionId: 'txnLocal',
|
|
1206
|
+
documentId: 'doc1',
|
|
1207
|
+
message: expect.stringMatching(/Failed to apply patches to the working document/),
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
// the failing transaction is dropped and local converges to remote
|
|
1211
|
+
expect(newState.applied).toEqual([])
|
|
1212
|
+
expect(newState.documentStates[docId]?.local).toEqual(remoteDoc)
|
|
1213
|
+
expect(newState.documentStates[docId]?.remote).toEqual(remoteDoc)
|
|
1214
|
+
})
|
|
1215
|
+
|
|
1216
|
+
it('re-applies preserved keyed patches onto a diverged remote document', () => {
|
|
1217
|
+
const docId = getDraftId(DocumentId('doc1'))
|
|
1218
|
+
const originalDoc: SanityDocument = {
|
|
1219
|
+
...exampleDoc,
|
|
1220
|
+
_id: docId,
|
|
1221
|
+
_rev: 'rev1',
|
|
1222
|
+
items: [
|
|
1223
|
+
{_key: 'a', value: 'first'},
|
|
1224
|
+
{_key: 'b', value: 'second'},
|
|
1225
|
+
],
|
|
1226
|
+
}
|
|
1227
|
+
const locallyEditedDoc: SanityDocument = {
|
|
1228
|
+
...originalDoc,
|
|
1229
|
+
items: [
|
|
1230
|
+
{_key: 'a', value: 'first'},
|
|
1231
|
+
{_key: 'c', value: 'in between'},
|
|
1232
|
+
{_key: 'b', value: 'second'},
|
|
1233
|
+
],
|
|
1234
|
+
_rev: 'txnLocal',
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const appliedTransaction: AppliedTransaction = {
|
|
1238
|
+
transactionId: 'txnLocal',
|
|
1239
|
+
actions: [
|
|
1240
|
+
{
|
|
1241
|
+
type: 'document.edit',
|
|
1242
|
+
documentId: 'doc1',
|
|
1243
|
+
documentType: 'author',
|
|
1244
|
+
patches: [
|
|
1245
|
+
{insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
|
|
1246
|
+
],
|
|
1247
|
+
preserveOperations: true,
|
|
1248
|
+
},
|
|
1249
|
+
],
|
|
1250
|
+
previous: {[docId]: originalDoc},
|
|
1251
|
+
base: {[docId]: originalDoc},
|
|
1252
|
+
working: {[docId]: locallyEditedDoc},
|
|
1253
|
+
previousRevs: {[docId]: 'rev1'},
|
|
1254
|
+
timestamp: '2025-02-06T00:18:00.000Z',
|
|
1255
|
+
outgoingActions: [],
|
|
1256
|
+
outgoingMutations: [],
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const initialState: SyncTransactionState = {
|
|
1260
|
+
queued: [],
|
|
1261
|
+
applied: [appliedTransaction],
|
|
1262
|
+
outgoing: undefined,
|
|
1263
|
+
grants,
|
|
1264
|
+
documentStates: {
|
|
1265
|
+
[docId]: {
|
|
1266
|
+
id: docId,
|
|
1267
|
+
subscriptions: ['sub1'],
|
|
1268
|
+
local: locallyEditedDoc,
|
|
1269
|
+
remote: originalDoc,
|
|
1270
|
+
unverifiedRevisions: {},
|
|
1271
|
+
},
|
|
1272
|
+
},
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// a foreign transaction appended a new item concurrently
|
|
1276
|
+
const remoteDoc: SanityDocument = {
|
|
1277
|
+
...originalDoc,
|
|
1278
|
+
items: [
|
|
1279
|
+
{_key: 'a', value: 'first'},
|
|
1280
|
+
{_key: 'b', value: 'second'},
|
|
1281
|
+
{_key: 'd', value: 'appended remotely'},
|
|
1282
|
+
],
|
|
1283
|
+
_rev: 'txnForeign',
|
|
1284
|
+
}
|
|
1285
|
+
const remote: RemoteDocument = {
|
|
1286
|
+
type: 'mutation',
|
|
1287
|
+
documentId: docId,
|
|
1288
|
+
document: remoteDoc,
|
|
1289
|
+
revision: 'txnForeign',
|
|
1290
|
+
previousRev: 'rev1',
|
|
1291
|
+
timestamp: '2025-02-06T00:19:00.000Z',
|
|
1292
|
+
mutations: [
|
|
1293
|
+
{
|
|
1294
|
+
patch: {
|
|
1295
|
+
id: docId,
|
|
1296
|
+
insert: {after: 'items[-1]', items: [{_key: 'd', value: 'appended remotely'}]},
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const events = new Subject<DocumentEvent>()
|
|
1303
|
+
const newState = applyRemoteDocument(initialState, remote, events)
|
|
1304
|
+
|
|
1305
|
+
// both edits survive: our keyed insert re-anchors after item "a" while
|
|
1306
|
+
// the remote append is preserved
|
|
1307
|
+
expect(newState.documentStates[docId]?.local?.['items']).toEqual([
|
|
1308
|
+
{_key: 'a', value: 'first'},
|
|
1309
|
+
{_key: 'c', value: 'in between'},
|
|
1310
|
+
{_key: 'b', value: 'second'},
|
|
1311
|
+
{_key: 'd', value: 'appended remotely'},
|
|
1312
|
+
])
|
|
1313
|
+
expect(newState.applied).toHaveLength(1)
|
|
1314
|
+
})
|
|
1315
|
+
})
|
|
797
1316
|
})
|
|
798
1317
|
|
|
799
1318
|
describe('addSubscriptionIdToDocument', () => {
|
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'
|
|
@@ -17,6 +17,13 @@ import {type DocumentSet} from './processMutations'
|
|
|
17
17
|
|
|
18
18
|
const EMPTY_REVISIONS: NonNullable<Required<DocumentState['unverifiedRevisions']>> = {}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How many of this client's own transaction IDs to remember per document for
|
|
22
|
+
* `remote-patches` origin labeling. Sized to comfortably outlast the window
|
|
23
|
+
* between submitting a transaction and observing its listener echo.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_RECENT_OWN_TRANSACTION_IDS = 50
|
|
26
|
+
|
|
20
27
|
export type SyncTransactionState = Pick<
|
|
21
28
|
DocumentStoreState,
|
|
22
29
|
'queued' | 'applied' | 'documentStates' | 'outgoing' | 'grants' | 'identity'
|
|
@@ -364,6 +371,15 @@ export function transitionAppliedTransactionsToOutgoing(
|
|
|
364
371
|
// add unverified revision
|
|
365
372
|
[transactionId]: {documentId, previousRev, transactionId, timestamp},
|
|
366
373
|
},
|
|
374
|
+
// also track the transaction ID durably (unverified revisions can be
|
|
375
|
+
// pruned by sync events before the listener echo arrives) so
|
|
376
|
+
// `remote-patches` events label our own transactions correctly
|
|
377
|
+
recentOwnTransactionIds: [
|
|
378
|
+
...(documentState.recentOwnTransactionIds ?? []).slice(
|
|
379
|
+
-(MAX_RECENT_OWN_TRANSACTION_IDS - 1),
|
|
380
|
+
),
|
|
381
|
+
transactionId,
|
|
382
|
+
],
|
|
367
383
|
}
|
|
368
384
|
|
|
369
385
|
return acc
|
|
@@ -433,9 +449,29 @@ export function revertOutgoingTransaction(prev: SyncTransactionState): SyncTrans
|
|
|
433
449
|
}
|
|
434
450
|
}
|
|
435
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Extracts the patch operations addressed to the given document from a set of
|
|
454
|
+
* raw listener mutations, stripping the mutation-level `id` addressing so the
|
|
455
|
+
* patches are rooted at the document. A transaction can carry mutations for
|
|
456
|
+
* other documents, so anything not explicitly id-addressed to this document
|
|
457
|
+
* (including query-addressed patches) is dropped.
|
|
458
|
+
*/
|
|
459
|
+
function extractPatchOperations(
|
|
460
|
+
mutations: Mutation[] | undefined,
|
|
461
|
+
documentId: string,
|
|
462
|
+
): PatchOperations[] {
|
|
463
|
+
if (!mutations) return []
|
|
464
|
+
return mutations.flatMap((mutation) => {
|
|
465
|
+
if (!('patch' in mutation) || !mutation.patch) return []
|
|
466
|
+
const {id, ...operations} = mutation.patch as PatchOperations & {id?: string}
|
|
467
|
+
if (id !== documentId) return []
|
|
468
|
+
return [operations]
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
436
472
|
export function applyRemoteDocument(
|
|
437
473
|
prev: SyncTransactionState,
|
|
438
|
-
{document, documentId, previousRev, revision, timestamp, type}: RemoteDocument,
|
|
474
|
+
{document, documentId, previousRev, revision, timestamp, type, mutations}: RemoteDocument,
|
|
439
475
|
events: DocumentStoreState['events'],
|
|
440
476
|
): SyncTransactionState {
|
|
441
477
|
if (!prev.grants) return prev
|
|
@@ -455,6 +491,31 @@ export function applyRemoteDocument(
|
|
|
455
491
|
unverifiedRevisions = omitProperty(prevUnverifiedRevisions, revision)
|
|
456
492
|
}
|
|
457
493
|
|
|
494
|
+
// surface the operational patches from this transaction before they are
|
|
495
|
+
// collapsed into the whole-document snapshot below. consumers that keep
|
|
496
|
+
// their own state (e.g. collaborative text editors) rely on these to apply
|
|
497
|
+
// remote changes without re-diffing document snapshots
|
|
498
|
+
if (type === 'mutation' && revision) {
|
|
499
|
+
const patches = extractPatchOperations(mutations, documentId)
|
|
500
|
+
if (patches.length) {
|
|
501
|
+
// `unverifiedRevisions` alone isn't a reliable origin marker: a sync
|
|
502
|
+
// event can prune an in-flight transaction's entry before its listener
|
|
503
|
+
// echo arrives. `recentOwnTransactionIds` survives that race
|
|
504
|
+
const isOwnTransaction =
|
|
505
|
+
Boolean(revisionToVerify) ||
|
|
506
|
+
(prevDocState.recentOwnTransactionIds?.includes(revision) ?? false)
|
|
507
|
+
events.next({
|
|
508
|
+
type: 'remote-patches',
|
|
509
|
+
documentId,
|
|
510
|
+
transactionId: revision,
|
|
511
|
+
previousRev,
|
|
512
|
+
timestamp,
|
|
513
|
+
patches,
|
|
514
|
+
origin: isOwnTransaction ? 'local' : 'remote',
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
458
519
|
// if this remote document is from a `'sync'` event (meaning that the whole
|
|
459
520
|
// thing was just fetched and not re-created from mutations)
|
|
460
521
|
if (type === 'sync') {
|
|
@@ -473,6 +534,34 @@ export function applyRemoteDocument(
|
|
|
473
534
|
// matches the previous revision we expected, we can "fast-forward" and skip
|
|
474
535
|
// rebasing local changes on top of this new base
|
|
475
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
|
+
|
|
476
565
|
return {
|
|
477
566
|
...prev,
|
|
478
567
|
documentStates: {
|
|
@@ -481,6 +570,7 @@ export function applyRemoteDocument(
|
|
|
481
570
|
...prevDocState,
|
|
482
571
|
remote: document,
|
|
483
572
|
remoteRev: revision,
|
|
573
|
+
local,
|
|
484
574
|
unverifiedRevisions,
|
|
485
575
|
},
|
|
486
576
|
},
|
|
@@ -531,6 +621,12 @@ export function applyRemoteDocument(
|
|
|
531
621
|
}
|
|
532
622
|
}
|
|
533
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
|
+
|
|
534
630
|
return {
|
|
535
631
|
...prev,
|
|
536
632
|
applied: nextApplied,
|
|
@@ -540,7 +636,7 @@ export function applyRemoteDocument(
|
|
|
540
636
|
...prevDocState,
|
|
541
637
|
remote: document,
|
|
542
638
|
remoteRev: revision,
|
|
543
|
-
local
|
|
639
|
+
local,
|
|
544
640
|
unverifiedRevisions,
|
|
545
641
|
},
|
|
546
642
|
},
|