Live collaboration with Yjs over ActionCable

All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:41:02 +02:00
co-authored by Claude Opus 5.5
parent e14bada114
commit 7fce972fdc
27 changed files with 1685 additions and 413 deletions
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
@@ -0,0 +1,12 @@
module ApplicationCable
# The WebSocket knows the signed-in user from the same session cookie as
# the rest of the app. Anonymous connections are allowed: a share link
# token (a subscription parameter) can grant access on its own.
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = Session.find_by(id: cookies.signed[:session_id])&.user if cookies.signed[:session_id]
end
end
end
+114
View File
@@ -0,0 +1,114 @@
# Live collaboration on one document (Yjs). Every edit is a Yjs update: the
# server stores it and passes it on to everyone connected; it never needs
# to understand what's inside. Binary data travels Base64-encoded.
#
# From the browser (perform):
# update { data } an edit (editors only)
# awareness { data } cursor/selection/name, not stored
# compact { state, ids, snapshot } replace the listed updates by one
# state; snapshot = JSON of the document
# snapshot { content } JSON of the document, for export etc.
#
# To the browser:
# init { role, title, updates: [{ id, data }], content? }
# content (JSON) only while no update exists yet: the browser
# then builds the Yjs form and sends it as the first update
# update { id, data, from } from = the sending subscription
# awareness { data, from }
# compacted { removed: [ids], id }
# meta { title }
# role { role } access changed
# revoked access gone (or document deleted)
class DocumentChannel < ApplicationCable::Channel
def subscribed
@document = Document.find_by(id: params[:id])
@token = params[:token].presence
@key = params[:key].to_s[0, 64]
@role = @document&.role_for(user: current_user, token: @token)
return reject unless @role
stream_for @document, coder: ActiveSupport::JSON do |message|
relay(message)
end
transmit init_message
end
def update(data)
return unless editor?
bytes = decode(data["data"]) or return
row = @document.updates.create!(data: bytes, user: current_user)
@document.update_columns(content_updated_at: Time.current, updated_by_id: current_user&.id)
broadcast({ type: "update", id: row.id, data: data["data"], from: @key })
rescue ActiveRecord::RecordInvalid
transmit({ type: "error", error: "too_large" })
end
def awareness(data)
return unless data["data"].is_a?(String) && data["data"].bytesize < 64.kilobytes
broadcast({ type: "awareness", data: data["data"], from: @key })
end
def compact(data)
return unless editor?
state = decode(data["state"]) or return
ids = Array(data["ids"]).map(&:to_i)
row = nil
@document.with_lock do
@document.updates.where(id: ids).delete_all
row = @document.updates.create!(data: state, user: current_user)
store_snapshot(data["snapshot"])
end
broadcast({ type: "compacted", removed: ids, id: row.id })
end
def snapshot(data)
return unless editor?
store_snapshot(data["content"])
end
private
def editor? = Document.role_at_least?(@role, :editor)
def broadcast(message) = DocumentChannel.broadcast_to(@document, message)
def relay(message)
case message["type"]
when "revalidate" then revalidate
when "awareness" then transmit(message) unless message["from"] == @key
else transmit(message)
end
end
# Membership or link changed: is this connection still allowed in?
def revalidate
role = Document.find_by(id: @document.id)&.role_for(user: current_user, token: @token)
if role.nil?
@role = nil
transmit({ type: "revoked" })
stop_all_streams
elsif role != @role
@role = role
transmit({ type: "role", role: })
end
end
def init_message
updates = @document.updates.order(:id).map(&:as_message)
message = { type: "init", role: @role, title: @document.title, updates: }
message[:content] = @document.content if updates.empty?
message
end
def decode(b64)
return unless b64.is_a?(String)
Base64.strict_decode64(b64)
rescue ArgumentError
nil
end
def store_snapshot(json)
return unless json.is_a?(String) && json.bytesize <= Document::MAX_CONTENT_BYTES && Document.valid_snapshot?(json)
@document.update_columns(content: json)
end
end