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).
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
// 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, 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;
|
|
version: number;
|
|
apply: (ops: ModelOp[]) => LocalApplyResult;
|
|
issues: ValidationIssue[];
|
|
issuesByElement: Map<string, ValidationIssue[]>;
|
|
}
|
|
|
|
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, 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[]): 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, projectId]);
|
|
|
|
const { issues, issuesByElement } = useMemo(() => {
|
|
const issues = validate(model);
|
|
const issuesByElement = new Map<string, ValidationIssue[]>();
|
|
for (const i of issues) {
|
|
const k = anchorKey(i.anchor);
|
|
if (!k) continue;
|
|
const prev = issuesByElement.get(k) ?? [];
|
|
prev.push(i);
|
|
issuesByElement.set(k, prev);
|
|
}
|
|
return { issues, issuesByElement };
|
|
}, [model]);
|
|
|
|
const value: ModelStoreValue = useMemo(
|
|
() => ({ model, version, apply, issues, issuesByElement }),
|
|
[model, version, apply, issues, issuesByElement]
|
|
);
|
|
|
|
return <ModelStoreContext.Provider value={value}>{children}</ModelStoreContext.Provider>;
|
|
}
|
|
|
|
export function useModelStore(): ModelStoreValue {
|
|
const ctx = useContext(ModelStoreContext);
|
|
if (!ctx) throw new Error("useModelStore must be used inside ModelStoreProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function useModel(): SysMLModel {
|
|
return useModelStore().model;
|
|
}
|
|
|
|
export function useApply(): (ops: ModelOp[]) => LocalApplyResult {
|
|
return useModelStore().apply;
|
|
}
|
|
|
|
function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
|
|
switch (anchor.kind) {
|
|
case "block": return anchor.id;
|
|
case "association": return `assoc:${anchor.id}`;
|
|
case "constraint": return `constraint:${anchor.id}`;
|
|
case "requirement": return `req:${anchor.id}`;
|
|
case "property": return anchor.blockId;
|
|
case "model": return null;
|
|
}
|
|
}
|