Files
PubScript/docs/DATA_MODEL.md
T
Schwitzz 301e842c5c feat(entity): Chunk #4 remaining items — consistency UI, context rail, palette search, expanded seed & DATA_MODEL
- 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
2026-10-26 12:02:03 -05:00

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, 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

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.


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.