feat(confidence-engine): v0.60g1 list investigation summaries

Recover to clean v0.60f then implement only the storage listing contract.

- Add listInvestigations() to provider: enumerate by prefix, project lightweight summary (id, scenario, updatedAt, investigationRevision, reportExists, reportGeneratedFromRevision), sort by updatedAt desc
- Add application-facing wrapper in investigation-storage.js
- Add 8 deterministic tests covering all listing invariants (coexistence, correct IDs, lightweight projection, legacy exclusion, unrelated exclusion, independent update, ordering, malformed skip)
- Fix MockStorageMap WebStorage API compatibility (.length + .key(i))
- Portfolio NOT migrated — that is v0.60g2
This commit is contained in:
2026-09-04 06:45:26 +01:00
parent 4b55ad1eae
commit 7ba1771bcf
4 changed files with 298 additions and 21 deletions
+52 -1
View File
@@ -461,7 +461,58 @@ Untouched in this increment. All `clearInvestigation()` calls remain without an
Next increment: migrate Portfolio card links ("Continue investigation", "View report") to use the first existing Investigation's durable ID — or migrate Portfolio to `listInvestigations()` with the four-operation storage contract. Do not proceed until both are addressed.
## Next implementation boundary
## v0.60g1 — List Investigation Summaries
**Purpose:** The previous v0.60g stalled attempt was discarded. This increment cleanly implements only the `listInvestigations()` storage contract: durable-ID listing returning lightweight summaries, excluding legacy/unrelated state.
### What was implemented
| File | Change |
|---|---|
| `lib/storage/providers/local-storage.js` | Added `listInvestigations()` — enumerates provider records by prefix match, parses each record, projects lightweight summary (explicitly excludes situationGraph, findings, investigationReport), sorts by updatedAt descending |
| `lib/storage/investigation-storage.js` | Imported and re-exported `listInvestigations` as the application-facing public API |
| `tests/storage/investigation-storage.test.js` | 8 new deterministic tests covering all listing invariants; fixed MockStorageMap to implement `.length` and `.key(i)` for WebStorage API compatibility |
### Listing contract details
- **Operation:** `listInvestigations()` — no arguments, returns `InvestigationSummary[]`
- **Summary fields (explicitly projected):** `id`, `scenario`, `updatedAt`, `investigationRevision`, `reportExists`, `reportGeneratedFromRevision`
- **Excluded fields:** situationGraph, findings, full investigationReport, reasoning history, Open Questions, graph nodes
- **Records included:** only keys matching `INVESTIGATION_PREFIX` (durable-ID entries)
- **Records excluded:** legacy singleton (`confidence-engine-investigation`), sessionStorage state, unrelated keys, malformed entries
- **Ordering:** updatedAt descending (most recent first); deterministic fallback by id for equal timestamps
- **Malformed handling:** skip silently — one malformed entry never blocks valid records
### Deterministic test evidence (8 tests, all pass on first run)
| Invariant | Result |
|---|---|
| Two durable-ID Investigations coexist → 2 summaries | ✅ PASS |
| Each summary carries correct durable ID | ✅ PASS |
| Lightweight projection: portfolio fields present, payload fields absent | ✅ PASS |
| Legacy singleton excluded from listing | ✅ PASS |
| Unrelated localStorage key excluded | ✅ PASS |
| Independent update preserves other Investigation | ✅ PASS |
| Deterministic ordering by updatedAt descending | ✅ PASS |
| Malformed durable-ID entry skipped, valid records still listed | ✅ PASS |
### Portfolio scope bounded
- Portfolio has **NOT** been migrated to consume `listInvestigations()` — that is v0.60g2
- `app/page.jsx` unchanged from v0.60f
- No rendering or interactive behaviour changes
### Production files changed
| File | Purpose |
|---|---|
| `lib/storage/providers/local-storage.js` | Provider implements record enumeration + lightweight projection |
| `lib/storage/investigation-storage.js` | Application-facing wrapper re-exports listing operation |
| `tests/storage/investigation-storage.test.js` | 8 new listing contract tests; MockStorageMap WebStorage API fix |
### Next restart point
v0.60g2: Migrate Portfolio to consume `listInvestigations()` for collection rendering — replace the hardcoded singleton card with a rendered list of Investigation summaries.
Smallest next increment: implement the four-operation storage contract in `lib/storage/investigation-storage.js` as a re-export of a provider-backed interface whose signatures accept/return domain Investigation objects keyed by durable ID — without committing to any specific localStorage or database representation. This means defining the exported function signatures and the Investigation shape that flows through them, while deferring key scheme, row schema, and collection structure to a later implementation decision.
+9 -1
View File
@@ -2,7 +2,7 @@
// Owns the canonical identity contract: snapshot.id is the sole save identity.
// Delegates to the concrete localStorage provider internally.
import { loadInvestigation as _load, saveInvestigation as _save, clearInvestigation as _clear } from "./providers/local-storage.js";
import { loadInvestigation as _load, saveInvestigation as _save, clearInvestigation as _clear, listInvestigations as _list } from "./providers/local-storage.js";
/**
* Canonical save contract: snapshot.id is the sole identity authority.
@@ -32,3 +32,11 @@ export function loadInvestigation(id) {
export function clearInvestigation(id) {
return _clear(id ?? undefined);
}
/**
* Lists all durable-ID Investigation records as lightweight summaries.
* Excludes legacy singleton, sessionStorage state, unrelated storage, malformed entries.
*/
export function listInvestigations() {
return _list();
}
+49
View File
@@ -126,6 +126,55 @@ export function clearInvestigation(id) {
}
}
// ── listInvestigations (durable-ID only) ───────────────────────
/**
* Enumerates all durable-ID Investigation records and returns lightweight summaries.
* Skips malformed entries; excludes legacy singleton, sessionStorage state, unrelated keys.
*/
export function listInvestigations() {
const storage = _getTargetStorage();
if (!storage) return [];
const summaries = [];
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (!key || !key.startsWith(INVESTIGATION_PREFIX)) continue;
try {
const raw = safeGet(storage, key);
if (!raw) continue;
const record = JSON.parse(raw);
if (!isPlainObject(record)) continue;
if (!record.id) continue;
summaries.push({
id: record.id,
scenario: record.scenario ?? null,
updatedAt: record.updatedAt ?? null,
investigationRevision: record.investigationRevision ?? 0,
reportExists: !!record.investigationReport,
reportGeneratedFromRevision: record.investigationReport
? record.investigationReport.generatedFromRevision ?? null
: null,
});
} catch (_) {
// Malformed durable-ID entry — skip silently
}
}
summaries.sort((a, b) => {
const ta = a.updatedAt ?? "";
const tb = b.updatedAt ?? "";
if (ta > tb) return -1;
if (ta < tb) return 1;
return a.id > b.id ? -1 : 1;
});
return summaries;
}
// ── internals ────────────────────────────────────────────────────────
function _persist(storage, key, value) {
+188 -19
View File
@@ -9,11 +9,16 @@ 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(); }
constructor() {
this._data = new Map();
this._keys = []; // ordered key list for WebStorage API compatibility
}
get length() { return this._data.size; }
getItem(k) { return this._data.has(k) ? this._data.get(k) : null; }
setItem(k, v) { this._data.set(k, String(v)); if (!this._keys.includes(k)) this._keys.push(k); }
removeItem(k) { this._data.delete(k); const idx = this._keys.indexOf(k); if (idx !== -1) this._keys.splice(idx, 1); }
clear() { this._data.clear(); this._keys.length = 0; }
key(n) { return this._keys[n] ?? null; }
}
function installMockStorage() {
@@ -23,20 +28,18 @@ function installMockStorage() {
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,
});
}
// Always force-install mock storage (previous uninstall may have left window
// without own properties but with inherited ones in some environments)
Object.defineProperty(globalThis.window, "localStorage", {
value: new MockStorageMap(),
writable: true,
configurable: true,
});
Object.defineProperty(globalThis.window, "sessionStorage", {
value: new MockStorageMap(),
writable: true,
configurable: true,
});
}
function uninstallMockStorage() {
@@ -431,3 +434,169 @@ describe("investigation-storage v0.60d canonical identity", () => {
});
});
// ── v0.60g1 — listInvestigations lightweight summaries ───────────────
describe("investigation-storage v0.60g1 listing", () => {
function newId() {
return `inv-${Math.random().toString(36).slice(2, 9)}`;
}
async function getWrapper() {
vi.resetModules();
const m = await import("../../lib/storage/investigation-storage.js");
return {
list: m.listInvestigations,
save: m.saveInvestigation,
load: m.loadInvestigation,
};
}
beforeEach(() => {
if (globalThis.window && globalThis.window.localStorage) {
try { globalThis.window.localStorage.clear(); } catch(e) {}
}
});
// ── 1. Two durable-ID Investigations coexist ────────────────────
it("listInvestigations returns summaries for two durable-ID Investigations", async () => {
const { list, save } = await getWrapper();
save(makeSnapshot({ id: newId(), scenario: "Scenario A" }));
save(makeSnapshot({ id: newId(), scenario: "Scenario B" }));
const summaries = list();
expect(summaries.length).toBe(2);
});
// ── 2. Correct identities ───────────────────────────────────────
it("each summary carries its correct durable ID", async () => {
const { list, save } = await getWrapper();
const idA = newId();
const idB = newId();
save(makeSnapshot({ id: idA, scenario: "Scenario A" }));
save(makeSnapshot({ id: idB, scenario: "Scenario B" }));
const summaries = list();
expect(summaries.map(s => s.id)).toContain(idA);
expect(summaries.map(s => s.id)).toContain(idB);
});
// ── 3. Lightweight projection ───────────────────────────────────
it("summary contains portfolio fields but not canonical payload", async () => {
const { list, save } = await getWrapper();
save(makeSnapshot({ id: newId(), scenario: "Scenario A" }));
const summaries = list();
const summary = summaries[0];
// Portfolio-required fields present
expect(summary).toHaveProperty("id");
expect(summary).toHaveProperty("scenario");
expect(summary).toHaveProperty("updatedAt");
expect(summary).toHaveProperty("investigationRevision");
expect(summary).toHaveProperty("reportExists");
expect(summary).toHaveProperty("reportGeneratedFromRevision");
// Canonical payload fields NOT exposed
expect(summary.situationGraph).toBeUndefined();
expect(summary.findings).toBeUndefined();
expect(summary.investigationReport).toBeUndefined();
});
// ── 4. Legacy singleton exclusion ───────────────────────────────
it("legacy singleton is not included in listing", async () => {
const { list } = await getWrapper();
globalThis.window.localStorage.setItem(
"confidence-engine-investigation",
JSON.stringify(makeSnapshot({ legacy: true }))
);
const summaries = list();
expect(summaries.length).toBe(0);
});
// ── 5. Unrelated storage exclusion ──────────────────────────────
it("unrelated localStorage record is not included", async () => {
const { list, save } = await getWrapper();
globalThis.window.localStorage.setItem(
"confidence-engine-investigation-metadata",
'{"data":42}'
);
save(makeSnapshot({ id: newId(), scenario: "Scenario X" }));
const summaries = list();
expect(summaries.length).toBe(1);
});
// ── 6. Independent update preserves other Investigation ─────────
it("updating inv-a does not replace or remove inv-b", async () => {
const { list, save } = await getWrapper();
const idA = newId();
const idB = newId();
save(makeSnapshot({ id: idA, scenario: "Scenario A" }));
save(makeSnapshot({ id: idB, scenario: "Scenario B" }));
// Update inv-a
save(makeSnapshot({ id: idA, scenario: "Updated Scenario A" }));
const summaries = list();
expect(summaries.length).toBe(2);
const aSummary = summaries.find(s => s.id === idA);
const bSummary = summaries.find(s => s.id === idB);
expect(aSummary.scenario).toBe("Updated Scenario A");
expect(bSummary.scenario).toBe("Scenario B");
});
// ── 7. Deterministic ordering by updatedAt descending ───────────
it("most recently updated Investigation listed first", async () => {
const { list, save } = await getWrapper();
const idA = newId();
const idB = newId();
save(makeSnapshot({
id: idA,
scenario: "Scenario A",
updatedAt: "2026-09-01T10:00:00.000Z",
}));
save(makeSnapshot({
id: idB,
scenario: "Scenario B",
updatedAt: "2026-09-02T10:00:00.000Z",
}));
const summaries = list();
expect(summaries[0].id).toBe(idB);
expect(summaries[1].id).toBe(idA);
});
// ── 8. Malformed durable-ID entry skipped ──────────────────────
it("malformed durable-ID entry does not prevent listing valid records", async () => {
globalThis.window.localStorage.setItem(
"confidence-engine-investigation:inv-malformed",
"not valid json {{{"
);
const { list, save } = await getWrapper();
save(makeSnapshot({ id: newId(), scenario: "Valid Scenario" }));
const summaries = list();
expect(summaries.length).toBe(1);
});
});