From 89117415ed3eff470d5e2cd2a38fe78c73b91859 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 12 Oct 2025 14:27:57 +0200 Subject: [PATCH] refactor: establish document as source of truth for node/edge types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useDocumentHistory.ts | 51 +++++-- src/hooks/useGraphWithHistory.ts | 56 ++++++-- src/stores/workspace/types.ts | 8 ++ src/stores/workspace/useActiveDocument.ts | 8 +- src/stores/workspaceStore.ts | 156 +++++++++++++++++++++- 5 files changed, 248 insertions(+), 31 deletions(-) diff --git a/src/hooks/useDocumentHistory.ts b/src/hooks/useDocumentHistory.ts index 8efc315..7234291 100644 --- a/src/hooks/useDocumentHistory.ts +++ b/src/hooks/useDocumentHistory.ts @@ -47,7 +47,8 @@ export function useDocumentHistory() { } // Get current state from stores - const graphStore = useGraphStore.getState(); + const workspaceStore = useWorkspaceStore.getState(); + const activeDoc = workspaceStore.getActiveDocument(); const timelineStore = useTimelineStore.getState(); const timeline = timelineStore.timelines.get(activeDocumentId); @@ -56,15 +57,21 @@ export function useDocumentHistory() { return; } + if (!activeDoc) { + console.warn('Active document not found'); + return; + } + // Create a snapshot of the complete document state + // NOTE: Read types from the document, not from graphStore const snapshot: DocumentSnapshot = { timeline: { states: new Map(timeline.states), // Clone the Map currentStateId: timeline.currentStateId, rootStateId: timeline.rootStateId, }, - nodeTypes: graphStore.nodeTypes, - edgeTypes: graphStore.edgeTypes, + nodeTypes: activeDoc.nodeTypes, + edgeTypes: activeDoc.edgeTypes, }; // Push to history @@ -87,7 +94,8 @@ export function useDocumentHistory() { } // Capture current state BEFORE undoing - const graphStore = useGraphStore.getState(); + const workspaceStore = useWorkspaceStore.getState(); + const activeDoc = workspaceStore.getActiveDocument(); const timelineStore = useTimelineStore.getState(); const timeline = timelineStore.timelines.get(activeDocumentId); @@ -96,14 +104,20 @@ export function useDocumentHistory() { return; } + if (!activeDoc) { + console.warn('Active document not found'); + return; + } + + // NOTE: Read types from the document, not from graphStore const currentSnapshot: DocumentSnapshot = { timeline: { states: new Map(timeline.states), currentStateId: timeline.currentStateId, rootStateId: timeline.rootStateId, }, - nodeTypes: graphStore.nodeTypes, - edgeTypes: graphStore.edgeTypes, + nodeTypes: activeDoc.nodeTypes, + edgeTypes: activeDoc.edgeTypes, }; const restoredState = historyStore.undo(activeDocumentId, currentSnapshot); @@ -112,7 +126,11 @@ export function useDocumentHistory() { // Restore complete document state (timeline + types) 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); setEdgeTypes(restoredState.edgeTypes); @@ -146,7 +164,8 @@ export function useDocumentHistory() { } // Capture current state BEFORE redoing - const graphStore = useGraphStore.getState(); + const workspaceStore = useWorkspaceStore.getState(); + const activeDoc = workspaceStore.getActiveDocument(); const timelineStore = useTimelineStore.getState(); const timeline = timelineStore.timelines.get(activeDocumentId); @@ -155,14 +174,20 @@ export function useDocumentHistory() { return; } + if (!activeDoc) { + console.warn('Active document not found'); + return; + } + + // NOTE: Read types from the document, not from graphStore const currentSnapshot: DocumentSnapshot = { timeline: { states: new Map(timeline.states), currentStateId: timeline.currentStateId, rootStateId: timeline.rootStateId, }, - nodeTypes: graphStore.nodeTypes, - edgeTypes: graphStore.edgeTypes, + nodeTypes: activeDoc.nodeTypes, + edgeTypes: activeDoc.edgeTypes, }; const restoredState = historyStore.redo(activeDocumentId, currentSnapshot); @@ -171,7 +196,11 @@ export function useDocumentHistory() { // Restore complete document state (timeline + types) 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); setEdgeTypes(restoredState.edgeTypes); diff --git a/src/hooks/useGraphWithHistory.ts b/src/hooks/useGraphWithHistory.ts index b7abed3..ecb3010 100644 --- a/src/hooks/useGraphWithHistory.ts +++ b/src/hooks/useGraphWithHistory.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useEffect } from 'react'; import { useGraphStore } from '../stores/graphStore'; +import { useWorkspaceStore } from '../stores/workspaceStore'; import { useDocumentHistory } from './useDocumentHistory'; import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } from '../types'; @@ -38,6 +39,13 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr */ export function useGraphWithHistory() { 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(); // Track if we're currently restoring from history to prevent recursive history pushes @@ -169,76 +177,100 @@ export function useGraphWithHistory() { const addNodeType = useCallback( (nodeType: NodeTypeConfig) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.addNodeType(nodeType); return; } 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( (id: string, updates: Partial>) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.updateNodeType(id, updates); return; } 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( (id: string) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.deleteNodeType(id); return; } const nodeType = graphStore.nodeTypes.find((nt) => nt.id === id); 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( (edgeType: EdgeTypeConfig) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.addEdgeType(edgeType); return; } 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( (id: string, updates: Partial>) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.updateEdgeType(id, updates); return; } 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( (id: string) => { + if (!activeDocumentId) { + console.warn('No active document'); + return; + } if (isRestoringRef.current) { graphStore.deleteEdgeType(id); return; } const edgeType = graphStore.edgeTypes.find((et) => et.id === id); 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( diff --git a/src/stores/workspace/types.ts b/src/stores/workspace/types.ts index 1615012..6dfab6b 100644 --- a/src/stores/workspace/types.ts +++ b/src/stores/workspace/types.ts @@ -87,6 +87,14 @@ export interface WorkspaceActions { markDocumentDirty: (documentId: string) => void; saveDocument: (documentId: string) => void; + // Type management (document-level) + addNodeTypeToDocument: (documentId: string, nodeType: NodeTypeConfig) => void; + updateNodeTypeInDocument: (documentId: string, typeId: string, updates: Partial>) => void; + deleteNodeTypeFromDocument: (documentId: string, typeId: string) => void; + addEdgeTypeToDocument: (documentId: string, edgeType: EdgeTypeConfig) => void; + updateEdgeTypeInDocument: (documentId: string, typeId: string, updates: Partial>) => void; + deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => void; + // Viewport operations saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void; getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined; diff --git a/src/stores/workspace/useActiveDocument.ts b/src/stores/workspace/useActiveDocument.ts index 8a21d8d..14a47bd 100644 --- a/src/stores/workspace/useActiveDocument.ts +++ b/src/stores/workspace/useActiveDocument.ts @@ -143,17 +143,17 @@ export function useActiveDocument() { } // 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 = JSON.stringify(graphNodes) !== JSON.stringify(lastSyncedStateRef.current.nodes) || - JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges) || - JSON.stringify(graphNodeTypes) !== JSON.stringify(lastSyncedStateRef.current.nodeTypes) || - JSON.stringify(graphEdgeTypes) !== JSON.stringify(lastSyncedStateRef.current.edgeTypes); + JSON.stringify(graphEdges) !== JSON.stringify(lastSyncedStateRef.current.edges); if (hasChanges) { console.log(`Document ${activeDocumentId} has changes, marking as dirty`); 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 = { documentId: activeDocumentId, nodes: graphNodes as Actor[], diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index 352a9ab..36c7df7 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -715,10 +715,9 @@ export const useWorkspaceStore = create((set, get) if (doc) { doc.metadata.updatedAt = new Date().toISOString(); - // Save global node and edge types from graph store - const graphStore = useGraphStore.getState(); - doc.nodeTypes = graphStore.nodeTypes; - doc.edgeTypes = graphStore.edgeTypes; + // NOTE: nodeTypes and edgeTypes are already part of the document structure + // and are managed via workspaceStore's type management actions. + // We do NOT copy them from graphStore because the document is the source of truth. // Save timeline data if exists const timelineState = useTimelineStore.getState(); @@ -882,4 +881,153 @@ export const useWorkspaceStore = create((set, get) const metadata = state.documentMetadata.get(documentId); 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); + } + }, }));