Import it from @grafloria/engine.
tsclass CommentStore
Properties
| Name | Type | Default | Description |
|---|---|---|---|
readState | ReadState |
Methods
constructor( readonly diagram: DiagramModel, options: CommentStoreOptions )createThread(spec: AnchorSpec, body: string): string— Start a thread. Three ops: head, anchor, first message.
Three and not one, because comments.<tid> and comments.<tid>.status would be
OVERLAPPING registers — LWW cannot order two writes to different paths, so a
whole-thread write that arrived late would silently wipe a resolve. Prefix-free
leaves are the price of correctness under out-of-order delivery, and the fuzz test
asserts prefix-freedom rather than trusting me to remember it.
reply(threadId: string, body: string): string— Add a message. ONE register — which is why a colleague's simultaneous reply lives.editMessage(threadId: string, messageId: string, body: string): boolean— Edit a message: rewrite its register whole.
Two people editing the same message concurrently is a genuine conflict on one register and LWW picks one, deterministically, on every peer. That is the right answer — an edit IS a whole-body replacement, so there is no finer cut that would have saved both, and merging two rewrites of one sentence produces a sentence neither person wrote.
deleteMessage(threadId: string, messageId: string): boolean— Delete a message — TOMBSTONE, never key removal.
Removing the key would let a concurrent edit of the same message resurrect it from the dead (the edit's register write auto-creates the path again), and it would shift the ordering of the surviving messages on a peer that has not yet seen the delete. A tombstone keeps the author and the timestamp, so the conversation still reads in the order it happened, with a hole where somebody withdrew something — which is what actually occurred.
resolve(threadId: string): booleanreopen(threadId: string): booleanreanchor(threadId: string, spec: AnchorSpec): boolean— Re-point a thread — the manual rescue for an orphan, and the only way to move one.resolveAnchor(threadId: string, anchor: CommentAnchor): ResolvedAnchor— WHERE THE PIN GOES, AND WHETHER ITS SUBJECT STILL EXISTS.
=========================================================================== WHAT HAPPENS TO A COMMENT WHEN SOMEBODY DELETES ITS NODE
It survives. It becomes ORPHANED — visibly detached, still readable, still replyable, still listed — and it never, under any circumstance, disappears.
Argued from the user's side, because that is the only side that matters here. A thread is a conversation between PEOPLE. The node is merely what it was about. If Ben deletes a box and that silently destroys the eight-message design argument Ada and Chen had about it, then a routine edit has quietly deleted other people's work — work they cannot recover, cannot see was lost, and were never asked about. Worse, the comment is very often ABOUT the deletion ("why is this still here? we cut this in March") — so the delete would destroy precisely the discussion that authorised it. There is no version of that trade that is worth a tidier canvas.
The inverse mistake is just as bad: keeping the pin attached to nothing, hovering at the coordinates of a box that is gone. That is a lie with a timestamp on it. So the orphan is DRAWN DIFFERENTLY, NAMED DIFFERENTLY ("detached"), and grouped separately in the panel. The user is told, in the place they are looking, exactly what happened.
===========================================================================
AND WHY attached IS DERIVED RATHER THAN STORED — this is the subtle half
The obvious implementation writes an orphaned: true flag when the node dies. It is
wrong three ways, and every one of them bites:
- IT RACES. Marking a thread orphaned would be an OP, emitted by whichever peer
noticed the delete first. Two peers notice, two ops. Worse, the flag now merges
by LWW against a concurrent un-delete, and the flag can converge to a value that
contradicts the diagram it is meant to describe:
orphaned: trueon a thread whose node is manifestly right there. Derived state cannot contradict its source. Stored state can, and eventually will. - IT BREAKS UNDO. Ctrl-Z brings the node back. A stored flag would need a second op to un-orphan the thread, from some peer that thought to look, and until then the thread sits detached beside the node it is attached to. Derived, the thread RE-ATTACHES on the very next read, with zero ops and zero code.
- IT ASSUMES AN ANSWER THE CRDT CARD HAS NOT GIVEN YET. If they land on add-wins, a node this store had marked dead comes back. Deriving the state means BOTH answers are already handled and neither can be wrong: whatever the diagram says right now, the pin agrees with it. That is the only way to be robust to a decision that has not been made.
So: nothing is stored, nothing is broadcast, and attached is simply "is the entity
in the diagram, right now". The thread survives the delete BY NOT DEPENDING ON THE
ENTITY AT ALL.
thread(threadId: string): CommentThreadView | undefined— Assemble a thread for reading.
INCOMPLETE THREADS ARE INVISIBLE, NOT BROKEN. A store that crashed on that would be broken by a packet reorder; a store that rendered a half-thread would show a message from nobody, about nothing. So a thread without a head or an anchor simply does not exist yet, and it appears — whole, with every message that arrived early already in it — the moment its head lands. Nothing is dropped; the pieces just wait.
threads(options?: { includeResolved?: boolean }): CommentThreadView[]— Every readable thread, in a stable order identical on every peer.orphans(): CommentThreadView[]— Threads whose subject is gone. Derived, every time. Never stored, never synced.threadsFor(kind: 'node' | 'link', id: string): CommentThreadView[]— Threads anchored to a given entity — attached ones only.markRead(threadId: string): voidmarkUnread(threadId: string): voidtotalUnread(): number— Unread messages across every unresolved thread — the number a badge shows.mentionsOfViewer(): CommentThreadView[]— Threads thatonChange(cb: () => void): Unsub— Fired whenever the comment DATA or this viewer's READ STATE changes.dispose(): void
Was this page helpful?