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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user