diff --git a/package-lock.json b/package-lock.json index 83de1b9..45b1ba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "salesforce-repo", + "name": "dml-lib", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "salesforce-repo", + "name": "dml-lib", "version": "1.0.0", "devDependencies": { "@lwc/eslint-plugin-lwc": "^3.3.0", diff --git a/website/.vitepress/theme/components/AnimationControls.vue b/website/.vitepress/theme/components/AnimationControls.vue new file mode 100644 index 0000000..1bd3c86 --- /dev/null +++ b/website/.vitepress/theme/components/AnimationControls.vue @@ -0,0 +1,220 @@ + + + + + ← + + + + + {{ isPlaying ? 'Pause' : 'Play' }} + + + + + → + + + + + Reset + + + + + Speed: + + 0.5× + 1× + 2× + + + + + + + + diff --git a/website/.vitepress/theme/components/DmlTable.vue b/website/.vitepress/theme/components/DmlTable.vue new file mode 100644 index 0000000..31b6251 --- /dev/null +++ b/website/.vitepress/theme/components/DmlTable.vue @@ -0,0 +1,249 @@ + + + DML Execution + + + + ✓ + {{ step.step }} + + + {{ step.operation }} + + + {{ step.sobject }} + + + ×{{ step.records.length }} + + + + + + + + + diff --git a/website/.vitepress/theme/components/GraphCanvas.vue b/website/.vitepress/theme/components/GraphCanvas.vue new file mode 100644 index 0000000..2a509c9 --- /dev/null +++ b/website/.vitepress/theme/components/GraphCanvas.vue @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ getNodeLabel(node.id) }} + + + + + + + + + + Account + + + + Contact + + + + Opportunity + + + + Lead + + + + + + + + diff --git a/website/.vitepress/theme/components/ProgressBar.vue b/website/.vitepress/theme/components/ProgressBar.vue new file mode 100644 index 0000000..e91d71b --- /dev/null +++ b/website/.vitepress/theme/components/ProgressBar.vue @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + diff --git a/website/.vitepress/theme/components/StepDescription.vue b/website/.vitepress/theme/components/StepDescription.vue new file mode 100644 index 0000000..034653f --- /dev/null +++ b/website/.vitepress/theme/components/StepDescription.vue @@ -0,0 +1,215 @@ + + + + {{ stepInfo.stepNumber || '' }} + + + {{ stepInfo.operation }} + + {{ stepInfo.sobject }} + + × {{ stepInfo.recordCount }} + + + + + + {{ stepInfo.title }} + {{ stepInfo.description }} + + + + + + + diff --git a/website/.vitepress/theme/components/UnitOfWorkAnimation.vue b/website/.vitepress/theme/components/UnitOfWorkAnimation.vue new file mode 100644 index 0000000..7160d1f --- /dev/null +++ b/website/.vitepress/theme/components/UnitOfWorkAnimation.vue @@ -0,0 +1,588 @@ + + + + + {{ ariaLiveMessage }} + + + + + Unit of Work — Dependency Resolution + 17 records → 12 DML statements + + + + + + + + + + + + + + + + + + + + + + + + + + + ⌨️ Keyboard Shortcuts + + + Space / K + Play/Pause + + + → / L + Next step + + + ← / J + Previous step + + + Home / 0 + Reset to start + + + Esc + Clear selection + + + + + + + + + diff --git a/website/.vitepress/theme/composables/useGraphLayout.js b/website/.vitepress/theme/composables/useGraphLayout.js new file mode 100644 index 0000000..5816f37 --- /dev/null +++ b/website/.vitepress/theme/composables/useGraphLayout.js @@ -0,0 +1,182 @@ +/** + * Hierarchical Graph Layout Algorithm + * + * Calculates node positions using multi-root topological sorting (BFS). + * Arranges nodes in levels based on dependency depth, then distributes + * them horizontally within each level. + */ + +/** + * Calculate hierarchical layout positions for graph nodes + * + * @param {Array} nodes - Array of node objects with id property + * @param {Array} edges - Array of edge objects with from/to properties + * @returns {Map} Map of node ID → {x, y} positions (0-100 coordinate system) + */ +export function calculateGraphLayout(nodes, edges) { + // Step 1: Build adjacency structures + const inDegree = new Map() + const outEdges = new Map() + const parents = new Map() + + // Initialize structures + nodes.forEach(node => { + inDegree.set(node.id, 0) + outEdges.set(node.id, []) + parents.set(node.id, []) + }) + + // Build dependency graph + edges.forEach(edge => { + outEdges.get(edge.from).push(edge.to) + parents.get(edge.to).push(edge.from) + inDegree.set(edge.to, inDegree.get(edge.to) + 1) + }) + + // Step 2: Assign levels using BFS from multiple roots + const levels = new Map() + const queue = [] + let maxLevel = 0 + + // Find all root nodes (no dependencies) + nodes.forEach(node => { + if (inDegree.get(node.id) === 0) { + levels.set(node.id, 0) + queue.push(node.id) + } + }) + + // BFS to assign dependency levels + while (queue.length > 0) { + const current = queue.shift() + const currentLevel = levels.get(current) + + outEdges.get(current).forEach(child => { + // Assign child to currentLevel + 1 (or deeper if already assigned) + const childLevel = Math.max( + levels.get(child) || 0, + currentLevel + 1 + ) + levels.set(child, childLevel) + maxLevel = Math.max(maxLevel, childLevel) + + // Decrement in-degree and enqueue if all parents processed + inDegree.set(child, inDegree.get(child) - 1) + if (inDegree.get(child) === 0) { + queue.push(child) + } + }) + } + + // Step 3: Group nodes by level + const levelGroups = new Map() + nodes.forEach(node => { + const level = levels.get(node.id) + if (!levelGroups.has(level)) { + levelGroups.set(level, []) + } + levelGroups.get(level).push(node) + }) + + // Step 4: Calculate Y positions (vertical - based on level) + const VERTICAL_PADDING = 12 + const VERTICAL_SPACING = (100 - 2 * VERTICAL_PADDING) / Math.max(1, maxLevel) + + // Step 5: Calculate X positions (horizontal - distribute within level) + const HORIZONTAL_PADDING = 10 + const positions = new Map() + + // Type order for consistent visual grouping + const typeOrder = { + account: 0, + contact: 1, + opportunity: 2, + lead: 3 + } + + for (let level = 0; level <= maxLevel; level++) { + const nodesInLevel = levelGroups.get(level) || [] + const nodeCount = nodesInLevel.length + + if (nodeCount === 0) continue + + const y = VERTICAL_PADDING + level * VERTICAL_SPACING + + // Sort nodes by SObject type for visual grouping + const sorted = [...nodesInLevel].sort((a, b) => { + const aType = a.id.replace(/[0-9]/g, '') + const bType = b.id.replace(/[0-9]/g, '') + return (typeOrder[aType] || 99) - (typeOrder[bType] || 99) + }) + + // Distribute horizontally with centering + const availableWidth = 100 - 2 * HORIZONTAL_PADDING + + if (nodeCount === 1) { + // Single node - center it + positions.set(sorted[0].id, { x: 50, y }) + } else { + // Multiple nodes - distribute evenly + const spacing = availableWidth / (nodeCount - 1) + sorted.forEach((node, index) => { + const x = HORIZONTAL_PADDING + index * spacing + positions.set(node.id, { x, y }) + }) + } + } + + // Step 6: Apply force-directed adjustments to improve layout + applyForceDirectedAdjustments(positions, edges, levels, maxLevel) + + return positions +} + +/** + * Apply subtle force-directed adjustments to reduce edge crossings + * and align parent-child relationships horizontally + * + * @param {Map} positions - Map of node ID → {x, y} + * @param {Array} edges - Array of edge objects + * @param {Map} levels - Map of node ID → level number + * @param {number} maxLevel - Maximum level number + */ +function applyForceDirectedAdjustments(positions, edges, levels, maxLevel) { + const ITERATIONS = 8 + let attractionStrength = 0.15 + const MIN_X = 10 + const MAX_X = 90 + + for (let iter = 0; iter < ITERATIONS; iter++) { + const forces = new Map() + + // Calculate attractive forces along edges (parent-child alignment) + edges.forEach(edge => { + const fromPos = positions.get(edge.from) + const toPos = positions.get(edge.to) + + if (!fromPos || !toPos) return + + // Only adjust X (horizontal) position, keep Y (level) fixed + const dx = fromPos.x - toPos.x + const force = dx * attractionStrength + + // Only apply if nodes are on adjacent levels (preserve hierarchy) + const fromLevel = levels.get(edge.from) + const toLevel = levels.get(edge.to) + + if (toLevel === fromLevel + 1) { + forces.set(edge.to, (forces.get(edge.to) || 0) + force) + } + }) + + // Apply forces with bounds checking + forces.forEach((force, nodeId) => { + const pos = positions.get(nodeId) + const newX = Math.max(MIN_X, Math.min(MAX_X, pos.x + force)) + positions.set(nodeId, { x: newX, y: pos.y }) + }) + + // Reduce force strength over iterations for convergence + attractionStrength *= 0.9 + } +} diff --git a/website/.vitepress/theme/composables/useIntersectionObserver.js b/website/.vitepress/theme/composables/useIntersectionObserver.js new file mode 100644 index 0000000..8062c55 --- /dev/null +++ b/website/.vitepress/theme/composables/useIntersectionObserver.js @@ -0,0 +1,88 @@ +/** + * Intersection Observer Composable for Auto-Play + * + * Triggers a callback when an element enters the viewport. + * Designed for one-time auto-play functionality. + */ + +import { ref, onMounted, onUnmounted } from 'vue' + +/** + * Auto-play composable using Intersection Observer + * + * @param {Ref} elementRef - Vue ref to the element to observe + * @param {Function} onEnterViewport - Callback to trigger when element enters viewport + * @returns {Object} - { hasTriggered: Ref } + */ +export function useAutoPlay(elementRef, onEnterViewport) { + const hasTriggered = ref(false) + let observer = null + + onMounted(() => { + const element = elementRef.value + if (!element) return + + // Check if element is already in viewport on mount + const rect = element.getBoundingClientRect() + const viewportHeight = window.innerHeight || document.documentElement.clientHeight + const viewportWidth = window.innerWidth || document.documentElement.clientWidth + + const isInViewport = ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= viewportHeight && + rect.right <= viewportWidth && + // Check if at least 30% is visible + rect.top <= viewportHeight * 0.7 + ) + + if (isInViewport && !hasTriggered.value) { + // Already in viewport - trigger immediately with small delay + // Delay ensures DOM is fully rendered and animations are ready + setTimeout(() => { + if (!hasTriggered.value) { + hasTriggered.value = true + onEnterViewport() + } + }, 300) + return + } + + // Set up observer for future viewport entries + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + // Trigger only once, only when entering (not leaving) + if (entry.isIntersecting && !hasTriggered.value) { + hasTriggered.value = true + onEnterViewport() + + // Disconnect observer after first trigger to save resources + if (observer) { + observer.disconnect() + observer = null + } + } + }) + }, + { + root: null, // Use viewport as root + threshold: 0.3, // Trigger when 30% of element is visible + rootMargin: '0px' // No margin adjustment + } + ) + + observer.observe(element) + }) + + onUnmounted(() => { + if (observer) { + observer.disconnect() + observer = null + } + }) + + return { + hasTriggered + } +} diff --git a/website/.vitepress/theme/data/graphData.js b/website/.vitepress/theme/data/graphData.js new file mode 100644 index 0000000..371fd0f --- /dev/null +++ b/website/.vitepress/theme/data/graphData.js @@ -0,0 +1,178 @@ +/** + * Graph data for Unit of Work dependency visualization + * + * This module contains the complete data for visualizing how DML Lib + * processes 17 records in 12 DML statements using dependency resolution. + */ + +/** + * 17 SObject records with their metadata + * Each node represents a Salesforce record to be inserted/upserted + */ +export const nodes = [ + // Accounts (8 total) + { id: 'account1', type: 'Account', operation: 'INSERT', name: 'Account 1', color: '#4ade80' }, + { id: 'account2', type: 'Account', operation: 'UPSERT', name: 'Account 2', color: '#f97316' }, + { id: 'account3', type: 'Account', operation: 'UPSERT', name: 'Account 3', color: '#94a3b8' }, + { id: 'account4', type: 'Account', operation: 'UPSERT', name: 'Account 4', color: '#78716c' }, + { id: 'account5', type: 'Account', operation: 'INSERT', name: 'Account 5', color: '#06b6d4' }, + { id: 'account6', type: 'Account', operation: 'INSERT', name: 'Account 6', color: '#22c55e' }, + { id: 'account7', type: 'Account', operation: 'INSERT', name: 'Account 7', color: '#84cc16' }, + { id: 'account8', type: 'Account', operation: 'INSERT', name: 'Account 8', color: '#4ade80' }, + + // Contacts (4 total) + { id: 'contact1', type: 'Contact', operation: 'INSERT', name: 'Contact 1', color: '#06b6d4' }, + { id: 'contact2', type: 'Contact', operation: 'INSERT', name: 'Contact 2', color: '#14b8a6' }, + { id: 'contact3', type: 'Contact', operation: 'INSERT', name: 'Contact 3', color: '#14b8a6' }, + { id: 'contact4', type: 'Contact', operation: 'INSERT', name: 'Contact 4', color: '#14b8a6' }, + + // Opportunities (4 total) + { id: 'opportunity1', type: 'Opportunity', operation: 'INSERT', name: 'Opportunity 1', color: '#a855f7' }, + { id: 'opportunity2', type: 'Opportunity', operation: 'INSERT', name: 'Opportunity 2', color: '#ec4899' }, + { id: 'opportunity3', type: 'Opportunity', operation: 'INSERT', name: 'Opportunity 3', color: '#3b82f6' }, + { id: 'opportunity4', type: 'Opportunity', operation: 'INSERT', name: 'Opportunity 4', color: '#3b82f6' }, + + // Lead (1 total) + { id: 'lead1', type: 'Lead', operation: 'INSERT', name: 'Lead 1', color: '#ef4444' } +] + +/** + * 14 dependency edges showing relationships between records + * Each edge represents a lookup/master-detail relationship that must be resolved + */ +export const edges = [ + // Account hierarchy (ParentId relationships) + { from: 'account2', to: 'account1', label: 'ParentId' }, + { from: 'account4', to: 'account2', label: 'ParentId' }, + { from: 'account5', to: 'account2', label: 'ParentId' }, + { from: 'account3', to: 'account5', label: 'ParentId' }, + { from: 'account6', to: 'account5', label: 'ParentId' }, + { from: 'account7', to: 'account5', label: 'ParentId' }, + + // Contact relationships (AccountId lookups) + { from: 'contact1', to: 'account2', label: 'AccountId' }, + { from: 'contact2', to: 'account3', label: 'AccountId' }, + { from: 'contact3', to: 'account6', label: 'AccountId' }, + { from: 'contact4', to: 'account6', label: 'AccountId' }, + + // Opportunity relationships (AccountId lookups) + { from: 'opportunity1', to: 'account1', label: 'AccountId' }, + { from: 'opportunity2', to: 'account4', label: 'AccountId' }, + { from: 'opportunity3', to: 'account6', label: 'AccountId' }, + { from: 'opportunity4', to: 'account6', label: 'AccountId' } +] + +/** + * 12 DML execution steps showing how records are grouped and committed + * Despite having 17 records, DML Lib optimizes to just 12 DML statements + */ +export const dmlSteps = [ + { + step: 1, + operation: 'INSERT', + sobject: 'Account', + records: ['account1', 'account8'], + reason: 'No dependencies, same bucket', + description: 'Insert independent Account records with no parent references.', + color: '#4ade80' + }, + { + step: 2, + operation: 'INSERT', + sobject: 'Lead', + records: ['lead1'], + reason: 'No dependencies', + description: 'Insert Lead - completely independent, no relationships.', + color: '#ef4444' + }, + { + step: 3, + operation: 'UPSERT', + sobject: 'Account', + records: ['account2'], + reason: 'Depends on account1 (ParentId)', + description: 'Account1 has ID now, upsert account2 with ParentId reference.', + color: '#f97316' + }, + { + step: 4, + operation: 'INSERT', + sobject: 'Opportunity', + records: ['opportunity1'], + reason: 'Depends on account1 (AccountId)', + description: 'Insert opportunity1 linked to account1.', + color: '#a855f7' + }, + { + step: 5, + operation: 'UPSERT', + sobject: 'Account', + records: ['account4'], + reason: 'Depends on account2 (ParentId)', + description: 'Account2 has ID - upsert account4 as child.', + color: '#78716c' + }, + { + step: 6, + operation: 'INSERT', + sobject: 'Account', + records: ['account5'], + reason: 'Depends on account2 (ParentId)', + description: 'Insert account5 as child of account2.', + color: '#06b6d4' + }, + { + step: 7, + operation: 'INSERT', + sobject: 'Contact', + records: ['contact1'], + reason: 'Depends on account2 (AccountId)', + description: 'Insert contact1 linked to account2.', + color: '#06b6d4' + }, + { + step: 8, + operation: 'UPSERT', + sobject: 'Account', + records: ['account3'], + reason: 'Depends on account5 (ParentId)', + description: 'Account5 has ID - upsert account3 as child.', + color: '#94a3b8' + }, + { + step: 9, + operation: 'INSERT', + sobject: 'Account', + records: ['account6', 'account7'], + reason: 'Depend on account5 (ParentId), same bucket', + description: 'Insert account6, account7 - both depend on account5.', + color: '#22c55e' + }, + { + step: 10, + operation: 'INSERT', + sobject: 'Opportunity', + records: ['opportunity2'], + reason: 'Depends on account4 (AccountId)', + description: 'Insert opportunity2 linked to account4.', + color: '#ec4899' + }, + { + step: 11, + operation: 'INSERT', + sobject: 'Contact', + records: ['contact2', 'contact3', 'contact4'], + reason: 'Depend on account3 and account6, same bucket', + description: 'Insert contacts - Account dependencies resolved.', + color: '#14b8a6' + }, + { + step: 12, + operation: 'INSERT', + sobject: 'Opportunity', + records: ['opportunity3', 'opportunity4'], + reason: 'Depend on account6 (AccountId), same bucket', + description: 'Final step - opportunities linked to account6.', + color: '#3b82f6' + } +] diff --git a/website/.vitepress/theme/index.js b/website/.vitepress/theme/index.js index 87aeeed..420c467 100644 --- a/website/.vitepress/theme/index.js +++ b/website/.vitepress/theme/index.js @@ -1,10 +1,12 @@ import DefaultTheme from 'vitepress/theme' import BTCFooter from './components/BTCFooter.vue' +import UnitOfWorkAnimation from './components/UnitOfWorkAnimation.vue' import './custom.css' export default { extends: DefaultTheme, enhanceApp({ app }) { app.component('BTCFooter', BTCFooter) + app.component('UnitOfWorkAnimation', UnitOfWorkAnimation) } } diff --git a/website/architecture/registration.md b/website/architecture/registration.md index 5124861..6b767f7 100644 --- a/website/architecture/registration.md +++ b/website/architecture/registration.md @@ -91,6 +91,12 @@ new DML() DML Lib minimizes the number of DML statements by building a dependency graph and grouping records into execution buckets. +### Interactive Visualization + + + +The animation above demonstrates how DML Lib intelligently processes 17 records using only 12 DML statements. Watch as the dependency resolution algorithm executes each step, showing how records are grouped into buckets based on their operation type, SObject type, and dependencies. Use the controls to step through the animation, or press **Space** to play/pause. + ### How It Works 1. **Graph Construction** - When you register records using `toInsert()`, `toUpdate()`, etc., each record becomes a node in a dependency graph. Relationships defined via `withRelationship()` create edges between nodes. diff --git a/website/public/logo.png b/website/public/logo.png index d38e9ed..63c6074 100644 Binary files a/website/public/logo.png and b/website/public/logo.png differ
{{ stepInfo.description }}
17 records → 12 DML statements