yrby 0.7.0 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/Cargo.lock +29 -29
  4. data/README.md +582 -380
  5. data/ext/yrby/Cargo.toml +3 -3
  6. data/ext/yrby/crates/html-core/Cargo.toml +5 -4
  7. data/ext/yrby/crates/html-core/src/lib.rs +16 -20
  8. data/ext/yrby/crates/lexical-html/Cargo.toml +5 -4
  9. data/ext/yrby/crates/lexical-html/src/lib.rs +57 -48
  10. data/ext/yrby/crates/prosemirror-html/Cargo.toml +5 -4
  11. data/ext/yrby/crates/prosemirror-html/src/lib.rs +151 -104
  12. data/ext/yrby/src/lib.rs +30 -15
  13. data/ext/yrby/src/protocol.rs +17 -17
  14. data/ext/yrby/src/read.rs +137 -26
  15. data/lib/generators/yrby/install/install_generator.rb +24 -13
  16. data/lib/generators/yrby/install/templates/document_channel.rb +4 -7
  17. data/lib/generators/yrby/tables/tables_generator.rb +7 -4
  18. data/lib/generators/yrby/tables/templates/create_y_tables.rb +1 -1
  19. data/lib/y/collaborative/attribute.rb +53 -0
  20. data/lib/y/collaborative/helper.rb +48 -0
  21. data/lib/y/collaborative.rb +98 -0
  22. data/lib/y/lexxy.rb +2 -2
  23. data/lib/y/rendering.rb +15 -15
  24. data/lib/y/tiptap.rb +3 -3
  25. data/lib/y/version.rb +1 -1
  26. data/lib/y.rb +1 -1
  27. metadata +4 -9
  28. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/common.rs +0 -355
  29. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/dynamic.rs +0 -276
  30. data/ext/yrby/target/debug/build/clang-sys-e3ae45bd384f74c3/out/macros.rs +0 -49
  31. data/ext/yrby/target/debug/build/rb-sys-4407948463231c4f/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
  32. data/ext/yrby/target/debug/build/rb-sys-dbeea42737529c2d/out/bindings-0.9.128-mri-arm64-darwin23-3.4.7.rs +0 -8934
  33. data/ext/yrby/target/debug/build/serde-58ea0ee887cc2602/out/private.rs +0 -6
  34. data/ext/yrby/target/debug/build/serde_core-41f407c21c1f205e/out/private.rs +0 -5
  35. data/ext/yrby/target/debug/build/thiserror-0f1416a82ff26f22/out/private.rs +0 -5
@@ -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 — acked and dropped, losing real content.
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 — ready iff nothing parks.
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 — not ready either way.
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 — the
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 — and cheaper than a full re-encode.
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 — a minority — take that path.
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 — the update advances, no probe needed. The common case (a novel
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 — return false, don't
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 — returning false would
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 — with two, a concurrent
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 — the case a clock-only readiness check misses.
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 — it must NOT be ready (previously it was,
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" — a server with
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 — recording such an
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 — that changes the doc, so it advances (it
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) — that fix
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 — the
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())
data/ext/yrby/src/read.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! Pure content-reading helpers over yrs shared types — no magnus/Ruby, so they
1
+ //! Pure content-reading helpers over yrs shared types, with no magnus/Ruby, so they
2
2
  //! can be unit-tested directly in Rust (like `protocol.rs`). The binding layer in
3
3
  //! `lib.rs` is a thin wrapper that opens a transaction and calls these.
4
4
 
@@ -6,8 +6,8 @@ use std::collections::HashMap;
6
6
  use std::sync::Arc;
7
7
  use yrs::types::text::YChange;
8
8
  use yrs::{
9
- Any, Array, GetString, Map, MapRef, Out, ReadTxn, Text, Xml, XmlFragment, XmlFragmentRef,
10
- XmlOut, XmlTextRef,
9
+ Any, Array, ArrayRef, GetString, Map, MapRef, Out, ReadTxn, Text, Xml, XmlFragment,
10
+ XmlFragmentRef, XmlOut, XmlTextRef,
11
11
  };
12
12
 
13
13
  /// Read an XML-shaped root as text, one top-level block per line.
@@ -18,7 +18,7 @@ use yrs::{
18
18
  /// (`<paragraph>…`). `get_string` already recurses these (tags included; the
19
19
  /// caller strips them), so we keep that path.
20
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 —
21
+ /// blocks (list items, table cells, nested lists) as *embedded* `Y.XmlText`s,
22
22
  /// which `get_string` silently omits, dropping all that content. So for a
23
23
  /// Lexical block we walk its content (`Text::diff`) instead: text runs build a
24
24
  /// line, inline children (links) join it, and nested block children flush the
@@ -34,7 +34,7 @@ pub fn xml_blocks_text<T: ReadTxn>(txn: &T, fragment: &XmlFragmentRef) -> String
34
34
  // them (tags kept, caller strips). A Lexical decorator is an
35
35
  // XmlElement *with* a `__type`: attachments carry readable text
36
36
  // (a mention's plain text, an upload's caption); the rest
37
- // (horizontal rule) have none — skip rather than emit their
37
+ // (horizontal rule) have none: skip rather than emit their
38
38
  // `<UNDEFINED …>` serialization.
39
39
  if e.get_attribute(txn, "__type").is_none() {
40
40
  out.push(e.get_string(txn));
@@ -158,35 +158,70 @@ fn walk_lexical_block<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut Vec<String>
158
158
  }
159
159
  }
160
160
 
161
- /// Read a `Y.Map` root as a JSON object string (keys sorted for stable output).
161
+ /// Read a `Y.Map` root as a JSON object string (object keys sorted at every
162
+ /// depth for stable, diffable output).
162
163
  ///
163
- /// The complement to `read_text`/`read_xml` for structured state — e.g. a shared
164
+ /// The complement to `read_text`/`read_xml` for structured state, e.g. a shared
164
165
  /// "view state" map. Values are converted recursively: primitives pass through;
165
166
  /// nested `Y.Map`/`Y.Array` recurse; `Y.Text`/XML values stringify. The caller
166
167
  /// parses the JSON (yrs's own `Out::to_json` is crate-private, so we walk the
167
168
  /// `Out` variants ourselves here).
168
169
  pub fn map_json<T: ReadTxn>(txn: &T, map: &MapRef) -> String {
169
- let mut pairs: Vec<(String, Any)> = map
170
- .iter(txn)
171
- .map(|(k, v)| (k.to_string(), out_to_any(txn, &v)))
172
- .collect();
173
- pairs.sort_by(|a, b| a.0.cmp(&b.0)); // deterministic key order
174
- let mut out = String::from("{");
175
- for (i, (k, v)) in pairs.iter().enumerate() {
176
- if i > 0 {
177
- out.push(',');
178
- }
179
- // Any::to_json serializes from the start of the buffer (it doesn't
180
- // append), so each piece goes into its own String, then concatenated.
181
- out.push_str(&any_to_json(&Any::String(Arc::from(k.as_str())))); // JSON-escaped key
182
- out.push(':');
183
- out.push_str(&any_to_json(v));
170
+ let mut hm: HashMap<String, Any> = HashMap::new();
171
+ for (k, v) in map.iter(txn) {
172
+ hm.insert(k.to_string(), out_to_any(txn, &v));
184
173
  }
185
- out.push('}');
186
- out
174
+ any_to_json(&Any::Map(Arc::new(hm)))
175
+ }
176
+
177
+ /// A Y.Array's elements as a JSON array string, recursing through nested
178
+ /// shared types exactly as `map_json` does (a Y.Map root and a Y.Array root are
179
+ /// the two document shapes yrs can read from the top). Element order is the
180
+ /// array's own order; nested object keys are sorted like map_json.
181
+ pub fn array_json<T: ReadTxn>(txn: &T, array: &ArrayRef) -> String {
182
+ let items: Vec<Any> = array.iter(txn).map(|o| out_to_any(txn, &o)).collect();
183
+ any_to_json(&Any::Array(items.into()))
187
184
  }
188
185
 
186
+ /// JSON-serialize an `Any` with object keys sorted at every depth. yrs's own
187
+ /// `Any::to_json` iterates `Any::Map` (a `HashMap`) in arbitrary order, so a
188
+ /// nested map would serialize unpredictably and differ run to run; sorting here
189
+ /// makes the whole tree deterministic. Scalars defer to `Any::to_json`, which
190
+ /// writes from the start of a buffer (it does not append), hence each scalar
191
+ /// gets its own fresh `String`.
189
192
  fn any_to_json(a: &Any) -> String {
193
+ match a {
194
+ Any::Array(items) => {
195
+ let mut out = String::from("[");
196
+ for (i, item) in items.iter().enumerate() {
197
+ if i > 0 {
198
+ out.push(',');
199
+ }
200
+ out.push_str(&any_to_json(item));
201
+ }
202
+ out.push(']');
203
+ out
204
+ }
205
+ Any::Map(entries) => {
206
+ let mut pairs: Vec<(&String, &Any)> = entries.iter().collect();
207
+ pairs.sort_by(|a, b| a.0.cmp(b.0)); // deterministic key order
208
+ let mut out = String::from("{");
209
+ for (i, (key, value)) in pairs.into_iter().enumerate() {
210
+ if i > 0 {
211
+ out.push(',');
212
+ }
213
+ out.push_str(&scalar_json(&Any::String(Arc::from(key.as_str())))); // JSON-escaped key
214
+ out.push(':');
215
+ out.push_str(&any_to_json(value));
216
+ }
217
+ out.push('}');
218
+ out
219
+ }
220
+ scalar => scalar_json(scalar),
221
+ }
222
+ }
223
+
224
+ fn scalar_json(a: &Any) -> String {
190
225
  let mut s = String::new();
191
226
  a.to_json(&mut s);
192
227
  s
@@ -307,6 +342,27 @@ mod tests {
307
342
  assert_eq!(map_json(&txn, &map), r#"{"user":{"name":"Ada"}}"#);
308
343
  }
309
344
 
345
+ #[test]
346
+ fn map_json_sorts_nested_object_keys() {
347
+ // Nested maps sort their keys too, not just the root: yrs stores map
348
+ // entries in a HashMap, so without this the inner object serializes in
349
+ // arbitrary, run-varying order.
350
+ let doc = Doc::new();
351
+ let map = doc.get_or_insert_map("state");
352
+ {
353
+ let mut txn = doc.transact_mut();
354
+ let inner = map.insert(&mut txn, "user", MapPrelim::default());
355
+ inner.insert(&mut txn, "name", "Ada");
356
+ inner.insert(&mut txn, "active", true);
357
+ inner.insert(&mut txn, "id", 7_i64);
358
+ }
359
+ let txn = doc.transact();
360
+ assert_eq!(
361
+ map_json(&txn, &map),
362
+ r#"{"user":{"active":true,"id":7,"name":"Ada"}}"#
363
+ );
364
+ }
365
+
310
366
  #[test]
311
367
  fn map_json_empty_is_object() {
312
368
  let doc = Doc::new();
@@ -315,6 +371,61 @@ mod tests {
315
371
  assert_eq!(map_json(&txn, &map), "{}");
316
372
  }
317
373
 
374
+ #[test]
375
+ fn array_json_serializes_primitives_in_order() {
376
+ let doc = Doc::new();
377
+ let array = doc.get_or_insert_array("rows");
378
+ {
379
+ let mut txn = doc.transact_mut();
380
+ array.push_back(&mut txn, "a");
381
+ array.push_back(&mut txn, 2_i64);
382
+ array.push_back(&mut txn, true);
383
+ }
384
+ let txn = doc.transact();
385
+ assert_eq!(array_json(&txn, &array), r#"["a",2,true]"#);
386
+ }
387
+
388
+ #[test]
389
+ fn array_json_recurses_into_nested_maps() {
390
+ // The kanban/spreadsheet shape: an array of Y.Maps. Each element must
391
+ // come through as its own object, keys sorted like map_json.
392
+ let doc = Doc::new();
393
+ let array = doc.get_or_insert_array("cards");
394
+ {
395
+ let mut txn = doc.transact_mut();
396
+ let card = array.push_back(&mut txn, MapPrelim::default());
397
+ card.insert(&mut txn, "text", "Ship it");
398
+ card.insert(&mut txn, "column", "done");
399
+ }
400
+ let txn = doc.transact();
401
+ assert_eq!(
402
+ array_json(&txn, &array),
403
+ r#"[{"column":"done","text":"Ship it"}]"#
404
+ );
405
+ }
406
+
407
+ #[test]
408
+ fn array_json_recurses_into_nested_arrays() {
409
+ let doc = Doc::new();
410
+ let array = doc.get_or_insert_array("grid");
411
+ {
412
+ let mut txn = doc.transact_mut();
413
+ let row = array.push_back(&mut txn, yrs::ArrayPrelim::default());
414
+ row.push_back(&mut txn, 1_i64);
415
+ row.push_back(&mut txn, 2_i64);
416
+ }
417
+ let txn = doc.transact();
418
+ assert_eq!(array_json(&txn, &array), r#"[[1,2]]"#);
419
+ }
420
+
421
+ #[test]
422
+ fn array_json_empty_is_array() {
423
+ let doc = Doc::new();
424
+ let array = doc.get_or_insert_array("rows");
425
+ let txn = doc.transact();
426
+ assert_eq!(array_json(&txn, &array), "[]");
427
+ }
428
+
318
429
  #[test]
319
430
  fn lexical_soft_line_break_and_tab_emit_their_characters() {
320
431
  // A paragraph "foo⏎bar" (shift-enter): Lexical stores the LineBreakNode
@@ -365,8 +476,8 @@ mod tests {
365
476
  #[test]
366
477
  fn lexxy_full_schema_doc_extracts_attachment_text_too() {
367
478
  // The full-schema capture (see lexical_html.rs): attachments must now
368
- // contribute readable text — a mention's plain text inline, an
369
- // upload's caption as its own line — while the divider stays silent.
479
+ // contribute readable text (a mention's plain text inline, an
480
+ // upload's caption as its own line) while the divider stays silent.
370
481
  use yrs::updates::decoder::Decode;
371
482
  use yrs::{Transact, Update};
372
483
  let bytes = include_bytes!("../crates/lexical-html/src/fixtures/lexxy_full.bin");
@@ -5,15 +5,16 @@ require "generators/yrby/tables/tables_generator"
5
5
 
6
6
  module Yrby
7
7
  module Generators
8
- # `bin/rails generate yrby:install` — a DocumentChannel speaking the
9
- # y-websocket protocol over the gem's document storage, plus the storage
10
- # migration (via yrby:tables). The models ship in the gem; only the
11
- # migration lands in the app.
8
+ # `bin/rails generate yrby:install` runs yrby:tables to create the storage
9
+ # migration. The models and Y::DocumentChannel ship in the gem, so that is
10
+ # the only file it generates by default. Pass --channel to also generate
11
+ # an application channel for custom authorization or room-keyed documents.
12
12
  class InstallGenerator < ::Rails::Generators::Base
13
13
  source_root File.expand_path("templates", __dir__)
14
+ class_option :channel, type: :boolean, default: false, desc: "Generate a custom DocumentChannel"
14
15
 
15
16
  def create_channel
16
- template "document_channel.rb", "app/channels/document_channel.rb"
17
+ template "document_channel.rb", "app/channels/document_channel.rb" if options[:channel]
17
18
  end
18
19
 
19
20
  def create_tables
@@ -25,15 +26,25 @@ module Yrby
25
26
 
26
27
  Next steps:
27
28
 
28
- 1. Authorize document access: implement `authorized?` in
29
- app/channels/document_channel.rb (it denies everyone until you do).
30
- 2. bin/rails db:migrate
31
- 3. Install the yrby-client npm package and connect an editor:
29
+ 1. bin/rails db:migrate
30
+ 2. Render a collaborative document on a page where the user may
31
+ edit the record:
32
32
 
33
- import { ActionCableProvider } from "yrby-client"
34
- const provider = new ActionCableProvider(doc, consumer,
35
- "DocumentChannel", { id: documentId })
36
- provider.connect()
33
+ <%= collaborative_document_tag @post, :body %>
34
+
35
+ 3. Install the yrby-client npm package. The tag renders an
36
+ element that connects automatically. Once it syncs, your code
37
+ gets the document and can bind it to any Yjs editor:
38
+
39
+ import "yrby-client/element"
40
+
41
+ document.addEventListener("yrby:synced", ({ target, detail }) => {
42
+ const editor = bindYourEditor(target, detail.doc, detail.provider)
43
+ detail.signal.addEventListener("abort", () => editor.destroy(), { once: true })
44
+ })
45
+
46
+ Run with --channel to also generate app/channels/document_channel.rb,
47
+ and implement its authorized? method before you use it.
37
48
 
38
49
  The README's Editors section links working integrations for
39
50
  Tiptap, Lexxy, Rhino Editor, and CodeMirror.
@@ -16,11 +16,7 @@ class DocumentChannel < ApplicationCable::Channel
16
16
  # and yrby-client retries it.
17
17
  on_change { |key, update| Y::Document.append(key, update) }
18
18
 
19
- def subscribed
20
- return reject unless authorized?(params[:id])
21
-
22
- sync_subscribed(params[:id])
23
- end
19
+ def subscribed = sync_subscribed(params[:id])
24
20
 
25
21
  def receive(data) = sync_receive(data, params[:id])
26
22
 
@@ -28,8 +24,9 @@ class DocumentChannel < ApplicationCable::Channel
28
24
 
29
25
  # Everyone is denied until you fill this in. Wire it to your app's auth:
30
26
  # identify current_user on the cable connection, then check they may read
31
- # and write this document. Don't lean on on_change raising for access
32
- # control; that path exists for store failures.
27
+ # and write this document. This runs once when the client subscribes, and
28
+ # the subscription stays authorized until it ends. Raising in on_change is
29
+ # for store failures, so don't use it for access control.
33
30
  def authorized?(_document_key)
34
31
  false
35
32
  end
@@ -10,10 +10,13 @@ module Yrby
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
14
- # document; a 16 MB cap would break compaction) and payload is
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).
13
+ # 1.gigabyte - 1: the largest limit every adapter accepts. MySQL maps
14
+ # anything over 16 MB to longblob (a compacted snapshot is the whole
15
+ # document; a 16 MB cap would break compaction); Postgres raises above
16
+ # 1 GB - 1 and ignores the limit on bytea otherwise; SQLite ignores
17
+ # limits. Payload is 16.megabytes - 1 (one update can carry a big paste
18
+ # or a client's accumulated offline edits; the 64 KB default blob is
19
+ # too small).
17
20
  # The partial unique index's WHERE only keeps
18
21
  # key-only rows out of the index: uniqueness holds without it, since
19
22
  # unique indexes treat NULLs as distinct on every supported database,
@@ -6,7 +6,7 @@ class CreateYTables < ActiveRecord::Migration<%= migration_version %>
6
6
  t.string :key, null: false, index: { unique: true }
7
7
  t.references :record, polymorphic: true, null: true, index: false
8
8
  t.string :name
9
- t.binary :state, limit: 4.gigabytes - 1
9
+ t.binary :state, limit: 1.gigabyte - 1
10
10
  t.timestamps
11
11
  t.index %i[record_type record_id name], unique: true,
12
12
  where: "record_type IS NOT NULL",
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Collaborative
5
+ # The collaborative document for one attribute of one record, such as a
6
+ # post's body. Ruby code and the shipped channel both use it, so they read
7
+ # and write the same rows.
8
+ class Attribute
9
+ attr_reader :record, :name
10
+
11
+ def initialize(record, name)
12
+ raise ArgumentError, "collaboration requires a persisted record" unless record.persisted?
13
+
14
+ @record = record
15
+ @name = name.to_s.dup.freeze
16
+ end
17
+
18
+ # The document as update bytes, or nil before the first write.
19
+ def load_state = existing_row&.load_state
20
+
21
+ # Records one change and returns once it's saved. The channel waits for
22
+ # this to return before it acknowledges or broadcasts the change.
23
+ def append(update) = document_row.append(update)
24
+
25
+ # Builds a new Y::Doc from storage on every call.
26
+ def y_doc
27
+ Y::Doc.new.tap do |doc|
28
+ state = load_state
29
+ doc.apply_update(state) if state
30
+ end
31
+ end
32
+
33
+ # The key clients sync the document under, such as "post/1/body". If a
34
+ # key-only channel created the document before it was linked to a
35
+ # record, this returns that original key.
36
+ def key = existing_row&.key || Y::Document.key_for(record, name)
37
+
38
+ # Finds or creates the Y::Document or Y::EncryptedDocument row. Writes
39
+ # and maintenance work such as compaction go through it.
40
+ def document_row = document_class.for(record, name)
41
+
42
+ private
43
+
44
+ # Returns the row if it exists without creating one, so reading a
45
+ # document never adds a row. It selects only id and key because the
46
+ # row's load_state reads the snapshot itself, and loading the snapshot
47
+ # here too would fetch it twice.
48
+ def existing_row = document_class.select(:id, :key).find_by(record:, name:)
49
+
50
+ def document_class = record.class.collaborative_document_class(name)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Collaborative
5
+ # The view side of a collaborative document. The engine includes it into
6
+ # Action View.
7
+ #
8
+ # <%= collaborative_document_tag @post, :body %>
9
+ #
10
+ # renders the auto-connecting element:
11
+ #
12
+ # <yrby-document grant="<signed sgid>" name="body"></yrby-document>
13
+ #
14
+ # Importing "yrby-client/element" registers the element. It subscribes to
15
+ # Y::DocumentChannel by itself and exposes the synced Y.Doc through its
16
+ # `doc` property and `yrby:synced` event, much like the element behind
17
+ # turbo_stream_from.
18
+ #
19
+ # The grant is a signed GlobalID scoped to this record and attribute
20
+ # (record.collaborative_sgid(name)). Only render the tag on pages where
21
+ # the user may edit the record, because by default anyone holding the
22
+ # grant can open the document. To also check the user's current
23
+ # permissions when they subscribe, use Y::DocumentChannel.authorize_document.
24
+ #
25
+ # Extra options pass through to the element and a block becomes its
26
+ # content, so the element can wrap the mount point an editor binds to:
27
+ #
28
+ # <%= collaborative_document_tag @post, :body, id: "editor" %>
29
+ #
30
+ # expires_in: limits how long the grant lasts. refresh: is a URL the
31
+ # element fetches a new grant from when a subscription is rejected, so it
32
+ # can resubscribe without a page load. The app's action there re-runs its
33
+ # own authorization and renders { grant: record.collaborative_sgid(name) }:
34
+ #
35
+ # <%= collaborative_document_tag @post, :body, expires_in: 10.minutes,
36
+ # refresh: grant_post_path(@post) %>
37
+ module Helper
38
+ def collaborative_document_tag(record, name, expires_in: nil, refresh: nil, **, &)
39
+ grant = record.collaborative_sgid(name, **{ expires_in: expires_in }.compact)
40
+ attributes = { grant: grant, name: name }
41
+ attributes[:refresh] = refresh if refresh
42
+ # The helper's attributes go last because a later key wins in a keyword
43
+ # splat, so a template can't override the grant or the name.
44
+ tag.yrby_document(**, **attributes, &)
45
+ end
46
+ end
47
+ end
48
+ end