mechanomeld 0.1.0 → 0.1.2

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: dc3b7a58de04a5445e59c398a68556fddb11bd3ec013451733e9a962e6121608
4
- data.tar.gz: 29c2f1e1c0eb082b0416994131c78073e3a93c8ffdbe122e3af96887e3712bb2
3
+ metadata.gz: 17ff6a2f602dc4e7a849a437f26c3d67194da8508f17b093bb8b807b9832ad5e
4
+ data.tar.gz: 45cb8059e7b6f004f4ae95db489cd203b1d97ba35840ae559a7185c8e493ca5c
5
5
  SHA512:
6
- metadata.gz: 16f9c6cd1505ce2931ac90f75ac9eb391eea8a2c7be2d7e6e6fcb513819e4c2eebfa849ce02cbcd96c8b833caab3d73d5eeb151e6087739d47aee5ab9e14c6aa
7
- data.tar.gz: 3d0c3dbbbd11ae5733fcb260548c2aa2eb03ce399970eacffc8571fa111e014e6471618737704914c2162def47ec4c204b3ec4f62158ad1581751e86ad525f07
6
+ metadata.gz: 7e7b6c9ad63929ec97f7513fd556d8a8860de99530fd67db5055bc169543dc9ae297a5998c325e9bee3f40a0d20eb4f92310fabf2b072e0c8b4a1723a5e35bbe
7
+ data.tar.gz: d8a0eb87b94e31175ce1e409e6535a33825bd3789f4c4233706f9eed0e5a34ddefa52813aa1053e00af80f087161f58f30fe2f453f58cc1c4faab957ae54d649
data/CHANGELOG.md CHANGED
@@ -5,17 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [0.1.0] - 2026-09-17
8
+ ## [0.1.2] - 2026-09-28
9
9
 
10
10
  ### Added
11
11
 
12
- - Initial release
13
- - Mechanomeld::Counter, Timestamp, Uint, Bytes, and Text value classes (6df6951)
14
- - Mechanomeld::Document.load reads documents saved by JavaScript Automerge (c7d31d8)
15
- - Document#get, #[], and #to_h read values by path (c7d31d8)
16
- - Document#keys and #length for maps, lists, and text (1d80c0b)
17
- - Mechanomeld::Document.new(actor_id:) creates an empty document (bccb69c)
18
- - Document#put, #[]=, #delete, and #save (bccb69c)
19
- - Document#commit(message:, timestamp:) and #rollback (a8ef491)
20
- - Document#change commits a block and rolls back if it raises (a8ef491)
21
- - Mechanomeld::Document.from builds a committed document from a Hash (a8ef491)
12
+ - Document#diff(from_heads, to_heads = heads) returns Automerge's patches between two versions of a document, as Hashes (9c9bf6d)
13
+
14
+ ## [0.1.1] - 2026-09-25
15
+
16
+ ### Added
17
+
18
+ - Document#load_incremental applies saved Automerge bytes into an existing document (d60a3eb)
19
+ - Document.load_repo reads a document from automerge-repo's NodeFSStorageAdapter directory (d60a3eb)
20
+ - Document#heads returns the document's change hashes as hex, matching JavaScript's Automerge.getHeads (d60a3eb)
21
+ - Document#includes_heads?(heads) reports whether a document is at or past a set of heads (d60a3eb)
22
+ - Mechanomeld::SyncState, the per-peer sync state, with encode and decode for persistence (eea3a51)
23
+ - Document#generate_sync_message and #receive_sync_message, Automerge's per-document sync protocol (eea3a51)
24
+
25
+ ### Changed
26
+
27
+ - releases also publish an x86_64-linux-musl platform gem (159dd59)
data/README.md CHANGED
@@ -28,6 +28,78 @@ end
28
28
  File.binwrite("groceries.automerge", doc.save)
