feat: migrate graph snapshot handling to localStorage for improved persistence
This commit is contained in:
@@ -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;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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 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.
|
* 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) ──────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user