Database
Schema: prisma/schema.prisma (~50 models, covering Part 8.3 in full).
Portability
Dev runs SQLite so the app boots with no infrastructure. Three concessions keep it Postgres-compatible, each isolated to one file:
| Concession | Why | Postgres migration |
|---|---|---|
JSON in String columns (*Json) | SQLite has no JSON scalar in this connector | Change the type to Json; all access is via server/lib/json.ts |
| Embeddings as JSON float arrays | No pgvector | Unsupported("vector(768)") + ivfflat; similarity moves into SQL. Access is via server/lib/vector.ts |
Enums as String | SQLite connector has no enums | Promote to Postgres enums; allowed values live in src/lib/enums.ts |
Client generation
This machine is Windows on ARM64, for which Prisma ships no native query engine — the legacy generator fails with "not a valid Win32 application".
The schema therefore uses:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
runtime = "nodejs"
moduleFormat = "cjs"
engineType = "client" // WASM compiler + JS driver adapter, no Rust engine
}
with PrismaBetterSQLite3 in server/db.ts. This is also the better production posture: one fewer platform-specific binary. Moving to Postgres swaps the adapter for @prisma/adapter-pg and nothing else changes.
Path gotcha
The Prisma CLI resolves a relative file: URL against prisma/; a driver adapter resolves it against process.cwd(). Left alone, prisma db push and the app use two different database files — and the symptom is a confusing TableDoesNotExist. resolveSqliteUrl in server/db.ts normalizes to an absolute path so one DATABASE_URL works for both.
Model groups
| Group | Models |
|---|---|
| Identity | User, Session, Organization, OrgMembership, Subscription, CreditLedger |
| Project | Project, ProjectBranch, Universe, BrandBible, LoreEntry |
| Character | Character, CharacterVersion, CharacterRefAsset, CharacterState, Wardrobe, Voice, ConsentRecord |
| World | World, Location, Prop |
| Script | Script, ScriptVersion, ToneMap, Scene, DirectionNote, MusicCue, SoundNote |
| Shot | Shot, ShotCharacter, ShotProp, DialogueLine |
| Style | StylePack, StyleModifier |
| Models | ModelRegistryEntry, ModelBenchmark, Generation |
| Assets | Asset, AssetFolder, GraphEdge, CascadePlan |
| Timeline | Timeline, TimelineTrack, TimelineClip, EditDecisionProposal |
| Continuity | ContinuityFlag, Contradiction, FilmAudit, BudgetSnapshot |
| Director | DirectorMessage |
| Theater | Publication, CastCard, ModerationCase, ReviewDecision, Report, Strike, WatchHistory, MyListEntry, Rating, Comment, Follow, FestivalEdition, FestivalEntry, FestivalVote |
| Publishing | SocialConnection, SocialPost, Campaign |
Notes on specific models
GraphEdge is the heart. Type-agnostic by design — adding a node type needs no migration. Indexed on (projectId, fromType, fromId) and (projectId, toType, toId) for both traversal directions, plus (projectId, stale) for the badge.
CreditLedger is append-only. Never update a row.
Generation.requestJson stores the full capability request, so any generation can be replayed exactly. provenanceJson stores what actually happened, including failovers.
CharacterVersion.validFromSceneOrder / validToSceneOrder implement canon pinning — a redesign in scene 30 cannot corrupt scene 4.
Publication.unlistedSlug exists because quality rejection is not removal.
Commands
npm run db:push # sync schema (dev)
npm run db:seed # styles, models, demo project, catalog
npm run db:reset # force-reset + reseed
npm run db:studio # Prisma Studio