yrby 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/Cargo.lock +3 -3
- data/README.md +582 -380
- data/ext/yrby/crates/html-core/Cargo.toml +1 -1
- data/ext/yrby/crates/html-core/src/lib.rs +13 -13
- data/ext/yrby/crates/lexical-html/Cargo.toml +2 -2
- data/ext/yrby/crates/lexical-html/src/lib.rs +52 -39
- data/ext/yrby/crates/prosemirror-html/Cargo.toml +2 -2
- data/ext/yrby/crates/prosemirror-html/src/lib.rs +37 -32
- data/ext/yrby/src/lib.rs +27 -12
- data/ext/yrby/src/protocol.rs +17 -17
- data/ext/yrby/src/read.rs +137 -26
- data/lib/generators/yrby/install/install_generator.rb +24 -13
- data/lib/generators/yrby/install/templates/document_channel.rb +4 -7
- data/lib/generators/yrby/tables/tables_generator.rb +7 -4
- data/lib/generators/yrby/tables/templates/create_y_tables.rb +1 -1
- data/lib/y/collaborative/attribute.rb +53 -0
- data/lib/y/collaborative/helper.rb +48 -0
- data/lib/y/collaborative.rb +98 -0
- data/lib/y/lexxy.rb +2 -2
- data/lib/y/rendering.rb +15 -15
- data/lib/y/tiptap.rb +3 -3
- data/lib/y/version.rb +1 -1
- metadata +4 -9
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +0 -355
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +0 -276
- data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +0 -49
- data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
- data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
- data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +0 -6
- data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +0 -5
- data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +0 -5
|
@@ -3,7 +3,7 @@ name = "yjs-html-core"
|
|
|
3
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.
|
|
6
|
+
version = "0.1.3"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
//!
|
|
2
|
-
//!
|
|
1
|
+
//! The custom render rules and segmented output that both HTML renderers
|
|
2
|
+
//! share.
|
|
3
3
|
//!
|
|
4
|
-
//! Callers register per
|
|
4
|
+
//! Callers register a rule per node. There are two tiers:
|
|
5
5
|
//!
|
|
6
|
-
//! -
|
|
7
|
-
//! [`NodeRule`]
|
|
6
|
+
//! - A declarative rule (tag, attributes, text, content slot) compiles to a
|
|
7
|
+
//! [`NodeRule`] or [`MarkRule`] and renders natively, inside the document
|
|
8
8
|
//! transaction, at full speed. This covers the tiptap-php `renderHTML`
|
|
9
9
|
//! shape: markup as data.
|
|
10
|
-
//! -
|
|
11
|
-
//! while the document is locked
|
|
12
|
-
//! entries
|
|
13
|
-
//! already-rendered children, and the caller fills them in
|
|
14
|
-
//! render returns.
|
|
15
|
-
//! once the transaction has closed and the GVL is held again.
|
|
10
|
+
//! - A callback rule defers to the caller. The renderer never runs
|
|
11
|
+
//! application code while the document is locked. It emits
|
|
12
|
+
//! [`Segment::Deferred`] entries with the node type, the attributes as
|
|
13
|
+
//! JSON, and the already-rendered children, and the caller fills them in
|
|
14
|
+
//! after the render returns. In the Ruby gem that caller is the app's
|
|
15
|
+
//! block, run once the transaction has closed and the GVL is held again.
|
|
16
16
|
//!
|
|
17
|
-
//! Rules arrive as one JSON document (see `parse`), so
|
|
18
|
-
//!
|
|
17
|
+
//! Rules arrive as one JSON document (see `parse`), so one format serves
|
|
18
|
+
//! every binding and caller.
|
|
19
19
|
|
|
20
20
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
21
21
|
use yrs::{Any, Out, ReadTxn, Xml};
|
|
@@ -3,7 +3,7 @@ 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.
|
|
6
|
+
version = "0.1.3"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
@@ -14,4 +14,4 @@ categories = ["text-processing", "web-programming"]
|
|
|
14
14
|
[dependencies]
|
|
15
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
16
16
|
serde_json = "1.0"
|
|
17
|
-
yjs-html-core = { path = "../html-core", version = "0.1.
|
|
17
|
+
yjs-html-core = { path = "../html-core", version = "0.1.3" }
|
|
@@ -1,48 +1,61 @@
|
|
|
1
|
-
//!
|
|
2
|
-
//!
|
|
1
|
+
//! HTML for Lexical documents, rendered from the Yjs structure the editor
|
|
2
|
+
//! syncs. No Node process and no headless editor. A server that holds the
|
|
3
|
+
//! document bytes produces the markup itself.
|
|
3
4
|
//!
|
|
4
|
-
//! This renders
|
|
5
|
-
//! and list items, tables, horizontal rules, links, and the whole
|
|
6
|
-
//! model.
|
|
5
|
+
//! This renders core Lexical: paragraphs, headings, quotes, code blocks,
|
|
6
|
+
//! lists and list items, tables, horizontal rules, links, and the whole
|
|
7
|
+
//! text-format model. Lexxy's additions live in the Ruby layer, as the
|
|
8
|
+
//! `Y::Lexxy` renderer's rule set: its own node types (attachments,
|
|
7
9
|
//! galleries, `early_escape_code`, `horizontal_divider`) and its decorations
|
|
8
|
-
//! of core nodes (the
|
|
9
|
-
//! nested-list-item class)
|
|
10
|
-
//!
|
|
11
|
-
//!
|
|
12
|
-
//!
|
|
13
|
-
//!
|
|
14
|
-
//!
|
|
15
|
-
//!
|
|
10
|
+
//! of core nodes (the figure around a table, header-cell styling, the
|
|
11
|
+
//! nested-list-item class). That rule set uses the same extension API
|
|
12
|
+
//! applications use. `Y::Lexical` is the core base class. The byte-parity
|
|
13
|
+
//! guarantee is held there too: the Ruby fixture tests and the
|
|
14
|
+
//! headless-Chrome end-to-end run pin `Y::Lexxy#to_html` against a real
|
|
15
|
+
//! editor's serialized value. The tests in this crate pin core output as
|
|
16
|
+
//! regression goldens. Stock Lexical has no canonical serializer to capture
|
|
17
|
+
//! from.
|
|
16
18
|
//!
|
|
17
|
-
//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in
|
|
18
|
-
//! PHP
|
|
19
|
-
//!
|
|
19
|
+
//! Prior art: `ueberdosis/tiptap-php` renders ProseMirror JSON to HTML in
|
|
20
|
+
//! plain PHP, outside any JavaScript runtime. This crate does the same job
|
|
21
|
+
//! from the collaborative (Yjs) structure.
|
|
20
22
|
//!
|
|
21
|
-
//!
|
|
22
|
-
//!
|
|
23
|
-
//! (+`__tag`), `quote`, `code` (+`__language`), `list`/`listitem`,
|
|
24
|
-
//! `table`/`tablerow`/`tablecell`, `link`/`autolink` (inline)).
|
|
25
|
-
//! - Text runs are preceded by an embedded `Y.Map` carrying per-run metadata
|
|
26
|
-
//! (`__type: "text" | "code-highlight" | "tab"`, `__format` bitmask).
|
|
27
|
-
//! - `linebreak` is a bare metadata map; `tab` is a map followed by a "\t" run.
|
|
28
|
-
//! - Decorator nodes are `Y.XmlElement`s with their fields as plain
|
|
29
|
-
//! attributes (`horizontalrule` here; app/Lexxy decorators via rules).
|
|
23
|
+
//! How Lexical stores a document, checked against bytes captured from a live
|
|
24
|
+
//! editor:
|
|
30
25
|
//!
|
|
31
|
-
//!
|
|
32
|
-
//!
|
|
33
|
-
//!
|
|
34
|
-
//!
|
|
35
|
-
//!
|
|
36
|
-
//!
|
|
37
|
-
//!
|
|
38
|
-
//!
|
|
39
|
-
//!
|
|
40
|
-
//!
|
|
26
|
+
//! - Blocks are `Y.XmlText` with a `__type` attribute: `paragraph`,
|
|
27
|
+
//! `heading` (plus `__tag`), `quote`, `code` (plus `__language`), `list`
|
|
28
|
+
//! and `listitem`, `table`, `tablerow` and `tablecell`, and the inline
|
|
29
|
+
//! `link` and `autolink`.
|
|
30
|
+
//! - Each text run is preceded by an embedded `Y.Map` with its metadata: a
|
|
31
|
+
//! `__type` of `text`, `code-highlight`, or `tab`, and a `__format`
|
|
32
|
+
//! bitmask.
|
|
33
|
+
//! - A `linebreak` is a bare metadata map. A `tab` is a map followed by a
|
|
34
|
+
//! `"\t"` run.
|
|
35
|
+
//! - Decorator nodes are `Y.XmlElement`s whose fields are plain attributes.
|
|
36
|
+
//! `horizontalrule` is handled here. Application and Lexxy decorators come
|
|
37
|
+
//! in through rules.
|
|
41
38
|
//!
|
|
42
|
-
//!
|
|
43
|
-
//!
|
|
44
|
-
//!
|
|
45
|
-
//! `
|
|
39
|
+
//! Text formatting follows the export pipeline Lexxy runs: Lexical's
|
|
40
|
+
//! `$generateHtmlFromNodes`, then sanitize. That output is the only
|
|
41
|
+
//! formatting truth that can be pinned from outside. The inner tag is
|
|
42
|
+
//! `strong` for bold, or `em` for italic without bold. The outer tag is
|
|
43
|
+
//! `code`, `mark`, `sub`, or `sup`. An `<i>` wraps only when bold and italic
|
|
44
|
+
//! combine, because the `em` slot is taken. `<s>` and `<u>` wrap whenever
|
|
45
|
+
//! present. `<span>`s are unwrapped, so unformatted text is bare. A run's
|
|
46
|
+
//! `__style` (highlight colors) survives on the createDOM tag, filtered to
|
|
47
|
+
//! color and background-color. On a plain run, or one with only strike or
|
|
48
|
+
//! underline, it goes away with the span. The case-transform format bits
|
|
49
|
+
//! never render; their text-transform style is outside the sanitize
|
|
50
|
+
//! whitelist.
|
|
51
|
+
//!
|
|
52
|
+
//! A node type with no rule still renders its text and child blocks, just
|
|
53
|
+
//! unwrapped.
|
|
54
|
+
//!
|
|
55
|
+
//! Custom nodes register rules by `__type` (see `yjs-html-core`). A rule is
|
|
56
|
+
//! consulted before the built-in arms, so it can extend the schema or
|
|
57
|
+
//! replace a built-in. Declarative rules render here. Callback rules emit
|
|
58
|
+
//! `Segment::Deferred` for the caller to fill in after the render returns.
|
|
46
59
|
|
|
47
60
|
// README examples are living code: compile-checked on every cargo test.
|
|
48
61
|
#[cfg(doctest)]
|
|
@@ -3,7 +3,7 @@ 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.
|
|
6
|
+
version = "0.1.4"
|
|
7
7
|
edition = "2024"
|
|
8
8
|
rust-version = "1.85"
|
|
9
9
|
authors = ["JP Camara <johnpcamara@gmail.com>"]
|
|
@@ -14,4 +14,4 @@ categories = ["text-processing", "web-programming"]
|
|
|
14
14
|
[dependencies]
|
|
15
15
|
yrs = { version = "0.27", features = ["sync"] }
|
|
16
16
|
serde_json = "1.0"
|
|
17
|
-
yjs-html-core = { path = "../html-core", version = "0.1.
|
|
17
|
+
yjs-html-core = { path = "../html-core", version = "0.1.3" }
|
|
@@ -1,40 +1,45 @@
|
|
|
1
|
-
//!
|
|
2
|
-
//! structure
|
|
1
|
+
//! HTML for ProseMirror and Tiptap documents, rendered from the Yjs
|
|
2
|
+
//! structure the editor syncs. No Node process and no headless editor.
|
|
3
3
|
//!
|
|
4
|
-
//! The y-prosemirror binding stores a document in a Y.XmlFragment
|
|
5
|
-
//! are Y.XmlElement
|
|
6
|
-
//! attrs
|
|
7
|
-
//! marks. Node and mark names come from the editor's
|
|
8
|
-
//! both spellings
|
|
9
|
-
//! prosemirror-schema-basic snake_case (`bullet_list`,
|
|
4
|
+
//! The y-prosemirror binding stores a document in a `Y.XmlFragment`. Block
|
|
5
|
+
//! nodes are `Y.XmlElement`s: the tag is the node type and the attributes
|
|
6
|
+
//! are the node attrs. Text is `Y.XmlText`, and each run's formatting
|
|
7
|
+
//! attributes are its marks. Node and mark names come from the editor's
|
|
8
|
+
//! schema, so both spellings are accepted: Tiptap's camelCase (`bulletList`,
|
|
9
|
+
//! `bold`) and prosemirror-schema-basic's snake_case (`bullet_list`,
|
|
10
|
+
//! `strong`).
|
|
10
11
|
//!
|
|
11
|
-
//! This renders
|
|
12
|
-
//!
|
|
13
|
-
//! code blocks, bullet
|
|
14
|
-
//! and tables
|
|
15
|
-
//! `min-width` styling editor
|
|
16
|
-
//! lists, mentions, the details family
|
|
17
|
-
//! `Y::Tiptap` renderer's rule set, built on the same
|
|
18
|
-
//!
|
|
19
|
-
//! `ueberdosis/tiptap-php
|
|
20
|
-
//! own `getHTML()` byte for byte on the captured fixtures
|
|
21
|
-
//! is held at the Ruby layer
|
|
22
|
-
//!
|
|
12
|
+
//! This renders core ProseMirror: the prosemirror-schema-basic node set plus
|
|
13
|
+
//! the prosemirror-tables family. That is paragraphs, headings, blockquotes,
|
|
14
|
+
//! code blocks, bullet and ordered lists, images, hard breaks, horizontal
|
|
15
|
+
//! rules, and tables as semantic `<table><tbody>…`, without the
|
|
16
|
+
//! `<colgroup>` and `min-width` styling an editor view injects. Tiptap's
|
|
17
|
+
//! extension nodes (task lists, mentions, the details family) live in the
|
|
18
|
+
//! Ruby layer as the `Y::Tiptap` renderer's rule set, built on the same
|
|
19
|
+
//! extension API applications use. `Y::ProseMirror` is the core base class.
|
|
20
|
+
//! The output follows `ueberdosis/tiptap-php`. With `Y::Tiptap`'s rules it
|
|
21
|
+
//! matches Tiptap's own `getHTML()` byte for byte on the captured fixtures.
|
|
22
|
+
//! That guarantee is held at the Ruby layer. Where a fixture contains
|
|
23
|
+
//! Tiptap-only nodes, the tests here pin core output as goldens.
|
|
23
24
|
//!
|
|
24
|
-
//! Marks stay native on purpose
|
|
25
|
-
//! (nesting order, textStyle's CSS, code's exclusivity)
|
|
26
|
-
//!
|
|
27
|
-
//! Tiptap's
|
|
28
|
-
//! strike, underline, highlight, then subscript
|
|
29
|
-
//! the other formatting marks, so a code run is `<code>` alone
|
|
25
|
+
//! Marks stay native on purpose. Mark serialization is text-run machinery
|
|
26
|
+
//! (nesting order, textStyle's CSS, code's exclusivity) that the rule system
|
|
27
|
+
//! cannot express. The built-in set covers schema-basic's marks and
|
|
28
|
+
//! Tiptap's, nested outermost-first: link, a textStyle span, bold, italic,
|
|
29
|
+
//! strike, underline, highlight, then subscript and superscript. `code`
|
|
30
|
+
//! excludes the other formatting marks, so a code run is `<code>` alone. A
|
|
30
31
|
//! link still wraps it (see `render_run`).
|
|
31
32
|
//!
|
|
32
|
-
//!
|
|
33
|
-
//!
|
|
34
|
-
//!
|
|
35
|
-
//!
|
|
36
|
-
//!
|
|
37
|
-
//!
|
|
33
|
+
//! A node type with no rule still renders its text and child blocks, just
|
|
34
|
+
//! unwrapped.
|
|
35
|
+
//!
|
|
36
|
+
//! Applications register custom nodes and marks as rules keyed by node type
|
|
37
|
+
//! and mark name (see `render_rules`). A node rule is consulted before the
|
|
38
|
+
//! built-in arms, so it can extend the schema or replace a built-in. A mark
|
|
39
|
+
//! rule claims its mark from the built-in wraps and wraps outside
|
|
40
|
+
//! everything, link included. Declarative rules render here. Callback rules
|
|
41
|
+
//! emit `Segment::Deferred` for the caller to fill in after the render
|
|
42
|
+
//! returns.
|
|
38
43
|
|
|
39
44
|
// README examples are living code: compile-checked on every cargo test.
|
|
40
45
|
#[cfg(doctest)]
|
data/ext/yrby/src/lib.rs
CHANGED
|
@@ -168,7 +168,7 @@ impl RbDoc {
|
|
|
168
168
|
let doc = &self.0;
|
|
169
169
|
nogvl(move || {
|
|
170
170
|
// Exactly ONE transaction per call. Opening a second while the
|
|
171
|
-
// first is still held deadlocks against a waiting writer
|
|
171
|
+
// first is still held deadlocks against a waiting writer, and
|
|
172
172
|
// inside nogvl that hang can't be interrupted.
|
|
173
173
|
let txn = doc.transact();
|
|
174
174
|
txn.get_text(name.as_str()).map(|t| t.get_string(&txn))
|
|
@@ -200,8 +200,22 @@ impl RbDoc {
|
|
|
200
200
|
})
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/// A `Y.Array` root serialized to a JSON array string (values recursive, in
|
|
204
|
+
/// array order). The counterpart to read_map for documents whose root is an
|
|
205
|
+
/// array: a board's cards, a sheet's rows. Callers parse the JSON (e.g.
|
|
206
|
+
/// `JSON.parse(doc.read_array("cards"))`). The serialization lives in
|
|
207
|
+
/// `read::array_json` (pure, Rust-tested).
|
|
208
|
+
fn read_array(&self, name: String) -> Option<String> {
|
|
209
|
+
let doc = &self.0;
|
|
210
|
+
nogvl(move || {
|
|
211
|
+
let txn = doc.transact();
|
|
212
|
+
let array = txn.get_array(name.as_str())?;
|
|
213
|
+
Some(read::array_json(&txn, &array))
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
/// True if the doc holds un-integrable pending structs or a pending delete
|
|
204
|
-
/// set
|
|
218
|
+
/// set: content that couldn't integrate because a causally-prior update is
|
|
205
219
|
/// missing. Such content is a recovery buffer, not document state; it heals if
|
|
206
220
|
/// the missing dependency later arrives. A pure read.
|
|
207
221
|
fn pending(&self) -> bool {
|
|
@@ -211,7 +225,7 @@ impl RbDoc {
|
|
|
211
225
|
|
|
212
226
|
/// Like `encode_state_as_update` (full state), but **gap-free**: it excludes
|
|
213
227
|
/// any pending (un-integrable) structs and pending delete set. Use this when
|
|
214
|
-
/// persisting or serving state that other peers will apply
|
|
228
|
+
/// persisting or serving state that other peers will apply. Serving pending
|
|
215
229
|
/// content poisons their sync. Non-destructive: this doc keeps its pending, so
|
|
216
230
|
/// a genuine gap still heals if its dependency arrives. (`encode_state_as_update`
|
|
217
231
|
/// stays lossless for raw-update recovery.)
|
|
@@ -337,12 +351,12 @@ impl RbDoc {
|
|
|
337
351
|
}
|
|
338
352
|
|
|
339
353
|
// ============================================================================
|
|
340
|
-
// Y::Lexical
|
|
354
|
+
// Y::Lexical: schema-pinned rendering of Lexical/Lexxy documents
|
|
341
355
|
// ============================================================================
|
|
342
356
|
|
|
343
357
|
/// A Lexical view over a `Y::Doc`. The schema knowledge lives here rather
|
|
344
358
|
/// than on the schema-agnostic `Doc`: core Lexical natively, everything else
|
|
345
|
-
/// through the render rules compiled at construction (see `render_rules
|
|
359
|
+
/// through the render rules compiled at construction (see `render_rules`;
|
|
346
360
|
/// the `Y::Lexxy` facade's rule set arrives that way). Holds a cheap clone of
|
|
347
361
|
/// the doc (yrs `Doc` is an Arc handle), so it reads live state.
|
|
348
362
|
///
|
|
@@ -357,7 +371,7 @@ struct RbLexical {
|
|
|
357
371
|
}
|
|
358
372
|
|
|
359
373
|
impl RbLexical {
|
|
360
|
-
/// `Y::NativeLexical.new(doc, rules_json)
|
|
374
|
+
/// `Y::NativeLexical.new(doc, rules_json)`; the Y::Lexical facade
|
|
361
375
|
/// compiles its `nodes:` config to the rules JSON.
|
|
362
376
|
fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
|
|
363
377
|
Ok(RbLexical {
|
|
@@ -367,7 +381,7 @@ impl RbLexical {
|
|
|
367
381
|
}
|
|
368
382
|
|
|
369
383
|
/// Render the document's XML root (default `"root"`, Lexical's standard
|
|
370
|
-
/// collab root name) natively
|
|
384
|
+
/// collab root name) natively, with no Node process or headless editor. The
|
|
371
385
|
/// native side renders core Lexical plus whatever the rules cover; with
|
|
372
386
|
/// the rule set `Y::Lexxy` passes, output matches Lexxy's own serializer
|
|
373
387
|
/// byte-for-byte on the reference fixtures (see `lexical_html.rs`). Returns nil when the root is missing or not
|
|
@@ -386,7 +400,7 @@ impl RbLexical {
|
|
|
386
400
|
segments_result(segments)
|
|
387
401
|
}
|
|
388
402
|
|
|
389
|
-
/// The document's node types as observed facts, JSON-encoded
|
|
403
|
+
/// The document's node types as observed facts, JSON-encoded, the
|
|
390
404
|
/// native half of the facade's `node_types` discovery aid. Nil when the
|
|
391
405
|
/// root is missing or not Lexical-shaped.
|
|
392
406
|
fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
|
|
@@ -415,13 +429,13 @@ impl RbLexical {
|
|
|
415
429
|
}
|
|
416
430
|
|
|
417
431
|
// ============================================================================
|
|
418
|
-
// Y::ProseMirror
|
|
432
|
+
// Y::ProseMirror: schema-pinned rendering of ProseMirror/Tiptap documents
|
|
419
433
|
// ============================================================================
|
|
420
434
|
|
|
421
435
|
/// A ProseMirror view over a `Y::Doc`. The schema knowledge lives here rather
|
|
422
436
|
/// than on the schema-agnostic `Doc`: core ProseMirror natively, everything
|
|
423
437
|
/// else through the render rules compiled at construction (see
|
|
424
|
-
/// `render_rules
|
|
438
|
+
/// `render_rules`; the `Y::Tiptap` facade's rule set arrives that way).
|
|
425
439
|
/// Holds a cheap clone of the doc (yrs `Doc` is an Arc handle), so it reads
|
|
426
440
|
/// live state.
|
|
427
441
|
///
|
|
@@ -436,7 +450,7 @@ struct RbProseMirror {
|
|
|
436
450
|
}
|
|
437
451
|
|
|
438
452
|
impl RbProseMirror {
|
|
439
|
-
/// `Y::NativeProseMirror.new(doc, rules_json)
|
|
453
|
+
/// `Y::NativeProseMirror.new(doc, rules_json)`; the Y::ProseMirror facade
|
|
440
454
|
/// compiles its `nodes:`/`marks:` config to the rules JSON.
|
|
441
455
|
fn native_new(doc: &RbDoc, rules_json: String) -> Result<Self, Error> {
|
|
442
456
|
Ok(RbProseMirror {
|
|
@@ -466,7 +480,7 @@ impl RbProseMirror {
|
|
|
466
480
|
segments_result(segments)
|
|
467
481
|
}
|
|
468
482
|
|
|
469
|
-
/// The document's node types as observed facts, JSON-encoded
|
|
483
|
+
/// The document's node types as observed facts, JSON-encoded, the
|
|
470
484
|
/// native half of the facade's `node_types` discovery aid. Nil when the
|
|
471
485
|
/// root is missing or not ProseMirror-shaped.
|
|
472
486
|
fn node_types(&self, args: &[Value]) -> Result<Value, Error> {
|
|
@@ -627,6 +641,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
627
641
|
doc_class.define_method("read_text", method!(RbDoc::read_text, 1))?;
|
|
628
642
|
doc_class.define_method("read_xml", method!(RbDoc::read_xml, 1))?;
|
|
629
643
|
doc_class.define_method("read_map", method!(RbDoc::read_map, 1))?;
|
|
644
|
+
doc_class.define_method("read_array", method!(RbDoc::read_array, 1))?;
|
|
630
645
|
doc_class.define_method("pending?", method!(RbDoc::pending, 0))?;
|
|
631
646
|
doc_class.define_method(
|
|
632
647
|
"compacted_state_update",
|
data/ext/yrby/src/protocol.rs
CHANGED
|
@@ -71,24 +71,24 @@ pub(crate) fn merged_doc_update(bytes: &[u8]) -> Result<Option<Vec<u8>>, String>
|
|
|
71
71
|
///
|
|
72
72
|
/// This must be EXACT: the sync layer records on "ready" and resyncs on "not
|
|
73
73
|
/// ready", and a parked update that slipped through would look like an
|
|
74
|
-
/// already-applied retry downstream
|
|
74
|
+
/// already-applied retry downstream: acked and dropped, losing real content.
|
|
75
75
|
///
|
|
76
76
|
/// Clocks alone can't decide it. An update can satisfy every per-client clock
|
|
77
77
|
/// and still fail to integrate: its items may reference other clients' blocks
|
|
78
78
|
/// (origins/parents), and merged updates hide internal gaps behind Skip blocks.
|
|
79
79
|
/// So the clock lower bound serves only as a cheap definitive REJECT; "ready"
|
|
80
80
|
/// is decided by trial-integrating on a throwaway probe seeded with the doc's
|
|
81
|
-
/// integrated state
|
|
81
|
+
/// integrated state: ready iff nothing parks.
|
|
82
82
|
pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, String> {
|
|
83
83
|
let update = yrs::Update::decode_v1(update_bytes).map_err(|e| e.to_string())?;
|
|
84
|
-
// Partial order: "not covered" includes incomparable
|
|
84
|
+
// Partial order: "not covered" includes incomparable: not ready either way.
|
|
85
85
|
let lower_covered = doc.transact().state_vector() >= update.state_vector_lower();
|
|
86
86
|
if !lower_covered {
|
|
87
87
|
return Ok(false);
|
|
88
88
|
}
|
|
89
89
|
// Seed the probe with the doc's INTEGRATED state (gap-free), for two
|
|
90
90
|
// reasons. A lossless seed would replant the doc's own pre-existing
|
|
91
|
-
// pending in the probe, making has_pending true for EVERY update
|
|
91
|
+
// pending in the probe, making has_pending true for EVERY update: the
|
|
92
92
|
// verdict must be about this update, not the doc's baggage. And an update
|
|
93
93
|
// whose dependency exists only in that pending buffer is genuinely not
|
|
94
94
|
// ready: recording it would put a gap in the durable log; a resync heals
|
|
@@ -118,13 +118,13 @@ pub(crate) fn update_is_ready(doc: &Doc, update_bytes: &[u8]) -> Result<bool, St
|
|
|
118
118
|
/// doc), then compare the probe before and after applying the update:
|
|
119
119
|
///
|
|
120
120
|
/// - **Insert/format-only updates** grow the probe's state vector, so comparing
|
|
121
|
-
/// the state vector is enough
|
|
121
|
+
/// the state vector is enough, and cheaper than a full re-encode.
|
|
122
122
|
/// - **Delete-bearing updates** don't move the state vector (a deletion tombstones
|
|
123
123
|
/// an existing struct rather than adding one), so we compare the full encoded
|
|
124
124
|
/// state, which carries the delete set. An already-applied pure-delete retry
|
|
125
125
|
/// re-encodes byte-identically → false; a genuinely new deletion changes the
|
|
126
126
|
/// delete set → true. This is exact but pays for two full encodes, so only
|
|
127
|
-
/// delete-bearing frames
|
|
127
|
+
/// delete-bearing frames, a minority, take that path.
|
|
128
128
|
///
|
|
129
129
|
/// Earlier this branch was conservative: any delete-bearing update returned true
|
|
130
130
|
/// (record it), which double-recorded and re-broadcast pure-delete retries the
|
|
@@ -157,7 +157,7 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// Fast path: blocks beyond the doc's state vector are content the doc
|
|
160
|
-
// lacks
|
|
160
|
+
// lacks: the update advances, no probe needed. The common case (a novel
|
|
161
161
|
// edit) exits here; only retries and ambiguous diffs pay for the probe.
|
|
162
162
|
if !has_deletes {
|
|
163
163
|
let covered = doc.transact().state_vector() >= update.state_vector();
|
|
@@ -206,10 +206,10 @@ pub(crate) fn update_advances_doc(doc: &Doc, update_bytes: &[u8]) -> Result<bool
|
|
|
206
206
|
return Ok(true);
|
|
207
207
|
}
|
|
208
208
|
// An unchanged state vector is ambiguous. Usually it means the doc
|
|
209
|
-
// already had everything in this update (a retry
|
|
209
|
+
// already had everything in this update (a retry: return false, don't
|
|
210
210
|
// re-record). But it can ALSO mean the update failed to integrate and
|
|
211
211
|
// was stashed as pending, which doesn't move the state vector either.
|
|
212
|
-
// That case is missing content, not a duplicate
|
|
212
|
+
// That case is missing content, not a duplicate: returning false would
|
|
213
213
|
// let a caller ack it and drop it. Distinguish the two by whether the
|
|
214
214
|
// probe gained pending. (The sync flow screens gaps out with
|
|
215
215
|
// update_is_ready before calling this; the check guards direct callers.)
|
|
@@ -240,7 +240,7 @@ pub(crate) fn has_pending(doc: &Doc) -> bool {
|
|
|
240
240
|
/// Non-destructive: the prune happens only on the throwaway copy; `doc` keeps its
|
|
241
241
|
/// pending, so a genuine gap still heals if its missing dependency later arrives.
|
|
242
242
|
pub(crate) fn integrated_update(doc: &Doc, sv: &StateVector) -> Result<Vec<u8>, String> {
|
|
243
|
-
// Pending check and encode share ONE transaction
|
|
243
|
+
// Pending check and encode share ONE transaction. With two, a concurrent
|
|
244
244
|
// gappy apply_update could slip between them and the encode would serve
|
|
245
245
|
// the very pending this function exists to exclude.
|
|
246
246
|
let full = {
|
|
@@ -548,7 +548,7 @@ mod tests {
|
|
|
548
548
|
// it and types between C's characters, so A's delta references C's blocks as
|
|
549
549
|
// origins. Returns (c_update, a_delta). On a doc missing `c_update`, the
|
|
550
550
|
// per-client clock lower bound of `a_delta` is satisfied (A starts at clock
|
|
551
|
-
// 0) but integration parks
|
|
551
|
+
// 0) but integration parks: the case a clock-only readiness check misses.
|
|
552
552
|
fn cross_client_origin_gap() -> (Vec<u8>, Vec<u8>) {
|
|
553
553
|
let c = Doc::new();
|
|
554
554
|
let ct = c.get_or_insert_text("t");
|
|
@@ -573,7 +573,7 @@ mod tests {
|
|
|
573
573
|
let (c_update, a_delta) = cross_client_origin_gap();
|
|
574
574
|
|
|
575
575
|
// A server that never saw C's content: the clock lower bound passes, but
|
|
576
|
-
// the update can't integrate
|
|
576
|
+
// the update can't integrate: it must NOT be ready (previously it was,
|
|
577
577
|
// and the downstream advances? probe then acked-and-dropped it).
|
|
578
578
|
let server = Doc::new();
|
|
579
579
|
assert!(
|
|
@@ -617,7 +617,7 @@ mod tests {
|
|
|
617
617
|
fn a_doc_with_legacy_pending_still_accepts_healthy_updates() {
|
|
618
618
|
// Why update_is_ready seeds its probe with the INTEGRATED state: with a
|
|
619
619
|
// lossless seed, the doc's own pre-existing pending would park in the
|
|
620
|
-
// probe and every verdict would come back "not ready"
|
|
620
|
+
// probe and every verdict would come back "not ready": a server with
|
|
621
621
|
// one legacy gap would reject every healthy keystroke forever.
|
|
622
622
|
let (_first, dependent) = gap_pair();
|
|
623
623
|
let doc = Doc::new();
|
|
@@ -645,7 +645,7 @@ mod tests {
|
|
|
645
645
|
#[test]
|
|
646
646
|
fn an_update_depending_only_on_pending_content_is_not_ready() {
|
|
647
647
|
// The other half of the integrated-only seed: a dependency that exists
|
|
648
|
-
// solely in the doc's pending buffer doesn't count
|
|
648
|
+
// solely in the doc's pending buffer doesn't count: recording such an
|
|
649
649
|
// update would put a gap in the durable log. Not ready; resync heals
|
|
650
650
|
// both as one complete delta.
|
|
651
651
|
let src = Doc::new();
|
|
@@ -674,7 +674,7 @@ mod tests {
|
|
|
674
674
|
#[test]
|
|
675
675
|
fn update_advances_reports_true_when_the_update_would_park() {
|
|
676
676
|
// Defense in depth for callers using advances? without the ready gate: a
|
|
677
|
-
// gappy update parks pending
|
|
677
|
+
// gappy update parks pending: that changes the doc, so it advances (it
|
|
678
678
|
// must never be misread as an already-applied retry and dropped).
|
|
679
679
|
let (_c_update, a_delta) = cross_client_origin_gap();
|
|
680
680
|
let server = Doc::new();
|
|
@@ -896,7 +896,7 @@ mod tests {
|
|
|
896
896
|
// for a fresh peer.
|
|
897
897
|
//
|
|
898
898
|
// Scope: this can't hit the original check-vs-encode race (its window
|
|
899
|
-
// is nanoseconds; never reproduced even at 20k iterations)
|
|
899
|
+
// is nanoseconds; never reproduced even at 20k iterations). That fix
|
|
900
900
|
// is guaranteed by using a single transaction. What this catches is
|
|
901
901
|
// coarser: encoding outside the lock, or a fast path skipping the
|
|
902
902
|
// pending check.
|
|
@@ -914,7 +914,7 @@ mod tests {
|
|
|
914
914
|
let first = first.clone();
|
|
915
915
|
std::thread::spawn(move || {
|
|
916
916
|
while !stop.load(Ordering::Relaxed) {
|
|
917
|
-
// Park a pending struct, then heal it, over and over
|
|
917
|
+
// Park a pending struct, then heal it, over and over: the
|
|
918
918
|
// encode below keeps racing both transitions.
|
|
919
919
|
doc.transact_mut()
|
|
920
920
|
.apply_update(yrs::Update::decode_v1(&dependent).unwrap())
|