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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d834b5052572c1d84374702a2c45963522725abf47eaaefd62acc934b75a4fe
4
- data.tar.gz: 025f4e5ae96c0fd3c9af6e43e2ddd16c4bc22cb34b95b17ad70f8ff74283557b
3
+ metadata.gz: 44da78141b84de4cf438a8b0315b4fdf92bf10290787f569e1c9f63087f2abf8
4
+ data.tar.gz: dce6b3a9227637b8e8828c8d23726b178107c19dd8670bd580da6d71d848bb6a
5
5
  SHA512:
6
- metadata.gz: 73e9cd5d0bf9ffbf3c729f54390731376dd443d1f59fa597917c926f3c6326089903ab7b9c67a2dc037de343368d6f1f045f937fae99a2c01d430500fd0f6fe5
7
- data.tar.gz: 67ad34f34202019617905d035e6cfb91f3693534eebed93d3fbb612b1e410bc75803f4bc2c311bedff076bed15c3718a4100153e3b42ae1de74e9cae0d7f73a5
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 = sync_for(params[:id])
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
- sync_for params[:id]
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 = sync_for(params[:id])
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`/`sync_unsubscribed` so the document key
233
- survives AnyCable's per-command instances.
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 = sync_for(params[:id])
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
@@ -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` and
39
- /// `&Mutex<Awareness>` are fine: both are `Sync` (asserted above).
40
- /// - LOCK DISCIPLINE: any native lock it takes -- the doc's internal RwLock OR
41
- /// the awareness `Mutex` (`self.0.lock()`) -- must be acquired AND released
42
- /// inside this closure (GVL already dropped). Never lock with the GVL held
43
- /// (e.g. before calling `nogvl`), or a thread waiting on the lock while
44
- /// holding the GVL can deadlock against the GVL reacquire. Same reason we
45
- /// never hold a lock across the GVL boundary.
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 -- an earlier
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 sync message and return response (if any)
208
- /// Returns [message_type, sync_type, response_bytes] or nil
209
- fn handle_sync_message(&self, data: RString) -> Result<Option<(u8, u8, RString)>, Error> {
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
- Message::AwarenessQuery => Ok((3, 0, Vec::new())),
245
- Message::Auth(_) => Ok((2, 0, Vec::new())),
246
- Message::Custom(tag, _) => Ok((tag, 0, Vec::new())),
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(Some((msg_type, sync_type, binary_string(&response))))
250
+ Ok((msg_type, sync_type, binary_string(&response)))
252
251
  }
253
252
  }
254
253
 
255
254
  // ============================================================================
256
- // Protocol codec (stateless) -- exposed as `YrbLite` module functions
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 rust protocol helpers (ie, no Ruby interop).
1
+ // Pure Rust protocol helpers (no Ruby interop).
2
2
  //
3
- // CLIENT IDs ARE NOT VALIDATED HERE -- on purpose. We deliberately don't police it -- every
4
- // legitimate peer (browser Yjs, and yrs's own `ClientID::random`) already emits
5
- // 53-bit ids, so it's the client's responsibility not to send a bad one, and we
6
- // don't want to own that logic.
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 (see `RbAwareness::message_kind` for codes).
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 -- the signal that an
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` -- i.e. it carries
80
- /// content the doc doesn't already have. Lets the server make durable side
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
- /// NOT re-run `on_change`.
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
- /// EMPTY state_vector() for a causally-pending diff (e.g. a resync delta whose
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 -- we
92
- /// conservatively report it as advancing (record it). That can still
93
- /// double-record a pure-delete retry, but it NEVER drops a real deletion, which
94
- /// is the safe direction. Assumes the update is already causally ready.
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 -- blocks that
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 EMPTY state_vector() in isolation -- a naive check would
217
- // misread it as a no-op. Verify the trial-apply gets it right.
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
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Entry point matching the gem name, so `Bundler.require` works out of the box.
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
4
  require "yrb_lite"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YrbLite
4
- VERSION = "0.1.0.beta9"
4
+ VERSION = "0.2.0"
5
5
  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.beta9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara