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

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;
}