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:
25
apps/web/app/api/projects/[projectId]/apply/route.ts
Normal file
25
apps/web/app/api/projects/[projectId]/apply/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// POST /api/projects/[projectId]/apply — body is { ops: ModelOp[], expectedVersion?: number, reason?: string }.
|
||||
// Returns { applied, model, version, idMapping, errors } from the server-side applyOps.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { applyOpsToProject } from "../../../../../lib/db/repo";
|
||||
import type { ModelOp } from "../../../../../lib/sync/ops";
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await params;
|
||||
let body: { ops?: ModelOp[]; expectedVersion?: number; reason?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
|
||||
}
|
||||
if (!body.ops || !Array.isArray(body.ops) || body.ops.length === 0) {
|
||||
return NextResponse.json({ error: "body.ops must be a non-empty array" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await applyOpsToProject(projectId, body.ops, body.expectedVersion, body.reason);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
15
apps/web/app/api/projects/[projectId]/route.ts
Normal file
15
apps/web/app/api/projects/[projectId]/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// GET /api/projects/[projectId] — returns the latest SysMLModel + version.
|
||||
// Auto-seeds Aristotle if the project doesn't exist yet.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadProject } from "../../../../lib/db/repo";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await params;
|
||||
try {
|
||||
const result = await loadProject(projectId);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user