State now survives a page refresh. Every applied op writes a fresh model
snapshot + a changelog row inside one transaction; the editor route
server-component loads the latest snapshot and seeds it as the initial
ModelStore state. Optimistic-local apply on the client + background POST
to /api/projects/[id]/apply gives the UI an instant feel without giving
up the server-as-truth contract.
apps/web/prisma
- schema.prisma: Project / ModelSnapshot / ChangelogEntry. SQLite for dev
(file:./dev.db). The same schema swaps to Postgres by changing the
provider line + DATABASE_URL.
apps/web/lib/db
- client.ts: PrismaClient singleton with hot-reload guard.
- repo.ts: loadProject() auto-seeds Aristotle from fromFixture();
applyOpsToProject() runs the pure applyOps() server-side inside a
$transaction, writes the snapshot + changelog atomically, returns
{ applied, model, version, idMapping, errors }. Optimistic-concurrency
via expectedVersion → returns the server model on mismatch so the
client can resync without losing its tab.
apps/web/app/api/projects/[projectId]
- route.ts (GET): returns latest { model, version }
- apply/route.ts (POST): body is { ops, expectedVersion?, reason? }
apps/web/lib/sync/ModelStore.tsx
- Now takes initialModel + initialVersion + projectId. apply() updates
local state immediately, then POSTs in the background. On response the
authoritative server model + version replace the optimistic state
(handles tempId resolution from the server). Network errors keep the
optimistic state; the next successful apply reconciles.
apps/web/app/editor/[projectId]/page.tsx
- Server component now: awaits loadProject(projectId), passes initialModel
+ initialVersion + projectId to EditorShell. EditorShell falls back to
the fixture path when those props are absent (legacy callers / tests).
package.json
- pnpm.onlyBuiltDependencies allowlists prisma + @prisma/client + @prisma/engines
- db:push / db:generate / db:reset scripts
.gitignore
- apps/web/prisma/dev.db + dev.db-journal excluded.
Pinned to prisma@6 (prisma@7 dropped url from schema in favor of the
new adapter pattern; not worth the churn for MVP).
138 lines
4.1 KiB
TypeScript
138 lines
4.1 KiB
TypeScript
// 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<LoadResult> {
|
|
// 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<string, string>;
|
|
errors: Array<{ opIndex: number; code: string; message: string }>;
|
|
}
|
|
|
|
export async function applyOpsToProject(
|
|
projectId: string,
|
|
ops: ModelOp[],
|
|
expectedVersion: number | undefined,
|
|
reason?: string
|
|
): Promise<ApplyResult> {
|
|
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),
|
|
},
|
|
});
|
|
return { projectId };
|
|
}
|