yrb-lite 0.1.0.beta9 → 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/ext/yrb_lite/src/lib.rs +21 -24
- data/ext/yrb_lite/src/protocol.rs +18 -18
- data/lib/yrb-lite.rb +1 -1
- data/lib/yrb_lite/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 44da78141b84de4cf438a8b0315b4fdf92bf10290787f569e1c9f63087f2abf8
|
|
4
|
+
data.tar.gz: dce6b3a9227637b8e8828c8d23726b178107c19dd8670bd580da6d71d848bb6a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4b3df640c6e502acd8545e18e019998c8d4200f3e7c3a6e70fc9f4616dc468fd57253b55d149713d314d4b706a029716dc42a09bbbe461e22c9ec3247241689
|
|
7
|
+
data.tar.gz: 14673427e3ebd711859572c5b589cdccfb0e8727d7e767daac214e9c5cef8d35fa12c3e7192ed415a4e18c3cedae6e4bf03b08ac1070fe4bec9e160a6aede1a7
|
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/ext/yrb_lite/src/lib.rs
CHANGED
|
@@ -35,14 +35,17 @@ fn assert_thread_safe() {
|
|
|
35
35
|
/// - It must not touch any Ruby object or call any Ruby API. Inputs are copied
|
|
36
36
|
/// out of Ruby strings before entering, and results are converted to Ruby
|
|
37
37
|
/// objects after returning.
|
|
38
|
-
/// - It must be `Send` (it runs while other threads own the GVL). `&Doc`
|
|
39
|
-
///
|
|
40
|
-
/// -
|
|
41
|
-
///
|
|
42
|
-
///
|
|
43
|
-
///
|
|
44
|
-
///
|
|
45
|
-
///
|
|
38
|
+
/// - It must be `Send` (it runs while other threads own the GVL). `&Doc` is
|
|
39
|
+
/// fine: it's `Sync` (asserted above).
|
|
40
|
+
/// - Lock discipline: any native lock it takes (the doc's internal RwLock) must
|
|
41
|
+
/// be acquired and released inside this closure, with the GVL already dropped.
|
|
42
|
+
/// Never lock with the GVL held (e.g. before calling `nogvl`), or a thread
|
|
43
|
+
/// waiting on the lock while holding the GVL can deadlock against the GVL
|
|
44
|
+
/// reacquire. Same reason we never hold a lock across the GVL boundary.
|
|
45
|
+
///
|
|
46
|
+
/// The closure runs with no unblock function, so it is not interruptible: a
|
|
47
|
+
/// Thread#kill, timeout, or signal can't preempt it mid-run. That's fine for the
|
|
48
|
+
/// bounded CRDT work it does; never call anything blocking or unbounded inside it.
|
|
46
49
|
///
|
|
47
50
|
/// Panics inside the closure are caught and re-raised (resumed) after the GVL
|
|
48
51
|
/// is reacquired, where magnus converts them to Ruby exceptions.
|
|
@@ -113,11 +116,6 @@ fn yrb_error(msg: String) -> Error {
|
|
|
113
116
|
Error::new(class, msg)
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
// CLIENT IDs ARE NOT VALIDATED -- whoever supplies the id (the app via
|
|
117
|
-
// `Doc.new(id)` / `Awareness.new(id)`, or a remote peer over the wire) is
|
|
118
|
-
// responsible for keeping it JS-safe (<= 2^53 - 1). See the protocol.rs header
|
|
119
|
-
// for why (and `ClientID::try_new`, proposed upstream, for strict rejection).
|
|
120
|
-
|
|
121
119
|
// ============================================================================
|
|
122
120
|
// Doc Implementation
|
|
123
121
|
// ============================================================================
|
|
@@ -176,7 +174,7 @@ impl RbDoc {
|
|
|
176
174
|
}
|
|
177
175
|
|
|
178
176
|
/// True if applying `update` would integrate cleanly (its dependencies are
|
|
179
|
-
/// all present). False means it would leave a pending struct
|
|
177
|
+
/// all present). False means it would leave a pending struct, i.e. an earlier
|
|
180
178
|
/// update is missing. Pure read; does not mutate.
|
|
181
179
|
fn update_ready(&self, update: RString) -> Result<bool, Error> {
|
|
182
180
|
let update_bytes = copy_bytes(update);
|
|
@@ -204,9 +202,10 @@ impl RbDoc {
|
|
|
204
202
|
binary_string(&encoded)
|
|
205
203
|
}
|
|
206
204
|
|
|
207
|
-
/// Handle a
|
|
208
|
-
///
|
|
209
|
-
|
|
205
|
+
/// Handle a Sync or Awareness message, returning
|
|
206
|
+
/// [message_type, sync_type, response_bytes]. Only Sync (step1/step2/update)
|
|
207
|
+
/// and Awareness are handled; any other frame type is rejected.
|
|
208
|
+
fn handle_sync_message(&self, data: RString) -> Result<(u8, u8, RString), Error> {
|
|
210
209
|
let data_bytes = copy_bytes(data);
|
|
211
210
|
let doc = &self.0;
|
|
212
211
|
|
|
@@ -241,19 +240,19 @@ impl RbDoc {
|
|
|
241
240
|
}
|
|
242
241
|
},
|
|
243
242
|
Message::Awareness(_) => Ok((1, 0, Vec::new())),
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
// Auth, awareness-query, and custom frames aren't part of this
|
|
244
|
+
// protocol; reject rather than pretend to handle them.
|
|
245
|
+
_ => Err("unsupported message type".to_string()),
|
|
247
246
|
}
|
|
248
247
|
})
|
|
249
248
|
.map_err(yrb_error)?;
|
|
250
249
|
|
|
251
|
-
Ok(
|
|
250
|
+
Ok((msg_type, sync_type, binary_string(&response)))
|
|
252
251
|
}
|
|
253
252
|
}
|
|
254
253
|
|
|
255
254
|
// ============================================================================
|
|
256
|
-
// Protocol codec (stateless)
|
|
255
|
+
// Protocol codec (stateless), exposed as `YrbLite` module functions
|
|
257
256
|
// ============================================================================
|
|
258
257
|
//
|
|
259
258
|
// The server never holds presence or document state to classify a frame; these
|
|
@@ -331,8 +330,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
331
330
|
// Define message type constants
|
|
332
331
|
module.const_set("MSG_SYNC", 0u8)?;
|
|
333
332
|
module.const_set("MSG_AWARENESS", 1u8)?;
|
|
334
|
-
module.const_set("MSG_AUTH", 2u8)?;
|
|
335
|
-
module.const_set("MSG_QUERY_AWARENESS", 3u8)?;
|
|
336
333
|
module.const_set("MSG_SYNC_STEP1", 0u8)?;
|
|
337
334
|
module.const_set("MSG_SYNC_STEP2", 1u8)?;
|
|
338
335
|
module.const_set("MSG_SYNC_UPDATE", 2u8)?;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Pure
|
|
1
|
+
// Pure Rust protocol helpers (no Ruby interop).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Client ids are not validated here, on purpose: every legitimate peer (browser
|
|
4
|
+
// Yjs, and yrs's own `ClientID::random`) already emits 53-bit ids, so it's the
|
|
5
|
+
// client's responsibility not to send a bad one, and we don't want to own that
|
|
6
|
+
// logic.
|
|
7
7
|
use yrs::encoding::read::{Cursor, Read};
|
|
8
8
|
use yrs::sync::protocol::MessageReader;
|
|
9
9
|
use yrs::sync::{Message, SyncMessage};
|
|
@@ -11,7 +11,7 @@ use yrs::updates::decoder::{Decode, DecoderV1};
|
|
|
11
11
|
use yrs::{Doc, ReadTxn, Transact};
|
|
12
12
|
|
|
13
13
|
/// Classify a frame: a non-zero code only for exactly one well-formed message
|
|
14
|
-
/// that consumes the whole buffer (
|
|
14
|
+
/// that consumes the whole buffer (the codes are the match arms below).
|
|
15
15
|
pub(crate) fn classify_message(bytes: &[u8]) -> u8 {
|
|
16
16
|
let mut decoder = DecoderV1::new(Cursor::new(bytes));
|
|
17
17
|
let msg = match Message::decode(&mut decoder) {
|
|
@@ -69,29 +69,29 @@ pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String>
|
|
|
69
69
|
/// True if applying `update_bytes` to `doc` would integrate cleanly: every
|
|
70
70
|
/// dependency the update references is already present (the doc's state vector
|
|
71
71
|
/// covers the update's lower bound). A pure read; does not mutate the doc.
|
|
72
|
-
/// When false, applying it would park a pending struct
|
|
72
|
+
/// When false, applying it would park a pending struct, the signal that an
|
|
73
73
|
/// earlier, causally-prior update is missing.
|
|
74
74
|
pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
|
|
75
75
|
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
76
76
|
Ok(doc.transact().state_vector() >= update.state_vector_lower())
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/// True if applying `update_bytes` would actually change `doc
|
|
80
|
-
/// content the doc doesn't already have.
|
|
79
|
+
/// True if applying `update_bytes` would actually change `doc`, i.e. it carries
|
|
80
|
+
/// content the doc doesn't already have. This lets the server make durable side
|
|
81
81
|
/// effects exactly-once: a lost-ack retry re-sends an update the server already
|
|
82
82
|
/// applied; that retry is causally ready (so `update_is_ready` is true) but must
|
|
83
|
-
///
|
|
83
|
+
/// not re-run `on_change`.
|
|
84
84
|
///
|
|
85
85
|
/// We can't read the update's own state vector to decide this: yrs reports an
|
|
86
|
-
///
|
|
86
|
+
/// empty state_vector() for a causally-pending diff (e.g. a resync delta whose
|
|
87
87
|
/// structs depend on updates the doc has but the standalone update doesn't),
|
|
88
88
|
/// which would look identical to a no-op. So measure the real effect: seed an
|
|
89
89
|
/// independent probe with the doc's current state, apply the update there, and
|
|
90
90
|
/// see whether the state vector grew. Deletes don't move the state vector, so we
|
|
91
|
-
/// can't cheaply prove a delete-bearing update is a duplicate
|
|
92
|
-
///
|
|
93
|
-
///
|
|
94
|
-
///
|
|
91
|
+
/// can't cheaply prove a delete-bearing update is a duplicate; we conservatively
|
|
92
|
+
/// report it as advancing (record it). That can still double-record a pure-delete
|
|
93
|
+
/// retry, but it never drops a real deletion, which is the safe direction.
|
|
94
|
+
/// Assumes the update is already causally ready.
|
|
95
95
|
pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
|
|
96
96
|
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
97
97
|
if !update.delete_set().is_empty() {
|
|
@@ -114,7 +114,7 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
|
|
|
114
114
|
Ok(after != before)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/// True if the doc holds pending structs or a pending delete set
|
|
117
|
+
/// True if the doc holds pending structs or a pending delete set: blocks that
|
|
118
118
|
/// couldn't integrate because a dependency is missing. Test-only: asserts the
|
|
119
119
|
/// causal-chain parking behavior in the unit tests below.
|
|
120
120
|
#[cfg(test)]
|
|
@@ -213,8 +213,8 @@ mod tests {
|
|
|
213
213
|
#[test]
|
|
214
214
|
fn update_advances_handles_a_dependent_diff_update() {
|
|
215
215
|
// A causally-pending diff (its structs depend on content the doc already
|
|
216
|
-
// has) reports an
|
|
217
|
-
// misread
|
|
216
|
+
// has) reports an empty state_vector() in isolation, which a naive check
|
|
217
|
+
// would misread as a no-op. Verify the trial-apply gets it right.
|
|
218
218
|
let doc = Doc::new();
|
|
219
219
|
let text = doc.get_or_insert_text("content");
|
|
220
220
|
text.insert(&mut doc.transact_mut(), 0, "a");
|
data/lib/yrb-lite.rb
CHANGED
data/lib/yrb_lite/version.rb
CHANGED