yrb-lite 0.1.0.beta3 → 0.1.0.beta5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdc68c7b4936a2e6598441f2def20050e456a543f44de6ecd35aeb1a894cdcc7
4
- data.tar.gz: 5a5ffbf2c1df1fc41d7eba424579459587d46e1e38db0606b79efefb5d5321c0
3
+ metadata.gz: 36bbe7531107d8654bf93ad29a1b3be40493b9f3a1172a3d478c39b75865f86d
4
+ data.tar.gz: 4175439a46c097b42c1316f44a6f43b4456db4977070072b7036a14e1bb5a64f
5
5
  SHA512:
6
- metadata.gz: 00bff87ec9b51a46f7bee55e838ef629531c0f4faaa43400f2447055932fefc4d8ce597c15724b98e384bbd5f119c881ebf1f85f2b2bab614ff6d3e00d570eaa
7
- data.tar.gz: 0dff053de27327d7517bdb83b850ee45c977d38f1556ee38210e8e3431da19937c1cc4e22201da2242ca249cee1a1647b8f0ec5dd1fe50ed75fb2d78743a4947
6
+ metadata.gz: da8fe865e590fd0c728f005c0a6bd040e8fca9424edf93d2f2bed4f0c88560c019451ade3c3bd9067ec1375a0c80fc02af55cd013901c8026510aab6c91d5bf1
7
+ data.tar.gz: 9cfca9a9430b4317041d748efc2d5a1116d97145f1d851a036bf466e159f0f87ef3c615ece560744bcfe78760efc8f08870be838341605c23792f406900acd44
data/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.0.beta5] - 2026-06-18
10
+
11
+ ### Changed
12
+
13
+ - **Breaking:** the ActionCable integration has been extracted into a separate
14
+ gem, [`yrb-lite-actioncable`](https://rubygems.org/gems/yrb-lite-actioncable).
15
+ `yrb-lite` is now a standalone y-crdt wrapper: CRDT documents, awareness, and
16
+ the y-websocket sync protocol primitives, with no Rails/ActionCable coupling
17
+ (mirrors the `y-rb` / `yrb-actioncable` split). The `base64` runtime
18
+ dependency moved with it.
19
+
20
+ ### Migration
21
+
22
+ - Using `YrbLite::Sync`? Add `gem "yrb-lite-actioncable"` and change
23
+ `include YrbLite::Sync` to `include YrbLite::ActionCable::Sync`. The concern's
24
+ API is otherwise unchanged. If you only use `YrbLite::Doc`/`YrbLite::Awareness`,
25
+ nothing changes.
26
+
27
+ ## [0.1.0.beta4] - 2026-06-18
28
+
29
+ ### Changed
30
+
31
+ - `on_change` block recorders now run in the **channel instance's context**
32
+ (via `instance_exec`), so a recorder can call the channel's own methods --
33
+ `current_user`, `params`, request/connection-scoped accessors -- directly,
34
+ instead of plumbing them in through a thread-local. A non-Proc callable (an
35
+ object responding to `#call`) is still invoked with `#call` and its own
36
+ context. `on_load`/`on_save` are unchanged: they can run in the shared
37
+ document registry during a cold load or eviction, where no connection
38
+ instance exists, so they remain key-only. Existing block recorders that use
39
+ only the `(key, update)` arguments and lexically-scoped constants are
40
+ unaffected; the only behavioral change is `self` inside the block.
41
+
9
42
  ## [0.1.0.beta3] - 2026-06-18
10
43
 
11
44
  ### Changed
@@ -76,7 +109,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
76
109
  - Precompiled native gems for common platforms (no Rust toolchain needed to
77
110
  install) via the cross-gem workflow.
78
111
 
79
- [Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...main
112
+ [Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta5...main
113
+ [0.1.0.beta5]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta4...v0.1.0.beta5
114
+ [0.1.0.beta4]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...v0.1.0.beta4
80
115
  [0.1.0.beta3]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...v0.1.0.beta3
81
116
  [0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
82
117
  [0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta1
@@ -1,10 +1,10 @@
1
1
  use magnus::{function, method, prelude::*, Error, RString, Ruby, TryConvert, Value};
2
+ use std::sync::Mutex;
2
3
  use yrs::encoding::read::{Cursor, Read};
3
4
  use yrs::sync::protocol::MessageReader;
4
5
  use yrs::sync::{Awareness, DefaultProtocol, Message, Protocol, SyncMessage};
5
6
  use yrs::updates::decoder::{Decode, DecoderV1};
6
7
  use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
7
- use std::sync::Mutex;
8
8
  use yrs::{ClientID, Doc, ReadTxn, Transact};
9
9
 
10
10
  /// Wrapper around yrs Doc.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YrbLite
4
- VERSION = "0.1.0.beta3"
4
+ VERSION = "0.1.0.beta5"
5
5
  end
data/lib/yrb_lite.rb CHANGED
@@ -13,8 +13,8 @@ rescue LoadError
13
13
  end
14
14
 
15
15
  module YrbLite
16
- # Error class is defined in Rust extension
17
-
18
- # Autoload Sync module - only loaded when ActionCable is available
19
- autoload :Sync, "yrb_lite/sync"
16
+ # Error class is defined in the Rust extension.
17
+ #
18
+ # The ActionCable integration (YrbLite::ActionCable::Sync) lives in the
19
+ # separate `yrb-lite-actioncable` gem; require "yrb_lite/action_cable".
20
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrb-lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta3
4
+ version: 0.1.0.beta5
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: base64
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '0.2'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
24
- - !ruby/object:Gem::Version
25
- version: '0.2'
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: rb_sys
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -79,10 +65,10 @@ dependencies:
79
65
  - - "~>"
80
66
  - !ruby/object:Gem::Version
81
67
  version: '1.2'
82
- description: yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library
83
- plus an ActionCable concern implementing the full y-websocket sync protocol and
84
- awareness. It lets a Rails app be the collaboration server for Y.js editors (Tiptap,
85
- ProseMirror, BlockNote) with no Node sidecar.
68
+ description: 'yrb-lite is a thread-safe Ruby binding over the Rust y-crdt (yrs) library:
69
+ CRDT documents, awareness/presence, and the y-websocket sync protocol primitives,
70
+ with the GVL released during native work so documents sync in parallel. The ActionCable/Rails
71
+ integration lives in the companion yrb-lite-actioncable gem.'
86
72
  email:
87
73
  - johnpcamara@gmail.com
88
74
  executables: []
@@ -99,7 +85,6 @@ files:
99
85
  - ext/yrb_lite/src/lib.rs
100
86
  - lib/yrb-lite.rb
101
87
  - lib/yrb_lite.rb
102
- - lib/yrb_lite/sync.rb
103
88
  - lib/yrb_lite/version.rb
104
89
  homepage: https://github.com/jpcamara/yrb-lite
105
90
  licenses:
@@ -125,6 +110,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
110
  requirements: []
126
111
  rubygems_version: 3.6.9
127
112
  specification_version: 4
128
- summary: Thread-safe Ruby bindings for y-crdt (Y.js) with the y-websocket sync protocol
129
- for ActionCable
113
+ summary: 'Thread-safe Ruby bindings for y-crdt (Y.js): documents, awareness, and the
114
+ y-websocket sync protocol'
130
115
  test_files: []
data/lib/yrb_lite/sync.rb DELETED
@@ -1,559 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "base64"
4
- require "securerandom"
5
-
6
- module YrbLite
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
- # { "m" => "<base64 bytes>" } # client -> server
14
- # { "m" => "...", "origin" => "<id>" } # server -> subscribers
15
- #
16
- # Example:
17
- # class DocumentChannel < ApplicationCable::Channel
18
- # include YrbLite::Sync
19
- #
20
- # on_load { |key| Document.find_by(key: key)&.content }
21
- # on_save { |key, update| Document.find_by(key: key)&.update!(content: update) }
22
- #
23
- # def subscribed
24
- # sync_for params[:id]
25
- # end
26
- #
27
- # def receive(data)
28
- # sync_receive(data)
29
- # end
30
- #
31
- # def unsubscribed
32
- # sync_clear_presence
33
- # end
34
- # end
35
- #
36
- # The shared YrbLite::Awareness instances are safe to use from ActionCable's
37
- # worker thread pool: the native types are Send + Sync and every operation
38
- # releases the GVL, so concurrent clients sync in parallel.
39
- module Sync
40
- # Validated frame kinds from Awareness#message_kind. A frame only gets a
41
- # non-DROP kind if it is exactly one well-formed message; anything
42
- # malformed, truncated, multi-message, or unknown is dropped before it can
43
- # be processed or relayed.
44
- MSG_KIND_DROP = 0
45
- MSG_KIND_SYNC_STEP1 = 1
46
- MSG_KIND_UPDATE = 2
47
- MSG_KIND_AWARENESS = 3
48
- MSG_KIND_AWARENESS_QUERY = 4
49
-
50
- def self.included(base)
51
- base.extend(ClassMethods)
52
- end
53
-
54
- module ClassMethods
55
- # Load persisted document state. Called once per key with (key);
56
- # return a binary Y.js update (or nil for a fresh document).
57
- def on_load(callable = nil, &block)
58
- @on_load = callable || block if callable || block
59
- @on_load
60
- end
61
-
62
- # Persist document state. Called with (key, update) after every
63
- # message that modified the document.
64
- def on_save(callable = nil, &block)
65
- @on_save = callable || block if callable || block
66
- @on_save
67
- end
68
-
69
- # Record every document change durably before it is applied or
70
- # distributed (authoritative audit mode). Called synchronously with
71
- # (key, update), where update is the exact CRDT delta, serialized per
72
- # document so the recorded order is the apply order. If the block raises,
73
- # the change is rejected: neither applied to the shared document nor
74
- # broadcast to other subscribers.
75
- #
76
- # Registering an on_change switches that channel onto the strict path
77
- # (record, apply, broadcast). Without it, the default fast path applies
78
- # and broadcasts, with an optional on_save snapshot.
79
- def on_change(callable = nil, &block)
80
- @on_change = callable || block if callable || block
81
- @on_change
82
- end
83
-
84
- # Select the document backend:
85
- # :memory (default): keep a warm in-memory replica per process and keep
86
- # it current via a custom stream_from callback. Fast, but it assumes
87
- # classic ActionCable (the callback runs in Ruby) and
88
- # process<->document affinity.
89
- # :store: stateless per message, with no warm replica and no custom
90
- # stream callback. Handshakes and reads are served from the durable
91
- # store (`on_load`); changes are recorded (`on_change`) and relayed.
92
- # Works under AnyCable (broadcasts handled outside Ruby, no worker
93
- # affinity) and across processes. Requires `on_load` and `on_change`.
94
- def sync_backend(mode = nil)
95
- @sync_backend = mode if mode
96
- @sync_backend || :memory
97
- end
98
- end
99
-
100
- # Call from `subscribed`. Streams broadcasts for this document and
101
- # transmits the server's opening handshake (SyncStep1 + awareness).
102
- def sync_for(key)
103
- @sync_key = key.to_s
104
- @sync_origin = SecureRandom.hex(8)
105
- @sync_clients = [] # awareness client IDs seen on this connection
106
-
107
- return sync_for_store_backed if self.class.sync_backend == :store
108
-
109
- Sync.subscribe(@sync_key)
110
- awareness = sync_awareness
111
-
112
- stream_from sync_stream_name, coder: ActiveSupport::JSON do |payload|
113
- sync_on_broadcast(payload)
114
- end
115
-
116
- # Opening handshake: SyncStep1 then the current awareness, each as its
117
- # own single-message frame, so providers that parse one message per frame
118
- # (e.g. @y-rb/actioncable) handle both. The client replies SyncStep2 to
119
- # the SyncStep1, delivering its state to the server.
120
- sync_transmit(awareness.sync_step1)
121
- sync_transmit(awareness.encode_awareness_update)
122
- end
123
-
124
- # Call from `receive`. Applies the client's message, replies directly
125
- # when the protocol calls for it, and relays document/awareness changes
126
- # to the other subscribers.
127
- #
128
- # If an `on_change` recorder is registered, document changes take the
129
- # strict authoritative path (record -> apply -> broadcast, serialized per
130
- # document); otherwise the fast path is used.
131
- #
132
- # Reliable delivery (opt-in, client-driven): if the frame carries an "id",
133
- # the server replies `{ "ack" => id }` once the update has been accepted
134
- # (recorded in audit mode, applied in fast mode). A causally-gapped update
135
- # is not acked -- it gets a resync instead -- so an ack-aware client knows
136
- # to retransmit until the update lands. Stock clients send no "id", never
137
- # get acks, and are completely unaffected.
138
- def sync_receive(data, key = nil)
139
- # Pass `key` (params[:id]) when your transport doesn't keep the channel
140
- # instance alive across actions. Under AnyCable each RPC command gets a
141
- # fresh channel, so instance variables set in `subscribed` are gone here.
142
- @sync_key = key.to_s if key
143
-
144
- # Accept both envelope keys: "m" (yrb-lite's own clients) and "update"
145
- # (the @y-rb/actioncable browser provider).
146
- m = data.is_a?(Hash) ? (data["m"] || data["update"]) : nil
147
- return unless m.is_a?(String)
148
-
149
- # Optional client-supplied id for reliable delivery (see sync_send_ack).
150
- id = data.is_a?(Hash) ? data["id"] : nil
151
-
152
- begin
153
- bytes = Base64.strict_decode64(m)
154
- rescue ArgumentError
155
- return # not valid base64; ignore the frame and keep the connection
156
- end
157
-
158
- sync_send_ack(id, sync_dispatch(m, bytes))
159
- end
160
-
161
- # Route a decoded frame to the backend/path that handles it and return the
162
- # outcome symbol (:recorded/:applied/:gap/:noop) used by the reliable-
163
- # delivery ack. A dropped frame returns nil (never acked).
164
- def sync_dispatch(encoded, bytes)
165
- return sync_receive_store_backed(encoded, bytes) if self.class.sync_backend == :store
166
-
167
- awareness = sync_awareness
168
- kind = awareness.message_kind(bytes)
169
- # Malformed / truncated / multi-message / unknown frames are dropped
170
- # before they can be processed or relayed to other clients.
171
- return if kind == MSG_KIND_DROP
172
-
173
- sync_track_clients(awareness, bytes) if kind == MSG_KIND_AWARENESS
174
-
175
- if kind == MSG_KIND_UPDATE && self.class.on_change
176
- sync_apply_authoritative(awareness, encoded, bytes)
177
- else
178
- sync_apply_fast(awareness, encoded, bytes, kind)
179
- end
180
- end
181
-
182
- # Call from `unsubscribed`. Clears the presence states this connection
183
- # introduced and tells the other subscribers to drop those cursors, so a
184
- # closed tab or dropped socket doesn't leave a ghost cursor behind until
185
- # the client-side timeout reaps it.
186
- def sync_clear_presence
187
- return if @sync_clients.nil? || @sync_clients.empty?
188
-
189
- removal = sync_awareness.remove_clients(@sync_clients)
190
- @sync_clients = []
191
- return if removal.empty?
192
-
193
- sync_distribute(Base64.strict_encode64(removal))
194
- end
195
-
196
- # Call from `unsubscribed`. Clears this connection's presence and, when the
197
- # last subscriber for the document leaves, persists and unloads it from
198
- # memory (only when an `on_load` is configured to bring it back; otherwise
199
- # the in-memory document is the only copy and is kept). Prevents a
200
- # long-running server from accumulating every document it has ever served.
201
- def sync_unsubscribed(key = nil)
202
- @sync_key = key.to_s if key
203
- return if self.class.sync_backend == :store # nothing cached per process
204
-
205
- sync_clear_presence
206
- saver = self.class.on_save
207
- Sync.release(@sync_key, evictable: !self.class.on_load.nil?) do |awareness|
208
- saver&.call(@sync_key, awareness.encode_state_as_update)
209
- end
210
- end
211
-
212
- # The shared Awareness (document + presence) for this channel's key.
213
- # Also useful for server-side reads, e.g.:
214
- # sync_awareness.encode_state_as_update
215
- def sync_awareness
216
- Sync.awareness_for(@sync_key, self.class.on_load)
217
- end
218
-
219
- private
220
-
221
- # Default path: apply the message, answer direct requests, relay
222
- # state-changing messages to the other subscribers. Routing comes from the
223
- # native `kind` (from Awareness#message_kind) rather than peeking at bytes.
224
- # Document changes (SyncStep2, Update) and awareness get relayed; requests
225
- # (SyncStep1, awareness-query) are answered above and not relayed. An
226
- # optional on_save snapshot is taken after a document change.
227
- #
228
- # Returns an outcome symbol for the reliable-delivery ack: :applied when a
229
- # document update was integrated and relayed, :gap when it was rejected for
230
- # a resync, :noop for everything else (requests, awareness, empty updates).
231
- def sync_apply_fast(awareness, encoded, bytes, kind)
232
- # A document update that isn't causally ready (an earlier one was lost in
233
- # transit) would relay an un-integrable change to peers and stall the
234
- # replica. Drop it and ask the client to resync instead, which re-delivers
235
- # the missing piece. See sync_apply_authoritative for the durable variant.
236
- if kind == MSG_KIND_UPDATE
237
- update = awareness.update_from_message(bytes)
238
- # A no-op message (e.g. the empty SyncStep2 in an opening handshake)
239
- # carries no change, so there's nothing to relay, persist, or ack.
240
- return :noop unless update
241
-
242
- unless awareness.update_ready?(update)
243
- sync_request_resync(awareness)
244
- return :gap
245
- end
246
- end
247
-
248
- response = awareness.handle(bytes)
249
- sync_transmit(response) unless response.empty?
250
-
251
- return :noop unless [MSG_KIND_UPDATE, MSG_KIND_AWARENESS].include?(kind)
252
-
253
- sync_distribute(encoded)
254
- return :noop unless kind == MSG_KIND_UPDATE
255
-
256
- sync_persist
257
- :applied
258
- end
259
-
260
- # Authoritative path: record the change durably, then apply it to the
261
- # shared document, then distribute it. The sequence runs under a
262
- # per-document lock so changes are recorded in a single total order that
263
- # matches the order they're applied, and nothing is distributed (or applied)
264
- # before it has been recorded. If the recorder raises, the change is
265
- # rejected (not applied, not broadcast) and the exception propagates, so the
266
- # channel can surface it and the client can resync.
267
- #
268
- # Before recording, the update must be causally ready: every dependency it
269
- # references must already be in the doc. If an earlier update was lost in
270
- # transit, or its record failed, a later update arrives with a gap. Recording
271
- # it would write a permanently-pending entry to the log -- one that can never
272
- # be replayed until the missing update shows up. Such an update is rejected
273
- # (not recorded, not applied, not relayed) and the client is asked to resync,
274
- # which re-delivers the missing range as one causally-complete delta.
275
- def sync_apply_authoritative(awareness, encoded, bytes)
276
- recorder = self.class.on_change
277
-
278
- outcome = Sync.lock_for(@sync_key).synchronize do
279
- update = awareness.update_from_message(bytes)
280
- # A no-op message (e.g. the empty SyncStep2 in a client's opening
281
- # handshake) carries no change, so there's nothing to record or relay.
282
- next :noop unless update
283
- next :gap unless awareness.update_ready?(update)
284
-
285
- recorder.call(@sync_key, update) # durable write; raise to reject
286
- awareness.apply_update(update) # only recorded changes reach the doc
287
- sync_distribute(encoded) # ...and only then the wire
288
- :recorded
289
- end
290
-
291
- case outcome
292
- when :recorded then sync_persist
293
- when :gap then sync_request_resync(awareness)
294
- end
295
-
296
- # Surface the outcome for the reliable-delivery ack: :recorded means the
297
- # update is durably written (and will be acked); :gap triggered a resync
298
- # (no ack); :noop carried no change.
299
- outcome
300
- end
301
-
302
- # Ask this connection's client to resync: re-send SyncStep1 carrying the
303
- # server's current (gap-free) state vector. The client replies SyncStep2
304
- # with everything the server is missing, delivered as one causally-complete
305
- # delta -- which heals the gap that triggered the resync.
306
- def sync_request_resync(awareness)
307
- sync_transmit(awareness.sync_step1)
308
- end
309
-
310
- # Reliable delivery: acknowledge an accepted update back to the sending
311
- # connection. An ack-aware client tags each outgoing update with an "id"
312
- # and retains it until the matching `{ "ack" => id }` returns, retransmitting
313
- # on a timer or reconnect; idempotent CRDT apply makes resends free. We ack
314
- # only when the client supplied an id (so stock clients are unaffected) and
315
- # the update was actually accepted -- recorded in audit mode, applied in fast
316
- # mode. A gapped update gets no ack (it got a resync), so the client keeps
317
- # retransmitting until the missing range lands and the update can integrate.
318
- def sync_send_ack(id, outcome)
319
- return if id.nil?
320
- return unless %i[recorded applied].include?(outcome)
321
-
322
- # Braces are load-bearing: a bare hash would bind to transmit's `via:`
323
- # keyword instead of its positional data argument.
324
- transmit({ "ack" => id })
325
- end
326
-
327
- # Single broadcast point for both paths (and presence removal), so the
328
- # relay semantics live in one place and tests can observe distribution.
329
- # `origin` identifies the sending connection (don't echo to it); `pid`
330
- # identifies the sending process (other processes apply it to their own
331
- # replica; see sync_on_broadcast).
332
- def sync_distribute(encoded)
333
- ActionCable.server.broadcast(
334
- sync_stream_name,
335
- sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
336
- )
337
- end
338
-
339
- # Transmit raw protocol bytes to this connection (base64, dual-key).
340
- def sync_transmit(bytes)
341
- transmit(sync_envelope(Base64.strict_encode64(bytes)))
342
- end
343
-
344
- # Build an outgoing envelope. We send the payload under both keys: "m"
345
- # (yrb-lite's own clients) and "update" (the @y-rb/actioncable provider),
346
- # so either client works against the same server.
347
- def sync_envelope(encoded, extra = {})
348
- { "m" => encoded, "update" => encoded }.merge(extra)
349
- end
350
-
351
- # Handle a broadcast delivered by the cable adapter. With a multi-process
352
- # adapter (Redis, solid_cable), it may have come from another server
353
- # process. Keep this process's in-memory replica current with changes that
354
- # originated elsewhere, then relay to this connection's browser.
355
- def sync_on_broadcast(payload)
356
- sync_apply_remote(payload["m"]) if payload["pid"] != Sync.process_id
357
- transmit(payload) unless payload["origin"] == @sync_origin
358
- end
359
-
360
- # Apply a change that originated on another process to this process's
361
- # replica, without re-recording it (the origin process already recorded it
362
- # before broadcasting). The CRDT merge is idempotent and commutative, so a
363
- # cold replica converges regardless of ordering, and applying from several
364
- # local connections is harmless.
365
- def sync_apply_remote(encoded)
366
- return unless encoded.is_a?(String)
367
-
368
- begin
369
- bytes = Base64.strict_decode64(encoded)
370
- rescue ArgumentError
371
- return
372
- end
373
-
374
- awareness = sync_awareness
375
- case awareness.message_kind(bytes)
376
- when MSG_KIND_UPDATE
377
- update = awareness.update_from_message(bytes)
378
- awareness.apply_update(update) if update
379
- when MSG_KIND_AWARENESS
380
- awareness.handle(bytes)
381
- end
382
- end
383
-
384
- # -- Store-backed (AnyCable-native) path --------------------------------
385
-
386
- # Subscribe without a custom block, so AnyCable (which delivers broadcasts
387
- # outside Ruby) relays them directly. Send the opening SyncStep1 built from
388
- # the durable store. No warm replica is kept.
389
- def sync_for_store_backed
390
- stream_from sync_stream_name
391
- sync_transmit(sync_load_doc.sync_step1)
392
- end
393
-
394
- # Stateless per message: no warm replica, no assumptions about which process
395
- # owns a document. A client's SyncStep1 is answered from the store, document
396
- # changes are recorded durably before relay and then broadcast, and
397
- # awareness is relayed best-effort. Echoing back to the sender is harmless,
398
- # since the CRDT apply is idempotent.
399
- #
400
- # Returns an outcome symbol for the reliable-delivery ack: :recorded when a
401
- # document update was durably recorded and relayed, :gap when it was
402
- # rejected for a resync, :noop for everything else.
403
- def sync_receive_store_backed(encoded, bytes)
404
- case Sync.codec.message_kind(bytes)
405
- when MSG_KIND_SYNC_STEP1
406
- result = sync_load_doc.handle_sync_message(bytes)
407
- sync_transmit(result[2]) if result
408
- :noop
409
- when MSG_KIND_UPDATE
410
- update = Sync.codec.update_from_message(bytes)
411
- return :noop unless update
412
-
413
- # Store mode keeps no warm replica, so to tell whether this update is
414
- # causally ready we rebuild the doc from the store and check against it.
415
- # That's an O(history) load per update (mitigated by snapshotting the
416
- # store on the load path). A gappy update -- an earlier one was lost or
417
- # its record failed -- is rejected and the client asked to resync,
418
- # rather than written to the log as a permanently-pending entry.
419
- doc = sync_load_doc
420
- unless doc.update_ready?(update)
421
- sync_transmit(doc.sync_step1)
422
- return :gap
423
- end
424
-
425
- self.class.on_change&.call(@sync_key, update) # record before relay
426
- sync_distribute(encoded)
427
- :recorded
428
- when MSG_KIND_AWARENESS
429
- sync_distribute(encoded)
430
- :noop
431
- else
432
- :noop
433
- end
434
- end
435
-
436
- # Build a fresh document from the durable store (on_load).
437
- def sync_load_doc
438
- doc = YrbLite::Doc.new
439
- state = self.class.on_load&.call(@sync_key)
440
- doc.apply_update(state) if state
441
- doc
442
- end
443
-
444
- # Record the awareness client IDs carried by an incoming message (already
445
- # known to be an awareness frame) so we can clear them when this connection
446
- # closes.
447
- def sync_track_clients(awareness, bytes)
448
- awareness.awareness_client_ids(bytes).each do |id|
449
- @sync_clients << id unless @sync_clients.include?(id)
450
- end
451
- end
452
-
453
- def sync_stream_name
454
- "yrb_lite:#{@sync_key}"
455
- end
456
-
457
- def sync_persist
458
- return unless (saver = self.class.on_save)
459
-
460
- saver.call(@sync_key, sync_awareness.encode_state_as_update)
461
- end
462
-
463
- # -- Shared document registry ------------------------------------------
464
-
465
- @registry = {}
466
- @locks = {}
467
- @subscribers = Hash.new(0)
468
- @registry_mutex = Mutex.new
469
-
470
- class << self
471
- # A stable id for this server process, stamped on every broadcast so
472
- # other processes know to apply it to their replica and this process
473
- # knows to skip its own. Survives for the life of the process.
474
- def process_id
475
- @process_id ||= SecureRandom.hex(8)
476
- end
477
-
478
- # A shared, stateless decoder for the store-backed path. message_kind and
479
- # update_from_message only read their argument (they don't touch the
480
- # instance's document), so one shared instance is safe across threads.
481
- def codec
482
- @codec ||= YrbLite::Awareness.new
483
- end
484
-
485
- # Get or create the shared Awareness for a key. Creation (including
486
- # the on_load callback) is serialized under a mutex so concurrent
487
- # subscribers can never observe two documents for one key; all
488
- # subsequent operations run lock-free on the thread-safe native types.
489
- def awareness_for(key, loader = nil)
490
- @registry_mutex.synchronize do
491
- @registry[key] ||= begin
492
- awareness = YrbLite::Awareness.new
493
- if loader && (state = loader.call(key))
494
- awareness.apply_update(state)
495
- end
496
- awareness
497
- end
498
- end
499
- end
500
-
501
- # Per-document mutex serializing the authoritative record -> apply ->
502
- # broadcast section, so a document's audit log is a single total order.
503
- # Only briefly holds the registry mutex to fetch/create the lock; the
504
- # durable write itself runs while holding only this per-key lock.
505
- def lock_for(key)
506
- @registry_mutex.synchronize { @locks[key] ||= Mutex.new }
507
- end
508
-
509
- # Count a new subscriber for a document.
510
- def subscribe(key)
511
- @registry_mutex.synchronize { @subscribers[key] += 1 }
512
- end
513
-
514
- # Drop a subscriber. When the last one leaves and the document is
515
- # evictable (there's an on_load to bring it back, so unloading can't lose
516
- # data), persist it via the given block and unload it from memory, so a
517
- # long-running server doesn't accumulate every document and lock it has
518
- # ever seen. Returns true if the document was evicted.
519
- #
520
- # The persist runs outside the registry lock (it may do I/O), and we
521
- # re-check the subscriber count afterward: if someone reconnected while
522
- # we were saving, eviction is aborted and the warm document is kept.
523
- def release(key, evictable:)
524
- awareness = @registry_mutex.synchronize do
525
- @subscribers[key] -= 1 if @subscribers[key].positive?
526
- next nil unless @subscribers[key].zero?
527
-
528
- @subscribers.delete(key)
529
- evictable ? @registry[key] : nil
530
- end
531
- return false unless awareness
532
-
533
- yield awareness if block_given?
534
-
535
- @registry_mutex.synchronize do
536
- # A subscriber may have returned during the persist above.
537
- next false unless @subscribers[key].zero?
538
-
539
- @subscribers.delete(key)
540
- @locks.delete(key)
541
- !@registry.delete(key).nil?
542
- end
543
- end
544
-
545
- def registry
546
- @registry_mutex.synchronize { @registry.dup }
547
- end
548
-
549
- # Clear all documents (useful for testing).
550
- def reset!
551
- @registry_mutex.synchronize do
552
- @registry = {}
553
- @locks = {}
554
- @subscribers = Hash.new(0)
555
- end
556
- end
557
- end
558
- end
559
- end