feat(confidence-engine): use server investigation persistence
This commit is contained in:
@@ -1,43 +1,81 @@
|
||||
// investigation-storage — application-facing persistence boundary
|
||||
// Owns the canonical identity contract: snapshot.id is the sole save identity.
|
||||
// Delegates to the concrete localStorage provider internally.
|
||||
// Delegates to the authenticated browser HTTP provider internally.
|
||||
|
||||
import { loadInvestigation as _load, saveInvestigation as _save, clearInvestigation as _clear, listInvestigations as _list, restartInvestigation as _restart } from "./providers/local-storage.js";
|
||||
import { loadInvestigation as _load, saveInvestigation as _save, listInvestigations as _list, restartInvestigation as _restart } from "./providers/server-http.js";
|
||||
|
||||
const saveStates = new Map();
|
||||
|
||||
function observeUnhandledRejection(promise) {
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function getSaveState(id) {
|
||||
if (!saveStates.has(id)) saveStates.set(id, { inFlight: false, pending: null, idleWaiters: [] });
|
||||
return saveStates.get(id);
|
||||
}
|
||||
|
||||
async function drainSaveState(id, state) {
|
||||
while (state.pending) {
|
||||
const pending = state.pending;
|
||||
state.pending = null;
|
||||
try {
|
||||
const saved = await _save(pending.snapshot, id);
|
||||
pending.waiters.forEach(({ resolve }) => resolve(saved));
|
||||
} catch (error) {
|
||||
pending.waiters.forEach(({ reject }) => reject(error));
|
||||
}
|
||||
}
|
||||
state.inFlight = false;
|
||||
state.idleWaiters.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
|
||||
function waitForSaves(id) {
|
||||
const state = saveStates.get(id);
|
||||
if (!state?.inFlight && !state?.pending) return Promise.resolve();
|
||||
return new Promise((resolve) => state.idleWaiters.push(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical save contract: snapshot.id is the sole identity authority.
|
||||
* When snapshot carries an id — persist under that id key (identity-aware).
|
||||
* When snapshot has no id — fall back to legacy singleton compatibility path.
|
||||
* A durable id is required and is sent to the authenticated server API.
|
||||
*/
|
||||
export function saveInvestigation(snapshot, explicitId) {
|
||||
if (snapshot && typeof snapshot === "object" && snapshot.id != null) {
|
||||
return _save(snapshot, snapshot.id);
|
||||
const id = snapshot?.id ?? explicitId;
|
||||
if (!snapshot || typeof snapshot !== "object" || !id) {
|
||||
return observeUnhandledRejection(Promise.reject(new Error("Investigation snapshot and id are required")));
|
||||
}
|
||||
// Legacy unidentified snapshot — singleton fallback for unmigrated callers
|
||||
return _save(snapshot, explicitId);
|
||||
const state = getSaveState(id);
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
// A pending entry has not started yet, so replacing it safely coalesces intermediate autosaves.
|
||||
if (state.pending) {
|
||||
state.pending.snapshot = snapshot;
|
||||
state.pending.waiters.push({ resolve, reject });
|
||||
} else {
|
||||
state.pending = { snapshot, waiters: [{ resolve, reject }] };
|
||||
}
|
||||
});
|
||||
if (!state.inFlight) {
|
||||
state.inFlight = true;
|
||||
void drainSaveState(id, state);
|
||||
}
|
||||
return observeUnhandledRejection(promise);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical load contract: select by durable id when supplied;
|
||||
* fall back to legacy singleton path otherwise.
|
||||
* Canonical load contract: select by durable id through the authenticated server API.
|
||||
*/
|
||||
export function loadInvestigation(id) {
|
||||
return _load(id != null ? id : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical clear contract: remove by identity-aware key when supplied;
|
||||
* fall back to legacy singleton keys otherwise.
|
||||
*/
|
||||
export function clearInvestigation(id) {
|
||||
return _clear(id ?? undefined);
|
||||
export async function loadInvestigation(id) {
|
||||
if (!id) return null;
|
||||
return _load(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all durable-ID Investigation records as lightweight summaries.
|
||||
* Excludes legacy singleton, sessionStorage state, unrelated storage, malformed entries.
|
||||
* Server persistence is the authority; legacy browser storage is not consulted.
|
||||
*/
|
||||
export function listInvestigations() {
|
||||
export async function listInvestigations() {
|
||||
return _list();
|
||||
}
|
||||
|
||||
@@ -49,6 +87,8 @@ export function listInvestigations() {
|
||||
*
|
||||
* Missing/invalid id → silently no-op (does NOT fall back to legacy singleton).
|
||||
*/
|
||||
export function restartInvestigation(id) {
|
||||
export async function restartInvestigation(id) {
|
||||
if (!id) return null;
|
||||
await waitForSaves(id);
|
||||
return _restart(id);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ const CANONICAL_KEY = "confidence-engine-investigation";
|
||||
const LEGACY_KEY = "confidence-engine-session";
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
import { restartSnapshot } from "../restart-investigation.js";
|
||||
|
||||
// Multi-Investigation key prefix (v0.60c)
|
||||
const INVESTIGATION_PREFIX = "confidence-engine-investigation:";
|
||||
|
||||
@@ -196,17 +198,7 @@ export function restartInvestigation(id) {
|
||||
const record = JSON.parse(raw);
|
||||
if (!isPlainObject(record)) return;
|
||||
|
||||
// Preserve container fields, reset reasoning-state fields
|
||||
record.situationGraph = null;
|
||||
record.selectedQuestion = null;
|
||||
record.summary = null;
|
||||
record.focusedContributions = [];
|
||||
record.findings = [];
|
||||
record.investigationReport = null;
|
||||
record.investigationRevision = 0;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
|
||||
_persist(storage, key, JSON.stringify(record));
|
||||
_persist(storage, key, JSON.stringify(restartSnapshot(record)));
|
||||
} catch (_) { /* storage errors must not crash caller */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
async function request(path, options) {
|
||||
const response = await fetch(path, options);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "Investigation persistence request failed");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function saveInvestigation(snapshot, id) {
|
||||
const body = await request("/api/investigations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, snapshot }),
|
||||
});
|
||||
return body.snapshot ?? null;
|
||||
}
|
||||
|
||||
export async function loadInvestigation(id) {
|
||||
const url = `/api/investigations/${encodeURIComponent(id)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || "Investigation persistence request failed");
|
||||
}
|
||||
const body = await response.json();
|
||||
return body.snapshot ?? null;
|
||||
}
|
||||
|
||||
export async function listInvestigations() {
|
||||
const body = await request("/api/investigations");
|
||||
return body.investigations ?? [];
|
||||
}
|
||||
|
||||
export async function restartInvestigation(id) {
|
||||
const body = await request(`/api/investigations/${encodeURIComponent(id)}/restart`, {
|
||||
method: "POST",
|
||||
});
|
||||
return body.snapshot ?? null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function restartSnapshot(snapshot, now = new Date().toISOString()) {
|
||||
return {
|
||||
...snapshot,
|
||||
situationGraph: null,
|
||||
selectedQuestion: null,
|
||||
summary: null,
|
||||
focusedContributions: [],
|
||||
findings: [],
|
||||
investigationReport: null,
|
||||
investigationRevision: 0,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createServerSupabaseClient,
|
||||
getAuthenticatedUser,
|
||||
} from "@/lib/supabase/server.js";
|
||||
import { restartSnapshot } from "./restart-investigation.js";
|
||||
|
||||
const SCHEMA = "confidence_engine";
|
||||
const TABLE = "investigations";
|
||||
@@ -50,17 +51,26 @@ export async function loadInvestigation(id) {
|
||||
|
||||
export async function listInvestigations() {
|
||||
const context = await getAuthenticatedPersistenceContext();
|
||||
if (!context) return null;
|
||||
if (!context) return [];
|
||||
|
||||
const { data, error } = await context.supabase
|
||||
.schema(SCHEMA)
|
||||
.from(TABLE)
|
||||
.select("id, created_at, updated_at")
|
||||
.select("id, snapshot, created_at, updated_at")
|
||||
.order("updated_at", { ascending: false });
|
||||
throwIfDatabaseError(error);
|
||||
return (data ?? []).map((record) => ({
|
||||
id: record.id,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
scenario: record.snapshot?.scenario ?? null,
|
||||
updatedAt: record.snapshot?.updatedAt ?? record.updated_at,
|
||||
investigationRevision: record.snapshot?.investigationRevision ?? 0,
|
||||
reportExists: !!record.snapshot?.investigationReport,
|
||||
reportGeneratedFromRevision: record.snapshot?.investigationReport?.generatedFromRevision ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function restartInvestigation(id) {
|
||||
const snapshot = await loadInvestigation(id);
|
||||
if (!snapshot) return null;
|
||||
return saveInvestigation(restartSnapshot(snapshot), id);
|
||||
}
|
||||
Reference in New Issue
Block a user