yrby 0.2.1 → 0.3.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: d6580b564ad0ffb8573b7e5f81acdba9ad4b59648d4c2b25c5914d144cef0623
4
- data.tar.gz: 1ee082cbd115166217d4ac48a62e32d2d0f161564d02644dfe3a23731822ca87
3
+ metadata.gz: 2dcb5650668e6cc3f1e0ef1349426ba875d5580a945e90a62216e61a24bf9cef
4
+ data.tar.gz: eead1e7159caeacf517febe05149d0c55491dcceaf6fbbbe2f3c40fdf7620978
5
5
  SHA512:
6
- metadata.gz: da995eb5f169b62d3a83bfe138be28894b66fd9048a091efb624019ed6e87b520429c8ea29df17eef5ad55929e4b7680190fb84fad06b1f952af72c0bec300d7
7
- data.tar.gz: a6bd21c7f3d353037fd877b0246bca1221bdd894bd7dc4f1a811fdd74fcbae26dc7a0d087d9bba13c79cbfb8134e4c588a2493e527a57ed31d6e1faed3504e8a
6
+ metadata.gz: 68948615ae023becf04deea8c767cdd075ccd7097bbcecefb52868a7c2257fe4bd39d20bbac83b1f6a47d78a2889d76e590a83b7df0d9f6e1ccea1a8fdfa115d
7
+ data.tar.gz: 70716f2948661046b71843e66dcec62e5cf3e2b5c1509e9ea76c49db6039909bc02f510a90e3b9f44054b20ea3c2297d10878834b3c751abd1a28c72d0d2cabc
data/CHANGELOG.md CHANGED
@@ -6,6 +6,72 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-07-01
10
+
11
+ ### Fixed
12
+
13
+ - **Sync no longer serves un-integrable pending structs.** When a doc holds a
14
+ *pending* struct (a gappy update whose causally-prior update is missing — e.g.
15
+ legacy data recorded before the `update_ready?` gate existed), its integrated
16
+ state vector is empty but `encode_state_as_update` merges the pending bytes back
17
+ in. Answering a peer's `SyncStep1` with that state handed the peer content it
18
+ couldn't integrate, so it parked the same pending forever and the empty-SV /
19
+ non-empty-content mismatch drove endless resync traffic (observed as a browser
20
+ re-sending frames several times a second). `handle_sync_message` now answers
21
+ `SyncStep1` with **integrated-only** state, so a server never serves a struct it
22
+ can't integrate itself. Neutralizes existing poisoned server state on deploy —
23
+ no migration needed. The server's own pending is untouched and still heals if
24
+ the missing dependency later arrives (only then does the content become
25
+ visible in sync). Live delta relay (`Update` frames) is unchanged.
26
+
27
+ ### Added
28
+
29
+ - `Doc#pending?` — true if the doc holds un-integrable pending structs or a
30
+ pending delete set (content waiting on a missing causally-prior update).
31
+ - `Doc#compacted_state_update` — like `encode_state_as_update` (full state) but
32
+ **gap-free**: excludes pending structs/delete set. Use it when persisting or
33
+ serving state other peers will apply. Non-destructive — the doc keeps its
34
+ pending (so it can still heal), and `encode_state_as_update` stays lossless for
35
+ raw-update recovery.
36
+
37
+ ## [0.2.3] - 2026-07-01
38
+
39
+ ### Fixed
40
+
41
+ - `Doc#update_advances?` is now exact for **delete-bearing** updates, so an
42
+ already-applied pure-delete retry no longer reports as advancing. Previously any
43
+ update carrying a delete set returned `true` (record it) because deletes don't
44
+ move the state vector, so the cheap state-vector probe couldn't prove a
45
+ duplicate. A lost-ack retry of a deletion the server had already integrated was
46
+ therefore re-recorded and re-broadcast every time. For delete-bearing updates we
47
+ now compare the full encoded document state (which includes the delete set)
48
+ before vs. after a trial apply on an isolated probe: a genuinely new deletion
49
+ changes it (`true`); an already-applied retry re-encodes identically (`false`).
50
+ Insert/format-only updates keep the cheaper state-vector path, so only
51
+ delete-bearing frames — a minority — pay for the exact comparison. The exactly-
52
+ once guarantee is unchanged in the safe direction: a real deletion is never
53
+ dropped.
54
+
55
+ This lets `yrby-actioncable` (and any caller gating `on_change` on
56
+ `update_advances?`) settle a duplicate pure-delete frame as `:applied` — acked,
57
+ but not stored or relayed — so apps no longer need an app-level
58
+ encode-and-compare guard around their durable writes.
59
+
60
+ ## [0.2.2] - 2026-06-30
61
+
62
+ ### Fixed
63
+
64
+ - `Doc#read_xml` now recovers text from **nested** Lexical/Lexxy blocks. Lexical
65
+ embeds child blocks (list items, table cells, nested lists) as `Y.XmlText`
66
+ embeds that `get_string` silently drops, so lists and tables previously came
67
+ back empty. `read_xml` now walks the embeds: text runs build a line, inline
68
+ children (links) join it, and nested block children flush and recurse — so a
69
+ document with headings, formatted text, links, bullet/numbered/check/nested
70
+ lists, blockquotes, code blocks and tables extracts every piece of text.
71
+ Lexical decorator elements (horizontal rule, image) are skipped instead of
72
+ emitting their `<UNDEFINED …>` serialization. ProseMirror handling is
73
+ unchanged.
74
+
9
75
  ## [0.2.1] - 2026-06-29
