// 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; } const ModelStoreContext = createContext(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(initialModel); const [version, setVersion] = useState(initialVersion); // Latest version we've seen from the server. Used as expectedVersion on POST. const versionRef = useRef(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 (server is authoritative). 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) { const body = await res.text().catch(() => ""); console.error("[ModelStore] /apply HTTP", res.status, body.slice(0, 200)); return; } const data = (await res.json()) as { applied: boolean; model: SysMLModel; version: number; idMapping: Record; errors?: Array<{ opIndex: number; code: string; message: string }>; }; if (!data.applied) { console.warn( "[ModelStore] server rejected ops; reverting to server state.", data.errors, "expected", expectedVersion, "got", data.version ); } versionRef.current = data.version; setVersion(data.version); setModel(data.model); }) .catch(err => { console.error("[ModelStore] /apply network error:", err); }); } return result; }, [model, projectId]); const { issues, issuesByElement } = useMemo(() => { const issues = validate(model); const issuesByElement = new Map(); 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 {children}; } 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; } }