29
29
  ```
30
30
 
31
+ ### Documents stored by automerge-repo
32
+
33
+ `Document.load_repo` reads a document straight out of the directory written by `@automerge/automerge-repo`'s `NodeFSStorageAdapter`, applying its snapshot and incremental chunks and skipping its sync-state files.
34
+
35
+ ```ruby
36
+ doc = Mechanomeld::Document.load_repo("automerge-repo-data", "4NtRZtd6yUtzf8qmEpKitAaW4JRp")
37
+ ```
38
+
39
+ `Document#load_incremental(bytes)` is the piece underneath: it applies any saved Automerge bytes (a full save, a snapshot chunk, or an incremental change chunk) into an existing document and returns the document.
40
+
41
+ ### Heads
42
+
43
+ `Document#heads` is the document's version: the change hashes JavaScript's `Automerge.getHeads(doc)` returns, as lowercase hex, so the two sides compare with `==`. `Document#includes_heads?(heads)` is true when every hash in `heads` is a change the document already contains, so `heads` is the same version or an older one.
44
+
45
+ ```ruby
46
+ doc.heads # => ["9d434616e7af865757bad75cdebe4151bccf7e29214a71f2a65150846c2a4275"]
47
+ doc.includes_heads?(heads) # => true when the document is at or past `heads`
48
+ ```
49
+
50
+ Both take hex. automerge-repo's `handle.heads()` returns base58 URL heads, which are not accepted; send `Automerge.getHeads(handle.doc())` from JavaScript instead. A hash that is not 32 bytes of hex raises `Mechanomeld::Error`; a well-formed hash the document does not have is simply not included. Like `save`, both commit any pending change first.
51
+
52
+ ### What changed between two versions
53
+
54
+ `Document#diff(from_heads, to_heads = doc.heads)` is Automerge's diff: the patches that take the document from one version to another, as Hashes with String keys. Both arguments are hex heads as `Document#heads` returns them; `to_heads` defaults to the current heads, and `[]` as `from_heads` diffs from the empty document.
55
+
56
+ ```ruby
57
+ before = doc.heads
58
+ doc.change { |d| d[["scores", "bravo"]] = 7 }
59
+ doc.diff(before)
60
+ # => [{"action" => "put", "path" => ["scores", "bravo"], "value" => 7, "conflict" => false}]
61
+ ```
62
+
63
+ Every patch has `"action"` and `"path"`. The path addresses the property the patch touches from the root, map keys as Strings and list indexes as Integers, as JavaScript's `Automerge.diff` reports it. The other keys depend on the action:
64
+
65
+ | action | keys |
66
+ |---|---|
67
+ | `put` | `value`, `conflict` (true when this value won a concurrent write) |
68
+ | `delete` | `index` and `length` when deleting from a list; nothing more for a map key |
69
+ | `insert` | `values`, the elements inserted at `path`'s index, in order |
70
+ | `splice_text` | `value`, the String spliced into a `Text` at `path`'s index |
71
+ | `increment` | `value`, the amount a `Counter` changed by, which may be negative |
72
+ | `conflict` | nothing more: a concurrent write now conflicts at `path` |
73
+ | `mark` | `marks`, each `{"name", "value", "start", "end"}` on a `Text` |
74
+
75
+ Putting or inserting a map, list, or `Text` yields the empty container (`{}`, `[]`, or `Mechanomeld::Text.new("")`); its contents follow as their own patches. Values use the same Ruby types as `get`. A head that is not hex, or that names a change the document does not contain, raises `Mechanomeld::Error`. Like `save`, `diff` commits any pending change first.
76
+
77
+ ### Syncing with a peer
78
+
79
+ `Document#generate_sync_message` and `Document#receive_sync_message` run Automerge's per-document sync protocol, so a Ruby document can exchange just the changes each side lacks with another Automerge peer: JavaScript's `Automerge.generateSyncMessage` / `receiveSyncMessage`, or another Ruby document. A `Mechanomeld::SyncState` tracks what one peer is known to have; keep one per peer for each document.
80
+
81
+ ```ruby
82
+ left = Mechanomeld::Document.load(bytes)
83
+ right = Mechanomeld::Document.load(bytes)
84
+ left.change { |d| d["left"] = 1 }
85
+ right.change { |d| d["right"] = 2 }
86
+
87
+ left_state = Mechanomeld::SyncState.new # left's view of right
88
+ right_state = Mechanomeld::SyncState.new # right's view of left
89
+ loop do
90
+ to_right = left.generate_sync_message(left_state)
91
+ right.receive_sync_message(right_state, to_right) if to_right
92
+ to_left = right.generate_sync_message(right_state)
93
+ left.receive_sync_message(left_state, to_left) if to_left
94
+ break if to_right.nil? && to_left.nil?
95
+ end
96
+ left.heads == right.heads # => true
97
+ ```
98
+
99
+ Messages are binary Strings. `generate_sync_message` returns nil when the peer is up to date or the last message is still unanswered. `SyncState#encode` and `SyncState.decode(bytes)` carry a state across connections, keeping only the heads both sides are known to share (as JavaScript's `encodeSyncState` does), so a process that handles one message per request can decode, receive, generate, and encode again. Like `save`, both sync methods commit any pending change first. Bytes that are not a sync message or a sync state raise `Mechanomeld::Error`. The protocol carries no document identity: a message from a peer holding a different document merges that document in.
100
+
101
+ This is the per-document protocol only. An automerge-repo sync server wraps these messages in its own envelope (peer and document ids, join and leave messages, CBOR encoding), which this gem does not provide.
102
+
31
103
  ### Values
32
104
 
33
105
  | Automerge | Ruby |
@@ -54,7 +126,7 @@ mise install
54
126
  mise run setup # bundle install, npm ci for fixtures
55
127
  mise run test # compile the extension and run the tests
56
128
  mise run test:interop # check JavaScript reads Ruby-written documents
57
- mise run fixtures # regenerate test/fixtures/*.automerge
129
+ mise run fixtures # regenerate test/fixtures/*.automerge, heads.json, and repo/
58
130
  ```
59
131
 
60
132
  Check packaging with `gem build mechanomeld.gemspec`. Do not run `rake build` or `rake release` locally: reissue bumps the version and commits during `build`.
@@ -0,0 +1,100 @@
1
+ //! Converting automerge patches into Ruby hashes.
2
+ //!
3
+ //! Every patch becomes `{"action" => ..., "path" => [...]}` plus the fields that
4
+ //! action carries. `path` addresses the property the patch touches from the root, as
5
+ //! JavaScript's `Automerge.diff` reports it. A put or insert of a map, list, or text
6
+ //! carries the empty container; the children arrive as their own patches.
7
+
8
+ use automerge::{marks::Mark, ObjType, Patch, PatchAction, Prop, Value as AmValue};
9
+ use magnus::{prelude::*, Error, RHash, Ruby, Value};
10
+
11
+ use crate::classes::TEXT;
12
+ use crate::errors::error;
13
+ use crate::read;
14
+
15
+ pub fn patch(ruby: &Ruby, patch: &Patch) -> Result<RHash, Error> {
16
+ let (action, last) = match &patch.action {
17
+ PatchAction::PutMap { key, .. } => ("put", Some(Prop::Map(key.clone()))),
18
+ PatchAction::PutSeq { index, .. } => ("put", Some(Prop::Seq(*index))),
19
+ PatchAction::Insert { index, .. } => ("insert", Some(Prop::Seq(*index))),
20
+ PatchAction::SpliceText { index, .. } => ("splice_text", Some(Prop::Seq(*index))),
21
+ PatchAction::Increment { prop, .. } => ("increment", Some(prop.clone())),
22
+ PatchAction::Conflict { prop } => ("conflict", Some(prop.clone())),
23
+ PatchAction::DeleteMap { key } => ("delete", Some(Prop::Map(key.clone()))),
24
+ PatchAction::DeleteSeq { index, .. } => ("delete", Some(Prop::Seq(*index))),
25
+ PatchAction::Mark { .. } => ("mark", None),
26
+ };
27
+ let path = ruby.ary_new_capa(patch.path.len() + 1);
28
+ for (_, prop) in &patch.path {
29
+ path.push(prop_value(ruby, prop))?;
30
+ }
31
+ if let Some(prop) = &last {
32
+ path.push(prop_value(ruby, prop))?;
33
+ }
34
+ let hash = ruby.hash_new();
35
+ hash.aset("action", action)?;
36
+ hash.aset("path", path)?;
37
+ match &patch.action {
38
+ PatchAction::PutMap {
39
+ value, conflict, ..
40
+ }
41
+ | PatchAction::PutSeq {
42
+ value, conflict, ..
43
+ } => {
44
+ hash.aset("value", self::value(ruby, &value.0)?)?;
45
+ hash.aset("conflict", *conflict)?;
46
+ }
47
+ PatchAction::Insert { values, .. } => {
48
+ let array = ruby.ary_new_capa(values.len());
49
+ for (item, _, _) in values.iter() {
50
+ array.push(self::value(ruby, item)?)?;
51
+ }
52
+ hash.aset("values", array)?;
53
+ }
54
+ PatchAction::SpliceText { value, .. } => {
55
+ hash.aset("value", ruby.str_new(&value.make_string()))?;
56
+ }
57
+ PatchAction::Increment { value, .. } => {
58
+ hash.aset("value", *value)?;
59
+ }
60
+ PatchAction::DeleteSeq { index, length } => {
61
+ hash.aset("index", *index)?;
62
+ hash.aset("length", *length)?;
63
+ }
64
+ PatchAction::Mark { marks } => {
65
+ let array = ruby.ary_new_capa(marks.len());
66
+ for mark in marks {
67
+ array.push(self::mark(ruby, mark)?)?;
68
+ }
69
+ hash.aset("marks", array)?;
70
+ }
71
+ PatchAction::Conflict { .. } | PatchAction::DeleteMap { .. } => {}
72
+ }
73
+ Ok(hash)
74
+ }
75
+
76
+ fn prop_value(ruby: &Ruby, prop: &Prop) -> Value {
77
+ match prop {
78
+ Prop::Map(key) => ruby.str_new(key).as_value(),
79
+ Prop::Seq(index) => ruby.integer_from_u64(*index as u64).as_value(),
80
+ }
81
+ }
82
+
83
+ fn value(ruby: &Ruby, value: &AmValue<'_>) -> Result<Value, Error> {
84
+ match value {
85
+ AmValue::Scalar(scalar) => read::scalar(ruby, scalar),
86
+ AmValue::Object(ObjType::Map) => Ok(ruby.hash_new().as_value()),
87
+ AmValue::Object(ObjType::List) => Ok(ruby.ary_new().as_value()),
88
+ AmValue::Object(ObjType::Text) => read::wrap(ruby, &TEXT, ruby.str_new("")),
89
+ AmValue::Object(ObjType::Table) => Err(error(ruby, "Automerge tables are not supported")),
90
+ }
91
+ }
92
+
93
+ fn mark(ruby: &Ruby, mark: &Mark) -> Result<RHash, Error> {
94
+ let hash = ruby.hash_new();
95
+ hash.aset("name", mark.name.as_str())?;
96
+ hash.aset("value", read::scalar(ruby, &mark.value)?)?;
97
+ hash.aset("start", mark.start)?;
98
+ hash.aset("end", mark.end)?;
99
+ Ok(hash)
100
+ }
@@ -2,8 +2,9 @@ use std::cell::{Ref, RefCell, RefMut};
2
2
  use std::str::FromStr;
3
3
 
4
4
  use automerge::{
5
+ sync::{Message, SyncDoc},
5
6
  transaction::{CommitOptions, Transactable},
6
- ActorId, AutoCommit, LoadOptions, ObjType, ReadDoc, TextEncoding, ROOT,
7
+ ActorId, AutoCommit, ChangeHash, LoadOptions, ObjType, ReadDoc, TextEncoding, ROOT,
7
8
  };
8
9
  use magnus::{
9
10
  prelude::*,
@@ -13,7 +14,8 @@ use magnus::{
13
14
  };
14
15
 
15
16
  use crate::errors::{arg_error, automerge_error, error};
16
- use crate::{path, read, write};
17
+ use crate::sync_state::SyncState;
18
+ use crate::{diff, path, read, write};
17
19
 
18
20
  /// Text indexes count Unicode code points, matching Ruby's `String#length`.
19
21
  const ENCODING: TextEncoding = TextEncoding::UnicodeCodePoint;
@@ -62,6 +64,21 @@ impl Document {
62
64
  })
