// 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 }; } // ─── 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, })), }; }