- 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
10 KiB
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:
type Entity = Character | WorldItem | Note;
Entities are identified by the absence of fields/body rather than an
explicit kind tag:
- Character — no
fields, nobody. Hasname,role,bio,appearance,voice,relationships,arc,status,tags. - WorldItem — has a
kind(location|faction|item|glossary|system) and a free-formfieldsrecord. - Note — has a
body,pinned,tags, and an optionallinkedTo.
2.1 Character
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
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
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.
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.
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:
{ "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.
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.
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
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.