From 47a738fde30488c6dccad5e9a22ffa400e6a7f56 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Wed, 27 May 2026 20:27:16 +0200 Subject: [PATCH] feat: migrate graph snapshot handling to localStorage for improved persistence --- src/hooks/useGraphData.js | 15 +++++---- src/lib/db.js | 64 ++++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/hooks/useGraphData.js b/src/hooks/useGraphData.js index 4b43ced..56bac46 100644 --- a/src/hooks/useGraphData.js +++ b/src/hooks/useGraphData.js @@ -52,11 +52,10 @@ export function useGraphData() { // 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 }); - } - }); + const snap = db.getGraphSnapshot(); + if (snap?.ts) { + setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount }); + } }, []); // ── Single-topic mutations ─────────────────────────────────────────────────── @@ -124,7 +123,7 @@ export function useGraphData() { */ 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); + db.saveGraphSnapshot(topicsRef.current, relationsRef.current); setSnapshotMeta({ ts: Date.now(), topicCount: topicsRef.current.length, @@ -145,7 +144,7 @@ export function useGraphData() { * Returns true on success, false if no snapshot exists. */ const restoreSnapshot = useCallback(async () => { - const snap = await db.getGraphSnapshot(); + const snap = db.getGraphSnapshot(); if (!snap?.topics) return false; // Apply without taking a new snapshot — we don't want to overwrite @@ -155,7 +154,7 @@ export function useGraphData() { setTopics(snap.topics); setRelations(snap.relations); - await db.clearGraphSnapshot(); + db.clearGraphSnapshot(); setSnapshotMeta(null); return true; }, []); diff --git a/src/lib/db.js b/src/lib/db.js index e0ee451..0ef7ce6 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -293,44 +293,52 @@ export function setSetting(key, 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. +// Stored in localStorage, not in PocketBase. // -// 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. +// The settings collection's `value` field is a plain text field designed for +// small strings (model overrides, flags). A full graph snapshot can easily +// reach 40–100 KB of JSON, which causes a 400 Bad Request from PocketBase. +// +// localStorage is the right choice here: the admin who clicks "Analyze" and +// the admin who clicks "Restore" are always the same person, on the same +// machine, in the same browser. Cross-device persistence is not required. -const SNAPSHOT_KEY = 'graph_snapshot:latest'; +const SNAPSHOT_LS_KEY = 'respellion:graph_snapshot'; /** - * Persist the current graph state as a rollback point. + * Persist the current graph state as a rollback point in localStorage. * Called by useGraphData.bulkSave() before overwriting the graph. * - * @param {object[]} topics — the graph state to preserve - * @param {object[]} relations — the graph state to preserve + * Silently no-ops if localStorage is unavailable (private browsing quota + * exceeded, etc.) — the snapshot is best-effort, not critical-path. + * + * @param {object[]} topics + * @param {object[]} relations */ -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); +export function saveGraphSnapshot(topics, relations) { + try { + localStorage.setItem(SNAPSHOT_LS_KEY, JSON.stringify({ + ts: Date.now(), + topicCount: topics.length, + relationCount: relations.length, + topics, + relations, + })); + } catch { + // Storage quota exceeded or unavailable — skip silently. + } } /** - * Read the latest snapshot back from PocketBase. - * Returns null when no snapshot exists or the stored value is malformed. + * Read the latest snapshot from localStorage. + * Returns null when nothing is stored or the 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; +export function getGraphSnapshot() { try { + const raw = localStorage.getItem(SNAPSHOT_LS_KEY); + if (!raw) return null; return JSON.parse(raw); } catch { return null; @@ -338,11 +346,13 @@ export async function getGraphSnapshot() { } /** - * Delete the stored snapshot (e.g. after a successful restore so the - * admin cannot accidentally restore twice). + * Remove the stored snapshot after a successful restore so the admin cannot + * accidentally restore twice. */ export function clearGraphSnapshot() { - return setSetting(SNAPSHOT_KEY, ''); + try { + localStorage.removeItem(SNAPSHOT_LS_KEY); + } catch { /* non-fatal */ } } // ── Curriculum Versions (v2) ──────────────────────────────────────────────────