From 6309ae716bd3f46f118881dba38a76435ee0748a Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Wed, 27 May 2026 17:43:18 +0200 Subject: [PATCH] feat: implement snapshot restore functionality and enhance graph state management --- src/components/admin/KnowledgeGraph.jsx | 28 ++++++++- src/components/admin/graph/GraphControls.jsx | 32 ++++++++-- src/hooks/useGraphData.js | 61 ++++++++++++++++++-- src/lib/db.js | 54 +++++++++++++++++ 4 files changed, 164 insertions(+), 11 deletions(-) diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index aae1a29..f9921cd 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -25,9 +25,12 @@ const KnowledgeGraph = () => { const [showExcludeNodes, setShowExcludeNodes] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState(null); + const [isRestoring, setIsRestoring] = useState(false); - const { topics, relations, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave } = - useGraphData(); + const { + topics, relations, snapshotMeta, + reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot, + } = useGraphData(); // ── Canvas sizing ──────────────────────────────────────────────────────────── @@ -177,6 +180,24 @@ const KnowledgeGraph = () => { } }, [selectedNode, deleteTopic]); + // ── Snapshot restore ───────────────────────────────────────────────────────── + + const handleRestore = useCallback(async () => { + if ( + !confirm( + `Restore the graph to the snapshot from ${new Date(snapshotMeta?.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}?\n\nThis will undo the last Analyze & Optimize run. The snapshot will be cleared after restoring.`, + ) + ) + return; + setIsRestoring(true); + try { + await restoreSnapshot(); + setSelectedNode(null); + } finally { + setIsRestoring(false); + } + }, [restoreSnapshot, snapshotMeta]); + // ── AI graph analysis ──────────────────────────────────────────────────────── const analyzeGraph = useCallback(async () => { @@ -309,6 +330,9 @@ Do not return the entire graph — only the actions to take.`; analyzeError={analyzeError} disabled={topics.length === 0} onApplied={reload} + snapshotMeta={snapshotMeta} + onRestore={handleRestore} + isRestoring={isRestoring} /> -
+
+ {/* Restore row — only visible after a bulkSave has written a snapshot */} + {snapshotMeta && ( + + )} + {analyzeError && ( -

+

{analyzeError}

diff --git a/src/hooks/useGraphData.js b/src/hooks/useGraphData.js index ea80aad..4b43ced 100644 --- a/src/hooks/useGraphData.js +++ b/src/hooks/useGraphData.js @@ -10,11 +10,20 @@ import * as db from '../lib/db'; * Relation shape contract: source and target are always plain strings. * The normalization in db.getRelations() enforces this at the DB boundary; * the filter here drops any orphaned edges whose endpoints no longer exist. + * + * Snapshot contract: + * bulkSave() writes a snapshot of the CURRENT state to PocketBase before + * overwriting it — so the admin can always roll back one step. snapshotMeta + * is non-null whenever a rollback point exists; restoreSnapshot() applies it + * and then clears it so it cannot be applied twice. */ export function useGraphData() { const [topics, setTopics] = useState([]); const [relations, setRelations] = useState([]); + // { ts: number, topicCount: number, relationCount: number } + const [snapshotMeta, setSnapshotMeta] = useState(null); + // Refs that always hold the latest state — lets mutation callbacks avoid // stale-closure bugs without including state in their dependency arrays. const topicsRef = useRef([]); @@ -41,11 +50,19 @@ export function useGraphData() { return () => window.removeEventListener('respellion:kb-updated', reload); }, [reload]); + // On mount, check whether a snapshot from a previous session exists. + useEffect(() => { + db.getGraphSnapshot().then(snap => { + if (snap?.ts) { + setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount }); + } + }); + }, []); + // ── Single-topic mutations ─────────────────────────────────────────────────── /** * Persist a topic update and mirror it into local state. - * Automatically locks learning_relevance when the admin changes it. * Returns the saved topic object. */ const updateTopic = useCallback(async (topic) => { @@ -74,7 +91,7 @@ export function useGraphData() { /** * Add a relation if not already present. - * Returns true if the relation was added, false if it was a duplicate. + * Returns true if added, false if it was a duplicate. */ const addRelation = useCallback(async (relation) => { const isDup = relationsRef.current.some( @@ -99,24 +116,60 @@ export function useGraphData() { // ── Bulk mutations (used by AI analysis) ──────────────────────────────────── /** - * Atomically replace the entire graph in PocketBase and local state. - * Called by analyzeGraph after all AI actions have been applied locally. + * Snapshot the current graph then replace everything in PocketBase and local + * state. The snapshot is written BEFORE the destructive delete-all/create-all + * so it survives a mid-operation failure. + * + * Called by analyzeGraph after all AI actions have been applied in memory. */ const bulkSave = useCallback(async (newTopics, newRelations) => { + // Persist a rollback point of the state we are about to overwrite. + await db.saveGraphSnapshot(topicsRef.current, relationsRef.current); + setSnapshotMeta({ + ts: Date.now(), + topicCount: topicsRef.current.length, + relationCount: relationsRef.current.length, + }); + await db.saveTopics(newTopics); await db.saveRelations(newRelations); setTopics(newTopics); setRelations(newRelations); }, []); + /** + * Load the latest snapshot from PocketBase and apply it as the current graph. + * Clears the snapshot afterwards so the admin cannot restore the same point + * twice (which would silently re-apply the AI changes they just rolled back). + * + * Returns true on success, false if no snapshot exists. + */ + const restoreSnapshot = useCallback(async () => { + const snap = await db.getGraphSnapshot(); + if (!snap?.topics) return false; + + // Apply without taking a new snapshot — we don't want to overwrite + // the only rollback point we have. + await db.saveTopics(snap.topics); + await db.saveRelations(snap.relations); + setTopics(snap.topics); + setRelations(snap.relations); + + await db.clearGraphSnapshot(); + setSnapshotMeta(null); + return true; + }, []); + return { topics, relations, + snapshotMeta, reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, + restoreSnapshot, }; } diff --git a/src/lib/db.js b/src/lib/db.js index 3e008ca..53832d4 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -284,6 +284,60 @@ export function setSetting(key, value) { return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) }); } +// ── Graph Snapshots ─────────────────────────────────────────────────────────── +// +// A single "latest" snapshot is persisted in the settings collection before +// every bulk graph write. This survives browser/tab close and lets the admin +// roll back an AI analysis that produced unwanted results. +// +// The value is stored as a JSON string because the settings collection only +// has a plain text `value` field. Large graphs (150+ topics) can produce +// ~100 KB of JSON, which SQLite handles without issue. + +const SNAPSHOT_KEY = 'graph_snapshot:latest'; + +/** + * Persist the current graph state as a rollback point. + * Called by useGraphData.bulkSave() before overwriting the graph. + * + * @param {object[]} topics — the graph state to preserve + * @param {object[]} relations — the graph state to preserve + */ +export async function saveGraphSnapshot(topics, relations) { + const payload = JSON.stringify({ + ts: Date.now(), + topicCount: topics.length, + relationCount: relations.length, + topics, + relations, + }); + return setSetting(SNAPSHOT_KEY, payload); +} + +/** + * Read the latest snapshot back from PocketBase. + * Returns null when no snapshot exists or the stored value is malformed. + * + * @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null} + */ +export async function getGraphSnapshot() { + const raw = await getSetting(SNAPSHOT_KEY, null); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** + * Delete the stored snapshot (e.g. after a successful restore so the + * admin cannot accidentally restore twice). + */ +export function clearGraphSnapshot() { + return setSetting(SNAPSHOT_KEY, ''); +} + // ── Curriculum Versions (v2) ────────────────────────────────────────────────── export async function getCurriculumVersions(status) {