refactor: establish document as source of truth for node/edge types

Refactors the type management system to properly follow the
Workspace→Document→Timeline→Graph hierarchy, where nodeTypes and
edgeTypes are owned by the document rather than being independent
state in graphStore.

Changes:
- Add type management actions to workspaceStore (document-level)
- Update workspaceStore.saveDocument to stop copying types from graphStore
- Modify useActiveDocument to only track node/edge changes for dirty state
- Update useGraphWithHistory to route type operations through workspaceStore
- Update useDocumentHistory to read/restore types from document

Architecture:
- Document: Source of truth for types (persistent storage)
- graphStore: Synchronized working memory (optimized for React Flow)
- useActiveDocument: Bridge that syncs document → graphStore
- Type mutations: Always go through workspaceStore, then sync to graphStore

This ensures proper data ownership while maintaining graphStore as a
performance optimization layer for the UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-12 14:27:57 +02:00
parent 5275b52f0a
commit 89117415ed
5 changed files with 248 additions and 31 deletions

View file

@ -47,7 +47,8 @@ export function useDocumentHistory() {
} }
// Get current state from stores // Get current state from stores
const graphStore = useGraphStore.getState(); const workspaceStore = useWorkspaceStore.getState();
const activeDoc = workspaceStore.getActiveDocument();
const timelineStore = useTimelineStore.getState(); const timelineStore = useTimelineStore.getState();
const timeline = timelineStore.timelines.get(activeDocumentId); const timeline = timelineStore.timelines.get(activeDocumentId);
@ -56,15 +57,21 @@ export function useDocumentHistory() {
return; return;
} }
if (!activeDoc) {
console.warn('Active document not found');
return;
}
// Create a snapshot of the complete document state // Create a snapshot of the complete document state
// NOTE: Read types from the document, not from graphStore
const snapshot: DocumentSnapshot = { const snapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), // Clone the Map states: new Map(timeline.states), // Clone the Map
currentStateId: timeline.currentStateId, currentStateId: timeline.currentStateId,
rootStateId: timeline.rootStateId, rootStateId: timeline.rootStateId,
}, },
nodeTypes: graphStore.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: activeDoc.edgeTypes,
}; };
// Push to history // Push to history
@ -87,7 +94,8 @@ export function useDocumentHistory() {
} }
// Capture current state BEFORE undoing // Capture current state BEFORE undoing
const graphStore = useGraphStore.getState(); const workspaceStore = useWorkspaceStore.getState();
const activeDoc = workspaceStore.getActiveDocument();
const timelineStore = useTimelineStore.getState(); const timelineStore = useTimelineStore.getState();
const timeline = timelineStore.timelines.get(activeDocumentId); const timeline = timelineStore.timelines.get(activeDocumentId);
@ -96,14 +104,20 @@ export function useDocumentHistory() {
return; return;
} }
if (!activeDoc) {
console.warn('Active document not found');
return;
}
// NOTE: Read types from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), states: new Map(timeline.states),
currentStateId: timeline.currentStateId, currentStateId: timeline.currentStateId,
rootStateId: timeline.rootStateId, rootStateId: timeline.rootStateId,
}, },
nodeTypes: graphStore.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: activeDoc.edgeTypes,
}; };
const restoredState = historyStore.undo(activeDocumentId, currentSnapshot); const restoredState = historyStore.undo(activeDocumentId, currentSnapshot);
@ -112,7 +126,11 @@ export function useDocumentHistory() {
// Restore complete document state (timeline + types) // Restore complete document state (timeline + types)
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline); timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
// Update graph store types // Update document's types (which will sync to graphStore via workspaceStore)
activeDoc.nodeTypes = restoredState.nodeTypes;
activeDoc.edgeTypes = restoredState.edgeTypes;
// Sync to graph store
setNodeTypes(restoredState.nodeTypes); setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes); setEdgeTypes(restoredState.edgeTypes);
@ -146,7 +164,8 @@ export function useDocumentHistory() {
} }
// Capture current state BEFORE redoing // Capture current state BEFORE redoing
const graphStore = useGraphStore.getState(); const workspaceStore = useWorkspaceStore.getState();
const activeDoc = workspaceStore.getActiveDocument();
const timelineStore = useTimelineStore.getState(); const timelineStore = useTimelineStore.getState();
const timeline = timelineStore.timelines.get(activeDocumentId); const timeline = timelineStore.timelines.get(activeDocumentId);
@ -155,14 +174,20 @@ export function useDocumentHistory() {
return; return;
} }
if (!activeDoc) {
console.warn('Active document not found');
return;
}
// NOTE: Read types from the document, not from graphStore
const currentSnapshot: DocumentSnapshot = { const currentSnapshot: DocumentSnapshot = {
timeline: { timeline: {
states: new Map(timeline.states), states: new Map(timeline.states),
currentStateId: timeline.currentStateId, currentStateId: timeline.currentStateId,
rootStateId: timeline.rootStateId, rootStateId: timeline.rootStateId,
}, },
nodeTypes: graphStore.nodeTypes, nodeTypes: activeDoc.nodeTypes,
edgeTypes: graphStore.edgeTypes, edgeTypes: activeDoc.edgeTypes,
}; };
const restoredState = historyStore.redo(activeDocumentId, currentSnapshot); const restoredState = historyStore.redo(activeDocumentId, currentSnapshot);
@ -171,7 +196,11 @@ export function useDocumentHistory() {
// Restore complete document state (timeline + types) // Restore complete document state (timeline + types)
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline); timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
// Update graph store types // Update document's types (which will sync to graphStore via workspaceStore)
activeDoc.nodeTypes = restoredState.nodeTypes;
activeDoc.edgeTypes = restoredState.edgeTypes;
// Sync to graph store
setNodeTypes(restoredState.nodeTypes); setNodeTypes(restoredState.nodeTypes);
setEdgeTypes(restoredState.edgeTypes); setEdgeTypes(restoredState.edgeTypes);

