yrby 0.6.1 → 0.7.1

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.
data/ext/yrby/Cargo.toml CHANGED
@@ -14,9 +14,9 @@ magnus = "0.8"
14
14
  rb-sys = "0.9"
15
15
  yrs = { version = "0.27", features = ["sync"] }
16
16
  serde_json = "1.0"
17
- yrs-html-core = { path = "crates/html-core" }
18
- yrs-lexical-html = { path = "crates/lexical-html" }
19
- yrs-prosemirror-html = { path = "crates/prosemirror-html" }
17
+ yjs-html-core = { path = "crates/html-core" }
18
+ lexical-yjs-html = { path = "crates/lexical-html" }
19
+ prosemirror-yjs-html = { path = "crates/prosemirror-html" }
20
20
 
21
21
  [dev-dependencies]
22
22
  rb-sys-env = "0.1"
@@ -1,14 +1,15 @@
1
1
  [package]
2
- name = "yrs-html-core"
3
- description = "Internal core for the yrs-*-html renderer crates; depend on those instead"
2
+ name = "yjs-html-core"
3
+ description = "Internal core for the lexical-yjs-html and prosemirror-yjs-html renderer crates; depend on those instead"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.0"
6
+ version = "0.1.1"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
10
10
  license = "MIT"
11
- publish = false
11
+ keywords = ["yjs", "crdt", "yrs", "html", "rendering"]
12
+ categories = ["text-processing", "web-programming"]
12
13
 
13
14
  [dependencies]
14
15
  yrs = { version = "0.27", features = ["sync"] }
@@ -196,11 +196,7 @@ pub fn resolve_parts<F: Fn(&str) -> Option<String>>(
196
196
  }
197
197
  }
198
198
  }
199
- if out.is_empty() {
200
- None
201
- } else {
202
- Some(out)
203
- }
199
+ if out.is_empty() { None } else { Some(out) }
204
200
  }
205
201
 
206
202
  /// An attribute reference on a node: rules say `:kind`; Lexical stores its own
@@ -384,7 +380,7 @@ fn parse_node_rule(name: &str, spec: &serde_json::Value) -> Result<NodeRule, Str
384
380
  Some(other) => {
385
381
  return Err(format!(
386
382
  "rule for {name:?}: unknown content kind {other:?} (blocks|inline|none)"
387
- ))
383
+ ));
388
384
  }
389
385
  };
390
386
  if spec
@@ -431,7 +427,7 @@ fn parse_attrs(
431
427
  Some(_) => {
432
428
  return Err(format!(
433
429
  "rule for {name:?}: attrs must be an array of [name, template] pairs"
434
- ))
430
+ ));
435
431
  }
436
432
  };
