feat: migrate graph snapshot handling to localStorage for improved persistence
All checks were successful
On Push to Main / test (push) Successful in 39s
On Push to Main / publish (push) Successful in 1m7s
On Push to Main / deploy-dev (push) Successful in 1m36s

This commit is contained in:
RaymondVerhoef
2026-05-27 20:27:16 +02:00
parent 9fb22b8090
commit 47a738fde3
2 changed files with 44 additions and 35 deletions

View File

@@ -52,11 +52,10 @@ export function useGraphData() {
// On mount, check whether a snapshot from a previous session exists. // On mount, check whether a snapshot from a previous session exists.
useEffect(() => { useEffect(() => {
db.getGraphSnapshot().then(snap => { const snap = db.getGraphSnapshot();
if (snap?.ts) { if (snap?.ts) {
setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount }); setSnapshotMeta({ ts: snap.ts, topicCount: snap.topicCount, relationCount: snap.relationCount });
} }
});
}, []); }, []);
// ── Single-topic mutations ─────────────────────────────────────────────────── // ── Single-topic mutations ───────────────────────────────────────────────────
@@ -124,7 +123,7 @@ export function useGraphData() {
*/ */
const bulkSave = useCallback(async (newTopics, newRelations) => { const bulkSave = useCallback(async (newTopics, newRelations) => {
// Persist a rollback point of the state we are about to overwrite. // 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({ setSnapshotMeta({
ts: Date.now(), ts: Date.now(),
topicCount: topicsRef.current.length, topicCount: topicsRef.current.length,
@@ -145,7 +144,7 @@ export function useGraphData() {
* Returns true on success, false if no snapshot exists. * Returns true on success, false if no snapshot exists.
*/ */
const restoreSnapshot = useCallback(async () => { const restoreSnapshot = useCallback(async () => {
const snap = await db.getGraphSnapshot(); const snap = db.getGraphSnapshot();
if (!snap?.topics) return false; if (!snap?.topics) return false;
// Apply without taking a new snapshot — we don't want to overwrite // Apply without taking a new snapshot — we don't want to overwrite
@@ -155,7 +154,7 @@ export function useGraphData() {
setTopics(snap.topics); setTopics(snap.topics);
setRelations(snap.relations); setRelations(snap.relations);
await db.clearGraphSnapshot(); db.clearGraphSnapshot();
setSnapshotMeta(null); setSnapshotMeta(null);
return true; return true;
}, []); }, []);

View File

@@ -293,44 +293,52 @@ export function setSetting(key, value) {
// ── Graph Snapshots ─────────────────────────────────────────────────────────── // ── Graph Snapshots ───────────────────────────────────────────────────────────
// //
// A single "latest" snapshot is persisted in the settings collection before // Stored in localStorage, not in PocketBase.
// 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 // The settings collection's `value` field is a plain text field designed for
// has a plain text `value` field. Large graphs (150+ topics) can produce // small strings (model overrides, flags). A full graph snapshot can easily
// ~100 KB of JSON, which SQLite handles without issue. // reach 40100 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. * Called by useGraphData.bulkSave() before overwriting the graph.
* *
* @param {object[]} topics — the graph state to preserve * Silently no-ops if localStorage is unavailable (private browsing quota
* @param {object[]} relations — the graph state to preserve * exceeded, etc.) — the snapshot is best-effort, not critical-path.
*
* @param {object[]} topics
* @param {object[]} relations
*/ */
export async function saveGraphSnapshot(topics, relations) { export function saveGraphSnapshot(topics, relations) {
const payload = JSON.stringify({ try {
localStorage.setItem(SNAPSHOT_LS_KEY, JSON.stringify({
ts: Date.now(), ts: Date.now(),
topicCount: topics.length, topicCount: topics.length,
relationCount: relations.length, relationCount: relations.length,
topics, topics,
relations, relations,
}); }));
return setSetting(SNAPSHOT_KEY, payload); } catch {
// Storage quota exceeded or unavailable — skip silently.
}
} }
/** /**
* Read the latest snapshot back from PocketBase. * Read the latest snapshot from localStorage.
* Returns null when no snapshot exists or the stored value is malformed. * Returns null when nothing is stored or the value is malformed.
* *
* @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null} * @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null}
*/ */
export async function getGraphSnapshot() { export function getGraphSnapshot() {
const raw = await getSetting(SNAPSHOT_KEY, null);
if (!raw) return null;
try { try {
const raw = localStorage.getItem(SNAPSHOT_LS_KEY);
if (!raw) return null;
return JSON.parse(raw); return JSON.parse(raw);
} catch { } catch {
return null; return null;
@@ -338,11 +346,13 @@ export async function getGraphSnapshot() {
} }
/** /**
* Delete the stored snapshot (e.g. after a successful restore so the * Remove the stored snapshot after a successful restore so the admin cannot
* admin cannot accidentally restore twice). * accidentally restore twice.
*/ */
export function clearGraphSnapshot() { export function clearGraphSnapshot() {
return setSetting(SNAPSHOT_KEY, ''); try {
localStorage.removeItem(SNAPSHOT_LS_KEY);
} catch { /* non-fatal */ }
} }
// ── Curriculum Versions (v2) ────────────────────────────────────────────────── // ── Curriculum Versions (v2) ──────────────────────────────────────────────────