View file

@ -1,5 +1,6 @@
import { useCallback, useRef, useEffect } from 'react'; import { useCallback, useRef, useEffect } from 'react';
import { useGraphStore } from '../stores/graphStore'; import { useGraphStore } from '../stores/graphStore';
import { useWorkspaceStore } from '../stores/workspaceStore';
import { useDocumentHistory } from './useDocumentHistory'; import { useDocumentHistory } from './useDocumentHistory';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } from '../types'; import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } from '../types';
@ -38,6 +39,13 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr
*/ */
export function useGraphWithHistory() { export function useGraphWithHistory() {
const graphStore = useGraphStore(); const graphStore = useGraphStore();
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
const addNodeTypeToDocument = useWorkspaceStore((state) => state.addNodeTypeToDocument);
const updateNodeTypeInDocument = useWorkspaceStore((state) => state.updateNodeTypeInDocument);
const deleteNodeTypeFromDocument = useWorkspaceStore((state) => state.deleteNodeTypeFromDocument);
const addEdgeTypeToDocument = useWorkspaceStore((state) => state.addEdgeTypeToDocument);
const updateEdgeTypeInDocument = useWorkspaceStore((state) => state.updateEdgeTypeInDocument);
const deleteEdgeTypeFromDocument = useWorkspaceStore((state) => state.deleteEdgeTypeFromDocument);
const { pushToHistory } = useDocumentHistory(); const { pushToHistory } = useDocumentHistory();
// Track if we're currently restoring from history to prevent recursive history pushes // Track if we're currently restoring from history to prevent recursive history pushes
@ -169,76 +177,100 @@ export function useGraphWithHistory() {
const addNodeType = useCallback( const addNodeType = useCallback(
(nodeType: NodeTypeConfig) => { (nodeType: NodeTypeConfig) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.addNodeType(nodeType); graphStore.addNodeType(nodeType);
return; return;
} }
pushToHistory(`Add Node Type: ${nodeType.label}`); // Synchronous push BEFORE mutation pushToHistory(`Add Node Type: ${nodeType.label}`); // Synchronous push BEFORE mutation
graphStore.addNodeType(nodeType); addNodeTypeToDocument(activeDocumentId, nodeType);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, addNodeTypeToDocument]
); );
const updateNodeType = useCallback( const updateNodeType = useCallback(
(id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) => { (id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.updateNodeType(id, updates); graphStore.updateNodeType(id, updates);
return; return;
} }
pushToHistory('Update Node Type'); // Synchronous push BEFORE mutation pushToHistory('Update Node Type'); // Synchronous push BEFORE mutation
graphStore.updateNodeType(id, updates); updateNodeTypeInDocument(activeDocumentId, id, updates);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, updateNodeTypeInDocument]
); );
const deleteNodeType = useCallback( const deleteNodeType = useCallback(
(id: string) => { (id: string) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.deleteNodeType(id); graphStore.deleteNodeType(id);
return; return;
} }
const nodeType = graphStore.nodeTypes.find((nt) => nt.id === id); const nodeType = graphStore.nodeTypes.find((nt) => nt.id === id);
pushToHistory(`Delete Node Type: ${nodeType?.label || id}`); // Synchronous push BEFORE mutation pushToHistory(`Delete Node Type: ${nodeType?.label || id}`); // Synchronous push BEFORE mutation
graphStore.deleteNodeType(id); deleteNodeTypeFromDocument(activeDocumentId, id);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, deleteNodeTypeFromDocument]
); );
const addEdgeType = useCallback( const addEdgeType = useCallback(
(edgeType: EdgeTypeConfig) => { (edgeType: EdgeTypeConfig) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.addEdgeType(edgeType); graphStore.addEdgeType(edgeType);
return; return;
} }
pushToHistory(`Add Edge Type: ${edgeType.label}`); // Synchronous push BEFORE mutation pushToHistory(`Add Edge Type: ${edgeType.label}`); // Synchronous push BEFORE mutation
graphStore.addEdgeType(edgeType); addEdgeTypeToDocument(activeDocumentId, edgeType);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, addEdgeTypeToDocument]
); );
const updateEdgeType = useCallback( const updateEdgeType = useCallback(
(id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => { (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.updateEdgeType(id, updates); graphStore.updateEdgeType(id, updates);
return; return;
} }
pushToHistory('Update Edge Type'); // Synchronous push BEFORE mutation pushToHistory('Update Edge Type'); // Synchronous push BEFORE mutation
graphStore.updateEdgeType(id, updates); updateEdgeTypeInDocument(activeDocumentId, id, updates);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, updateEdgeTypeInDocument]
); );
const deleteEdgeType = useCallback( const deleteEdgeType = useCallback(
(id: string) => { (id: string) => {
if (!activeDocumentId) {
console.warn('No active document');
return;
}
if (isRestoringRef.current) { if (isRestoringRef.current) {
graphStore.deleteEdgeType(id); graphStore.deleteEdgeType(id);
return; return;
} }
const edgeType = graphStore.edgeTypes.find((et) => et.id === id); const edgeType = graphStore.edgeTypes.find((et) => et.id === id);
pushToHistory(`Delete Edge Type: ${edgeType?.label || id}`); // Synchronous push BEFORE mutation pushToHistory(`Delete Edge Type: ${edgeType?.label || id}`); // Synchronous push BEFORE mutation
graphStore.deleteEdgeType(id); deleteEdgeTypeFromDocument(activeDocumentId, id);
}, },
[graphStore, pushToHistory] [activeDocumentId, graphStore, pushToHistory, deleteEdgeTypeFromDocument]
); );
const clearGraph = useCallback( const clearGraph = useCallback(

View file

@ -87,6 +87,14 @@ export interface WorkspaceActions {
markDocumentDirty: (documentId: string) => void; markDocumentDirty: (documentId: string) => void;
saveDocument: (documentId: string) => void; saveDocument: (documentId: string) => void;
// Type management (document-level)
addNodeTypeToDocument: (documentId: string, nodeType: NodeTypeConfig) => void;
updateNodeTypeInDocument: (documentId: string, typeId: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) => void;
deleteNodeTypeFromDocument: (documentId: string, typeId: string) => void;
addEdgeTypeToDocument: (documentId: string, edgeType: EdgeTypeConfig) => void;
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => void;
// Viewport operations // Viewport operations
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void; saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined; getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;

View file

@ -143,17 +143,17 @@ export function useActiveDocument() {
} }
// Mark document as dirty when graph changes // Mark document as dirty when graph changes
// NOTE: We only track nodes/edges here. Type changes are handled by workspaceStore's
// type management actions, which directly mark the document as dirty.
const hasChanges = const hasChanges =
JSON.stringify(graphNodes) !== JSON.stringify(lastSyncedStateRef.current.nodes) || JSON.stringify(graphNodes) !== JSON.stringify(lastSyncedStateRef.current.nodes) ||
JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges) || JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges);
JSON.stringify(graphNodeTypes) !== JSON.stringify(lastSyncedStateRef.current.nodeTypes) ||
JSON.stringify(graphEdgeTypes) !== JSON.stringify(lastSyncedStateRef.current.edgeTypes);
if (hasChanges) { if (hasChanges) {
console.log(`Document ${activeDocumentId} has changes, marking as dirty`); console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
markDocumentDirty(activeDocumentId); markDocumentDirty(activeDocumentId);
// Update the last synced state // Update the last synced state (keep types for reference, but don't track them for changes)
lastSyncedStateRef.current = { lastSyncedStateRef.current = {
documentId: activeDocumentId, documentId: activeDocumentId,
nodes: graphNodes as Actor[], nodes: graphNodes as Actor[],

View file

@ -715,10 +715,9 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
if (doc) { if (doc) {
doc.metadata.updatedAt = new Date().toISOString(); doc.metadata.updatedAt = new Date().toISOString();
// Save global node and edge types from graph store // NOTE: nodeTypes and edgeTypes are already part of the document structure
const graphStore = useGraphStore.getState(); // and are managed via workspaceStore's type management actions.
doc.nodeTypes = graphStore.nodeTypes; // We do NOT copy them from graphStore because the document is the source of truth.
doc.edgeTypes = graphStore.edgeTypes;
// Save timeline data if exists // Save timeline data if exists
const timelineState = useTimelineStore.getState(); const timelineState = useTimelineStore.getState();
@ -882,4 +881,153 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
const metadata = state.documentMetadata.get(documentId); const metadata = state.documentMetadata.get(documentId);
return metadata?.viewport; return metadata?.viewport;
}, },
// Type management - document-level operations
addNodeTypeToDocument: (documentId: string, nodeType) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Add to document's node types
doc.nodeTypes = [...doc.nodeTypes, nodeType];
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
}
},
updateNodeTypeInDocument: (documentId: string, typeId: string, updates) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Update in document's node types
doc.nodeTypes = doc.nodeTypes.map((type) =>
type.id === typeId ? { ...type, ...updates } : type
);
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
}
},
deleteNodeTypeFromDocument: (documentId: string, typeId: string) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Remove from document's node types
doc.nodeTypes = doc.nodeTypes.filter((type) => type.id !== typeId);
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
}
},
addEdgeTypeToDocument: (documentId: string, edgeType) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Add to document's edge types
doc.edgeTypes = [...doc.edgeTypes, edgeType];
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
}
},
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Update in document's edge types
doc.edgeTypes = doc.edgeTypes.map((type) =>
type.id === typeId ? { ...type, ...updates } : type
);
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
}
},
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => {
const state = get();
const doc = state.documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Remove from document's edge types
doc.edgeTypes = doc.edgeTypes.filter((type) => type.id !== typeId);
// Save document
saveDocumentToStorage(documentId, doc);
// Mark as dirty
get().markDocumentDirty(documentId);
// If this is the active document, sync to graphStore
if (documentId === state.activeDocumentId) {
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
}
},
})); }));