yrb-lite 0.1.0.beta6 → 0.1.0.beta9

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: 9553ad44479cc56e9ccd2645017c8ebf3dba79a0d5905651396b5f397ca1ad6a
4
- data.tar.gz: 8c81c7a844a50def1af8b4a2cdbb1bbf95f576077976b7cc6fc4aef740173164
3
+ metadata.gz: 8d834b5052572c1d84374702a2c45963522725abf47eaaefd62acc934b75a4fe
4
+ data.tar.gz: 025f4e5ae96c0fd3c9af6e43e2ddd16c4bc22cb34b95b17ad70f8ff74283557b
5
5
  SHA512:
6
- metadata.gz: 0db8acba6f12e4f9f9289cb80b7d2aa4870ed2c26f090eea1529fd19fa1a1b07c6731ec36097c96a47735f5f6abbf91f592701512250487acf053edd734eb771
7
- data.tar.gz: d5c4e9e3a98f16735b150c76c68e5e7b584c329f554edf12c3949d4104e5b9a06c31fe9d3082bc2f510f67f17fa0ea9402d373ad926320c2f96eb2af744f60a1
6
+ metadata.gz: 73e9cd5d0bf9ffbf3c729f54390731376dd443d1f59fa597917c926f3c6326089903ab7b9c67a2dc037de343368d6f1f045f937fae99a2c01d430500fd0f6fe5
7
+ data.tar.gz: 67ad34f34202019617905d035e6cfb91f3693534eebed93d3fbb612b1e410bc75803f4bc2c311bedff076bed15c3718a4100153e3b42ae1de74e9cae0d7f73a5
data/CHANGELOG.md CHANGED
@@ -5,135 +5,3 @@ All notable changes to this project are documented here. The format is based on
5
5
  to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
