@sanity/sdk 2.15.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.
Files changed (41) hide show
  1. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
  2. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
  3. package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
  4. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  5. package/dist/_chunks-es/version.js +1 -1
  6. package/dist/index.d.ts +69 -4
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +79 -32
  9. package/dist/index.js.map +1 -1
  10. package/package.json +12 -12
  11. package/src/_exports/index.ts +2 -0
  12. package/src/auth/authStore.test.ts +1 -1
  13. package/src/auth/handleAuthCallback.test.ts +1 -1
  14. package/src/auth/refreshStampedToken.test.ts +199 -477
  15. package/src/auth/refreshStampedToken.ts +67 -195
  16. package/src/client/liveEvents.test.ts +151 -0
  17. package/src/client/liveEvents.ts +107 -0
  18. package/src/document/actions.ts +36 -0
  19. package/src/document/documentStore.test.ts +132 -1
  20. package/src/document/documentStore.ts +8 -0
  21. package/src/document/events.ts +32 -0
  22. package/src/document/listen.ts +8 -0
  23. package/src/document/processActions/edit.ts +19 -14
  24. package/src/document/processActions/shared.ts +55 -8
  25. package/src/document/processActions.test.ts +145 -0
  26. package/src/document/reducers.test.ts +396 -0
  27. package/src/document/reducers.ts +62 -1
  28. package/src/document/sharedListener.test.ts +1 -0
  29. package/src/document/sharedListener.ts +2 -0
  30. package/src/query/queryStore.test.ts +232 -38
  31. package/src/query/queryStore.ts +47 -47
  32. package/src/query/reducers.test.ts +0 -42
  33. package/src/query/reducers.ts +0 -16
  34. package/src/releases/getPerspectiveState.test.ts +3 -8
  35. package/src/releases/getPerspectiveState.ts +6 -19
  36. package/src/releases/observeReleases.test.ts +127 -0
  37. package/src/releases/observeReleases.ts +102 -0
  38. package/src/releases/releasesStore.test.ts +51 -53
  39. package/src/releases/releasesStore.ts +32 -32
  40. package/src/releases/utils/sortReleases.test.ts +36 -0
  41. package/src/releases/utils/sortReleases.ts +14 -9
@@ -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') {
@@ -51,6 +51,7 @@ describe('createSharedListener', () => {
51
51
  {
52
52
  events: ['mutation', 'welcome', 'reconnect'],
53
53
  includeResult: false,
54
+ includeAllVersions: true,
54
55
  tag: 'document-listener',
55
56
  },
56
57
  )
@@ -43,6 +43,8 @@ export function createSharedListener(
43
43
  {
44
44
  events: ['mutation', 'welcome', 'reconnect'],
45
45
  includeResult: false,
46
+ // the document store handles version documents, so we need to include all versions
47
+ includeAllVersions: true,
46
48
  tag: 'document-listener',
47
49
  // // from manual testing, it seems like mendoza patches may be
48
50
  // // causing some ambiguity/wonkiness