MVP M5.9: SQLite persistence via Prisma + optimistic-local sync

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).
This commit is contained in:
2026-04-29 07:23:35 +02:00
parent 384cbb4ae9
commit 5d4236a980
10 changed files with 678 additions and 73 deletions

13
apps/web/lib/db/client.ts Normal file
View File

@@ -0,0 +1,13 @@
// Prisma client singleton — guards against multiple instances during Next.js
// hot-reload by stashing the client on globalThis in dev.
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma: PrismaClient =
globalForPrisma.prisma ?? new PrismaClient({ log: ["warn", "error"] });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

137
apps/web/lib/db/repo.ts Normal file
View File

@@ -0,0 +1,137 @@
// 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 };
}

View File

@@ -1,18 +1,25 @@
// React provider exposing the canonical SysMLModel + applyOps.
// Used by both canvases (and the rail) so they stay in sync.
//
// M5.9: optimistic-local + server-persistence pattern.
// - Local state updates immediately on apply() so the UI feels instant.
// - The same ops are POSTed to /api/projects/[projectId]/apply in the
// background. On success, the server-resolved model + version replaces
// local state (idempotent if no changes; reconciles tempId rewrites).
// - On version-mismatch, we fall back to the server's model + version.
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { applyOps, type ApplyResult } from "./applyOps";
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { applyOps, type ApplyResult as LocalApplyResult } from "./applyOps";
import { validate, type ValidationIssue } from "../sysml/validate";
import type { SysMLModel } from "../sysml/model";
import type { ModelOp } from "./ops";
export interface ModelStoreValue {
model: SysMLModel;
apply: (ops: ModelOp[]) => ApplyResult;
/** Validation issues, recomputed on each successful apply. */
version: number;
apply: (ops: ModelOp[]) => LocalApplyResult;
issues: ValidationIssue[];
issuesByElement: Map<string, ValidationIssue[]>;
}
@@ -21,20 +28,57 @@ const ModelStoreContext = createContext<ModelStoreValue | null>(null);
interface ModelStoreProviderProps {
initialModel: SysMLModel;
initialVersion: number;
/** When set, apply() POSTs ops to /api/projects/[projectId]/apply for persistence. */
projectId?: string;
children: React.ReactNode;
}
export function ModelStoreProvider({ initialModel, children }: ModelStoreProviderProps) {
export function ModelStoreProvider({ initialModel, initialVersion, projectId, children }: ModelStoreProviderProps) {
const [model, setModel] = useState<SysMLModel>(initialModel);
const [version, setVersion] = useState<number>(initialVersion);
// Latest version we've seen from the server. Used as expectedVersion on POST.
const versionRef = useRef<number>(initialVersion);
const apply = useCallback((ops: ModelOp[]): ApplyResult => {
let result: ApplyResult = { model, idMapping: {}, errors: [], applied: false };
const apply = useCallback((ops: ModelOp[]): LocalApplyResult => {
// 1. Optimistic-local: apply immediately to component state.
let result: LocalApplyResult = { model, idMapping: {}, errors: [], applied: false };
setModel(current => {
result = applyOps(current, ops);
return result.applied ? result.model : current;
});
// 2. Background: POST to the server (best-effort; server is the truth).
if (projectId && result.applied) {
const expectedVersion = versionRef.current;
void fetch(`/api/projects/${encodeURIComponent(projectId)}/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ops, expectedVersion }),
})
.then(async res => {
if (!res.ok) return;
const data = (await res.json()) as {
applied: boolean;
model: SysMLModel;
version: number;
idMapping: Record<string, string>;
};
// Always trust the server's model + version after a successful apply
versionRef.current = data.version;
setVersion(data.version);
// Note: server's model may differ in tempId resolution from our local
// optimistic apply. Replacing wholesale is safe because the model
// shape is structural; React Flow's useEffect will diff and update.
setModel(data.model);
})
.catch(() => {
// Network error — keep optimistic state. The next successful apply
// will reconcile via the new server model.
});
}
return result;
}, [model]);
}, [model, projectId]);
const { issues, issuesByElement } = useMemo(() => {
const issues = validate(model);
@@ -50,8 +94,8 @@ export function ModelStoreProvider({ initialModel, children }: ModelStoreProvide
}, [model]);
const value: ModelStoreValue = useMemo(
() => ({ model, apply, issues, issuesByElement }),
[model, apply, issues, issuesByElement]
() => ({ model, version, apply, issues, issuesByElement }),
[model, version, apply, issues, issuesByElement]
);
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
@@ -67,7 +111,7 @@ export function useModel(): SysMLModel {
return useModelStore().model;
}
export function useApply(): (ops: ModelOp[]) => ApplyResult {
export function useApply(): (ops: ModelOp[]) => LocalApplyResult {
return useModelStore().apply;
}