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.
@@ -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