Leaf.Collab.Room (Leaf v0.7.0)

Copy Markdown View Source

The process that holds a shared document: one room per document, applying every session's operations in one place and broadcasting what it applied.

This shape exists because the first version of its testbed kept the log in each LiveView's own assigns, which quietly made the page unable to show the thing it was built to show. A LiveView is torn down whenever its socket drops — routine on this host, where the socket often falls back to longpoll and a backgrounded tab stops polling — and it remounts with empty assigns. So a tab left open while you typed in another one came back blank, having missed every broadcast it was not alive for.

Holding the document and the operation log in one process fixes that and is also honest about what real collaboration needs: a session that joins late, or rejoins after a drop, has to be able to catch up. A page that only ever works for sessions present since the first keystroke is not collaborating.

Still no CRDT, on purpose — but not a bystander either. An arriving operation is rebased over everything that crossed it on the wire (rebase_over/2), so two people typing in different places both land. What rebasing cannot honestly place — overlapping edits, or an editor further behind than the history reaches — is refused rather than applied at a wrong offset, and the refused session is settled up from the room's copy of the document. Convergence without merge semantics: somebody's keystroke can lose, but the documents never quietly disagree.

Summary

Functions

Replace the document with a session's own copy of it.

Apply one session's operation to the shared document.

Returns a specification to start this module under a supervisor.

Everyone's caret except except, ready to hand to Leaf.

Write the document out now, without waiting for the timers.

Where this room's sessions hear about each other.

Register a session so its caret can be shown to everyone else.

Take a session out of the room deliberately.

Move a session's caret and selection.

Rebase op so it means the same thing after applied has happened.

Record what a session says it is holding.

Every session's last report.

Clear the document and the log, for everyone.

The current document and recent operations, for a session that just mounted.

Start a room for one document.

Stop the room gracefully.

Functions

adopt(room, session_id, markdown)

Replace the document with a session's own copy of it.

Used when an operation could not be placed. The editor's serialization is the canonical form — markdown round-tripped through HTML comes back normalized, so a room holding hand-written markdown (- ] rather than - ]) can never agree with an editor about lengths. Taking the editor's text ends that disagreement in the only direction that converges; pushing the room's text back at the editor does not, because the editor immediately re-normalizes it and disagrees again.

apply_operation(room, session_id, op)

Apply one session's operation to the shared document.

An operation built against a document of a different length than the room holds is REJECTED rather than applied. Its offsets refer to text that is not here, so applying it would put characters in the wrong places for everybody, and no later edit would undo that. The reply says applied: false and carries the room's document so the sender can be put back in step.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

cursors(room, except \\ nil)

Everyone's caret except except, ready to hand to Leaf.

flush_now(room)

Write the document out now, without waiting for the timers.

info(room)

Where this room's sessions hear about each other.

Asked of the room rather than repeated by the host: one place decides, and a host that gets it wrong would have sessions that never see each other with nothing obviously broken.

join(room, session_id, pid, identity \\ %{})

Register a session so its caret can be shown to everyone else.

identity is who the host says this is: %{name: "Sasha", color: "#e11d48"}. Both are optional and both have fallbacks, because a host may have no idea who is editing — a public page, a draft nobody has signed in for — and that should still work. A host with real users passes their names, and everyone else sees a name rather than a random identifier.

The room monitors pid, so a session that closes its tab — or drops its socket, which happens routinely on longpoll — has its caret removed without needing to say goodbye. A LiveView cannot be relied on to run cleanup on the way out, so being told is not an option.

leave(room, session_id)

Take a session out of the room deliberately.

The monitor covers a session whose process dies — a closed tab, a dropped socket. It cannot cover a LiveView that outlives its stay: one that switches documents without remounting keeps its pid alive, so without saying goodbye the person lingers here as a ghost caret for as long as the tab stays open. This is the goodbye. Idempotent: leaving a room twice, or one never joined, is a no-op.

The monitor itself stays until the process actually dies; a :DOWN for a session already gone removes nothing and costs nothing.

put_cursor(room, session_id, offset, anchor \\ nil)

Move a session's caret and selection.

offset is the caret and anchor is where the selection began; equal means nothing is selected. nil means the person is no longer in the editor.

rebase(op, applied)

Rebase op so it means the same thing after applied has happened.

Two people typing at once each describe their edit against the text as they saw it, which is not the text the other one's edit produced. Applying both verbatim puts the characters in different places for each of them — the documents come apart by exactly the length of what the other person typed.

The disjoint cases are exact. Overlapping edits — two people changing the same characters — have no answer that preserves both intentions, so what is kept is whatever each edit touched that the other did not, which at least leaves every session with the same text.

report(room, session_id, info)

Record what a session says it is holding.

Kept so two sessions can be compared: holding the same document while disagreeing about how many characters are in it is exactly the state that misplaces a caret, and neither session can see it on its own.

reports(room)

Every session's last report.

reset(room)

Clear the document and the log, for everyone.

snapshot(room)

The current document and recent operations, for a session that just mounted.

start_link(opts)

Start a room for one document.

One per document, started and supervised by the host — a vault would start one per note, on demand.

By default a room runs until stopped: it keeps the operation log warm, so a session that drops and comes straight back — routine on longpoll — rejoins where it was instead of resyncing. The cost is that browsing a large vault leaves a process per visited note. A host that would rather have rooms tidy themselves away passes :idle_after, and the room stops itself — flushing first — when it has been empty that long. Pair it with restart: :transient (or :temporary) in the child spec: under :permanent, the supervisor resurrects every room the moment it stops.

Options:

  • :name — how to reach it. Required in practice: every other function names the room it means.
  • :pubsub — your Phoenix.PubSub, how the sessions in this document hear about each other.
  • :document_id — what to call this document when talking to the store.
  • :initial_content — what an empty document contains. The store wins if it has anything, so this is the text for a document nobody has written yet.
  • :store — where the document lives; see Leaf.Collab.Store. Defaults to Leaf.Collab.Store.None, which keeps nothing.
  • :flush_after — how long a pause in the writing counts as finished, in milliseconds. Defaults to 2 seconds.
  • :flush_at_most_every — how long writing that never pauses may go unwritten. Defaults to 15 seconds.
  • :idle_after — how long a room may stand empty before stopping itself, in milliseconds. Defaults to :infinity: staying up is the default, see above.

Stop it rather than killing it — stop/1, or anything that delivers a graceful shutdown: the last thing a room does is write down what it was holding.

stop(room)

Stop the room gracefully.

The last thing it does is write down what it was holding, so this is the way to take a room down by hand — as opposed to killing it, which loses whatever had not flushed yet. With :idle_after set the room does this to itself when it has stood empty long enough.