@sanity/sdk 2.18.0 → 2.20.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 (75) hide show
  1. package/dist/_exports/_internal.d.ts +2 -2
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +196 -22
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/authStore-CWTAuieY.js +1072 -0
  6. package/dist/authStore-CWTAuieY.js.map +1 -0
  7. package/dist/clientStore-CMzJ7XhZ.js +168 -0
  8. package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
  9. package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
  10. package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
  11. package/dist/getEnv-76AjZd_f.js +178 -0
  12. package/dist/getEnv-76AjZd_f.js.map +1 -0
  13. package/dist/index.d.ts +2 -1921
  14. package/dist/index.js +4165 -3711
  15. package/dist/index.js.map +1 -1
  16. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  17. package/dist/telemetryManager-Bd3O8z2S.js +127 -0
  18. package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
  19. package/dist/utils-BMwA8Kg9.d.ts +3334 -0
  20. package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
  21. package/dist/version-DL2l07HG.js +9 -0
  22. package/dist/version-DL2l07HG.js.map +1 -0
  23. package/package.json +13 -21
  24. package/src/_exports/_internal.ts +1 -0
  25. package/src/_exports/index.ts +37 -1
  26. package/src/comments/addonDatasetStore.test.ts +215 -0
  27. package/src/comments/addonDatasetStore.ts +249 -0
  28. package/src/comments/buildCommentThreads.test.ts +83 -0
  29. package/src/comments/buildCommentThreads.ts +54 -0
  30. package/src/comments/commentActions.test.ts +540 -0
  31. package/src/comments/commentActions.ts +555 -0
  32. package/src/comments/commentFieldPath.test.ts +40 -0
  33. package/src/comments/commentFieldPath.ts +44 -0
  34. package/src/comments/commentsConstants.ts +80 -0
  35. package/src/comments/commentsStore.test.ts +419 -0
  36. package/src/comments/commentsStore.ts +483 -0
  37. package/src/comments/normalizeComment.test.ts +116 -0
  38. package/src/comments/normalizeComment.ts +45 -0
  39. package/src/comments/observeComments.test.ts +193 -0
  40. package/src/comments/observeComments.ts +113 -0
  41. package/src/comments/reducers.test.ts +277 -0
  42. package/src/comments/reducers.ts +316 -0
  43. package/src/comments/types.ts +249 -0
  44. package/src/comments/weakenReferences.test.ts +47 -0
  45. package/src/comments/weakenReferences.ts +29 -0
  46. package/src/document/documentConstants.ts +8 -0
  47. package/src/document/documentStore.concurrency.test.ts +313 -22
  48. package/src/document/documentStore.test.ts +98 -1
  49. package/src/document/documentStore.ts +36 -5
  50. package/src/document/patchOperations.test.ts +27 -0
  51. package/src/document/patchOperations.ts +10 -1
  52. package/src/document/permissions.test.ts +3 -1
  53. package/src/document/reducers.test.ts +133 -0
  54. package/src/document/reducers.ts +18 -3
  55. package/src/document/util.test.ts +46 -0
  56. package/src/document/util.ts +37 -1
  57. package/src/presence/bifurTransport.test.ts +155 -3
  58. package/src/presence/bifurTransport.ts +79 -12
  59. package/src/presence/paths.test.ts +70 -0
  60. package/src/presence/paths.ts +41 -0
  61. package/src/presence/presenceStore.test.ts +822 -2
  62. package/src/presence/presenceStore.ts +518 -102
  63. package/src/presence/types.ts +175 -3
  64. package/src/projection/projectionStore.test.ts +0 -8
  65. package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
  66. package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
  67. package/dist/_chunks-es/_internal.js +0 -133
  68. package/dist/_chunks-es/_internal.js.map +0 -1
  69. package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
  70. package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
  71. package/dist/_chunks-es/telemetryManager.js +0 -93
  72. package/dist/_chunks-es/telemetryManager.js.map +0 -1
  73. package/dist/_chunks-es/version.js +0 -7
  74. package/dist/_chunks-es/version.js.map +0 -1
  75. package/dist/index.d.ts.map +0 -1
@@ -25,6 +25,7 @@ import {
25
25
  type SanityClient,
26
26
  type WelcomeEvent,
27
27
  } from '@sanity/client'
28
+ import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
28
29
  import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
29
30
  import {type Mutation, type PatchOperations, type SanityDocument} from '@sanity/types'
