yrb-lite-actioncable 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 +4 -4
- data/CHANGELOG-actioncable.md +0 -47
- data/README.md +50 -67
- data/lib/yrb_lite/action_cable/sync.rb +51 -95
- data/lib/yrb_lite/action_cable/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d48432476f4a644c91c193f72d6857aa5ce6bb0f2ded537b3277d31e8c49cfb
|
|
4
|
+
data.tar.gz: e28d77164e95bbba949133989f11555015160f39786a0c17bab9acacaaf05570
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d69b40149fccf8d84d8fc4cbfffe7f9ce4309a47e9971bbb0c65befc9bed6a5827d2251caa807da6da5b2b5b7f3ef13c4c8659d2856868848df13748deed77d2
|
|
7
|
+
data.tar.gz: a8babf32c1f72b70ff9f3e551c826b62306dd446ee86d44d09b71b5f5164b4c0817328a5ad14e16bcbc0134f1fc4a20624d59abc13a75766292d1a567933e432
|
data/CHANGELOG-actioncable.md
CHANGED
|
@@ -5,50 +5,3 @@ format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
|
-
|
|
9
|
-
## [0.1.0.beta3] - 2026-06-22
|
|
10
|
-
|
|
11
|
-
### Changed
|
|
12
|
-
|
|
13
|
-
- Document streams and awareness streams are separate under AnyCable. Document
|
|
14
|
-
streams are normal server-relayed streams; awareness streams are subscribed
|
|
15
|
-
with `whisper: true` and carry only ephemeral presence frames.
|
|
16
|
-
- The channel accepts and emits the canonical document envelope,
|
|
17
|
-
`{ "update" => "<base64 frame>" }`. Accepted document updates carrying an
|
|
18
|
-
`"id"` are acknowledged with `{ "ack" => id }`.
|
|
19
|
-
- Removed the backend switch. `YrbLite::ActionCable::Sync` is always
|
|
20
|
-
store-backed and fails closed unless both `on_load` and `on_change` are
|
|
21
|
-
configured, so acknowledgements always mean the update is durably recorded.
|
|
22
|
-
- Requires `yrb-lite >= 0.1.0.beta6` (uses the new `update_advances?` and
|
|
23
|
-
wire client-id frame validation).
|
|
24
|
-
|
|
25
|
-
## [0.1.0.beta2] - 2026-06-20
|
|
26
|
-
|
|
27
|
-
### Added
|
|
28
|
-
|
|
29
|
-
- Automatic AnyCable whispering for awareness/presence. When running under
|
|
30
|
-
AnyCable, the channel now enables client-to-client whispering on its stream
|
|
31
|
-
(`stream_from key, whisper: true`), so a client that whispers awareness has its
|
|
32
|
-
presence frames broadcast directly to other subscribers with no server
|
|
33
|
-
round-trip. It's automatic -- no configuration -- and a no-op on plain
|
|
34
|
-
ActionCable (no whisper support), where presence stays server-relayed. Document
|
|
35
|
-
updates are never whispered.
|
|
36
|
-
|
|
37
|
-
## [0.1.0.beta1] - 2026-06-18
|
|
38
|
-
|
|
39
|
-
### Added
|
|
40
|
-
|
|
41
|
-
- Initial release, extracted from `yrb-lite` (which shipped this as
|
|
42
|
-
`YrbLite::Sync` through 0.1.0.beta4). Provides `YrbLite::ActionCable::Sync`, an
|
|
43
|
-
ActionCable channel concern implementing the y-websocket sync protocol and
|
|
44
|
-
awareness/presence over ActionCable and AnyCable: record-before-distribute
|
|
45
|
-
auditing (`on_change`), persistence hooks, presence reaping, idle-document
|
|
46
|
-
eviction, and multi-process replica sync. Depends on `yrb-lite`
|
|
47
|
-
(>= 0.1.0.beta5) for the CRDT documents, awareness, and protocol primitives.
|
|
48
|
-
- `on_change` recorders run in the channel instance's context (carried over from
|
|
49
|
-
`yrb-lite` 0.1.0.beta4), so a recorder can call the channel's own methods
|
|
50
|
-
directly.
|
|
51
|
-
|
|
52
|
-
[Unreleased]: https://github.com/jpcamara/yrb-lite/commits/main
|
|
53
|
-
[0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/commits/main
|
|
54
|
-
[0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta5
|
data/README.md
CHANGED
|
@@ -19,12 +19,12 @@ end
|
|
|
19
19
|
|
|
20
20
|
On the browser, use the `yrb-lite-client` `ActionCableProvider`. Tiptap,
|
|
21
21
|
ProseMirror, and BlockNote all sync through the `Y.Doc` you pass in and the
|
|
22
|
-
provider's Awareness instance
|
|
22
|
+
provider's Awareness instance.
|
|
23
23
|
|
|
24
24
|
## What you get
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
|
|
26
|
+
- A thread-safe Ruby `Doc` you can share across Puma threads; native CRDT work
|
|
27
|
+
runs with the GVL released.
|
|
28
28
|
- The y-websocket protocol (document sync plus awareness/presence) as a
|
|
29
29
|
one-include ActionCable concern.
|
|
30
30
|
- Store-backed ActionCable/AnyCable delivery for multi-process deployments.
|
|
@@ -86,11 +86,7 @@ require "yrb_lite"
|
|
|
86
86
|
|
|
87
87
|
# Create docs
|
|
88
88
|
doc = YrbLite::Doc.new # random client ID
|
|
89
|
-
doc = YrbLite::Doc.new(12345) # specific client ID
|
|
90
|
-
|
|
91
|
-
# Get document info
|
|
92
|
-
doc.client_id # => unique client identifier
|
|
93
|
-
doc.guid # => document GUID
|
|
89
|
+
doc = YrbLite::Doc.new(12345) # specific client ID (used for CRDT identity)
|
|
94
90
|
|
|
95
91
|
# Encoding
|
|
96
92
|
doc.encode_state_vector # => current state vector
|
|
@@ -100,36 +96,23 @@ doc.encode_state_as_update(sv) # => update diff against state vector
|
|
|
100
96
|
# Applying updates
|
|
101
97
|
doc.apply_update(update_bytes) # apply raw V1 update
|
|
102
98
|
|
|
103
|
-
# Sync protocol
|
|
104
|
-
doc.sync_step1 # => SyncStep1 message (
|
|
105
|
-
doc.
|
|
106
|
-
|
|
107
|
-
doc.encode_update_message(update) # => wrap update as sync Update message
|
|
99
|
+
# Sync protocol
|
|
100
|
+
doc.sync_step1 # => SyncStep1 message (this doc's state vector)
|
|
101
|
+
doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers a
|
|
102
|
+
# peer's SyncStep1 with a SyncStep2
|
|
108
103
|
```
|
|
109
104
|
|
|
110
|
-
###
|
|
111
|
-
|
|
112
|
-
```ruby
|
|
113
|
-
# Create awareness instances (each contains a Doc)
|
|
114
|
-
awareness = YrbLite::Awareness.new # random client ID
|
|
115
|
-
awareness = YrbLite::Awareness.new(12345) # specific client ID
|
|
116
|
-
|
|
117
|
-
# Get document info
|
|
118
|
-
awareness.client_id # => unique client identifier
|
|
119
|
-
awareness.guid # => document GUID
|
|
120
|
-
```
|
|
105
|
+
### Protocol codec (module functions)
|
|
121
106
|
|
|
122
|
-
|
|
107
|
+
Classifying and unwrapping wire frames is stateless, so it's exposed as
|
|
108
|
+
`YrbLite` module functions rather than a class. The server never holds presence
|
|
109
|
+
or document state to route a frame — presence lives in the browser clients, and
|
|
110
|
+
the server only relays awareness frames opaquely.
|
|
123
111
|
|
|
124
112
|
```ruby
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
# When receiving messages from peer
|
|
130
|
-
response = awareness.handle(incoming_data)
|
|
131
|
-
# Send response back to peer if not empty
|
|
132
|
-
send_to_peer(response) unless response.empty?
|
|
113
|
+
YrbLite.message_kind(frame) # => 0 drop / 1 step1 / 2 update / 3 awareness / 4 query
|
|
114
|
+
YrbLite.update_from_message(frame) # => the document delta carried by a frame, or nil
|
|
115
|
+
YrbLite.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
|
|
133
116
|
```
|
|
134
117
|
|
|
135
118
|
### ActionCable Integration
|
|
@@ -178,6 +161,34 @@ oversized, or unknown frames are dropped. A bad frame can't crash the process: a
|
|
|
178
161
|
Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And
|
|
179
162
|
no single client can relay garbage that breaks the others in a room.
|
|
180
163
|
|
|
164
|
+
#### Delivery guarantees
|
|
165
|
+
|
|
166
|
+
The contract is the same at every scale — one process, or hundreds across many
|
|
167
|
+
servers:
|
|
168
|
+
|
|
169
|
+
- **The document always converges.** CRDT updates are commutative and
|
|
170
|
+
idempotent, so out-of-order, duplicate, or concurrent delivery all converge to
|
|
171
|
+
the same correct document. This needs no coordination and holds everywhere.
|
|
172
|
+
- **The durable log never goes gappy.** An update is recorded only once its
|
|
173
|
+
causal dependencies are already in the store (checked against `on_load`); a
|
|
174
|
+
causally-incomplete update triggers a resync instead, so the log always
|
|
175
|
+
rebuilds cleanly.
|
|
176
|
+
- **`on_change` is at-least-once, and the durable guarantee is that replaying the
|
|
177
|
+
log reconstructs the document.** Every change is recorded before it's acked or
|
|
178
|
+
broadcast (record-before-distribute). Entry count is not 1:1 with edits: a
|
|
179
|
+
best-effort check skips most lost-ack retries but isn't cross-process exact (a
|
|
180
|
+
retry on another process can record the same update twice), and a resync can
|
|
181
|
+
coalesce a client's un-acked tail into a single record. So **make `on_change`
|
|
182
|
+
idempotent** if duplicate side effects would matter (a webhook, a counter) — a
|
|
183
|
+
raw append-only delta log is naturally fine, since it replays to the same
|
|
184
|
+
document either way.
|
|
185
|
+
|
|
186
|
+
There is deliberately no in-gem cross-process lock. One that only spanned a
|
|
187
|
+
single process would give exactly-once at small scale and silently degrade as
|
|
188
|
+
you scale out, so the guarantee is uniform instead. If you need exactly-once
|
|
189
|
+
*side effects*, enforce it in your store (a unique key on the update) or with
|
|
190
|
+
your own distributed lock — the gem stays storage-agnostic and assumes neither.
|
|
191
|
+
|
|
181
192
|
#### Multi-process deployments
|
|
182
193
|
|
|
183
194
|
Most Rails apps run several processes (Puma workers, multiple dynos), and any of
|
|
@@ -295,43 +306,15 @@ one `{ ack: id }` cumulatively confirms everything up to it. Because CRDT apply
|
|
|
295
306
|
is idempotent, a resend that already landed is a harmless no-op that just
|
|
296
307
|
re-acks. Awareness stays ephemeral and is not acked.
|
|
297
308
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
# Set local user state (cursor position, name, etc.)
|
|
302
|
-
awareness.set_local_state('{"user": {"name": "Alice", "color": "#ff0000"}}')
|
|
303
|
-
|
|
304
|
-
# Get local state
|
|
305
|
-
awareness.local_state # => '{"user": {"name": "Alice", "color": "#ff0000"}}'
|
|
306
|
-
|
|
307
|
-
# Clear local state (e.g., when disconnecting)
|
|
308
|
-
awareness.clear_local_state
|
|
309
|
-
|
|
310
|
-
# Encode awareness update for broadcasting
|
|
311
|
-
update = awareness.encode_awareness_update
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
### Low-Level Access
|
|
315
|
-
|
|
316
|
-
```ruby
|
|
317
|
-
# Get state vector for manual sync
|
|
318
|
-
sv = awareness.encode_state_vector
|
|
319
|
-
|
|
320
|
-
# Get update diffed against a state vector
|
|
321
|
-
update = awareness.encode_state_as_update(remote_state_vector)
|
|
322
|
-
|
|
323
|
-
# Apply raw update to the document
|
|
324
|
-
awareness.apply_update(update_bytes)
|
|
325
|
-
|
|
326
|
-
# Wrap raw update data in a sync message
|
|
327
|
-
message = awareness.encode_update(update_bytes)
|
|
328
|
-
```
|
|
309
|
+
Presence (cursors, selections) is owned by the browser clients — the server
|
|
310
|
+
never sets or holds presence state, it only relays awareness frames opaquely.
|
|
311
|
+
See `yrb-lite-client` for the client-side awareness API.
|
|
329
312
|
|
|
330
313
|
## Thread Safety
|
|
331
314
|
|
|
332
|
-
`Doc`
|
|
333
|
-
|
|
334
|
-
|
|
315
|
+
A `Doc` is safe to share across Ruby threads — used concurrently from Puma
|
|
316
|
+
workers, ActionCable connection threads, or background jobs without external
|
|
317
|
+
locking.
|
|
335
318
|
|
|
336
319
|
That comes from how the underlying types work, not from locking on top:
|
|
337
320
|
|
|
@@ -41,15 +41,12 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
41
41
|
# validated against `on_load`, recorded through `on_change`, then broadcast.
|
|
42
42
|
# No authoritative document state is kept in ActionCable process memory.
|
|
43
43
|
module Sync
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# be processed or relayed.
|
|
48
|
-
MSG_KIND_DROP = 0
|
|
44
|
+
# Frame kinds we act on, from Awareness#message_kind. The other codes it can
|
|
45
|
+
# return -- 0 (drop: malformed/truncated/multi-message/unknown) and 4
|
|
46
|
+
# (awareness query) -- fall through to a no-op in the dispatch below.
|
|
49
47
|
MSG_KIND_SYNC_STEP1 = 1
|
|
50
48
|
MSG_KIND_UPDATE = 2
|
|
51
49
|
MSG_KIND_AWARENESS = 3
|
|
52
|
-
MSG_KIND_AWARENESS_QUERY = 4
|
|
53
50
|
|
|
54
51
|
# Default incoming-frame size cap (decoded bytes). Generous enough for a
|
|
55
52
|
# large initial SyncStep2, small enough to bound a single message's
|
|
@@ -61,10 +58,11 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
61
58
|
end
|
|
62
59
|
|
|
63
60
|
module ClassMethods
|
|
64
|
-
# Load persisted document state. Called once per key with (key);
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
# Load persisted document state. Called once per key with (key); return a
|
|
62
|
+
# binary Y.js update (or nil for a fresh document). The block runs in the
|
|
63
|
+
# channel instance's context, the same as on_change (see below).
|
|
64
|
+
def on_load(&block)
|
|
65
|
+
@on_load = block if block
|
|
68
66
|
return @on_load if defined?(@on_load) && @on_load
|
|
69
67
|
|
|
70
68
|
superclass.respond_to?(:on_load) ? superclass.on_load : nil
|
|
@@ -75,13 +73,12 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
75
73
|
# the exact CRDT delta. If the block raises, the change is rejected:
|
|
76
74
|
# neither acknowledged nor broadcast to other subscribers.
|
|
77
75
|
#
|
|
78
|
-
#
|
|
79
|
-
# call the channel's own methods (current_user, params, a
|
|
80
|
-
# Current.* accessor) directly, with no thread-local
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@on_change = callable || block if callable || block
|
|
76
|
+
# The block runs in the *channel instance's* context (via instance_exec),
|
|
77
|
+
# so it can call the channel's own methods (current_user, params, a
|
|
78
|
+
# per-connection Current.* accessor) directly, with no thread-local
|
|
79
|
+
# plumbing. on_change always fires from within sync_receive.
|
|
80
|
+
def on_change(&block)
|
|
81
|
+
@on_change = block if block
|
|
85
82
|
return @on_change if defined?(@on_change) && @on_change
|
|
86
83
|
|
|
87
84
|
superclass.respond_to?(:on_change) ? superclass.on_change : nil
|
|
@@ -106,8 +103,11 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
106
103
|
@sync_origin = SecureRandom.hex(8)
|
|
107
104
|
sync_require_store_recorder!
|
|
108
105
|
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
# The document stream is never whisper-enabled; under AnyCable we also
|
|
107
|
+
# subscribe an awareness stream with `whisper: true`, scoping the client-to-
|
|
108
|
+
# client path to ephemeral presence rather than the durable document stream.
|
|
109
|
+
stream_from sync_stream_name
|
|
110
|
+
stream_from sync_awareness_stream_name, whisper: true if respond_to?(:whispers_to)
|
|
111
111
|
sync_transmit(sync_load_doc.sync_step1)
|
|
112
112
|
end
|
|
113
113
|
|
|
@@ -145,18 +145,11 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
145
145
|
|
|
146
146
|
return if cap && bytes.bytesize > cap
|
|
147
147
|
|
|
148
|
-
sync_send_ack(id,
|
|
148
|
+
sync_send_ack(id, sync_handle_frame(encoded, bytes))
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
# delivery ack. A dropped frame returns nil (never acked).
|
|
154
|
-
def sync_dispatch(encoded, bytes)
|
|
155
|
-
sync_receive_store_backed(encoded, bytes)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Kept as the ActionCable lifecycle hook target. There is no cached document
|
|
159
|
-
# or server-owned presence state to clean up in the store-backed design.
|
|
151
|
+
# The `unsubscribed` hook target. Nothing to clean up: the server keeps no
|
|
152
|
+
# per-connection document or presence state.
|
|
160
153
|
def sync_unsubscribed(key = nil)
|
|
161
154
|
@sync_key = key.to_s if key
|
|
162
155
|
end
|
|
@@ -201,7 +194,6 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
201
194
|
transmit(sync_envelope(Base64.strict_encode64(bytes)))
|
|
202
195
|
end
|
|
203
196
|
|
|
204
|
-
# Build an outgoing envelope.
|
|
205
197
|
def sync_envelope(encoded, extra = {})
|
|
206
198
|
{ "update" => encoded }.merge(extra)
|
|
207
199
|
end
|
|
@@ -223,53 +215,44 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
223
215
|
"that never happened, and a cold load would lose the edit."
|
|
224
216
|
end
|
|
225
217
|
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
#
|
|
230
|
-
def sync_stream(name, **, &)
|
|
231
|
-
stream_from(name, **, &)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Stateless per message: no warm replica, no assumptions about which process
|
|
235
|
-
# owns a document. A client's SyncStep1 is answered from the store, document
|
|
236
|
-
# changes are recorded durably before relay and then broadcast, and
|
|
237
|
-
# awareness is relayed best-effort. Echoing back to the sender is harmless,
|
|
238
|
-
# since the CRDT apply is idempotent.
|
|
218
|
+
# Stateless per message: any process can handle any document. A client's
|
|
219
|
+
# SyncStep1 is answered from the store, document changes are recorded durably
|
|
220
|
+
# before relay and then broadcast, and awareness is relayed best-effort.
|
|
221
|
+
# Echoing back to the sender is harmless, since the CRDT apply is idempotent.
|
|
239
222
|
#
|
|
240
223
|
# Returns an outcome symbol for the reliable-delivery ack: :recorded when a
|
|
241
224
|
# document update was durably recorded and relayed, :gap when it was
|
|
242
225
|
# rejected for a resync, :noop for everything else.
|
|
243
|
-
def
|
|
226
|
+
def sync_handle_frame(encoded, bytes)
|
|
244
227
|
sync_require_store_recorder!
|
|
245
228
|
|
|
246
|
-
case
|
|
229
|
+
case YrbLite.message_kind(bytes)
|
|
247
230
|
when MSG_KIND_SYNC_STEP1
|
|
248
231
|
result = sync_load_doc.handle_sync_message(bytes)
|
|
249
232
|
sync_transmit(result[2]) if result
|
|
250
233
|
:noop
|
|
251
234
|
when MSG_KIND_UPDATE
|
|
252
|
-
update =
|
|
235
|
+
update = YrbLite.update_from_message(bytes)
|
|
253
236
|
return :noop unless update
|
|
254
237
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# Lost-ack retry: the durable store already contains this update.
|
|
266
|
-
# Ack it without recording or relaying again.
|
|
267
|
-
next :applied unless doc.update_advances?(update)
|
|
268
|
-
|
|
269
|
-
sync_record_change(self.class.on_change, update) # record before relay
|
|
270
|
-
sync_distribute(encoded)
|
|
271
|
-
:recorded
|
|
238
|
+
# Rebuild from the store (O(history) per update; snapshot in on_load if
|
|
239
|
+
# that cost bites).
|
|
240
|
+
doc = sync_load_doc
|
|
241
|
+
|
|
242
|
+
# Don't record a causally-incomplete update; resync instead so the gap
|
|
243
|
+
# heals as one complete delta.
|
|
244
|
+
unless doc.update_ready?(update)
|
|
245
|
+
sync_request_resync(doc)
|
|
246
|
+
return :gap
|
|
272
247
|
end
|
|
248
|
+
|
|
249
|
+
# Skip a lost-ack retry the store already has. Best-effort, not
|
|
250
|
+
# cross-process exactly-once (see "Delivery guarantees" in the README).
|
|
251
|
+
return :applied unless doc.update_advances?(update)
|
|
252
|
+
|
|
253
|
+
sync_record_change(self.class.on_change, update) # record before relay
|
|
254
|
+
sync_distribute(encoded)
|
|
255
|
+
:recorded
|
|
273
256
|
when MSG_KIND_AWARENESS
|
|
274
257
|
sync_distribute(encoded)
|
|
275
258
|
:noop
|
|
@@ -281,7 +264,8 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
281
264
|
# Build a fresh document from the durable store (on_load).
|
|
282
265
|
def sync_load_doc
|
|
283
266
|
doc = YrbLite::Doc.new
|
|
284
|
-
|
|
267
|
+
loader = self.class.on_load
|
|
268
|
+
state = instance_exec(@sync_key, &loader) if loader
|
|
285
269
|
doc.apply_update(state) if state
|
|
286
270
|
doc
|
|
287
271
|
end
|
|
@@ -294,19 +278,14 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
294
278
|
"#{sync_stream_name}:awareness"
|
|
295
279
|
end
|
|
296
280
|
|
|
297
|
-
# Invoke the on_change recorder
|
|
298
|
-
#
|
|
299
|
-
# non-Proc callable is invoked with #call, since it carries its own context.
|
|
281
|
+
# Invoke the on_change recorder in this channel instance's context
|
|
282
|
+
# (instance_exec) so it can reach the channel's own methods.
|
|
300
283
|
def sync_record_change(recorder, update)
|
|
301
|
-
|
|
302
|
-
recorder.is_a?(Proc) ? instance_exec(*args, &recorder) : recorder.call(*args)
|
|
284
|
+
instance_exec(@sync_key, update, &recorder)
|
|
303
285
|
end
|
|
304
286
|
|
|
305
287
|
# -- Shared process state ----------------------------------------------
|
|
306
288
|
|
|
307
|
-
@locks = {}
|
|
308
|
-
@registry_mutex = Mutex.new
|
|
309
|
-
|
|
310
289
|
class << self
|
|
311
290
|
# A stable id for this server process, stamped on every broadcast so
|
|
312
291
|
# other processes know to apply it to their replica and this process
|
|
@@ -314,29 +293,6 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
314
293
|
def process_id
|
|
315
294
|
@process_id ||= SecureRandom.hex(8)
|
|
316
295
|
end
|
|
317
|
-
|
|
318
|
-
# A shared, stateless decoder for the store-backed path. message_kind and
|
|
319
|
-
# update_from_message only read their argument (they don't touch the
|
|
320
|
-
# instance's document), so one shared instance is safe across threads.
|
|
321
|
-
def codec
|
|
322
|
-
@codec ||= YrbLite::Awareness.new
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Per-document mutex serializing load -> record -> broadcast within this
|
|
326
|
-
# process. The durable store remains the cross-process source of truth.
|
|
327
|
-
# Only briefly holds the registry mutex to fetch/create the lock; the
|
|
328
|
-
# durable write itself runs while holding only this per-key lock.
|
|
329
|
-
def lock_for(key)
|
|
330
|
-
@registry_mutex.synchronize { @locks[key] ||= Mutex.new }
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
# Clear process-local locks and codec (useful for testing).
|
|
334
|
-
def reset!
|
|
335
|
-
@registry_mutex.synchronize do
|
|
336
|
-
@locks = {}
|
|
337
|
-
@codec = nil
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
296
|
end
|
|
341
297
|
end
|
|
342
298
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yrb-lite-actioncable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.beta5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- JP Camara
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.1.0.
|
|
32
|
+
version: 0.1.0.beta9
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.1.0.
|
|
39
|
+
version: 0.1.0.beta9
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: actioncable
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|