Import these from @grafloria/engine.
On their own pages
Functions
mentionIds
Just the ids — what gets stored on the message register.
tsfunction mentionIds(body: string): string[]
mentionKey
The idempotency key. Derived only from op-borne data — never from local state.
tsfunction mentionKey(threadId: string, messageId: string): string
messageKey
The sort key of a message, as an opaque comparable string. Used by read-state.
tsfunction messageKey(m: CommentMessage): string
messageOrder
THE TOTAL ORDER ON MESSAGES.
Every peer must list a thread's messages in the SAME order or two people reading the
same conversation read different conversations. (createdAt, author, id) is total
(ids are unique) and it is computed from data that travels IN the ops, so it is
identical on every peer and stable across replay.
It is wall time, and that is a deliberate, bounded concession. Wall time may not decide MERGE — a skewed clock deciding which edit wins is silent data loss, which is why the substrate uses Lamport clocks for that and why this function is not used for anything but display order. Wall time deciding the ORDER OF A CHAT LIST is what every messaging product on earth does, and its worst case is cosmetic: two messages sent within the clock skew of each other may read in the wrong order. Nobody loses a comment. (The alternative — a per-message Lamport stamp — is unavailable without reaching into OpCapture's clock, and buying strict causal order for a chat list at the price of a second way to mint stamps is a bad trade.)
tsfunction messageOrder(a: CommentMessage, b: CommentMessage): number
parseMentions
Extract the mentions from a body. Pure, and total: never throws, never re-orders.
Deduplicated by id — mentioning Ada three times in one message is one notification, not three, and that is a property of the DATA, not of the notifier's retry policy.
tsfunction parseMentions(body: string): MentionRef[]
Classes
ReadState
What THIS viewer has seen. Local. Never synced.
tsclass ReadState
Methods
constructor(readonly viewer: string)markRead(threadId: string, messages: readonly CommentMessage[]): void— Mark a thread read up to its newest message.
Takes the messages rather than a key so the caller cannot accidentally set a watermark ahead of the messages it has actually seen — which would silently swallow a message that is still in flight.
markUnread(threadId: string): void— Explicitly mark unread again (a "mark as unread" affordance).unreadCount(threadId: string, messages: readonly CommentMessage[]): number— How many messages in this thread the viewer has not seen.
A message YOU wrote is never unread — you were there. A tombstoned message is never unread either: a badge that says "1 unread" and opens onto "message deleted" is a badge that has wasted someone's attention, which is the only currency a notification has.
isRead(threadId: string, messages: readonly CommentMessage[]): booleantoJSON(): SerializedReadStatestatic fromJSON(data: SerializedReadState): ReadState(static)
Interfaces
CommentMessage
One message. ONE register — the rule that makes concurrent replies survive.
An edit rewrites the whole register (an edit IS a whole-body replacement, so nothing
is gained by splitting it). A delete rewrites it as a TOMBSTONE — deleted: true,
body cleared, author and createdAt kept — rather than removing the key, because
removing it would let a concurrent edit resurrect the message with no body, and
because the ordering of the surviving messages must not shift under a peer that has
not yet seen the delete.
tsinterface CommentMessage
Properties
| Name | Type | Default | Description |
|---|---|---|---|
id | string | ||
author | string | ||
body | string | ||
createdAt | number | ||
mentions? | string[] | User ids extracted from the body at authoring time. Convergent, queryable. | |
editedAt? | number | ||
deleted? | boolean |
CommentStoreOptions
tsinterface CommentStoreOptions
Properties
| Name | Type | Default | Description |
|---|---|---|---|
viewer | string | The PERSON at this peer. Not the actor/session id. | |
idFactory? | () => string | Deterministic ids for tests. Default: unique by construction (see mintId). | |
now? | () => number | Deterministic time for tests. Default: Date.now. | |
notifier? | MentionNotifier | The | |
readState? | ReadState | This viewer's unread watermarks — hand back what you persisted. | |
labelOf? | (kind: 'node' | 'link', id: string) => string | undefined | How to name an entity. Default: metadata.label, then type, then the id. |
CommentThreadHead
Write-once thread identity. One register, so a thread appears whole or not at all.
tsinterface CommentThreadHead
Properties
| Name | Type | Default | Description |
|---|---|---|---|
id | string | ||
author | string | The PERSON who started the thread (a user id, not a session/actor id). | |
createdAt | number | Wall-clock milliseconds, captured at authoring time and carried IN the op. |
CommentThreadStatus
Resolve/reopen. ONE register, so the flag and its attribution can never split.
tsinterface CommentThreadStatus
Properties
| Name | Type | Default | Description |
|---|---|---|---|
resolved | boolean | ||
by | string | ||
at | number |
CommentThreadView
A thread, assembled for reading: sorted messages, derived anchor, viewer's unread count.
tsinterface CommentThreadView
Properties
| Name | Type | Default | Description |
|---|---|---|---|
id | string | ||
author | string | ||
createdAt | number | ||
anchor | CommentAnchor | ||
resolved | boolean | ||
resolvedBy? | string | ||
resolvedAt? | number | ||
messages | CommentMessage[] | Total order, identical on every peer. Tombstones included (flagged deleted). | |
unread | number | Messages this viewer has not seen, authored by someone else. LOCAL, never synced. | |
resolvedAnchor | ResolvedAnchor | Derived from the live diagram — see ResolvedAnchor. |
MentionEvent
What a notifier is handed. Everything it needs, nothing it would have to guess.
anchor is in here on purpose: "Ada was mentioned" is useless; "Ada was mentioned in a
thread on the Payment gateway node" is a notification a human can act on without opening
the file.
tsinterface MentionEvent
Properties
| Name | Type | Default | Description |
|---|---|---|---|
key | string | STABLE ACROSS PEERS AND ACROSS REPLAYS. Dedupe on this or send N copies. See header. | |
diagramId | string | ||
threadId | string | ||
messageId | string | ||
mentioned | string[] | User ids. The host's directory turns these into people. | |
author | string | The user id of whoever wrote the message. | |
body | string | ||
anchor | CommentAnchor | ||
createdAt | number | ||
local | boolean | True when this peer is the one that authored the message. |
MentionNotifier
The seam. One method. The engine calls it; the host implements it.
tsinterface MentionNotifier
Members
notify(event: MentionEvent): void
MentionRef
One
tsinterface MentionRef
Properties
| Name | Type | Default | Description |
|---|---|---|---|
id | string | The user id to notify. From @[Name](id) this is id; from @handle it is handle. | |
display | string | What was written on screen, for rendering the chip. | |
raw | string | The exact source text, so a renderer can replace it in place. | |
index | number | Character offset of raw in the body. |
ResolvedAnchor
WHERE A THREAD'S PIN GOES, AND WHETHER ITS SUBJECT STILL EXISTS.
attached is DERIVED — computed from the live diagram on every read, never stored
and never synced. That is the single most important decision in this file, and it is
argued at length in comment-store.ts (resolveAnchor).
tsinterface ResolvedAnchor
Properties
| Name | Type | Default | Description |
|---|---|---|---|
point | WorldPoint | World coordinates for the pin. | |
attached | boolean | False ⇒ ORPHANED: the node/link this thread is about is not in the diagram. | |
targetLabel | string | The live label if attached; the snapshot taken at anchor time if not. | |
targetKind | 'node' | 'link' | 'region' | ||
targetId? | string |
SerializedReadState
The serializable shape. Give this to the host to persist; hand it back on load.
tsinterface SerializedReadState
Properties
| Name | Type | Default | Description |
|---|---|---|---|
viewer | string | ||
watermarks | Record<string, string> | threadId → the messageKey of the newest message this viewer has seen. |
StoredThread
The raw register tree as it sits on the model and in the serialized document.
tsinterface StoredThread
Properties
| Name | Type | Default | Description |
|---|---|---|---|
head? | CommentThreadHead | ||
anchor? | CommentAnchor | ||
status? | CommentThreadStatus | ||
messages? | Record<string, CommentMessage> |
WorldPoint
A point in WORLD space. Never screen space — see CommentAnchor.
tsinterface WorldPoint
Properties
| Name | Type | Default | Description |
|---|---|---|---|
x | number | ||
y | number |
Types
AnchorSpec
What a caller asks for. The store fills in the obituary (fallback point + label).
tstype AnchorSpec =
| { kind: 'node'; id: string }
| { kind: 'link'; id: string }
| { kind: 'region'; x: number; y: number; width?: number; height?: number };
Properties
| Name | Type | Default | Description |
|---|---|---|---|
kind | 'node' |
CommentAnchor
WHAT A COMMENT IS ABOUT.
ANCHOR BY IDENTITY, NEVER BY COORDINATES — for anything that has an identity. A comment pinned to (420, 180) is a comment about whatever happens to be at (420, 180) right now, which after one auto-layout is a different node and a different meaning. The comment would still be there, still timestamped, still confidently wrong. So a node/link anchor stores the ENTITY ID and derives its position from the live entity every frame; move the node across the canvas and the pin comes with it, and not one op is emitted to make that happen (position is derived, and derived state is never synced — the same rule capture.ts applies to link routes).
A FREE-REGION anchor genuinely is coordinates, and they are WORLD coordinates. Screen coordinates would make the pin's meaning depend on the reader's scroll position and zoom — a note about the top-right corner of a subsystem would drift onto empty canvas the moment anyone panned. World coordinates are invariant under pan and zoom by construction, and the renderer draws them inside the viewBox, so the pin sticks to the diagram rather than to the glass.
fallback and targetLabel are the ANCHOR'S OBITUARY: where the target was and what
it was called, captured at the moment the thread was created. They exist for exactly
one purpose — so that a thread whose target has been DELETED can still be shown, in
the right place, saying what it was about. See ResolvedAnchor.
tstype CommentAnchor =
| {
kind: 'node';
id: string;
fallback: WorldPoint;
targetLabel?: string;
}
| {
kind: 'link';
id: string;
fallback: WorldPoint;
targetLabel?: string;
}
| {
kind: 'region';
x: number;
y: number;
width?: number;
height?: number;
};
Properties
| Name | Type | Default | Description |
|---|---|---|---|
kind | 'node' |
CommentRegisterTree
tstype CommentRegisterTree = Record<string, StoredThread>;
Was this page helpful?