@@ -899,4 +899,155 @@ describe("Schema", () => {
899899 ) . toBe ( true ) ;
900900 } ) ;
901901
902+ it ( 'offers all paths of a deeply nested non-recursive object as ReferenceValue suggestions' , ( ) => {
903+ // Node 1: custom::test::deeply_nested(): a 10-level nested object ending in TEXT.
904+ // Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST<TEXT>
905+ //
906+ // Reference path extraction caps traversal depth only for recursive data
907+ // types. A deeply nested but acyclic object must be traversed exhaustively,
908+ // so even the leaf 10 levels down is offered as a suggestion.
909+ const DEEPLY_NESTED_FN : FunctionDefinition = {
910+ id : "gid://sagittarius/FunctionDefinition/9004" ,
911+ identifier : "custom::test::deeply_nested" ,
912+ signature : "(): { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: TEXT } } } } } } } } } }" ,
913+ } ;
914+
915+ const flow : Flow = {
916+ id : "gid://sagittarius/Flow/1" ,
917+ startingNodeId : "gid://sagittarius/NodeFunction/1" ,
918+ signature : "(): void" ,
919+ nodes : {
920+ nodes : [
921+ {
922+ id : "gid://sagittarius/NodeFunction/1" ,
923+ functionDefinition : { identifier : "custom::test::deeply_nested" } ,
924+ nextNodeId : "gid://sagittarius/NodeFunction/2" ,
925+ parameters : {
926+ nodes : [ ] ,
927+ } ,
928+ } ,
929+ {
930+ id : "gid://sagittarius/NodeFunction/2" ,
931+ functionDefinition : { identifier : "std::text::split" } ,
932+ parameters : {
933+ nodes : [
934+ { value : null } ,
935+ { value : { __typename : "LiteralValue" , value : "," } } ,
936+ ] ,
937+ } ,
938+ } ,
939+ ] ,
940+ } ,
941+ } ;
942+
943+ const [ valueSchema ] = getSignatureSchema (
944+ flow ,
945+ DATA_TYPES ,
946+ [ ...FUNCTION_SIGNATURES , DEEPLY_NESTED_FN ] ,
947+ "gid://sagittarius/NodeFunction/2" ,
948+ ) ;
949+
950+ // value: TEXT → free-form text input.
951+ expect ( valueSchema . schema . input ) . toBe ( "text" ) ;
952+
953+ const paths = ( ( valueSchema . schema . suggestions ?? [ ] ) as any [ ] )
954+ . filter (
955+ ( s ) =>
956+ s . __typename === "ReferenceValue" &&
957+ s . nodeFunctionId === "gid://sagittarius/NodeFunction/1" ,
958+ )
959+ . map ( ( s ) => ( s . referencePath ?? [ ] ) . map ( ( p : any ) => p . path ) . join ( "." ) ) ;
960+
961+ expect ( paths ) . toContain ( "l1.l2.l3.l4.l5.l6.l7.l8.l9.l10" ) ;
962+ } ) ;
963+
964+ it ( 'cuts cycles and depth-caps traversal of recursive data types' , ( ) => {
965+ // Two families of recursive data types, both returned by node 1:
966+ //
967+ // 1. A mutually recursive pair (RECURSIVE_ORDER ↔ RECURSIVE_CUSTOMER), the
968+ // same shape external actions ship for entity graphs (e.g. Shopware's
969+ // order → delivery → order). A branch must stop as soon as a type
970+ // already on it reappears, instead of overflowing the stack.
971+ //
972+ // 2. A cycle of 8 distinct types (CHAIN_1 → … → CHAIN_8 → CHAIN_1). The
973+ // per-branch cycle cut alone would allow simple paths through all 8
974+ // types, so recursive types are additionally depth-capped at 7 levels.
975+ const RECURSIVE_DATA_TYPES = [
976+ { identifier : "RECURSIVE_ORDER" , genericKeys : [ ] , type : "{ id: TEXT; customer?: RECURSIVE_CUSTOMER | null }" } ,
977+ { identifier : "RECURSIVE_CUSTOMER" , genericKeys : [ ] , type : "{ name: TEXT; lastOrder?: RECURSIVE_ORDER | null }" } ,
978+ { identifier : "CHAIN_1" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_2 | null }" } ,
979+ { identifier : "CHAIN_2" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_3 | null }" } ,
980+ { identifier : "CHAIN_3" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_4 | null }" } ,
981+ { identifier : "CHAIN_4" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_5 | null }" } ,
982+ { identifier : "CHAIN_5" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_6 | null }" } ,
983+ { identifier : "CHAIN_6" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_7 | null }" } ,
984+ { identifier : "CHAIN_7" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_8 | null }" } ,
985+ { identifier : "CHAIN_8" , genericKeys : [ ] , type : "{ value: TEXT; next?: CHAIN_1 | null }" } ,
986+ ] ;
987+
988+ const RECURSIVE_FN : FunctionDefinition = {
989+ id : "gid://sagittarius/FunctionDefinition/9005" ,
990+ identifier : "custom::test::recursive" ,
991+ signature : "(): { pair: RECURSIVE_ORDER; chain: CHAIN_1 }" ,
992+ } ;
993+
994+ const flow : Flow = {
995+ id : "gid://sagittarius/Flow/1" ,
996+ startingNodeId : "gid://sagittarius/NodeFunction/1" ,
997+ signature : "(): void" ,
998+ nodes : {
999+ nodes : [
1000+ {
1001+ id : "gid://sagittarius/NodeFunction/1" ,
1002+ functionDefinition : { identifier : "custom::test::recursive" } ,
1003+ nextNodeId : "gid://sagittarius/NodeFunction/2" ,
1004+ parameters : {
1005+ nodes : [ ] ,
1006+ } ,
1007+ } ,
1008+ {
1009+ id : "gid://sagittarius/NodeFunction/2" ,
1010+ functionDefinition : { identifier : "std::text::split" } ,
1011+ parameters : {
1012+ nodes : [
1013+ { value : null } ,
1014+ { value : { __typename : "LiteralValue" , value : "," } } ,
1015+ ] ,
1016+ } ,
1017+ } ,
1018+ ] ,
1019+ } ,
1020+ } ;
1021+
1022+ const [ valueSchema ] = getSignatureSchema (
1023+ flow ,
1024+ [ ...DATA_TYPES , ...RECURSIVE_DATA_TYPES ] ,
1025+ [ ...FUNCTION_SIGNATURES , RECURSIVE_FN ] ,
1026+ "gid://sagittarius/NodeFunction/2" ,
1027+ ) ;
1028+
1029+ // value: TEXT → free-form text input.
1030+ expect ( valueSchema . schema . input ) . toBe ( "text" ) ;
1031+
1032+ const paths = ( ( valueSchema . schema . suggestions ?? [ ] ) as any [ ] )
1033+ . filter (
1034+ ( s ) =>
1035+ s . __typename === "ReferenceValue" &&
1036+ s . nodeFunctionId === "gid://sagittarius/NodeFunction/1" ,
1037+ )
1038+ . map ( ( s ) => ( s . referencePath ?? [ ] ) . map ( ( p : any ) => p . path ) . join ( "." ) ) ;
1039+
1040+ // Properties inside the cycle are still reachable …
1041+ expect ( paths ) . toContain ( "pair.id" ) ;
1042+ expect ( paths ) . toContain ( "pair.customer.name" ) ;
1043+ // … but the branch stops where RECURSIVE_ORDER would reappear on it.
1044+ expect ( paths ) . not . toContain ( "pair.customer.lastOrder.id" ) ;
1045+
1046+ // The chain is walked through distinct types up to the depth cap of 7
1047+ // (chain + 5×next + value) …
1048+ expect ( paths ) . toContain ( "chain.next.next.next.next.next.value" ) ;
1049+ // … and no further, even though the next type would still be new to the branch.
1050+ expect ( paths ) . not . toContain ( "chain.next.next.next.next.next.next.value" ) ;
1051+ } ) ;
1052+
9021053} )
0 commit comments