@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.
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +196 -22
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/authStore-CWTAuieY.js +1072 -0
- package/dist/authStore-CWTAuieY.js.map +1 -0
- package/dist/clientStore-CMzJ7XhZ.js +168 -0
- package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
- package/dist/getEnv-76AjZd_f.js +178 -0
- package/dist/getEnv-76AjZd_f.js.map +1 -0
- package/dist/index.d.ts +2 -1921
- package/dist/index.js +4165 -3711
- package/dist/index.js.map +1 -1
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/telemetryManager-Bd3O8z2S.js +127 -0
- package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
- package/dist/utils-BMwA8Kg9.d.ts +3334 -0
- package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
- package/dist/version-DL2l07HG.js +9 -0
- package/dist/version-DL2l07HG.js.map +1 -0
- package/package.json +13 -21
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +37 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/document/documentConstants.ts +8 -0
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/document/documentStore.test.ts +98 -1
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +133 -0
- package/src/document/reducers.ts +18 -3
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/projection/projectionStore.test.ts +0 -8
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -133
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- 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: () => {
|
|
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({
|
|
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: {
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: {
|
|
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
|
-
{
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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;
|
|
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()).
|
|
1242
|
+
expect(server.pendingTransactionIds().some((id) => id.startsWith('txn-a'))).toBe(true)
|
|
968
1243
|
})
|
|
969
|
-
|
|
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 {
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
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
|
)
|