import { useState, useEffect, useCallback, useRef } from 'react'; import * as db from '../lib/db'; /** * Centralises all knowledge-graph data fetching and mutation. * * Returns stable callback refs so callers can safely list them in * useEffect dependency arrays without causing infinite loops. * * 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([]); const relationsRef = useRef([]); useEffect(() => { topicsRef.current = topics; }, [topics]); useEffect(() => { relationsRef.current = relations; }, [relations]); // ── Load ──────────────────────────────────────────────────────────────────── const reload = useCallback(async () => { const [allTopics, allRelations] = await Promise.all([ db.getTopics(), db.getRelations(), // already normalised to string IDs ]); const topicIds = new Set(allTopics.map(t => t.id)); setTopics(allTopics); setRelations(allRelations.filter(r => topicIds.has(r.source) && topicIds.has(r.target))); }, []); useEffect(() => { reload(); window.addEventListener('respellion:kb-updated', reload); return () => window.removeEventListener('respellion:kb-updated', reload); }, [reload]); // On mount, check whether a snapshot from a previous session exists. useEffect(() => { const snap = db.getGraphSnapshot(); 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. * Returns the saved topic object. */ const updateTopic = useCallback(async (topic) => { await db.upsertTopic(topic); setTopics(prev => prev.map(t => (t.id === topic.id ? topic : t))); return topic; }, []); /** * Delete a topic plus all relations that reference it. * Uses the ref so the callback is stable even as state changes. */ const deleteTopic = useCallback(async (topicId) => { const updatedTopics = topicsRef.current.filter(t => t.id !== topicId); const updatedRelations = relationsRef.current.filter( r => r.source !== topicId && r.target !== topicId, ); await db.saveTopics(updatedTopics); await db.saveRelations(updatedRelations); await db.deleteContent(topicId); setTopics(updatedTopics); setRelations(updatedRelations); }, []); // ── Relation mutations ─────────────────────────────────────────────────────── /** * Add a relation if not already present. * Returns true if added, false if it was a duplicate. */ const addRelation = useCallback(async (relation) => { const isDup = relationsRef.current.some( r => r.source === relation.source && r.target === relation.target && r.type === relation.type, ); if (isDup) return false; await db.addRelation(relation); setRelations(prev => [...prev, relation]); return true; }, []); /** * Apply the same patch to many topics in one call. `ids` is the set of * topic IDs to update; `patch` is the partial-topic to merge into each one * (e.g. `{ type: 'process' }` or `{ learning_relevance: 'core', relevance_locked: true }`). * * Persists each topic via db.upsertTopic in parallel and mirrors into state. * Returns the number of topics actually written (selected ∩ existing). */ const bulkUpdateTopics = useCallback(async (ids, patch) => { const idSet = new Set(ids); const targets = topicsRef.current.filter(t => idSet.has(t.id)); const updated = targets.map(t => ({ ...t, ...patch })); await Promise.all(updated.map(t => db.upsertTopic(t))); setTopics(prev => prev.map(t => (idSet.has(t.id) ? { ...t, ...patch } : t))); return updated.length; }, []); /** Remove a specific (source, target, type) relation triple. */ const removeRelation = useCallback(async (source, target, type) => { await db.removeRelation(source, target, type); setRelations(prev => prev.filter(r => !(r.source === source && r.target === target && r.type === type)), ); }, []); // ── Bulk mutations (used by AI analysis) ──────────────────────────────────── /** * 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. 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 = 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); db.clearGraphSnapshot(); setSnapshotMeta(null); return true; }, []); return { topics, relations, snapshotMeta, reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot, }; }