437
433
  for entry in entries {
@@ -1,16 +1,17 @@
1
1
  [package]
2
- name = "yrs-lexical-html"
2
+ name = "lexical-yjs-html"
3
3
  description = "Render Lexical-shaped yrs documents to HTML, no browser or Node required"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.0"
6
+ version = "0.1.1"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
10
10
  license = "MIT"
11
- publish = false
11
+ keywords = ["yjs", "crdt", "lexical", "html", "yrs"]
12
+ categories = ["text-processing", "web-programming"]
12
13
 
13
14
  [dependencies]
14
15
  yrs = { version = "0.27", features = ["sync"] }
15
16
  serde_json = "1.0"
16
- yrs-html-core = { path = "../html-core" }
17
+ yjs-html-core = { path = "../html-core", version = "0.1.1" }
@@ -39,7 +39,7 @@
39
39
  //! with the unwrapped span. Case-transform format bits are never rendered
40
40
  //! (their text-transform style is outside the sanitize whitelist).
41
41
  //!
42
- //! Custom nodes: rules are registered by `__type` (see `yrs-html-core`) and
42
+ //! Custom nodes: rules are registered by `__type` (see `yjs-html-core`) and
43
43
  //! consulted before the built-in arms, so they extend the schema or override
44
44
  //! a built-in. Declarative rules render here; callback rules emit
45
45
  //! `Segment::Deferred` for the caller to fill in after the render.
@@ -50,8 +50,8 @@
50
50
  mod readme_examples {}
51
51
 
52
52
  // The full rules surface, re-exported: depend on this crate alone;
53
- // yrs-html-core is an internal implementation crate.
54
- pub use yrs_html_core::*;
53
+ // yjs-html-core is an internal implementation crate.
54
+ pub use yjs_html_core::*;
55
55
  use yrs::types::text::YChange;
56
56
  use yrs::{
57
57
  Any, GetString, Map, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef,
@@ -130,7 +130,7 @@ pub fn render_segments<T: ReadTxn>(
130
130
  /// callback rules, segments always flatten.
131
131
  pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
132
132
  render_segments(txn, fragment, &Rules::empty()).map(|segs| {
133
- yrs_html_core::flatten(segs)
133
+ yjs_html_core::flatten(segs)
134
134
  .into_html()
135
135
  .expect("no callback rules registered")
136
136
  })
@@ -773,11 +773,7 @@ fn lexxy_style(style: &str) -> Option<String> {
773
773
  css.push(';');
774
774
  }
775
775
  }
776
- if css.is_empty() {
777
- None
778
- } else {
779
- Some(css)
780
- }
776
+ if css.is_empty() { None } else { Some(css) }
781
777
  }
782
778
 
783
779
  /// `link` / `autolink`: Lexxy's sanitize keeps only `href` and `title`
@@ -1301,7 +1297,7 @@ mod tests {
1301
1297
  let txn = doc.transact();
1302
1298
  let frag = txn.get_xml_fragment("root").unwrap();
1303
1299
  let segs = render_segments(&txn, &frag, &rules).unwrap();
1304
- let html = yrs_html_core::flatten(segs).into_html().unwrap();
1300
+ let html = yjs_html_core::flatten(segs).into_html().unwrap();
1305
1301
  assert_eq!(
1306
1302
  html,
1307
1303
  "<p><a class=\"app-link\" href=\"https://x.example\">site</a>\
@@ -1,16 +1,17 @@
1
1
  [package]
2
- name = "yrs-prosemirror-html"
2
+ name = "prosemirror-yjs-html"
3
3
  description = "Render ProseMirror-shaped yrs documents to HTML, no browser or Node required"
4
4
  repository = "https://github.com/jpcamara/yrby"
5
5
  readme = "README.md"
6
- version = "0.1.0"
6
+ version = "0.1.2"
7
7
  edition = "2024"
8
8
  rust-version = "1.85"
9
9
  authors = ["JP Camara <johnpcamara@gmail.com>"]
10
10
  license = "MIT"
11
- publish = false
11
+ keywords = ["yjs", "crdt", "prosemirror", "tiptap", "html"]
12
+ categories = ["text-processing", "web-programming"]
12
13
 
13
14
  [dependencies]
14
15
  yrs = { version = "0.27", features = ["sync"] }
15
16
  serde_json = "1.0"
16
- yrs-html-core = { path = "../html-core" }
17
+ yjs-html-core = { path = "../html-core", version = "0.1.1" }
@@ -42,10 +42,10 @@
42
42
  mod readme_examples {}
43
43
 
44
44
  // The full rules surface, re-exported: depend on this crate alone;
45
- // yrs-html-core is an internal implementation crate.
46
- pub use yrs_html_core::*;
47
- use yrs::types::text::YChange;
45
+ // yjs-html-core is an internal implementation crate.
46
+ pub use yjs_html_core::*;
48
47
  use yrs::types::Attrs;
48
+ use yrs::types::text::YChange;
49
49
  use yrs::{
50
50
  Any, GetString, Out, ReadTxn, Text, Xml, XmlElementRef, XmlFragment, XmlFragmentRef, XmlOut,
51
51
  XmlTextRef,
@@ -105,7 +105,7 @@ pub fn render_segments<T: ReadTxn>(
105
105
  /// callback rules, segments always flatten.
106
106
  pub fn render<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> Option<String> {
107
107
  render_segments(txn, fragment, &Rules::empty()).map(|segs| {
108
- yrs_html_core::flatten(segs)
108
+ yjs_html_core::flatten(segs)
109
109
  .into_html()
110
110
  .expect("no callback rules registered")
111
111
  })
@@ -605,102 +605,120 @@ fn render_rule_inline<T: ReadTxn>(
605
605
  em.push('>');
606
606
  }
607
607
 
608
- /// Emit each formatted run of a Y.XmlText.
608
+ /// Emit the formatted runs of a Y.XmlText, keeping marks shared by
609
+ /// adjacent runs open across them: `bold` then `bold italic` renders as
610
+ /// `<strong>a<em>b</em></strong>`, the way the editor's DOM serializer
611
+ /// keeps an active mark open into the next run, not as two sibling
612
+ /// `<strong>` wraps. Sharing is by identical opening tag, so a mark whose
613
+ /// attributes differ between runs (a different href, a different style)
614
+ /// never merges.
609
615
  fn render_text_runs<T: ReadTxn>(txn: &T, t: &XmlTextRef, em: &mut Emitter, rules: &Rules) {
616
+ let mut open: Vec<(String, String)> = Vec::new();
610
617
  for d in t.diff(txn, YChange::identity) {
611
618
  if let Out::Any(Any::String(s)) = &d.insert {
612
- em.push_str(&render_run(s, d.attributes.as_deref(), rules));
619
+ let stack = run_stack(d.attributes.as_deref(), rules);
620
+ let shared = open
621
+ .iter()
622
+ .zip(stack.iter())
623
+ .take_while(|(a, b)| a == b)
624
+ .count();
625
+ for (_, close) in open.split_off(shared).into_iter().rev() {
626
+ em.push_str(&close);
627
+ }
628
+ for (open_tag, _) in &stack[shared..] {
629
+ em.push_str(open_tag);
630
+ }
631
+ open = stack;
632
+ em.push_str(&escape_text(s));
613
633
  }
614
634
  }
635
+ for (_, close) in open.into_iter().rev() {
636
+ em.push_str(&close);
637
+ }
615
638
  }
616
639
 
617
- /// Wrap one text run in its marks, nesting innermost-first:
618
- /// subscript/superscript, highlight, underline, strike, italic, bold, a
619
- /// textStyle span, then link on the outside. `code` renders alone among the
620
- /// formatting marks (Tiptap's Code mark excludes them all), but a link still
621
- /// wraps it — Tiptap can't produce code+link, prosemirror-schema-basic can,
622
- /// and dropping the link would lose the href.
623
- ///
624
- /// A registered mark rule claims its stored name from the built-in wraps
625
- /// (overriding it) and wraps outside everything; multiple custom marks nest
626
- /// alphabetically by name, so output is deterministic regardless of
627
- /// registration order. Overriding replaces only the markup: a claimed
628
- /// `code` still excludes the other formatting marks, and a claimed
629
- /// formatting mark stays excluded from a code run.
630
- fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
631
- let mut html = escape_text(text);
640
+ /// The run's mark wrappers as (open tag, close tag) pairs, outermost
641
+ /// first: registered custom marks (reverse-alphabetical, so the last name
642
+ /// alphabetically is outermost), link, the textStyle span, then bold,
643
+ /// italic, strike, underline, highlight, and innermost
644
+ /// subscript/superscript — or `code` alone in their place.
645
+ fn run_stack(marks: Option<&Attrs>, rules: &Rules) -> Vec<(String, String)> {
632
646
  let Some(marks) = marks else {
633
- return html;
647
+ return Vec::new();
634
648
  };
649
+ // Innermost first, reversed at the end.
650
+ let mut stack: Vec<(String, String)> = Vec::new();
651
+ let tag = |t: &str| (format!("<{t}>"), format!("</{t}>"));
635
652
  if has(marks, &["code"], rules) {
636
- html = wrap(html, "code");
653
+ stack.push(tag("code"));
637
654
  } else if !marks.contains_key("code") {
638
655
  // (A claimed `code` lands here too — on the run, but skipped by the
639
- // else-if: the rule replaces the wrap, via wrap_custom_marks below,
656
+ // else-if: the rule replaces the wrap, via the custom marks below,
640
657
  // and code's exclusivity over the other formatting marks holds.)
641
658
  if has(marks, &["subscript", "sub"], rules) {
642
- html = wrap(html, "sub");
659
+ stack.push(tag("sub"));
643
660
  } else if has(marks, &["superscript", "sup"], rules) {
644
- html = wrap(html, "sup");
661
+ stack.push(tag("sup"));
645
662
  }
646
663
  if has(marks, &["highlight"], rules) {
647
- html = wrap(html, "mark");
664
+ stack.push(tag("mark"));
648
665
  }
649
666
  if has(marks, &["underline", "u"], rules) {
650
- html = wrap(html, "u");
667
+ stack.push(tag("u"));
651
668
  }
652
669
  if has(marks, &["strike", "s"], rules) {
653
- html = wrap(html, "s");
670
+ stack.push(tag("s"));
654
671
  }
655
672
  if has(marks, &["italic", "em"], rules) {
656
- html = wrap(html, "em");
673
+ stack.push(tag("em"));
657
674
  }
658
675
  if has(marks, &["bold", "strong"], rules) {
659
- html = wrap(html, "strong");
676
+ stack.push(tag("strong"));
660
677
  }
661
678
  if !rules.marks.contains_key("textStyle") {
662
679
  if let Some(Any::Map(style)) = marks.get("textStyle") {
663
680
  let css = text_style_css(style);
664
681
  if !css.is_empty() {
665
- html = format!("<span style=\"{}\">{html}</span>", escape_attr(&css));
682
+ stack.push((
683
+ format!("<span style=\"{}\">", escape_attr(&css)),
684
+ "</span>".to_string(),
685
+ ));
666
686
  }
667
687
  }
668
688
  }
669
689
  }
670
690
  if !rules.marks.contains_key("link") {
671
691
  if let Some(Any::Map(link)) = marks.get("link") {
672
- html = wrap_link(html, link);
692
+ stack.push((link_open(link), "</a>".to_string()));
673
693
  }
674
694
  }
675
695
  if !rules.marks.is_empty() {
676
- html = wrap_custom_marks(html, marks, rules);
677
- }
678
- html
679
- }
680
-
681
- /// Apply the run's registered custom marks, outermost of everything. A code
682
- /// run keeps its exclusivity under overrides too: only the `code` and `link`
683
- /// claims themselves may wrap it, matching what the built-in wraps allow —
684
- /// otherwise overriding a formatting mark would change which marks render on
685
- /// a code run, not just their markup.
686
- fn wrap_custom_marks(html: String, marks: &Attrs, rules: &Rules) -> String {
687
- let code = marks.contains_key("code");
688
- let mut names: Vec<&str> = rules
689
- .marks
690
- .keys()
691
- .map(String::as_str)
692
- .filter(|name| marks.contains_key(*name))
693
- .filter(|name| !code || matches!(*name, "code" | "link"))
694
- .collect();
695
- names.sort_unstable();
696
- let mut html = html;
697
- for name in names {
698
- html = wrap_custom_mark(html, &rules.marks[name], marks.get(name));
696
+ let code = marks.contains_key("code");
697
+ let mut names: Vec<&str> = rules
698
+ .marks
699
+ .keys()
700
+ .map(String::as_str)
701
+ .filter(|name| marks.contains_key(*name))
702
+ .filter(|name| !code || matches!(*name, "code" | "link"))
703
+ .collect();
704
+ names.sort_unstable();
705
+ for name in names {
706
+ let rule = &rules.marks[name];
707
+ stack.push((
708
+ custom_mark_open(rule, marks.get(name)),
709
+ format!("</{}>", rule.tag),
710
+ ));
711
+ }
699
712
  }
700
- html
713
+ stack.reverse();
714
+ stack
701
715
  }
702
716
 
703
- fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> String {
717
+ /// The opening tag for a registered custom mark. Custom marks sit outside
718
+ /// everything; a code run keeps its exclusivity under overrides too: only
719
+ /// the `code` and `link` claims themselves may wrap it, matching what the
720
+ /// built-in wraps allow.
721
+ fn custom_mark_open(rule: &MarkRule, value: Option<&Any>) -> String {
704
722
  let mut out = String::from("<");
705
723
  out.push_str(&rule.tag);
706
724
  for (attr, parts) in &rule.attrs {
@@ -709,14 +727,10 @@ fn wrap_custom_mark(inner: String, rule: &MarkRule, value: Option<&Any>) -> Stri
709
727
  out.push_str(attr);
710
728
  out.push_str("=\"");
711
729
  out.push_str(&escape_attr(&v));
712
- out.push('"');
730
+ out.push('\"');
713
731
  }
714
732
  }
715
733
  out.push('>');
716
- out.push_str(&inner);
717
- out.push_str("</");
718
- out.push_str(&rule.tag);
719
- out.push('>');
720
734
  out
721
735
  }
722
736
 
@@ -790,7 +804,7 @@ fn hex_to_rgb(value: &str) -> Option<String> {
790
804
 
791
805
  /// `<a>` with Tiptap's attribute order (target, rel, class, href, title),
792
806
  /// skipping any that are absent or null.
793
- fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> String {
807
+ fn link_open(link: &std::collections::HashMap<String, Any>) -> String {
794
808
  let mut out = String::from("<a");
795
809
  for key in ["target", "rel", "class", "href", "title"] {
796
810
  if let Some(Any::String(v)) = link.get(key) {
@@ -802,8 +816,6 @@ fn wrap_link(inner: String, link: &std::collections::HashMap<String, Any>) -> St
802
816
  }
803
817
  }
804
818
  out.push('>');
805
- out.push_str(&inner);
806
- out.push_str("</a>");
807
819
  out
808
820
  }
809
821
 
@@ -822,10 +834,6 @@ fn render_image<T: ReadTxn>(txn: &T, e: &XmlElementRef, em: &mut Emitter) {
822
834
  em.push('>');
823
835
  }
824
836
 
825
- fn wrap(inner: String, tag: &str) -> String {
826
- format!("<{tag}>{inner}</{tag}>")
827
- }
828
-
829
837
  /// A mark is present and not claimed by a registered rule for that stored
830
838
  /// name (a rule overrides the built-in wrap).
831
839
  fn has(marks: &Attrs, keys: &[&str], rules: &Rules) -> bool {
@@ -903,10 +911,44 @@ mod tests {
903
911
  a
904
912
  }
905
913
 
914
+ /// Single-run rendering for the mark unit tests: the run's full
915
+ /// open/close stack around the escaped text.
916
+ fn render_run(text: &str, marks: Option<&Attrs>, rules: &Rules) -> String {
917
+ let stack = run_stack(marks, rules);
918
+ let mut html: String = stack.iter().map(|(open, _)| open.as_str()).collect();
919
+ html.push_str(&escape_text(text));
920
+ for (_, close) in stack.iter().rev() {
921
+ html.push_str(close);
922
+ }
923
+ html
924
+ }
925
+
906
926
  fn run(text: &str, marks: Option<&Attrs>) -> String {
907
927
  render_run(text, marks, &Rules::empty())
908
928
  }
909
929
 
930
+ #[test]
931
+ fn adjacent_runs_share_their_common_outer_marks() {
932
+ use yrs::{Text, XmlFragment};
933
+ let doc = Doc::new();
934
+ let f = doc.get_or_insert_xml_fragment("default");
935
+ let mut txn = doc.transact_mut();
936
+ let p = f.insert(&mut txn, 0, XmlElementPrelim::empty("paragraph"));
937
+ let t = p.insert(&mut txn, 0, XmlTextPrelim::new(""));
938
+ let bold = marks(&["bold"]);
939
+ let bold_em = marks(&["bold", "italic"]);
940
+ t.insert_with_attributes(&mut txn, 0, "nested ", bold.clone());
941
+ t.insert_with_attributes(&mut txn, 7, "deep", bold_em);
942
+ t.insert_with_attributes(&mut txn, 11, " tail", bold);
943
+ drop(txn);
944
+ let txn = doc.transact();
945
+ assert_eq!(
946
+ render(&txn, &f).unwrap(),
947
+ "<p><strong>nested <em>deep</em> tail</strong></p>",
948
+ "shared outer marks stay open across adjacent runs"
949
+ );
950
+ }
951
+
910
952
  /// Core rendering of the captured Tiptap document, pinned as a golden
911
953
  /// (`.core.html`). The task list renders through the unknown-container
912
954
  /// fallback here (unwrapped children); the external truth — byte parity
@@ -1236,7 +1278,7 @@ mod tests {
1236
1278
  let txn = doc.transact();
1237
1279
  let segs = render_segments(&txn, &frag, &rules).unwrap();
1238
1280
  assert_eq!(
1239
- yrs_html_core::flatten(segs).into_html().unwrap(),
1281
+ yjs_html_core::flatten(segs).into_html().unwrap(),
1240
1282
  "<aside class=\"callout callout--warning\"><div class=\"para\">careful</div></aside>"
1241
1283
  );
1242
1284
  }
@@ -1294,7 +1336,7 @@ mod tests {
1294
1336
  }
1295
1337
  let txn = doc.transact();
1296
1338
  let map = collect_node_types(&txn, &frag).unwrap();
1297
- let json = yrs_html_core::type_map_json(&map, |ty| {
1339
+ let json = yjs_html_core::type_map_json(&map, |ty| {
1298
1340
  if is_builtin(ty) {
1299
1341
  Some("builtin")
1300
1342
  } else {
data/ext/yrby/src/lib.rs CHANGED
@@ -9,14 +9,14 @@ use yrs::{Doc, GetString, ReadTxn, Transact};
9
9
 
10
10
  mod protocol;
11
11
  mod read;
12
+ use lexical_yjs_html as lexical_html;
13
+ use prosemirror_yjs_html as prosemirror_html;
12
14
  use protocol::{
13
15
  classify_message, has_pending, integrated_update, merged_doc_update, update_advances_doc,
14
16
  update_is_ready,
15
17
  };
16
18
  use render_rules::{Rules, Segment};
17
- pub(crate) use yrs_html_core as render_rules;
18
- use yrs_lexical_html as lexical_html;
19
- use yrs_prosemirror_html as prosemirror_html;
19
+ pub(crate) use yjs_html_core as render_rules;
20
20
 
21
21
  /// Wrapper around yrs Doc.
22
22
  ///
@@ -297,13 +297,13 @@ impl RbDoc {
297
297
  match msg {
298
298
  Message::Sync(sync_msg) => match sync_msg {
299
299
  SyncMessage::SyncStep1(sv) => {
300
- // Respond with SyncStep2 carrying only *integrated*
301
- // state. Never hand a peer un-integrable pending
302
- // structs: the peer would park the same pending
303
- // forever and the state-vector/content mismatch drives
304
- // endless resync traffic. (integrated_update is a no-op
305
- // fast path when nothing is pending.)
306
- let update = integrated_update(doc, &sv)?;
300
+ // Respond with SyncStep2 carrying the doc's full
301
+ // state, pending included, matching Y.js's
302
+ // encodeStateAsUpdate. A peer parks a pending
303
+ // struct exactly as this doc does and heals it
304
+ // when the missing dependency arrives.
305
+ let txn = doc.transact();
306
+ let update = txn.encode_state_as_update_v1(&sv);
307
307
  let response = Message::Sync(SyncMessage::SyncStep2(update));
308
308
  Ok((0, 0, response.encode_v1()))
309
309
  }
@@ -135,6 +135,27 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
135
135
  let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
136
136
  let has_deletes = !update.delete_set().is_empty();
137
137
 
138
+ // A merged update can hide an internal gap behind a Skip block, and
139
+ // blocks past the gap can still integrate (yrs plants a Skip hole in the
140
+ // store) when their origins don't need the missing range. That moves
141
+ // neither the doc's public state vector (yrs caps it at the first hole)
142
+ // nor pending: invisible to every comparison below, so genuinely novel
143
+ // content would report "doesn't advance": applied, acked, but never
144
+ // recorded or broadcast. The update's own state_vector() caps at its
145
+ // first Skip the same way, so any insertion past a non-zero cap reveals
146
+ // such a gap: conservatively report it as advancing (record it; a
147
+ // duplicate in the log is harmless, dropped content is not). Standard
148
+ // Yjs providers never emit these frames (a client's own updates and
149
+ // diffs are gap-free), so this guards crafted or pathologically merged
150
+ // input, not a hot path.
151
+ let capped = update.state_vector();
152
+ for (client, ranges) in update.insertions(true).iter() {
153
+ let cap = capped.get(client);
154
+ if cap > 0 && ranges.iter().any(|range| range.end > cap) {
155
+ return Ok(true);
156
+ }
157
+ }
158
+
138
159
  // Fast path: blocks beyond the doc's state vector are content the doc
139
160
  // lacks — the update advances, no probe needed. The common case (a novel
140
161
  // edit) exits here; only retries and ambiguous diffs pay for the probe.
@@ -663,6 +684,48 @@ mod tests {
663
684
  );
664
685
  }
665
686
 
687
+ #[test]
688
+ fn a_gappy_update_with_novel_post_hole_content_advances() {
689
+ // A merged update hiding an internal gap behind a Skip, whose
690
+ // post-hole blocks integrate anyway (their origins don't need the
691
+ // missing range). Integration plants a Skip hole, so neither the
692
+ // public state vector nor pending moves, before the hidden-gap
693
+ // check, the probe comparison reported genuinely novel content as
694
+ // "doesn't advance": update_ready? accepted it, the doc applied it,
695
+ // but it was never recorded or broadcast. Unreachable through
696
+ // standard Yjs providers (a client's own updates and diffs are
697
+ // gap-free); this is a crafted/hostile frame shape.
698
+ let a = Doc::new();
699
+ let t1 = a.get_or_insert_text("one");
700
+ t1.insert(&mut a.transact_mut(), 0, "first");
701
+ let u1 = a
702
+ .transact()
703
+ .encode_state_as_update_v1(&yrs::StateVector::default());
704
+ t1.insert(&mut a.transact_mut(), 5, " second"); // the future hole
705
+ let sv2 = a.transact().state_vector();
706
+ // Novel content on a DIFFERENT root: no dependency on the hole.
707
+ let t3 = a.get_or_insert_text("two");
708
+ t3.insert(&mut a.transact_mut(), 0, "third");
709
+ let u3 = a.transact().encode_state_as_update_v1(&sv2);
710
+
711
+ let server = Doc::new();
712
+ server
713
+ .transact_mut()
714
+ .apply_update(yrs::Update::decode_v1(&u1).unwrap())
715
+ .unwrap();
716
+ // The crafted frame: u1 + u3 merged, u2's range an internal Skip.
717
+ let gappy = yrs::merge_updates_v1([&u1, &u3]).unwrap();
718
+
719
+ assert!(
720
+ update_is_ready(&server, &gappy).unwrap(),
721
+ "sanity: the sync flow would accept and apply this frame"
722
+ );
723
+ assert!(
724
+ update_advances_doc(&server, &gappy).unwrap(),
725
+ "novel content hiding behind a Skip must be recorded, not dropped"
726
+ );
727
+ }
728
+
666
729
  // Build a causal gap: `first` inserts "a", `dependent` inserts "b" after it,
667
730
  // so `dependent` alone parks as pending on a doc that lacks `first`.
668
731
  fn gap_pair() -> (Vec<u8>, Vec<u8>) {
@@ -29,7 +29,7 @@ class DocumentChannel < ApplicationCable::Channel
29
29
  # Everyone is denied until you fill this in. Wire it to your app's auth:
30
30
  # identify current_user on the cable connection, then check they may read
31
31
  # and write this document. Don't lean on on_change raising for access
32
- # control — that path exists for store failures.
32
+ # control; that path exists for store failures.
33
33
  def authorized?(_document_key)
34
34
  false
35
35
  end
@@ -5,15 +5,15 @@ require "rails/generators/active_record"
5
5
 
6
6
  module Yrby
7
7
  module Generators
8
- # `bin/rails generate yrby:tables` — the migration for the gem-owned
8
+ # `bin/rails generate yrby:tables`: the migration for the gem-owned
9
9
  # document models (Y::Document + Y::DocumentUpdate). Invoked by
10
10
  # yrby:install, and by other gems building on the same storage.
11
11
  #
12
12
  # Template notes (kept here, not in the emitted migration): state is
13
- # 4.gigabytes - 1 (longblob on MySQL — a compacted snapshot is the whole
13
+ # 4.gigabytes - 1 (longblob on MySQL; a compacted snapshot is the whole
14
14
  # document; a 16 MB cap would break compaction) and payload is
15
15
  # 16.megabytes - 1 (one update can carry a big paste or a client's
16
- # accumulated offline edits — the 64 KB default blob is too small).
16
+ # accumulated offline edits; the 64 KB default blob is too small).
17
17
  # The partial unique index's WHERE only keeps
18
18
  # key-only rows out of the index: uniqueness holds without it, since
19
19
  # unique indexes treat NULLs as distinct on every supported database,