@sanity/sdk 2.18.0 → 3.0.0-rc.1
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/_chunks-dts/createGroqSearchFilter.d.ts +233 -12
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/_internal.js +1 -0
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +284 -6
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +2 -2
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +3 -1
- package/dist/index.d.ts +599 -185
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +492 -337
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/_exports/_internal.ts +11 -0
- package/src/_exports/index.ts +57 -12
- package/src/access/checkPermissions.test.ts +55 -0
- package/src/access/checkPermissions.ts +54 -0
- package/src/applications/applications.test-d.ts +83 -0
- package/src/applications/applications.test.ts +130 -0
- package/src/applications/applications.ts +364 -0
- package/src/applications/userApplications.test.ts +126 -0
- package/src/applications/userApplications.ts +261 -0
- package/src/auth/getOrganizationVerificationState.test.ts +7 -7
- package/src/auth/getOrganizationVerificationState.ts +4 -4
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/installations/installations.test-d.ts +68 -0
- package/src/installations/installations.test.ts +62 -0
- package/src/installations/installations.ts +202 -0
- package/src/organization/organization.test-d.ts +18 -84
- package/src/organization/organization.test.ts +11 -11
- package/src/organization/organization.ts +26 -54
- package/src/organizations/organizations.test-d.ts +11 -32
- package/src/organizations/organizations.test.ts +8 -8
- package/src/organizations/organizations.ts +29 -54
- package/src/project/project.test-d.ts +19 -75
- package/src/project/project.test.ts +57 -10
- package/src/project/project.ts +33 -52
- package/src/projects/projects.test-d.ts +15 -21
- package/src/projects/projects.test.ts +8 -8
- package/src/projects/projects.ts +25 -49
- package/src/store/fetcherStore.test-d.ts +121 -0
- package/src/store/fetcherStore.test.ts +299 -0
- package/src/store/fetcherStore.ts +801 -0
- package/src/utils/buildQuery.test.ts +14 -0
- package/src/utils/buildQuery.ts +17 -0
- package/src/utils/includeShape.ts +36 -0
- package/src/utils/setCleanupTimeout.ts +21 -8
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- package/src/utils/defineIntent.test.ts +0 -477
- package/src/utils/defineIntent.ts +0 -244
|
@@ -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)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {expectTypeOf, test} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
4
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
5
|
+
import {type FetcherSnapshot} from '../store/fetcherStore'
|
|
6
|
+
import {
|
|
7
|
+
type Installation,
|
|
8
|
+
installation,
|
|
9
|
+
type InstallationAccess,
|
|
10
|
+
type InstallationActiveConfig,
|
|
11
|
+
type InstallationBase,
|
|
12
|
+
type InstallationInclude,
|
|
13
|
+
type InstallationInterface,
|
|
14
|
+
installations,
|
|
15
|
+
type InstallationsResponse,
|
|
16
|
+
} from './installations'
|
|
17
|
+
|
|
18
|
+
const instance = {} as SanityInstance
|
|
19
|
+
|
|
20
|
+
type IncludeKeys = 'activeConfig' | 'access' | 'interfaces'
|
|
21
|
+
|
|
22
|
+
// Whether key `K` of `T` is optional, without an `{}`-based check.
|
|
23
|
+
type KeyModifier<T, K extends keyof T> =
|
|
24
|
+
Pick<T, K> extends Required<Pick<T, K>> ? 'required' : 'optional'
|
|
25
|
+
|
|
26
|
+
test('Installation — no includes: only the base shape', () => {
|
|
27
|
+
expectTypeOf<Installation>().toEqualTypeOf<InstallationBase>()
|
|
28
|
+
expectTypeOf<Installation<never>>().toEqualTypeOf<InstallationBase>()
|
|
29
|
+
expectTypeOf<Extract<keyof Installation, IncludeKeys>>().toEqualTypeOf<never>()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('Installation — each token adds its top-level field, required, others absent', () => {
|
|
33
|
+
expectTypeOf<Installation<'access'>['access']>().toEqualTypeOf<InstallationAccess[]>()
|
|
34
|
+
expectTypeOf<
|
|
35
|
+
Extract<keyof Installation<'access'>, 'activeConfig' | 'interfaces'>
|
|
36
|
+
>().toEqualTypeOf<never>()
|
|
37
|
+
|
|
38
|
+
expectTypeOf<Installation<'interfaces'>['interfaces']>().toEqualTypeOf<InstallationInterface[]>()
|
|
39
|
+
expectTypeOf<
|
|
40
|
+
Installation<'activeConfig'>['activeConfig']
|
|
41
|
+
>().toEqualTypeOf<InstallationActiveConfig | null>()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('Installation — multiple tokens add each field', () => {
|
|
45
|
+
type Both = Installation<'access' | 'interfaces'>
|
|
46
|
+
expectTypeOf<Both['access']>().toEqualTypeOf<InstallationAccess[]>()
|
|
47
|
+
expectTypeOf<Both['interfaces']>().toEqualTypeOf<InstallationInterface[]>()
|
|
48
|
+
expectTypeOf<Extract<keyof Both, 'activeConfig'>>().toEqualTypeOf<never>()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('Installation — a wide include array makes the fields optional', () => {
|
|
52
|
+
type Wide = Installation<InstallationInclude>
|
|
53
|
+
expectTypeOf<KeyModifier<Wide, 'access'>>().toEqualTypeOf<'optional'>()
|
|
54
|
+
expectTypeOf<KeyModifier<Wide, 'interfaces'>>().toEqualTypeOf<'optional'>()
|
|
55
|
+
expectTypeOf<KeyModifier<Wide, 'activeConfig'>>().toEqualTypeOf<'optional'>()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('installations.resolveState — resolves the wide list envelope', () => {
|
|
59
|
+
expectTypeOf(
|
|
60
|
+
installations.resolveState(instance, {organizationId: 'org_1'}),
|
|
61
|
+
).resolves.toEqualTypeOf<InstallationsResponse<InstallationInclude>>()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('installation.getState — exposes the snapshot envelope', () => {
|
|
65
|
+
expectTypeOf(installation.getState(instance, 'inst_1')).toEqualTypeOf<
|
|
66
|
+
StateSource<FetcherSnapshot<Installation<InstallationInclude>>>
|
|
67
|
+
>()
|
|
68
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {of} from 'rxjs'
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {getClientState} from '../client/clientStore'
|
|
6
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
7
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
|
+
import {installation, installations} from './installations'
|
|
9
|
+
|
|
10
|
+
vi.mock('../client/clientStore')
|
|
11
|
+
|
|
12
|
+
describe('installations', () => {
|
|
13
|
+
let instance: SanityInstance
|
|
14
|
+
const request = vi.fn()
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
instance = createSanityInstance({projectId: 'p', dataset: 'd'})
|
|
18
|
+
request.mockReset()
|
|
19
|
+
vi.mocked(getClientState).mockReturnValue({
|
|
20
|
+
observable: of({observable: {request}} as unknown as SanityClient),
|
|
21
|
+
} as StateSource<SanityClient>)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
instance.dispose()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('lists installations for an organization on the global client', async () => {
|
|
29
|
+
const response = {nextCursor: null, data: [{id: 'inst1'}]}
|
|
30
|
+
request.mockReturnValue(of(response))
|
|
31
|
+
|
|
32
|
+
const result = await installations.resolveState(instance, {
|
|
33
|
+
organizationId: 'org1',
|
|
34
|
+
include: ['interfaces'],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual(response)
|
|
38
|
+
expect(getClientState).toHaveBeenCalledWith(instance, {
|
|
39
|
+
apiVersion: 'vX',
|
|
40
|
+
scope: 'global',
|
|
41
|
+
})
|
|
42
|
+
expect(request).toHaveBeenCalledWith({
|
|
43
|
+
uri: '/installations',
|
|
44
|
+
query: {organizationId: 'org1', include: 'interfaces'},
|
|
45
|
+
tag: 'installations.list',
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('fetches a single installation by id', async () => {
|
|
50
|
+
const inst = {id: 'inst1', applicationId: 'app1'}
|
|
51
|
+
request.mockReturnValue(of(inst))
|
|
52
|
+
|
|
53
|
+
const result = await installation.resolveState(instance, 'inst1')
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual(inst)
|
|
56
|
+
expect(request).toHaveBeenCalledWith({
|
|
57
|
+
uri: '/installations/inst1',
|
|
58
|
+
query: {},
|
|
59
|
+
tag: 'installations.get',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|