mechanomeld 0.1.0 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +48 -1
- data/ext/mechanomeld/src/document.rs +76 -1
- data/ext/mechanomeld/src/lib.rs +17 -0
- data/ext/mechanomeld/src/sync_state.rs +49 -0
- data/lib/mechanomeld/document.rb +15 -1
- data/lib/mechanomeld/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e71318030a041eeb04f082e8ce7d096c015e7462e4273d244026331d79e1c2d
|
|
4
|
+
data.tar.gz: f66fecdc446f07fdea887099626204f446314353ad77b61602902e491cbf9d9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 909d370afa1a826a03f1d7afe2b1fcdc1f639f29d50cb6f50cb532822ae47df2b18d59c7849fb960b5ce05532312ac3a20c809ca3cf0738ab0ff067a4a5571df
|
|
7
|
+
data.tar.gz: 41686d01f296e39ffb334f94b33a08cd887f201439fd6ce5e907e1f3eb666ec5d14b0576807030e364db49be2bc283d3bd2c36a364709326c9f77d40f2e243ee
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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.1] - 2026-09-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Document#load_incremental applies saved Automerge bytes into an existing document (d60a3eb)
|
|
13
|
+
- Document.load_repo reads a document from automerge-repo's NodeFSStorageAdapter directory (d60a3eb)
|
|
14
|
+
- Document#heads returns the document's change hashes as hex, matching JavaScript's Automerge.getHeads (d60a3eb)
|
|
15
|
+
- Document#includes_heads?(heads) reports whether a document is at or past a set of heads (d60a3eb)
|
|
16
|
+
- Mechanomeld::SyncState, the per-peer sync state, with encode and decode for persistence (eea3a51)
|
|
17
|
+
- Document#generate_sync_message and #receive_sync_message, Automerge's per-document sync protocol (eea3a51)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- releases also publish an x86_64-linux-musl platform gem (159dd59)
|
|
22
|
+
|
|
8
23
|
## [0.1.0] - 2026-09-17
|
|
9
24
|
|
|
10
25
|
### Added
|
data/README.md
CHANGED
|
@@ -28,6 +28,53 @@ 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
|
+
### Syncing with a peer
|
|
53
|
+
|
|
54
|
+
`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.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
left = Mechanomeld::Document.load(bytes)
|
|
58
|
+
right = Mechanomeld::Document.load(bytes)
|
|
59
|
+
left.change { |d| d["left"] = 1 }
|
|
60
|
+
right.change { |d| d["right"] = 2 }
|
|
61
|
+
|
|
62
|
+
left_state = Mechanomeld::SyncState.new # left's view of right
|
|
63
|
+
right_state = Mechanomeld::SyncState.new # right's view of left
|
|
64
|
+
loop do
|
|
65
|
+
to_right = left.generate_sync_message(left_state)
|
|
66
|
+
right.receive_sync_message(right_state, to_right) if to_right
|
|
67
|
+
to_left = right.generate_sync_message(right_state)
|
|
68
|
+
left.receive_sync_message(left_state, to_left) if to_left
|
|
69
|
+
break if to_right.nil? && to_left.nil?
|
|
70
|
+
end
|
|
71
|
+
left.heads == right.heads # => true
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
31
78
|
### Values
|
|
32
79
|
|
|
33
80
|
| Automerge | Ruby |
|
|
@@ -54,7 +101,7 @@ mise install
|
|
|
54
101
|
mise run setup # bundle install, npm ci for fixtures
|
|
55
102
|
mise run test # compile the extension and run the tests
|
|
56
103
|
mise run test:interop # check JavaScript reads Ruby-written documents
|
|
57
|
-
mise run fixtures # regenerate test/fixtures/*.automerge
|
|
104
|
+
mise run fixtures # regenerate test/fixtures/*.automerge, heads.json, and repo/
|
|
58
105
|
```
|
|
59
106
|
|
|
60
107
|
Check packaging with `gem build mechanomeld.gemspec`. Do not run `rake build` or `rake release` locally: reissue bumps the version and commits during `build`.
|
|
@@ -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,6 +14,7 @@ use magnus::{
|
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
use crate::errors::{arg_error, automerge_error, error};
|
|
17
|
+
use crate::sync_state::SyncState;
|
|
16
18
|
use crate::{path, read, write};
|
|
17
19
|
|
|
18
20
|
/// Text indexes count Unicode code points, matching Ruby's `String#length`.
|
|
@@ -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,64 @@ 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.generate_sync_message(sync_state)`: the next binary message for the peer
|
|
210
|
+
/// `sync_state` tracks, or nil when there is nothing to send.
|
|
211
|
+
pub fn generate_sync_message(
|
|
212
|
+
ruby: &Ruby,
|
|
213
|
+
rb_self: &Self,
|
|
214
|
+
sync_state: &SyncState,
|
|
215
|
+
) -> Result<Option<RString>, Error> {
|
|
216
|
+
let mut doc = rb_self.doc_mut(ruby)?;
|
|
217
|
+
let mut state = sync_state.state_mut(ruby)?;
|
|
218
|
+
let message = doc.sync().generate_sync_message(&mut state);
|
|
219
|
+
Ok(message.map(|message| ruby.str_from_slice(&message.encode())))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// `doc.receive_sync_message(sync_state, bytes)`: applies a peer's message to this
|
|
223
|
+
/// document and to `sync_state`.
|
|
224
|
+
pub fn receive_sync_message(
|
|
225
|
+
ruby: &Ruby,
|
|
226
|
+
rb_self: Obj<Self>,
|
|
227
|
+
sync_state: &SyncState,
|
|
228
|
+
bytes: RString,
|
|
229
|
+
) -> Result<Obj<Self>, Error> {
|
|
230
|
+
let data = unsafe { bytes.as_slice() }.to_vec();
|
|
231
|
+
let message = Message::decode(&data)
|
|
232
|
+
.map_err(|e| error(ruby, format!("could not decode sync message: {e}")))?;
|
|
233
|
+
{
|
|
234
|
+
let mut doc = rb_self.doc_mut(ruby)?;
|
|
235
|
+
let mut state = sync_state.state_mut(ruby)?;
|
|
236
|
+
doc.sync()
|
|
237
|
+
.receive_sync_message(&mut state, message)
|
|
238
|
+
.map_err(|e| automerge_error(ruby, e))?;
|
|
239
|
+
}
|
|
240
|
+
Ok(rb_self)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn change_hash(ruby: &Ruby, hex: &str) -> Result<ChangeHash, Error> {
|
|
245
|
+
ChangeHash::from_str(hex).map_err(|e| error(ruby, format!("invalid change hash {hex:?}: {e}")))
|
|
171
246
|
}
|
|
172
247
|
|
|
173
248
|
fn optional_path(ruby: &Ruby, args: &[Value]) -> Result<Vec<Value>, Error> {
|
data/ext/mechanomeld/src/lib.rs
CHANGED
|
@@ -3,11 +3,13 @@ mod document;
|
|
|
3
3
|
mod errors;
|
|
4
4
|
mod path;
|
|
5
5
|
mod read;
|
|
6
|
+
mod sync_state;
|
|
6
7
|
mod write;
|
|
7
8
|
|
|
8
9
|
use magnus::{function, method, prelude::*, Error, Ruby};
|
|
9
10
|
|
|
10
11
|
use crate::document::Document;
|
|
12
|
+
use crate::sync_state::SyncState;
|
|
11
13
|
|
|
12
14
|
#[magnus::init]
|
|
13
15
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
@@ -15,6 +17,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
15
17
|
let class = module.define_class("Document", ruby.class_object())?;
|
|
16
18
|
class.define_singleton_method("new", function!(Document::new, -1))?;
|
|
17
19
|
class.define_singleton_method("load", function!(Document::load, 1))?;
|
|
20
|
+
class.define_method("load_incremental", method!(Document::load_incremental, 1))?;
|
|
18
21
|
class.define_method("get", method!(Document::get, 1))?;
|
|
19
22
|
class.define_method("keys", method!(Document::keys, -1))?;
|
|
20
23
|
class.define_method("length", method!(Document::length, -1))?;
|
|
@@ -23,5 +26,19 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
23
26
|
class.define_method("commit", method!(Document::commit, -1))?;
|
|
24
27
|
class.define_method("rollback", method!(Document::rollback, 0))?;
|
|
25
28
|
class.define_method("save", method!(Document::save, 0))?;
|
|
29
|
+
class.define_method("heads", method!(Document::heads, 0))?;
|
|
30
|
+
class.define_method("includes_heads?", method!(Document::includes_heads, 1))?;
|
|
31
|
+
class.define_method(
|
|
32
|
+
"generate_sync_message",
|
|
33
|
+
method!(Document::generate_sync_message, 1),
|
|
34
|
+
)?;
|
|
35
|
+
class.define_method(
|
|
36
|
+
"receive_sync_message",
|
|
37
|
+
method!(Document::receive_sync_message, 2),
|
|
38
|
+
)?;
|
|
39
|
+
let sync_state = module.define_class("SyncState", ruby.class_object())?;
|
|
40
|
+
sync_state.define_singleton_method("new", function!(SyncState::new, 0))?;
|
|
41
|
+
sync_state.define_singleton_method("decode", function!(SyncState::decode, 1))?;
|
|
42
|
+
sync_state.define_method("encode", method!(SyncState::encode, 0))?;
|
|
26
43
|
Ok(())
|
|
27
44
|
}
|
|
@@ -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
|
+
}
|
data/lib/mechanomeld/document.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mechanomeld
|
|
4
|
-
# Native methods (new, load, get, keys, length, put, delete, commit,
|
|
4
|
+
# Native methods (new, load, load_incremental, get, keys, length, put, delete, commit,
|
|
5
|
+
# rollback, save, heads, includes_heads?, generate_sync_message, receive_sync_message)
|
|
5
6
|
# are defined in ext/mechanomeld/src/document.rs.
|
|
6
7
|
class Document
|
|
7
8
|
def self.from(hash, actor_id: nil)
|
|
@@ -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
|
data/lib/mechanomeld/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jim Gay
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- ext/mechanomeld/src/lib.rs
|
|
47
47
|
- ext/mechanomeld/src/path.rs
|
|
48
48
|
- ext/mechanomeld/src/read.rs
|
|
49
|
+
- ext/mechanomeld/src/sync_state.rs
|
|
49
50
|
- ext/mechanomeld/src/write.rs
|
|
50
51
|
- lib/mechanomeld.rb
|
|
51
52
|
- lib/mechanomeld/document.rb
|
|
@@ -74,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
75
|
- !ruby/object:Gem::Version
|
|
75
76
|
version: '0'
|
|
76
77
|
requirements: []
|
|
77
|
-
rubygems_version: 4.0.
|
|
78
|
+
rubygems_version: 4.0.20
|
|
78
79
|
specification_version: 4
|
|
79
80
|
summary: Read and write Automerge documents from Ruby
|
|
80
81
|
test_files: []
|