Kotoba Collab lets people edit the same document, see participant cursors, and recover local drafts after a reconnect. Forms submit a revision that the server has accepted and saved.
Collaboration ships in Kotoba's Hex package. Import the optional bundle for editors that share a document.
JavaScript
Enable the optional implementation before connecting the LiveSocket:
import { Kotoba, registerCollaboration } from "kotoba"
import { enableCollaboration } from "kotoba/collab"
enableCollaboration(registerCollaboration)
// Use Kotoba in the LiveSocket's hooks as usual.For import maps, map kotoba/collab to kotoba-collab.esm.js. Both ESM and
CommonJS collaboration bundles are included. They use the core editor's
Lexical instance through the registration boundary, so Lexical is not
bundled a second time. Phoenix's JavaScript client is an external dependency,
as it is in a Phoenix application.
Supervision and storage
Start a document supervisor in your application's supervision tree:
{Kotoba.Collab.Supervisor,
name: MyApp.CollabSupervisor,
store: {MyApp.CollabStore, MyApp.Repo},
policy: [features: ~w(bold italic links lists)],
ttl: 30_000}The policy belongs to the shared document, rather than a browser. Every
accepted transaction passes node validation, nesting rules, URL checks,
feature checks, and an optional :validate callback that returns :ok or
{:error, reason}. Configure custom nodes in config :kotoba, nodes: [...]
and load their JavaScript modules in every editor that joins the document.
Implement Kotoba.Collab.Store. load/2 returns
{:ok, %{state: nil, content: server_owned_initial_content}} for a document
without editing state, or its complete saved state and content. The browser
cannot seed the shared document. save/5 must atomically compare the stored
{epoch, revision} with expected and save both state and content.
Return {:error, :conflict} for a stale writer. Returning :ok before a
durable commit would break the acknowledgement contract.
An Ecto application can keep both fields in a dedicated row. For example,
give MyApp.SharedDraft a string primary key, :epoch string, :revision
integer, :state map and :content of type Kotoba.Content. Its migration
uses :map for both :state and :content:
defmodule MyApp.CollabStore do
@behaviour Kotoba.Collab.Store
import Ecto.Query
alias MyApp.SharedDraft
def load(repo, id) do
case repo.get(SharedDraft, id) do
nil -> {:ok, %{state: nil, content: MyApp.Documents.initial_content(id)}}
row -> {:ok, %{state: row.state, content: row.content}}
end
end
def save(repo, id, expected, state, content) do
values = %{epoch: state["epoch"], revision: state["revision"],
state: state, content: content}
result = case expected do
nil ->
repo.insert_all(SharedDraft, [Map.put(values, :id, id)],
on_conflict: :nothing, conflict_target: [:id])
{epoch, revision} ->
query = from d in SharedDraft,
where: d.id == ^id and d.epoch == ^epoch and d.revision == ^revision
repo.update_all(query, set: Map.to_list(values))
end
case result do
{1, _} -> :ok
{0, _} -> {:error, :conflict}
end
rescue
error -> {:error, error}
end
endThis example omits timestamps. Add them to the inserted/updated values if your schema requires them. Authorize document access before opening or publishing it. The store's initial-content function must reject unknown documents if arbitrary creation is inappropriate for your application.
Kotoba.Collab.Store.Memory is supplied for development and tests. It
survives a document process restart, but loses everything on a node restart.
The repository's /collab demo uses this store.
Channel and permissions
Use an application channel so authorization remains in your domain:
defmodule MyAppWeb.CollabChannel do
use Kotoba.Collab.Channel, supervisor: MyApp.CollabSupervisor
def authorize(socket, document_id, action) do
user_id = socket.assigns.kotoba_collab_user["id"]
if MyApp.Documents.allowed?(user_id, document_id, action),
do: :ok, else: {:error, :forbidden}
end
end
defmodule MyAppWeb.CollabSocket do
use Phoenix.Socket
channel "kotoba:*", MyAppWeb.CollabChannel
def connect(_params, socket, _connect_info), do: {:ok, socket}
def id(_socket), do: nil
end
# In the endpoint:
socket "/kotoba/socket", MyAppWeb.CollabSocket,
websocket: [max_frame_size: 1_000_000]The channel verifies a signed, document-scoped token before assigning
kotoba_collab_user and invoking authorization. Tokens expire after five
minutes for new joins. Authorization runs on incoming messages and outgoing
document/presence broadcasts. A signed :read role cannot transact, even
when the browser enables contenteditable. Presence is separate from content
and never persisted in the document.
The shared editors of a page share one socket per socket URL, each with its
own channel on it, so a page of many shared editors opens one WebSocket. A
second editor of the same document on the page, or an editor past the
socket's channel limit, gets another socket. The limit is
Kotoba.Collab.token/3's :max_channels, 100 by default, the default
:max_channels_per_transport of the Phoenix versions that limit channels
(1.7.24 and later, 1.8.2 and later). If you set a lower
:max_channels_per_transport on the collaboration socket, pass the same
number as :max_channels.
LiveView and forms
Authorize the authenticated user before signing credentials:
collab = Kotoba.Collab.token(socket, document.id,
user: %{id: user.id, name: user.name, color: "#2563eb"}, role: :write)
socket = assign(socket, collab: collab)<.kotoba field={@form[:body]} id="body_editor" collab={@collab} />Use role: :read for viewers. readonly also locks the UI, but authorization
and the signed role enforce permissions. The browser renews credentials
through the LiveView every three minutes and after an expired join. Handle
kotoba:collab_token, check the editor/document against your socket assigns,
recheck access, then reply with fresh credentials:
def handle_event("kotoba:collab_token", %{"id" => "body_editor", "document_id" => id}, socket) do
user = socket.assigns.current_user
# Check id against socket.assigns.document.id and authorize again here.
credentials = Kotoba.Collab.token(socket, id,
user: %{id: user.id, name: user.name}, role: :write)
{:reply, credentials, socket}
endThe hidden content input holds the visible local document. It can include
unsent edits, so it is not proof that the authority accepted them. On submit,
the collaboration provider briefly locks editing, waits for all fields in
the form to drain, obtains an accepted revision, and then resubmits. Each
field posts a JSON revision in kotoba_collab[editor_id]. Publish that exact
revision through the authority:
def handle_event("save", %{"kotoba_collab" => %{"body_editor" => json}}, socket) do
# Authorize publishing this document independently of editing access.
with {:ok, revision} <- JSON.decode(json),
{:ok, content} <- Kotoba.Collab.content(MyApp.CollabSupervisor,
socket.assigns.document.id, revision),
{:ok, document} <- MyApp.Documents.publish(socket.assigns.document, content) do
{:noreply, assign(socket, document: document)}
else
_ -> {:noreply, put_flash(socket, :error, "The document could not be published")}
end
endThe journal can reconstruct an immutable accepted revision, even if another
author edited again before the submit reached LiveView. The application owns
publishing transactions and permissions. An offline form cannot submit an
accepted revision until it reconnects. Custom JavaScript save flows can call
document.getElementById("body_editor").kotobaCollab.flush().
Merge and history behavior
Transactions carry protocol/schema versions, a document epoch, a unique transaction ID, their base revision, and operations. Receipts make retries of the same transaction idempotent. The server orders insertions sharing an anchor by arrival; a later insertion goes immediately after that anchor. All participants replay that accepted order. This is a central authority protocol; independent peers cannot choose their own accepted histories.
Nodes and characters retain identities through moves, link wrapping and paragraph splits. Deleted characters remain as insertion anchors. Deletions name the characters actually observed, so they preserve unseen concurrent insertions. Formatting changes patch individual marks, allowing concurrent bold and italic to combine. Concurrent writes to the same attribute follow server order. New text inherits concurrent formatting from its anchor unless the local edit explicitly changed that attribute.
Undo belongs to the current participant's session. It sends inverse operations, rather than restoring a whole snapshot. Deletion tags and attribute/move versions prevent an inverse from removing another person's deletion or overwriting their later formatting/move. The local undo stack does not survive a page reload. Upload markers and suggestion previews stay local; an accepted attachment or suggestion becomes a normal transaction.
Offline and retention
Each tab keeps its accepted snapshot and immutable pending transactions in IndexedDB, scoped by origin, document, user and tab session. Reloads reuse that session. Reconnect joins with pending IDs, learns which already committed, and overlays the remainder on the latest server snapshot before resending. When local storage is unavailable, the status says edits are kept only in the tab. Kotoba does not provide an offline app shell or service worker.
A deleted target, revoked permission, changed epoch or invalid concurrent structure can require review. Kotoba freezes the draft and offers a JSON download; it preserves local work instead of silently replacing it. These conflicts do not currently have an interactive merge UI.
The v1 store retains the initial snapshot, tombstones, receipts and accepted journal. It does not compact them. The defaults limit a channel message to 1 MB, a transaction to 1,000 operations, and stored state to 16 MB. Size limits produce a rejected transaction, not a false acknowledgement. Plan retention and migrations before accepting unbounded documents. Removing tombstones or changing an epoch invalidates old pending work.
Named branches, peer replication and branch review are not supported.
The default owner registry is :global, suitable for a single node or a
fully connected BEAM cluster. Storage compare-and-swap rejects stale writers;
it does not promise availability across network partitions. A custom
registry module can implement name(supervisor_identity, document_id).
Persistence and channel authorization must be shared consistently across
nodes.
Backend edits use Kotoba.Collab.transact/4 with semantic operations through
the same authority. To retry idempotently, pass the original immutable
transaction map, including its original base revision and ID. Whole-document
Kotoba.Live.push_content/3 is refused by collaborative editors because it
cannot preserve pending identities. Ordinary editors keep their existing
behavior.