yrby 0.2.0-x64-mingw-ucrt
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 +7 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/lib/y/3.4/y_ruby.so +0 -0
- data/lib/y/4.0/y_ruby.so +0 -0
- data/lib/y/decoder/version.rb +7 -0
- data/lib/y/decoder.rb +66 -0
- data/lib/y/version.rb +5 -0
- data/lib/y.rb +19 -0
- data/lib/yrby-decoder.rb +4 -0
- data/lib/yrby.rb +4 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d8f9c831b3b112ea2f7a2aa5f68f6e6bd130bb4b39649b14efd60cc204657c0a
|
|
4
|
+
data.tar.gz: 4dbc1b01d07061d881a222b3275fbab01d394ea3b5a8aeafaa8f5a96050c4a54
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f3915cb10a57f9fdad38a014c9ecf6e063e9dc4327d19f30f94b4a07f973663990902f965bcf466fafb33fde442e95cd95dd59fd0868a8616902af60700e8835
|
|
7
|
+
data.tar.gz: 9ea6218b065c1cdc05c4a183418bd8f941132c247a5d5028acc4baa2a092fd7f029f296efe75d3ac97064b61870c75de128af6c08066e16393e5ff71df239cf1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims
|
|
5
|
+
to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-06-28
|
|
10
|
+
|
|
11
|
+
First release under the **`yrby`** name (the project was previously developed
|
|
12
|
+
as `yrb-lite`). The public Ruby interface is the top-level module **`Y`** —
|
|
13
|
+
mirroring the `y-rb` gem's `Y::Doc` interface.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Renamed `yrb-lite` → `yrby`.** Module `YrbLite` → top-level `Y`
|
|
17
|
+
(`Y::Doc`, `Y::Error`, `Y::VERSION`). Require path `require "yrb_lite"` →
|
|
18
|
+
`require "y"`. Native extension crate `yrb_lite` → `y_ruby`, loaded from
|
|
19
|
+
`lib/y/y_ruby.bundle`.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Native `Doc#read_text` and `Doc#read_map` readers — reconstruct plain text and
|
|
23
|
+
a JSON map from the stored CRDT state in-process, server-side, with no Node or
|
|
24
|
+
subprocess.
|
|
25
|
+
|
|
26
|
+
### Notes
|
|
27
|
+
- y-crdt wrapper over Rust `yrs` 0.27.2 (magnus/rb-sys), with the full
|
|
28
|
+
y-websocket sync protocol + Awareness, thread-safe (`Send`/`Sync`,
|
|
29
|
+
GVL released around CRDT work). Precompiled platform gems are published
|
|
30
|
+
alongside the source gem so `gem install yrby` needs no Rust toolchain.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# yrby
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jpcamara/yrby/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Collaborative editing for Rails, backed by [y-crdt](https://github.com/y-crdt/y-crdt)
|
|
6
|
+
(the Rust library behind Y.js). Your Rails server speaks the y-websocket sync
|
|
7
|
+
protocol directly, so there's no separate Node process hosting the Y.js
|
|
8
|
+
documents.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
12
|
+
include Y::ActionCable::Sync
|
|
13
|
+
|
|
14
|
+
on_load { |key| MyStore.load(key) }
|
|
15
|
+
on_change { |key, update| MyStore.append(key, update) }
|
|
16
|
+
|
|
17
|
+
def subscribed = sync_subscribed(params[:id])
|
|
18
|
+
def receive(data) = sync_receive(data, params[:id])
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
On the browser, use the `ActionCableProvider` from the
|
|
23
|
+
[`@yrby/client`](https://www.npmjs.com/package/@yrby/client) npm package.
|
|
24
|
+
Integrates with any editor that includes Y.js support, such as Tiptap, ProseMirror
|
|
25
|
+
and [Lexxy](https://www.npmjs.com/package/lexxy-realtime).
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Install the gem and npm package:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
gem install yrby-actioncable # depends on yrby
|
|
33
|
+
npm install @yrby/client
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## What you get
|
|
37
|
+
|
|
38
|
+
- A thread-safe Ruby `Doc` you can share across Ruby threads/fibers, and native CRDT work
|
|
39
|
+
runs with the GVL released.
|
|
40
|
+
- The y-websocket protocol (document sync plus awareness/presence) as a
|
|
41
|
+
one-include ActionCable concern.
|
|
42
|
+
- Authoritative record-before-distribute semantics: each document change can be
|
|
43
|
+
recorded durably before it goes out to anyone.
|
|
44
|
+
-
|
|
45
|
+
|
|
46
|
+
## Why "lite"
|
|
47
|
+
|
|
48
|
+
The "lite" is the size of the surface. `yrby` binds just the part of `y-crdt` you
|
|
49
|
+
need to *sync and persist* collaborative documents - a `Doc`, awareness, and the
|
|
50
|
+
y-websocket protocol primitives. The Ruby side treats a document as opaque CRDT
|
|
51
|
+
state: it applies updates, answers sync handshakes, and records deltas, but never
|
|
52
|
+
reaches in to read or edit the contents. The browser editor owns the document's
|
|
53
|
+
shape.
|
|
54
|
+
|
|
55
|
+
## What isn't "lite"
|
|
56
|
+
|
|
57
|
+
The surface area may be "lite", but a core focus is on durability, resiliency, delivery
|
|
58
|
+
guarantees, correctness, and thread safety.
|
|
59
|
+
|
|
60
|
+
Towards that goal, `yrby` adds capabilities that may even stand out in the Yjs ecosystem:
|
|
61
|
+
|
|
62
|
+
- Built-in update acknowledgement: the `ActionCableProvider` in `@yrby/client` will continue to
|
|
63
|
+
send updates until an ack is received from the server. [`yrby-actioncable`](https://rubygems.org/gems/yrby-actioncable)
|
|
64
|
+
only sends an ack when applying an update is successful. The goal is at-least-once delivery,
|
|
65
|
+
and because CRDTs are idempotent a duplicate update is effectively a no-op.
|
|
66
|
+
- Gap detection in document updates: before applying an update and sending an ack to the client,
|
|
67
|
+
`yrby` checks whether the update results in any causal gap. Ie, an update comes through
|
|
68
|
+
which depends on a previous update that is not yet present in the document. This can result in
|
|
69
|
+
a document stuck with "pending" updates, which will _never_ apply if the missing update is not sent.
|
|
70
|
+
To avoid this, `yrby` does not apply the update, and starts a new y-protocol sync with the client.
|
|
71
|
+
That will cause the client to synchronize its document with the server, sending through any updates
|
|
72
|
+
that may have been missed
|
|
73
|
+
|
|
74
|
+
## What about [yrb](https://github.com/y-crdt/yrb)?
|
|
75
|
+
|
|
76
|
+
`yrb` has a much larger interface that gives you most of the Yjs type system -
|
|
77
|
+
shared text, arrays, maps, XML - to build and query documents in Ruby. It was a great
|
|
78
|
+
inspiration for my use of Yjs in Ruby/Rails, and I originally considered building
|
|
79
|
+
on top of it. There are a few reasons I went with `yrby` instead:
|
|
80
|
+
|
|
81
|
+
- `yrb` is largely unmaintained. It was built as an experiment for GitLab, and the original
|
|
82
|
+
author mostly moved onto other projects.
|
|
83
|
+
- [It isn't thread-safe](https://github.com/y-crdt/yrb/issues/72). It segfaults in a threaded
|
|
84
|
+
environment (such as ActionCable...)
|
|
85
|
+
- It's a much larger set of features to maintain, which most people don't need. The vast
|
|
86
|
+
majority of people manipulate Y.js documents in the browser, not from a server-side language.
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
Ruby and Rust unit tests cover the core. CI also runs the npm client tests and a
|
|
91
|
+
Rails demo smoke slice against the real ActionCable stack. The demo includes
|
|
92
|
+
heavier local suites for hostile input, crash recovery, multi-browser editing,
|
|
93
|
+
AnyCable, and load testing. The benchmark note below is from a single laptop.
|
|
94
|
+
Issues and PRs are welcome.
|
|
95
|
+
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Core CRDT + protocol primitives:
|
|
100
|
+
gem "yrby"
|
|
101
|
+
|
|
102
|
+
# For the Rails/ActionCable server concern (Y::ActionCable::Sync):
|
|
103
|
+
gem "yrby-actioncable"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Requires Ruby 3.4 or newer. The release workflow builds precompiled gems for
|
|
107
|
+
Ruby 3.4 and 4.0 across the supported Ruby platforms, with native smoke tests
|
|
108
|
+
on Linux x86_64 and macOS arm64. Installing from a matching platform gem needs
|
|
109
|
+
no Rust; a source build needs [Rust](https://rustup.rs).
|
|
110
|
+
|
|
111
|
+
To work on the gem itself:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/jpcamara/yrby
|
|
115
|
+
cd yrby
|
|
116
|
+
bundle install
|
|
117
|
+
bundle exec rake compile test
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The rest of the dev setup, plus the demo, is in [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
121
|
+
|
|
122
|
+
## Docs
|
|
123
|
+
|
|
124
|
+
- The ActionCable concern and a quickstart are [below](#actioncable-integration).
|
|
125
|
+
- [`examples/actioncable-demo`](examples/actioncable-demo): a runnable Rails +
|
|
126
|
+
Tiptap app with collaborative cursors, the AnyCable setup, a Postgres store,
|
|
127
|
+
and the test/load suites.
|
|
128
|
+
- [CHANGELOG.md](CHANGELOG.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
129
|
+
|
|
130
|
+
## Usage
|
|
131
|
+
|
|
132
|
+
### Doc (Low-Level Document Sync)
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
require "y"
|
|
136
|
+
|
|
137
|
+
# Create docs
|
|
138
|
+
doc = Y::Doc.new # random client ID
|
|
139
|
+
doc = Y::Doc.new(12345) # specific client ID (used for CRDT identity)
|
|
140
|
+
|
|
141
|
+
# Encoding
|
|
142
|
+
doc.encode_state_vector # => current state vector
|
|
143
|
+
doc.encode_state_as_update # => full update
|
|
144
|
+
doc.encode_state_as_update(sv) # => update diff against state vector
|
|
145
|
+
|
|
146
|
+
# Applying updates
|
|
147
|
+
doc.apply_update(update_bytes) # apply raw V1 update
|
|
148
|
+
|
|
149
|
+
# Sync protocol
|
|
150
|
+
doc.sync_step1 # => SyncStep1 message (this doc's state vector)
|
|
151
|
+
doc.handle_sync_message(data) # => [msg_type, sync_type, response]; answers a
|
|
152
|
+
# peer's SyncStep1 with a SyncStep2
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Protocol codec (module functions)
|
|
156
|
+
|
|
157
|
+
Classifying and unwrapping wire frames is stateless, so it's exposed as
|
|
158
|
+
`Y` module functions rather than a class. The server never holds presence
|
|
159
|
+
or document state to route a frame — presence lives in the browser clients, and
|
|
160
|
+
the server only relays awareness frames opaquely.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
Y.message_kind(frame) # => 0 drop / 1 step1 / 2 update / 3 awareness / 4 query
|
|
164
|
+
Y.update_from_message(frame) # => the document delta carried by a frame, or nil
|
|
165
|
+
Y.wrap_update(update_bytes) # => wrap a raw doc update as a sync Update frame
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### ActionCable Integration
|
|
169
|
+
|
|
170
|
+
`Y::ActionCable::Sync` (from the `yrby-actioncable` gem) is a channel
|
|
171
|
+
concern that implements the full y-websocket protocol (document sync +
|
|
172
|
+
awareness/presence) over ActionCable:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
# app/channels/document_channel.rb
|
|
176
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
177
|
+
include Y::ActionCable::Sync
|
|
178
|
+
|
|
179
|
+
on_load { |key| MyStore.load(key) } # source of truth
|
|
180
|
+
on_change { |key, update| MyStore.append(key, update) } # durable record
|
|
181
|
+
|
|
182
|
+
def subscribed
|
|
183
|
+
sync_subscribed params[:id]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def receive(data)
|
|
187
|
+
sync_receive(data, params[:id])
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The concern is store-backed. A handshake is answered from `on_load`; document
|
|
193
|
+
changes are checked against that durable state, recorded through `on_change`,
|
|
194
|
+
then broadcast. Nothing authoritative is kept in ActionCable process memory, so
|
|
195
|
+
AnyCable RPC workers, Puma workers, and separate dynos can all handle messages
|
|
196
|
+
for the same document as long as they share the same store and cable adapter.
|
|
197
|
+
|
|
198
|
+
`on_load` and `on_change` are required. If either is missing, the channel fails
|
|
199
|
+
before it can acknowledge or broadcast edits. Presence is ephemeral:
|
|
200
|
+
awareness frames are relayed, and `@yrby/client` sends a best-effort
|
|
201
|
+
presence-removal frame on disconnect/pagehide, with the client-side awareness
|
|
202
|
+
timeout as the fallback for abrupt disconnects.
|
|
203
|
+
|
|
204
|
+
Incoming frames are validated as a single well-formed protocol message before
|
|
205
|
+
anything processes or relays them. Malformed, truncated, multi-message,
|
|
206
|
+
oversized, or unknown frames are dropped. A bad frame can't crash the process: a
|
|
207
|
+
Rust panic is caught at the FFI boundary and re-raised as a Ruby exception. And
|
|
208
|
+
no single client can relay garbage that breaks the others in a room.
|
|
209
|
+
|
|
210
|
+
#### Delivery guarantees
|
|
211
|
+
|
|
212
|
+
The contract is the same at every scale — one process, or hundreds across many
|
|
213
|
+
servers:
|
|
214
|
+
|
|
215
|
+
- **The document always converges.** CRDT updates are commutative and
|
|
216
|
+
idempotent, so out-of-order, duplicate, or concurrent delivery all converge to
|
|
217
|
+
the same correct document. This needs no coordination and holds everywhere.
|
|
218
|
+
- **The durable log never goes gappy.** An update is recorded only once its
|
|
219
|
+
causal dependencies are already in the store (checked against `on_load`); a
|
|
220
|
+
causally-incomplete update triggers a resync instead, so the log always
|
|
221
|
+
rebuilds cleanly.
|
|
222
|
+
- **`on_change` is at-least-once, and the durable guarantee is that replaying the
|
|
223
|
+
log reconstructs the document.** Every update triggers `on_change` before it's acked or
|
|
224
|
+
broadcast (record-before-distribute). If exactly-once updates matter for you, **you
|
|
225
|
+
must make `on_change` idempotent**. But remember that the CRDT can handle duplicates.
|
|
226
|
+
- **A raising `on_change` rejects the update implicitly.** If the block raises,
|
|
227
|
+
the update is neither acked nor broadcast (record-before-distribute stops both).
|
|
228
|
+
There is no negative-ack: the client simply never receives the ack, keeps the
|
|
229
|
+
update pending, and retransmits on its timer/reconnect. This is built for
|
|
230
|
+
*transient* failures (the store is briefly down → a retry lands). A block that
|
|
231
|
+
raises *deterministically* — a validation that always fails for this edit —
|
|
232
|
+
will be retried forever, since nothing tells the client to stop. Enforce hard
|
|
233
|
+
rejections before the edit reaches `on_change` (channel authorization in
|
|
234
|
+
`subscribed`), not by raising inside it.
|
|
235
|
+
- **An over-cap frame is dropped the same silent way.** A frame larger than
|
|
236
|
+
`max_frame_bytes` (default 8 MiB) is dropped before decoding — no ack, no
|
|
237
|
+
broadcast — to bound the work a client can force. For a genuine document
|
|
238
|
+
update that means the same implicit rejection as above: unacked, retransmitted
|
|
239
|
+
forever. Normal typing never approaches the cap, but a large paste, an embedded
|
|
240
|
+
image, or a big initial `SyncStep2` can. The drop is logged (`warn` for
|
|
241
|
+
over-cap, `debug` for undecodable) with the document key and update id so it's
|
|
242
|
+
findable; override `sync_log_context` on the channel to add a user/connection
|
|
243
|
+
id. Size the cap for your largest expected payload, and reject
|
|
244
|
+
genuinely-too-big content upstream rather than relying on the cap to reject it
|
|
245
|
+
gracefully.
|
|
246
|
+
|
|
247
|
+
#### Multi-process deployments
|
|
248
|
+
|
|
249
|
+
Most Rails apps run several processes, and any of them might serve a given document.
|
|
250
|
+
Two pieces keep them in step.
|
|
251
|
+
|
|
252
|
+
Broadcasts cross processes through the Action Cable adapter, so it needs to something
|
|
253
|
+
like `redis` or `solid_cable`, not `async`. With that in place, a change
|
|
254
|
+
on one process reaches clients on all of them.
|
|
255
|
+
|
|
256
|
+
Every process rebuilds document state from the durable store through `on_load`.
|
|
257
|
+
Because changes are recorded before broadcast, record-before-distribute holds
|
|
258
|
+
across processes: whichever process receives a change records it to the shared
|
|
259
|
+
store before anyone, anywhere, sees it.
|
|
260
|
+
|
|
261
|
+
`bun multiprocess.mjs` in the demo runs clients across two processes and checks
|
|
262
|
+
convergence, fresh reads on both, presence across processes, and one shared log.
|
|
263
|
+
|
|
264
|
+
##### AnyCable
|
|
265
|
+
|
|
266
|
+
`yrby` fully supports AnyCable.
|
|
267
|
+
|
|
268
|
+
The demo checks this against a real anycable-go + RPC server
|
|
269
|
+
(`frontend/anycable_probe.mjs`, `anycable_concurrent.mjs`): liveness, the
|
|
270
|
+
yrby client provider, cross-process reads, and concurrent convergence.
|
|
271
|
+
|
|
272
|
+
##### Demo
|
|
273
|
+
|
|
274
|
+
[`examples/actioncable-demo`](examples/actioncable-demo) is a full Rails + Tiptap
|
|
275
|
+
app using the yrby provider, with end-to-end tests.
|
|
276
|
+
|
|
277
|
+
#### Record Before Distribute
|
|
278
|
+
|
|
279
|
+
Every document change is handed to the `on_change` handler before broadcasting.
|
|
280
|
+
It is up to you to durably record it:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
class DocumentChannel < ApplicationCable::Channel
|
|
284
|
+
include Y::ActionCable::Sync
|
|
285
|
+
|
|
286
|
+
# ...
|
|
287
|
+
|
|
288
|
+
on_change do |key, update|
|
|
289
|
+
# Synchronous, durable write. `update` is the exact CRDT delta.
|
|
290
|
+
AuditLog.append!(key, update) # raise to REJECT the change
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# ...
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
If the recorder raises (say the store is down), the change is rejected: not
|
|
298
|
+
applied, not sent to anyone. The cost is a synchronous durable write on the path
|
|
299
|
+
of every change. There's no in-gem per-document lock; concurrent writes to one
|
|
300
|
+
document can both record (at-least-once), and since CRDT apply is idempotent a
|
|
301
|
+
duplicate record replays to the same document.
|
|
302
|
+
|
|
303
|
+
The demo wires `on_change` to a durable Postgres-backed log by default, and checks
|
|
304
|
+
end to end that the log alone rebuilds the document.
|
|
305
|
+
|
|
306
|
+
#### Reliable delivery (acks)
|
|
307
|
+
|
|
308
|
+
yrby document delivery is ack-tracked. Browser document updates carry an
|
|
309
|
+
`"id"`, and the server replies `{ "ack": <id> }` once `on_change` has succesfully fired.
|
|
310
|
+
A causally-gapped update is not acked; the server sends a resync request, and
|
|
311
|
+
the client keeps the update queued until it lands.
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
client -> server { "update": "<base64 update>", "id": 42 }
|
|
315
|
+
server -> client { "ack": 42 } # update accepted; safe to forget
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`@yrby/client`'s `ActionCableProvider` handles this automatically. It keeps
|
|
319
|
+
the unacknowledged local document tail in a queue and sends the merged tail as a
|
|
320
|
+
single causally-complete delta. The id is the highest sequence in the batch, so
|
|
321
|
+
one `{ ack: id }` cumulatively confirms everything up to it. Because CRDT apply
|
|
322
|
+
is idempotent, a resend that already landed is a harmless no-op that just
|
|
323
|
+
re-acks. Awareness stays ephemeral and is not acked.
|
|
324
|
+
|
|
325
|
+
Presence (cursors, selections) is owned by the browser clients — the server
|
|
326
|
+
never sets or holds presence state, it only relays awareness frames opaquely.
|
|
327
|
+
See `@yrby/client` for the client-side awareness API.
|
|
328
|
+
|
|
329
|
+
## Thread Safety
|
|
330
|
+
|
|
331
|
+
A `Doc` is safe to share across Ruby threads — used concurrently from Puma
|
|
332
|
+
workers, ActionCable connection threads, or background jobs without external
|
|
333
|
+
locking.
|
|
334
|
+
|
|
335
|
+
`test/thread_safety_test.rb` runs shared docs, the full sync handshake, and
|
|
336
|
+
fan-in sync across 8 threads at once, and checks the interleaving doesn't change
|
|
337
|
+
convergence.
|
|
338
|
+
|
|
339
|
+
### Parallelism (GVL release)
|
|
340
|
+
|
|
341
|
+
Every method that does real CRDT work (applying updates, encoding state,
|
|
342
|
+
handling sync messages) releases Ruby's Global VM Lock
|
|
343
|
+
(`rb_thread_call_without_gvl`) while the native code runs. That buys two things.
|
|
344
|
+
|
|
345
|
+
CRDT work runs in parallel across Ruby threads on MRI, not just
|
|
346
|
+
JRuby/TruffleRuby. `bench/parallelism_bench.rb` measures over 2x wall-clock
|
|
347
|
+
speedup applying a ~900 KB update concurrently; native code that held the GVL
|
|
348
|
+
couldn't beat serial time.
|
|
349
|
+
|
|
350
|
+
A slow operation also can't stall the VM. A thread applying a large update holds
|
|
351
|
+
the doc's write lock without holding the GVL, so other Ruby threads keep running
|
|
352
|
+
instead of queuing behind it.
|
|
353
|
+
|
|
354
|
+
Each method has the same shape: copy Ruby byte strings first, drop the GVL, do
|
|
355
|
+
the yrs work while taking and releasing native locks entirely inside the
|
|
356
|
+
closure, take the GVL back, then build Ruby objects. No Ruby API is touched
|
|
357
|
+
without the GVL, and no native lock is held while reacquiring it, so the lock
|
|
358
|
+
order can't deadlock. Panics in native code are caught and re-raised as Ruby
|
|
359
|
+
exceptions.
|
|
360
|
+
|
|
361
|
+
## Message Type Constants
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
Y::MSG_SYNC # 0 - Document sync messages
|
|
365
|
+
Y::MSG_AWARENESS # 1 - User presence data
|
|
366
|
+
|
|
367
|
+
Y::MSG_SYNC_STEP1 # 0 - State vector request
|
|
368
|
+
Y::MSG_SYNC_STEP2 # 1 - Update response
|
|
369
|
+
Y::MSG_SYNC_UPDATE # 2 - Incremental update
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Sync Flow
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
Client A Server
|
|
376
|
+
| |
|
|
377
|
+
|-------- connect() ------------->|
|
|
378
|
+
| (SyncStep1 + Awareness) |
|
|
379
|
+
| |
|
|
380
|
+
|<--- handle_sync_message resp ---|
|
|
381
|
+
| (SyncStep2) |
|
|
382
|
+
| |
|
|
383
|
+
| (Document synchronized!) |
|
|
384
|
+
| |
|
|
385
|
+
|<------- updates ----------------|
|
|
386
|
+
|-------- updates --------------->|
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Development
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
# Setup
|
|
393
|
+
bundle install
|
|
394
|
+
|
|
395
|
+
# Build extension
|
|
396
|
+
rake compile
|
|
397
|
+
|
|
398
|
+
# Run tests
|
|
399
|
+
rake test
|
|
400
|
+
|
|
401
|
+
# Clean build artifacts
|
|
402
|
+
rake clean
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## License
|
|
406
|
+
|
|
407
|
+
MIT License
|
|
408
|
+
|
|
409
|
+
## Acknowledgments
|
|
410
|
+
|
|
411
|
+
- [y-crdt/yrs](https://github.com/y-crdt/y-crdt) - The Rust implementation of Y.js
|
|
412
|
+
- [Magnus](https://github.com/matsadler/magnus) - Ruby bindings for Rust
|
|
413
|
+
- [rb-sys](https://github.com/oxidize-rb/rb-sys) - Rust extensions for Ruby
|
data/lib/y/3.4/y_ruby.so
ADDED
|
Binary file
|
data/lib/y/4.0/y_ruby.so
ADDED
|
Binary file
|
data/lib/y/decoder.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "y"
|
|
4
|
+
require "y/decoder/version"
|
|
5
|
+
|
|
6
|
+
module Y
|
|
7
|
+
# Plain-text reconstruction of a stored Yjs document, in pure Ruby — for search
|
|
8
|
+
# indexing and previews. The core `yrby` gem moves and stores opaque CRDT
|
|
9
|
+
# updates without reading them; this reads the text out of the shared type the
|
|
10
|
+
# editor uses (Lexical's `Y.XmlText`, plain `Y.Text`, or ProseMirror's
|
|
11
|
+
# `Y.XmlFragment`), in-process, on the native extension core already ships — no
|
|
12
|
+
# Node, no subprocess, no binary.
|
|
13
|
+
#
|
|
14
|
+
# state = doc.encode_state_as_update # opaque CRDT bytes from the store
|
|
15
|
+
# Y::Decoder.text(state) # => "hello world"
|
|
16
|
+
# Y::Decoder.preview(state, 280) # => "hello world…"
|
|
17
|
+
#
|
|
18
|
+
# Full-fidelity reconstruction (the exact Lexical EditorState / HTML, which
|
|
19
|
+
# needs @lexical/yjs) is a separate, opt-in concern — see the `yrby-decode`
|
|
20
|
+
# package's Bun binary. This gem stays pure Ruby on purpose.
|
|
21
|
+
module Decoder
|
|
22
|
+
class Error < Y::Error; end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Plain text of the document. `field` pins the root key (Lexical: the editor
|
|
27
|
+
# id; ProseMirror: "default"); omit it to use the document's sole root.
|
|
28
|
+
def text(state, field: nil)
|
|
29
|
+
field ||= Y::Doc.new.tap { |d| d.apply_update(state) }.root_names.first
|
|
30
|
+
return "" unless field
|
|
31
|
+
|
|
32
|
+
# A plain `Y.Text` root (a simple shared-text editor) reads straight out.
|
|
33
|
+
# (A yrs root's type is fixed by its first typed access, so each reader
|
|
34
|
+
# gets a fresh doc to try a different shared type against the same state.)
|
|
35
|
+
direct = load(state).read_text(field)
|
|
36
|
+
return normalize(direct) if direct && !direct.strip.empty?
|
|
37
|
+
|
|
38
|
+
# Lexical (each block a sibling `Y.XmlText`) and ProseMirror (blocks are
|
|
39
|
+
# `Y.XmlElement`s) both come back from read_xml as block-per-line markup;
|
|
40
|
+
# strip any element tags to plain text.
|
|
41
|
+
markup = load(state).read_xml(field)
|
|
42
|
+
markup ? normalize(strip_tags(markup)) : ""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A compact, single-line preview for list UIs.
|
|
46
|
+
def preview(state, limit: 280, field: nil)
|
|
47
|
+
body = text(state, field: field).gsub(/\s+/, " ").strip
|
|
48
|
+
body.length > limit ? "#{body[0, limit].rstrip}…" : body
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load(state)
|
|
52
|
+
Y::Doc.new.tap { |doc| doc.apply_update(state) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def strip_tags(markup)
|
|
56
|
+
markup.gsub(/<[^>]*>/, " ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize(text)
|
|
60
|
+
text.gsub(/[ \t]+/, " ") # collapse runs of spaces/tabs
|
|
61
|
+
.gsub(/ *\n */, "\n") # trim spaces left around block separators
|
|
62
|
+
.gsub(/\n{3,}/, "\n\n") # cap blank-line runs
|
|
63
|
+
.strip
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/y/version.rb
ADDED
data/lib/y.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "y/version"
|
|
4
|
+
|
|
5
|
+
# Load the native extension. Precompiled gems ship it in a per-Ruby-version
|
|
6
|
+
# subdir (lib/y/<major.minor>/y_ruby.<ext>); a source build puts it flat at
|
|
7
|
+
# lib/y/y_ruby.<ext>. Try the versioned path first, fall back.
|
|
8
|
+
begin
|
|
9
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
10
|
+
require_relative "y/#{Regexp.last_match(1)}/y_ruby"
|
|
11
|
+
rescue LoadError
|
|
12
|
+
require_relative "y/y_ruby"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Y
|
|
16
|
+
# Doc, Error, and the protocol module functions are defined in the Rust
|
|
17
|
+
# extension. The ActionCable integration (Y::ActionCable::Sync) lives in the
|
|
18
|
+
# separate `yrby-actioncable` gem; require "y/action_cable".
|
|
19
|
+
end
|
data/lib/yrby-decoder.rb
ADDED
data/lib/yrby.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yrby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: x64-mingw-ucrt
|
|
6
|
+
authors:
|
|
7
|
+
- JP Camara
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake-compiler
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.2'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.2'
|
|
55
|
+
description: 'yrby is a thread-safe Ruby binding over the Rust y-crdt (yrs) library:
|
|
56
|
+
CRDT documents, awareness/presence, and the y-websocket sync protocol primitives,
|
|
57
|
+
with the GVL released during native work so documents sync in parallel. The ActionCable/Rails
|
|
58
|
+
integration lives in the companion yrby-actioncable gem.'
|
|
59
|
+
email:
|
|
60
|
+
- johnpcamara@gmail.com
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- LICENSE
|
|
67
|
+
- README.md
|
|
68
|
+
- lib/y.rb
|
|
69
|
+
- lib/y/3.4/y_ruby.so
|
|
70
|
+
- lib/y/4.0/y_ruby.so
|
|
71
|
+
- lib/y/decoder.rb
|
|
72
|
+
- lib/y/decoder/version.rb
|
|
73
|
+
- lib/y/version.rb
|
|
74
|
+
- lib/yrby-decoder.rb
|
|
75
|
+
- lib/yrby.rb
|
|
76
|
+
homepage: https://github.com/jpcamara/yrby
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
source_code_uri: https://github.com/jpcamara/yrby
|
|
81
|
+
changelog_uri: https://github.com/jpcamara/yrby/blob/main/CHANGELOG.md
|
|
82
|
+
bug_tracker_uri: https://github.com/jpcamara/yrby/issues
|
|
83
|
+
rubygems_mfa_required: 'true'
|
|
84
|
+
post_install_message:
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '3.4'
|
|
93
|
+
- - "<"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 4.1.dev
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.5.23
|
|
103
|
+
signing_key:
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: 'Thread-safe Ruby bindings for y-crdt (Y.js): documents, awareness, and the
|
|
106
|
+
y-websocket sync protocol'
|
|
107
|
+
test_files: []
|