@composable-svelte/code
Version:
Code editor, syntax highlighting, and node-based canvas components for Composable Svelte - Built with Prism.js, CodeMirror, and SvelteFlow
187 lines (186 loc) • 6.8 kB
JavaScript
/**
* Connection Validation Utilities
*
* Type-safe connection validation for node-based editors.
* Validates connections based on port types, multiplicity, and custom rules.
*/
/**
* Default connection validator with type-safe port validation.
*
* Validates:
* - Nodes exist
* - No self-connections
* - Port types are compatible
* - Port multiplicity (single vs multiple connections)
* - Custom node-level validation
*/
export function createConnectionValidator(nodeTypes) {
return (state, sourceNodeId, sourceHandle, targetNodeId, targetHandle) => {
// Get nodes
const sourceNode = state.nodes[sourceNodeId];
const targetNode = state.nodes[targetNodeId];
// Validate nodes exist
if (!sourceNode) {
return {
valid: false,
error: `Source node ${sourceNodeId} not found`
};
}
if (!targetNode) {
return {
valid: false,
error: `Target node ${targetNodeId} not found`
};
}
// Prevent self-connections
if (sourceNodeId === targetNodeId) {
return {
valid: false,
error: 'Cannot connect a node to itself'
};
}
// Get node type definitions
const sourceNodeType = nodeTypes[sourceNode.type ?? 'default'];
const targetNodeType = nodeTypes[targetNode.type ?? 'default'];
if (!sourceNodeType || !targetNodeType) {
// No type definitions - allow connection (permissive mode)
return { valid: true };
}
// Get port definitions
const sourcePort = sourceHandle
? sourceNodeType.outputs?.find((p) => p.id === sourceHandle)
: sourceNodeType.outputs?.[0]; // Default to first output
const targetPort = targetHandle
? targetNodeType.inputs?.find((p) => p.id === targetHandle)
: targetNodeType.inputs?.[0]; // Default to first input
// Validate ports exist
if (!sourcePort) {
return {
valid: false,
error: `Source node has no output port ${sourceHandle || '(default)'}`
};
}
if (!targetPort) {
return {
valid: false,
error: `Target node has no input port ${targetHandle || '(default)'}`
};
}
// Validate port data types are compatible
if (sourcePort.dataType !== targetPort.dataType) {
return {
valid: false,
error: `Incompatible types: ${sourcePort.dataType} -> ${targetPort.dataType}`
};
}
// Check if target port already has a connection (if not multiple)
if (!targetPort.multiple) {
const existingConnection = Object.values(state.edges).find((edge) => edge.target === targetNodeId && edge.targetHandle === targetHandle);
if (existingConnection) {
return {
valid: false,
error: `Target port ${targetPort.label} already has a connection`
};
}
}
// Check if source port has reached max connections (if not multiple)
if (!sourcePort.multiple) {
const existingConnection = Object.values(state.edges).find((edge) => edge.source === sourceNodeId && edge.sourceHandle === sourceHandle);
if (existingConnection) {
return {
valid: false,
error: `Source port ${sourcePort.label} already has a connection`
};
}
}
// Check for cycles (would create circular dependency)
const wouldCreateCycle = detectCycle(state, sourceNodeId, targetNodeId, sourceHandle, targetHandle);
if (wouldCreateCycle) {
return {
valid: false,
error: 'Connection would create a circular dependency'
};
}
// Custom node-level validation
if (sourceNodeType.validate) {
const error = sourceNodeType.validate(sourceNode.data);
if (error) {
return { valid: false, error: `Source node: ${error}` };
}
}
if (targetNodeType.validate) {
const error = targetNodeType.validate(targetNode.data);
if (error) {
return { valid: false, error: `Target node: ${error}` };
}
}
return { valid: true };
};
}
/**
* Detect if adding an edge would create a cycle in the graph.
* Uses depth-first search to check for cycles.
*/
function detectCycle(state, sourceNodeId, targetNodeId, sourceHandle, targetHandle) {
// Build adjacency list from existing edges
const adjacencyList = new Map();
for (const edge of Object.values(state.edges)) {
if (!adjacencyList.has(edge.source)) {
adjacencyList.set(edge.source, new Set());
}
adjacencyList.get(edge.source).add(edge.target);
}
// Add the proposed edge
if (!adjacencyList.has(sourceNodeId)) {
adjacencyList.set(sourceNodeId, new Set());
}
adjacencyList.get(sourceNodeId).add(targetNodeId);
// DFS to detect cycle
const visited = new Set();
const recStack = new Set();
function hasCycleDFS(nodeId) {
if (recStack.has(nodeId))
return true; // Found cycle
if (visited.has(nodeId))
return false; // Already checked
visited.add(nodeId);
recStack.add(nodeId);
const neighbors = adjacencyList.get(nodeId);
if (neighbors) {
for (const neighbor of neighbors) {
if (hasCycleDFS(neighbor))
return true;
}
}
recStack.delete(nodeId);
return false;
}
// Check from the target node (if we can reach source, it's a cycle)
return hasCycleDFS(targetNodeId);
}
/**
* Helper to create permissive validator (allows all connections).
*/
export function permissiveValidator() {
return { valid: true };
}
/**
* Helper to create strict validator (blocks all connections).
*/
export function strictValidator(error = 'Connections not allowed') {
return { valid: false, error };
}
/**
* Compose multiple validators (all must pass).
*/
export function composeValidators(...validators) {
return (state, sourceNodeId, sourceHandle, targetNodeId, targetHandle) => {
for (const validator of validators) {
const result = validator(state, sourceNodeId, sourceHandle, targetNodeId, targetHandle);
if (!result.valid) {
return result;
}
}
return { valid: true };
};
}