10
76
 
11
77
  ### Changed
@@ -15,15 +81,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
15
81
 
16
82
  ## [0.2.0] - 2026-06-28
17
83
 
18
- First release under the **`yrby`** name (the project was previously developed
19
- as `yrb-lite`). The public Ruby interface is the top-level module **`Y`** —
20
- mirroring the `y-rb` gem's `Y::Doc` interface.
21
-
22
- ### Changed
23
- - **Renamed `yrb-lite` → `yrby`.** Module `YrbLite` → top-level `Y`
24
- (`Y::Doc`, `Y::Error`, `Y::VERSION`). Require path `require "yrb_lite"` →
25
- `require "y"`. (The native extension crate shipped as `y_ruby` in 0.2.0; see
26
- 0.2.1 for its rename to `yrby`.)
84
+ First release. The public Ruby interface is the top-level module **`Y`**
85
+ (`Y::Doc`, `Y::Error`, `Y::VERSION`), loaded with `require "y"` mirroring the
86
+ `y-rb` gem's `Y::Doc` interface.
27
87
 
28
88
  ### Added
29
89
  - Native `Doc#read_text` and `Doc#read_map` readers — reconstruct plain text and
data/README.md CHANGED
@@ -41,23 +41,25 @@ npm install yrby-client
41
41
  one-include ActionCable concern.
42
42
  - Authoritative record-before-distribute semantics: each document change can be
43
43
  recorded durably before it goes out to anyone.
44
- -
44
+ - Optional server-side reads: `Doc#read_text` and `Doc#read_map` reconstruct a
45
+ document's contents in Ruby - no Node process - for search, exports, validation,
46
+ or server-side rendering.
45
47
 
46
- ## Why "lite"
48
+ ## Scope
47
49
 
48
- The "lite" is the size of the surface. `yrby` binds just the part of `y-crdt` you
49
- need to *sync and persist* collaborative documents - a `Doc`, awareness, and the
50
- y-websocket protocol primitives. The Ruby side treats a document as opaque CRDT
51
- state: it applies updates, answers sync handshakes, and records deltas, but never
52
- reaches in to read or edit the contents. The browser editor owns the document's
53
- shape.
50
+ `yrby` binds just the part of `y-crdt` you need to *sync and persist* collaborative
51
+ documents - a `Doc`, awareness, and the y-websocket protocol primitives. By default
52
+ the Ruby side treats a document as opaque CRDT state: it applies updates, answers
53
+ sync handshakes, and records deltas without reaching into the contents - the browser
54
+ editor owns the document's shape. When you do need to look inside, `Doc#read_text`
55
+ and `Doc#read_map` reconstruct it server-side, in Ruby.
54
56
 
55
- ## What isn't "lite"
57
+ ## Durability and delivery
56
58
 
57
- The surface area may be "lite", but a core focus is on durability, resiliency, delivery
59
+ The surface is intentionally small, but the focus is durability, resiliency, delivery
58
60
  guarantees, correctness, and thread safety.
59
61
 
60
- Towards that goal, `yrby` adds capabilities that may even stand out in the Yjs ecosystem:
62
+ Towards that goal, `yrby` adds capabilities that stand out even in the Yjs ecosystem:
61
63
 
62
64
  - Built-in update acknowledgement: the `ActionCableProvider` in `yrby-client` will continue to
63
65
  send updates until an ack is received from the server. [`yrby-actioncable`](https://rubygems.org/gems/yrby-actioncable)
@@ -140,18 +142,39 @@ doc = Y::Doc.new(12345) # specific client ID (used for CRDT identity)
140
142
 
141
143
  # Encoding
142
144
  doc.encode_state_vector # => current state vector
143
- doc.encode_state_as_update # => full update
145
+ doc.encode_state_as_update # => full update (lossless: keeps pending)
144
146
  doc.encode_state_as_update(sv) # => update diff against state vector
147
+ doc.compacted_state_update # => full update, gap-free (excludes pending)
145
148
 
146
149
  # Applying updates
147
150
  doc.apply_update(update_bytes) # apply raw V1 update
151
+ doc.pending? # => true if holding un-integrable pending structs
148
152
 
149
153
  # Sync protocol
150
154
  doc.sync_step1 # => SyncStep1 message (this doc's state vector)
151
155
  doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers a
152
- # peer's SyncStep1 with a SyncStep2
156
+ # peer's SyncStep1 with an integrated-only
157
+ # SyncStep2 (never serves pending structs)
153
158
  ```
154
159
 
160
+ ### Pending structs and gap-free state
161
+
162
+ If a doc applies an update whose causally-prior update is missing (a "gappy"
163
+ update), yrs parks it as a **pending** struct: the integrated state vector stays
164
+ empty, but the pending block is held as a recovery buffer and heals if the
165
+ missing dependency later arrives. `Doc#pending?` reports this.
166
+
167
+ Pending structs are *not* document state, so they must not cross the sync
168
+ boundary — a peer that receives one can't integrate it and gets stuck. Two
169
+ guarantees keep serving safe:
170
+
171
+ - `handle_sync_message` answers `SyncStep1` with **integrated-only** state, so a
172
+ server never serves a struct it can't integrate itself (this is automatic).
173
+ - `Doc#compacted_state_update` gives you the same gap-free full-state update for
174
+ when you persist or hand off state yourself. It's non-destructive (the doc
175
+ keeps its pending), while `encode_state_as_update` stays lossless so you can
176
+ still preserve the raw pending bytes for recovery.
177
+
155
178
  ### Protocol codec (module functions)
