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:
Schwitzz
2026-10-26 12:02:03 -05:00
parent 4efa71515e
commit 301e842c5c
17 changed files with 1265 additions and 65 deletions
+315
View File
@@ -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.