mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
feat: add atomic transaction pattern to type management operations
Implements Phase 3.1 of the state management refactoring plan. Changes: - Added executeTypeTransaction() helper in workspaceStore for atomic operations with automatic rollback on failure - Refactored all 9 type/label operations to use transaction pattern: * Node types: add, update, delete * Edge types: add, update, delete * Labels: add, update, delete - Each operation now captures original state and can rollback if: * localStorage quota is exceeded (QuotaExceededError) * Any other error occurs during the multi-step process Transaction pattern ensures atomicity: 1. Capture original state (for rollback) 2. Execute operation (update doc, save storage, mark dirty, sync) 3. If error: rollback to original state and show error toast 4. Rollback restores: document state, isDirty flag, graphStore sync Label deletion includes comprehensive rollback: - Restores label array - Restores label references in all timeline states (nodes and edges) - Syncs graphStore if active document Benefits: - Prevents partial failures leaving state inconsistent - Handles storage quota errors gracefully - Provides user feedback via toast notifications - Maintains data integrity across all stores 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6a56b94477
commit
1059c05242
2 changed files with 400 additions and 129 deletions
|
|
@ -103,4 +103,11 @@ export interface WorkspaceActions {
|
|||
// Viewport operations
|
||||
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
|
||||
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;
|
||||
|
||||
// Transaction helper (internal utility for atomic operations)
|
||||
executeTypeTransaction: <T>(
|
||||
operation: () => T,
|
||||
rollback: () => void,
|
||||
operationName: string
|
||||
) => T | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -957,7 +957,51 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return metadata?.viewport;
|
||||
},
|
||||
|
||||
// Type management - document-level operations
|
||||
// ============================================================================
|
||||
// TYPE MANAGEMENT - DOCUMENT-LEVEL OPERATIONS WITH TRANSACTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute a type operation with transaction semantics and automatic rollback
|
||||
*
|
||||
* This ensures type operations are atomic: either all steps succeed or all are rolled back.
|
||||
* Handles errors gracefully (e.g., localStorage quota exceeded) with automatic rollback.
|
||||
*
|
||||
* @param operation - Function that performs the operation (can throw)
|
||||
* @param rollback - Function to rollback changes on failure
|
||||
* @param operationName - Human-readable name for error messages
|
||||
* @returns Operation result or null on failure
|
||||
*/
|
||||
executeTypeTransaction: <T>(
|
||||
operation: () => T,
|
||||
rollback: () => void,
|
||||
operationName: string
|
||||
): T | null => {
|
||||
try {
|
||||
const result = operation();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`${operationName} failed:`, error);
|
||||
|
||||
// Rollback changes
|
||||
try {
|
||||
rollback();
|
||||
console.log(`Rolled back ${operationName}`);
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${operationName}:`, rollbackError);
|
||||
}
|
||||
|
||||
// Show user-friendly error
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
useToastStore.getState().showToast(
|
||||
`Failed to ${operationName}: ${errorMessage}`,
|
||||
'error'
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
addNodeTypeToDocument: (documentId: string, nodeType) => {
|
||||
const state = get();
|
||||
const doc = state.documents.get(documentId);
|
||||
|
|
@ -967,20 +1011,41 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Add to document's node types
|
||||
// Capture original state for rollback
|
||||
const originalNodeTypes = [...doc.nodeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
// 1. Update document in memory
|
||||
doc.nodeTypes = [...doc.nodeTypes, nodeType];
|
||||
|
||||
// Save document
|
||||
// 2. Save to storage (can throw QuotaExceededError)
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
// 3. Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
// 4. Sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Rollback on failure
|
||||
doc.nodeTypes = originalNodeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
// Re-sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
'add node type'
|
||||
);
|
||||
},
|
||||
|
||||
updateNodeTypeInDocument: (documentId: string, typeId: string, updates) => {
|
||||
const state = get();
|
||||
|
|
@ -991,22 +1056,43 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Update in document's node types
|
||||
// Capture original state for rollback
|
||||
const originalNodeTypes = [...doc.nodeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
// 1. Update document in memory
|
||||
doc.nodeTypes = doc.nodeTypes.map((type) =>
|
||||
type.id === typeId ? { ...type, ...updates } : type
|
||||
);
|
||||
|
||||
// Save document
|
||||
// 2. Save to storage (can throw QuotaExceededError)
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
// 3. Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
// 4. Sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Rollback on failure
|
||||
doc.nodeTypes = originalNodeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
// Re-sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
'update node type'
|
||||
);
|
||||
},
|
||||
|
||||
deleteNodeTypeFromDocument: (documentId: string, typeId: string) => {
|
||||
const state = get();
|
||||
|
|
@ -1017,20 +1103,41 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Remove from document's node types
|
||||
// Capture original state for rollback
|
||||
const originalNodeTypes = [...doc.nodeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
// 1. Update document in memory
|
||||
doc.nodeTypes = doc.nodeTypes.filter((type) => type.id !== typeId);
|
||||
|
||||
// Save document
|
||||
// 2. Save to storage (can throw QuotaExceededError)
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
// 3. Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
// 4. Sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Rollback on failure
|
||||
doc.nodeTypes = originalNodeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
// Re-sync to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
||||
}
|
||||
},
|
||||
'delete node type'
|
||||
);
|
||||
},
|
||||
|
||||
addEdgeTypeToDocument: (documentId: string, edgeType) => {
|
||||
const state = get();
|
||||
|
|
@ -1041,20 +1148,32 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Add to document's edge types
|
||||
// Capture original state for rollback
|
||||
const originalEdgeTypes = [...doc.edgeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
doc.edgeTypes = originalEdgeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||
}
|
||||
},
|
||||
'add edge type'
|
||||
);
|
||||
},
|
||||
|
||||
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates) => {
|
||||
const state = get();
|
||||
|
|
@ -1065,22 +1184,34 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Update in document's edge types
|
||||
// Capture original state for rollback
|
||||
const originalEdgeTypes = [...doc.edgeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
doc.edgeTypes = originalEdgeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||
}
|
||||
},
|
||||
'update edge type'
|
||||
);
|
||||
},
|
||||
|
||||
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => {
|
||||
const state = get();
|
||||
|
|
@ -1091,20 +1222,32 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
return;
|
||||
}
|
||||
|
||||
// Remove from document's edge types
|
||||
// Capture original state for rollback
|
||||
const originalEdgeTypes = [...doc.edgeTypes];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
doc.edgeTypes = originalEdgeTypes;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||
}
|
||||
},
|
||||
'delete edge type'
|
||||
);
|
||||
},
|
||||
|
||||
// Label management - document-level operations
|
||||
addLabelToDocument: (documentId: string, label) => {
|
||||
|
|
@ -1121,20 +1264,32 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Add to document's labels
|
||||
doc.labels = [...doc.labels, label];
|
||||
// Capture original state for rollback
|
||||
const originalLabels = [...doc.labels];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
// Save document
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
doc.labels = [...(doc.labels || []), label];
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
doc.labels = originalLabels;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
'add label'
|
||||
);
|
||||
},
|
||||
|
||||
updateLabelInDocument: (documentId: string, labelId: string, updates) => {
|
||||
const state = get();
|
||||
|
|
@ -1150,22 +1305,34 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Update in document's labels
|
||||
doc.labels = doc.labels.map((label) =>
|
||||
// Capture original state for rollback
|
||||
const originalLabels = [...doc.labels];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
doc.labels = (doc.labels || []).map((label) =>
|
||||
label.id === labelId ? { ...label, ...updates } : label
|
||||
);
|
||||
|
||||
// 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().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
doc.labels = originalLabels;
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
'update label'
|
||||
);
|
||||
},
|
||||
|
||||
deleteLabelFromDocument: (documentId: string, labelId: string) => {
|
||||
const state = get();
|
||||
|
|
@ -1181,13 +1348,51 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
doc.labels = [];
|
||||
}
|
||||
|
||||
// Remove from document's labels
|
||||
doc.labels = doc.labels.filter((label) => label.id !== labelId);
|
||||
// Capture original state for rollback
|
||||
const originalLabels = [...doc.labels];
|
||||
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||
|
||||
// Remove label from all nodes and edges in all timeline states
|
||||
// Capture original timeline state (deep copy of nodes/edges that contain the label)
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
const timeline = timelineStore.timelines.get(documentId);
|
||||
const originalTimelineStates = new Map<string, {
|
||||
nodes: Array<{ id: string; labels: string[] }>;
|
||||
edges: Array<{ id: string; labels: string[] }>;
|
||||
}>();
|
||||
|
||||
if (timeline) {
|
||||
timeline.states.forEach((constellationState, stateId) => {
|
||||
const affectedNodes: Array<{ id: string; labels: string[] }> = [];
|
||||
const affectedEdges: Array<{ id: string; labels: string[] }> = [];
|
||||
|
||||
// Capture nodes that have this label
|
||||
constellationState.graph.nodes.forEach((node) => {
|
||||
const nodeData = node.data as { labels?: string[] };
|
||||
if (nodeData?.labels && nodeData.labels.includes(labelId)) {
|
||||
affectedNodes.push({ id: node.id, labels: [...nodeData.labels] });
|
||||
}
|
||||
});
|
||||
|
||||
// Capture edges that have this label
|
||||
constellationState.graph.edges.forEach((edge) => {
|
||||
const edgeData = edge.data as { labels?: string[] };
|
||||
if (edgeData?.labels && edgeData.labels.includes(labelId)) {
|
||||
affectedEdges.push({ id: edge.id, labels: [...edgeData.labels] });
|
||||
}
|
||||
});
|
||||
|
||||
if (affectedNodes.length > 0 || affectedEdges.length > 0) {
|
||||
originalTimelineStates.set(stateId, { nodes: affectedNodes, edges: affectedEdges });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get().executeTypeTransaction(
|
||||
() => {
|
||||
// 1. Remove from document's labels
|
||||
doc.labels = (doc.labels || []).filter((label) => label.id !== labelId);
|
||||
|
||||
// 2. Remove label from all nodes and edges in all timeline states
|
||||
if (timeline) {
|
||||
let hasChanges = false;
|
||||
|
||||
|
|
@ -1224,15 +1429,74 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
|||
}
|
||||
}
|
||||
|
||||
// Save document
|
||||
// 3. Save document to storage (can throw QuotaExceededError)
|
||||
saveDocumentToStorage(documentId, doc);
|
||||
|
||||
// Mark as dirty
|
||||
// 4. Mark as dirty
|
||||
get().markDocumentDirty(documentId);
|
||||
|
||||
// If this is the active document, sync to graphStore
|
||||
// 5. If this is the active document, sync to graphStore
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Rollback on failure
|
||||
doc.labels = originalLabels;
|
||||
|
||||
// Restore timeline state label references
|
||||
if (timeline) {
|
||||
originalTimelineStates.forEach((originalState, stateId) => {
|
||||
const constellationState = timeline.states.get(stateId);
|
||||
if (!constellationState) return;
|
||||
|
||||
// Restore node labels
|
||||
originalState.nodes.forEach((originalNode) => {
|
||||
const node = constellationState.graph.nodes.find((n) => n.id === originalNode.id);
|
||||
if (node) {
|
||||
const nodeData = node.data as { labels?: string[] };
|
||||
if (nodeData) {
|
||||
nodeData.labels = [...originalNode.labels];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Restore edge labels
|
||||
originalState.edges.forEach((originalEdge) => {
|
||||
const edge = constellationState.graph.edges.find((e) => e.id === originalEdge.id);
|
||||
if (edge) {
|
||||
const edgeData = edge.data as { labels?: string[] };
|
||||
if (edgeData) {
|
||||
edgeData.labels = [...originalEdge.labels];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sync restored state to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
const currentState = timeline.states.get(timeline.currentStateId);
|
||||
if (currentState) {
|
||||
useGraphStore.setState({
|
||||
nodes: currentState.graph.nodes as Actor[],
|
||||
edges: currentState.graph.edges as Relation[],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore isDirty flag
|
||||
const metadata = state.documentMetadata.get(documentId);
|
||||
if (metadata && originalIsDirty !== undefined) {
|
||||
metadata.isDirty = originalIsDirty;
|
||||
}
|
||||
|
||||
// Sync labels to graphStore if active
|
||||
if (documentId === state.activeDocumentId) {
|
||||
useGraphStore.getState().setLabels(doc.labels);
|
||||
}
|
||||
},
|
||||
'delete label'
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue