301e842c5c
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
316 lines
10 KiB
Markdown
316 lines
10 KiB
Markdown
# 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<string, string | string[]>;
|
|
}
|
|
```
|
|
|
|
`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
|
|
`<span class="mention mention-character" data-id="…" data-label="…">@Name</span>`.
|
|
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<entityId>` — the IDs mentioned in one doc.
|
|
- `collectMentionedEntityIds(chapters)` → `Set<entityId>` — 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<string, unknown>;
|
|
}
|
|
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<ProjectMeta[]>;
|
|
createProject(p: NewProject): Promise<Project>;
|
|
getProject(id: string): Promise<Project>;
|
|
|
|
// books & chapters
|
|
createBook(b: { projectId; title }): Promise<Book>;
|
|
listBooks(projectId): Promise<Book[]>;
|
|
listChapters(bookId): Promise<Chapter[]>;
|
|
|
|
// content (debounced autosave)
|
|
saveDocument(doc: DocumentRef): Promise<void>;
|
|
getChapter(id): Promise<Chapter | null>;
|
|
|
|
// entities (the Chunk 4 engine)
|
|
saveEntity(e: Entity): Promise<void>;
|
|
listEntities(projectId, kind?): Promise<Entity[]>; // kind: "character" | "world" | "note"
|
|
getEntity(projectId, kind, id): Promise<Entity | null>;
|
|
deleteEntity(projectId, kind, id): Promise<void>;
|
|
searchEntities(projectId, q): Promise<Entity[]>; // fuzzy over name + fields
|
|
|
|
// board
|
|
saveBoard(b: Board): Promise<void>;
|
|
saveAISession(s: AISession): Promise<void>;
|
|
|
|
// 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.
|