How I Made devUML - Part 2: Version Control for Diagrams
Building a robust version tracking system that works with bidirectional sync

Girish Koundinya
In my previous post, I described how devUML keeps visual diagrams and code representations in perfect sync. Today, I want to talk about the next challenge I faced: implementing version control for diagrams.
Version control is second nature for code. We commit, branch, merge, and resolve conflicts without thinking twice. But diagrams have historically been left out of this workflow. They're often treated as static artifacts, saved as images or PDFs, with no way to track incremental changes or collaborate effectively.
The Challenge: Version Control for a Dual-Format Document
When I started thinking about version control for devUML, I quickly realized this wasn't just about tracking changes to a file. The bidirectional sync system meant that I was effectively dealing with a dual-format document:
- Text representation - The Mermaid code that defines the diagram structure
- Visual representation - The positions, styles, and layout of nodes and connections
Git and similar systems are great for tracking text changes, but they have no concept of visual layout or positioning. I needed a system that could track both aspects of a diagram while maintaining their relationship.
The Database Schema
I started with a database schema that could capture both the textual and visual aspects of diagrams:
model Diagram {
id String @id @default(cuid())
title String
description String? @db.Text
diagram_code String @db.Text @default("")
diagram_position Json
version Int @default(1)
chat_history ChatMessage[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
The key elements here are:
diagram_code
: Stores the Mermaid syntax (the text representation)diagram_position
: A JSON field that stores the positions of all nodes (the visual representation)version
: An incrementing counter to track changes
This schema gives us the foundation for tracking changes, but it's not enough on its own.
Atomic Version Increments
I needed to ensure that version numbers increment atomically, even when multiple users are editing the same diagram. This is crucial for detecting conflicts.
const saveDiagram = async () => {
try {
setIsSaving(true);
setError(null);
// Check for version conflicts first
const hasConflict = await checkVersionConflict();
if (hasConflict) {
setError('Version conflict detected. Please refresh to get the latest version.');
return;
}
// Prepare payload with all required fields and version
const payload = {
title: diagramTitle,
diagram_code: code,
diagram_position: JSON.stringify(diagramPositions),
edges: JSON.stringify(edges),
version: currentVersion,
version_bump: true
};
const response = await fetch(`/workspace/api/diagrams/${diagramId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (!response.ok) {
if (response.status === 409) {
throw new Error('Version conflict: The diagram was updated by someone else');
}
throw new Error('Failed to save diagram');
}
const savedData = await response.json();
setCurrentVersion(savedData.version);
setLastSaved(new Date());
useDiagramStore.getState().setHasChanges(false);
} catch (error) {
console.error('Error saving diagram:', error);
setError(error.message);
} finally {
setIsSaving(false);
}
};
On the server side, I use a transaction to ensure that version numbers are incremented atomically:
// In the PUT handler for /api/diagrams/:id
const { version, version_bump, ...data } = req.body;
// Use a transaction to ensure atomic updates
const result = await prisma.$transaction(async (tx) => {
// Get the current diagram with FOR UPDATE lock
const diagram = await tx.diagram.findUnique({
where: { id: diagramId },
select: { version: true }
});
// Verify version matches to prevent conflicts
if (diagram.version !== version) {
throw new Error('Version conflict');
}
// Update the diagram with new version number
return tx.diagram.update({
where: { id: diagramId },
data: {
...data,
version: version_bump ? { increment: 1 } : version
}
});
});
This ensures that even if two users try to save at the same time, only one will succeed, and the other will get a conflict error.
Conflict Detection
Detecting conflicts early is critical for a good user experience. I implemented a polling system that regularly checks if the diagram has been updated by someone else:
// Function to check for version conflicts
const checkVersionConflict = async () => {
try {
const response = await fetch(`/workspace/api/diagrams/${diagramId}`);
if (!response.ok) throw new Error('Failed to check version');
const latestData = await response.json();
if (latestData.version > currentVersion) {
throw new Error('Someone else has made changes to this diagram. Please refresh to get the latest version.');
}
return false; // No conflict
} catch (error) {
console.error('Version check error:', error);
return true; // Assume conflict on error
}
};
// Regular version check (every 30 seconds)
useEffect(() => {
const intervalId = setInterval(async () => {
if (hasChanges) {
const hasConflict = await checkVersionConflict();
if (hasConflict) {
setError('Someone else has made changes. Please refresh to get the latest version.');
}
}
}, 30000);
return () => clearInterval(intervalId);
}, [hasChanges, diagramId]);
This approach alerts users early if someone else has modified the diagram, preventing them from making changes that would be lost in a conflict.
Auto-Save with Debouncing
A good version control system should be unobtrusive. I didn't want users to have to manually save their changes constantly, but I also didn't want to flood the server with save requests for every small edit.
The solution was to implement a debounced auto-save function that groups changes together:
// Create debounced version of save function - 2 second delay
const debouncedSave = useCallback(
_.debounce(() => {
saveDiagram();
}, 2000),
[diagramId, diagramTitle, code, nodes, edges, currentVersion]
);
// Trigger auto-save when changes occur
useEffect(() => {
if (hasChanges) {
debouncedSave();
}
return () => {
debouncedSave.cancel();
};
}, [hasChanges, code, nodes, edges, diagramTitle, debouncedSave]);
This creates a nice balance: changes are saved automatically after 2 seconds of inactivity, giving users a seamless experience while keeping the server load reasonable.
User Interface Considerations
Version control is not just about the technical implementation; it's also about creating a UI that makes the system intuitive for users. I added several UI elements to make version management visible:
<div className="flex items-center gap-4">
{lastSaved && (
<span className="text-sm text-gray-500">
Last saved {new Date(lastSaved).toLocaleTimeString()} (v{currentVersion})
</span>
)}
<Button
onClick={saveDiagram}
size="sm"
className="gap-2"
disabled={isSaving || !hasChanges}
>
<Save size={18} />
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
This gives users visibility into the version history of their diagram and the current save status. It's a small touch, but it makes a big difference in user confidence.
Handling Merge Conflicts
In traditional version control systems like Git, merge conflicts are resolved by manually editing the conflicting files. But with diagrams, this approach doesn't work well. The visual nature of diagrams means that conflicts are often spatial rather than textual - two people might move the same node to different positions, for example.
I decided on a simpler approach for the initial version: alert users to conflicts and ask them to refresh to get the latest version. This isn't as sophisticated as Git's merge conflict resolution, but it's clear and intuitive for users. In the future, I plan to implement more advanced conflict resolution, such as showing both versions side by side and allowing users to choose which changes to keep.
Integration with Bidirectional Sync
The biggest challenge was integrating version control with the bidirectional sync system. I needed to ensure that when a user loads a diagram from the server, both the code and the visual representation are loaded correctly and stay in sync.
The solution was to modify the initialization process to load both aspects of the diagram and then use the existing sync mechanism to keep them in sync:
const fetchDiagram = async () => {
try {
setLoading(true);
const response = await fetch(`/workspace/api/diagrams/${diagramId}`, {
headers: {
'Content-Type': 'application/json',
}
});
if (response.status === 401) {
throw new Error('Please sign in to view this diagram');
}
if (!response.ok) {
throw new Error('Failed to load diagram');
}
const data = await response.json();
setDiagram(data);
// Initialize diagram store with the loaded data
const diagramStore = useDiagramStore.getState();
// Set the code
if (data.diagram_code) {
diagramStore.updateCode(data.diagram_code);
}
// If there are saved positions, parse and set them
if (data.diagram_position) {
try {
const positions = typeof data.diagram_position === 'string'
? JSON.parse(data.diagram_position)
: data.diagram_position;
await diagramStore.parseDiagram();
diagramStore.updateNodePositions(positions);
} catch (e) {
console.error('Error parsing diagram positions:', e);
// If position parsing fails, still parse the diagram code
await diagramStore.parseDiagram();
}
} else {
// If no positions, just parse the diagram
await diagramStore.parseDiagram();
}
} catch (error) {
console.error('Error fetching diagram:', error);
setError(error.message);
} finally {
setLoading(false);
}
};
This ensures that when a user loads a diagram, they get both the code and the visual layout exactly as they were saved, maintaining the bidirectional relationship.
Lessons Learned
Implementing version control for devUML taught me several important lessons:
- Version tracking isn't just for text - Visual elements like positions and layouts are equally important to track
- Early conflict detection is better than late conflict resolution - It's better to alert users to potential conflicts early rather than try to resolve complex conflicts later
- Auto-save with debouncing creates a seamless experience - Users shouldn't have to think about saving, but the system should be smart about when to save
- Version control needs to be visible - Users need to see the current version and save status to have confidence in the system
What's Next
The current version control system works well for individual diagrams, but there's still more I want to do:
- Branching and merging - Allow users to create branches of diagrams for exploring alternative designs
- Visual diff view - Show visual differences between versions, highlighting moved or modified nodes
- More sophisticated conflict resolution - Let users choose which changes to keep when conflicts occur
- Integration with Git - Allow diagrams to be stored and versioned directly in Git repositories alongside code
In the next post, I'll explore how I integrated AI into devUML, using generative models to create and modify diagrams based on natural language descriptions. Stay tuned!

Girish Koundinya
Related Posts
How I Made devUML - Part 1: The Magic of Bidirectional Sync Between Code and Diagrams
A deep dive into how devUML maintains perfect sync between code and visuals
How I Made devUML - Part 3: Integrating AI for Intuitive Diagram Generation
Creating a natural language interface for UML diagramming with Google's Gemini model