diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 2273e7b..9d27672 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -284,6 +284,71 @@ The storage contract accepts/returns domain-level Investigation objects keyed by Storage contract = persistence only. Not synchronization. No sync-specific fields (syncStatus, remoteId, dirtyFlags, lastSyncedAt) belong in the Investigation shape during this increment. Decisions here do not prevent future sync — the Investigation object carries its own durable ID sufficient for identity resolution. +## v0.60c — Identity-Aware Investigation Storage Implementation + +**First production implementation increment of v0.60.** Bounded capability: two independently addressable Investigations can be persisted and loaded through the storage contract. + +### What was implemented + +- `loadInvestigation(id)` — accepts optional durable ID; selects by that identity when provided; returns `null` for unknown IDs +- `saveInvestigation(investigation, id)` — accepts optional durable ID; persists under provider-chosen key derived from `id`; does NOT allocate or replace the supplied ID +- `clearInvestigation(id)` — accepts optional durable ID; removes specific investigation by identity when provided + +### Representation chosen (internal to localStorage provider) + +Each Investigation is stored as a separate top-level localStorage key: +``` +confidence-engine-investigation: +``` +e.g. `confidence-engine-investigation:inv-abc123` + +This was the smallest sufficient representation because: +- Direct key lookup provides O(1) per-investigation access without needing an index +- No generic repository layer required — each key is independently addressable by its durable ID +- The two-Investigation proof requires independent storage and retrieval, which this achieves with zero indexes or aggregation + +Representation is **not exposed** to callers — the contract does not reveal localStorage key structure. + +### Two-Investigation proof results (deterministic test) + +| Invariant | Result | +|---|---| +| saveInvestigation(A) persists A under A.id | ✅ PASS | +| saveInvestigation(B) persists B independently under B.id | ✅ PASS | +| saving B does not overwrite A | ✅ PASS | +| loadInvestigation(A.id) returns A | ✅ PASS | +| loadInvestigation(B.id) returns B | ✅ PASS | +| loadInvestigation(unknownId) returns null | ✅ PASS | +| saveInvestigation() does not invent/change supplied ID | ✅ PASS | + +### Singleton compatibility seam + +- Existing consumers call `loadInvestigation()` and `clearInvestigation()` **without** an id argument — these continue via the legacy singleton path (canonical key + sessionStorage fallback) +- No caller was refactored in this increment +- The compatibility seam is: functions accept optional second parameter; when absent, behaviour matches pre-v0.60c singleton semantics +- `case-1` is **NOT** introduced as canonical identity — it exists only in existing consumer route URLs and legacy data + +### Legacy migration + +- No legacy singleton → new durable ID migration policy was invented +- Existing `case-1` localStorage data continues to be served by the backward-compatible path +- Migration of existing consumers to the new identity-aware calls is deferred to a later increment + +### Production files changed + +| File | Purpose | +|---|---| +| `lib/storage/providers/local-storage.js` | Identity-aware storage contract implementation | +| `tests/storage/investigation-storage.test.js` | 6 new targeted tests for v0.60c invariants | + +### Next restart point + +Migrate existing application callers to the identity-aware contract signatures: +1. `[id]/page.jsx` — pass route `[id]` to `loadInvestigation(id)` +2. `[id]/report/page.jsx` — pass route `[id]` to `loadInvestigation(id)` and `saveInvestigation(..., id)` +3. `scenario-form.jsx` — pass investigation ID through save calls +4. `app/page.jsx` — migrate Portfolio to use `listInvestigations()` (next increment) + ## Next implementation boundary 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. diff --git a/lib/storage/providers/local-storage.js b/lib/storage/providers/local-storage.js index 1054146..e89b76a 100644 --- a/lib/storage/providers/local-storage.js +++ b/lib/storage/providers/local-storage.js @@ -4,6 +4,9 @@ const CANONICAL_KEY = "confidence-engine-investigation"; const LEGACY_KEY = "confidence-engine-session"; const SCHEMA_VERSION = 1; +// Multi-Investigation key prefix (v0.60c) +const INVESTIGATION_PREFIX = "confidence-engine-investigation:"; + // ── helpers ────────────────────────────────────────────────────────── function safeGet(storage, key) { @@ -20,16 +23,34 @@ function isPlainObject(value) { ); } -// ── loadInvestigation ──────────────────────────────────────────────── +// ── loadInvestigation (identity-aware) ─────────────────────────────── /** - * Returns the persisted investigation snapshot (normalised to current schema) - * or null when no usable state exists. + * Returns the persisted investigation snapshot keyed by durable id + * (normalised to current schema), or null when no usable state exists. + * + * When called without an id argument, reads from the legacy singleton key + * for backward-compatible consumers that have not yet migrated. */ -export function loadInvestigation() { +export function loadInvestigation(id) { const storage = _getTargetStorage(); if (!storage) return null; + // — identity-aware contract: select by durable id — + if (id != null) { + const raw = safeGet(storage, `${INVESTIGATION_PREFIX}${id}`); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw); + if (isPlainObject(parsed)) { + if (!("schemaVersion" in parsed)) parsed.schemaVersion = SCHEMA_VERSION; + return parsed; + } + } catch (_) { return null; } + return null; + } + + // — backward-compatible singleton path (no id supplied) — // 1 – canonical key first let raw = safeGet(storage, CANONICAL_KEY); if (raw !== null) { @@ -64,36 +85,45 @@ export function loadInvestigation() { return parsed; } -// ── saveInvestigation ──────────────────────────────────────────────── +// ── saveInvestigation (identity-aware) ─────────────────────────────── /** - * Persists the supplied snapshot to the canonical localStorage key. + * Persists the supplied snapshot under its durable id key. * The caller's object is never mutated. + * + * When called without an id argument, writes to the legacy singleton key + * for backward-compatible consumers that have not yet migrated. */ -export function saveInvestigation(snapshot) { +export function saveInvestigation(snapshot, id) { 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)); + const key = id != null ? `${INVESTIGATION_PREFIX}${id}` : CANONICAL_KEY; + _persist(storage, key, JSON.stringify(record)); } catch (_) { /* storage errors must not crash caller */ } } -// ── clearInvestigation ─────────────────────────────────────────────── +// ── clearInvestigation (identity-aware) ────────────────────────────── -/** Removes the canonical investigation key and the legacy session key. */ -export function clearInvestigation() { +/** + * Removes a specific investigation by durable id. + * When called without an id, clears the legacy singleton keys only. + */ +export function clearInvestigation(id) { const storage = _getTargetStorage(); if (!storage) return; - try { storage.removeItem(CANONICAL_KEY); } catch (_) {} - - // Also remove the legacy sessionStorage key so it cannot resurrect stale - // state after loadInvestigation falls through from missing canonical key. - const legacyStorage = _getLegacyStorage(); - if (!legacyStorage) return; - try { legacyStorage.removeItem(LEGACY_KEY); } catch (_) {} + if (id != null) { + try { storage.removeItem(`${INVESTIGATION_PREFIX}${id}`); } catch (_) {} + } else { + try { storage.removeItem(CANONICAL_KEY); } catch (_) {} + const legacyStorage = _getLegacyStorage(); + if (legacyStorage) { + try { legacyStorage.removeItem(LEGACY_KEY); } catch (_) {} + } + } } // ── internals ──────────────────────────────────────────────────────── diff --git a/tests/storage/investigation-storage.test.js b/tests/storage/investigation-storage.test.js index e35a6a7..c1ff2cf 100644 --- a/tests/storage/investigation-storage.test.js +++ b/tests/storage/investigation-storage.test.js @@ -226,3 +226,110 @@ describe("investigation-storage provider", () => { }); }); + +// ── v0.60c — identity-aware multi-investigation persistence ──────────── + +describe("investigation-storage v0.60c identity-aware", () => { + + function newId() { + return `inv-${Math.random().toString(36).slice(2, 9)}`; + } + + beforeEach(() => { + globalThis.window.localStorage.clear(); + }); + + // ── A: saveInvestigation(A) persists A under A.id ─────────────── + + it("save with id persists under that id key", async () => { + const { load, save } = await getModule(); + const idA = newId(); + const snapA = makeSnapshot({ id: idA, title: "Alpha" }); + + save(snapA, idA); + + const loaded = load(idA); + expect(loaded).not.toBeNull(); + expect(loaded.id).toBe(idA); + expect(loaded.title).toBe("Alpha"); + }); + + // ── B: saveInvestigation(B) persists B independently under B.id ── + + it("save two investigations with different ids are independent", async () => { + const { load, save } = await getModule(); + const idA = newId(); + const idB = newId(); + const snapA = makeSnapshot({ id: idA, title: "Alpha" }); + const snapB = makeSnapshot({ id: idB, title: "Beta" }); + + save(snapA, idA); + save(snapB, idB); + + // A survives B save + const loadedA = load(idA); + expect(loadedA).not.toBeNull(); + expect(loadedA.id).toBe(idA); + expect(loadedA.title).toBe("Alpha"); + }); + + // ── A loads by A.id ────────────────────────────────────────────── + + it("loadInvestigation(A.id) returns A", async () => { + const { load, save } = await getModule(); + const idA = newId(); + const snapA = makeSnapshot({ id: idA, title: "Alpha" }); + save(snapA, idA); + + const loaded = load(idA); + expect(loaded).not.toBeNull(); + expect(loaded.id).toBe(idA); + expect(loaded.title).toBe("Alpha"); + }); + + // ── B loads by B.id ────────────────────────────────────────────── + + it("loadInvestigation(B.id) returns B", async () => { + const { load, save } = await getModule(); + const idA = newId(); + const idB = newId(); + const snapA = makeSnapshot({ id: idA, title: "Alpha" }); + const snapB = makeSnapshot({ id: idB, title: "Beta" }); + + save(snapA, idA); + save(snapB, idB); + + const loaded = load(idB); + expect(loaded).not.toBeNull(); + expect(loaded.id).toBe(idB); + expect(loaded.title).toBe("Beta"); + }); + + // ── unknown ID returns null ────────────────────────────────────── + + it("loadInvestigation(unknownId) returns null", async () => { + const { load } = await getModule(); + const loaded = load("non-existent-id-xyz"); + expect(loaded).toBeNull(); + }); + + // ── save does not invent or change the supplied ID ─────────────── + + it("saveInvestigation does not invent or replace the supplied id", async () => { + const { save } = await getModule(); + const originalId = `inv-unique-77`; + const snapA = makeSnapshot({ id: originalId, title: "Alpha" }); + + save(snapA, originalId); + + // Verify the persisted record still carries exactly the supplied id + // (the deep clone should not have mutated it) + const raw = globalThis.window.localStorage.getItem( + `confidence-engine-investigation:${originalId}` + ); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw); + expect(parsed.id).toBe(originalId); + }); + +});