156
179
 
157
180
  Classifying and unwrapping wire frames is stateless, so it's exposed as
data/ext/yrby/src/lib.rs CHANGED
@@ -8,7 +8,10 @@ use yrs::{Doc, GetString, ReadTxn, Transact};
8
8
 
9
9
  mod protocol;
10
10
  mod read;
11
- use protocol::{classify_message, merged_doc_update, update_advances_doc, update_is_ready};
11
+ use protocol::{
12
+ classify_message, has_pending, integrated_update, merged_doc_update, update_advances_doc,
13
+ update_is_ready,
14
+ };
12
15
 
13
16
  /// Wrapper around yrs Doc.
14
17
  ///
@@ -188,6 +191,28 @@ impl RbDoc {
188
191
  })
189
192
  }
190
193
 
194
+ /// True if the doc holds un-integrable pending structs or a pending delete
195
+ /// set — content that couldn't integrate because a causally-prior update is
196
+ /// missing. Such content is a recovery buffer, not document state; it heals if
197
+ /// the missing dependency later arrives. A pure read.
198
+ fn pending(&self) -> bool {
199
+ let doc = &self.0;
200
+ nogvl(move || has_pending(doc))
201
+ }
202
+
203
+ /// Like `encode_state_as_update` (full state), but **gap-free**: it excludes
204
+ /// any pending (un-integrable) structs and pending delete set. Use this when
205
+ /// persisting or serving state that other peers will apply — serving pending
206
+ /// content poisons their sync. Non-destructive: this doc keeps its pending, so
207
+ /// a genuine gap still heals if its dependency arrives. (`encode_state_as_update`
208
+ /// stays lossless for raw-update recovery.)
209
+ fn compacted_state_update(&self) -> Result<RString, Error> {
210
+ let doc = &self.0;
211
+ let update = nogvl(move || integrated_update(doc, &yrs::StateVector::default()))
212
+ .map_err(yrb_error)?;
213
+ Ok(binary_string(&update))
214
+ }
215
+
191
216
  /// Encode state as update (optionally diffed against a state vector)
