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:
2026-04-29 07:23:35 +02:00
parent 384cbb4ae9
commit 5d4236a980
10 changed files with 678 additions and 73 deletions

View File

@@ -0,0 +1,54 @@
// SQLite-backed dev persistence (M5.9). The model is stored as a JSON snapshot
// per project + a changelog of applied ops. This is the smallest shape that
// gives us "refresh persists state" without committing to the full
// event-sourced architecture in docs/sync.md (that lands later).
//
// Switch `provider` to "postgresql" + set DATABASE_URL=postgres://… to move
// to a real DB later — schema is portable.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Project {
id String @id
name String
scope String
tagline String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
snapshots ModelSnapshot[]
changes ChangelogEntry[]
}
model ModelSnapshot {
id String @id @default(cuid())
projectId String
version Int
json String // serialized SysMLModel
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, version])
@@index([projectId, version])
}
model ChangelogEntry {
id String @id @default(cuid())
projectId String
version Int // resulting model version after these ops landed
ops String // JSON-encoded ModelOp[]
reason String? // optional human/Socrates-supplied rationale
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, version])
}