-
9
- ## [0.1.0.beta6] - 2026-06-22
10
-
11
- (yrb-lite core gem. The `yrb-lite-client` npm package ships these client changes as 0.1.2.)
12
-
13
- ### Added
14
-
15
- - `yrb-lite-client`, the TypeScript client package for the yrb-lite
16
- ActionCable/AnyCable protocol. It provides `ActionCableProvider`,
17
- `YProtocolSession`, and the standalone `ReliableSync` delivery core.
18
-
19
- ### Changed
20
-
21
- - Document delivery is ack-tracked by default in `yrb-lite-client`: document
22
- frames use `{ update, id }`, acknowledgements use `{ ack }`, and pending
23
- document updates stay queued until acked.
24
- - The ActionCable protocol surface uses a single canonical document envelope:
25
- `{ "update" => "<base64 frame>" }`.
26
- - AnyCable awareness/presence uses an awareness-only whisper envelope,
27
- `{ awareness: "<base64 awareness frame>" }`, while document frames stay on
28
- the server persistence/ack path.
29
-
30
- ### Fixed
31
-
32
- - Incoming protocol frames are validated before mutating documents or awareness
33
- state, including trailing-byte rejection on the TypeScript client.
34
- - Native/Rust protocol entry points reject wire client IDs that are unsafe for
35
- JavaScript clients.
36
- - `lib0` is declared as a direct runtime dependency of `yrb-lite-client`.
37
-
38
- ## [0.1.0.beta5] - 2026-06-18
39
-
40
- ### Changed
41
-
42
- - **Breaking:** the ActionCable integration has been extracted into a separate
43
- gem, [`yrb-lite-actioncable`](https://rubygems.org/gems/yrb-lite-actioncable).
44
- `yrb-lite` is now a standalone y-crdt wrapper: CRDT documents, awareness, and
45
- the y-websocket sync protocol primitives, with no Rails/ActionCable coupling
46
- (mirrors the `y-rb` / `yrb-actioncable` split). The `base64` runtime
47
- dependency moved with it.
48
-
49
- ### Migration
50
-
51
- - Using `YrbLite::Sync`? Add `gem "yrb-lite-actioncable"` and change
52
- `include YrbLite::Sync` to `include YrbLite::ActionCable::Sync`. The concern's
53
- API is otherwise unchanged. If you only use `YrbLite::Doc`/`YrbLite::Awareness`,
54
- nothing changes.
55
-
56
- ## [0.1.0.beta4] - 2026-06-18
57
-
58
- ### Changed
59
-
60
- - `on_change` block recorders now run in the **channel instance's context**
61
- (via `instance_exec`), so a recorder can call the channel's own methods --
62
- `current_user`, `params`, request/connection-scoped accessors -- directly,
63
- instead of plumbing them in through a thread-local. A non-Proc callable (an
64
- object responding to `#call`) is still invoked with `#call` and its own
65
- context. Existing block recorders that use
66
- only the `(key, update)` arguments and lexically-scoped constants are
67
- unaffected; the only behavioral change is `self` inside the block.
68
-
69
- ## [0.1.0.beta3] - 2026-06-18
70
-
71
- ### Changed
72
-
73
- - Upgraded the bundled `yrs` (y-crdt) from 0.21 to 0.27.2. No change to the
74
- `YrbLite::Doc`, `YrbLite::Awareness`, or `YrbLite::Sync` public API; existing
75
- code and the wire protocol are unaffected.
76
- - Thread-safety is preserved across the upgrade. yrs 0.27 dropped `Awareness`'s
77
- internal locking (its mutating methods now take `&mut self`, and `Awareness`
78
- is no longer `Sync`), so `YrbLite::Awareness` now serializes access through an
79
- internal `Mutex`. The lock is taken only while the GVL is released and is
80
- never held across the GVL boundary, so concurrent access from multiple Ruby
81
- threads stays safe and deadlock-free, and document reads still run in parallel
82
- (they operate on a cheaply-cloned, `Arc`-backed `Doc` handle, not under the
83
- presence lock).
84
-
85
- ### Build
86
-
87
- - Building the gem from source now requires **Rust 1.94 or newer** (yrs 0.27.2
88
- uses `let`-chains). The precompiled platform gems are unaffected -- they need
89
- no Rust toolchain to install.
90
-
91
- ## [0.1.0.beta2] - 2026-06-16
92
-
93
- ### Added
94
-
95
- - Reliable delivery (opt-in, client-driven). A client may tag a document update
96
- with an `"id"`; the server replies `{ "ack": <id> }` once the update has been
97
- durably recorded. This lets an
98
- ack-aware client retain and retransmit an update until delivery is confirmed,
99
- so an edit can't be silently lost on a flaky connection. Clients that omit
100
- `"id"` are still accepted, but their delivery is not ack-tracked.
101
- - Demo coverage for reliable delivery with "sync-since-last-ack" framing (the
102
- unacknowledged tail is sent as one merged, causally-complete delta), plus a
103
- minimal reference client and an intensive message-loss stress test.
104
-
105
- ### Fixed
106
-
107
- - Causal-gap protection. The authoritative, fast, and store paths now reject a
108
- document update that isn't causally ready -- one whose dependencies are
109
- missing because an earlier update was lost in transit or its durable record
110
- failed -- and ask the client to resync, instead of recording or relaying an
111
- un-integrable update that would leave the log permanently pending. Adds native
112
- `Doc#update_ready?`/`#pending?` (cheap, read-only checks) used to gate the
113
- record-before-distribute path.
114
-
115
- ## [0.1.0.beta1]
116
-
117
- ### Added
118
-
119
- - Thread-safe `YrbLite::Doc` and `YrbLite::Awareness` over `yrs` (magnus/rb-sys
120
- native extension). The GVL is released during CRDT work so docs can run in
121
- parallel on MRI.
122
- - `YrbLite::Sync` ActionCable channel concern implementing the y-websocket
123
- protocol (document sync plus awareness/presence).
124
- - A "record-before-distribute" mode via an `on_change` hook, so every change is
125
- recorded durably before it's applied or relayed.
126
- - Presence cleanup on disconnect, and idle-document eviction.
127
- - Store-backed ActionCable delivery for AnyCable and multi-process use.
128
- - Hardening against bad input: malformed or multi-message frames are dropped
129
- before processing or relay, and native panics are contained at the FFI
130
- boundary.
131
- - Precompiled native gems for common platforms (no Rust toolchain needed to
132
- install) via the cross-gem workflow.
133
-
134
- [Unreleased]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta5...main
135
- [0.1.0.beta5]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta4...v0.1.0.beta5
136
- [0.1.0.beta4]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta3...v0.1.0.beta4
137
- [0.1.0.beta3]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta2...v0.1.0.beta3
138
- [0.1.0.beta2]: https://github.com/jpcamara/yrb-lite/compare/v0.1.0.beta1...v0.1.0.beta2
139
- [0.1.0.beta1]: https://github.com/jpcamara/yrb-lite/releases/tag/v0.1.0.beta1
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, unless you supply your own.
22
+ provider's Awareness instance.
23
23
 
24
24
  ## What you get
25
25
 
26
- - Thread-safe Ruby wrappers for `Doc` and `Awareness`. You can share them
27
- across Puma threads; native CRDT work runs with the GVL released.
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 messages
104
- doc.sync_step1 # => SyncStep1 message (contains state vector)
105
- doc.sync_step2(state_vector) # => SyncStep2 message (contains update)
106
- doc.handle_sync_message(data) # => [msg_type, sync_type, response]
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
- ### Awareness (Document + Presence)
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
- ### Handling Sync Messages
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
- # When connection opens, send initial sync messages
126
- initial_message = awareness.start
127
- # Send initial_message to peer via WebSocket
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
- ### User Awareness/Presence
299
-
300
- ```ruby
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` and `Awareness` are safe to share across Ruby threads. A `Doc` or
333
- `Awareness` can be used concurrently from Puma workers, ActionCable connection
334
- threads, or background jobs without external locking.
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
 
@@ -1,18 +1,13 @@
1
1
  use magnus::{
2
2
  function, method, prelude::*, Error, ExceptionClass, RString, Ruby, TryConvert, Value,
3
3
  };
4
- use std::sync::Mutex;
5
- use yrs::sync::{Awareness, DefaultProtocol, Message, Protocol, SyncMessage};
4
+ use yrs::sync::{Message, SyncMessage};
6
5
  use yrs::updates::decoder::Decode;
7
- use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
8
- use yrs::{ClientID, Doc, ReadTxn, Transact};
6
+ use yrs::updates::encoder::Encode;
7
+ use yrs::{Doc, ReadTxn, Transact};
9
8
 
10
9
  mod protocol;
11
- use protocol::{
12
- awareness_client_ids_in, classify_message, doc_has_pending, merged_doc_update,
13
- update_advances_doc, update_is_ready, validate_frame_client_ids,
14
- validate_state_vector_client_ids, validate_update_client_ids,
15
- };
10
+ use protocol::{classify_message, merged_doc_update, update_advances_doc, update_is_ready};
16
11
 
17
12
  /// Wrapper around yrs Doc.
18
13
  ///
@@ -24,37 +19,13 @@ use protocol::{
24
19
  #[magnus::wrap(class = "YrbLite::Doc", free_immediately, size)]
25
20
  struct RbDoc(Doc);
26
21
 
27
- /// Wrapper around yrs Awareness (which contains a Doc).
28
- ///
29
- /// Thread safety: as of yrs 0.27 `Awareness` dropped its internal locking and
30
- /// its mutating methods (`handle`, `set_local_state`, `clean_local_state`,
31
- /// `remove_state`, `update_with_clients`) take `&mut self`. It is `Send` but no
32
- /// longer `Sync`, so we serialize all access through a `Mutex`.
33
- ///
34
- /// CRITICAL: the `Mutex` is ALWAYS locked inside the `nogvl` closure (never with
35
- /// the GVL held) and the guard is dropped before the closure returns. This obeys
36
- /// the same rule as the doc's RwLock (see `nogvl`): a thread never waits on this
37
- /// lock while holding the GVL, and never reacquires the GVL while holding this
38
- /// lock, so the GVL and this `Mutex` can't deadlock on lock order. Locking with
39
- /// the GVL held (outside `nogvl`) reintroduces that deadlock -- don't.
40
- ///
41
- /// For doc-only reads we clone the (Arc-backed) `Doc` out under the brief lock
42
- /// and operate on the owned clone, so a long encode holds only the doc's own
43
- /// RwLock, not this `Mutex`, and never blocks presence updates on another
44
- /// thread. Lock order is always Mutex-then-doc-RwLock (or doc-RwLock alone),
45
- /// never the reverse.
46
- #[magnus::wrap(class = "YrbLite::Awareness", free_immediately, size)]
47
- struct RbAwareness(Mutex<Awareness>);
48
-
49
- /// Compile-time proof that the wrapped types are thread-safe. If a future
50
- /// yrs upgrade makes Doc lose Send/Sync, or Awareness lose Send, this fails the
51
- /// build instead of silently shipping a thread-unsafe gem. (Awareness is no
52
- /// longer `Sync` as of yrs 0.27, hence the `Mutex` wrapper, which restores it.)
22
+ /// Compile-time proof that the wrapped Doc is thread-safe. If a future yrs
23
+ /// upgrade makes Doc lose Send/Sync, this fails the build instead of silently
24
+ /// shipping a thread-unsafe gem.
53
25
  #[allow(dead_code)]
54
26
  fn assert_thread_safe() {
55
27
  fn is_send_sync<T: Send + Sync>() {}
56
28
  is_send_sync::<Doc>();
57
- is_send_sync::<Mutex<Awareness>>();
58
29
  }
59
30
 
60
31
  /// Run `f` with the GVL (Global VM Lock) released, so other Ruby threads,
@@ -131,9 +102,9 @@ fn copy_bytes(s: RString) -> Vec<u8> {
131
102
  }
132
103
 
133
104
  /// Build a `YrbLite::Error` (the gem's own error class, defined in `init`) so
134
- /// native decode/apply/validation failures surface as a project-specific error
135
- /// rather than a generic RuntimeError. Falls back to RuntimeError only if the
136
- /// class somehow can't be resolved.
105
+ /// native decode/apply failures surface as a project-specific error rather than
106
+ /// a generic RuntimeError. Falls back to RuntimeError only if the class somehow
107
+ /// can't be resolved.
137
108
  fn yrb_error(msg: String) -> Error {
138
109
  let ruby = Ruby::get().unwrap();
139
110
  let class = ruby
@@ -142,26 +113,10 @@ fn yrb_error(msg: String) -> Error {
142
113
  Error::new(class, msg)
143
114
  }
144
115
 
145
- /// Yjs/lib0 client IDs must be JS-safe integers (<= 2^53 - 1). Above that they
146
- /// round or collide when crossing the JS/Yjs boundary, and a client-id collision
147
- /// corrupts a CRDT. Explicit (Ruby-supplied) IDs are validated here; the random
148
- /// default IDs that yrs generates are already in range.
149
- const MAX_SAFE_CLIENT_ID: u64 = (1 << 53) - 1;
150
-
151
- /// Pure predicate (no Ruby), so the boundary is unit-testable without a VM.
152
- pub(crate) fn is_safe_client_id(id: u64) -> bool {
153
- id <= MAX_SAFE_CLIENT_ID
154
- }
155
-
156
- fn validate_client_id(id: u64) -> Result<u64, Error> {
157
- if !is_safe_client_id(id) {
158
- return Err(yrb_error(format!(
159
- "client_id {id} exceeds the maximum safe integer ({MAX_SAFE_CLIENT_ID} = 2^53 - 1); \
160
- Yjs client IDs must be JS-safe integers to avoid collisions"
161
- )));
162
- }
163
- Ok(id)
164
- }
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).
165
120
 
166
121
  // ============================================================================
167
122
  // Doc Implementation
@@ -173,23 +128,12 @@ impl RbDoc {
173
128
  let doc = if args.is_empty() {
174
129
  Doc::new()
175
130
  } else {
176
- let client_id = validate_client_id(TryConvert::try_convert(args[0])?)?;
131
+ let client_id: u64 = TryConvert::try_convert(args[0])?;
177
132
  Doc::with_client_id(client_id)
178
133
  };
179
134
  Ok(RbDoc(doc))
180
135
  }
181
136
 
182
- /// Get the client ID
183
- fn client_id(&self) -> u64 {
184
- self.0.client_id().get()
185
- }
186
-
187
- /// Get the document GUID
188
- fn guid(&self) -> String {
189
- self.0.guid().to_string()
190
- }
191
-
192
- /// Get the current state vector encoded as bytes
193
137
  fn encode_state_vector(&self) -> RString {
194
138
  let doc = &self.0;
195
139
  let sv = nogvl(move || {
@@ -220,12 +164,10 @@ impl RbDoc {
220
164
  Ok(binary_string(&update))
221
165
  }
222
166
 
223
- /// Apply a V1 update to the document
224
167
  fn apply_update(&self, update: RString) -> Result<(), Error> {
225
168
  let update_bytes = copy_bytes(update);
226
169
  let doc = &self.0;
227
170
  nogvl(move || -> Result<(), String> {
228
- validate_update_client_ids(&update_bytes)?;
229
171
  let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
230
172
  let mut txn = doc.transact_mut();
231
173
  txn.apply_update(update).map_err(|e| e.to_string())
@@ -251,13 +193,6 @@ impl RbDoc {
251
193
  nogvl(move || update_advances_doc(doc, &update_bytes)).map_err(yrb_error)
252
194
  }
253
195
 
254
- /// True if the document holds pending (un-integrable) structs waiting on a
255
- /// missing dependency.
256
- fn pending(&self) -> bool {
257
- let doc = &self.0;
258
- nogvl(move || doc_has_pending(doc))
259
- }
260
-
261
196
  /// Sync step 1: Create a sync message with our state vector
262
197
  fn sync_step1(&self) -> RString {
263
198
  let doc = &self.0;
@@ -269,21 +204,6 @@ impl RbDoc {
269
204
  binary_string(&encoded)
270
205
  }
271
206
 
272
- /// Sync step 2: Create a sync message with updates for the given state vector
273
- fn sync_step2(&self, sv_bytes: RString) -> Result<RString, Error> {
274
- let sv_data = copy_bytes(sv_bytes);
275
- let doc = &self.0;
276
- let encoded = nogvl(move || -> Result<Vec<u8>, String> {
277
- validate_state_vector_client_ids(&sv_data)?;
278
- let sv = yrs::StateVector::decode_v1(&sv_data).map_err(|e| e.to_string())?;
279
- let txn = doc.transact();
280
- let update = txn.encode_state_as_update_v1(&sv);
281
- Ok(Message::Sync(SyncMessage::SyncStep2(update)).encode_v1())
282
- })
283
- .map_err(yrb_error)?;
284
- Ok(binary_string(&encoded))
285
- }
286
-
287
207
  /// Handle a sync message and return response (if any)
288
208
  /// Returns [message_type, sync_type, response_bytes] or nil
289
209
  fn handle_sync_message(&self, data: RString) -> Result<Option<(u8, u8, RString)>, Error> {
@@ -292,7 +212,6 @@ impl RbDoc {
292
212
 
293
213
  let (msg_type, sync_type, response) =
294
214
  nogvl(move || -> Result<(u8, u8, Vec<u8>), String> {
295
- validate_frame_client_ids(&data_bytes)?;
296
215
  let msg = Message::decode_v1(&data_bytes).map_err(|e| e.to_string())?;
297
216
 
298
217
  match msg {
@@ -331,309 +250,46 @@ impl RbDoc {
331
250
 
332
251
  Ok(Some((msg_type, sync_type, binary_string(&response))))
333
252
  }
334
-
335
- /// Encode raw update bytes as a sync Update message
336
- fn encode_update_message(&self, update: RString) -> RString {
337
- let update_bytes = copy_bytes(update);
338
- let msg = Message::Sync(SyncMessage::Update(update_bytes));
339
- binary_string(&msg.encode_v1())
340
- }
341
253
  }
342
254
 
343
255
  // ============================================================================
344
- // Awareness Implementation (includes Doc + presence)
256
+ // Protocol codec (stateless) -- exposed as `YrbLite` module functions
345
257
  // ============================================================================
258
+ //
259
+ // The server never holds presence or document state to classify a frame; these
260
+ // are pure functions of their bytes. (Presence lives in the browser clients; the
261
+ // server only relays awareness frames opaquely.)
262
+
263
+ /// Wrap a raw document update in a sync Update message frame, ready to relay.
264
+ fn wrap_update(update: RString) -> RString {
265
+ let update_bytes = copy_bytes(update);
266
+ let msg = Message::Sync(SyncMessage::Update(update_bytes));
267
+ binary_string(&msg.encode_v1())
268
+ }
346
269
 
347
- impl RbAwareness {
348
- /// Create a new Awareness with an optional client_id
349
- fn new(args: &[Value]) -> Result<Self, Error> {
350
- let awareness = if args.is_empty() {
351
- Awareness::new(Doc::new())
352
- } else {
353
- let client_id = validate_client_id(TryConvert::try_convert(args[0])?)?;
354
- Awareness::new(Doc::with_client_id(client_id))
355
- };
356
- Ok(RbAwareness(Mutex::new(awareness)))
357
- }
358
-
359
- /// Get the client ID of the underlying document
360
- fn client_id(&self) -> u64 {
361
- let awareness = &self.0;
362
- nogvl(move || awareness.lock().unwrap().doc().client_id().get())
363
- }
364
-
365
- /// Get the document GUID
366
- fn guid(&self) -> String {
367
- let awareness = &self.0;
368
- nogvl(move || awareness.lock().unwrap().doc().guid().to_string())
369
- }
370
-
371
- /// A standalone SyncStep1 message (the server's state vector). Sent as its
372
- /// own frame in the opening handshake so providers that parse one message
373
- /// per frame (e.g. @y-rb/actioncable) handle it correctly.
374
- fn sync_step1(&self) -> RString {
375
- let awareness = &self.0;
376
- let encoded = nogvl(move || {
377
- let doc = awareness.lock().unwrap().doc().clone();
378
- let txn = doc.transact();
379
- let sv = txn.state_vector();
380
- Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1()
381
- });
382
- binary_string(&encoded)
383
- }
384
-
385
- /// Create initial sync messages to send when connection opens.
386
- /// Returns binary data containing SyncStep1 + Awareness update.
387
- fn start(&self) -> Result<RString, Error> {
388
- let awareness = &self.0;
389
- let encoded = nogvl(move || -> Result<Vec<u8>, String> {
390
- let awareness = awareness.lock().unwrap();
391
- let protocol = DefaultProtocol;
392
- let mut encoder = EncoderV1::new();
393
- protocol
394
- .start(&awareness, &mut encoder)
395
- .map_err(|e| e.to_string())?;
396
- Ok(encoder.to_vec())
397
- })
398
- .map_err(yrb_error)?;
399
- Ok(binary_string(&encoded))
400
- }
401
-
402
- /// Handle incoming message and return response messages (if any).
403
- /// Returns binary data containing response messages, or empty if no response needed.
404
- fn handle(&self, data: RString) -> Result<RString, Error> {
405
- let data_bytes = copy_bytes(data);
406
- let awareness = &self.0;
407
-
408
- let encoded = nogvl(move || -> Result<Vec<u8>, String> {
409
- validate_frame_client_ids(&data_bytes)?;
410
- let mut awareness = awareness.lock().unwrap();
411
- let protocol = DefaultProtocol;
412
- let responses = protocol
413
- .handle(&mut awareness, &data_bytes)
414
- .map_err(|e| e.to_string())?;
415
-
416
- if responses.is_empty() {
417
- return Ok(Vec::new());
418
- }
419
-
420
- let mut encoder = EncoderV1::new();
421
- for msg in responses {
422
- msg.encode(&mut encoder);
423
- }
424
- Ok(encoder.to_vec())
425
- })
426
- .map_err(yrb_error)?;
427
- Ok(binary_string(&encoded))
428
- }
429
-
430
- /// Encode an update message for broadcasting changes to peers.
431
- fn encode_update(&self, update: RString) -> Result<RString, Error> {
432
- let update_bytes = copy_bytes(update);
433
- nogvl({
434
- let update_bytes = update_bytes.clone();
435
- move || validate_update_client_ids(&update_bytes)
436
- })
437
- .map_err(yrb_error)?;
438
- let msg = Message::Sync(SyncMessage::Update(update_bytes));
439
- Ok(binary_string(&msg.encode_v1()))
440
- }
441
-
442
- /// Get the current state vector encoded as bytes
443
- fn encode_state_vector(&self) -> RString {
444
- let awareness = &self.0;
445
- let sv = nogvl(move || {
446
- let doc = awareness.lock().unwrap().doc().clone();
447
- let txn = doc.transact();
448
- txn.state_vector().encode_v1()
449
- });
450
- binary_string(&sv)
451
- }
452
-
453
- /// Encode state as update (optionally diffed against a state vector)
454
- fn encode_state_as_update(&self, args: &[Value]) -> Result<RString, Error> {
455
- let sv_bytes: Option<Vec<u8>> = if args.is_empty() {
456
- None
457
- } else {
458
- let sv_string: RString = TryConvert::try_convert(args[0])?;
459
- Some(copy_bytes(sv_string))
460
- };
461
- let awareness = &self.0;
462
- let update = nogvl(move || -> Result<Vec<u8>, String> {
463
- let sv = match &sv_bytes {
464
- None => yrs::StateVector::default(),
465
- Some(bytes) => yrs::StateVector::decode_v1(bytes).map_err(|e| e.to_string())?,
466
- };
467
- let doc = awareness.lock().unwrap().doc().clone();
468
- let txn = doc.transact();
469
- Ok(txn.encode_state_as_update_v1(&sv))
470
- })
471
- .map_err(yrb_error)?;
472
- Ok(binary_string(&update))
473
- }
474
-
475
- /// Set local awareness state (JSON string)
476
- fn set_local_state(&self, json: String) -> Result<(), Error> {
477
- let value: serde_json::Value =
478
- serde_json::from_str(&json).map_err(|e| yrb_error(e.to_string()))?;
479
- let awareness = &self.0;
480
- nogvl(move || -> Result<(), String> {
481
- awareness
482
- .lock()
483
- .unwrap()
484
- .set_local_state(value)
485
- .map_err(|e| e.to_string())
486
- })
487
- .map_err(yrb_error)
488
- }
489
-
490
- /// Get local awareness state as JSON string (or nil if not set)
491
- fn local_state(&self) -> Option<String> {
492
- let awareness = &self.0;
493
- nogvl(move || {
494
- awareness
495
- .lock()
496
- .unwrap()
497
- .local_state::<serde_json::Value>()
498
- .map(|v| v.to_string())
499
- })
500
- }
501
-
502
- /// Clear local awareness state
503
- fn clear_local_state(&self) {
504
- let awareness = &self.0;
505
- nogvl(move || awareness.lock().unwrap().clean_local_state());
506
- }
507
-
508
- /// Get awareness update for broadcasting to peers
509
- fn encode_awareness_update(&self) -> Result<RString, Error> {
510
- let awareness = &self.0;
511
- let encoded = nogvl(move || -> Result<Vec<u8>, String> {
512
- let awareness = awareness.lock().unwrap();
513
- let update = awareness.update().map_err(|e| e.to_string())?;
514
- Ok(Message::Awareness(update).encode_v1())
515
- })
516
- .map_err(yrb_error)?;
517
- Ok(binary_string(&encoded))
518
- }
519
-
520
- /// Apply a raw update to the underlying document
521
- fn apply_update(&self, update: RString) -> Result<(), Error> {
522
- let update_bytes = copy_bytes(update);
523
- let awareness = &self.0;
524
- nogvl(move || -> Result<(), String> {
525
- validate_update_client_ids(&update_bytes)?;
526
- let update = yrs::Update::decode_v1(&update_bytes).map_err(|e| e.to_string())?;
527
- let doc = awareness.lock().unwrap().doc().clone();
528
- let mut txn = doc.transact_mut();
529
- txn.apply_update(update).map_err(|e| e.to_string())
530
- })
531
- .map_err(yrb_error)
532
- }
533
-
534
- /// True if applying `update` would integrate cleanly (its dependencies are
535
- /// all present). False means it depends on a missing, causally-prior update.
536
- /// Pure read; does not mutate.
537
- fn update_ready(&self, update: RString) -> Result<bool, Error> {
538
- let update_bytes = copy_bytes(update);
539
- let awareness = &self.0;
540
- nogvl(move || {
541
- let doc = awareness.lock().unwrap().doc().clone();
542
- update_is_ready(&doc, &update_bytes)
543
- })
544
- .map_err(yrb_error)
545
- }
546
-
547
- /// True if applying `update` would change the document, false if it's an
548
- /// already-applied retry. See `update_advances_doc`. Pure read.
549
- fn update_advances(&self, update: RString) -> Result<bool, Error> {
550
- let update_bytes = copy_bytes(update);
551
- let awareness = &self.0;
552
- nogvl(move || {
553
- let doc = awareness.lock().unwrap().doc().clone();
554
- update_advances_doc(&doc, &update_bytes)
555
- })
556
- .map_err(yrb_error)
557
- }
558
-
559
- /// True if the document holds pending (un-integrable) structs waiting on a
560
- /// missing dependency.
561
- fn pending(&self) -> bool {
562
- let awareness = &self.0;
563
- nogvl(move || {
564
- let doc = awareness.lock().unwrap().doc().clone();
565
- doc_has_pending(&doc)
566
- })
567
- }
568
-
569
- /// Decode the awareness client IDs referenced by a protocol message
570
- /// (which may pack several sub-messages together). Sync sub-messages are
571
- /// ignored. The ActionCable layer uses this to learn which presence
572
- /// states arrived on a connection, so it can clear them when that
573
- /// connection closes.
574
- fn awareness_client_ids(&self, data: RString) -> Result<Vec<u64>, Error> {
575
- let data_bytes = copy_bytes(data);
576
- nogvl(move || awareness_client_ids_in(&data_bytes)).map_err(yrb_error)
577
- }
578
-
579
- /// Classify a frame for safe routing and relay. Returns a code only when
580
- /// the frame is exactly one well-formed message that consumes the whole
581
- /// buffer, so a malformed, truncated, multi-message, or trailing-garbage
582
- /// frame (which a malicious client could craft to disrupt others if
583
- /// relayed) is rejected up front:
584
- /// 0 = drop (malformed, multiple, unknown, or empty)
585
- /// 1 = sync step1 (a request: respond, do not relay)
586
- /// 2 = sync step2/update (a document change: record/apply/relay)
587
- /// 3 = awareness (presence: relay)
588
- /// 4 = awareness query (a request: respond, do not relay)
589
- fn message_kind(&self, data: RString) -> u8 {
590
- let data_bytes = copy_bytes(data);
591
- nogvl(move || classify_message(&data_bytes))
592
- }
593
-
594
- /// Extract the document-update delta carried by a protocol message: the
595
- /// payloads of any Update or SyncStep2 sub-messages, merged into a single
596
- /// update. Returns nil if the message carries no document change (for
597
- /// instance a SyncStep1 request or an awareness update). The strict audit
598
- /// path records this exact delta before applying it.
599
- fn update_from_message(&self, data: RString) -> Result<Option<RString>, Error> {
600
- let data_bytes = copy_bytes(data);
601
- let merged = nogvl(move || merged_doc_update(&data_bytes)).map_err(yrb_error)?;
602
- Ok(merged.map(|b| binary_string(&b)))
603
- }
270
+ /// Classify a frame for safe routing and relay. Returns a code only when the
271
+ /// frame is exactly one well-formed message that consumes the whole buffer, so
272
+ /// a malformed, truncated, multi-message, or trailing-garbage frame (which a
273
+ /// malicious client could craft to disrupt others if relayed) is rejected up
274
+ /// front:
275
+ /// 0 = drop (malformed, multiple, unknown, or empty)
276
+ /// 1 = sync step1 (a request: respond, do not relay)
277
+ /// 2 = sync step2/update (a document change: record/apply/relay)
278
+ /// 3 = awareness (presence: relay)
279
+ /// 4 = awareness query (a request: respond, do not relay)
280
+ fn message_kind(data: RString) -> u8 {
281
+ let data_bytes = copy_bytes(data);
282
+ nogvl(move || classify_message(&data_bytes))
283
+ }
604
284
 
605
- /// Mark the given clients as disconnected and return an awareness protocol
606
- /// message (null-state, bumped clock) announcing their removal to peers.
607
- /// Only clients currently known to this Awareness are removed; unknown
608
- /// IDs are skipped (so we never broadcast phantom removals). Returns an
609
- /// empty string when nothing was removed.
610
- fn remove_clients(&self, client_ids: Vec<u64>) -> Result<RString, Error> {
611
- let client_ids = client_ids
612
- .into_iter()
613
- .map(validate_client_id)
614
- .collect::<Result<Vec<_>, _>>()?;
615
- let awareness = &self.0;
616
- let encoded = nogvl(move || -> Result<Vec<u8>, String> {
617
- let mut awareness = awareness.lock().unwrap();
618
- let mut removed = Vec::new();
619
- for id in client_ids {
620
- let cid = ClientID::new(id);
621
- if awareness.meta(cid).is_some() {
622
- awareness.remove_state(cid);
623
- removed.push(cid);
624
- }
625
- }
626
- if removed.is_empty() {
627
- return Ok(Vec::new());
628
- }
629
- let update = awareness
630
- .update_with_clients(removed)
631
- .map_err(|e| e.to_string())?;
632
- Ok(Message::Awareness(update).encode_v1())
633
- })
634
- .map_err(yrb_error)?;
635
- Ok(binary_string(&encoded))
636
- }
285
+ /// Extract the document-update delta carried by a protocol message: the payloads
286
+ /// of any Update or SyncStep2 sub-messages, merged into a single update. Returns
287
+ /// nil if the message carries no document change (a SyncStep1 request or an
288
+ /// awareness update). The store-backed path records this exact delta before relay.
289
+ fn update_from_message(data: RString) -> Result<Option<RString>, Error> {
290
+ let data_bytes = copy_bytes(data);
291
+ let merged = nogvl(move || merged_doc_update(&data_bytes)).map_err(yrb_error)?;
292
+ Ok(merged.map(|b| binary_string(&b)))
637
293
  }
638
294
 
639
295
  // ============================================================================
@@ -651,8 +307,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
651
307
  // Define Doc class
652
308
  let doc_class = module.define_class("Doc", ruby.class_object())?;
653
309
  doc_class.define_singleton_method("new", function!(RbDoc::new, -1))?;
654
- doc_class.define_method("client_id", method!(RbDoc::client_id, 0))?;
655
- doc_class.define_method("guid", method!(RbDoc::guid, 0))?;
656
310
  doc_class.define_method(
657
311
  "encode_state_vector",
658
312
  method!(RbDoc::encode_state_vector, 0),
@@ -664,59 +318,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
664
318
  doc_class.define_method("apply_update", method!(RbDoc::apply_update, 1))?;
665
319
  doc_class.define_method("update_ready?", method!(RbDoc::update_ready, 1))?;
666
320
  doc_class.define_method("update_advances?", method!(RbDoc::update_advances, 1))?;
667
- doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
668
321
  doc_class.define_method("sync_step1", method!(RbDoc::sync_step1, 0))?;
669
- doc_class.define_method("sync_step2", method!(RbDoc::sync_step2, 1))?;
670
322
  doc_class.define_method(
671
323
  "handle_sync_message",
672
324
  method!(RbDoc::handle_sync_message, 1),
673
325
  )?;
674
- doc_class.define_method(
675
- "encode_update_message",
676
- method!(RbDoc::encode_update_message, 1),
677
- )?;
678
-
679
- // Define Awareness class
680
- let awareness_class = module.define_class("Awareness", ruby.class_object())?;
681
- awareness_class.define_singleton_method("new", function!(RbAwareness::new, -1))?;
682
- awareness_class.define_method("client_id", method!(RbAwareness::client_id, 0))?;
683
- awareness_class.define_method("guid", method!(RbAwareness::guid, 0))?;
684
- awareness_class.define_method("start", method!(RbAwareness::start, 0))?;
685
- awareness_class.define_method("sync_step1", method!(RbAwareness::sync_step1, 0))?;
686
- awareness_class.define_method("handle", method!(RbAwareness::handle, 1))?;
687
- awareness_class.define_method("encode_update", method!(RbAwareness::encode_update, 1))?;
688
- awareness_class.define_method(
689
- "encode_state_vector",
690
- method!(RbAwareness::encode_state_vector, 0),
691
- )?;
692
- awareness_class.define_method(
693
- "encode_state_as_update",
694
- method!(RbAwareness::encode_state_as_update, -1),
695
- )?;
696
- awareness_class.define_method("apply_update", method!(RbAwareness::apply_update, 1))?;
697
- awareness_class.define_method("update_ready?", method!(RbAwareness::update_ready, 1))?;
698
- awareness_class.define_method("update_advances?", method!(RbAwareness::update_advances, 1))?;
699
- awareness_class.define_method("pending?", method!(RbAwareness::pending, 0))?;
700
- awareness_class.define_method("set_local_state", method!(RbAwareness::set_local_state, 1))?;
701
- awareness_class.define_method("local_state", method!(RbAwareness::local_state, 0))?;
702
- awareness_class.define_method(
703
- "clear_local_state",
704
- method!(RbAwareness::clear_local_state, 0),
705
- )?;
706
- awareness_class.define_method(
707
- "encode_awareness_update",
708
- method!(RbAwareness::encode_awareness_update, 0),
709
- )?;
710
- awareness_class.define_method(
711
- "awareness_client_ids",
712
- method!(RbAwareness::awareness_client_ids, 1),
713
- )?;
714
- awareness_class.define_method("remove_clients", method!(RbAwareness::remove_clients, 1))?;
715
- awareness_class.define_method(
716
- "update_from_message",
717
- method!(RbAwareness::update_from_message, 1),
718
- )?;
719
- awareness_class.define_method("message_kind", method!(RbAwareness::message_kind, 1))?;
326
+ // Stateless protocol codec, as YrbLite module functions.
327
+ module.define_module_function("wrap_update", function!(wrap_update, 1))?;
328
+ module.define_module_function("message_kind", function!(message_kind, 1))?;
329
+ module.define_module_function("update_from_message", function!(update_from_message, 1))?;
720
330
 
721
331
  // Define message type constants
722
332
  module.const_set("MSG_SYNC", 0u8)?;
@@ -1,219 +1,18 @@
1
- // Pure protocol helpers: no Ruby, no GVL, no `unsafe`. Everything here operates
2
- // on plain byte slices and `yrs` types, so it's unit-tested directly (see the
3
- // `tests` module below) without a Ruby VM. The Ruby-facing wrappers in lib.rs
4
- // copy bytes out of Ruby strings and call into these under `nogvl`.
5
- use crate::is_safe_client_id;
6
- use std::sync::Arc;
7
- use yrs::encoding::read::{Cursor, Error as ReadError, Read};
8
- use yrs::sync::protocol::{
9
- MessageReader, MSG_AUTH, MSG_AWARENESS, MSG_QUERY_AWARENESS, MSG_SYNC, MSG_SYNC_STEP_1,
10
- MSG_SYNC_STEP_2, MSG_SYNC_UPDATE, PERMISSION_DENIED,
11
- };
1
+ // Pure rust protocol helpers (ie, no Ruby interop).
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.
7
+ use yrs::encoding::read::{Cursor, Read};
8
+ use yrs::sync::protocol::MessageReader;
12
9
  use yrs::sync::{Message, SyncMessage};
13
- use yrs::updates::decoder::{Decode, Decoder, DecoderV1};
14
- use yrs::{Any, ClientID, Doc, ReadTxn, Transact, ID};
15
-
16
- fn unsafe_client_id_error(id: u64) -> ReadError {
17
- ReadError::Custom(format!(
18
- "client_id {id} exceeds the maximum safe integer (2^53 - 1); \
19
- Yjs client IDs must be JS-safe integers to avoid collisions"
20
- ))
21
- }
22
-
23
- fn checked_client_id(id: u64) -> Result<ClientID, ReadError> {
24
- if !is_safe_client_id(id) {
25
- return Err(unsafe_client_id_error(id));
26
- }
27
- Ok(ClientID::new(id))
28
- }
29
-
30
- fn validate_raw_client_id(id: u64) -> Result<(), ReadError> {
31
- if !is_safe_client_id(id) {
32
- return Err(unsafe_client_id_error(id));
33
- }
34
- Ok(())
35
- }
36
-
37
- struct CheckedDecoderV1<'a> {
38
- cursor: Cursor<'a>,
39
- }
40
-
41
- impl<'a> CheckedDecoderV1<'a> {
42
- fn new(cursor: Cursor<'a>) -> Self {
43
- CheckedDecoderV1 { cursor }
44
- }
45
-
46
- fn read_id(&mut self) -> Result<ID, ReadError> {
47
- let client: u64 = self.read_var()?;
48
- validate_raw_client_id(client)?;
49
- let clock = self.read_var()?;
50
- Ok(ID::new(ClientID::new(client), clock))
51
- }
52
- }
53
-
54
- impl<'a> Read for CheckedDecoderV1<'a> {
55
- #[inline]
56
- fn read_u8(&mut self) -> Result<u8, ReadError> {
57
- self.cursor.read_u8()
58
- }
59
-
60
- #[inline]
61
- fn read_exact(&mut self, len: usize) -> Result<&[u8], ReadError> {
62
- self.cursor.read_exact(len)
63
- }
64
- }
65
-
66
- impl<'a> Decoder for CheckedDecoderV1<'a> {
67
- #[inline]
68
- fn reset_ds_cur_val(&mut self) {}
69
-
70
- #[inline]
71
- fn read_ds_clock(&mut self) -> Result<u32, ReadError> {
72
- self.read_var()
73
- }
74
-
75
- #[inline]
76
- fn read_ds_len(&mut self) -> Result<u32, ReadError> {
77
- self.read_var()
78
- }
79
-
80
- #[inline]
81
- fn read_left_id(&mut self) -> Result<ID, ReadError> {
82
- self.read_id()
83
- }
84
-
85
- #[inline]
86
- fn read_right_id(&mut self) -> Result<ID, ReadError> {
87
- self.read_id()
88
- }
89
-
90
- #[inline]
91
- fn read_client(&mut self) -> Result<ClientID, ReadError> {
92
- let client: u64 = self.cursor.read_var()?;
93
- checked_client_id(client)
94
- }
95
-
96
- #[inline]
97
- fn read_info(&mut self) -> Result<u8, ReadError> {
98
- self.cursor.read_u8()
99
- }
100
-
101
- #[inline]
102
- fn read_parent_info(&mut self) -> Result<bool, ReadError> {
103
- let info: u32 = self.cursor.read_var()?;
104
- Ok(info == 1)
105
- }
106
-
107
- #[inline]
108
- fn read_type_ref(&mut self) -> Result<u8, ReadError> {
109
- self.cursor.read_u8()
110
- }
111
-
112
- #[inline]
113
- fn read_len(&mut self) -> Result<u32, ReadError> {
114
- self.read_var()
115
- }
116
-
117
- #[inline]
118
- fn read_any(&mut self) -> Result<Any, ReadError> {
119
- Any::decode(self)
120
- }
121
-
122
- #[inline]
123
- fn read_json(&mut self) -> Result<Any, ReadError> {
124
- let src = self.read_string()?;
125
- Any::from_json(src)
126
- }
127
-
128
- #[inline]
129
- fn read_key(&mut self) -> Result<Arc<str>, ReadError> {
130
- let str: Arc<str> = self.read_string()?.into();
131
- Ok(str)
132
- }
133
-
134
- #[inline]
135
- fn read_to_end(&mut self) -> Result<&[u8], ReadError> {
136
- Ok(&self.cursor.buf[self.cursor.next..])
137
- }
138
- }
139
-
140
- pub(crate) fn validate_state_vector_client_ids(bytes: &[u8]) -> Result<(), String> {
141
- let mut cursor = Cursor::new(bytes);
142
- let len: u32 = cursor.read_var().map_err(|e| e.to_string())?;
143
- for _ in 0..len {
144
- let client: u64 = cursor.read_var().map_err(|e| e.to_string())?;
145
- validate_raw_client_id(client).map_err(|e| e.to_string())?;
146
- let _: u32 = cursor.read_var().map_err(|e| e.to_string())?;
147
- }
148
- if cursor.has_content() {
149
- return Err("state vector has trailing bytes".to_string());
150
- }
151
- Ok(())
152
- }
153
-
154
- pub(crate) fn validate_update_client_ids(update_bytes: &[u8]) -> Result<(), String> {
155
- let mut decoder = CheckedDecoderV1::new(Cursor::new(update_bytes));
156
- yrs::Update::decode(&mut decoder).map_err(|e| e.to_string())?;
157
- if decoder.cursor.has_content() {
158
- return Err("update has trailing bytes".to_string());
159
- }
160
- Ok(())
161
- }
162
-
163
- fn validate_awareness_update_client_ids(bytes: &[u8]) -> Result<(), String> {
164
- let mut cursor = Cursor::new(bytes);
165
- let len: u32 = cursor.read_var().map_err(|e| e.to_string())?;
166
- for _ in 0..len {
167
- let client: u64 = cursor.read_var().map_err(|e| e.to_string())?;
168
- validate_raw_client_id(client).map_err(|e| e.to_string())?;
169
- let _: u32 = cursor.read_var().map_err(|e| e.to_string())?;
170
- let _ = cursor.read_string().map_err(|e| e.to_string())?;
171
- }
172
- if cursor.has_content() {
173
- return Err("awareness update has trailing bytes".to_string());
174
- }
175
- Ok(())
176
- }
177
-
178
- pub(crate) fn validate_frame_client_ids(bytes: &[u8]) -> Result<(), String> {
179
- let mut cursor = Cursor::new(bytes);
180
- while cursor.has_content() {
181
- let tag: u8 = cursor.read_var().map_err(|e| e.to_string())?;
182
- match tag {
183
- MSG_SYNC => {
184
- let sync_tag: u8 = cursor.read_var().map_err(|e| e.to_string())?;
185
- let payload = cursor.read_buf().map_err(|e| e.to_string())?;
186
- match sync_tag {
187
- MSG_SYNC_STEP_1 => validate_state_vector_client_ids(payload)?,
188
- MSG_SYNC_STEP_2 | MSG_SYNC_UPDATE => validate_update_client_ids(payload)?,
189
- _ => return Err("unknown sync message type".to_string()),
190
- }
191
- }
192
- MSG_AWARENESS => {
193
- let payload = cursor.read_buf().map_err(|e| e.to_string())?;
194
- validate_awareness_update_client_ids(payload)?;
195
- }
196
- MSG_AUTH => {
197
- let permission: u8 = cursor.read_var().map_err(|e| e.to_string())?;
198
- if permission == PERMISSION_DENIED {
199
- let _ = cursor.read_string().map_err(|e| e.to_string())?;
200
- }
201
- }
202
- MSG_QUERY_AWARENESS => {}
203
- _ => {
204
- let _ = cursor.read_buf().map_err(|e| e.to_string())?;
205
- }
206
- }
207
- }
208
- Ok(())
209
- }
10
+ use yrs::updates::decoder::{Decode, DecoderV1};
11
+ use yrs::{Doc, ReadTxn, Transact};
210
12
 
211
13
  /// Classify a frame: a non-zero code only for exactly one well-formed message
212
14
  /// that consumes the whole buffer (see `RbAwareness::message_kind` for codes).
213
15
  pub(crate) fn classify_message(bytes: &[u8]) -> u8 {
214
- if validate_frame_client_ids(bytes).is_err() {
215
- return 0;
216
- }
217
16
  let mut decoder = DecoderV1::new(Cursor::new(bytes));
218
17
  let msg = match Message::decode(&mut decoder) {
219
18
  Ok(msg) => msg,
@@ -236,7 +35,6 @@ pub(crate) fn classify_message(bytes: &[u8]) -> u8 {
236
35
  /// frame into one update, or `None` if the frame carries no document change
237
36
  /// (a request, an awareness update, or a no-op handshake SyncStep2).
238
37
  pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String> {
239
- validate_frame_client_ids(bytes)?;
240
38
  let mut decoder = DecoderV1::new(Cursor::new(bytes));
241
39
  let mut updates: Vec<Vec<u8>> = Vec::new();
242
40
  for msg in MessageReader::new(&mut decoder) {
@@ -255,7 +53,7 @@ pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String>
255
53
  let update = yrs::Update::decode_v1(&merged).map_err(|e| e.to_string())?;
256
54
  // A genuine no-op (e.g. the empty SyncStep2 in an opening handshake) carries
257
55
  // no structs, no deletes, and no dependencies. We must NOT treat a causally-
258
- // pending update as a no-op: since yrs 0.26 such an update reports an empty
56
+ // pending update as a no-op: such an update reports an empty
259
57
  // state_vector (its structs can't integrate yet), but it still carries
260
58
  // content and a non-empty lower bound (the deps it's waiting on). Dropping it
261
59
  // here would silently swallow a gappy update instead of rejecting + resyncing.
@@ -268,26 +66,12 @@ pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String>
268
66
  Ok(Some(merged))
269
67
  }
270
68
 
271
- /// Collect the awareness client IDs referenced by a frame's awareness messages.
272
- pub(crate) fn awareness_client_ids_in(bytes: &[u8]) -> Result<Vec<u64>, String> {
273
- validate_frame_client_ids(bytes)?;
274
- let mut decoder = DecoderV1::new(Cursor::new(bytes));
275
- let mut ids = Vec::new();
276
- for msg in MessageReader::new(&mut decoder) {
277
- if let Message::Awareness(update) = msg.map_err(|e| e.to_string())? {
278
- ids.extend(update.clients.keys().map(|c| c.get()));
279
- }
280
- }
281
- Ok(ids)
282
- }
283
-
284
69
  /// True if applying `update_bytes` to `doc` would integrate cleanly: every
285
70
  /// dependency the update references is already present (the doc's state vector
286
71
  /// covers the update's lower bound). A pure read; does not mutate the doc.
287
72
  /// When false, applying it would park a pending struct -- the signal that an
288
73
  /// earlier, causally-prior update is missing.
289
74
  pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
290
- validate_update_client_ids(update_bytes)?;
291
75
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
292
76
  Ok(doc.transact().state_vector() >= update.state_vector_lower())
293
77
  }
@@ -309,7 +93,6 @@ pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, St
309
93
  /// double-record a pure-delete retry, but it NEVER drops a real deletion, which
310
94
  /// is the safe direction. Assumes the update is already causally ready.
311
95
  pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
312
- validate_update_client_ids(update_bytes)?;
313
96
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
314
97
  if !update.delete_set().is_empty() {
315
98
  return Ok(true); // can't cheaply prove a delete is a duplicate; record it
@@ -332,8 +115,9 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
332
115
  }
333
116
 
334
117
  /// True if the doc holds pending structs or a pending delete set -- blocks that
335
- /// couldn't integrate because a dependency is missing. Used as a backstop after
336
- /// loading from storage: leftover pending means the stored log has a causal gap.
118
+ /// couldn't integrate because a dependency is missing. Test-only: asserts the
119
+ /// causal-chain parking behavior in the unit tests below.
120
+ #[cfg(test)]
337
121
  pub(crate) fn doc_has_pending(doc: &Doc) -> bool {
338
122
  let txn = doc.transact();
339
123
  txn.store().pending_update().is_some() || txn.store().pending_ds().is_some()
@@ -342,10 +126,8 @@ pub(crate) fn doc_has_pending(doc: &Doc) -> bool {
342
126
  #[cfg(test)]
343
127
  mod tests {
344
128
  use super::*;
345
- use crate::is_safe_client_id;
346
- use yrs::encoding::write::Write;
347
129
  use yrs::sync::Awareness;
348
- use yrs::updates::encoder::{Encode, Encoder, EncoderV1};
130
+ use yrs::updates::encoder::Encode;
349
131
  use yrs::Text;
350
132
 
351
133
  fn text_update(content: &str) -> Vec<u8> {
@@ -374,42 +156,6 @@ mod tests {
374
156
  Message::Awareness(awareness.update().unwrap()).encode_v1()
375
157
  }
376
158
 
377
- fn unsafe_struct_client_update() -> Vec<u8> {
378
- let mut update = EncoderV1::new();
379
- update.write_var(1u32); // client count
380
- update.write_var(0u32); // block count for this client
381
- update.write_var(1u64 << 53); // unsafe client id
382
- update.write_var(0u32); // clock
383
- update.write_var(0u32); // delete-set client count
384
- update.to_vec()
385
- }
386
-
387
- fn unsafe_awareness_frame() -> Vec<u8> {
388
- let mut payload = EncoderV1::new();
389
- payload.write_var(1u32); // client count
390
- payload.write_var(1u64 << 53); // unsafe client id
391
- payload.write_var(1u32); // clock
392
- payload.write_string("{}");
393
-
394
- let mut frame = EncoderV1::new();
395
- frame.write_var(MSG_AWARENESS);
396
- frame.write_buf(payload.to_vec());
397
- frame.to_vec()
398
- }
399
-
400
- fn unsafe_step1_frame() -> Vec<u8> {
401
- let mut sv = EncoderV1::new();
402
- sv.write_var(1u32); // state-vector entry count
403
- sv.write_var(1u64 << 53); // unsafe client id
404
- sv.write_var(0u32); // clock
405
-
406
- let mut frame = EncoderV1::new();
407
- frame.write_var(MSG_SYNC);
408
- frame.write_var(MSG_SYNC_STEP_1);
409
- frame.write_buf(sv.to_vec());
410
- frame.to_vec()
411
- }
412
-
413
159
  #[test]
414
160
  fn classify_accepts_clean_single_messages() {
415
161
  assert_eq!(classify_message(&step1_frame()), 1);
@@ -500,51 +246,6 @@ mod tests {
500
246
  );
501
247
  }
502
248
 
503
- #[test]
504
- fn client_id_safe_integer_boundary() {
505
- assert!(is_safe_client_id(0), "zero is fine");
506
- assert!(
507
- is_safe_client_id((1 << 53) - 1),
508
- "2^53 - 1 is the max safe id"
509
- );
510
- assert!(!is_safe_client_id(1 << 53), "2^53 is unsafe");
511
- assert!(!is_safe_client_id(1 << 63), "2^63 is unsafe");
512
- assert!(!is_safe_client_id(u64::MAX), "u64::MAX is unsafe");
513
- }
514
-
515
- #[test]
516
- fn wire_client_id_validation_rejects_unsafe_sync_update_clients() {
517
- let update = unsafe_struct_client_update();
518
- assert!(
519
- validate_update_client_ids(&update).is_err(),
520
- "raw unsafe update client id is rejected before yrs can mask it"
521
- );
522
-
523
- let frame = Message::Sync(SyncMessage::Update(update)).encode_v1();
524
- assert_eq!(
525
- classify_message(&frame),
526
- 0,
527
- "unsafe sync frame is not relayable"
528
- );
529
- assert!(merged_doc_update(&frame).is_err());
530
- }
531
-
532
- #[test]
533
- fn wire_client_id_validation_rejects_unsafe_awareness_and_step1_clients() {
534
- assert!(
535
- validate_frame_client_ids(&unsafe_awareness_frame()).is_err(),
536
- "raw unsafe awareness client id is rejected"
537
- );
538
- assert_eq!(classify_message(&unsafe_awareness_frame()), 0);
539
- assert!(awareness_client_ids_in(&unsafe_awareness_frame()).is_err());
540
-
541
- assert!(
542
- validate_frame_client_ids(&unsafe_step1_frame()).is_err(),
543
- "raw unsafe state-vector client id is rejected"
544
- );
545
- assert_eq!(classify_message(&unsafe_step1_frame()), 0);
546
- }
547
-
548
249
  #[test]
549
250
  fn merged_doc_update_extracts_and_skips_no_ops() {
550
251
  // A document update yields a delta that reconstructs the content.
@@ -582,18 +283,6 @@ mod tests {
582
283
  assert!(yrs::Update::decode_v1(&merged).is_ok());
583
284
  }
584
285
 
585
- #[test]
586
- fn awareness_client_ids_are_collected() {
587
- assert_eq!(
588
- awareness_client_ids_in(&awareness_frame(111)).unwrap(),
589
- vec![111]
590
- );
591
- // A document frame has no awareness client ids.
592
- assert!(awareness_client_ids_in(&update_frame("x"))
593
- .unwrap()
594
- .is_empty());
595
- }
596
-
597
286
  #[test]
598
287
  fn update_readiness_and_pending_detect_a_causal_gap() {
599
288
  // Three sequential single-char inserts from one client: A, then B, then
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YrbLite
4
- VERSION = "0.1.0.beta6"
4
+ VERSION = "0.1.0.beta9"
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.beta6
4
+ version: 0.1.0.beta9
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara