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:
137
apps/web/lib/db/repo.ts
Normal file
137
apps/web/lib/db/repo.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Project + model persistence layer.
|
||||
//
|
||||
// Stores the full SysMLModel as a JSON snapshot per version, plus a changelog
|
||||
// of applied ops. Reads return the latest snapshot; writes apply ops
|
||||
// server-side and write a new snapshot + changelog row atomically.
|
||||
|
||||
import { prisma } from "./client";
|
||||
import { applyOps } from "../sync/applyOps";
|
||||
import { fromFixture } from "../sysml/fromFixture";
|
||||
import { aristotleFixture } from "../fixtures/aristotle";
|
||||
import type { SysMLModel } from "../sysml/model";
|
||||
import type { ModelOp } from "../sync/ops";
|
||||
|
||||
export interface LoadResult {
|
||||
model: SysMLModel;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export async function loadProject(projectId: string): Promise<LoadResult> {
|
||||
// Auto-seed: if the project doesn't exist (or has no snapshot), bootstrap
|
||||
// it from the Aristotle fixture so the editor route always has data.
|
||||
const seeded = await ensureSeeded(projectId);
|
||||
const snapshot = await prisma.modelSnapshot.findFirst({
|
||||
where: { projectId: seeded.projectId },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (!snapshot) {
|
||||
throw new Error(`Project ${projectId} has no snapshots after seed — should not happen`);
|
||||
}
|
||||
return {
|
||||
model: JSON.parse(snapshot.json) as SysMLModel,
|
||||
version: snapshot.version,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
applied: boolean;
|
||||
model: SysMLModel;
|
||||
version: number;
|
||||
idMapping: Record<string, string>;
|
||||
errors: Array<{ opIndex: number; code: string; message: string }>;
|
||||
}
|
||||
|
||||
export async function applyOpsToProject(
|
||||
projectId: string,
|
||||
ops: ModelOp[],
|
||||
expectedVersion: number | undefined,
|
||||
reason?: string
|
||||
): Promise<ApplyResult> {
|
||||
return prisma.$transaction(async tx => {
|
||||
const latest = await tx.modelSnapshot.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (!latest) throw new Error(`No snapshot for project ${projectId}`);
|
||||
|
||||
if (expectedVersion !== undefined && expectedVersion !== latest.version) {
|
||||
// Optimistic-concurrency miss; caller must resync.
|
||||
return {
|
||||
applied: false,
|
||||
model: JSON.parse(latest.json) as SysMLModel,
|
||||
version: latest.version,
|
||||
idMapping: {},
|
||||
errors: [{ opIndex: -1, code: "VERSION_MISMATCH", message: `Expected version ${expectedVersion}, server is ${latest.version}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const currentModel = JSON.parse(latest.json) as SysMLModel;
|
||||
const result = applyOps(currentModel, ops);
|
||||
if (!result.applied) {
|
||||
return {
|
||||
applied: false,
|
||||
model: currentModel,
|
||||
version: latest.version,
|
||||
idMapping: {},
|
||||
errors: result.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const newVersion = latest.version + 1;
|
||||
await tx.modelSnapshot.create({
|
||||
data: {
|
||||
projectId,
|
||||
version: newVersion,
|
||||
json: JSON.stringify(result.model),
|
||||
},
|
||||
});
|
||||
await tx.changelogEntry.create({
|
||||
data: {
|
||||
projectId,
|
||||
version: newVersion,
|
||||
ops: JSON.stringify(ops),
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
model: result.model,
|
||||
version: newVersion,
|
||||
idMapping: result.idMapping,
|
||||
errors: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Seeding ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function ensureSeeded(projectId: string): Promise<{ projectId: string }> {
|
||||
const existing = await prisma.project.findUnique({ where: { id: projectId } });
|
||||
if (existing) {
|
||||
// Make sure there's at least one snapshot
|
||||
const snapCount = await prisma.modelSnapshot.count({ where: { projectId } });
|
||||
if (snapCount > 0) return { projectId };
|
||||
}
|
||||
|
||||
// Bootstrap with the Aristotle fixture
|
||||
const seedModel = fromFixture(aristotleFixture);
|
||||
await prisma.project.upsert({
|
||||
where: { id: projectId },
|
||||
update: {},
|
||||
create: {
|
||||
id: projectId,
|
||||
name: aristotleFixture.project.name,
|
||||
scope: aristotleFixture.project.scope,
|
||||
tagline: aristotleFixture.project.tagline,
|
||||
},
|
||||
});
|
||||
await prisma.modelSnapshot.create({
|
||||
data: {
|
||||
projectId,
|
||||
version: 1,
|
||||
json: JSON.stringify(seedModel),
|
||||
},
|
||||
});
|
||||
return { projectId };
|
||||
}
|
||||
Reference in New Issue
Block a user