yrby-rails 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG-rails.md +123 -0
- data/LICENSE +21 -0
- data/README.md +753 -0
- data/app/models/y/document.rb +182 -0
- data/app/models/y/document_update.rb +10 -0
- data/lib/generators/yrby/install/USAGE +11 -0
- data/lib/generators/yrby/install/install_generator.rb +44 -0
- data/lib/generators/yrby/install/templates/document_channel.rb +36 -0
- data/lib/generators/yrby/tables/tables_generator.rb +41 -0
- data/lib/generators/yrby/tables/templates/create_y_tables.rb +24 -0
- data/lib/y/action_cable/sync.rb +333 -0
- data/lib/y/action_cable.rb +23 -0
- data/lib/yrby/engine.rb +11 -0
- data/lib/yrby/rails/version.rb +7 -0
- data/lib/yrby-rails.rb +6 -0
- metadata +175 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One row per collaborative document, addressed two ways:
|
|
4
|
+
#
|
|
5
|
+
# key what a channel addresses: one opaque, unique string.
|
|
6
|
+
# Apps can supply their own ("room-42"), so nothing
|
|
7
|
+
# parses meaning out of a key.
|
|
8
|
+
# record + name which model attribute the document backs, where name
|
|
9
|
+
# is the attribute name ("body") — optional, one
|
|
10
|
+
# document per attribute per record, the same scheme as
|
|
11
|
+
# ActionText::RichText.
|
|
12
|
+
#
|
|
13
|
+
# When a binding exists and no key was supplied, the key derives as
|
|
14
|
+
# post/1/body. Either side can arrive first — a channel can write under a
|
|
15
|
+
# key before any binding exists — so `.for` adopts a key-only row whose
|
|
16
|
+
# key matches the derived one, converging both on one row.
|
|
17
|
+
#
|
|
18
|
+
# `state` holds the merged snapshot; the update rows are the uncompacted
|
|
19
|
+
# tail, so a load reads the snapshot plus whatever the tail currently
|
|
20
|
+
# holds. The models store CRDT state only: derived data (rendered HTML,
|
|
21
|
+
# search text) is the application's job, typically done in the channel's
|
|
22
|
+
# on_change.
|
|
23
|
+
class Y::Document < ActiveRecord::Base
|
|
24
|
+
self.table_name = "y_documents"
|
|
25
|
+
|
|
26
|
+
belongs_to :record, polymorphic: true, optional: true
|
|
27
|
+
has_many :updates, class_name: "Y::DocumentUpdate", dependent: :delete_all
|
|
28
|
+
|
|
29
|
+
validates :key, presence: true
|
|
30
|
+
# A bound row carries record + name. Checked on the columns, not the
|
|
31
|
+
# association: reading `record` constantizes record_type, which must not
|
|
32
|
+
# be a validity requirement.
|
|
33
|
+
validates :name, presence: true, if: :record_type
|
|
34
|
+
validates :record_type, presence: true, if: -> { name || record_id }
|
|
35
|
+
validates :record_id, presence: true, if: :record_type
|
|
36
|
+
before_validation :assign_default_key, on: :create
|
|
37
|
+
|
|
38
|
+
# How long the tail may grow before an append compacts it into state.
|
|
39
|
+
# Lower values compact more often; higher values leave more tail rows
|
|
40
|
+
# for each load to merge.
|
|
41
|
+
class_attribute :compact_every, instance_writer: false, default: 64
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def locate(key) = find_by(key: key)
|
|
45
|
+
|
|
46
|
+
def locate!(key)
|
|
47
|
+
find_by(key: key) || create_or_find_by!(key: key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The document bound to a record's attribute, created on first use.
|
|
51
|
+
# Find first (after the first call, every call is a read), then adopt:
|
|
52
|
+
# if a channel already appended under the key this binding derives, a
|
|
53
|
+
# key-only row exists whose key is taken — claiming it converges the two
|
|
54
|
+
# identities where a plain insert would collide on the key index.
|
|
55
|
+
#
|
|
56
|
+
# The insert can still lose a race it can't see: a channel creates the
|
|
57
|
+
# key-only row after adopt looked and before the insert lands, so
|
|
58
|
+
# create_or_find_by! collides on the key index — and its internal
|
|
59
|
+
# retry, which looks up by record + name, misses the key-only row and
|
|
60
|
+
# raises RecordNotFound. One more pass adopts the row that won.
|
|
61
|
+
def for(record, name)
|
|
62
|
+
attempts = 0
|
|
63
|
+
begin
|
|
64
|
+
find_by(record: record, name: name.to_s) ||
|
|
65
|
+
adopt(record, name) ||
|
|
66
|
+
create_or_find_by!(record: record, name: name.to_s)
|
|
67
|
+
rescue ActiveRecord::RecordNotFound
|
|
68
|
+
raise if (attempts += 1) > 1
|
|
69
|
+
|
|
70
|
+
retry
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The store contract for a sync channel, keyed by the transport key.
|
|
75
|
+
# Both skip the state blob (select(:id)): append never reads it, and
|
|
76
|
+
# load_state re-reads it fresh after the tail (see below), so neither
|
|
77
|
+
# should drag a potentially large snapshot over the wire per call.
|
|
78
|
+
def load_state(key) = select(:id).find_by(key: key)&.load_state
|
|
79
|
+
|
|
80
|
+
def append(key, update)
|
|
81
|
+
(select(:id).find_by(key: key) || create_or_find_by!(key: key)).append(update)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def adopt(record, name)
|
|
87
|
+
document = find_by(key: derived_key(record, name), record_type: nil)
|
|
88
|
+
document&.update!(record: record, name: name.to_s)
|
|
89
|
+
document
|
|
90
|
+
rescue ActiveRecord::RecordNotUnique
|
|
91
|
+
find_by(record: record, name: name.to_s) # a racer adopted or created it first
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def derived_key(record, name)
|
|
95
|
+
# polymorphic_name is what Rails writes to record_type, so adoption
|
|
96
|
+
# and assign_default_key derive the same string under any setting of
|
|
97
|
+
# store_full_class_name.
|
|
98
|
+
"#{record.class.polymorphic_name.underscore}/#{record.id}/#{name}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Record one delta. The trigger is at-or-over rather than an exact
|
|
103
|
+
# multiple (concurrent appends can jump past one) and counts only clean
|
|
104
|
+
# rows: pending rows never satisfy it, so a quarantined gap doesn't
|
|
105
|
+
# retrigger compaction on every append.
|
|
106
|
+
def append(bytes)
|
|
107
|
+
updates.create!(payload: bytes)
|
|
108
|
+
compact! if updates.where(pending: false).count >= compact_every
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The merged document: state plus the whole tail. The tail is read first
|
|
112
|
+
# and the snapshot re-read after it, both straight from the database: a
|
|
113
|
+
# compaction committing between the two reads then hands us rows already
|
|
114
|
+
# folded into the fresh snapshot — an idempotent double-apply — where
|
|
115
|
+
# the reverse order could pair a pre-compaction snapshot with an empty
|
|
116
|
+
# tail and omit committed changes. Quarantined rows are applied too —
|
|
117
|
+
# the output goes through compacted_state_update, which is gap-free by
|
|
118
|
+
# construction, so an unhealed gap contributes nothing while a gap
|
|
119
|
+
# healed by a newer tail row is served immediately instead of waiting
|
|
120
|
+
# for the next compaction.
|
|
121
|
+
def load_state
|
|
122
|
+
tail = Y::DocumentUpdate.where(document_id: id).pluck(:payload)
|
|
123
|
+
snapshot = self.class.where(id: id).pick(:state)
|
|
124
|
+
return snapshot if tail.empty?
|
|
125
|
+
|
|
126
|
+
doc = Y::Doc.new
|
|
127
|
+
doc.apply_update(snapshot) if snapshot
|
|
128
|
+
tail.each { |payload| doc.apply_update(payload) }
|
|
129
|
+
doc.compacted_state_update
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Compact the tail into state. The row lock serializes racing
|
|
133
|
+
# compactions; a delta landing mid-compaction isn't in `rows`, so it
|
|
134
|
+
# survives the delete and compacts next time.
|
|
135
|
+
#
|
|
136
|
+
# A causally-gapped batch is never compacted whole and never deleted:
|
|
137
|
+
# state would silently exclude the gap, destroying the only healable
|
|
138
|
+
# copy. If the clean rows alone merge gap-free, they compact and only
|
|
139
|
+
# the gap is quarantined (marked pending); rows that causally build on
|
|
140
|
+
# quarantined content quarantine with it.
|
|
141
|
+
def compact!
|
|
142
|
+
with_lock do
|
|
143
|
+
rows = updates.pluck(:id, :payload, :pending)
|
|
144
|
+
next if rows.empty?
|
|
145
|
+
|
|
146
|
+
unless compact_rows(rows)
|
|
147
|
+
clean = rows.reject { |_, _, pending| pending }
|
|
148
|
+
remainder = compact_rows(clean) ? rows - clean : rows
|
|
149
|
+
updates.where(id: remainder.map(&:first)).update_all(pending: true)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
# Compact state + the given rows if the merge is gap-free: writes state,
|
|
157
|
+
# deletes the rows, returns true. Leaves everything untouched and returns
|
|
158
|
+
# false on a gap.
|
|
159
|
+
def compact_rows(rows) # rubocop:disable Naming/PredicateMethod -- compacts AND reports
|
|
160
|
+
return true if rows.empty?
|
|
161
|
+
|
|
162
|
+
doc = Y::Doc.new
|
|
163
|
+
doc.apply_update(state) if state
|
|
164
|
+
rows.each { |_, payload, _| doc.apply_update(payload) }
|
|
165
|
+
return false if doc.pending?
|
|
166
|
+
|
|
167
|
+
update!(state: doc.compacted_state_update)
|
|
168
|
+
updates.where(id: rows.map(&:first)).delete_all
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Derives post/1/body from the polymorphic record_type — Rails stores
|
|
173
|
+
# the polymorphic_name there, which is base_class-derived, so STI
|
|
174
|
+
# subclasses share a key. Namespaces keep their slash
|
|
175
|
+
# (admin/post/1/body); flattening would collide Admin::Post with
|
|
176
|
+
# AdminPost. record_id is nil for an unsaved record at validation time
|
|
177
|
+
# (autosave runs after), so it guards too. Key-only documents supply
|
|
178
|
+
# their own key.
|
|
179
|
+
def assign_default_key
|
|
180
|
+
self.key ||= record_type && record_id && "#{record_type.underscore}/#{record_id}/#{name}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One CRDT delta per row: the document's uncompacted tail, compacted into
|
|
4
|
+
# Y::Document#state and deleted at the compaction threshold. pending marks a
|
|
5
|
+
# causally-gapped row, quarantined until its dependency arrives.
|
|
6
|
+
class Y::DocumentUpdate < ActiveRecord::Base
|
|
7
|
+
self.table_name = "y_document_updates"
|
|
8
|
+
|
|
9
|
+
belongs_to :document, class_name: "Y::Document"
|
|
10
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates a DocumentChannel speaking the y-websocket protocol, backed by
|
|
3
|
+
the gem's document storage (Y::Document + Y::DocumentUpdate), and the
|
|
4
|
+
migration for its tables.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
bin/rails generate yrby:install
|
|
8
|
+
|
|
9
|
+
This will create:
|
|
10
|
+
app/channels/document_channel.rb
|
|
11
|
+
db/migrate/XXXXXXXX_create_y_tables.rb
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "generators/yrby/tables/tables_generator"
|
|
5
|
+
|
|
6
|
+
module Yrby
|
|
7
|
+
module Generators
|
|
8
|
+
# `bin/rails generate yrby:install` — a DocumentChannel speaking the
|
|
9
|
+
# y-websocket protocol over the gem's document storage, plus the storage
|
|
10
|
+
# migration (via yrby:tables). The models ship in the gem; only the
|
|
11
|
+
# migration lands in the app.
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
def create_channel
|
|
16
|
+
template "document_channel.rb", "app/channels/document_channel.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_tables
|
|
20
|
+
invoke "yrby:tables"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show_next_steps
|
|
24
|
+
say <<~NEXT
|
|
25
|
+
|
|
26
|
+
Next steps:
|
|
27
|
+
|
|
28
|
+
1. Authorize document access: implement `authorized?` in
|
|
29
|
+
app/channels/document_channel.rb (it denies everyone until you do).
|
|
30
|
+
2. bin/rails db:migrate
|
|
31
|
+
3. Install the yrby-client npm package and connect an editor:
|
|
32
|
+
|
|
33
|
+
import { ActionCableProvider } from "yrby-client"
|
|
34
|
+
const provider = new ActionCableProvider(doc, consumer,
|
|
35
|
+
"DocumentChannel", { id: documentId })
|
|
36
|
+
provider.connect()
|
|
37
|
+
|
|
38
|
+
The README's Editors section links working integrations for
|
|
39
|
+
Tiptap, Lexxy, Rhino Editor, and CodeMirror.
|
|
40
|
+
NEXT
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Collaborative documents over Action Cable: one channel speaking the
|
|
4
|
+
# y-websocket protocol (sync plus presence). Storage is Y::Document +
|
|
5
|
+
# Y::DocumentUpdate; a document is created on its first change and its
|
|
6
|
+
# history goes with it when it is destroyed. Point on_load/on_change
|
|
7
|
+
# elsewhere to swap storage.
|
|
8
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
9
|
+
include Y::ActionCable
|
|
10
|
+
|
|
11
|
+
# Rebuild a document from durable storage (nil means a brand-new document).
|
|
12
|
+
on_load { |key| Y::Document.load_state(key) }
|
|
13
|
+
|
|
14
|
+
# Record each CRDT delta durably. Runs before the change is acknowledged
|
|
15
|
+
# or broadcast; if this raises, the change is neither acked nor relayed,
|
|
16
|
+
# and yrby-client retries it.
|
|
17
|
+
on_change { |key, update| Y::Document.append(key, update) }
|
|
18
|
+
|
|
19
|
+
def subscribed
|
|
20
|
+
return reject unless authorized?(params[:id])
|
|
21
|
+
|
|
22
|
+
sync_subscribed(params[:id])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def receive(data) = sync_receive(data, params[:id])
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Everyone is denied until you fill this in. Wire it to your app's auth:
|
|
30
|
+
# identify current_user on the cable connection, then check they may read
|
|
31
|
+
# and write this document. Don't lean on on_change raising for access
|
|
32
|
+
# control — that path exists for store failures.
|
|
33
|
+
def authorized?(_document_key)
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Yrby
|
|
7
|
+
module Generators
|
|
8
|
+
# `bin/rails generate yrby:tables` — the migration for the gem-owned
|
|
9
|
+
# document models (Y::Document + Y::DocumentUpdate). Invoked by
|
|
10
|
+
# yrby:install, and by other gems building on the same storage.
|
|
11
|
+
#
|
|
12
|
+
# Template notes (kept here, not in the emitted migration): state is
|
|
13
|
+
# 4.gigabytes - 1 (longblob on MySQL — a compacted snapshot is the whole
|
|
14
|
+
# document; a 16 MB cap would break compaction) and payload is
|
|
15
|
+
# 16.megabytes - 1 (one update can carry a big paste or a client's
|
|
16
|
+
# accumulated offline edits — the 64 KB default blob is too small).
|
|
17
|
+
# The partial unique index's WHERE only keeps
|
|
18
|
+
# key-only rows out of the index: uniqueness holds without it, since
|
|
19
|
+
# unique indexes treat NULLs as distinct on every supported database,
|
|
20
|
+
# and MySQL drops the predicate harmlessly. y_document_updates indexes
|
|
21
|
+
# (document_id, pending) instead of bare document_id: the prefix
|
|
22
|
+
# serves the tail and foreign-key lookups, and the pair serves the
|
|
23
|
+
# clean-row count every append runs.
|
|
24
|
+
class TablesGenerator < ::Rails::Generators::Base
|
|
25
|
+
include ActiveRecord::Generators::Migration
|
|
26
|
+
|
|
27
|
+
source_root File.expand_path("templates", __dir__)
|
|
28
|
+
|
|
29
|
+
def create_migration_file
|
|
30
|
+
migration_template "create_y_tables.rb",
|
|
31
|
+
File.join(db_migrate_path, "create_y_tables.rb")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def migration_version
|
|
37
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateYTables < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :y_documents do |t|
|
|
6
|
+
t.string :key, null: false, index: { unique: true }
|
|
7
|
+
t.references :record, polymorphic: true, null: true, index: false
|
|
8
|
+
t.string :name
|
|
9
|
+
t.binary :state, limit: 4.gigabytes - 1
|
|
10
|
+
t.timestamps
|
|
11
|
+
t.index %i[record_type record_id name], unique: true,
|
|
12
|
+
where: "record_type IS NOT NULL",
|
|
13
|
+
name: "index_y_documents_on_record_and_name"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :y_document_updates do |t|
|
|
17
|
+
t.references :document, null: false, foreign_key: { to_table: :y_documents }, index: false
|
|
18
|
+
t.binary :payload, null: false, limit: 16.megabytes - 1
|
|
19
|
+
t.boolean :pending, null: false, default: false
|
|
20
|
+
t.datetime :created_at, null: false
|
|
21
|
+
t.index %i[document_id pending]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "y"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
7
|
+
# y-websocket protocol over ActionCable.
|
|
8
|
+
#
|
|
9
|
+
# Include this module in an ActionCable channel to sync Y.js documents
|
|
10
|
+
# (and awareness/presence) with browser clients. Messages are the standard
|
|
11
|
+
# y-protocols binary messages, base64-encoded in a JSON envelope:
|
|
12
|
+
#
|
|
13
|
+
# { "update" => "<base64 bytes>", "id" => 42 } # client -> server
|
|
14
|
+
# { "update" => "<base64 bytes>" } # server -> subscribers
|
|
15
|
+
# { "ack" => 42 } # server -> sender
|
|
16
|
+
#
|
|
17
|
+
# Example:
|
|
18
|
+
# class DocumentChannel < ApplicationCable::Channel
|
|
19
|
+
# include Y::ActionCable::Sync
|
|
20
|
+
#
|
|
21
|
+
# on_load { |key| Document.find_by(key: key)&.content }
|
|
22
|
+
# # on_change runs in the channel instance's context, so instance methods
|
|
23
|
+
# # (current_user, params, ...) are available:
|
|
24
|
+
# on_change { |key, update| Document.record!(key, update, by: current_user) }
|
|
25
|
+
#
|
|
26
|
+
# def subscribed
|
|
27
|
+
# sync_subscribed params[:id]
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def receive(data)
|
|
31
|
+
# sync_receive(data)
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# There is no unsubscribe hook: the server keeps no per-connection document or
|
|
36
|
+
# presence state, so a disconnect needs no server-side cleanup.
|
|
37
|
+
#
|
|
38
|
+
# The concern is store-backed: every document update is validated against
|
|
39
|
+
# `on_load`, recorded through `on_change`, and only then broadcast.
|
|
40
|
+
# No authoritative document state is kept in ActionCable process memory.
|
|
41
|
+
module Sync
|
|
42
|
+
# Frame kinds we act on, from Y.message_kind. Its other codes (0 for a
|
|
43
|
+
# drop: malformed/truncated/multi-message/unknown, and 4 for an awareness
|
|
44
|
+
# query) fall through to a no-op in the dispatch below.
|
|
45
|
+
MSG_KIND_SYNC_STEP1 = 1
|
|
46
|
+
MSG_KIND_UPDATE = 2
|
|
47
|
+
MSG_KIND_AWARENESS = 3
|
|
48
|
+
|
|
49
|
+
# Default incoming-frame size cap (decoded bytes). Generous enough for a
|
|
50
|
+
# large initial SyncStep2, small enough to bound a single message's
|
|
51
|
+
# allocation/parse cost. Override per channel with `max_frame_bytes`.
|
|
52
|
+
DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
def self.included(base)
|
|
55
|
+
base.extend(ClassMethods)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module ClassMethods
|
|
59
|
+
# Load persisted document state. Called once per key with (key); return a
|
|
60
|
+
# binary Y.js update (or nil for a fresh document). Runs in the channel
|
|
61
|
+
# instance's context (instance_exec).
|
|
62
|
+
def on_load(&block)
|
|
63
|
+
@on_load = block if block
|
|
64
|
+
return @on_load if defined?(@on_load) && @on_load
|
|
65
|
+
|
|
66
|
+
superclass.respond_to?(:on_load) ? superclass.on_load : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Record every document change durably before it is applied or
|
|
70
|
+
# distributed. Called synchronously with (key, update), where update is
|
|
71
|
+
# the exact CRDT delta. If the block raises, the change is rejected:
|
|
72
|
+
# neither acknowledged nor broadcast to other subscribers.
|
|
73
|
+
#
|
|
74
|
+
# Runs in the channel instance's context (instance_exec). Fires from within
|
|
75
|
+
# sync_receive.
|
|
76
|
+
def on_change(&block)
|
|
77
|
+
@on_change = block if block
|
|
78
|
+
return @on_change if defined?(@on_change) && @on_change
|
|
79
|
+
|
|
80
|
+
superclass.respond_to?(:on_change) ? superclass.on_change : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Maximum size, in decoded bytes, of an incoming document/awareness frame.
|
|
84
|
+
# Oversized frames are dropped before base64 decode and before native
|
|
85
|
+
# parsing, so a client can't force huge allocations/CPU (a DoS vector).
|
|
86
|
+
# Defaults to DEFAULT_MAX_FRAME_BYTES; set to nil to disable the cap.
|
|
87
|
+
def max_frame_bytes(bytes = :__unset__)
|
|
88
|
+
# Combined reader/writer; the sentinel keeps nil a real value (disables the cap).
|
|
89
|
+
@max_frame_bytes = bytes unless bytes == :__unset__
|
|
90
|
+
return @max_frame_bytes if defined?(@max_frame_bytes)
|
|
91
|
+
|
|
92
|
+
superclass.respond_to?(:max_frame_bytes) ? superclass.max_frame_bytes : DEFAULT_MAX_FRAME_BYTES
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Call from `subscribed`. Streams broadcasts for this document and
|
|
97
|
+
# transmits the server's opening handshake (SyncStep1 from the store).
|
|
98
|
+
def sync_subscribed(key)
|
|
99
|
+
@sync_key = key.to_s
|
|
100
|
+
sync_validate_required_hooks!
|
|
101
|
+
|
|
102
|
+
# The document stream is never whisper-enabled; under AnyCable we also
|
|
103
|
+
# subscribe an awareness stream with `whisper: true`, scoping the client-to-
|
|
104
|
+
# client path to ephemeral presence rather than the durable document stream.
|
|
105
|
+
stream_from sync_stream_name
|
|
106
|
+
stream_from sync_awareness_stream_name, whisper: true if respond_to?(:whispers_to)
|
|
107
|
+
sync_transmit(sync_load_doc.sync_step1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Call from `receive`. Applies the client's message, replies directly
|
|
111
|
+
# when the protocol calls for it, and relays document/awareness changes
|
|
112
|
+
# to the other subscribers.
|
|
113
|
+
#
|
|
114
|
+
# Reliable delivery: document updates carry an "id", and the server replies
|
|
115
|
+
# `{ "ack" => id }` once the update has been durably recorded. A
|
|
116
|
+
# causally-gapped update is not acked; it gets a resync instead, so the
|
|
117
|
+
# client retransmits until the update lands.
|
|
118
|
+
def sync_receive(data, key = nil)
|
|
119
|
+
# Pass `key` (params[:id]) when your transport doesn't keep the channel
|
|
120
|
+
# instance alive across actions. Under AnyCable each RPC command gets a
|
|
121
|
+
# fresh channel, so instance variables set in `subscribed` are gone here.
|
|
122
|
+
@sync_key = key.to_s if key
|
|
123
|
+
|
|
124
|
+
encoded = data.is_a?(Hash) ? data["update"] : nil
|
|
125
|
+
return unless encoded.is_a?(String)
|
|
126
|
+
|
|
127
|
+
# Optional client-supplied id for reliable delivery (see sync_send_ack).
|
|
128
|
+
# data is known to be a Hash here (encoded came from it above).
|
|
129
|
+
id = data["id"]
|
|
130
|
+
|
|
131
|
+
# Frame-size cap: drop oversized frames before decoding (the encoded form
|
|
132
|
+
# is ~4/3 the decoded size) and again after, so a client can't force large
|
|
133
|
+
# base64 decodes / native parses / merges. A dropped frame is never acked,
|
|
134
|
+
# and there is no protocol NACK, so a legitimate oversized update is
|
|
135
|
+
# retransmitted indefinitely. Log the drop so it is at least findable.
|
|
136
|
+
cap = self.class.max_frame_bytes
|
|
137
|
+
if cap && encoded.bytesize > (cap * 4 / 3) + 4
|
|
138
|
+
sync_log_drop(:warn, "encoded #{encoded.bytesize}B exceeds max_frame_bytes #{cap}B", id)
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
bytes = Base64.strict_decode64(encoded)
|
|
144
|
+
rescue ArgumentError
|
|
145
|
+
sync_log_drop(:debug, "not valid base64", id) # garbage or a probe, rarely a real client
|
|
146
|
+
return # ignore the frame and keep the connection
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if cap && bytes.bytesize > cap
|
|
150
|
+
sync_log_drop(:warn, "decoded #{bytes.bytesize}B exceeds max_frame_bytes #{cap}B", id)
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sync_send_ack(id, sync_handle_frame(encoded, bytes))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
# Ask this connection's client to resync: re-send SyncStep1 carrying the
|
|
160
|
+
# server's current (gap-free) state vector. The client replies SyncStep2
|
|
161
|
+
# with everything the server is missing, delivered as one causally-complete
|
|
162
|
+
# delta, which heals the gap that triggered the resync.
|
|
163
|
+
def sync_request_resync(doc)
|
|
164
|
+
sync_transmit(doc.sync_step1)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Reliable delivery: acknowledge an accepted update back to the sending
|
|
168
|
+
# connection. An ack-aware client tags each outgoing update with an "id"
|
|
169
|
+
# and retains it until the matching `{ "ack" => id }` returns, retransmitting
|
|
170
|
+
# on a timer or reconnect; idempotent CRDT apply makes resends free. Acks
|
|
171
|
+
# are sent only after the update has been durably recorded, or when a retry
|
|
172
|
+
# is already present in the durable store.
|
|
173
|
+
def sync_send_ack(id, outcome)
|
|
174
|
+
return if id.nil?
|
|
175
|
+
return unless %i[recorded applied].include?(outcome)
|
|
176
|
+
|
|
177
|
+
# The braces are required: a bare hash would bind to transmit's `via:`
|
|
178
|
+
# keyword instead of its positional data argument.
|
|
179
|
+
transmit({ "ack" => id })
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Single broadcast point so relay semantics live in one place and tests can
|
|
183
|
+
# observe distribution. Store-backed streams intentionally echo to the
|
|
184
|
+
# sender; applying the same CRDT update twice is a no-op.
|
|
185
|
+
def sync_distribute(encoded)
|
|
186
|
+
ActionCable.server.broadcast(sync_stream_name, sync_envelope(encoded))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Transmit raw protocol bytes to this connection.
|
|
190
|
+
def sync_transmit(bytes)
|
|
191
|
+
transmit(sync_envelope(Base64.strict_encode64(bytes)))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def sync_envelope(encoded)
|
|
195
|
+
{ "update" => encoded }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Override in the channel to add identifying context to dropped-frame logs --
|
|
199
|
+
# a user id, a connection id, a request id. Return a short string (or nil for
|
|
200
|
+
# none); it is appended to the log line. Default: no extra context.
|
|
201
|
+
def sync_log_context
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Surface a dropped frame through the channel logger. Drops are otherwise
|
|
206
|
+
# invisible (no ack, no broadcast); an oversized legitimate update is never
|
|
207
|
+
# acked and the client retransmits it forever, so make it findable. Names the
|
|
208
|
+
# document key, the reliable-delivery id when present, and whatever
|
|
209
|
+
# sync_log_context returns, so a drop can be tied to a specific document,
|
|
210
|
+
# update, and connection.
|
|
211
|
+
def sync_log_drop(level, reason, id = nil)
|
|
212
|
+
logger.public_send(level) do
|
|
213
|
+
parts = ["key=#{@sync_key.inspect}"]
|
|
214
|
+
parts << "id=#{id}" unless id.nil?
|
|
215
|
+
# A broken context hook must surface, not take down frame handling.
|
|
216
|
+
context = begin
|
|
217
|
+
sync_log_context
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
"log-context-error=#{e.class}"
|
|
220
|
+
end
|
|
221
|
+
parts << context if context
|
|
222
|
+
"[yrby] dropped frame (#{parts.join(" ")}): #{reason}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# This concern acks updates as durably recorded, so it must have both a
|
|
227
|
+
# loader (to rebuild the doc and detect causal gaps) and a recorder (to
|
|
228
|
+
# actually persist before acking). Fail closed rather than silently acking
|
|
229
|
+
# and broadcasting updates that were never stored, which a cold load or
|
|
230
|
+
# reconnect would then lose.
|
|
231
|
+
def sync_validate_required_hooks!
|
|
232
|
+
missing = []
|
|
233
|
+
missing << :on_load unless self.class.on_load
|
|
234
|
+
missing << :on_change unless self.class.on_change
|
|
235
|
+
return if missing.empty?
|
|
236
|
+
|
|
237
|
+
raise Y::Error,
|
|
238
|
+
"Y::ActionCable::Sync requires #{missing.join(" and ")}. Updates are acked as " \
|
|
239
|
+
"durably recorded; without a loader and recorder, an ack would claim a persistence " \
|
|
240
|
+
"that never happened, and a cold load would lose the edit."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Fail closed when no document key is set (typically: AnyCable rebuilt the
|
|
244
|
+
# channel instance and the app forgot to pass `key` to sync_receive).
|
|
245
|
+
# Proceeding would record under nil, broadcast to a stream nobody
|
|
246
|
+
# subscribes to, and still ack — the client believes the edit was
|
|
247
|
+
# delivered when it reached no one.
|
|
248
|
+
def sync_validate_key!
|
|
249
|
+
return unless @sync_key.nil? || @sync_key.empty?
|
|
250
|
+
|
|
251
|
+
raise Y::Error,
|
|
252
|
+
"Y::ActionCable::Sync has no document key. Call sync_subscribed(key) in " \
|
|
253
|
+
"subscribed, and pass the key to sync_receive(data, key) when the transport " \
|
|
254
|
+
"doesn't keep the channel instance alive across actions (e.g. AnyCable)."
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Stateless per message: any process can handle any document. A client's
|
|
258
|
+
# SyncStep1 is answered from the store, document changes are recorded durably
|
|
259
|
+
# before relay and then broadcast, and awareness is relayed best-effort.
|
|
260
|
+
# Echoing back to the sender is harmless, since the CRDT apply is idempotent.
|
|
261
|
+
#
|
|
262
|
+
# Returns an outcome symbol for the reliable-delivery ack: :recorded when a
|
|
263
|
+
# document update was durably recorded and relayed, :gap when it was
|
|
264
|
+
# rejected for a resync, :noop for everything else.
|
|
265
|
+
def sync_handle_frame(encoded, bytes)
|
|
266
|
+
sync_validate_required_hooks!
|
|
267
|
+
sync_validate_key!
|
|
268
|
+
|
|
269
|
+
case Y.message_kind(bytes)
|
|
270
|
+
when MSG_KIND_SYNC_STEP1
|
|
271
|
+
result = sync_load_doc.handle_sync_message(bytes)
|
|
272
|
+
sync_transmit(result[2])
|
|
273
|
+
:noop
|
|
274
|
+
when MSG_KIND_UPDATE
|
|
275
|
+
update = Y.update_from_message(bytes)
|
|
276
|
+
return :noop unless update
|
|
277
|
+
|
|
278
|
+
# Rebuild from the store (O(history) per update; snapshot in on_load if
|
|
279
|
+
# that cost bites).
|
|
280
|
+
doc = sync_load_doc
|
|
281
|
+
|
|
282
|
+
# Don't record a causally-incomplete update; resync instead so the gap
|
|
283
|
+
# heals as one complete delta.
|
|
284
|
+
unless doc.update_ready?(update)
|
|
285
|
+
sync_request_resync(doc)
|
|
286
|
+
return :gap
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# A lost-ack retry: already recorded, so skip on_change — but DO
|
|
290
|
+
# re-broadcast. If the first attempt died between record and broadcast,
|
|
291
|
+
# this retry is the only path left to the live subscribers. Duplicate
|
|
292
|
+
# broadcasts are free (CRDT apply is idempotent).
|
|
293
|
+
unless doc.update_advances?(update)
|
|
294
|
+
sync_distribute(encoded)
|
|
295
|
+
return :applied
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
sync_record_change(update) # record before relay
|
|
299
|
+
sync_distribute(encoded)
|
|
300
|
+
:recorded
|
|
301
|
+
when MSG_KIND_AWARENESS
|
|
302
|
+
sync_distribute(encoded)
|
|
303
|
+
:noop
|
|
304
|
+
else
|
|
305
|
+
:noop
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Build a fresh document from the durable store (on_load). Callers validate
|
|
310
|
+
# the hooks first, so on_load is present; a nil state means a fresh document.
|
|
311
|
+
def sync_load_doc
|
|
312
|
+
doc = Y::Doc.new
|
|
313
|
+
state = instance_exec(@sync_key, &self.class.on_load)
|
|
314
|
+
doc.apply_update(state) if state
|
|
315
|
+
doc
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def sync_stream_name
|
|
319
|
+
"yrby:#{@sync_key}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def sync_awareness_stream_name
|
|
323
|
+
"#{sync_stream_name}:awareness"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Invoke the on_change recorder in this channel instance's context
|
|
327
|
+
# (instance_exec) so it can reach the channel's own methods. Mirrors how
|
|
328
|
+
# sync_load_doc fetches and runs on_load.
|
|
329
|
+
def sync_record_change(update)
|
|
330
|
+
instance_exec(@sync_key, update, &self.class.on_change)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|