Add in-memory metadata cache to avoid redundant disk I/O

List, prune, and delete operations now use a lightweight in-memory cache
of snapshot metadata (everything except graphData). Only get_full_record()
and update_meta() hit disk after warm-up, keeping sidebar loads and
auto-capture prune cycles fast.

Key changes:
- snapshot_storage.py: cache layer (_cache, _cache_warmed, _extract_meta,
  _ensure_cached), new get_full_record() and update_meta() functions,
  all existing functions updated to use cache
- snapshot_routes.py: new /get and /update-meta endpoints
- snapshot_manager.js: db_getFullRecord() and db_updateMeta() helpers,
  lazy graphData fetch in restore/swap/diff/preview/tooltip, label/notes/
  lock use update_meta instead of full put to preserve graphData on disk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 10:57:41 +01:00
parent ab3bbc7f71
commit 7518821447
3 changed files with 267 additions and 56 deletions

View File

@@ -132,6 +132,38 @@ async function db_getAllWorkflowKeys() {
}
}
async function db_updateMeta(workflowKey, id, fields) {
try {
const resp = await api.fetchApi("/snapshot-manager/update-meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workflowKey, id, fields }),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || resp.statusText);
}
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Update meta failed:`, err);
showToast("Failed to update snapshot", "error");
}
}
async function db_getFullRecord(workflowKey, id) {
try {
const resp = await api.fetchApi("/snapshot-manager/get", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workflowKey, id }),
});
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Get full record failed:`, err);
return null;
}
}
async function pruneSnapshots(workflowKey) {
try {
const resp = await api.fetchApi("/snapshot-manager/prune", {
@@ -1115,7 +1147,13 @@ function showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraphData, ta
// ─── Preview Modal ──────────────────────────────────────────────────
function showPreviewModal(record) {
async function showPreviewModal(record) {
if (!record.graphData) {
const full = await db_getFullRecord(record.workflowKey, record.id);
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
record = full;
}
const overlay = document.createElement("div");
overlay.className = "snap-preview-overlay";
@@ -1281,6 +1319,11 @@ function scheduleCaptureSnapshot() {
// ─── Restore ─────────────────────────────────────────────────────────
async function restoreSnapshot(record) {
if (!record.graphData) {
const full = await db_getFullRecord(record.workflowKey, record.id);
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
record = full;
}
await withRestoreLock(async () => {
if (!validateSnapshotData(record.graphData)) {
showToast("Invalid snapshot data", "error");
@@ -1316,6 +1359,12 @@ async function swapSnapshot(record) {
currentSnapshotId = capturedId || prevCurrentId;
}
if (!record.graphData) {
const full = await db_getFullRecord(record.workflowKey, record.id);
if (!full) { showToast("Failed to load snapshot data", "error"); return; }
record = full;
}
await withRestoreLock(async () => {
if (!validateSnapshotData(record.graphData)) {
showToast("Invalid snapshot data", "error");
@@ -2435,7 +2484,7 @@ async function buildSidebar(el) {
const newLabel = input.value.trim() || originalLabel;
if (newLabel !== originalLabel) {
rec.label = newLabel;
await db_put(rec);
await db_updateMeta(rec.workflowKey, rec.id, { label: newLabel });
await refresh();
} else {
labelDiv.textContent = originalLabel;
@@ -2515,7 +2564,7 @@ async function buildSidebar(el) {
saved = true;
const newNotes = textarea.value.trim();
rec.notes = newNotes || undefined;
await db_put(rec);
await db_updateMeta(rec.workflowKey, rec.id, { notes: newNotes || null });
await refresh();
};
textarea.addEventListener("keydown", (ev) => {
@@ -2533,7 +2582,7 @@ async function buildSidebar(el) {
lockBtn.title = rec.locked ? "Unlock snapshot" : "Lock snapshot";
lockBtn.addEventListener("click", async () => {
rec.locked = !rec.locked;
await db_put(rec);
await db_updateMeta(rec.workflowKey, rec.id, { locked: rec.locked });
await refresh();
});
@@ -2592,26 +2641,31 @@ async function buildSidebar(el) {
refresh();
return;
}
// Normal click
let baseGraph, targetGraph, baseLabel, targetLabel;
if (diffBaseSnapshot && diffBaseSnapshot.id !== rec.id) {
// Two-snapshot compare: base vs this
baseGraph = diffBaseSnapshot.graphData || {};
targetGraph = rec.graphData || {};
baseLabel = diffBaseSnapshot.label;
targetLabel = rec.label;
diffBaseSnapshot = null;
refresh(); // clear highlight
} else {
// Compare this snapshot vs current live workflow
baseGraph = rec.graphData || {};
targetGraph = getGraphData() || {};
baseLabel = rec.label;
targetLabel = "Current Workflow";
}
const diff = computeDetailedDiff(baseGraph, targetGraph);
const allNodes = buildNodeLookup(baseGraph, targetGraph);
showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraph, targetGraph);
// Normal click — async to allow lazy graphData fetch
(async () => {
let baseGraph, targetGraph, baseLabel, targetLabel;
if (diffBaseSnapshot && diffBaseSnapshot.id !== rec.id) {
// Two-snapshot compare: base vs this
const baseFull = diffBaseSnapshot.graphData ? diffBaseSnapshot : await db_getFullRecord(diffBaseSnapshot.workflowKey, diffBaseSnapshot.id);
const targetFull = rec.graphData ? rec : await db_getFullRecord(rec.workflowKey, rec.id);
baseGraph = (baseFull && baseFull.graphData) || {};
targetGraph = (targetFull && targetFull.graphData) || {};
baseLabel = diffBaseSnapshot.label;
targetLabel = rec.label;
diffBaseSnapshot = null;
refresh(); // clear highlight
} else {
// Compare this snapshot vs current live workflow
const full = rec.graphData ? rec : await db_getFullRecord(rec.workflowKey, rec.id);
baseGraph = (full && full.graphData) || {};
targetGraph = getGraphData() || {};
baseLabel = rec.label;
targetLabel = "Current Workflow";
}
const diff = computeDetailedDiff(baseGraph, targetGraph);
const allNodes = buildNodeLookup(baseGraph, targetGraph);
showDiffModal(baseLabel, targetLabel, diff, allNodes, baseGraph, targetGraph);
})();
});
const previewBtn = document.createElement("button");
@@ -2633,8 +2687,16 @@ async function buildSidebar(el) {
// Hover tooltip
item.addEventListener("mouseenter", () => {
tooltipTimer = setTimeout(() => {
const svg = getCachedSVG(rec.id, rec.graphData, { width: 240, height: 180 });
tooltipTimer = setTimeout(async () => {
const svgCacheKey = `${rec.id}:240x180`;
let graphData = rec.graphData;
if (!graphData && !svgCache.has(svgCacheKey)) {
const full = await db_getFullRecord(rec.workflowKey, rec.id);
if (!full || !tooltipTimer) return; // abort if mouse already left
graphData = full.graphData;
}
if (!tooltipTimer) return; // abort if mouse left during fetch
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
if (!svg) return;
tooltip.innerHTML = "";
tooltip.appendChild(svg);