Skip to content
D
Documentation

Comments

reference
8 min readUpdated

Import these from @grafloria/engine.

On their own pages

Functions

mentionIds

Just the ids — what gets stored on the message register.

ts
function mentionIds(body: string): string[]

mentionKey

The idempotency key. Derived only from op-borne data — never from local state.

ts
function mentionKey(threadId: string, messageId: string): string

messageKey

The sort key of a message, as an opaque comparable string. Used by read-state.

ts
function 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.)

ts
function 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.

ts
function parseMentions(body: string): MentionRef[]

Classes

ReadState

What THIS viewer has seen. Local. Never synced.

ts
class 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[]): boolean
  • toJSON(): SerializedReadState
  • static 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.

ts
interface CommentMessage

Properties

NameTypeDefaultDescription
idstring
authorstring
bodystring
createdAtnumber
mentions?string[]User ids extracted from the body at authoring time. Convergent, queryable.
editedAt?number
deleted?boolean

CommentStoreOptions

ts
interface CommentStoreOptions

Properties

NameTypeDefaultDescription
viewerstringThe PERSON at this peer. Not the actor/session id.
idFactory?() => stringDeterministic ids for tests. Default: unique by construction (see mintId).
now?() => numberDeterministic time for tests. Default: Date.now.
notifier?MentionNotifierThe
readState?ReadStateThis viewer's unread watermarks — hand back what you persisted.
labelOf?(kind: 'node' | 'link', id: string) => string | undefinedHow 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.

ts
interface CommentThreadHead

Properties

NameTypeDefaultDescription
idstring
authorstringThe PERSON who started the thread (a user id, not a session/actor id).
createdAtnumberWall-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.

ts
interface CommentThreadStatus

Properties

NameTypeDefaultDescription
resolvedboolean
bystring
atnumber

CommentThreadView

A thread, assembled for reading: sorted messages, derived anchor, viewer's unread count.

ts
interface CommentThreadView

Properties

NameTypeDefaultDescription
idstring
authorstring
createdAtnumber
anchorCommentAnchor
resolvedboolean
resolvedBy?string
resolvedAt?number
messagesCommentMessage[]Total order, identical on every peer. Tombstones included (flagged deleted).
unreadnumberMessages this viewer has not seen, authored by someone else. LOCAL, never synced.
resolvedAnchorResolvedAnchorDerived 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.

ts
interface MentionEvent

Properties

NameTypeDefaultDescription
keystringSTABLE ACROSS PEERS AND ACROSS REPLAYS. Dedupe on this or send N copies. See header.
diagramIdstring
threadIdstring
messageIdstring
mentionedstring[]User ids. The host's directory turns these into people.
authorstringThe user id of whoever wrote the message.
bodystring
anchorCommentAnchor
createdAtnumber
localbooleanTrue when this peer is the one that authored the message.

MentionNotifier

The seam. One method. The engine calls it; the host implements it.

ts
interface MentionNotifier

Members

  • notify(event: MentionEvent): void

MentionRef

One

ts
interface MentionRef

Properties

NameTypeDefaultDescription
idstringThe user id to notify. From @[Name](id) this is id; from @handle it is handle.
displaystringWhat was written on screen, for rendering the chip.
rawstringThe exact source text, so a renderer can replace it in place.
indexnumberCharacter 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).

ts
interface ResolvedAnchor

Properties

NameTypeDefaultDescription
pointWorldPointWorld coordinates for the pin.
attachedbooleanFalse ⇒ ORPHANED: the node/link this thread is about is not in the diagram.
targetLabelstringThe 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.

ts
interface SerializedReadState

Properties

NameTypeDefaultDescription
viewerstring
watermarksRecord<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.

ts
interface StoredThread

Properties

NameTypeDefaultDescription
head?CommentThreadHead
anchor?CommentAnchor
status?CommentThreadStatus
messages?Record<string, CommentMessage>

WorldPoint

A point in WORLD space. Never screen space — see CommentAnchor.

ts
interface WorldPoint

Properties

NameTypeDefaultDescription
xnumber
ynumber

Types

AnchorSpec

What a caller asks for. The store fills in the obituary (fallback point + label).

ts
type AnchorSpec =
  | { kind: 'node'; id: string }
  | { kind: 'link'; id: string }
  | { kind: 'region'; x: number; y: number; width?: number; height?: number };

Properties

NameTypeDefaultDescription
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.

ts
type 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

NameTypeDefaultDescription
kind'node'

CommentRegisterTree

ts
type CommentRegisterTree = Record<string, StoredThread>;

Was this page helpful?

Comments — Grafloria