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:
@@ -22,6 +22,12 @@ import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
|
||||
|
||||
interface EditorShellProps {
|
||||
data: FixtureData;
|
||||
/** Server-loaded initial model + version (M5.9). When omitted, falls back to
|
||||
* deriving from `data` (legacy fixture path; useful for tests). */
|
||||
initialModel?: import("../../lib/sysml/model").SysMLModel;
|
||||
initialVersion?: number;
|
||||
/** When set, apply() POSTs to /api/projects/[projectId]/apply. */
|
||||
projectId?: string;
|
||||
density?: Density;
|
||||
markupStyle?: MarkupStyle;
|
||||
diagramStyle?: DiagramVariant;
|
||||
@@ -38,6 +44,9 @@ export function EditorShell(props: EditorShellProps) {
|
||||
|
||||
function EditorShellInner({
|
||||
data,
|
||||
initialModel,
|
||||
initialVersion,
|
||||
projectId,
|
||||
density = "comfortable",
|
||||
markupStyle = "color",
|
||||
diagramStyle = "softened",
|
||||
@@ -51,13 +60,19 @@ function EditorShellInner({
|
||||
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
|
||||
}, [searchParams]);
|
||||
|
||||
const initialModel = useMemo(() => {
|
||||
const base = fromFixture(data);
|
||||
// Prefer server-loaded model; fall back to fixture derivation for legacy
|
||||
// callers without DB persistence wiring.
|
||||
const startingModel = useMemo(() => {
|
||||
const base = initialModel ?? fromFixture(data);
|
||||
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
|
||||
}, [data, breaks]);
|
||||
}, [initialModel, data, breaks]);
|
||||
|
||||
return (
|
||||
<ModelStoreProvider initialModel={initialModel}>
|
||||
<ModelStoreProvider
|
||||
initialModel={startingModel}
|
||||
initialVersion={initialVersion ?? 1}
|
||||
projectId={projectId}
|
||||
>
|
||||
<ShellBody
|
||||
data={data}
|
||||
density={density}
|
||||
@@ -70,14 +85,16 @@ function EditorShellInner({
|
||||
);
|
||||
}
|
||||
|
||||
function ShellBody({
|
||||
data,
|
||||
density,
|
||||
markupStyle,
|
||||
diagramStyle,
|
||||
presence,
|
||||
breaks,
|
||||
}: Required<Omit<EditorShellProps, "data">> & { data: FixtureData; breaks: BreakName[] }) {
|
||||
interface ShellBodyProps {
|
||||
data: FixtureData;
|
||||
density: Density;
|
||||
markupStyle: MarkupStyle;
|
||||
diagramStyle: DiagramVariant;
|
||||
presence: SocratesPresence;
|
||||
breaks: BreakName[];
|
||||
}
|
||||
|
||||
function ShellBody({ data, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const { model, issues, issuesByElement } = useModelStore();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user