refactor(confidence-engine): add investigation storage provider

This commit is contained in:
2026-08-27 15:24:38 +01:00
parent 22e1d7484b
commit ba956eeb3a
3 changed files with 344 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
// investigation-storage — generic persistence boundary
// Exposes loadInvestigation / saveInvestigation / clearInvestigation
// and delegates internally to the concrete localStorage provider.
export { loadInvestigation, saveInvestigation, clearInvestigation } from "./providers/local-storage.js";
+111
View File
@@ -0,0 +1,111 @@
// ── canonical identifiers ────────────────────────────────────────────
const CANONICAL_KEY = "confidence-engine-investigation";
const LEGACY_KEY = "confidence-engine-session";
const SCHEMA_VERSION = 1;
// ── helpers ──────────────────────────────────────────────────────────
function safeGet(storage, key) {
if (!storage || typeof storage.getItem !== "function") return null;
try { return storage.getItem(key); } catch (_) { return null; }
}
function isPlainObject(value) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
// ── loadInvestigation ────────────────────────────────────────────────
/**
* Returns the persisted investigation snapshot (normalised to current schema)
* or null when no usable state exists.
*/
export function loadInvestigation() {
const storage = _getTargetStorage();
if (!storage) return null;
// 1 canonical key first
let raw = safeGet(storage, CANONICAL_KEY);
if (raw !== null) {
try {
const parsed = JSON.parse(raw);
if (isPlainObject(parsed)) {
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
return parsed;
}
} catch (_) { /* malformed — fall through to legacy */ }
}
// 2 legacy fallback
const legacyStorage = _getLegacyStorage();
if (!legacyStorage) return null;
raw = safeGet(legacyStorage, LEGACY_KEY);
if (raw === null) return null;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (_) { return null; }
if (!isPlainObject(parsed)) return null;
// Normalise schemaVersion for legacy data
if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION;
// Migrate into canonical key
_persist(storage, CANONICAL_KEY, JSON.stringify(parsed));
return parsed;
}
// ── saveInvestigation ────────────────────────────────────────────────
/**
* Persists the supplied snapshot to the canonical localStorage key.
* The caller's object is never mutated.
*/
export function saveInvestigation(snapshot) {
const storage = _getTargetStorage();
if (!storage) return; // silently no-op in non-browser
try {
const record = JSON.parse(JSON.stringify(snapshot));
record.schemaVersion = SCHEMA_VERSION;
_persist(storage, CANONICAL_KEY, JSON.stringify(record));
} catch (_) { /* storage errors must not crash caller */ }
}
// ── clearInvestigation ───────────────────────────────────────────────
/** Removes only the canonical investigation key. */
export function clearInvestigation() {
const storage = _getTargetStorage();
if (!storage) return;
try { storage.removeItem(CANONICAL_KEY); } catch (_) {}
}
// ── internals ────────────────────────────────────────────────────────
function _persist(storage, key, value) {
try { storage.setItem(key, value); } catch (_) {}
}
function _getTargetStorage() {
if (typeof globalThis.window === "undefined") return null;
const s = globalThis.window.localStorage;
if (!s || typeof s.getItem !== "function") return null;
return s;
}
function _getLegacyStorage() {
if (typeof globalThis.window === "undefined") return null;
const s = globalThis.window.sessionStorage;
if (!s || typeof s.getItem !== "function") return null;
return s;
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Focused provider-level contract tests for investigation-storage.
*
* Deterministic in-memory mocks — no React, no Playwright, no LLM calls.
*/
import { describe, it, expect, vi } from "vitest";
// ── test-time storage mock (injected into globalThis.window) ───────────
class MockStorageMap {
constructor() { this._data = new Map(); }
getItem(k) { return this._data.has(k) ? this._data.get(k) : null; }
setItem(k, v){ this._data.set(k, String(v)); }
removeItem(k){ this._data.delete(k); }
clear() { this._data.clear(); }
}
function installMockStorage() {
// Define window with mock localStorage and sessionStorage as properties
Object.defineProperty(globalThis, "window", {
value: globalThis.window || {},
writable: true,
configurable: true,
});
if (!globalThis.window.localStorage) {
Object.defineProperty(globalThis.window, "localStorage", {
value: new MockStorageMap(),
writable: true,
configurable: true,
});
}
if (!globalThis.window.sessionStorage) {
Object.defineProperty(globalThis.window, "sessionStorage", {
value: new MockStorageMap(),
writable: true,
configurable: true,
});
}
}
function uninstallMockStorage() {
const desc = Object.getOwnPropertyDescriptor(globalThis, "window");
if (desc && !("localStorage" in globalThis.window)) return;
delete globalThis.window.localStorage;
delete globalThis.window.sessionStorage;
if (Object.getOwnPropertyNames(globalThis.window).length === 0) {
delete globalThis.window;
}
}
// ── lifecycle ──────────────────────────────────────────────────────────
beforeEach(() => { installMockStorage(); });
afterEach (() => { uninstallMockStorage(); });
// ── import under test (fresh module each time) ────────────────────────
async function getModule() {
// Clear all cached modules to get fresh bindings per test
vi.resetModules();
const m = await import("../../lib/storage/providers/local-storage.js");
return { load: m.loadInvestigation, save: m.saveInvestigation, clear: m.clearInvestigation };
}
// ── shared snapshot factories ──────────────────────────────────────────
function makeSnapshot(overrides = {}) {
return {
schemaVersion: overrides.schemaVersion ?? 1,
situationGraph: { nodes: [{ id: "n1", label: "A" }], edges: [] },
openQuestions: ["q1"],
assumptions: [],
observations: [],
uncertainties: [],
relationships: [],
messages: [],
...overrides,
};
}
function legacySnapshot() {
return { situationGraph: { nodes: [{ id: "old" }], edges: [] }, legacy: true };
}
// ════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════
describe("investigation-storage provider", () => {
// ── 1. save → load round trip ───────────────────────────────────
it("save then load returns the same snapshot (round-trip)", async () => {
const { load, save } = await getModule();
const snap = makeSnapshot({ situationGraph: { nodes: [{ id: "x1" }], edges: [] }, openQuestions: ["why?"] });
save(snap);
const loaded = load();
expect(loaded).not.toBeNull();
expect(loaded.situationGraph.nodes[0].id).toBe("x1");
expect(loaded.openQuestions).toEqual(["why?"]);
});
// ── 2. schemaVersion = 1 persisted ──────────────────────────────
it("persisted record includes schemaVersion = 1", async () => {
const { load, save } = await getModule();
const snap = makeSnapshot({ schemaVersion: undefined });
save(snap);
const loaded = load();
expect(loaded.schemaVersion).toBe(1);
});
// ── 3. caller snapshot object not mutated ───────────────────────
it("save does not mutate the caller's snapshot object", async () => {
const { save } = await getModule();
const snap = { nodes: [{ id: "a" }] };
const before = JSON.stringify(snap);
save(snap);
expect(JSON.stringify(snap)).toBe(before);
});
// ── 4. clear → load returns null ────────────────────────────────
it("clear then load returns null", async () => {
const { load, save, clear } = await getModule();
save(makeSnapshot());
expect(load()).not.toBeNull();
clear();
expect(load()).toBeNull();
});
// ── 5. malformed localStorage → null, no throw ─────────────────
it("malformed localStorage returns null without throwing", async () => {
const { load } = await getModule();
globalThis.window.localStorage.setItem(
"confidence-engine-investigation",
"not valid json {{{"
);
expect(() => load()).not.toThrow();
expect(load()).toBeNull();
});
// ── 6. localStorage takes precedence over sessionStorage ────────
it("localStorage value used even when legacy sessionStorage exists", async () => {
const { load, save } = await getModule();
const localSnap = makeSnapshot({ source: "local" });
const sessionSnap = legacySnapshot(); // no schemaVersion
save(localSnap);
globalThis.window.sessionStorage.setItem(
"confidence-engine-session",
JSON.stringify(sessionSnap)
);
const loaded = load();
expect(loaded).not.toBeNull();
expect(loaded.source).toBe("local"); // from localStorage, not sessionStorage
});
// ── 7. empty localStorage + valid legacy sessionStorage → legacy returned ──
it("empty canonical key returns legacy snapshot when no local state", async () => {
const { load, save } = await getModule();
save(makeSnapshot({ source: "local" })); // populate a value
globalThis.window.localStorage.clear(); // remove it (canonical key gone)
globalThis.window.sessionStorage.setItem(
"confidence-engine-session",
JSON.stringify(legacySnapshot())
);
const loaded = load();
expect(loaded).not.toBeNull();
expect(loaded.legacy).toBe(true); // came from sessionStorage
});
// ── 8. legacy snapshot copied into localStorage ─────────────────
it("legacy migration copies snapshot into canonical key", async () => {
const { load } = await getModule();
globalThis.window.localStorage.clear();
globalThis.window.sessionStorage.setItem(
"confidence-engine-session",
JSON.stringify(legacySnapshot())
);
load(); // triggers migration
const migrated = globalThis.window.localStorage.getItem("confidence-engine-investigation");
expect(migrated).not.toBeNull();
expect(JSON.parse(migrated).legacy).toBe(true);
expect(JSON.parse(migrated).schemaVersion).toBe(1);
});
// ── 9. unrelated keys untouched ─────────────────────────────────
it("unrelated storage keys remain untouched", async () => {
const { load, save } = await getModule();
globalThis.window.localStorage.setItem("unrelated-key", '{"data":42}');
globalThis.window.sessionStorage.setItem("unrelated-session", "hello");
save(makeSnapshot());
load();
expect(globalThis.window.localStorage.getItem("unrelated-key")).toBe('{"data":42}');
expect(globalThis.window.sessionStorage.getItem("unrelated-session")).toBe("hello");
});
// ── 10. non-browser environment safely returns null / no-op ─────
it("non-browser environment returns null load, safe no-op save/clear", async () => {
uninstallMockStorage(); // removes window entirely
const { load, save, clear } = await getModule();
expect(load()).toBeNull(); // no browser → null
expect(() => save(makeSnapshot())).not.toThrow(); // no-op without crash
expect(() => clear()).not.toThrow(); // no-op without crash
installMockStorage(); // restore for remaining tests
});
});