// Project + model persistence layer. // // Stores the full SysMLModel as a JSON snapshot per version, plus a changelog // of applied ops. Reads return the latest snapshot; writes apply ops // server-side and write a new snapshot + changelog row atomically. import { prisma } from "./client"; import { applyOps } from "../sync/applyOps"; import { fromFixture } from "../sysml/fromFixture"; import { aristotleFixture } from "../fixtures/aristotle"; import type { SysMLModel } from "../sysml/model"; import type { ModelOp } from "../sync/ops"; export interface LoadResult { model: SysMLModel; version: number; } export async function loadProject(projectId: string): Promise { // Auto-seed: if the project doesn't exist (or has no snapshot), bootstrap // it from the Aristotle fixture so the editor route always has data. const seeded = await ensureSeeded(projectId); const snapshot = await prisma.modelSnapshot.findFirst({ where: { projectId: seeded.projectId }, orderBy: { version: "desc" }, }); if (!snapshot) { throw new Error(`Project ${projectId} has no snapshots after seed — should not happen`); } return { model: JSON.parse(snapshot.json) as SysMLModel, version: snapshot.version, }; } export interface ApplyResult { applied: boolean; model: SysMLModel; version: number; idMapping: Record; errors: Array<{ opIndex: number; code: string; message: string }>; } export async function applyOpsToProject( projectId: string, ops: ModelOp[], expectedVersion: number | undefined, reason?: string ): Promise { return prisma.$transaction(async tx => { const latest = await tx.modelSnapshot.findFirst({ where: { projectId }, orderBy: { version: "desc" }, }); if (!latest) throw new Error(`No snapshot for project ${projectId}`); if (expectedVersion !== undefined && expectedVersion !== latest.version) { // Optimistic-concurrency miss; caller must resync. return { applied: false, model: JSON.parse(latest.json) as SysMLModel, version: latest.version, idMapping: {}, errors: [{ opIndex: -1, code: "VERSION_MISMATCH", message: `Expected version ${expectedVersion}, server is ${latest.version}` }], }; } const currentModel = JSON.parse(latest.json) as SysMLModel; const result = applyOps(currentModel, ops); if (!result.applied) { return { applied: false, model: currentModel, version: latest.version, idMapping: {}, errors: result.errors, }; } const newVersion = latest.version + 1; await tx.modelSnapshot.create({ data: { projectId, version: newVersion, json: JSON.stringify(result.model), }, }); await tx.changelogEntry.create({ data: { projectId, version: newVersion, ops: JSON.stringify(ops), reason, }, }); return { applied: true, model: result.model, version: newVersion, idMapping: result.idMapping, errors: [], }; }); } // ─── Seeding ───────────────────────────────────────────────────────────── async function ensureSeeded(projectId: string): Promise<{ projectId: string }> { const existing = await prisma.project.findUnique({ where: { id: projectId } }); if (existing) { // Make sure there's at least one snapshot const snapCount = await prisma.modelSnapshot.count({ where: { projectId } }); if (snapCount > 0) return { projectId }; } // Bootstrap with the Aristotle fixture const seedModel = fromFixture(aristotleFixture); await prisma.project.upsert({ where: { id: projectId }, update: {}, create: { id: projectId, name: aristotleFixture.project.name, scope: aristotleFixture.project.scope, tagline: aristotleFixture.project.tagline, }, }); await prisma.modelSnapshot.create({ data: { projectId, version: 1, json: JSON.stringify(seedModel), }, }); // Auto-create an opening Socrates thread so the dock has somewhere to talk. // Socrates' opening turn is generated lazily on first GET (so we don't // pin a model selection to seed time). await prisma.socratesThread.create({ data: { projectId, title: "Active thread · " + aristotleFixture.project.name, status: "open", }, }); return { projectId }; } // ─── Findings (M8) ─────────────────────────────────────────────────────── import type { Finding as DetectedFinding } from "../llm/detect"; export interface StoredFinding { id: string; kind: string; text: string; linkedElementIds: string[]; confidence: number; severity: string | null; validationCode: string | null; status: string; modelVersion: number; provider: string | null; llmModel: string | null; createdAt: Date; } export async function listOpenFindings(projectId: string): Promise { const rows = await prisma.finding.findMany({ where: { projectId, status: "open" }, orderBy: [{ kind: "asc" }, { createdAt: "desc" }], }); return rows.map(rowToFinding); } export async function replaceFindings( projectId: string, findings: DetectedFinding[], modelVersion: number, provider: string, llmModel: string ): Promise { return prisma.$transaction(async tx => { // Wipe previous open findings — re-running detection supersedes them. await tx.finding.deleteMany({ where: { projectId, status: "open" } }); if (findings.length === 0) return []; const created = await Promise.all( findings.map(f => tx.finding.create({ data: { projectId, kind: f.kind, text: f.text, linkedElementIds: JSON.stringify(f.linkedElementIds), confidence: f.confidence, severity: f.severity ?? null, validationCode: f.validationCode ?? null, status: "open", modelVersion, provider, llmModel, }, }) ) ); return created.map(rowToFinding); }); } export async function dismissFinding(findingId: string): Promise { await prisma.finding.update({ where: { id: findingId }, data: { status: "dismissed" }, }); } interface FindingRow { id: string; kind: string; text: string; linkedElementIds: string; confidence: number; severity: string | null; validationCode: string | null; status: string; modelVersion: number; provider: string | null; llmModel: string | null; createdAt: Date; } function rowToFinding(row: FindingRow): StoredFinding { let linked: string[] = []; try { const parsed = JSON.parse(row.linkedElementIds); if (Array.isArray(parsed)) linked = parsed.filter((x): x is string => typeof x === "string"); } catch { linked = []; } return { id: row.id, kind: row.kind, text: row.text, linkedElementIds: linked, confidence: row.confidence, severity: row.severity, validationCode: row.validationCode, status: row.status, modelVersion: row.modelVersion, provider: row.provider, llmModel: row.llmModel, createdAt: row.createdAt, }; } // ─── Socrates threads ──────────────────────────────────────────────────── export async function getActiveThread(projectId: string): Promise<{ id: string; title: string | null; messages: Array<{ id: string; role: string; content: string; createdAt: Date }> } | null> { const thread = await prisma.socratesThread.findFirst({ where: { projectId, status: "open" }, orderBy: { updatedAt: "desc" }, include: { messages: { orderBy: { createdAt: "asc" } } }, }); if (!thread) return null; return { id: thread.id, title: thread.title, messages: thread.messages.map(m => ({ id: m.id, role: m.role, content: m.content, createdAt: m.createdAt, })), }; }