63
65
  }
64
66
 
67
+ /// `doc.load_incremental(bytes)`: applies a full save, snapshot, or incremental
68
+ /// change chunk into this document.
69
+ pub fn load_incremental(
70
+ ruby: &Ruby,
71
+ rb_self: Obj<Self>,
72
+ bytes: RString,
73
+ ) -> Result<Obj<Self>, Error> {
74
+ let data = unsafe { bytes.as_slice() }.to_vec();
75
+ rb_self
76
+ .doc_mut(ruby)?
77
+ .load_incremental(&data)
78
+ .map_err(|e| error(ruby, format!("could not load Automerge document: {e}")))?;
79
+ Ok(rb_self)
80
+ }
81
+
65
82
  /// `doc.get(path)`: the value at `path`, or nil when anything along it is missing.
66
83
  pub fn get(ruby: &Ruby, rb_self: &Self, path: Value) -> Result<Value, Error> {
67
84
  let segments = path::segments(path)?;
@@ -168,6 +185,102 @@ impl Document {
168
185
  let bytes = rb_self.doc_mut(ruby)?.save();
169
186
  Ok(ruby.str_from_slice(&bytes))
170
187
  }
188
+
189
+ /// `doc.heads`: the current heads as lowercase hex change hashes, the format
190
+ /// JavaScript's `Automerge.getHeads` returns.
191
+ pub fn heads(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
192
+ let heads = rb_self.doc_mut(ruby)?.get_heads();
193
+ Ok(ruby.ary_from_iter(heads.iter().map(ChangeHash::to_string)))
194
+ }
195
+
196
+ /// `doc.includes_heads?(heads)`: whether every hex change hash in `heads` is a
197
+ /// change this document already contains.
198
+ pub fn includes_heads(ruby: &Ruby, rb_self: &Self, heads: Vec<String>) -> Result<bool, Error> {
199
+ let hashes = heads
200
+ .iter()
201
+ .map(|hex| change_hash(ruby, hex))
202
+ .collect::<Result<Vec<_>, _>>()?;
203
+ let mut doc = rb_self.doc_mut(ruby)?;
204
+ Ok(hashes
205
+ .iter()
206
+ .all(|hash| doc.get_change_meta_by_hash(hash).is_some()))
207
+ }
208
+
209
+ /// `doc.diff(from_heads, to_heads = heads)`: the patches that take the document from
210
+ /// `from_heads` to `to_heads`, as Hashes (see diff.rs for their shape). `[]` as
211
+ /// `from_heads` diffs from the empty document. Every head must be a change this
212
+ /// document contains.
213
+ pub fn diff(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<RArray, Error> {
214
+ let args = scan_args::<(Vec<String>,), (Option<Vec<String>>,), (), (), (), ()>(args)?;
215
+ let mut doc = rb_self.doc_mut(ruby)?;
216
+ let from = known_heads(ruby, &mut doc, &args.required.0)?;
217
+ let to = match args.optional.0 {
218
+ Some(heads) => known_heads(ruby, &mut doc, &heads)?,
219
+ None => doc.get_heads(),
220
+ };
221
+ let patches = doc.diff(&from, &to);
222
+ let array = ruby.ary_new_capa(patches.len());
223
+ for patch in &patches {
224
+ array.push(diff::patch(ruby, patch)?)?;
225
+ }
226
+ Ok(array)
227
+ }
228
+
229
+ /// `doc.generate_sync_message(sync_state)`: the next binary message for the peer
230
+ /// `sync_state` tracks, or nil when there is nothing to send.
231
+ pub fn generate_sync_message(
232
+ ruby: &Ruby,
233
+ rb_self: &Self,
234
+ sync_state: &SyncState,
235
+ ) -> Result<Option<RString>, Error> {
236
+ let mut doc = rb_self.doc_mut(ruby)?;
237
+ let mut state = sync_state.state_mut(ruby)?;
238
+ let message = doc.sync().generate_sync_message(&mut state);
239
+ Ok(message.map(|message| ruby.str_from_slice(&message.encode())))
240
+ }
241
+
242
+ /// `doc.receive_sync_message(sync_state, bytes)`: applies a peer's message to this
243
+ /// document and to `sync_state`.
244
+ pub fn receive_sync_message(
245
+ ruby: &Ruby,
246
+ rb_self: Obj<Self>,
247
+ sync_state: &SyncState,
248
+ bytes: RString,
249
+ ) -> Result<Obj<Self>, Error> {
250
+ let data = unsafe { bytes.as_slice() }.to_vec();
251
+ let message = Message::decode(&data)
252
+ .map_err(|e| error(ruby, format!("could not decode sync message: {e}")))?;
253
+ {
254
+ let mut doc = rb_self.doc_mut(ruby)?;
255
+ let mut state = sync_state.state_mut(ruby)?;
256
+ doc.sync()
257
+ .receive_sync_message(&mut state, message)
258
+ .map_err(|e| automerge_error(ruby, e))?;
259
+ }
260
+ Ok(rb_self)
261
+ }
262
+ }
263
+
264
+ fn change_hash(ruby: &Ruby, hex: &str) -> Result<ChangeHash, Error> {
265
+ ChangeHash::from_str(hex).map_err(|e| error(ruby, format!("invalid change hash {hex:?}: {e}")))
266
+ }
267
+
268
+ /// Parses hex change hashes, rejecting any change `doc` does not contain.
269
+ fn known_heads(
270
+ ruby: &Ruby,
271
+ doc: &mut AutoCommit,
272
+ heads: &[String],
273
+ ) -> Result<Vec<ChangeHash>, Error> {
274
+ heads
275
+ .iter()
276
+ .map(|hex| {
277
+ let hash = change_hash(ruby, hex)?;
278
+ if doc.get_change_meta_by_hash(&hash).is_none() {
279
+ return Err(error(ruby, format!("unknown change hash {hex:?}")));
280
+ }
281
+ Ok(hash)
282
+ })
283
+ .collect()
171
284
  }
172
285
 
173
286
  fn optional_path(ruby: &Ruby, args: &[Value]) -> Result<Vec<Value>, Error> {
@@ -1,13 +1,16 @@
1
1
  mod classes;
2
+ mod diff;
2
3
  mod document;
3
4
  mod errors;
4
5
  mod path;
5
6
  mod read;
7
+ mod sync_state;
6
8
  mod write;
7
9
 
8
10
  use magnus::{function, method, prelude::*, Error, Ruby};
9
11
 
10
12
  use crate::document::Document;
13
+ use crate::sync_state::SyncState;
11
14
 
12
15
  #[magnus::init]
13
16
  fn init(ruby: &Ruby) -> Result<(), Error> {
@@ -15,6 +18,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
15
18
  let class = module.define_class("Document", ruby.class_object())?;
16
19
  class.define_singleton_method("new", function!(Document::new, -1))?;
17
20
  class.define_singleton_method("load", function!(Document::load, 1))?;
21
+ class.define_method("load_incremental", method!(Document::load_incremental, 1))?;
18
22
  class.define_method("get", method!(Document::get, 1))?;
19
23
  class.define_method("keys", method!(Document::keys, -1))?;
20
24
  class.define_method("length", method!(Document::length, -1))?;
@@ -23,5 +27,20 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
23
27
  class.define_method("commit", method!(Document::commit, -1))?;
24
28
  class.define_method("rollback", method!(Document::rollback, 0))?;
25
29
  class.define_method("save", method!(Document::save, 0))?;
30
+ class.define_method("heads", method!(Document::heads, 0))?;
31
+ class.define_method("includes_heads?", method!(Document::includes_heads, 1))?;
32
+ class.define_method("diff", method!(Document::diff, -1))?;
33
+ class.define_method(
34
+ "generate_sync_message",
35
+ method!(Document::generate_sync_message, 1),
36
+ )?;
37
+ class.define_method(
38
+ "receive_sync_message",
39
+ method!(Document::receive_sync_message, 2),
40
+ )?;
41
+ let sync_state = module.define_class("SyncState", ruby.class_object())?;
42
+ sync_state.define_singleton_method("new", function!(SyncState::new, 0))?;
43
+ sync_state.define_singleton_method("decode", function!(SyncState::decode, 1))?;
44
+ sync_state.define_method("encode", method!(SyncState::encode, 0))?;
26
45
  Ok(())
27
46
  }
@@ -72,7 +72,11 @@ pub fn scalar(ruby: &Ruby, scalar: &ScalarValue) -> Result<Value, Error> {
72
72
  }
73
73
 
74
74
  /// Builds a scalar wrapper by allocating it and setting `@value`, without running Ruby code.
75
- fn wrap(ruby: &Ruby, class: &'static Lazy<RClass>, value: impl IntoValue) -> Result<Value, Error> {
75
+ pub fn wrap(
76
+ ruby: &Ruby,
77
+ class: &'static Lazy<RClass>,
78
+ value: impl IntoValue,
79
+ ) -> Result<Value, Error> {
76
80
  let instance = ruby.get_inner(class).obj_alloc()?;
77
81
  let object = RObject::try_convert(instance.as_value())?;
78
82
  object.ivar_set("@value", value)?;
@@ -0,0 +1,49 @@
1
+ use std::cell::{Ref, RefCell, RefMut};
2
+
3
+ use automerge::sync::State;
4
+ use magnus::{Error, RString, Ruby};
5
+
6
+ use crate::errors::error;
7
+
8
+ /// What a document knows about one peer while syncing with it.
9
+ #[magnus::wrap(class = "Mechanomeld::SyncState", free_immediately, size)]
10
+ pub struct SyncState {
11
+ inner: RefCell<State>,
12
+ }
13
+
14
+ impl SyncState {
15
+ fn state(&self, ruby: &Ruby) -> Result<Ref<'_, State>, Error> {
16
+ self.inner
17
+ .try_borrow()
18
+ .map_err(|_| error(ruby, "sync state is being modified"))
19
+ }
20
+
21
+ pub fn state_mut(&self, ruby: &Ruby) -> Result<RefMut<'_, State>, Error> {
22
+ self.inner
23
+ .try_borrow_mut()
24
+ .map_err(|_| error(ruby, "sync state is already in use"))
25
+ }
26
+
27
+ /// `SyncState.new`: a fresh state for a peer this document has not synced with.
28
+ pub fn new() -> Self {
29
+ Self {
30
+ inner: RefCell::new(State::new()),
31
+ }
32
+ }
33
+
34
+ /// `SyncState.decode(bytes)`
35
+ pub fn decode(ruby: &Ruby, bytes: RString) -> Result<Self, Error> {
36
+ let data = unsafe { bytes.as_slice() }.to_vec();
37
+ let state = State::decode(&data)
38
+ .map_err(|e| error(ruby, format!("could not decode sync state: {e}")))?;
39
+ Ok(Self {
40
+ inner: RefCell::new(state),
41
+ })
42
+ }
43
+
44
+ /// `sync_state.encode`: the state as a binary String, keeping only what is worth
45
+ /// reusing on a later connection with the same peer.
46
+ pub fn encode(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
47
+ Ok(ruby.str_from_slice(&rb_self.state(ruby)?.encode()))
48
+ }
49
+ }
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mechanomeld
4
- # Native methods (new, load, get, keys, length, put, delete, commit, rollback, save)
5
- # are defined in ext/mechanomeld/src/document.rs.
4
+ # Native methods (new, load, load_incremental, get, keys, length, put, delete, commit,
5
+ # rollback, save, heads, includes_heads?, diff, generate_sync_message,
6
+ # receive_sync_message) are defined in ext/mechanomeld/src/document.rs.
6
7
  class Document
7
8
  def self.from(hash, actor_id: nil)
8
9
  raise ArgumentError, "Automerge document root must be a Hash" unless hash.is_a?(Hash)
@@ -13,6 +14,19 @@ module Mechanomeld
13
14
  doc
14
15
  end
15
16
 
17
+ # Loads a document from the directory written by @automerge/automerge-repo's
18
+ # NodeFSStorageAdapter. Snapshot chunks are applied before incremental ones, as
19
+ # automerge-repo does; its sync-state files are not document data and are skipped.
20
+ def self.load_repo(base_dir, document_id)
21
+ document_dir = File.join(base_dir, document_id[0, 2], document_id[2..])
22
+ chunks = %w[snapshot incremental].flat_map do |kind|
23
+ Dir.glob(File.join(document_dir, kind, "*")).sort.select { |path| File.file?(path) }
24
+ end
25
+ raise Error, "no chunks for document #{document_id} in #{base_dir}" if chunks.empty?
26
+
27
+ chunks.each_with_object(new) { |path, doc| doc.load_incremental(File.binread(path)) }
28
+ end
29
+
16
30
  def [](path)
17
31
  get(path)
18
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mechanomeld
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mechanomeld
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -41,11 +41,13 @@ files:
41
41
  - ext/mechanomeld/Cargo.toml
42
42
  - ext/mechanomeld/extconf.rb
43
43
  - ext/mechanomeld/src/classes.rs
44
+ - ext/mechanomeld/src/diff.rs
44
45
  - ext/mechanomeld/src/document.rs
45
46
  - ext/mechanomeld/src/errors.rs
46
47
  - ext/mechanomeld/src/lib.rs
47
48
  - ext/mechanomeld/src/path.rs
48
49
  - ext/mechanomeld/src/read.rs
50
+ - ext/mechanomeld/src/sync_state.rs
49
51
  - ext/mechanomeld/src/write.rs
50
52
  - lib/mechanomeld.rb
51
53
  - lib/mechanomeld/document.rb
@@ -74,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
76
  - !ruby/object:Gem::Version
75
77
  version: '0'
76
78
  requirements: []
77
- rubygems_version: 4.0.16
79
+ rubygems_version: 4.0.20
78
80
  specification_version: 4
79
81
  summary: Read and write Automerge documents from Ruby
80
82
  test_files: []