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.
useEffect(() => {
db.getGraphSnapshot().then(snap => {
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;
}, []);

View File

@@ -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 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.
*
* @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({
export function saveGraphSnapshot(topics, relations) {
try {
localStorage.setItem(SNAPSHOT_LS_KEY, JSON.stringify({
ts: Date.now(),
topicCount: topics.length,
relationCount: relations.length,
topics,
relations,
});
return setSetting(SNAPSHOT_KEY, payload);
}));
} 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) ──────────────────────────────────────────────────