@sanity/sdk 2.16.0 → 2.17.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.
@@ -527,6 +527,151 @@ describe('processActions', () => {
527
527
  })
528
528
  })
529
529
 
530
+ describe('document.edit with preserveOperations', () => {
531
+ const createKeyedDoc = (): SanityDocument => ({
532
+ _id: 'drafts.doc1',
533
+ _type: 'article',
534
+ _createdAt: '2025-01-01T00:00:00.000Z',
535
+ _updatedAt: '2025-01-01T00:00:00.000Z',
536
+ _rev: 'initial',
537
+ items: [
538
+ {_key: 'a', value: 'first'},
539
+ {_key: 'b', value: 'second'},
540
+ ],
541
+ })
542
+
543
+ it('forwards the given patches verbatim instead of re-diffing', () => {
544
+ const draft = createKeyedDoc()
545
+ const base: DocumentSet = {'drafts.doc1': draft}
546
+ const working: DocumentSet = {'drafts.doc1': draft}
547
+ const patches = [
548
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
549
+ ]
550
+ const actions: DocumentAction[] = [
551
+ {
552
+ documentId: 'doc1',
553
+ documentType: 'article',
554
+ type: 'document.edit',
555
+ patches,
556
+ preserveOperations: true,
557
+ },
558
+ ]
559
+ const result = processActions({
560
+ actions,
561
+ transactionId,
562
+ base,
563
+ working,
564
+ timestamp,
565
+ grants: defaultGrants,
566
+ })
567
+
568
+ // the outgoing action carries the caller's keyed patch untouched
569
+ expect(result.outgoingActions).toEqual([
570
+ {
571
+ actionType: 'sanity.action.document.edit',
572
+ draftId: 'drafts.doc1',
573
+ publishedId: 'doc1',
574
+ patch: patches[0],
575
+ },
576
+ ])
577
+
578
+ // and it still applies to the local working copy
579
+ expect(result.working['drafts.doc1']?.['items']).toEqual([
580
+ {_key: 'a', value: 'first'},
581
+ {_key: 'c', value: 'in between'},
582
+ {_key: 'b', value: 'second'},
583
+ ])
584
+ })
585
+
586
+ it('applies keyed unsets and sets to the local working copy', () => {
587
+ const draft = createKeyedDoc()
588
+ const base: DocumentSet = {'drafts.doc1': draft}
589
+ const working: DocumentSet = {'drafts.doc1': draft}
590
+ const actions: DocumentAction[] = [
591
+ {
592
+ documentId: 'doc1',
593
+ documentType: 'article',
594
+ type: 'document.edit',
595
+ patches: [
596
+ {unset: ['items[_key=="a"]']},
597
+ {set: {'items[_key=="b"].value': 'updated second'}},
598
+ ],
599
+ preserveOperations: true,
600
+ },
601
+ ]
602
+ const result = processActions({
603
+ actions,
604
+ transactionId,
605
+ base,
606
+ working,
607
+ timestamp,
608
+ grants: defaultGrants,
609
+ })
610
+
611
+ expect(result.working['drafts.doc1']?.['items']).toEqual([
612
+ {_key: 'b', value: 'updated second'},
613
+ ])
614
+ expect(result.outgoingActions.map((action) => 'patch' in action && action.patch)).toEqual([
615
+ {unset: ['items[_key=="a"]']},
616
+ {set: {'items[_key=="b"].value': 'updated second'}},
617
+ ])
618
+ })
619
+
620
+ it('surfaces patch application failures as ActionError', () => {
621
+ const draft = {...createKeyedDoc(), count: 5}
622
+ const base: DocumentSet = {'drafts.doc1': draft}
623
+ const working: DocumentSet = {'drafts.doc1': draft}
624
+ const actions: DocumentAction[] = [
625
+ {
626
+ documentId: 'doc1',
627
+ documentType: 'article',
628
+ type: 'document.edit',
629
+ // diff-match-patch on a non-string value cannot apply
630
+ patches: [{diffMatchPatch: {count: '@@ -1,3 +1,3 @@\n-foo\n+bar\n'}}],
631
+ preserveOperations: true,
632
+ },
633
+ ]
634
+ expect(() =>
635
+ processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
636
+ ).toThrow(ActionError)
637
+ expect(() =>
638
+ processActions({actions, transactionId, base, working, timestamp, grants: defaultGrants}),
639
+ ).toThrow(/Failed to apply patches to the base document/)
640
+ })
641
+
642
+ it('forwards patches verbatim through the liveEdit mutation path', () => {
643
+ const liveDoc = {...createKeyedDoc(), _id: 'live-doc'}
644
+ const base: DocumentSet = {'live-doc': liveDoc}
645
+ const working: DocumentSet = {'live-doc': liveDoc}
646
+ const patches = [{insert: {before: 'items[_key=="a"]', items: [{_key: 'z', value: 'start'}]}}]
647
+ const actions: DocumentAction[] = [
648
+ {
649
+ documentId: 'live-doc',
650
+ documentType: 'liveArticle',
651
+ type: 'document.edit',
652
+ liveEdit: true,
653
+ patches,
654
+ preserveOperations: true,
655
+ },
656
+ ]
657
+ const result = processActions({
658
+ actions,
659
+ transactionId,
660
+ base,
661
+ working,
662
+ timestamp,
663
+ grants: defaultGrants,
664
+ })
665
+
666
+ expect(result.outgoingMutations).toEqual([{patch: {id: 'live-doc', ...patches[0]}}])
667
+ expect(result.working['live-doc']?.['items']).toEqual([
668
+ {_key: 'z', value: 'start'},
669
+ {_key: 'a', value: 'first'},
670
+ {_key: 'b', value: 'second'},
671
+ ])
672
+ })
673
+ })
674
+
530
675
  describe('document.publish', () => {
531
676
  it('should publish a draft document', () => {
532
677
  const draft = createDoc('drafts.doc1', 'Draft Title', '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
 
@@ -794,6 +797,399 @@ describe('applyRemoteDocument', () => {
794
797
  expect(newDocState?.remote).toEqual(remote.document)
795
798
  expect(newDocState?.remoteRev).toBe('currentRev')
796
799
  })
800
+
801
+ describe('remote-patches events', () => {
802
+ const docId = getDraftId(DocumentId('doc1'))
803
+
804
+ function createInitialState(
805
+ unverifiedRevisions: NonNullable<
806
+ SyncTransactionState['documentStates'][string]
807
+ >['unverifiedRevisions'] = {},
808
+ recentOwnTransactionIds?: string[],
809
+ ): SyncTransactionState {
810
+ return {
811
+ queued: [],
812
+ applied: [],
813
+ outgoing: undefined,
814
+ grants,
815
+ documentStates: {
816
+ [docId]: {
817
+ id: docId,
818
+ subscriptions: ['sub1'],
819
+ local: {...exampleDoc, _id: docId, foo: 'old'},
820
+ remote: {...exampleDoc, _id: docId, foo: 'old'},
821
+ unverifiedRevisions,
822
+ ...(recentOwnTransactionIds && {recentOwnTransactionIds}),
823
+ },
824
+ },
825
+ }
826
+ }
827
+
828
+ it('emits a remote-patches event with origin "remote" for foreign transactions', () => {
829
+ const events = new Subject<DocumentEvent>()
830
+ const received: DocumentEvent[] = []
831
+ events.subscribe((e) => received.push(e))
832
+
833
+ const remote: RemoteDocument = {
834
+ type: 'mutation',
835
+ documentId: docId,
836
+ document: {...exampleDoc, _id: docId, foo: 'new'},
837
+ revision: 'txnForeign',
838
+ previousRev: 'txn0',
839
+ timestamp: '2025-02-06T00:12:00.000Z',
840
+ mutations: [
841
+ {
842
+ patch: {
843
+ id: docId,
844
+ unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]'],
845
+ },
846
+ },
847
+ {
848
+ patch: {
849
+ id: docId,
850
+ insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]},
851
+ },
852
+ },
853
+ ],
854
+ }
855
+
856
+ applyRemoteDocument(createInitialState(), remote, events)
857
+
858
+ expect(received).toEqual([
859
+ {
860
+ type: 'remote-patches',
861
+ documentId: docId,
862
+ transactionId: 'txnForeign',
863
+ previousRev: 'txn0',
864
+ timestamp: '2025-02-06T00:12:00.000Z',
865
+ origin: 'remote',
866
+ patches: [
867
+ {unset: ['blocks[_key=="a"].children[_key=="b"].marks[0]']},
868
+ {insert: {after: 'blocks[_key=="a"].markDefs[-1]', items: [{_key: 'link1'}]}},
869
+ ],
870
+ },
871
+ ])
872
+ })
873
+
874
+ it('emits origin "local" when the transaction is one of our own unverified revisions', () => {
875
+ const events = new Subject<DocumentEvent>()
876
+ const received: DocumentEvent[] = []
877
+ events.subscribe((e) => received.push(e))
878
+
879
+ const remote: RemoteDocument = {
880
+ type: 'mutation',
881
+ documentId: docId,
882
+ document: {...exampleDoc, _id: docId, foo: 'new'},
883
+ revision: 'txnOwn',
884
+ previousRev: 'txn0',
885
+ timestamp: '2025-02-06T00:13:00.000Z',
886
+ mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
887
+ }
888
+
889
+ applyRemoteDocument(
890
+ createInitialState({
891
+ txnOwn: {
892
+ transactionId: 'txnOwn',
893
+ documentId: docId,
894
+ previousRev: 'txn0',
895
+ timestamp: '2025-02-06T00:13:00.000Z',
896
+ },
897
+ }),
898
+ remote,
899
+ events,
900
+ )
901
+
902
+ expect(received).toEqual([
903
+ {
904
+ type: 'remote-patches',
905
+ documentId: docId,
906
+ transactionId: 'txnOwn',
907
+ previousRev: 'txn0',
908
+ timestamp: '2025-02-06T00:13:00.000Z',
909
+ origin: 'local',
910
+ patches: [{set: {foo: 'new'}}],
911
+ },
912
+ ])
913
+ })
914
+
915
+ it('does not emit for sync events', () => {
916
+ const events = new Subject<DocumentEvent>()
917
+ const received: DocumentEvent[] = []
918
+ events.subscribe((e) => received.push(e))
919
+
920
+ const remote: RemoteDocument = {
921
+ type: 'sync',
922
+ documentId: docId,
923
+ document: {...exampleDoc, _id: docId, foo: 'new'},
924
+ revision: 'revSync',
925
+ timestamp: '2025-02-06T00:14:00.000Z',
926
+ }
927
+
928
+ applyRemoteDocument(createInitialState(), remote, events)
929
+ expect(received).toEqual([])
930
+ })
931
+
932
+ it('does not emit when the mutations contain no patches', () => {
933
+ const events = new Subject<DocumentEvent>()
934
+ const received: DocumentEvent[] = []
935
+ events.subscribe((e) => received.push(e))
936
+
937
+ const remote: RemoteDocument = {
938
+ type: 'mutation',
939
+ documentId: docId,
940
+ document: {...exampleDoc, _id: docId, foo: 'new'},
941
+ revision: 'txnCreate',
942
+ previousRev: 'txn0',
943
+ timestamp: '2025-02-06T00:15:00.000Z',
944
+ mutations: [{createOrReplace: {...exampleDoc, _id: docId, foo: 'new'}}],
945
+ }
946
+
947
+ applyRemoteDocument(createInitialState(), remote, events)
948
+ expect(received).toEqual([])
949
+ })
950
+
951
+ it('excludes patches addressed to other documents in the same transaction', () => {
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: 'txnMulti',
961
+ previousRev: 'txn0',
962
+ timestamp: '2025-02-06T00:20:00.000Z',
963
+ mutations: [
964
+ {patch: {id: docId, set: {foo: 'new'}}},
965
+ {patch: {id: 'some-other-doc', set: {foo: 'other'}}},
966
+ {patch: {query: '*[_type == "author"]', set: {foo: 'queried'}}},
967
+ ],
968
+ }
969
+
970
+ applyRemoteDocument(createInitialState(), remote, events)
971
+
972
+ expect(received).toHaveLength(1)
973
+ expect((received[0] as {patches: unknown}).patches).toEqual([{set: {foo: 'new'}}])
974
+ })
975
+
976
+ it('labels origin "local" via recentOwnTransactionIds when the unverified revision was pruned', () => {
977
+ const events = new Subject<DocumentEvent>()
978
+ const received: DocumentEvent[] = []
979
+ events.subscribe((e) => received.push(e))
980
+
981
+ const remote: RemoteDocument = {
982
+ type: 'mutation',
983
+ documentId: docId,
984
+ document: {...exampleDoc, _id: docId, foo: 'new'},
985
+ revision: 'txnPruned',
986
+ previousRev: 'txn0',
987
+ timestamp: '2025-02-06T00:21:00.000Z',
988
+ mutations: [{patch: {id: docId, set: {foo: 'new'}}}],
989
+ }
990
+
991
+ // a sync event racing the listener echo pruned the unverified revision,
992
+ // but the transaction ID is still tracked as one of our own
993
+ applyRemoteDocument(createInitialState({}, ['txnPruned']), remote, events)
994
+
995
+ expect(received).toHaveLength(1)
996
+ expect(received[0]).toMatchObject({
997
+ type: 'remote-patches',
998
+ transactionId: 'txnPruned',
999
+ origin: 'local',
1000
+ })
1001
+ })
1002
+ })
1003
+
1004
+ describe('rebase with preserved operations', () => {
1005
+ it('skips a preserved-operations transaction that fails to re-apply and emits rebase-error', () => {
1006
+ const docId = getDraftId(DocumentId('doc1'))
1007
+ const originalDoc: SanityDocument = {
1008
+ ...exampleDoc,
1009
+ _id: docId,
1010
+ _rev: 'rev1',
1011
+ title: 'The quick brown fox',
1012
+ }
1013
+ const locallyEditedDoc: SanityDocument = {
1014
+ ...originalDoc,
1015
+ title: 'The quick brown cat',
1016
+ _rev: 'txnLocal',
1017
+ }
1018
+
1019
+ const appliedTransaction: AppliedTransaction = {
1020
+ transactionId: 'txnLocal',
1021
+ actions: [
1022
+ {
1023
+ type: 'document.edit',
1024
+ documentId: 'doc1',
1025
+ documentType: 'author',
1026
+ patches: [{diffMatchPatch: {title: '@@ -13,7 +13,7 @@\n own \n-fox\n+cat\n'}}],
1027
+ preserveOperations: true,
1028
+ },
1029
+ ],
1030
+ previous: {[docId]: originalDoc},
1031
+ base: {[docId]: originalDoc},
1032
+ working: {[docId]: locallyEditedDoc},
1033
+ previousRevs: {[docId]: 'rev1'},
1034
+ timestamp: '2025-02-06T00:16:00.000Z',
1035
+ outgoingActions: [],
1036
+ outgoingMutations: [],
1037
+ }
1038
+
1039
+ const initialState: SyncTransactionState = {
1040
+ queued: [],
1041
+ applied: [appliedTransaction],
1042
+ outgoing: undefined,
1043
+ grants,
1044
+ documentStates: {
1045
+ [docId]: {
1046
+ id: docId,
1047
+ subscriptions: ['sub1'],
1048
+ local: locallyEditedDoc,
1049
+ remote: originalDoc,
1050
+ unverifiedRevisions: {},
1051
+ },
1052
+ },
1053
+ }
1054
+
1055
+ // a foreign transaction replaced the title with a non-string, so our
1056
+ // diffMatchPatch can no longer re-apply during the rebase
1057
+ const remoteDoc: SanityDocument = {
1058
+ ...originalDoc,
1059
+ title: 42 as unknown as string,
1060
+ _rev: 'txnForeign',
1061
+ }
1062
+ const remote: RemoteDocument = {
1063
+ type: 'mutation',
1064
+ documentId: docId,
1065
+ document: remoteDoc,
1066
+ revision: 'txnForeign',
1067
+ previousRev: 'rev1',
1068
+ timestamp: '2025-02-06T00:17:00.000Z',
1069
+ mutations: [{patch: {id: docId, set: {title: 42}}}],
1070
+ }
1071
+
1072
+ const events = new Subject<DocumentEvent>()
1073
+ const received: DocumentEvent[] = []
1074
+ events.subscribe((e) => received.push(e))
1075
+
1076
+ const newState = applyRemoteDocument(initialState, remote, events)
1077
+
1078
+ const rebaseErrors = received.filter((e) => e.type === 'rebase-error')
1079
+ expect(rebaseErrors).toHaveLength(1)
1080
+ expect(rebaseErrors[0]).toMatchObject({
1081
+ type: 'rebase-error',
1082
+ transactionId: 'txnLocal',
1083
+ documentId: 'doc1',
1084
+ message: expect.stringMatching(/Failed to apply patches to the working document/),
1085
+ })
1086
+
1087
+ // the failing transaction is dropped and local converges to remote
1088
+ expect(newState.applied).toEqual([])
1089
+ expect(newState.documentStates[docId]?.local).toEqual(remoteDoc)
1090
+ expect(newState.documentStates[docId]?.remote).toEqual(remoteDoc)
1091
+ })
1092
+
1093
+ it('re-applies preserved keyed patches onto a diverged remote document', () => {
1094
+ const docId = getDraftId(DocumentId('doc1'))
1095
+ const originalDoc: SanityDocument = {
1096
+ ...exampleDoc,
1097
+ _id: docId,
1098
+ _rev: 'rev1',
1099
+ items: [
1100
+ {_key: 'a', value: 'first'},
1101
+ {_key: 'b', value: 'second'},
1102
+ ],
1103
+ }
1104
+ const locallyEditedDoc: SanityDocument = {
1105
+ ...originalDoc,
1106
+ items: [
1107
+ {_key: 'a', value: 'first'},
1108
+ {_key: 'c', value: 'in between'},
1109
+ {_key: 'b', value: 'second'},
1110
+ ],
1111
+ _rev: 'txnLocal',
1112
+ }
1113
+
1114
+ const appliedTransaction: AppliedTransaction = {
1115
+ transactionId: 'txnLocal',
1116
+ actions: [
1117
+ {
1118
+ type: 'document.edit',
1119
+ documentId: 'doc1',
1120
+ documentType: 'author',
1121
+ patches: [
1122
+ {insert: {after: 'items[_key=="a"]', items: [{_key: 'c', value: 'in between'}]}},
1123
+ ],
1124
+ preserveOperations: true,
1125
+ },
1126
+ ],
1127
+ previous: {[docId]: originalDoc},
1128
+ base: {[docId]: originalDoc},
1129
+ working: {[docId]: locallyEditedDoc},
1130
+ previousRevs: {[docId]: 'rev1'},
1131
+ timestamp: '2025-02-06T00:18:00.000Z',
1132
+ outgoingActions: [],
1133
+ outgoingMutations: [],
1134
+ }
1135
+
1136
+ const initialState: SyncTransactionState = {
1137
+ queued: [],
1138
+ applied: [appliedTransaction],
1139
+ outgoing: undefined,
1140
+ grants,
1141
+ documentStates: {
1142
+ [docId]: {
1143
+ id: docId,
1144
+ subscriptions: ['sub1'],
1145
+ local: locallyEditedDoc,
1146
+ remote: originalDoc,
1147
+ unverifiedRevisions: {},
1148
+ },
1149
+ },
1150
+ }
1151
+
1152
+ // a foreign transaction appended a new item concurrently
1153
+ const remoteDoc: SanityDocument = {
1154
+ ...originalDoc,
1155
+ items: [
1156
+ {_key: 'a', value: 'first'},
1157
+ {_key: 'b', value: 'second'},
1158
+ {_key: 'd', value: 'appended remotely'},
1159
+ ],
1160
+ _rev: 'txnForeign',
1161
+ }
1162
+ const remote: RemoteDocument = {
1163
+ type: 'mutation',
1164
+ documentId: docId,
1165
+ document: remoteDoc,
1166
+ revision: 'txnForeign',
1167
+ previousRev: 'rev1',
1168
+ timestamp: '2025-02-06T00:19:00.000Z',
1169
+ mutations: [
1170
+ {
1171
+ patch: {
1172
+ id: docId,
1173
+ insert: {after: 'items[-1]', items: [{_key: 'd', value: 'appended remotely'}]},
1174
+ },
1175
+ },
1176
+ ],
1177
+ }
1178
+
1179
+ const events = new Subject<DocumentEvent>()
1180
+ const newState = applyRemoteDocument(initialState, remote, events)
1181
+
1182
+ // both edits survive: our keyed insert re-anchors after item "a" while
1183
+ // the remote append is preserved
1184
+ expect(newState.documentStates[docId]?.local?.['items']).toEqual([
1185
+ {_key: 'a', value: 'first'},
1186
+ {_key: 'c', value: 'in between'},
1187
+ {_key: 'b', value: 'second'},
1188
+ {_key: 'd', value: 'appended remotely'},
1189
+ ])
1190
+ expect(newState.applied).toHaveLength(1)
1191
+ })
1192
+ })
797
1193
  })
798
1194
 
799
1195
  describe('addSubscriptionIdToDocument', () => {
@@ -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') {