30
31
  import {
@@ -100,7 +101,10 @@ interface HeldSubmission {
100
101
  }
101
102
 
102
103
  interface MockContentLake {
103
- connect: () => {events: Observable<ListenEvent<SanityDocument>>; dispose: () => void}
104
+ connect: () => {
105
+ events: Observable<ListenEvent<SanityDocument>>
106
+ dispose: () => void
107
+ }
104
108
  getDocument: (id: string) => SanityDocument | null
105
109
  seed: (docs: DocumentSet) => void
106
110
  holdSubmissions: () => void
@@ -165,7 +169,12 @@ function createMockContentLake(): MockContentLake {
165
169
 
166
170
  for (const action of actions) {
167
171
  const mutations = convertAction(action, next)
168
- next = processMutations({documents: next, mutations, transactionId, timestamp})
172
+ next = processMutations({
173
+ documents: next,
174
+ mutations,
175
+ transactionId,
176
+ timestamp,
177
+ })
169
178
  allMutations.push(...mutations)
170
179
  }
171
180
 
@@ -378,6 +387,15 @@ const getSpanText = (doc: RigDocument | null, blockKey: string): string | undefi
378
387
  return blockNode?.children[0]?.text
379
388
  }
380
389
 
390
+ /** concatenated text of every span in a block, in child order */
391
+ const blockText = (doc: RigDocument | null, blockKey: string): string | undefined => {
392
+ const blockNode = doc?.content?.find((b) => b._key === blockKey)
393
+ return blockNode?.children.map((child) => child.text).join('')
394
+ }
395
+
396
+ /** diff-match-patch payload transforming `before` into `after` */
397
+ const dmp = (before: string, after: string): string => stringifyPatches(makePatches(before, after))
398
+
381
399
  const getSpanMarks = (doc: RigDocument | null, blockKey: string): string[] | undefined => {
382
400
  const blockNode = doc?.content?.find((b) => b._key === blockKey)
383
401
  return blockNode?.children[0]?.marks
@@ -390,7 +408,11 @@ const getSpanMarks = (doc: RigDocument | null, blockKey: string): string[] | und
390
408
  * Keyed by `scenarioId` with an optional restriction on which apply order
391
409
  * exhibits the loss.
392
410
  */
393
- const KNOWN_LOSS: {scenario: string; firstWriter?: 'A' | 'B'; reason: string}[] = [
411
+ const KNOWN_LOSS: {
412
+ scenario: string
413
+ firstWriter?: 'A' | 'B'
414
+ reason: string
415
+ }[] = [
394
416
  {
395
417
  scenario: 'preserved-set-text-vs-typing',
396
418
  reason:
@@ -492,11 +514,19 @@ const scenarios: Scenario[] = [
492
514
  id: 'different-blocks-text',
493
515
  title: 'A and B type into different blocks of the same array',
494
516
  actA: editField(
495
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
517
+ {
518
+ set: {
519
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
520
+ },
521
+ },
496
522
  'txn-a',
497
523
  ),
498
524
  actB: editField(
499
- {set: {'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel'}},
525
+ {
526
+ set: {
527
+ 'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
528
+ },
529
+ },
500
530
  'txn-b',
501
531
  ),
502
532
  verify: ({serverDraft}) => {
@@ -508,11 +538,19 @@ const scenarios: Scenario[] = [
508
538
  id: 'same-span-disjoint-offsets',
509
539
  title: 'A and B type into the same span at different offsets',
510
540
  actA: editField(
511
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
541
+ {
542
+ set: {
543
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
544
+ },
545
+ },
512
546
  'txn-a',
513
547
  ),
514
548
  actB: editField(
515
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta'}},
549
+ {
550
+ set: {
551
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
552
+ },
553
+ },
516
554
  'txn-b',
517
555
  ),
518
556
  verify: ({serverDraft}) => {
@@ -561,7 +599,11 @@ const scenarios: Scenario[] = [
561
599
  })
562
600
  },
563
601
  actB: editField(
564
- {set: {'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel'}},
602
+ {
603
+ set: {
604
+ 'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
605
+ },
606
+ },
565
607
  'txn-b',
566
608
  ),
567
609
  verify: ({serverDraft}) => {
@@ -579,11 +621,25 @@ const scenarios: Scenario[] = [
579
621
  id: 'preserved-keyed-inserts',
580
622
  title: 'A and B insert different blocks with preserveOperations',
581
623
  actA: editPreserved(
582
- [{insert: {after: 'content[_key=="blkA"]', items: [block('blkC', 'spC', 'inserted by A')]}}],
624
+ [
625
+ {
626
+ insert: {
627
+ after: 'content[_key=="blkA"]',
628
+ items: [block('blkC', 'spC', 'inserted by A')],
629
+ },
630
+ },
631
+ ],
583
632
  'txn-a',
584
633
  ),
585
634
  actB: editPreserved(
586
- [{insert: {after: 'content[_key=="blkB"]', items: [block('blkD', 'spD', 'inserted by B')]}}],
635
+ [
636
+ {
637
+ insert: {
638
+ after: 'content[_key=="blkB"]',
639
+ items: [block('blkD', 'spD', 'inserted by B')],
640
+ },
641
+ },
642
+ ],
587
643
  'txn-b',
588
644
  ),
589
645
  verify: ({serverDraft}) => {
@@ -633,11 +689,19 @@ const scenarios: Scenario[] = [
633
689
  id: 'format-vs-typing',
634
690
  title: 'A formats a span (marks) while B types into it',
635
691
  actA: editField(
636
- {set: {'content[_key=="blkA"].children[_key=="spA"].marks': ['strong']}},
692
+ {
693
+ set: {
694
+ 'content[_key=="blkA"].children[_key=="spA"].marks': ['strong'],
695
+ },
696
+ },
637
697
  'txn-a',
638
698
  ),
639
699
  actB: editField(
640
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo twoB charlie delta'}},
700
+ {
701
+ set: {
702
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo twoB charlie delta',
703
+ },
704
+ },
641
705
  'txn-b',
642
706
  ),
643
707
  verify: ({serverDraft}) => {
@@ -831,16 +895,107 @@ describe.each([
831
895
  interface RebaseScenario {
832
896
  id: string
833
897
  title: string
898
+ /** custom seed; defaults to `seedDocument()` */
899
+ seed?: () => SanityDocument
834
900
  /** first edit from A; becomes the in-flight (held) txn-a1 */
835
901
  actA1: Act
836
902
  /** second edit from A; stays in `applied` until txn-a1 clears */
837
903
  actA2: Act
904
+ /** optional third edit from A, queued behind txn-a2 (txn-a3) */
905
+ actA3?: Act
838
906
  /** concurrent edit from B (txn-b) */
839
907
  actB: Act
908
+ /** optional second edit from B, applied after the first releases (txn-b2) */
909
+ actB2?: Act
840
910
  verify: (result: CollisionResult, firstWriter: 'A' | 'B') => void
841
911
  }
842
912
 
913
+ /**
914
+ * The exact three transactions a live portabletext/editor client pushed
915
+ * while a user toggled bold on "armadillo" twice (select word, bold,
916
+ * unbold), captured from a two-client harness trace that ended with the
917
+ * store scrambling the fragment order. Each transaction is a span split or
918
+ * merge: dmp-trim a span, insert keyed fragments after it, set marks, and
919
+ * on merge extend a fragment and unset its siblings. Applied in sequence
920
+ * (verified against real Content Lake) they reconstruct the seed sentence
921
+ * exactly.
922
+ */
923
+ const LIVE_SPLIT_SENTENCE = 'A: There is an armadillo in the restaurant and it wants a taco '
924
+ // prettier-ignore
925
+ const LIVE_SPLIT_PUSHES: PatchOperations[][] = [
926
+ [
927
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -53,11 +53,4 @@\n nts \n-a taco \n'}},
928
+ {setIfMissing: {'content[_key=="blockA"].children': []}},
929
+ {insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: '31f77137d3fb', _type: 'span', marks: [], text: 'a taco '}]}},
930
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -44,13 +44,4 @@\n and \n-it wants \n'}},
931
+ {setIfMissing: {'content[_key=="blockA"].children': []}},
932
+ {insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: '6d39d5732d93', _type: 'span', marks: [], text: 'it wants '}]}},
933
+ {set: {'content[_key=="blockA"].children[_key=="6d39d5732d93"].marks': ['strong']}},
934
+ ],
935
+ [
936
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -35,13 +35,4 @@\n stau\n-rant and \n'}},
937
+ {setIfMissing: {'content[_key=="blockA"].children': []}},
938
+ {insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: 'a014aab8b2c6', _type: 'span', marks: [], text: 'rant and '}]}},
939
+ {set: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].marks': ['strong']}},
940
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].text': '@@ -2,8 +2,17 @@\n ant and \n+it wants \n'}},
941
+ {unset: ['content[_key=="blockA"].children[_key=="6d39d5732d93"]']},
942
+ ],
943
+ [
944
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].text': '@@ -1,18 +1,4 @@\n rant\n- and it wants \n'}},
945
+ {setIfMissing: {'content[_key=="blockA"].children': []}},
946
+ {insert: {after: 'content[_key=="blockA"].children[_key=="a014aab8b2c6"]', items: [{_key: '0ebc53c64b6c', _type: 'span', marks: ['strong'], text: ' and it wants '}]}},
947
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="spanA"].text': '@@ -30,9 +30,4 @@\n he r\n-estau\n'}},
948
+ {setIfMissing: {'content[_key=="blockA"].children': []}},
949
+ {insert: {after: 'content[_key=="blockA"].children[_key=="spanA"]', items: [{_key: 'f508bf653099', _type: 'span', marks: [], text: 'estau'}]}},
950
+ {set: {'content[_key=="blockA"].children[_key=="f508bf653099"].marks': ['strong']}},
951
+ {set: {'content[_key=="blockA"].children[_key=="a014aab8b2c6"].marks': ['strong']}},
952
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="f508bf653099"].text': '@@ -1,5 +1,9 @@\n estau\n+rant\n'}},
953
+ {unset: ['content[_key=="blockA"].children[_key=="a014aab8b2c6"]']},
954
+ {diffMatchPatch: {'content[_key=="blockA"].children[_key=="f508bf653099"].text': '@@ -2,8 +2,22 @@\n staurant\n+ and it wants \n'}},
955
+ {unset: ['content[_key=="blockA"].children[_key=="0ebc53c64b6c"]']},
956
+ ],
957
+ ]
958
+
959
+ function liveSplitSeed(): SanityDocument {
960
+ return {
961
+ ...seedDocument(),
962
+ content: [block('blockA', 'spanA', LIVE_SPLIT_SENTENCE), block('blockB', 'spanB', 'B: ')],
963
+ }
964
+ }
965
+
843
966
  const rebaseScenarios: RebaseScenario[] = [
967
+ {
968
+ id: 'rebase-live-span-split-capture',
969
+ title: 'the captured live bold-toggle transaction chain survives interleaved foreign edits',
970
+ seed: liveSplitSeed,
971
+ actA1: editPreserved(LIVE_SPLIT_PUSHES[0], 'txn-a1'),
972
+ actA2: editPreserved(LIVE_SPLIT_PUSHES[1], 'txn-a2'),
973
+ actA3: editPreserved(LIVE_SPLIT_PUSHES[2], 'txn-a3'),
974
+ actB: editField(
975
+ {
976
+ set: {
977
+ 'content[_key=="blockB"].children[_key=="spanB"].text': 'B: coverage ',
978
+ },
979
+ },
980
+ 'txn-b',
981
+ ),
982
+ actB2: editField(
983
+ {
984
+ set: {
985
+ 'content[_key=="blockB"].children[_key=="spanB"].text': 'B: coverage exclusive ',
986
+ },
987
+ },
988
+ 'txn-b2',
989
+ ),
990
+ verify: ({serverDraft, local}) => {
991
+ // Formatting toggles must never change the text, on the server or in
992
+ // either client's local view.
993
+ expect(blockText(serverDraft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
994
+ expect(blockText(local.A.draft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
995
+ expect(blockText(local.B.draft, 'blockA')).toBe(LIVE_SPLIT_SENTENCE)
996
+ expect(blockText(serverDraft, 'blockB')).toBe('B: coverage exclusive ')
997
+ },
998
+ },
844
999
  {
845
1000
  id: 'rebase-pending-set-stomps-remote',
846
1001
  title: "A's pending whole-string set is re-applied over B's concurrent insert",
@@ -866,7 +1021,11 @@ const rebaseScenarios: RebaseScenario[] = [
866
1021
  id: 'rebase-pending-span-set-stomps-remote',
867
1022
  title: "A's pending span text set is re-applied over B's concurrent typing in the same span",
868
1023
  actA1: editField(
869
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta'}},
1024
+ {
1025
+ set: {
1026
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha oneA bravo charlie delta',
1027
+ },
1028
+ },
870
1029
  'txn-a1',
871
1030
  ),
872
1031
  actA2: ({instance, resource}) => {
@@ -875,7 +1034,9 @@ const rebaseScenarios: RebaseScenario[] = [
875
1034
  return applyDocumentActions(instance, {
876
1035
  actions: [
877
1036
  editDocument(handle, {
878
- set: {'content[_key=="blkA"].children[_key=="spA"].text': `${text} moreA`},
1037
+ set: {
1038
+ 'content[_key=="blkA"].children[_key=="spA"].text': `${text} moreA`,
1039
+ },
879
1040
  }),
880
1041
  ],
881
1042
  resource,
@@ -883,7 +1044,11 @@ const rebaseScenarios: RebaseScenario[] = [
883
1044
  })
884
1045
  },
885
1046
  actB: editField(
886
- {set: {'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta'}},
1047
+ {
1048
+ set: {
1049
+ 'content[_key=="blkA"].children[_key=="spA"].text': 'alpha bravo charlie twoB delta',
1050
+ },
1051
+ },
887
1052
  'txn-b',
888
1053
  ),
889
1054
  verify: ({serverDraft}) => {
@@ -905,19 +1070,126 @@ const rebaseScenarios: RebaseScenario[] = [
905
1070
  expect(serverDraft?.subtitle).toBe('subtitle from B')
906
1071
  },
907
1072
  },
1073
+ {
1074
+ id: 'rebase-pte-span-split',
1075
+ title:
1076
+ "A's pending span-split (bold toggle) is rebased over B's concurrent typing in another block",
1077
+ // Mirrors the patch language portabletext/editor emits when a user
1078
+ // selects a word and toggles bold: dmp-trim the span, insert keyed
1079
+ // fragments anchored after it, set marks — then (second transaction)
1080
+ // merge back: extend a fragment with dmp and unset its sibling. Captured
1081
+ // from a live two-client trace where the store settled on scrambled
1082
+ // fragment order while the editor's local application was correct.
1083
+ actA1: editPreserved(
1084
+ [
1085
+ {
1086
+ diffMatchPatch: {
1087
+ [`content[_key=="blkA"].children[_key=="spA"].text`]: dmp(
1088
+ 'alpha bravo charlie delta',
1089
+ 'alpha bravo charlie ',
1090
+ ),
1091
+ },
1092
+ },
1093
+ {setIfMissing: {[`content[_key=="blkA"].children`]: []}},
1094
+ {
1095
+ insert: {
1096
+ after: `content[_key=="blkA"].children[_key=="spA"]`,
1097
+ items: [span('fragTail', 'delta')],
1098
+ },
1099
+ },
1100
+ {
1101
+ diffMatchPatch: {
1102
+ [`content[_key=="blkA"].children[_key=="spA"].text`]: dmp(
1103
+ 'alpha bravo charlie ',
1104
+ 'alpha bravo ',
1105
+ ),
1106
+ },
1107
+ },
1108
+ {setIfMissing: {[`content[_key=="blkA"].children`]: []}},
1109
+ {
1110
+ insert: {
1111
+ after: `content[_key=="blkA"].children[_key=="spA"]`,
1112
+ items: [span('fragBold', 'charlie ')],
1113
+ },
1114
+ },
1115
+ {
1116
+ set: {
1117
+ [`content[_key=="blkA"].children[_key=="fragBold"].marks`]: ['strong'],
1118
+ },
1119
+ },
1120
+ ],
1121
+ 'txn-a1',
1122
+ ),
1123
+ // second toggle: unbold and merge the fragments back
1124
+ actA2: editPreserved(
1125
+ [
1126
+ {
1127
+ set: {
1128
+ [`content[_key=="blkA"].children[_key=="fragBold"].marks`]: [],
1129
+ },
1130
+ },
1131
+ {
1132
+ diffMatchPatch: {
1133
+ [`content[_key=="blkA"].children[_key=="fragBold"].text`]: dmp(
1134
+ 'charlie ',
1135
+ 'charlie delta',
1136
+ ),
1137
+ },
1138
+ },
1139
+ {unset: [`content[_key=="blkA"].children[_key=="fragTail"]`]},
1140
+ ],
1141
+ 'txn-a2',
1142
+ ),
1143
+ actB: editField(
1144
+ {
1145
+ set: {
1146
+ 'content[_key=="blkB"].children[_key=="spB"].text': 'echo foxtrot twoB golf hotel',
1147
+ },
1148
+ },
1149
+ 'txn-b',
1150
+ ),
1151
+ verify: ({serverDraft}) => {
1152
+ // Formatting toggles must never change the text: block A reads exactly
1153
+ // the seed sentence, in order, regardless of apply order.
1154
+ expect(blockText(serverDraft, 'blkA')).toBe('alpha bravo charlie delta')
1155
+ // and B's concurrent edit in the other block survives
1156
+ expect(blockText(serverDraft, 'blkB')).toBe('echo foxtrot twoB golf hotel')
1157
+ },
1158
+ },
908
1159
  {
909
1160
  id: 'rebase-preserved-keyed-inserts',
910
1161
  title: "A's pending preserveOperations insert is rebased over B's concurrent keyed insert",
911
1162
  actA1: editPreserved(
912
- [{insert: {after: 'content[_key=="blkA"]', items: [block('blkC', 'spC', 'inserted by A1')]}}],
1163
+ [
1164
+ {
1165
+ insert: {
1166
+ after: 'content[_key=="blkA"]',
1167
+ items: [block('blkC', 'spC', 'inserted by A1')],
1168
+ },
1169
+ },
1170
+ ],
913
1171
  'txn-a1',
914
1172
  ),
915
1173
  actA2: editPreserved(
916
- [{insert: {after: 'content[_key=="blkC"]', items: [block('blkE', 'spE', 'inserted by A2')]}}],
1174
+ [
1175
+ {
1176
+ insert: {
1177
+ after: 'content[_key=="blkC"]',
1178
+ items: [block('blkE', 'spE', 'inserted by A2')],
1179
+ },
1180
+ },
1181
+ ],
917
1182
  'txn-a2',
918
1183
  ),
919
1184
  actB: editPreserved(
920
- [{insert: {after: 'content[_key=="blkB"]', items: [block('blkD', 'spD', 'inserted by B')]}}],
1185
+ [
1186
+ {
1187
+ insert: {
1188
+ after: 'content[_key=="blkB"]',
1189
+ items: [block('blkD', 'spD', 'inserted by B')],
1190
+ },
1191
+ },
1192
+ ],
921
1193
  'txn-b',
922
1194
  ),
923
1195
  verify: ({serverDraft}) => {
@@ -933,7 +1205,7 @@ async function runRebaseCollision(
933
1205
  scenario: RebaseScenario,
934
1206
  order: 'a-first' | 'b-first',
935
1207
  ): Promise<CollisionResult> {
936
- server.seed({[DRAFT_ID]: seedDocument()})
1208
+ server.seed({[DRAFT_ID]: (scenario.seed ?? seedDocument)()})
937
1209
 
938
1210
  const stateA = getDocumentState<RigDocument>(clientA.instance, handle)
939
1211
  const stateB = getDocumentState<RigDocument>(clientB.instance, handle)
@@ -953,6 +1225,7 @@ async function runRebaseCollision(
953
1225
  expect(server.pendingTransactionIds()).toContain('txn-a1')
954
1226
  })
955
1227
  await scenario.actA2(clientA)
1228
+ await scenario.actA3?.(clientA)
956
1229
  await scenario.actB(clientB)
957
1230
  await vi.waitFor(() => {
958
1231
  expect(server.pendingTransactionIds()).toContain('txn-b')
@@ -962,11 +1235,29 @@ async function runRebaseCollision(
962
1235
  await server.release(firstTxn)
963
1236
  await server.release(secondTxn)
964
1237
 
965
- // txn-a1's ack clears A's outgoing slot; txn-a2 (rebased) submits next
1238
+ // txn-a1's ack clears A's outgoing slot; the queued edits submit next,
1239
+ // either individually or batched into one outgoing transaction (queued
1240
+ // applied edits coalesce, and the batch carries the last transactionId)
966
1241
  await vi.waitFor(() => {
967
- expect(server.pendingTransactionIds()).toContain('txn-a2')
1242
+ expect(server.pendingTransactionIds().some((id) => id.startsWith('txn-a'))).toBe(true)
968
1243
  })
969
- await server.release('txn-a2')
1244
+ // B's second edit (if any) lands while A's follow-up is in flight, so
1245
+ // any remaining rebase happens over a remote that already contains it
1246
+ if (scenario.actB2) {
1247
+ await scenario.actB2(clientB)
1248
+ await vi.waitFor(() => {
1249
+ expect(server.pendingTransactionIds()).toContain('txn-b2')
1250
+ })
1251
+ await server.release('txn-b2')
1252
+ }
1253
+ // release A transactions until none remain pending or queued
1254
+ for (;;) {
1255
+ const pendingA = server.pendingTransactionIds().filter((id) => id.startsWith('txn-a'))
1256
+ if (pendingA.length === 0) break
1257
+ await server.release(pendingA[0])
1258
+ // let the store submit the next queued transaction, if any
1259
+ await new Promise((resolve) => setTimeout(resolve, 25))
1260
+ }
970
1261
 
971
1262
  const syncA = getDocumentSyncStatus(clientA.instance, handle)
972
1263
  const syncB = getDocumentSyncStatus(clientB.instance, handle)
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type BaseActionOptions,
3
3
  type BaseMutationOptions,
4
+ ClientError,
4
5
  type FilteredResponseQueryOptions,
5
6
  type ListenEvent,
6
7
  type MultipleActionResult,
@@ -17,7 +18,18 @@ import {diffValue} from '@sanity/diff-patch'
17
18
  import {DocumentId, getDraftId, getPublishedId} from '@sanity/id-utils'
18
19
  import {type Mutation, type SanityDocument} from '@sanity/types'
19
20
  import {evaluate, parse} from 'groq-js'
20
- import {delay, first, firstValueFrom, from, Observable, of, ReplaySubject, Subject} from 'rxjs'
21
+ import {
22
+ defer,
23
+ delay,
24
+ first,
25
+ firstValueFrom,
26
+ from,
27
+ Observable,
28
+ of,
29
+ ReplaySubject,
30
+ Subject,
31
+ throwError,
32
+ } from 'rxjs'
21
33
  import {afterEach, beforeEach, expect, it, vi} from 'vitest'
22
34
 
23
35
  import {getClientState} from '../client/clientStore'
@@ -917,6 +929,89 @@ it('does not send credentials with the dataset ACL request', async () => {
917
929
  expect(aclCall![0]).not.toHaveProperty('withCredentials')
918
930
  })
919
931
 
932
+ it('retries transient dataset ACL fetch failures instead of failing fatally', async () => {
933
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
934
+
935
+ // the first attempt fails like a dropped connection; subsequent attempts succeed
936
+ let aclAttempts = 0
937
+ client.observable.request = vi.fn().mockReturnValue(
938
+ defer(() => {
939
+ aclAttempts++
940
+ if (aclAttempts === 1) return throwError(() => new Error('socket hang up'))
941
+ return of(datasetAcl)
942
+ }),
943
+ )
944
+
945
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
946
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
947
+
948
+ expect(result).toEqual({allowed: true})
949
+ expect(aclAttempts).toBe(2)
950
+
951
+ // the store never entered the fatal error state
952
+ const documentState = getDocumentState(instance, doc)
953
+ expect(() => documentState.getCurrent()).not.toThrow()
954
+ })
955
+
956
+ it('does not retry dataset ACL fetch failures caused by 4xx client errors', async () => {
957
+ const forbidden = new ClientError({
958
+ statusCode: 403,
959
+ headers: {},
960
+ body: {error: 'Forbidden', message: 'Session does not have access to this dataset'},
961
+ })
962
+
963
+ let aclAttempts = 0
964
+ client.observable.request = vi.fn().mockReturnValue(
965
+ defer(() => {
966
+ aclAttempts++
967
+ return throwError(() => forbidden)
968
+ }),
969
+ )
970
+
971
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
972
+ const documentState = getDocumentState(instance, doc)
973
+ const unsubscribe = documentState.subscribe()
974
+
975
+ // the error is fatal: it surfaces through the store instead of being retried
976
+ await vi.waitFor(() => {
977
+ expect(() => documentState.getCurrent()).toThrow(
978
+ 'Forbidden - Session does not have access to this dataset',
979
+ )
980
+ })
981
+ expect(aclAttempts).toBe(1)
982
+
983
+ unsubscribe()
984
+ })
985
+
986
+ it('retries dataset ACL fetch failures caused by 429 rate-limit errors', async () => {
987
+ const rateLimited = new ClientError({
988
+ statusCode: 429,
989
+ headers: {},
990
+ body: {error: 'Too Many Requests', message: 'Rate limit exceeded'},
991
+ })
992
+ const datasetAcl = [{filter: 'true', permissions: ['read', 'update', 'create', 'history']}]
993
+
994
+ // the first attempt is rate limited; subsequent attempts succeed
995
+ let aclAttempts = 0
996
+ client.observable.request = vi.fn().mockReturnValue(
997
+ defer(() => {
998
+ aclAttempts++
999
+ if (aclAttempts === 1) return throwError(() => rateLimited)
1000
+ return of(datasetAcl)
1001
+ }),
1002
+ )
1003
+
1004
+ const doc = createDocumentHandle({documentId: crypto.randomUUID(), documentType: 'article'})
1005
+ const result = await resolvePermissions(instance, {actions: [createDocument(doc)]})
1006
+
1007
+ expect(result).toEqual({allowed: true})
1008
+ expect(aclAttempts).toBe(2)
1009
+
1010
+ // the store never entered the fatal error state
1011
+ const documentState = getDocumentState(instance, doc)
1012
+ expect(() => documentState.getCurrent()).not.toThrow()
1013
+ })
1014
+
920
1015
  it('fetches ACL for MediaLibraryResource', async () => {
921
1016
  const mediaLibraryInstance = createSanityInstance({
922
1017
  projectId: 'p',
@@ -1250,6 +1345,8 @@ vi.mock('./documentConstants.ts', async (importOriginal) => {
1250
1345
  DOCUMENT_STATE_CLEAR_DELAY: 25,
1251
1346
  OUT_OF_SYNC_RETRY_BASE_DELAY: 0,
1252
1347
  OUT_OF_SYNC_RETRY_MAX_DELAY: 0,
1348
+ ACL_RETRY_BASE_DELAY: 0,
1349
+ ACL_RETRY_MAX_DELAY: 0,
1253
1350
  }
1254
1351
  })
1255
1352
 
@@ -1,4 +1,4 @@
1
- import {type Action, type Mutation} from '@sanity/client'
1
+ import {type Action, ClientError, CorsOriginError, type Mutation} from '@sanity/client'
2
2
  import {DocumentId, getDraftId, getPublishedId, getVersionId} from '@sanity/id-utils'
3
3
  import {jsonMatch} from '@sanity/json-match'
4
4
  import {type SanityDocument} from 'groq'
@@ -48,6 +48,8 @@ import {createStateSourceAction, type StateSource} from '../store/createStateSou
48
48
  import {defineStore, type StoreContext} from '../store/defineStore'
49
49
  import {type DocumentAction} from './actions'
50
50
  import {
51
+ ACL_RETRY_BASE_DELAY,
52
+ ACL_RETRY_MAX_DELAY,
51
53
  API_VERSION,
52
54
  INITIAL_OUTGOING_THROTTLE_TIME,
53
55
  OUT_OF_SYNC_RETRY_BASE_DELAY,
@@ -562,10 +564,39 @@ const subscribeToClientAndFetchDatasetAcl = ({
562
564
  return getClientState(instance, clientOptions)
563
565
  .observable.pipe(
564
566
  switchMap((client) =>
565
- client.observable.request<DatasetAcl>({
566
- uri,
567
- tag: 'acl.get',
568
- }),
567
+ client.observable
568
+ .request<DatasetAcl>({
569
+ uri,
570
+ tag: 'acl.get',
571
+ })
572
+ .pipe(
573
+ retry({
574
+ delay: (error, retryCount) => {
575
+ // 4xx responses and CORS misconfigurations are not transient —
576
+ // the server will keep rejecting the request, so rethrow to
577
+ // surface them as a fatal store error. 408 (request timeout)
578
+ // and 429 (rate limit) are the exceptions: they resolve on
579
+ // their own, so they are retried like network errors
580
+ const isTransientClientError =
581
+ error instanceof ClientError &&
582
+ (error.statusCode === 408 || error.statusCode === 429)
583
+ if (
584
+ (error instanceof ClientError && !isTransientClientError) ||
585
+ error instanceof CorsOriginError
586
+ ) {
587
+ return throwError(() => error)
588
+ }
589
+ // network errors (no status code), 408/429 responses, and 5xx
590
+ // responses are retried with exponential backoff so a transient
591
+ // failure during startup doesn't permanently brick the store
592
+ const backoff = Math.min(
593
+ ACL_RETRY_BASE_DELAY * 2 ** (retryCount - 1),
594
+ ACL_RETRY_MAX_DELAY,
595
+ )
596
+ return timer(backoff)
597
+ },
598
+ }),
599
+ ),
569
600
  ),
570
601
  tap((datasetAcl) => state.set('setGrants', {grants: createGrantsLookup(datasetAcl)})),
571
602
  )