@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.
@@ -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', () => {
@@ -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: working[documentId],
639
+ local,
544
640
  unverifiedRevisions,
545
641
  },
546
642
  },