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
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
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) => {
|
addNodeTypeToDocument: (documentId: string, nodeType) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const doc = state.documents.get(documentId);
|
const doc = state.documents.get(documentId);
|
||||||
|
|
@ -967,19 +1011,40 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to document's node types
|
// Capture original state for rollback
|
||||||
doc.nodeTypes = [...doc.nodeTypes, nodeType];
|
const originalNodeTypes = [...doc.nodeTypes];
|
||||||
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
// 1. Update document in memory
|
||||||
|
doc.nodeTypes = [...doc.nodeTypes, nodeType];
|
||||||
|
|
||||||
// Mark as dirty
|
// 2. Save to storage (can throw QuotaExceededError)
|
||||||
get().markDocumentDirty(documentId);
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
|
||||||
// If this is the active document, sync to graphStore
|
// 3. Mark as dirty
|
||||||
if (documentId === state.activeDocumentId) {
|
get().markDocumentDirty(documentId);
|
||||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
|
||||||
}
|
// 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) => {
|
updateNodeTypeInDocument: (documentId: string, typeId: string, updates) => {
|
||||||
|
|
@ -991,21 +1056,42 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in document's node types
|
// Capture original state for rollback
|
||||||
doc.nodeTypes = doc.nodeTypes.map((type) =>
|
const originalNodeTypes = [...doc.nodeTypes];
|
||||||
type.id === typeId ? { ...type, ...updates } : type
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Save to storage (can throw QuotaExceededError)
|
||||||
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
|
||||||
|
// 3. Mark as dirty
|
||||||
|
get().markDocumentDirty(documentId);
|
||||||
|
|
||||||
|
// 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'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) => {
|
deleteNodeTypeFromDocument: (documentId: string, typeId: string) => {
|
||||||
|
|
@ -1017,19 +1103,40 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from document's node types
|
// Capture original state for rollback
|
||||||
doc.nodeTypes = doc.nodeTypes.filter((type) => type.id !== typeId);
|
const originalNodeTypes = [...doc.nodeTypes];
|
||||||
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
// 1. Update document in memory
|
||||||
|
doc.nodeTypes = doc.nodeTypes.filter((type) => type.id !== typeId);
|
||||||
|
|
||||||
// Mark as dirty
|
// 2. Save to storage (can throw QuotaExceededError)
|
||||||
get().markDocumentDirty(documentId);
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
|
||||||
// If this is the active document, sync to graphStore
|
// 3. Mark as dirty
|
||||||
if (documentId === state.activeDocumentId) {
|
get().markDocumentDirty(documentId);
|
||||||
useGraphStore.getState().setNodeTypes(doc.nodeTypes);
|
|
||||||
}
|
// 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) => {
|
addEdgeTypeToDocument: (documentId: string, edgeType) => {
|
||||||
|
|
@ -1041,19 +1148,31 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to document's edge types
|
// Capture original state for rollback
|
||||||
doc.edgeTypes = [...doc.edgeTypes, edgeType];
|
const originalEdgeTypes = [...doc.edgeTypes];
|
||||||
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
doc.edgeTypes = [...doc.edgeTypes, edgeType];
|
||||||
// Mark as dirty
|
saveDocumentToStorage(documentId, doc);
|
||||||
get().markDocumentDirty(documentId);
|
get().markDocumentDirty(documentId);
|
||||||
|
if (documentId === state.activeDocumentId) {
|
||||||
// If this is the active document, sync to graphStore
|
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||||
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) => {
|
updateEdgeTypeInDocument: (documentId: string, typeId: string, updates) => {
|
||||||
|
|
@ -1065,21 +1184,33 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in document's edge types
|
// Capture original state for rollback
|
||||||
doc.edgeTypes = doc.edgeTypes.map((type) =>
|
const originalEdgeTypes = [...doc.edgeTypes];
|
||||||
type.id === typeId ? { ...type, ...updates } : type
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
|
get().executeTypeTransaction(
|
||||||
|
() => {
|
||||||
|
doc.edgeTypes = doc.edgeTypes.map((type) =>
|
||||||
|
type.id === typeId ? { ...type, ...updates } : type
|
||||||
|
);
|
||||||
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
get().markDocumentDirty(documentId);
|
||||||
|
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'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) => {
|
deleteEdgeTypeFromDocument: (documentId: string, typeId: string) => {
|
||||||
|
|
@ -1091,19 +1222,31 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from document's edge types
|
// Capture original state for rollback
|
||||||
doc.edgeTypes = doc.edgeTypes.filter((type) => type.id !== typeId);
|
const originalEdgeTypes = [...doc.edgeTypes];
|
||||||
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
doc.edgeTypes = doc.edgeTypes.filter((type) => type.id !== typeId);
|
||||||
// Mark as dirty
|
saveDocumentToStorage(documentId, doc);
|
||||||
get().markDocumentDirty(documentId);
|
get().markDocumentDirty(documentId);
|
||||||
|
if (documentId === state.activeDocumentId) {
|
||||||
// If this is the active document, sync to graphStore
|
useGraphStore.getState().setEdgeTypes(doc.edgeTypes);
|
||||||
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
|
// Label management - document-level operations
|
||||||
|
|
@ -1121,19 +1264,31 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
doc.labels = [];
|
doc.labels = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to document's labels
|
// Capture original state for rollback
|
||||||
doc.labels = [...doc.labels, label];
|
const originalLabels = [...doc.labels];
|
||||||
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
doc.labels = [...(doc.labels || []), label];
|
||||||
// Mark as dirty
|
saveDocumentToStorage(documentId, doc);
|
||||||
get().markDocumentDirty(documentId);
|
get().markDocumentDirty(documentId);
|
||||||
|
if (documentId === state.activeDocumentId) {
|
||||||
// If this is the active document, sync to graphStore
|
useGraphStore.getState().setLabels(doc.labels);
|
||||||
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) => {
|
updateLabelInDocument: (documentId: string, labelId: string, updates) => {
|
||||||
|
|
@ -1150,21 +1305,33 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
doc.labels = [];
|
doc.labels = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in document's labels
|
// Capture original state for rollback
|
||||||
doc.labels = doc.labels.map((label) =>
|
const originalLabels = [...doc.labels];
|
||||||
label.id === labelId ? { ...label, ...updates } : label
|
const originalIsDirty = state.documentMetadata.get(documentId)?.isDirty;
|
||||||
|
|
||||||
|
get().executeTypeTransaction(
|
||||||
|
() => {
|
||||||
|
doc.labels = (doc.labels || []).map((label) =>
|
||||||
|
label.id === labelId ? { ...label, ...updates } : label
|
||||||
|
);
|
||||||
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
get().markDocumentDirty(documentId);
|
||||||
|
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'
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteLabelFromDocument: (documentId: string, labelId: string) => {
|
deleteLabelFromDocument: (documentId: string, labelId: string) => {
|
||||||
|
|
@ -1181,58 +1348,155 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
doc.labels = [];
|
doc.labels = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from document's labels
|
// Capture original state for rollback
|
||||||
doc.labels = doc.labels.filter((label) => label.id !== labelId);
|
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 timelineStore = useTimelineStore.getState();
|
||||||
const timeline = timelineStore.timelines.get(documentId);
|
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) {
|
if (timeline) {
|
||||||
let hasChanges = false;
|
timeline.states.forEach((constellationState, stateId) => {
|
||||||
|
const affectedNodes: Array<{ id: string; labels: string[] }> = [];
|
||||||
|
const affectedEdges: Array<{ id: string; labels: string[] }> = [];
|
||||||
|
|
||||||
// Iterate through all timeline states and clean up label references
|
// Capture nodes that have this label
|
||||||
timeline.states.forEach((constellationState) => {
|
|
||||||
// Clean up nodes
|
|
||||||
constellationState.graph.nodes.forEach((node) => {
|
constellationState.graph.nodes.forEach((node) => {
|
||||||
const nodeData = node.data as { labels?: string[] };
|
const nodeData = node.data as { labels?: string[] };
|
||||||
if (nodeData?.labels && nodeData.labels.includes(labelId)) {
|
if (nodeData?.labels && nodeData.labels.includes(labelId)) {
|
||||||
nodeData.labels = nodeData.labels.filter((id: string) => id !== labelId);
|
affectedNodes.push({ id: node.id, labels: [...nodeData.labels] });
|
||||||
hasChanges = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up edges
|
// Capture edges that have this label
|
||||||
constellationState.graph.edges.forEach((edge) => {
|
constellationState.graph.edges.forEach((edge) => {
|
||||||
const edgeData = edge.data as { labels?: string[] };
|
const edgeData = edge.data as { labels?: string[] };
|
||||||
if (edgeData?.labels && edgeData.labels.includes(labelId)) {
|
if (edgeData?.labels && edgeData.labels.includes(labelId)) {
|
||||||
edgeData.labels = edgeData.labels.filter((id: string) => id !== labelId);
|
affectedEdges.push({ id: edge.id, labels: [...edgeData.labels] });
|
||||||
hasChanges = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// If this is the active document and changes were made, sync to graphStore
|
if (affectedNodes.length > 0 || affectedEdges.length > 0) {
|
||||||
if (hasChanges && documentId === state.activeDocumentId) {
|
originalTimelineStates.set(stateId, { nodes: affectedNodes, edges: affectedEdges });
|
||||||
const currentState = timeline.states.get(timeline.currentStateId);
|
|
||||||
if (currentState) {
|
|
||||||
useGraphStore.setState({
|
|
||||||
nodes: currentState.graph.nodes as Actor[],
|
|
||||||
edges: currentState.graph.edges as Relation[],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save document
|
get().executeTypeTransaction(
|
||||||
saveDocumentToStorage(documentId, doc);
|
() => {
|
||||||
|
// 1. Remove from document's labels
|
||||||
|
doc.labels = (doc.labels || []).filter((label) => label.id !== labelId);
|
||||||
|
|
||||||
// Mark as dirty
|
// 2. Remove label from all nodes and edges in all timeline states
|
||||||
get().markDocumentDirty(documentId);
|
if (timeline) {
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
// If this is the active document, sync to graphStore
|
// Iterate through all timeline states and clean up label references
|
||||||
if (documentId === state.activeDocumentId) {
|
timeline.states.forEach((constellationState) => {
|
||||||
useGraphStore.getState().setLabels(doc.labels);
|
// Clean up nodes
|
||||||
}
|
constellationState.graph.nodes.forEach((node) => {
|
||||||
|
const nodeData = node.data as { labels?: string[] };
|
||||||
|
if (nodeData?.labels && nodeData.labels.includes(labelId)) {
|
||||||
|
nodeData.labels = nodeData.labels.filter((id: string) => id !== labelId);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up edges
|
||||||
|
constellationState.graph.edges.forEach((edge) => {
|
||||||
|
const edgeData = edge.data as { labels?: string[] };
|
||||||
|
if (edgeData?.labels && edgeData.labels.includes(labelId)) {
|
||||||
|
edgeData.labels = edgeData.labels.filter((id: string) => id !== labelId);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this is the active document and changes were made, sync to graphStore
|
||||||
|
if (hasChanges && 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[],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Save document to storage (can throw QuotaExceededError)
|
||||||
|
saveDocumentToStorage(documentId, doc);
|
||||||
|
|
||||||
|
// 4. Mark as dirty
|
||||||
|
get().markDocumentDirty(documentId);
|
||||||
|
|
||||||
|
// 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