# PubScript Data Model The canonical reference for the PubScript data model. Every type lives in `packages/core/src/types.ts`; the field registry lives in `packages/core/src/schema.ts`; the persistence contract lives in `packages/core/src/storage.ts`. Code against these, never against a backend. - IDs are **ULIDs** (sortable, collision-free) — see `packages/core/src/ids.ts`. - Timestamps are **UTC ISO-8601** strings — see `packages/core/src/time.ts`. - Documents are stored as **ProseMirror JSON** (the TipTap schema). --- ## 1. The project tree A project is the top-level container. Everything below it carries a `projectId`. ``` Project ├── Book[] ordered collection of chapters │ └── Chapter[] a ProseMirror doc + word count / scene map ├── Entity[] characters, world items, notes (the entity engine) ├── Board a freeform node/edge canvas (plot, timeline) └── AISession[] AI conversations + inline suggestions ``` | Type | Key fields | Notes | | --- | --- | --- | | `Project` | `id, projectId, title, ownerId, sharedWith[], settings, cover, createdAt, updatedAt` | `sharedWith` is `{ userId, perm }`; `perm` is `read` \| `edit`. | | `Book` | `id, projectId, title, order, chapters[]` | `chapters` are materialized; the store also keeps chapters by `doc` for autosave. | | `Chapter` | `id, bookId, projectId, title, doc, rev, order, meta` | `rev` is an optimistic-concurrency counter; `meta` holds `wordCount`, `scenes`, `updatedAt`. | | `DocumentRef` | `id, projectId, bookId?, title, doc, rev` | The save shape handed to `adapter.saveDocument`. | `ChapterMeta.scenes` are scene-break positions computed from the doc; a scene break is a `sceneBreak` node in the ProseMirror tree (Chunk 3). --- ## 2. The entity model The entity engine (Chunk 4) is the heart of PubScript's "story brain." An `Entity` is a discriminated union of three kinds: ```ts type Entity = Character | WorldItem | Note; ``` Entities are identified by the *absence* of `fields`/`body` rather than an explicit `kind` tag: - **Character** — no `fields`, no `body`. Has `name`, `role`, `bio`, `appearance`, `voice`, `relationships`, `arc`, `status`, `tags`. - **WorldItem** — has a `kind` (`location` \| `faction` \| `item` \| `glossary` \| `system`) and a free-form `fields` record. - **Note** — has a `body`, `pinned`, `tags`, and an optional `linkedTo`. ### 2.1 Character ```ts interface Character { id: string; projectId: string; name: string; role: string; // protagonist / antagonist / supporting … bio: string; appearance: string; voice: string; relationships: Array<{ targetId: string; // id of another Character kind: RelationshipKind; note: string; }>; arc: string; status: string; // active / inactive / dead / unseen tags: string[]; } ``` `status: "dead"` is meaningful: the consistency checker flags a dead character who is still mentioned (by entity id) in a chapter body — a dead character should not appear alive in the story. ### 2.2 WorldItem ```ts interface WorldItem { id: string; projectId: string; kind: WorldItemKind; // location | faction | item | glossary | system name: string; fields: Record; } ``` `fields` is free-form and driven by the field registry (`ENTITY_FIELDS`); a `list` field (e.g. a location's `keyPeople`) stores a `string[]`. ### 2.3 Note ```ts interface Note { id: string; projectId: string; body: string; pinned: boolean; tags: string[]; linkedTo?: string; // id of the entity this note annotates } ``` --- ## 3. The field registry (`ENTITY_FIELDS`) `packages/core/src/schema.ts` is the single source of truth that drives both the generic entity editor and the server's validation. Adding a field to a kind here makes it appear in the UI with no per-entity code. ```ts type FieldType = "text" | "textarea" | "select" | "list" | "link"; interface FieldDef { key: string; label: string; type: FieldType; options?: string[]; // required for `select` and `link` hint?: string; } ``` | Kind | Fields | | --- | --- | | `character` | name, role (select), status (select), **home (link → location)**, bio, appearance, voice, arc, tags (list) | | `location` | name, region, climate, keyPeople (list), description | | `faction` | name, goals, rivals (list), allies (list), description | | `item` | name, origin, powers (list), holder | | `glossary` | name (Term), definition | | `system` | name, rules, limitations | | `note` | body, tags (list) | The `link` field type renders an entity-reference selector; its `hint` records the target kind (e.g. "linked location"). `ENTITY_KINDS` lists every kind in display order. --- ## 4. Relationships Characters relate to one another through `relationships`. Each entry names a `targetId` (another character's id), a `kind`, and a free-form `note`. ```ts type RelationshipKind = | "ally" | "rival" | "family" | "sibling" | "friend" | "spouse" | "partner" | "mentor" | "romantic" | "custom"; ``` Relationship kinds split into two classes for the consistency checker: - **Symmetric** — should hold in both directions: `sibling`, `friend`, `spouse`, `partner`, `family`, `rival`. If Ada lists "Bram: sibling", Bram should list "Ada: sibling" too. - **Directed** — one-directional is fine: `ally`, `mentor`, `romantic`, `custom`. `SYMMETRIC_RELATIONSHIPS` (in `consistency.ts`) is the source of truth for this split. --- ## 5. Consistency checks (`consistency.ts`) `findInconsistencies(chapters, entities)` runs three checks and returns a `ConsistencyIssue[]`: | Issue kind | Detector | Meaning | | --- | --- | --- | | `orphan-mention` | `findOrphanedMentions` | A chapter mentions an entity id that no longer exists (the entity was deleted). | | `relationship-asymmetry` | `findRelationshipAsymmetry` | A symmetric relationship recorded by one side but not reciprocated. | | `dead-character-mentioned` | `findDeadCharacterMentions` | A `dead` character is still mentioned (by entity id) in a chapter body. | Bare-name mentions (no `entityId`) are ignored by the dead-character check — a reference to a name that is not yet an entity is an orphan-mention concern, not a dead-character one. --- ## 6. Mentions & backlinks (`backlinks.ts`) A **mention** is an inline atom node (`CharacterMention`) that links prose to a character entity. In the ProseMirror JSON it is: ```json { "type": "CharacterMention", "attrs": { "entityId": "char-…", "name": "Mara" } } ``` The node renders inline in HTML as `@Name`. The in-memory attribute is `entityId` (what the core reads); the DOM keeps `data-id` so the hover popover can resolve the entity on mouseover. Helpers: - `collectMentionsInDoc(doc)` → `Set` — the IDs mentioned in one doc. - `collectMentionedEntityIds(chapters)` → `Set` — across many docs. - `findMentionsInDoc(doc, entityIds, entityNames)` → node paths where an entity is referenced. - `backlinksForEntity(chapters, entityId)` → `Backlink[]` — every chapter that references the entity, with the node paths where it appears. - `docHasMentions(doc)` → `boolean`. Backlinks power the context rail (showing where an entity is referenced) and the mention hover card. --- ## 7. The board (`Board`) A freeform canvas of nodes and edges, used for plotting and timelines. ```ts interface FlowNode { id: string; type: "character" | "world" | "chapter" | "note" | "image" | "group"; position: { x: number; y: number }; data: Record; } interface FlowEdge { id: string; source: string; target: string; type: "leads-to" | "contrasts" | "causes" | "custom"; label?: string; } interface Board { id; projectId; nodes: FlowNode[]; edges: FlowEdge[]; } ``` --- ## 8. Persistence: the StorageAdapter contract `packages/core/src/storage.ts` defines the seam that makes web / desktop / local modes interchangeable. Every feature codes against this contract, never against `ServerAdapter` or `LocalAdapter` directly. A single contract-test suite (`contract` script) enforces parity across implementations. ```ts interface StorageAdapter { // projects listProjects(): Promise; createProject(p: NewProject): Promise; getProject(id: string): Promise; // books & chapters createBook(b: { projectId; title }): Promise; listBooks(projectId): Promise; listChapters(bookId): Promise; // content (debounced autosave) saveDocument(doc: DocumentRef): Promise; getChapter(id): Promise; // entities (the Chunk 4 engine) saveEntity(e: Entity): Promise; listEntities(projectId, kind?): Promise; // kind: "character" | "world" | "note" getEntity(projectId, kind, id): Promise; deleteEntity(projectId, kind, id): Promise; searchEntities(projectId, q): Promise; // fuzzy over name + fields // board saveBoard(b: Board): Promise; saveAISession(s: AISession): Promise; // live updates onEvent(cb: (e: StoreEvent) => void): () => void; } ``` `listEntities` filters by a coarse `kind` (`"character"`, `"world"`, `"note"`); `searchEntities` does a fuzzy match over an entity's name and field values, ordered best-first (empty `q` returns all). `StoreEvent` fans live updates to subscribed clients: `doc-changed`, `entity-changed`, `board-changed`, `ai-session-changed`, `presence`, `conflict`. ### Implementations - **Local** (`@pubscript/core/local`) — IndexedDB via Dexie, used by the offline browser app. - **Server** (`ServerAdapter`) — Fastify + SQLite, used in multi-tenant mode. Both must pass the identical contract suite. --- ## 9. AI suggestions ```ts type SuggestionKind = "replace" | "insert" | "delete" | "entity-update" | "draft"; type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned"; interface SuggestionTarget { docId: string; range?: { nodeId: string }; // a stable node anchor, not a raw offset nodePath?: number[]; } interface Suggestion { id; sessionId; target: SuggestionTarget; before; after; rationale; kind; status; } ``` A suggestion targets a **stable node anchor** (`range.nodeId`), not a raw offset, so it survives edits — see PLAN.md §5.5.