yrb-lite-actioncable 0.1.0.beta5 → 0.2.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 +4 -4
- data/README.md +42 -15
- data/lib/yrb-lite-actioncable.rb +1 -1
- data/lib/yrb_lite/action_cable/sync.rb +79 -64
- 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: 927dca0304b576a035cbbeff9f5e4c72a9c947c01e4a8de584462842b4412d13
|
|
4
|
+
data.tar.gz: 6c7cdde2224501295ee6238562876cf1bd7caea452ac8bac26a8376387cc5e26
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3d2b8170e6749403cc9bfb9f84734fc1346fc4200192bd0a35d6e119004aee5d040e5961f2990ac247e1a33665803df532e36a717b20ed6c8be282dd94e2731
|
|
7
|
+
data.tar.gz: c811d7a6ad403ab3465d0d4469751c7632fb5012b950e0854445c2b2f3238f7d582f93456f919de844d3630dd3e96cd8a5eb2ddd699df23273a2b0848fe66992
|
data/README.md
CHANGED
|
@@ -11,9 +11,8 @@ documents.
|
|
|
11
11
|
class DocumentChannel < ApplicationCable::Channel
|
|
12
12
|
include YrbLite::ActionCable::Sync
|
|
13
13
|
|
|
14
|
-
def subscribed =
|
|
14
|
+
def subscribed = sync_subscribed(params[:id])
|
|
15
15
|
def receive(data) = sync_receive(data)
|
|
16
|
-
def unsubscribed = sync_unsubscribed(params[:id])
|
|
17
16
|
end
|
|
18
17
|
```
|
|
19
18
|
|
|
@@ -35,6 +34,22 @@ What it doesn't do: auth, read-only connections, rate limiting, webhooks,
|
|
|
35
34
|
metrics. Hocuspocus ships extensions for those; here you'd build them with
|
|
36
35
|
Rails.
|
|
37
36
|
|
|
37
|
+
## Why "lite"
|
|
38
|
+
|
|
39
|
+
The "lite" is the size of the surface. yrb-lite binds just the part of y-crdt you
|
|
40
|
+
need to *sync and persist* collaborative documents — a `Doc`, awareness, and the
|
|
41
|
+
y-websocket protocol primitives. The Ruby side treats a document as opaque CRDT
|
|
42
|
+
state: it applies updates, answers sync handshakes, and records deltas, but never
|
|
43
|
+
reaches in to read or edit the contents. The browser editor owns the document's
|
|
44
|
+
shape; Rails owns durability and delivery.
|
|
45
|
+
|
|
46
|
+
A full y-crdt Ruby binding like `y-rb` gives you the whole type system — shared
|
|
47
|
+
text, arrays, maps, XML — to build and query documents in Ruby. yrb-lite leaves
|
|
48
|
+
that out on purpose. What's left is a sync engine plus a one-include ActionCable
|
|
49
|
+
concern, with the server concerns it skips (auth, rate limiting, metrics — see
|
|
50
|
+
above) built from the Rails you already run, and no Node process hosting the
|
|
51
|
+
documents.
|
|
52
|
+
|
|
38
53
|
## Testing
|
|
39
54
|
|
|
40
55
|
Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a
|
|
@@ -130,16 +145,12 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
130
145
|
on_change { |key, update| MyStore.append(key, update) } # durable record
|
|
131
146
|
|
|
132
147
|
def subscribed
|
|
133
|
-
|
|
148
|
+
sync_subscribed params[:id]
|
|
134
149
|
end
|
|
135
150
|
|
|
136
151
|
def receive(data)
|
|
137
152
|
sync_receive(data, params[:id])
|
|
138
153
|
end
|
|
139
|
-
|
|
140
|
-
def unsubscribed
|
|
141
|
-
sync_unsubscribed(params[:id])
|
|
142
|
-
end
|
|
143
154
|
end
|
|
144
155
|
```
|
|
145
156
|
|
|
@@ -182,6 +193,26 @@ servers:
|
|
|
182
193
|
idempotent** if duplicate side effects would matter (a webhook, a counter) — a
|
|
183
194
|
raw append-only delta log is naturally fine, since it replays to the same
|
|
184
195
|
document either way.
|
|
196
|
+
- **A raising `on_change` rejects the update implicitly.** If the block raises,
|
|
197
|
+
the update is neither acked nor broadcast (record-before-distribute stops both).
|
|
198
|
+
There is no negative-ack: the client simply never receives the ack, keeps the
|
|
199
|
+
update pending, and retransmits on its timer/reconnect. This is built for
|
|
200
|
+
*transient* failures (the store is briefly down → a retry lands). A block that
|
|
201
|
+
raises *deterministically* — a validation that always fails for this edit —
|
|
202
|
+
will be retried forever, since nothing tells the client to stop. Enforce hard
|
|
203
|
+
rejections before the edit reaches `on_change` (channel authorization in
|
|
204
|
+
`subscribed`), not by raising inside it.
|
|
205
|
+
- **An over-cap frame is dropped the same silent way.** A frame larger than
|
|
206
|
+
`max_frame_bytes` (default 8 MiB) is dropped before decoding — no ack, no
|
|
207
|
+
broadcast — to bound the work a client can force. For a genuine document
|
|
208
|
+
update that means the same implicit rejection as above: unacked, retransmitted
|
|
209
|
+
forever. Normal typing never approaches the cap, but a large paste, an embedded
|
|
210
|
+
image, or a big initial `SyncStep2` can. The drop is logged (`warn` for
|
|
211
|
+
over-cap, `debug` for undecodable) with the document key and update id so it's
|
|
212
|
+
findable; override `sync_log_context` on the channel to add a user/connection
|
|
213
|
+
id. Size the cap for your largest expected payload, and reject
|
|
214
|
+
genuinely-too-big content upstream rather than relying on the cap to reject it
|
|
215
|
+
gracefully.
|
|
185
216
|
|
|
186
217
|
There is deliberately no in-gem cross-process lock. One that only spanned a
|
|
187
218
|
single process would give exactly-once at small scale and silently degrade as
|
|
@@ -215,9 +246,8 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
215
246
|
on_load { |key| MyStore.load(key) } # required: source of truth
|
|
216
247
|
on_change { |key, update| MyStore.append(key, update) } # required: record
|
|
217
248
|
|
|
218
|
-
def subscribed =
|
|
249
|
+
def subscribed = sync_subscribed(params[:id])
|
|
219
250
|
def receive(data) = sync_receive(data, params[:id]) # pass the key each call
|
|
220
|
-
def unsubscribed = sync_unsubscribed(params[:id])
|
|
221
251
|
end
|
|
222
252
|
```
|
|
223
253
|
|
|
@@ -229,8 +259,8 @@ end
|
|
|
229
259
|
separate awareness stream with AnyCable `whisper: true`, so cursor traffic can
|
|
230
260
|
take the low-latency client-to-client path without bypassing document
|
|
231
261
|
durability.
|
|
232
|
-
- Pass `params[:id]` into `sync_receive
|
|
233
|
-
|
|
262
|
+
- Pass `params[:id]` into `sync_receive` so the document key survives AnyCable's
|
|
263
|
+
per-command instances.
|
|
234
264
|
- The sender gets its own updates echoed back (no Ruby callback to filter them).
|
|
235
265
|
That's a no-op, since applying an update twice does nothing.
|
|
236
266
|
|
|
@@ -267,9 +297,8 @@ class DocumentChannel < ApplicationCable::Channel
|
|
|
267
297
|
AuditLog.append!(key, update) # raise to REJECT the change
|
|
268
298
|
end
|
|
269
299
|
|
|
270
|
-
def subscribed =
|
|
300
|
+
def subscribed = sync_subscribed(params[:id])
|
|
271
301
|
def receive(data) = sync_receive(data, params[:id])
|
|
272
|
-
def unsubscribed = sync_unsubscribed(params[:id])
|
|
273
302
|
end
|
|
274
303
|
```
|
|
275
304
|
|
|
@@ -362,8 +391,6 @@ exceptions.
|
|
|
362
391
|
```ruby
|
|
363
392
|
YrbLite::MSG_SYNC # 0 - Document sync messages
|
|
364
393
|
YrbLite::MSG_AWARENESS # 1 - User presence data
|
|
365
|
-
YrbLite::MSG_AUTH # 2 - Authentication
|
|
366
|
-
YrbLite::MSG_QUERY_AWARENESS # 3 - Request awareness state
|
|
367
394
|
|
|
368
395
|
YrbLite::MSG_SYNC_STEP1 # 0 - State vector request
|
|
369
396
|
YrbLite::MSG_SYNC_STEP2 # 1 - Update response
|
data/lib/yrb-lite-actioncable.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "yrb_lite"
|
|
4
4
|
require "base64"
|
|
5
|
-
require "securerandom"
|
|
6
5
|
|
|
7
6
|
module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
8
7
|
# y-websocket protocol over ActionCable.
|
|
@@ -12,7 +11,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
12
11
|
# y-protocols binary messages, base64-encoded in a JSON envelope:
|
|
13
12
|
#
|
|
14
13
|
# { "update" => "<base64 bytes>", "id" => 42 } # client -> server
|
|
15
|
-
# { "update" => "
|
|
14
|
+
# { "update" => "<base64 bytes>" } # server -> subscribers
|
|
16
15
|
# { "ack" => 42 } # server -> sender
|
|
17
16
|
#
|
|
18
17
|
# Example:
|
|
@@ -20,30 +19,29 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
20
19
|
# include YrbLite::ActionCable::Sync
|
|
21
20
|
#
|
|
22
21
|
# on_load { |key| Document.find_by(key: key)&.content }
|
|
23
|
-
# # on_change
|
|
24
|
-
# #
|
|
22
|
+
# # on_change runs in the channel instance's context, so instance methods
|
|
23
|
+
# # (current_user, params, ...) are available:
|
|
25
24
|
# on_change { |key, update| Document.record!(key, update, by: current_user) }
|
|
26
25
|
#
|
|
27
26
|
# def subscribed
|
|
28
|
-
#
|
|
27
|
+
# sync_subscribed params[:id]
|
|
29
28
|
# end
|
|
30
29
|
#
|
|
31
30
|
# def receive(data)
|
|
32
31
|
# sync_receive(data)
|
|
33
32
|
# end
|
|
34
|
-
#
|
|
35
|
-
# def unsubscribed
|
|
36
|
-
# sync_unsubscribed
|
|
37
|
-
# end
|
|
38
33
|
# end
|
|
39
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
|
+
#
|
|
40
38
|
# The concern is store-backed and fail-closed: every document update is
|
|
41
39
|
# validated against `on_load`, recorded through `on_change`, then broadcast.
|
|
42
40
|
# No authoritative document state is kept in ActionCable process memory.
|
|
43
41
|
module Sync
|
|
44
|
-
# Frame kinds we act on, from
|
|
45
|
-
#
|
|
46
|
-
#
|
|
42
|
+
# Frame kinds we act on, from YrbLite.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.
|
|
47
45
|
MSG_KIND_SYNC_STEP1 = 1
|
|
48
46
|
MSG_KIND_UPDATE = 2
|
|
49
47
|
MSG_KIND_AWARENESS = 3
|
|
@@ -59,8 +57,8 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
59
57
|
|
|
60
58
|
module ClassMethods
|
|
61
59
|
# Load persisted document state. Called once per key with (key); return a
|
|
62
|
-
# binary Y.js update (or nil for a fresh document).
|
|
63
|
-
#
|
|
60
|
+
# binary Y.js update (or nil for a fresh document). Runs in the channel
|
|
61
|
+
# instance's context (instance_exec).
|
|
64
62
|
def on_load(&block)
|
|
65
63
|
@on_load = block if block
|
|
66
64
|
return @on_load if defined?(@on_load) && @on_load
|
|
@@ -73,10 +71,8 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
73
71
|
# the exact CRDT delta. If the block raises, the change is rejected:
|
|
74
72
|
# neither acknowledged nor broadcast to other subscribers.
|
|
75
73
|
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# per-connection Current.* accessor) directly, with no thread-local
|
|
79
|
-
# plumbing. on_change always fires from within sync_receive.
|
|
74
|
+
# Runs in the channel instance's context (instance_exec). Fires from within
|
|
75
|
+
# sync_receive.
|
|
80
76
|
def on_change(&block)
|
|
81
77
|
@on_change = block if block
|
|
82
78
|
return @on_change if defined?(@on_change) && @on_change
|
|
@@ -89,6 +85,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
89
85
|
# parsing, so a client can't force huge allocations/CPU (a DoS vector).
|
|
90
86
|
# Defaults to DEFAULT_MAX_FRAME_BYTES; set to nil to disable the cap.
|
|
91
87
|
def max_frame_bytes(bytes = :__unset__)
|
|
88
|
+
# Combined reader/writer; the sentinel keeps nil a real value (disables the cap).
|
|
92
89
|
@max_frame_bytes = bytes unless bytes == :__unset__
|
|
93
90
|
return @max_frame_bytes if defined?(@max_frame_bytes)
|
|
94
91
|
|
|
@@ -98,10 +95,9 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
98
95
|
|
|
99
96
|
# Call from `subscribed`. Streams broadcasts for this document and
|
|
100
97
|
# transmits the server's opening handshake (SyncStep1 from the store).
|
|
101
|
-
def
|
|
98
|
+
def sync_subscribed(key)
|
|
102
99
|
@sync_key = key.to_s
|
|
103
|
-
|
|
104
|
-
sync_require_store_recorder!
|
|
100
|
+
sync_validate_required_hooks!
|
|
105
101
|
|
|
106
102
|
# The document stream is never whisper-enabled; under AnyCable we also
|
|
107
103
|
# subscribe an awareness stream with `whisper: true`, scoping the client-to-
|
|
@@ -117,7 +113,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
117
113
|
#
|
|
118
114
|
# Reliable delivery: document updates carry an "id", and the server replies
|
|
119
115
|
# `{ "ack" => id }` once the update has been durably recorded. A
|
|
120
|
-
# causally-gapped update is not acked
|
|
116
|
+
# causally-gapped update is not acked; it gets a resync instead, so the
|
|
121
117
|
# client retransmits until the update lands.
|
|
122
118
|
def sync_receive(data, key = nil)
|
|
123
119
|
# Pass `key` (params[:id]) when your transport doesn't keep the channel
|
|
@@ -129,37 +125,41 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
129
125
|
return unless encoded.is_a?(String)
|
|
130
126
|
|
|
131
127
|
# Optional client-supplied id for reliable delivery (see sync_send_ack).
|
|
132
|
-
|
|
128
|
+
# data is known to be a Hash here (encoded came from it above).
|
|
129
|
+
id = data["id"]
|
|
133
130
|
|
|
134
131
|
# Frame-size cap: drop oversized frames before decoding (the encoded form
|
|
135
132
|
# is ~4/3 the decoded size) and again after, so a client can't force large
|
|
136
|
-
# base64 decodes / native parses / merges. A dropped frame is never acked
|
|
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.
|
|
137
136
|
cap = self.class.max_frame_bytes
|
|
138
|
-
|
|
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
|
|
139
141
|
|
|
140
142
|
begin
|
|
141
143
|
bytes = Base64.strict_decode64(encoded)
|
|
142
144
|
rescue ArgumentError
|
|
143
|
-
|
|
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
|
|
144
147
|
end
|
|
145
148
|
|
|
146
|
-
|
|
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
|
|
147
153
|
|
|
148
154
|
sync_send_ack(id, sync_handle_frame(encoded, bytes))
|
|
149
155
|
end
|
|
150
156
|
|
|
151
|
-
# The `unsubscribed` hook target. Nothing to clean up: the server keeps no
|
|
152
|
-
# per-connection document or presence state.
|
|
153
|
-
def sync_unsubscribed(key = nil)
|
|
154
|
-
@sync_key = key.to_s if key
|
|
155
|
-
end
|
|
156
|
-
|
|
157
157
|
private
|
|
158
158
|
|
|
159
159
|
# Ask this connection's client to resync: re-send SyncStep1 carrying the
|
|
160
160
|
# server's current (gap-free) state vector. The client replies SyncStep2
|
|
161
161
|
# with everything the server is missing, delivered as one causally-complete
|
|
162
|
-
# delta
|
|
162
|
+
# delta, which heals the gap that triggered the resync.
|
|
163
163
|
def sync_request_resync(doc)
|
|
164
164
|
sync_transmit(doc.sync_step1)
|
|
165
165
|
end
|
|
@@ -174,7 +174,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
174
174
|
return if id.nil?
|
|
175
175
|
return unless %i[recorded applied].include?(outcome)
|
|
176
176
|
|
|
177
|
-
#
|
|
177
|
+
# The braces are required: a bare hash would bind to transmit's `via:`
|
|
178
178
|
# keyword instead of its positional data argument.
|
|
179
179
|
transmit({ "ack" => id })
|
|
180
180
|
end
|
|
@@ -183,10 +183,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
183
183
|
# observe distribution. Store-backed streams intentionally echo to the
|
|
184
184
|
# sender; applying the same CRDT update twice is a no-op.
|
|
185
185
|
def sync_distribute(encoded)
|
|
186
|
-
ActionCable.server.broadcast(
|
|
187
|
-
sync_stream_name,
|
|
188
|
-
sync_envelope(encoded, "origin" => @sync_origin, "pid" => Sync.process_id)
|
|
189
|
-
)
|
|
186
|
+
ActionCable.server.broadcast(sync_stream_name, sync_envelope(encoded))
|
|
190
187
|
end
|
|
191
188
|
|
|
192
189
|
# Transmit raw protocol bytes to this connection.
|
|
@@ -194,16 +191,44 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
194
191
|
transmit(sync_envelope(Base64.strict_encode64(bytes)))
|
|
195
192
|
end
|
|
196
193
|
|
|
197
|
-
def sync_envelope(encoded
|
|
198
|
-
{ "update" => encoded }
|
|
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
|
+
"[yrb-lite] dropped frame (#{parts.join(" ")}): #{reason}"
|
|
223
|
+
end
|
|
199
224
|
end
|
|
200
225
|
|
|
201
|
-
# This concern acks updates as
|
|
226
|
+
# This concern acks updates as durably recorded, so it must have both a
|
|
202
227
|
# loader (to rebuild the doc and detect causal gaps) and a recorder (to
|
|
203
228
|
# actually persist before acking). Fail closed rather than silently acking
|
|
204
|
-
# and broadcasting updates that were never stored
|
|
229
|
+
# and broadcasting updates that were never stored, which a cold load or
|
|
205
230
|
# reconnect would then lose.
|
|
206
|
-
def
|
|
231
|
+
def sync_validate_required_hooks!
|
|
207
232
|
missing = []
|
|
208
233
|
missing << :on_load unless self.class.on_load
|
|
209
234
|
missing << :on_change unless self.class.on_change
|
|
@@ -224,12 +249,12 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
224
249
|
# document update was durably recorded and relayed, :gap when it was
|
|
225
250
|
# rejected for a resync, :noop for everything else.
|
|
226
251
|
def sync_handle_frame(encoded, bytes)
|
|
227
|
-
|
|
252
|
+
sync_validate_required_hooks!
|
|
228
253
|
|
|
229
254
|
case YrbLite.message_kind(bytes)
|
|
230
255
|
when MSG_KIND_SYNC_STEP1
|
|
231
256
|
result = sync_load_doc.handle_sync_message(bytes)
|
|
232
|
-
sync_transmit(result[2])
|
|
257
|
+
sync_transmit(result[2])
|
|
233
258
|
:noop
|
|
234
259
|
when MSG_KIND_UPDATE
|
|
235
260
|
update = YrbLite.update_from_message(bytes)
|
|
@@ -250,7 +275,7 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
250
275
|
# cross-process exactly-once (see "Delivery guarantees" in the README).
|
|
251
276
|
return :applied unless doc.update_advances?(update)
|
|
252
277
|
|
|
253
|
-
sync_record_change(
|
|
278
|
+
sync_record_change(update) # record before relay
|
|
254
279
|
sync_distribute(encoded)
|
|
255
280
|
:recorded
|
|
256
281
|
when MSG_KIND_AWARENESS
|
|
@@ -261,11 +286,11 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
261
286
|
end
|
|
262
287
|
end
|
|
263
288
|
|
|
264
|
-
# Build a fresh document from the durable store (on_load).
|
|
289
|
+
# Build a fresh document from the durable store (on_load). Callers validate
|
|
290
|
+
# the hooks first, so on_load is present; a nil state means a fresh document.
|
|
265
291
|
def sync_load_doc
|
|
266
292
|
doc = YrbLite::Doc.new
|
|
267
|
-
|
|
268
|
-
state = instance_exec(@sync_key, &loader) if loader
|
|
293
|
+
state = instance_exec(@sync_key, &self.class.on_load)
|
|
269
294
|
doc.apply_update(state) if state
|
|
270
295
|
doc
|
|
271
296
|
end
|
|
@@ -279,20 +304,10 @@ module YrbLite::ActionCable # rubocop:disable Style/ClassAndModuleChildren
|
|
|
279
304
|
end
|
|
280
305
|
|
|
281
306
|
# Invoke the on_change recorder in this channel instance's context
|
|
282
|
-
# (instance_exec) so it can reach the channel's own methods.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
# -- Shared process state ----------------------------------------------
|
|
288
|
-
|
|
289
|
-
class << self
|
|
290
|
-
# A stable id for this server process, stamped on every broadcast so
|
|
291
|
-
# other processes know to apply it to their replica and this process
|
|
292
|
-
# knows to skip its own. Survives for the life of the process.
|
|
293
|
-
def process_id
|
|
294
|
-
@process_id ||= SecureRandom.hex(8)
|
|
295
|
-
end
|
|
307
|
+
# (instance_exec) so it can reach the channel's own methods. Mirrors how
|
|
308
|
+
# sync_load_doc fetches and runs on_load.
|
|
309
|
+
def sync_record_change(update)
|
|
310
|
+
instance_exec(@sync_key, update, &self.class.on_change)
|
|
296
311
|
end
|
|
297
312
|
end
|
|
298
313
|
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.
|
|
4
|
+
version: 0.2.0
|
|
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.
|
|
32
|
+
version: 0.2.0
|
|
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.
|
|
39
|
+
version: 0.2.0
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: actioncable
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|