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
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user