742 lines
22 KiB
TypeScript
742 lines
22 KiB
TypeScript
/**
|
|
* 노드 에디터 상태 관리 스토어
|
|
*/
|
|
|
|
import { create } from "zustand";
|
|
import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChanges, applyEdgeChanges } from "reactflow";
|
|
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
|
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
|
|
|
interface FlowEditorState {
|
|
// 노드 및 엣지
|
|
nodes: FlowNode[];
|
|
edges: FlowEdge[];
|
|
|
|
// 선택 상태
|
|
selectedNodes: string[];
|
|
selectedEdges: string[];
|
|
|
|
// 플로우 메타데이터
|
|
flowId: number | null;
|
|
flowName: string;
|
|
flowDescription: string;
|
|
|
|
// UI 상태
|
|
isExecuting: boolean;
|
|
isSaving: boolean;
|
|
showValidationPanel: boolean;
|
|
showPropertiesPanel: boolean;
|
|
|
|
// 검증 결과
|
|
validationResult: ValidationResult | null;
|
|
|
|
// ========================================================================
|
|
// 노드 관리
|
|
// ========================================================================
|
|
|
|
setNodes: (nodes: FlowNode[]) => void;
|
|
onNodesChange: (changes: NodeChange[]) => void;
|
|
addNode: (node: FlowNode) => void;
|
|
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
|
removeNode: (id: string) => void;
|
|
removeNodes: (ids: string[]) => void;
|
|
|
|
// ========================================================================
|
|
// 엣지 관리
|
|
// ========================================================================
|
|
|
|
setEdges: (edges: FlowEdge[]) => void;
|
|
onEdgesChange: (changes: EdgeChange[]) => void;
|
|
onConnect: (connection: Connection) => void;
|
|
removeEdge: (id: string) => void;
|
|
removeEdges: (ids: string[]) => void;
|
|
|
|
// ========================================================================
|
|
// 선택 관리
|
|
// ========================================================================
|
|
|
|
selectNode: (id: string, multi?: boolean) => void;
|
|
selectNodes: (ids: string[]) => void;
|
|
selectEdge: (id: string, multi?: boolean) => void;
|
|
clearSelection: () => void;
|
|
|
|
// ========================================================================
|
|
// 플로우 관리
|
|
// ========================================================================
|
|
|
|
loadFlow: (id: number, name: string, description: string, nodes: FlowNode[], edges: FlowEdge[]) => void;
|
|
clearFlow: () => void;
|
|
setFlowName: (name: string) => void;
|
|
setFlowDescription: (description: string) => void;
|
|
saveFlow: () => Promise<{ success: boolean; flowId?: number; message?: string }>;
|
|
exportFlow: () => string;
|
|
|
|
// ========================================================================
|
|
// 검증
|
|
// ========================================================================
|
|
|
|
validateFlow: () => ValidationResult;
|
|
setValidationResult: (result: ValidationResult | null) => void;
|
|
|
|
// ========================================================================
|
|
// UI 상태
|
|
// ========================================================================
|
|
|
|
setIsExecuting: (value: boolean) => void;
|
|
setIsSaving: (value: boolean) => void;
|
|
setShowValidationPanel: (value: boolean) => void;
|
|
setShowPropertiesPanel: (value: boolean) => void;
|
|
|
|
// ========================================================================
|
|
// 유틸리티
|
|
// ========================================================================
|
|
|
|
getNodeById: (id: string) => FlowNode | undefined;
|
|
getEdgeById: (id: string) => FlowEdge | undefined;
|
|
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
|
|
}
|
|
|
|
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
|
// 초기 상태
|
|
nodes: [],
|
|
edges: [],
|
|
selectedNodes: [],
|
|
selectedEdges: [],
|
|
flowId: null,
|
|
flowName: "새 제어 플로우",
|
|
flowDescription: "",
|
|
isExecuting: false,
|
|
isSaving: false,
|
|
showValidationPanel: false,
|
|
showPropertiesPanel: true,
|
|
validationResult: null,
|
|
|
|
// ========================================================================
|
|
// 노드 관리
|
|
// ========================================================================
|
|
|
|
setNodes: (nodes) => set({ nodes }),
|
|
|
|
onNodesChange: (changes) => {
|
|
set({
|
|
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
|
|
});
|
|
},
|
|
|
|
addNode: (node) => {
|
|
set((state) => ({
|
|
nodes: [...state.nodes, node],
|
|
}));
|
|
},
|
|
|
|
updateNode: (id, data) => {
|
|
set((state) => ({
|
|
nodes: state.nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
data: { ...node.data, ...data },
|
|
}
|
|
: node,
|
|
),
|
|
}));
|
|
},
|
|
|
|
removeNode: (id) => {
|
|
set((state) => ({
|
|
nodes: state.nodes.filter((node) => node.id !== id),
|
|
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
|
}));
|
|
},
|
|
|
|
removeNodes: (ids) => {
|
|
set((state) => ({
|
|
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
|
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
|
}));
|
|
},
|
|
|
|
// ========================================================================
|
|
// 엣지 관리
|
|
// ========================================================================
|
|
|
|
setEdges: (edges) => set({ edges }),
|
|
|
|
onEdgesChange: (changes) => {
|
|
set({
|
|
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
|
});
|
|
},
|
|
|
|
onConnect: (connection) => {
|
|
// 연결 검증
|
|
const validation = validateConnection(connection, get().nodes);
|
|
if (!validation.valid) {
|
|
console.warn("연결 검증 실패:", validation.error);
|
|
return;
|
|
}
|
|
|
|
set((state) => ({
|
|
edges: addEdge(
|
|
{
|
|
...connection,
|
|
type: "smoothstep",
|
|
animated: false,
|
|
data: {
|
|
validation: { valid: true },
|
|
},
|
|
},
|
|
state.edges,
|
|
) as FlowEdge[],
|
|
}));
|
|
},
|
|
|
|
removeEdge: (id) => {
|
|
set((state) => ({
|
|
edges: state.edges.filter((edge) => edge.id !== id),
|
|
}));
|
|
},
|
|
|
|
removeEdges: (ids) => {
|
|
set((state) => ({
|
|
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
|
}));
|
|
},
|
|
|
|
// ========================================================================
|
|
// 선택 관리
|
|
// ========================================================================
|
|
|
|
selectNode: (id, multi = false) => {
|
|
set((state) => ({
|
|
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
|
|
}));
|
|
},
|
|
|
|
selectNodes: (ids) => {
|
|
set({
|
|
selectedNodes: ids,
|
|
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
|
|
});
|
|
},
|
|
|
|
selectEdge: (id, multi = false) => {
|
|
set((state) => ({
|
|
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
|
|
}));
|
|
},
|
|
|
|
clearSelection: () => {
|
|
set({ selectedNodes: [], selectedEdges: [] });
|
|
},
|
|
|
|
// ========================================================================
|
|
// 플로우 관리
|
|
// ========================================================================
|
|
|
|
loadFlow: (id, name, description, nodes, edges) => {
|
|
set({
|
|
flowId: id,
|
|
flowName: name,
|
|
flowDescription: description,
|
|
nodes,
|
|
edges,
|
|
selectedNodes: [],
|
|
selectedEdges: [],
|
|
});
|
|
},
|
|
|
|
clearFlow: () => {
|
|
set({
|
|
flowId: null,
|
|
flowName: "새 제어 플로우",
|
|
flowDescription: "",
|
|
nodes: [],
|
|
edges: [],
|
|
selectedNodes: [],
|
|
selectedEdges: [],
|
|
validationResult: null,
|
|
});
|
|
},
|
|
|
|
setFlowName: (name) => set({ flowName: name }),
|
|
setFlowDescription: (description) => set({ flowDescription: description }),
|
|
|
|
saveFlow: async () => {
|
|
const { flowId, flowName, flowDescription, nodes, edges } = get();
|
|
|
|
if (!flowName || flowName.trim() === "") {
|
|
return { success: false, message: "플로우 이름을 입력해주세요." };
|
|
}
|
|
|
|
// 검증
|
|
const validation = get().validateFlow();
|
|
if (!validation.valid) {
|
|
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
|
|
}
|
|
|
|
set({ isSaving: true });
|
|
|
|
try {
|
|
// 플로우 데이터 직렬화
|
|
const flowData = {
|
|
nodes: nodes.map((node) => ({
|
|
id: node.id,
|
|
type: node.type,
|
|
position: node.position,
|
|
data: node.data,
|
|
})),
|
|
edges: edges.map((edge) => ({
|
|
id: edge.id,
|
|
source: edge.source,
|
|
target: edge.target,
|
|
sourceHandle: edge.sourceHandle,
|
|
targetHandle: edge.targetHandle,
|
|
})),
|
|
};
|
|
|
|
const result = flowId
|
|
? await updateNodeFlow({
|
|
flowId,
|
|
flowName,
|
|
flowDescription,
|
|
flowData: JSON.stringify(flowData),
|
|
})
|
|
: await createNodeFlow({
|
|
flowName,
|
|
flowDescription,
|
|
flowData: JSON.stringify(flowData),
|
|
});
|
|
|
|
set({ flowId: result.flowId });
|
|
return { success: true, flowId: result.flowId, message: "저장 완료!" };
|
|
} catch (error) {
|
|
console.error("플로우 저장 오류:", error);
|
|
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
|
|
} finally {
|
|
set({ isSaving: false });
|
|
}
|
|
},
|
|
|
|
exportFlow: () => {
|
|
const { flowName, flowDescription, nodes, edges } = get();
|
|
const flowData = {
|
|
flowName,
|
|
flowDescription,
|
|
nodes,
|
|
edges,
|
|
version: "1.0",
|
|
exportedAt: new Date().toISOString(),
|
|
};
|
|
return JSON.stringify(flowData, null, 2);
|
|
},
|
|
|
|
// ========================================================================
|
|
// 검증
|
|
// ========================================================================
|
|
|
|
validateFlow: () => {
|
|
const { nodes, edges } = get();
|
|
const result = performFlowValidation(nodes, edges);
|
|
set({ validationResult: result });
|
|
return result;
|
|
},
|
|
|
|
setValidationResult: (result) => set({ validationResult: result }),
|
|
|
|
// ========================================================================
|
|
// UI 상태
|
|
// ========================================================================
|
|
|
|
setIsExecuting: (value) => set({ isExecuting: value }),
|
|
setIsSaving: (value) => set({ isSaving: value }),
|
|
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
|
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
|
|
|
// ========================================================================
|
|
// 유틸리티
|
|
// ========================================================================
|
|
|
|
getNodeById: (id) => {
|
|
return get().nodes.find((node) => node.id === id);
|
|
},
|
|
|
|
getEdgeById: (id) => {
|
|
return get().edges.find((edge) => edge.id === id);
|
|
},
|
|
|
|
getConnectedNodes: (nodeId) => {
|
|
const { nodes, edges } = get();
|
|
|
|
const incoming = edges
|
|
.filter((edge) => edge.target === nodeId)
|
|
.map((edge) => nodes.find((node) => node.id === edge.source))
|
|
.filter((node): node is FlowNode => node !== undefined);
|
|
|
|
const outgoing = edges
|
|
.filter((edge) => edge.source === nodeId)
|
|
.map((edge) => nodes.find((node) => node.id === edge.target))
|
|
.filter((node): node is FlowNode => node !== undefined);
|
|
|
|
return { incoming, outgoing };
|
|
},
|
|
}));
|
|
|
|
// ============================================================================
|
|
// 헬퍼 함수들
|
|
// ============================================================================
|
|
|
|
/**
|
|
* 연결 검증
|
|
*/
|
|
function validateConnection(connection: Connection, nodes: FlowNode[]): { valid: boolean; error?: string } {
|
|
const sourceNode = nodes.find((n) => n.id === connection.source);
|
|
const targetNode = nodes.find((n) => n.id === connection.target);
|
|
|
|
if (!sourceNode || !targetNode) {
|
|
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
|
}
|
|
|
|
// 소스 노드가 출력을 가져야 함
|
|
if (isSourceOnlyNode(sourceNode.type)) {
|
|
// OK
|
|
} else if (isActionNode(targetNode.type)) {
|
|
// 액션 노드는 입력만 받을 수 있음
|
|
} else {
|
|
// 기타 경우는 허용
|
|
}
|
|
|
|
// 자기 자신에게 연결 방지
|
|
if (connection.source === connection.target) {
|
|
return { valid: false, error: "자기 자신에게 연결할 수 없습니다" };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* 플로우 검증
|
|
*/
|
|
function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): ValidationResult {
|
|
const errors: ValidationResult["errors"] = [];
|
|
|
|
// 1. 노드가 하나도 없으면 경고
|
|
if (nodes.length === 0) {
|
|
errors.push({
|
|
message: "노드가 없습니다. 최소 하나의 노드를 추가하세요.",
|
|
severity: "warning",
|
|
});
|
|
}
|
|
|
|
// 2. 소스 노드 확인
|
|
const sourceNodes = nodes.filter((n) => isSourceNode(n.type));
|
|
if (sourceNodes.length === 0 && nodes.length > 0) {
|
|
errors.push({
|
|
message: "데이터 소스 노드가 필요합니다.",
|
|
severity: "error",
|
|
});
|
|
}
|
|
|
|
// 3. 액션 노드 확인
|
|
const actionNodes = nodes.filter((n) => isActionNode(n.type));
|
|
if (actionNodes.length === 0 && nodes.length > 0) {
|
|
errors.push({
|
|
message: "최소 하나의 액션 노드가 필요합니다.",
|
|
severity: "error",
|
|
});
|
|
}
|
|
|
|
// 4. 고아 노드 확인 (연결되지 않은 노드) - Comment와 Log는 제외
|
|
nodes.forEach((node) => {
|
|
// Comment와 Log는 독립적으로 존재 가능
|
|
if (node.type === "comment" || node.type === "log") {
|
|
return;
|
|
}
|
|
|
|
const hasIncoming = edges.some((e) => e.target === node.id);
|
|
const hasOutgoing = edges.some((e) => e.source === node.id);
|
|
|
|
if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`,
|
|
severity: "warning",
|
|
});
|
|
}
|
|
});
|
|
|
|
// 5. 액션 노드가 입력을 받는지 확인
|
|
actionNodes.forEach((node) => {
|
|
const hasInput = edges.some((e) => e.target === node.id);
|
|
if (!hasInput) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
});
|
|
|
|
// 6. 순환 참조 검증
|
|
const cycles = detectCycles(nodes, edges);
|
|
cycles.forEach((cycle) => {
|
|
errors.push({
|
|
message: `순환 참조가 감지되었습니다: ${cycle.join(" → ")}`,
|
|
severity: "error",
|
|
});
|
|
});
|
|
|
|
// 7. 노드별 필수 속성 검증
|
|
nodes.forEach((node) => {
|
|
const nodeErrors = validateNodeProperties(node);
|
|
errors.push(...nodeErrors);
|
|
});
|
|
|
|
return {
|
|
valid: errors.filter((e) => e.severity === "error").length === 0,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 소스 노드 여부 확인
|
|
*/
|
|
function isSourceNode(type: NodeType): boolean {
|
|
return type === "tableSource" || type === "externalDBSource" || type === "restAPISource";
|
|
}
|
|
|
|
/**
|
|
* 소스 전용 노드 여부 확인
|
|
*/
|
|
function isSourceOnlyNode(type: NodeType): boolean {
|
|
return isSourceNode(type);
|
|
}
|
|
|
|
/**
|
|
* 액션 노드 여부 확인
|
|
*/
|
|
function isActionNode(type: NodeType): boolean {
|
|
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
|
|
}
|
|
|
|
/**
|
|
* 순환 참조 검증 (DFS 사용)
|
|
*/
|
|
function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
|
const cycles: string[][] = [];
|
|
const visited = new Set<string>();
|
|
const recursionStack = new Set<string>();
|
|
|
|
// 인접 리스트 생성
|
|
const adjacencyList = new Map<string, string[]>();
|
|
nodes.forEach((node) => adjacencyList.set(node.id, []));
|
|
edges.forEach((edge) => {
|
|
const targets = adjacencyList.get(edge.source) || [];
|
|
targets.push(edge.target);
|
|
adjacencyList.set(edge.source, targets);
|
|
});
|
|
|
|
function dfs(nodeId: string, path: string[]): boolean {
|
|
visited.add(nodeId);
|
|
recursionStack.add(nodeId);
|
|
path.push(nodeId);
|
|
|
|
const neighbors = adjacencyList.get(nodeId) || [];
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
if (dfs(neighbor, [...path])) {
|
|
return true;
|
|
}
|
|
} else if (recursionStack.has(neighbor)) {
|
|
// 순환 발견
|
|
const cycleStart = path.indexOf(neighbor);
|
|
const cycle = path.slice(cycleStart).concat(neighbor);
|
|
const nodeNames = cycle.map((id) => {
|
|
const node = nodes.find((n) => n.id === id);
|
|
return node?.data.displayName || id;
|
|
});
|
|
cycles.push(nodeNames);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
recursionStack.delete(nodeId);
|
|
return false;
|
|
}
|
|
|
|
// 모든 노드에서 DFS 시작
|
|
nodes.forEach((node) => {
|
|
if (!visited.has(node.id)) {
|
|
dfs(node.id, []);
|
|
}
|
|
});
|
|
|
|
return cycles;
|
|
}
|
|
|
|
/**
|
|
* 노드별 필수 속성 검증
|
|
*/
|
|
function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
|
|
const errors: ValidationResult["errors"] = [];
|
|
|
|
switch (node.type) {
|
|
case "tableSource":
|
|
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "externalDBSource":
|
|
if (!node.data.connectionName || node.data.connectionName.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
if (!node.data.tableName || node.data.tableName.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "restAPISource":
|
|
if (!node.data.url || node.data.url.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
if (!node.data.method) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "insertAction":
|
|
case "updateAction":
|
|
case "deleteAction":
|
|
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
if (node.type === "insertAction" || node.type === "updateAction") {
|
|
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
}
|
|
if (node.type === "updateAction" || node.type === "deleteAction") {
|
|
if (!node.data.whereConditions || node.data.whereConditions.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "upsertAction":
|
|
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "condition":
|
|
if (!node.data.conditions || node.data.conditions.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
|
|
severity: "error",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "fieldMapping":
|
|
if (!node.data.mappings || node.data.mappings.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`,
|
|
severity: "warning",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "dataTransform":
|
|
if (!node.data.transformations || node.data.transformations.length === 0) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
|
|
severity: "warning",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "log":
|
|
if (!node.data.message || node.data.message.trim() === "") {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`,
|
|
severity: "warning",
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "comment":
|
|
// Comment 노드는 내용이 없어도 괜찮음
|
|
break;
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* 액션 타입 이름 반환
|
|
*/
|
|
function getActionTypeName(type: string): string {
|
|
const names: Record<string, string> = {
|
|
insertAction: "INSERT",
|
|
updateAction: "UPDATE",
|
|
deleteAction: "DELETE",
|
|
upsertAction: "UPSERT",
|
|
};
|
|
return names[type] || type;
|
|
}
|