yrby 0.2.0-arm64-darwin

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7e301d4bf70b08390eba5ac7edc02781c47edf98b688725fa639db1ebee99e51
4
+ data.tar.gz: b23e2aba79d1f3609f2421a82757359f636b6e376764891f5349cec862eef97e
5
+ SHA512:
6
+ metadata.gz: '08444d03a50bddfa51cde2987c750961eb26e6b226d5194608a92413de3abcd7ffe596814a81e495f667ab9addcff6d5fe5eba90f63dbbc85a78b78c5ba3f1ed'
7
+ data.tar.gz: d0eda3683c1c6a61f152e109ad90eb5e9b1beb64cf6677d099b408512bde8e9627f6efb3ef8e93e4c51c32d94c6d91c04c503fd649f527b8169e8a77687b6a39
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
+ [![CI](https://github.com/jpcamara/yrby/actions/workflows/ci.yml/badge.svg)](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
Binary file
Binary file
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ module Decoder
5
+ VERSION = "0.1.0.alpha1"
6
+ end
7
+ end
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Y
4
+ VERSION = "0.2.0"
5
+ end
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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
+ require "y/decoder"
data/lib/yrby.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `Bundler.require` loads it automatically.
4
+ require "y"
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: arm64-darwin
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.bundle
70
+ - lib/y/4.0/y_ruby.bundle
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: []