192
217
  fn encode_state_as_update(&self, args: &[Value]) -> Result<RString, Error> {
193
218
  let sv_bytes: Option<Vec<u8>> = if args.is_empty() {
@@ -263,9 +288,13 @@ impl RbDoc {
263
288
  match msg {
264
289
  Message::Sync(sync_msg) => match sync_msg {
265
290
  SyncMessage::SyncStep1(sv) => {
266
- // Respond with SyncStep2
267
- let txn = doc.transact();
268
- let update = txn.encode_state_as_update_v1(&sv);
291
+ // Respond with SyncStep2 carrying only *integrated*
292
+ // state. Never hand a peer un-integrable pending
293
+ // structs: the peer would park the same pending
294
+ // forever and the state-vector/content mismatch drives
295
+ // endless resync traffic. (integrated_update is a no-op
296
+ // fast path when nothing is pending.)
297
+ let update = integrated_update(doc, &sv)?;
269
298
  let response = Message::Sync(SyncMessage::SyncStep2(update));
270
299
  Ok((0, 0, response.encode_v1()))
271
300
  }
@@ -366,6 +395,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
366
395
  doc_class.define_method("read_text", method!(RbDoc::read_text, 1))?;
367
396
  doc_class.define_method("read_xml", method!(RbDoc::read_xml, 1))?;
368
397
  doc_class.define_method("read_map", method!(RbDoc::read_map, 1))?;
398
+ doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
399
+ doc_class.define_method(
400
+ "compacted_state_update",
401
+ method!(RbDoc::compacted_state_update, 0),
402
+ )?;
369
403
  doc_class.define_method("update_ready?", method!(RbDoc::update_ready, 1))?;
370
404
  doc_class.define_method("update_advances?", method!(RbDoc::update_advances, 1))?;
371
405
  doc_class.define_method("sync_step1", method!(RbDoc::sync_step1, 0))?;
@@ -8,7 +8,7 @@ use yrs::encoding::read::{Cursor, Read};
8
8
  use yrs::sync::protocol::MessageReader;
9
9
  use yrs::sync::{Message, SyncMessage};
10
10
  use yrs::updates::decoder::{Decode, DecoderV1};
11
- use yrs::{Doc, ReadTxn, Transact};
11
+ use yrs::{Doc, ReadTxn, StateVector, Transact, Update, WriteTxn};
12
12
 
13
13
  /// Classify a frame: a non-zero code only for exactly one well-formed message
14
14
  /// that consumes the whole buffer (the codes are the match arms below).
@@ -77,26 +77,38 @@ pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, St
77
77
  }
78
78
 
79
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
- /// effects exactly-once: a lost-ack retry re-sends an update the server already
82
- /// applied; that retry is causally ready (so `update_is_ready` is true) but must
83
- /// not re-run `on_change`.
80
+ /// content (an insert, a format, or a deletion) the doc doesn't already have.
81
+ /// This lets the server make durable side effects exactly-once: a lost-ack retry
82
+ /// re-sends an update the server already applied; that retry is causally ready
83
+ /// (so `update_is_ready` is true) but must 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
- /// which would look identical to a no-op. So measure the real effect: seed an
89
- /// independent probe with the doc's current state, apply the update there, and
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 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.
88
+ /// which would look identical to a no-op. So measure the real effect on an
89
+ /// independent probe seeded with the doc's current state (never mutating the real
90
+ /// doc), then compare the probe before and after applying the update:
91
+ ///
92
+ /// - **Insert/format-only updates** grow the probe's state vector, so comparing
93
+ /// the state vector is enough and cheaper than a full re-encode.
94
+ /// - **Delete-bearing updates** don't move the state vector (a deletion tombstones
95
+ /// an existing struct rather than adding one), so we compare the full encoded
96
+ /// state, which carries the delete set. An already-applied pure-delete retry
97
+ /// re-encodes byte-identically → false; a genuinely new deletion changes the
98
+ /// delete set → true. This is exact but pays for two full encodes, so only
99
+ /// delete-bearing frames — a minority — take that path.
100
+ ///
101
+ /// Earlier this branch was conservative: any delete-bearing update returned true
102
+ /// (record it), which double-recorded and re-broadcast pure-delete retries the
103
+ /// server had already integrated. The exact comparison removes that duplication
104
+ /// while still never dropping a real deletion. Assumes the update is already
105
+ /// causally ready.
95
106
  pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
96
107
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
97
- if !update.delete_set().is_empty() {
98
- return Ok(true); // can't cheaply prove a delete is a duplicate; record it
99
- }
108
+ let has_deletes = !update.delete_set().is_empty();
109
+
110
+ // Seed an independent probe with the doc's current state so we can measure the
111
+ // update's effect without mutating the real doc.
100
112
  let probe = Doc::new();
101
113
  let current = doc
102
114
  .transact()
@@ -105,30 +117,78 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
105
117
  .transact_mut()
106
118
  .apply_update(yrs::Update::decode_v1(&current).map_err(|e| e.to_string())?)
107
119
  .map_err(|e| e.to_string())?;
108
- let before = probe.transact().state_vector();
109
- probe
110
- .transact_mut()
111
- .apply_update(update)
112
- .map_err(|e| e.to_string())?;
113
- let after = probe.transact().state_vector();
114
- Ok(after != before)
120
+
121
+ if has_deletes {
122
+ // Deletes don't move the state vector; compare the full encoded state
123
+ // (which includes the delete set), before vs. after, on the same probe.
124
+ let before = probe
125
+ .transact()
126
+ .encode_state_as_update_v1(&yrs::StateVector::default());
127
+ probe
128
+ .transact_mut()
129
+ .apply_update(update)
130
+ .map_err(|e| e.to_string())?;
131
+ let after = probe
132
+ .transact()
133
+ .encode_state_as_update_v1(&yrs::StateVector::default());
134
+ Ok(before != after)
135
+ } else {
136
+ let before = probe.transact().state_vector();
137
+ probe
138
+ .transact_mut()
139
+ .apply_update(update)
140
+ .map_err(|e| e.to_string())?;
141
+ let after = probe.transact().state_vector();
142
+ Ok(before != after)
143
+ }
115
144
  }
116
145
 
117
- /// True if the doc holds pending structs or a pending delete set: blocks that
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)]
121
- pub(crate) fn doc_has_pending(doc: &Doc) -> bool {
146
+ /// True if the doc holds un-integrable pending structs or a pending delete set:
147
+ /// blocks that couldn't integrate because a causally-prior update is missing. A
148
+ /// pure read; does not mutate.
149
+ pub(crate) fn has_pending(doc: &Doc) -> bool {
122
150
  let txn = doc.transact();
123
151
  txn.store().pending_update().is_some() || txn.store().pending_ds().is_some()
124
152
  }
125
153
 
154
+ /// Encode the doc's **integrated** state as a v1 update diffed against `sv`,
155
+ /// excluding any pending (un-integrable) structs and pending delete set.
156
+ ///
157
+ /// Pending blocks are a recovery buffer, not document state. Serving them across
158
+ /// the sync boundary hands a peer content it can't integrate, so the peer parks
159
+ /// the same pending forever and the state-vector/content mismatch drives endless
160
+ /// resync traffic. `encode_state_as_update_v1` merges pending back in (see yrs
161
+ /// `merge_pending_v1`), so to get a gap-free encode we rebuild the state into a
162
+ /// throwaway doc and `prune_pending` there before re-encoding.
163
+ ///
164
+ /// Non-destructive: the prune happens only on the throwaway copy; `doc` keeps its
165
+ /// pending, so a genuine gap still heals if its missing dependency later arrives.
166
+ pub(crate) fn integrated_update(doc: &Doc, sv: &StateVector) -> Result<Vec<u8>, String> {
167
+ // Fast path: with nothing pending the direct encode is already gap-free, so
168
+ // the clean common case keeps the zero-copy behavior.
169
+ if !has_pending(doc) {
170
+ return Ok(doc.transact().encode_state_as_update_v1(sv));
171
+ }
172
+ let full = doc
173
+ .transact()
174
+ .encode_state_as_update_v1(&StateVector::default());
175
+ let clean = Doc::new();
176
+ {
177
+ let mut txn = clean.transact_mut();
178
+ txn.apply_update(Update::decode_v1(&full).map_err(|e| e.to_string())?)
179
+ .map_err(|e| e.to_string())?;
180
+ txn.prune_pending();
181
+ }
182
+ let out = clean.transact().encode_state_as_update_v1(sv);
183
+ Ok(out)
184
+ }
185
+
126
186
  #[cfg(test)]
127
187
  mod tests {
128
188
  use super::*;
129
189
  use yrs::sync::Awareness;
130
190
  use yrs::updates::encoder::Encode;
131
- use yrs::Text;
191
+ use yrs::{GetString, Text};
132
192
 
133
193
  fn text_update(content: &str) -> Vec<u8> {
134
194
  let doc = Doc::new();
@@ -246,6 +306,87 @@ mod tests {
246
306
  );
247
307
  }
248
308
 
309
+ #[test]
310
+ fn update_advances_is_exact_for_pure_delete_retries() {
311
+ // Build "hello", snapshot the pre-delete content, then delete a char and
312
+ // capture just that deletion as a diff (only a delete set, no new structs).
313
+ let doc = Doc::new();
314
+ let text = doc.get_or_insert_text("content");
315
+ text.insert(&mut doc.transact_mut(), 0, "hello");
316
+ let content_state = doc
317
+ .transact()
318
+ .encode_state_as_update_v1(&yrs::StateVector::default());
319
+ let sv_before = doc.transact().state_vector();
320
+ text.remove_range(&mut doc.transact_mut(), 0, 1); // delete "h"
321
+ let delete = doc.transact().encode_state_as_update_v1(&sv_before);
322
+
323
+ assert!(
324
+ !yrs::Update::decode_v1(&delete)
325
+ .unwrap()
326
+ .delete_set()
327
+ .is_empty(),
328
+ "the diff carries a delete set"
329
+ );
330
+
331
+ // A server holding the pre-delete content, but not the deletion yet.
332
+ let server = Doc::new();
333
+ server
334
+ .transact_mut()
335
+ .apply_update(yrs::Update::decode_v1(&content_state).unwrap())
336
+ .unwrap();
337
+
338
+ // The deletion is new: it advances (must be recorded).
339
+ assert!(
340
+ update_advances_doc(&server, &delete).unwrap(),
341
+ "a not-yet-applied deletion advances the doc"
342
+ );
343
+
344
+ // Apply it; now the byte-identical pure-delete retry must NOT advance.
345
+ // (This is the behavior change: it used to conservatively return true.)
346
+ server
347
+ .transact_mut()
348
+ .apply_update(yrs::Update::decode_v1(&delete).unwrap())
349
+ .unwrap();
350
+ assert!(
351
+ !update_advances_doc(&server, &delete).unwrap(),
352
+ "an already-applied pure-delete retry does not advance"
353
+ );
354
+ }
355
+
356
+ #[test]
357
+ fn update_advances_for_a_delete_bundled_with_new_content() {
358
+ // A delete-bearing update that ALSO carries a new struct still advances,
359
+ // even after the pure-delete part would be a no-op on its own.
360
+ let doc = Doc::new();
361
+ let text = doc.get_or_insert_text("content");
362
+ text.insert(&mut doc.transact_mut(), 0, "hello");
363
+ let content_state = doc
364
+ .transact()
365
+ .encode_state_as_update_v1(&yrs::StateVector::default());
366
+ let sv_before = doc.transact().state_vector();
367
+ text.remove_range(&mut doc.transact_mut(), 0, 1); // delete "h"
368
+ text.insert(&mut doc.transact_mut(), 4, "!"); // and add "!"
369
+ let mixed = doc.transact().encode_state_as_update_v1(&sv_before);
370
+
371
+ let server = Doc::new();
372
+ server
373
+ .transact_mut()
374
+ .apply_update(yrs::Update::decode_v1(&content_state).unwrap())
375
+ .unwrap();
376
+ assert!(
377
+ update_advances_doc(&server, &mixed).unwrap(),
378
+ "an insert+delete update advances"
379
+ );
380
+ server
381
+ .transact_mut()
382
+ .apply_update(yrs::Update::decode_v1(&mixed).unwrap())
383
+ .unwrap();
384
+ assert!(
385
+ !update_advances_doc(&server, &mixed).unwrap(),
386
+ "its byte-identical retry does not advance"
387
+ );
388
+ }
389
+
249
390
  #[test]
250
391
  fn merged_doc_update_extracts_and_skips_no_ops() {
251
392
  // A document update yields a delta that reconstructs the content.
@@ -308,24 +449,213 @@ mod tests {
308
449
  !update_is_ready(&doc, u3).unwrap(),
309
450
  "u3 depends on the missing u2"
310
451
  );
311
- assert!(
312
- !doc_has_pending(&doc),
313
- "nothing pending until u3 is applied"
314
- );
452
+ assert!(!has_pending(&doc), "nothing pending until u3 is applied");
315
453
 
316
454
  // Applying u3 anyway parks it as a pending struct.
317
455
  doc.transact_mut()
318
456
  .apply_update(yrs::Update::decode_v1(u3).unwrap())
319
457
  .unwrap();
320
- assert!(
321
- doc_has_pending(&doc),
322
- "u3 is pending: its parent u2 is missing"
323
- );
458
+ assert!(has_pending(&doc), "u3 is pending: its parent u2 is missing");
324
459
 
325
460
  // Once u2 arrives (via resync), u3 integrates and pending clears.
326
461
  doc.transact_mut()
327
462
  .apply_update(yrs::Update::decode_v1(u2).unwrap())
328
463
  .unwrap();
329
- assert!(!doc_has_pending(&doc), "u2 arrived; u3 integrated");
464
+ assert!(!has_pending(&doc), "u2 arrived; u3 integrated");
465
+ }
466
+
467
+ // Build a causal gap: `first` inserts "a", `dependent` inserts "b" after it,
468
+ // so `dependent` alone parks as pending on a doc that lacks `first`.
469
+ fn gap_pair() -> (Vec<u8>, Vec<u8>) {
470
+ let src = Doc::new();
471
+ let txt = src.get_or_insert_text("notepad");
472
+ txt.insert(&mut src.transact_mut(), 0, "a");
473
+ let first = src
474
+ .transact()
475
+ .encode_state_as_update_v1(&yrs::StateVector::default());
476
+ let sv = src.transact().state_vector();
477
+ txt.insert(&mut src.transact_mut(), 1, "b");
478
+ let dependent = src.transact().encode_state_as_update_v1(&sv);
479
+ (first, dependent)
480
+ }
481
+
482
+ #[test]
483
+ fn integrated_update_strips_pending_and_is_non_destructive() {
484
+ let (_first, dependent) = gap_pair();
485
+ let doc = Doc::new();
486
+ doc.transact_mut()
487
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
488
+ .unwrap();
489
+ assert!(has_pending(&doc), "the gappy update parked as pending");
490
+
491
+ // encode_state_as_update carries the pending; integrated_update does not.
492
+ let full = doc
493
+ .transact()
494
+ .encode_state_as_update_v1(&yrs::StateVector::default());
495
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
496
+ assert_ne!(full, gap_free, "integrated_update drops the pending bytes");
497
+
498
+ // Applying the gap-free encode to a fresh peer must NOT poison it.
499
+ let peer = Doc::new();
500
+ peer.transact_mut()
501
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
502
+ .unwrap();
503
+ assert!(
504
+ !has_pending(&peer),
505
+ "peer got no pending from the gap-free state"
506
+ );
507
+
508
+ // Non-destructive: the source doc keeps its pending (so it can still heal).
509
+ assert!(
510
+ has_pending(&doc),
511
+ "integrated_update did not mutate the source"
512
+ );
513
+ }
514
+
515
+ #[test]
516
+ fn integrated_update_fast_path_matches_direct_encode_when_clean() {
517
+ // No pending -> byte-identical to encode_state_as_update (zero-copy path).
518
+ let (first, _dependent) = gap_pair();
519
+ let doc = Doc::new();
520
+ doc.transact_mut()
521
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
522
+ .unwrap();
523
+ assert!(!has_pending(&doc));
524
+ let direct = doc
525
+ .transact()
526
+ .encode_state_as_update_v1(&yrs::StateVector::default());
527
+ let via = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
528
+ assert_eq!(direct, via);
529
+ }
530
+
531
+ #[test]
532
+ fn a_healed_gap_serves_its_content() {
533
+ // After the missing dependency arrives, the (formerly pending) content is
534
+ // integrated and integrated_update includes it.
535
+ let (first, dependent) = gap_pair();
536
+ let doc = Doc::new();
537
+ doc.transact_mut()
538
+ .apply_update(yrs::Update::decode_v1(&dependent).unwrap())
539
+ .unwrap();
540
+ doc.transact_mut()
541
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
542
+ .unwrap();
543
+ assert!(!has_pending(&doc), "gap healed once first arrived");
544
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
545
+ let peer = Doc::new();
546
+ peer.transact_mut()
547
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
548
+ .unwrap();
549
+ assert_eq!(
550
+ peer.get_or_insert_text("notepad")
551
+ .get_string(&peer.transact()),
552
+ "ab"
553
+ );
554
+ }
555
+
556
+ // A gappy insert from its own independent client: inserts two chars and
557
+ // returns only the second delta, which depends on the (missing) first.
558
+ fn independent_gappy_insert() -> Vec<u8> {
559
+ let src = Doc::new();
560
+ let txt = src.get_or_insert_text("notepad");
561
+ txt.insert(&mut src.transact_mut(), 0, "x");
562
+ let sv = src.transact().state_vector();
563
+ txt.insert(&mut src.transact_mut(), 1, "y");
564
+ let txn = src.transact();
565
+ txn.encode_state_as_update_v1(&sv)
566
+ }
567
+
568
+ #[test]
569
+ fn integrated_update_keeps_content_and_drops_pending_when_mixed() {
570
+ // The realistic case: a doc with real integrated content AND a pending
571
+ // struct. Pruning must keep the content and drop only the pending.
572
+ let (first, _dep) = gap_pair();
573
+ let doc = Doc::new();
574
+ doc.transact_mut()
575
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
576
+ .unwrap(); // integrated "a"
577
+ doc.transact_mut()
578
+ .apply_update(yrs::Update::decode_v1(&independent_gappy_insert()).unwrap())
579
+ .unwrap(); // + a pending struct from another client
580
+ assert!(has_pending(&doc));
581
+
582
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
583
+ let peer = Doc::new();
584
+ peer.transact_mut()
585
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
586
+ .unwrap();
587
+ assert_eq!(
588
+ peer.get_or_insert_text("notepad")
589
+ .get_string(&peer.transact()),
590
+ "a",
591
+ "kept the integrated content"
592
+ );
593
+ assert!(!has_pending(&peer), "dropped the pending");
594
+ }
595
+
596
+ #[test]
597
+ fn integrated_update_diffs_against_a_peer_sv_and_excludes_pending() {
598
+ // The production signature: `handle_sync_message` calls
599
+ // integrated_update(doc, peer_sv). A peer already holding the integrated
600
+ // content should get a diff carrying no new content and no pending.
601
+ let (first, _dep) = gap_pair();
602
+ let server = Doc::new();
603
+ server
604
+ .transact_mut()
605
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
606
+ .unwrap();
607
+ server
608
+ .transact_mut()
609
+ .apply_update(yrs::Update::decode_v1(&independent_gappy_insert()).unwrap())
610
+ .unwrap();
611
+
612
+ let peer = Doc::new();
613
+ peer.transact_mut()
614
+ .apply_update(yrs::Update::decode_v1(&first).unwrap())
615
+ .unwrap();
616
+ let peer_sv = peer.transact().state_vector();
617
+
618
+ let diff = integrated_update(&server, &peer_sv).unwrap();
619
+ peer.transact_mut()
620
+ .apply_update(yrs::Update::decode_v1(&diff).unwrap())
621
+ .unwrap();
622
+ assert_eq!(
623
+ peer.get_or_insert_text("notepad")
624
+ .get_string(&peer.transact()),
625
+ "a"
626
+ );
627
+ assert!(!has_pending(&peer), "the diff carried no pending");
628
+ }
629
+
630
+ #[test]
631
+ fn integrated_update_strips_a_pending_delete_set() {
632
+ // A deletion whose target struct is absent parks as a pending *delete
633
+ // set* -- the delete-side counterpart to a pending struct.
634
+ let src = Doc::new();
635
+ let txt = src.get_or_insert_text("notepad");
636
+ txt.insert(&mut src.transact_mut(), 0, "z");
637
+ let sv = src.transact().state_vector();
638
+ txt.remove_range(&mut src.transact_mut(), 0, 1);
639
+ let deletion = src.transact().encode_state_as_update_v1(&sv); // delete-only
640
+
641
+ let doc = Doc::new();
642
+ doc.transact_mut()
643
+ .apply_update(yrs::Update::decode_v1(&deletion).unwrap())
644
+ .unwrap();
645
+ assert!(
646
+ has_pending(&doc),
647
+ "the orphan deletion parked as a pending delete set"
648
+ );
649
+
650
+ let gap_free = integrated_update(&doc, &yrs::StateVector::default()).unwrap();
651
+ let peer = Doc::new();
652
+ peer.transact_mut()
653
+ .apply_update(yrs::Update::decode_v1(&gap_free).unwrap())
654
+ .unwrap();
655
+ assert!(!has_pending(&peer), "the pending delete set was not served");
656
+ assert!(
657
+ has_pending(&doc),
658
+ "non-destructive: source keeps its pending"
659
+ );
330
660
  }
331
661
  }
data/ext/yrby/src/read.rs CHANGED
@@ -4,29 +4,99 @@
4
4
 
5
5
  use std::collections::HashMap;
6
6
  use std::sync::Arc;
7
- use yrs::{Any, Array, GetString, Map, MapRef, Out, ReadTxn, XmlFragment, XmlFragmentRef, XmlOut};
7
+ use yrs::types::text::YChange;
8
+ use yrs::{
9
+ Any, Array, GetString, Map, MapRef, Out, ReadTxn, Text, Xml, XmlFragment, XmlFragmentRef,
10
+ XmlOut, XmlTextRef,
11
+ };
8
12
 
9
13
  /// Read an XML-shaped root as text, one top-level block per line.
10
14
  ///
11
- /// ProseMirror stores blocks as `Y.XmlElement` children (`<paragraph>…`);
12
- /// Lexical stores each block as a sibling `Y.XmlText` (its node metadata is an
13
- /// embed, which yrs omits from the string). We serialize each top-level child and
14
- /// join with "\n", so adjacent blocks don't merge into one run of words. Without
15
- /// the separator, Lexical whose blocks carry no element tags — would glue
16
- /// paragraphs together (e.g. "first paragraphsecond paragraph"), breaking word
17
- /// boundaries for search/preview. Element tags are kept (the caller strips them);
18
- /// deeper nesting is flattened, but its inner tags still separate words after
19
- /// stripping.
15
+ /// Two editors store their documents differently, and both are handled:
16
+ ///
17
+ /// - **ProseMirror** (Tiptap) stores blocks as `Y.XmlElement` children
18
+ /// (`<paragraph>…`). `get_string` already recurses these (tags included; the
19
+ /// caller strips them), so we keep that path.
20
+ /// - **Lexical** (Lexxy) stores every node as a `Y.XmlText`, and nests child
21
+ /// blocks (list items, table cells, nested lists) as *embedded* `Y.XmlText`s
22
+ /// which `get_string` silently omits, dropping all that content. So for a
23
+ /// Lexical block we walk its content (`Text::diff`) instead: text runs build a
24
+ /// line, inline children (links) join it, and nested block children flush the
25
+ /// line and recurse. Each leaf block becomes one line, so words never glue
26
+ /// across blocks and lists/tables come through intact.
20
27
  pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String {
21
- fragment
22
- .children(txn)
23
- .map(|node| match node {
24
- XmlOut::Element(e) => e.get_string(txn),
25
- XmlOut::Text(t) => t.get_string(txn),
26
- XmlOut::Fragment(f) => f.get_string(txn),
27
- })
28
- .collect::<Vec<_>>()
29
- .join("\n")
28
+ let mut out: Vec<String> = Vec::new();
29
+ for node in fragment.children(txn) {
30
+ match node {
31
+ XmlOut::Text(t) => walk_lexical_block(txn, &t, &mut out),
32
+ XmlOut::Element(e) => {
33
+ // ProseMirror blocks have a tag but no `__type`; get_string recurses
34
+ // them (tags kept, caller strips). A Lexical decorator (horizontal
35
+ // rule, image) is an XmlElement *with* a `__type` and no extractable
36
+ // text -- skip it rather than emit its `<UNDEFINED …>` serialization.
37
+ if e.get_attribute(txn, "__type").is_none() {
38
+ out.push(e.get_string(txn));
39
+ }
40
+ }
41
+ XmlOut::Fragment(f) => out.push(f.get_string(txn)),
42
+ }
43
+ }
44
+ out.join("\n")
45
+ }
46
+
47
+ /// Lexical node `__type`s whose text belongs on the surrounding line rather than
48
+ /// a new block (e.g. a link inside a paragraph). Everything else with embedded
49
+ /// child `Y.XmlText`s is treated as a block and recursed.
50
+ fn is_inline_lexical_type(ty: &str) -> bool {
51
+ matches!(
52
+ ty,
53
+ "text" | "link" | "autolink" | "linebreak" | "tab" | "hashtag" | "mark" | "overflow"
54
+ )
55
+ }
56
+
57
+ /// A Lexical node's `__type` (stored as an XML attribute on its `Y.XmlText`).
58
+ fn lexical_type<T: ReadTxn>(txn: &T, t: &XmlTextRef) -> String {
59
+ match t.get_attribute(txn, "__type") {
60
+ Some(Out::Any(Any::String(s))) => s.to_string(),
61
+ _ => String::new(),
62
+ }
63
+ }
64
+
65
+ /// Gather the text of an inline Lexical element (its text runs and any nested
66
+ /// inline elements) without introducing block breaks.
67
+ fn inline_lexical_text<T: ReadTxn>(txn: &T, t: &XmlTextRef, buf: &mut String) {
68
+ for d in t.diff(txn, YChange::identity) {
69
+ match d.insert {
70
+ Out::Any(Any::String(s)) => buf.push_str(&s),
71
+ Out::YXmlText(child) => inline_lexical_text(txn, &child, buf),
72
+ _ => {} // per-text-node metadata map, decorator embeds: no text
73
+ }
74
+ }
75
+ }
76
+
77
+ /// Walk a Lexical block (`Y.XmlText`), pushing one line per leaf block. Text runs
78
+ /// accumulate; inline children join the line; block children flush it and recurse.
79
+ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>) {
80
+ let mut line = String::new();
81
+ for d in t.diff(txn, YChange::identity) {
82
+ match d.insert {
83
+ Out::Any(Any::String(s)) => line.push_str(&s),
84
+ Out::YXmlText(child) => {
85
+ if is_inline_lexical_type(&lexical_type(txn, &child)) {
86
+ inline_lexical_text(txn, &child, &mut line);
87
+ } else {
88
+ if !line.is_empty() {
89
+ out.push(std::mem::take(&mut line));
90
+ }
91
+ walk_lexical_block(txn, &child, out);
92
+ }
93
+ }
94
+ _ => {} // per-text-node metadata map; embeds we don't read for text
95
+ }
96
+ }
97
+ if !line.is_empty() {
98
+ out.push(line);
99
+ }
30
100
  }
31
101
 
32
102
  /// Read a `Y.Map` root as a JSON object string (keys sorted for stable output).
@@ -185,4 +255,49 @@ mod tests {
185
255
  let txn = doc.transact();
186
256
  assert_eq!(map_json(&txn, &map), "{}");
187
257
  }
258
+
259
+ #[test]
260
+ fn lexical_complex_doc_extracts_all_nested_text() {
261
+ // A real Lexxy/Lexical doc with every block type: headings, formatted
262
+ // text, an inline link, bullet + NESTED bullet + numbered + check lists,
263
+ // a quote, a code block, a horizontal rule, and a table. Every piece of
264
+ // text -- including list items, the nested sub-list, and table cells --
265
+ // must come through (get_string alone dropped all the nested ones).
266
+ use yrs::updates::decoder::Decode;
267
+ use yrs::{Transact, Update};
268
+ let bytes = include_bytes!("fixtures/lexical_rich.bin");
269
+ let doc = Doc::new();
270
+ {
271
+ let mut txn = doc.transact_mut();
272
+ txn.apply_update(Update::decode_v1(bytes).unwrap()).unwrap();
273
+ }
274
+ let txn = doc.transact();
275
+ let frag = txn.get_xml_fragment("root").unwrap();
276
+ let text = xml_blocks_text(&txn, &frag);
277
+ for expected in [
278
+ "Heading One",
279
+ "Heading Two",
280
+ "Plain, bold, italic, strike, underline, and code.",
281
+ "Visit the website for more.", // link text stays inline
282
+ "First bullet",
283
+ "Second bullet",
284
+ "Nested A", // nested sub-list
285
+ "Nested B",
286
+ "Step one",
287
+ "Step two",
288
+ "Done item",
289
+ "Todo item",
290
+ "A blockquote about CRDTs.",
291
+ "const x = 1;", // code block (keeps its internal newline)
292
+ "console.log(x);",
293
+ "Name", // table header cells
294
+ "Role",
295
+ "Ada", // table body cells
296
+ "Engineer",
297
+ ] {
298
+ assert!(text.contains(expected), "missing {expected:?} in:\n{text}");
299
+ }
300
+ // The inline link must NOT have been split onto its own line.
301
+ assert!(text.contains("Visit the website for more."));
302
+ }
188
303
  }
data/lib/y/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Y
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara