yrby-rails 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 397e3f48e611d521d0afa26ddca292826276406b1fd04fb2bcfd09858a73081d
4
- data.tar.gz: 51e88f6856f8a2859d18823e87ff2d5cd901e678e8b7a618ff7b2b0308d80c1d
3
+ metadata.gz: 83a5440fa6532dbedcf992660959297611b9b7cf958f7c7c6c1522dd0ac162b0
4
+ data.tar.gz: 23509b37d1bb0ce706c648a779993236addb244cb603031415de4084a58e4636
5
5
  SHA512:
6
- metadata.gz: 3299b4149a6dea79105be6bf69e643c8079029ac751bb50f68ce4b8d49ce570541b184f5b601215214d92b7848694a8b9a9a685eaf593f885378cbc8454e0a18
7
- data.tar.gz: 4490087a7702972c2e447abcb2f4afff938d72e77704eb7cfc04d5a5272bc6e5248d22a883b193b3840db0aa5c3e602e6a0c50b57fb1d3169839e7530a764ff4
6
+ metadata.gz: c51b2e0b9acefc89d6c69e3b935206d195ccaba7b302ec13316c39f7fab0115d09fd7a5d108f537294b8c069a54ef2e1a53fc19b39ec21873e7b61be866e7a5d
7
+ data.tar.gz: 5ffd94a472cd16ada77f62e57c7bd571f7df461a9552756212ef6fd12c9c1a2cb92bdb3ac72fe0320a1992d27f6b22ce8964240db81057ffaaa4cc2be1872124
data/CHANGELOG-rails.md CHANGED
@@ -7,6 +7,47 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-11-03
11
+
12
+ Requires yrby 0.9.0 or later.
13
+
14
+ ### Experimental
15
+
16
+ These are new, and they may change in a minor release. See
17
+ [docs/experimental.md](docs/experimental.md).
18
+
19
+ - `collaborative_document(:body).edit { |doc| ... }` edits a record's
20
+ document from Ruby. It records the change in the document's rows, encrypted
21
+ ones included, and broadcasts it to open editors.
22
+ `Y::ActionCable.broadcast(key, update)` sends an already recorded update.
23
+
24
+ ### Added
25
+
26
+ - `collaborative_document(name)` answers `grant(expires_in:)` and `channel`:
27
+ the grant a page or client subscribes with, and the channel that accepts
28
+ it. A gem with its own grants and channel, such as lexxy-realtime, returns
29
+ its own document with both.
30
+
31
+ ### Changed
32
+
33
+ - `collaborative_document_tag` renders the document's grant and channel, so
34
+ it works for a gem that returns its own document. `channel:` still
35
+ overrides the channel.
36
+
37
+ ### Deprecated
38
+
39
+ - `collaborative_sgid(name, expires_in:)`. Use
40
+ `collaborative_document(name).grant(expires_in:)`. It logs through
41
+ `Y.deprecator`, which the engine adds to `Rails.application.deprecators`,
42
+ and goes away in yrby-rails 0.9.
43
+
44
+ ### Fixed
45
+
46
+ - The channel relays a client's SyncStep2 to peers as a plain update.
47
+ Relayed as is, a peer that was still joining could take it for the answer
48
+ to its own handshake and report itself loaded before it had the server's
49
+ state.
50
+
10
51
  ## [0.7.0] - 2026-10-01
11
52
 
12
53
  ### Added
data/README.md CHANGED
@@ -101,6 +101,7 @@ bin/rails generate yrby:install && bin/rails db:migrate
101
101
  - [Rendering to HTML](#rendering-to-html)
102
102
  - [Protocol codec (module functions)](#protocol-codec-module-functions)
103
103
  - [ActionCable Integration](#actioncable-integration)
104
+ - [Experimental: writing from Ruby](#experimental-writing-from-ruby)
104
105
  - [Thread Safety](#thread-safety)
105
106
  - [Parallelism (GVL release)](#parallelism-gvl-release)
106
107
  - [Message Type Constants](#message-type-constants)
@@ -117,7 +118,9 @@ primitives. By default the Ruby side treats a document as opaque CRDT state. It
117
118
  applies updates, answers sync handshakes, and records deltas without reading
118
119
  the contents, and the browser editor decides what shape the document has. When
119
120
  you do need to look inside, `Doc#read_text` and `Doc#read_map` rebuild it in
120
- Ruby.
121
+ Ruby, and live handles (`Doc#get_map`, `get_array`, `get_text`,
122
+ `get_xml_text`) write to it. The handles are
123
+ [experimental](docs/experimental.md).
121
124
 
122
125
  ## Durability and delivery
123
126
 
@@ -338,12 +341,12 @@ every editor configures its own. `Y::Lexical` is the core Lexical base
338
341
  (paragraphs, headings, quotes, code, lists, tables, links, and the full
339
342
  text-format model), and other Lexical editors extend it with rules.
340
343
 
341
- It handles every node in the Lexxy 0.9.x set: paragraphs, headings, every text
342
- format and their combinations, links, the four list types with nesting,
343
- blockquotes, code blocks, tabs and soft breaks, horizontal rules, tables with
344
- header cells, image galleries, and ActionText attachments. Uploads and mentions
345
- both render as `<action-text-attachment>` elements, which ActionText can
346
- re-render.
344
+ It handles every node in Lexxy 1.0, which has the same set as 0.9.x:
345
+ paragraphs, headings, every text format and their combinations, links, the
346
+ four list types with nesting, blockquotes, code blocks, tabs and soft breaks,
347
+ horizontal rules, tables with header cells, image galleries, and ActionText
348
+ attachments. Uploads and mentions both render as `<action-text-attachment>`
349
+ elements, which ActionText can re-render.
347
350
 
348
351
  The Lexical support has the same two layers. `Y::Lexical` handles core Lexical
349
352
  structure natively, and everything Lexxy adds is in `Y::Lexxy`'s rule set,
@@ -401,6 +404,13 @@ attribute names your templates and blocks will read, and `children` and `text`
401
404
  tell you which `contains:` to pick. Child block types mean `:blocks`, and text
402
405
  means `:inline`.
403
406
 
407
+ `unknown_types` lists the types whose `handled` is nil. `to_html` still
408
+ renders them as well as it can. An unknown container or inline wrapper renders
409
+ its children without its own markup, so its text stays. A node that keeps its
410
+ content only in its attributes renders nothing. Either way, the node's markup is
411
+ missing from the HTML, and nothing raises. When you add editor nodes, add a test
412
+ that renders a real document and asserts `unknown_types` is empty.
413
+
404
414
  When a declarative rule can't express the markup, give the node a block:
405
415
 
406
416
  ```ruby
@@ -673,7 +683,7 @@ gets blocked at the next reconnect. To avoid that, pair `expires_in:` with
673
683
  # app/controllers/posts_controller.rb
674
684
  def grant
675
685
  @post = current_user.posts.find(params[:id]) # your own authorization, again
676
- render json: { grant: @post.collaborative_sgid(:body, expires_in: 10.minutes) }
686
+ render json: { grant: @post.collaborative_document(:body).grant(expires_in: 10.minutes) }
677
687
  end
678
688
  ```
679
689
 
@@ -737,7 +747,7 @@ up the record from it, so the page decides which document the client gets.
737
747
 
738
748
  ```erb
739
749
  <%# the view picks the document and signs it %>
740
- <%= tag.div data: { grant: post.collaborative_sgid(:body) } %>
750
+ <%= tag.div data: { grant: post.collaborative_document(:body).grant } %>
741
751
  ```
742
752
 
743
753
  ```ruby
@@ -1067,6 +1077,18 @@ The browser clients manage presence (cursors, selections). The server doesn't
1067
1077
  set or hold presence state, and it relays awareness frames without reading
1068
1078
  them. See `yrby-client` for the client-side awareness API.
1069
1079
 
1080
+ ## Experimental: writing from Ruby
1081
+
1082
+ These APIs are new. They may change in any minor release, and they aren't
1083
+ covered by yrby's usual compatibility promise.
1084
+ [docs/experimental.md](docs/experimental.md) documents them:
1085
+
1086
+ - Live `Y::Map`, `Y::Array`, and `Y::Text` handles that write to a document
1087
+ - Writing Lexical rich text from Ruby, with `Y::Lexical` and `Y::Lexxy`
1088
+ - Anchors: a block's identity as a value
1089
+ - Streaming into a document with `Doc#edit`
1090
+ - Editing a record's document from Ruby
1091
+
1070
1092
  ## Thread Safety
1071
1093
 
1072
1094
  You can share a `Doc` across Ruby threads. Puma workers, ActionCable connection
@@ -6,7 +6,7 @@ module Y
6
6
  # doesn't write a channel of its own.
7
7
  #
8
8
  # The client never picks its own document. It sends the signed grant the
9
- # page rendered (record.collaborative_sgid(name)), and the channel opens
9
+ # page rendered (record.collaborative_document(name).grant), and the channel opens
10
10
  # whatever document that grant verifies to. Your controller authorized the
11
11
  # request when it rendered the tag, and the grant is how that decision gets
12
12
  # to the socket. You can also register an authorize_document block to check
@@ -398,7 +398,7 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
398
398
  update = Y.update_from_message(bytes)
399
399
  return :noop unless update
400
400
 
401
- sync_handle_document_update(update, encoded)
401
+ sync_handle_document_update(update, sync_relay_frame(update, encoded, bytes))
402
402
  when MSG_KIND_AWARENESS
403
403
  sync_distribute(encoded)
404
404
  :noop
@@ -422,6 +422,16 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
422
422
  :recorded
423
423
  end
424
424
 
425
+ # A client's SyncStep2 answers the server's Step1 and is meant for the
426
+ # server. Peers get it as a plain update, so the only Step2 a client ever
427
+ # receives is the server's answer to its own handshake, which is how a
428
+ # client knows it has loaded.
429
+ def sync_relay_frame(update, encoded, bytes)
430
+ return encoded unless bytes.getbyte(1) == Y::MSG_SYNC_STEP2
431
+
432
+ Base64.strict_encode64(Y.wrap_update(update))
433
+ end
434
+
425
435
  # Build a fresh document from the durable store (on_load). Callers validate
426
436
  # the hooks first, so on_load is present; a nil state means a fresh document.
427
437
  def sync_load_doc
@@ -432,7 +442,7 @@ module Y::ActionCable # rubocop:disable Style/ClassAndModuleChildren
432
442
  end
433
443
 
434
444
  def sync_stream_name
435
- "yrby:#{@sync_key}"
445
+ Y::ActionCable.stream_name(@sync_key)
436
446
  end
437
447
 
438
448
  def sync_awareness_stream_name
@@ -17,6 +17,17 @@ module Y
17
17
  def self.included(base)
18
18
  base.include(Sync)
19
19
  end
20
+
21
+ # The stream a document's subscribers listen on.
22
+ def self.stream_name(key) = "yrby:#{key}"
23
+
24
+ # Sends an update to everyone subscribed to a document, from outside a
25
+ # channel: a job, a console, or Y::Collaborative::Attribute#edit. Record
26
+ # the update first. This only distributes it.
27
+ def self.broadcast(key, update)
28
+ encoded = Base64.strict_encode64(Y.wrap_update(update))
29
+ ::ActionCable.server.broadcast(stream_name(key), { "update" => encoded })
30
+ end
20
31
  end
21
32
  end
22
33
 
@@ -22,6 +22,42 @@ module Y
22
22
  # this to return before it acknowledges or broadcasts the change.
23
23
  def append(update) = document_row.append(update)
24
24
 
25
+ # A signed grant for this document, what a page or a Ruby client
26
+ # subscribes with. It's a signed GlobalID scoped to this attribute, and
27
+ # Y::Collaborative.locate turns it back into the record. Pass
28
+ # expires_in: to limit how long it lasts. Without it GlobalID's own
29
+ # default applies, which is one month under Rails. The option goes
30
+ # through only when it's given, because an explicit nil would mean
31
+ # "never expire".
32
+ def grant(expires_in: nil)
33
+ options = { for: Y::Collaborative.sgid_purpose(name) }
34
+ options[:expires_in] = expires_in if expires_in
35
+ record.to_sgid(**options).to_s
36
+ end
37
+
38
+ # The channel that accepts this document's grant. A gem with its own
39
+ # grants and channel, such as lexxy-realtime, returns its own document
40
+ # with both.
41
+ def channel = "Y::DocumentChannel"
42
+
43
+ # Edits the document from Ruby the way a browser does: yields the
44
+ # current document, records what the block changed, and broadcasts it so
45
+ # open editors apply it. Returns the update, or nil when the block
46
+ # changed nothing.
47
+ #
48
+ # post.collaborative_document(:body).edit do |doc|
49
+ # Y::Lexxy.append_paragraph(doc, "Reviewed by ops.")
50
+ # end
51
+ def edit
52
+ doc = y_doc
53
+ update = doc.edit { yield doc }
54
+ return unless update
55
+
56
+ append(update)
57
+ Y::ActionCable.broadcast(key, update)
58
+ update
59
+ end
60
+
25
61
  # Builds a new Y::Doc from storage on every call.
26
62
  def y_doc
27
63
  Y::Doc.new.tap do |doc|
@@ -9,15 +9,18 @@ module Y
9
9
  #
10
10
  # renders the auto-connecting element:
11
11
  #
12
- # <yrby-document grant="<signed sgid>" name="body"></yrby-document>
12
+ # <yrby-document channel="Y::DocumentChannel" grant="<signed grant>" name="body"></yrby-document>
13
13
  #
14
14
  # Importing "yrby-client/element" registers the element. It subscribes to
15
- # Y::DocumentChannel by itself and exposes the synced Y.Doc through its
15
+ # the document's channel by itself and exposes the synced Y.Doc through its
16
16
  # `doc` property and `yrby:synced` event, much like the element behind
17
17
  # turbo_stream_from.
18
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
19
+ # The grant and channel come from record.collaborative_document(name), so
20
+ # the tag works for any gem that returns its own document, such as
21
+ # lexxy-realtime. Pass channel: for an app's own Y::DocumentChannel
22
+ # subclass. The grant is scoped to this record and attribute. Only render
23
+ # the tag on pages where
21
24
  # the user may edit the record, because by default anyone holding the
22
25
  # grant can open the document. To also check the user's current
23
26
  # permissions when they subscribe, use Y::DocumentChannel.authorize_document.
@@ -30,18 +33,18 @@ module Y
30
33
  # expires_in: limits how long the grant lasts. refresh: is a URL the
31
34
  # element fetches a new grant from when a subscription is rejected, so it
32
35
  # can resubscribe without a page load. The app's action there re-runs its
33
- # own authorization and renders { grant: record.collaborative_sgid(name) }:
36
+ # own authorization and renders { grant: record.collaborative_document(name).grant }:
34
37
  #
35
38
  # <%= collaborative_document_tag @post, :body, expires_in: 10.minutes,
36
39
  # refresh: grant_post_path(@post) %>
37
40
  module Helper
38
41
  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 }
42
+ document = record.collaborative_document(name)
43
+ attributes = { grant: document.grant(expires_in:), name: name }
41
44
  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
+ # A later key wins in a keyword splat. A template can pass its own
46
+ # channel, but it can't override the grant or the name.
47
+ tag.yrby_document(channel: document.channel, **, **attributes, &)
45
48
  end
46
49
  end
47
50
  end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/concern"
4
+ require "active_support/deprecation"
4
5
  require "global_id"
6
+ require "y/action_cable"
5
7
 
6
8
  module Y
7
9
  # The signed token that connects a page to a channel for record-backed
@@ -12,7 +14,7 @@ module Y
12
14
  # record up from it. The token is a signed GlobalID scoped to one attribute.
13
15
  #
14
16
  # # the view
15
- # tag.div data: { grant: post.collaborative_sgid(:body) }
17
+ # tag.div data: { grant: post.collaborative_document(:body).grant }
16
18
  #
17
19
  # # the channel
18
20
  # def authorized?(_key)
@@ -28,6 +30,10 @@ module Y
28
30
  #
29
31
  # The engine includes this into ActiveRecord::Base so any channel's
30
32
  # authorized? can use it. lexxy-realtime uses the same token flow.
33
+ # Warns about yrby-rails methods that are going away. The engine adds it to
34
+ # Rails.application.deprecators, so the app's deprecation settings apply.
35
+ def self.deprecator = @deprecator ||= ActiveSupport::Deprecation.new("0.9", "yrby-rails")
36
+
31
37
  module Collaborative
32
38
  extend ActiveSupport::Concern
33
39
 
@@ -39,7 +45,7 @@ module Y
39
45
  # shipped one can both resolve the same tokens for an attribute.
40
46
  def sgid_purpose(name) = "yrby/#{name}"
41
47
 
42
- # Looks up the record for a token from `collaborative_sgid(name)`.
48
+ # Looks up the record for a grant from `collaborative_document(name).grant`.
43
49
  # Returns nil for an invalid, tampered, expired, or wrong-attribute
44
50
  # token, and for a record that no longer exists.
45
51
  def locate(sgid, name)
@@ -76,20 +82,17 @@ module Y
76
82
  end
77
83
 
78
84
  # Returns the document for one attribute, stored the way the model
79
- # declared. It responds to load_state, append, y_doc, and key.
85
+ # declared. It responds to load_state, append, edit, y_doc, key, grant,
86
+ # and channel.
80
87
  def collaborative_document(name)
81
88
  Attribute.new(self, name)
82
89
  end
83
90
 
84
- # A signed token that a channel can pass to Y::Collaborative.locate to get
85
- # this record back, for this attribute only. Pass expires_in: to limit how
86
- # long the grant lasts. Without it GlobalID's own default applies, which is
87
- # one month under Rails. We pass the option through only when it's given,
88
- # because an explicit nil would mean "never expire".
91
+ # Deprecated. Use collaborative_document(name).grant. This one always
92
+ # signs yrby's own grant, as it did before.
89
93
  def collaborative_sgid(name, expires_in: nil)
90
- options = { for: Y::Collaborative.sgid_purpose(name) }
91
- options[:expires_in] = expires_in if expires_in
92
- to_sgid(**options).to_s
94
+ Y.deprecator.warn("collaborative_sgid(name) is deprecated. Use collaborative_document(name).grant instead.")
95
+ Attribute.new(self, name).grant(expires_in:)
93
96
  end
94
97
  end
95
98
  end
data/lib/yrby/engine.rb CHANGED
@@ -12,5 +12,9 @@ module Yrby
12
12
  ActiveSupport.on_load(:active_record) { include Y::Collaborative }
13
13
  ActiveSupport.on_load(:action_view) { include Y::Collaborative::Helper }
14
14
  end
15
+
16
+ initializer "yrby.deprecator" do |app|
17
+ app.deprecators[:yrby] = Y.deprecator
18
+ end
15
19
  end
16
20
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yrby
4
4
  module Rails
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yrby-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: 0.7.0
60
+ version: 0.9.0
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: 0.7.0
67
+ version: 0.9.0
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: actioncable
70
70
  requirement: !ruby/object:Gem::Requirement