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
+94
View File
@@ -0,0 +1,94 @@
require "test_helper"
class DocumentChannelTest < ActionCable::Channel::TestCase
setup do
@anna = create_user("anna@example.com")
@bob = create_user("bob@example.com")
@doc = create_document(@anna)
end
def b64(s) = Base64.strict_encode64(s)
def last = transmissions.last
test "rejects without access" do
stub_connection current_user: @bob
subscribe id: @doc.id
assert subscription.rejected?
end
test "init sends the JSON while there are no updates yet, then the updates" do
stub_connection current_user: @anna
subscribe id: @doc.id, key: "a"
assert subscription.confirmed?
assert_equal "owner", last["role"].to_s
assert_equal DOC_JSON, last["content"]
assert_empty last["updates"]
@doc.updates.create!(data: "\x01\x02")
subscribe id: @doc.id, key: "a"
assert_nil last["content"]
assert_equal [ b64("\x01\x02") ], last["updates"].map { _1["data"] }
end
test "an editor's update is stored and passed on to everyone" do
stub_connection current_user: @anna
subscribe id: @doc.id, key: "a"
assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do
perform :update, data: b64("abc")
end
assert_equal [ "abc" ], @doc.updates.pluck(:data)
end
test "a viewer (link) can't change anything but may send awareness" do
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
stub_connection current_user: nil
subscribe id: @doc.id, token: link.token, key: "v"
assert subscription.confirmed?
perform :update, data: b64("x")
perform :compact, state: b64("x"), ids: [], snapshot: DOC_JSON
assert_equal 0, @doc.updates.count
assert_broadcasts(DocumentChannel.broadcasting_for(@doc), 1) do
perform :awareness, data: b64("cursor")
end
end
test "compact replaces the listed updates by one state and stores the snapshot" do
a = @doc.updates.create!(data: "1")
b = @doc.updates.create!(data: "2")
keep = @doc.updates.create!(data: "3")
stub_connection current_user: @anna
subscribe id: @doc.id, key: "a"
snapshot = { version: 2, sheets: [ { id: "s9", name: "Neu", objects: [] } ] }.to_json
perform :compact, state: b64("12"), ids: [ a.id, b.id ], snapshot: snapshot
assert_equal %w[3 12], @doc.updates.order(:id).pluck(:data)
assert keep.reload
assert_equal snapshot, @doc.reload.content
end
test "invalid snapshots are ignored" do
stub_connection current_user: @anna
subscribe id: @doc.id, key: "a"
perform :snapshot, content: "[1]"
assert_equal DOC_JSON, @doc.reload.content
end
test "losing access mid-session" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
stub_connection current_user: @bob
subscribe id: @doc.id, key: "b"
@doc.document_memberships.delete_all
subscription.send(:revalidate)
assert_equal "revoked", last["type"]
perform :update, data: b64("late")
assert_equal 0, @doc.updates.count
end
test "role change is announced" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
stub_connection current_user: @bob
subscribe id: @doc.id, key: "b"
@doc.document_memberships.update_all(role: DocumentMembership.roles[:viewer])
subscription.send(:revalidate)
assert_equal({ "type" => "role", "role" => "viewer" }, last.slice("type", "role").transform_values(&:to_s))
end
end
+14 -37
View File
@@ -1,14 +1,16 @@
require "test_helper"
class DocumentsApiTest < ActionDispatch::IntegrationTest
include ActionCable::TestHelper
setup do
@anna = create_user("anna@example.com")
@bob = create_user("bob@example.com")
@doc = create_document(@anna)
end
def put_content(doc, base_version, headers: {})
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
def rename(doc, title, headers: {})
patch "/api/documents/#{doc.id}", params: { title: }, headers:, as: :json
end
test "listing and creating need an account" do
@@ -38,39 +40,6 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
assert_response :not_found
end
test "save with version check; stale base_version is a conflict" do
sign_in @anna
put_content(@doc, 1)
assert_response :success
assert_equal 2, json.dig("document", "version")
put_content(@doc, 1)
assert_response :conflict
assert_equal 2, json["version"]
end
test "known_version returns metadata only when unchanged" do
sign_in @anna
get "/api/documents/#{@doc.id}", params: { known_version: 1 }
assert json["unchanged"]
assert_nil json.dig("document", "content")
end
test "edit lock blocks saves from another tab" do
sign_in @anna
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
assert json.dig("lock", "mine")
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" }
assert_not json.dig("lock", "mine")
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :locked
delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
assert_response :success
end
test "share link without account: read with token; viewer cannot write" do
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
headers = { "X-Share-Token" => link.token }
@@ -79,7 +48,7 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
assert_equal "viewer", json.dig("document", "role")
get "/api/documents/#{@doc.id}", headers: headers
assert_response :success
put_content(@doc, 1, headers:)
rename(@doc, "Neu", headers:)
assert_response :forbidden
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
@@ -92,10 +61,18 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
get "/api/documents"
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
put_content(@doc, 1)
rename(@doc, "Von Bob")
assert_response :success
end
test "renaming tells everyone connected" do
sign_in @anna
assert_broadcast_on(DocumentChannel.broadcasting_for(@doc), { type: "meta", title: "Neu" }) do
rename(@doc, "Neu")
end
assert_equal "Neu", @doc.reload.title
end
test "owner deletes; a member only leaves" do
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
sign_in @bob
-11
View File
@@ -49,15 +49,4 @@ class DocumentTest < ActiveSupport::TestCase
DocumentMembership.grant!(document: @doc, user: bob, role: :viewer, source: :link)
assert_equal :editor, @doc.role_for(user: bob)
end
test "edit lock: one holder at a time, expires without heartbeat" do
assert @doc.acquire_lock!("1:tab-a", "Anna")
assert @doc.acquire_lock!("1:tab-a", "Anna"), "refreshing own lock"
assert_not @doc.acquire_lock!("2:tab-b", "Bob")
assert @doc.reload.locked_by_other?("2:tab-b")
travel Document::LOCK_TTL + 1.second do
assert @doc.acquire_lock!("2:tab-b", "Bob")
end
end
end