mechanomeld 0.1.1 → 0.1.3
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 +4 -22
- data/README.md +28 -3
- data/ext/mechanomeld/src/diff.rs +100 -0
- data/ext/mechanomeld/src/document.rs +43 -4
- data/ext/mechanomeld/src/lib.rs +3 -1
- data/ext/mechanomeld/src/read.rs +5 -1
- data/lib/mechanomeld/document.rb +4 -2
- data/lib/mechanomeld/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 401645183c5b387c8f7ebd914db2d8ff1014c03f86fb65b9d994153510f7482f
|
|
4
|
+
data.tar.gz: d660d41f97da18bd39e4306e131b4c19d70e809e8da17236f7a4251efa1cf177
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3bb7618d943ad8c284dbc328fe2156599b8a2c39a5a7e218bc2650f076fc16468e7994cab92209c5bfb790e6248969a2616a94967f41ddcdbac03879f54ce9fa
|
|
7
|
+
data.tar.gz: 65bf168075b5a415b4bd582ab98f474913188106fdba093f5811d1b1d57b94a050dd1a1eff1e542cc566c35c9081da749a11ee3aa05997bdc8ced485fcd40672
|
data/CHANGELOG.md
CHANGED
|
@@ -5,32 +5,14 @@ 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.
|
|
8
|
+
## [0.1.3] - 2026-10-01
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
- Document#
|
|
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)
|
|
12
|
+
- Document#up_to_date_with?(heads), the plain-language name for includes_heads?, which stays as an alias (9c1baa6)
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- releases also publish an x86_64-linux-musl platform gem (159dd59)
|
|
22
|
-
|
|
23
|
-
## [0.1.0] - 2026-09-17
|
|
14
|
+
## [0.1.2] - 2026-09-28
|
|
24
15
|
|
|
25
16
|
### Added
|
|
26
17
|
|
|
27
|
-
-
|
|
28
|
-
- Mechanomeld::Counter, Timestamp, Uint, Bytes, and Text value classes (6df6951)
|
|
29
|
-
- Mechanomeld::Document.load reads documents saved by JavaScript Automerge (c7d31d8)
|
|
30
|
-
- Document#get, #[], and #to_h read values by path (c7d31d8)
|
|
31
|
-
- Document#keys and #length for maps, lists, and text (1d80c0b)
|
|
32
|
-
- Mechanomeld::Document.new(actor_id:) creates an empty document (bccb69c)
|
|
33
|
-
- Document#put, #[]=, #delete, and #save (bccb69c)
|
|
34
|
-
- Document#commit(message:, timestamp:) and #rollback (a8ef491)
|
|
35
|
-
- Document#change commits a block and rolls back if it raises (a8ef491)
|
|
36
|
-
- Mechanomeld::Document.from builds a committed document from a Hash (a8ef491)
|
|
18
|
+
- Document#diff(from_heads, to_heads = heads) returns Automerge's patches between two versions of a document, as Hashes (9c9bf6d)
|
data/README.md
CHANGED
|
@@ -40,15 +40,40 @@ doc = Mechanomeld::Document.load_repo("automerge-repo-data", "4NtRZtd6yUtzf8qmEp
|
|
|
40
40
|
|
|
41
41
|
### Heads
|
|
42
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#
|
|
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#up_to_date_with?(heads)` is true when the document is at the version `heads` names, or a later one: every hash in `heads` is a change the document already contains. `includes_heads?` is the same method under its older name.
|
|
44
44
|
|
|
45
45
|
```ruby
|
|
46
|
-
doc.heads
|
|
47
|
-
doc.
|
|
46
|
+
doc.heads # => ["9d434616e7af865757bad75cdebe4151bccf7e29214a71f2a65150846c2a4275"]
|
|
47
|
+
doc.up_to_date_with?(heads) # => true when the document is at or past `heads`
|
|
48
48
|
```
|
|
49
49
|
|
|
50
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
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
|
+
|
|
52
77
|
### Syncing with a peer
|
|
53
78
|
|
|
54
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.
|
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,7 @@ use magnus::{
|
|
|
15
15
|
|
|
16
16
|
use crate::errors::{arg_error, automerge_error, error};
|
|
17
17
|
use crate::sync_state::SyncState;
|
|
18
|
-
use crate::{path, read, write};
|
|
18
|
+
use crate::{diff, path, read, write};
|
|
19
19
|
|
|
20
20
|
/// Text indexes count Unicode code points, matching Ruby's `String#length`.
|
|
21
21
|
const ENCODING: TextEncoding = TextEncoding::UnicodeCodePoint;
|
|
@@ -193,9 +193,10 @@ impl Document {
|
|
|
193
193
|
Ok(ruby.ary_from_iter(heads.iter().map(ChangeHash::to_string)))
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/// `doc.
|
|
197
|
-
/// change
|
|
198
|
-
|
|
196
|
+
/// `doc.up_to_date_with?(heads)`: whether this document is at the version `heads`
|
|
197
|
+
/// names, or a later one: every hex change hash in `heads` is a change it already
|
|
198
|
+
/// contains. Ruby also exposes it as `includes_heads?`.
|
|
199
|
+
pub fn up_to_date_with(ruby: &Ruby, rb_self: &Self, heads: Vec<String>) -> Result<bool, Error> {
|
|
199
200
|
let hashes = heads
|
|
200
201
|
.iter()
|
|
201
202
|
.map(|hex| change_hash(ruby, hex))
|
|
@@ -206,6 +207,26 @@ impl Document {
|
|
|
206
207
|
.all(|hash| doc.get_change_meta_by_hash(hash).is_some()))
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/// `doc.diff(from_heads, to_heads = heads)`: the patches that take the document from
|
|
211
|
+
/// `from_heads` to `to_heads`, as Hashes (see diff.rs for their shape). `[]` as
|
|
212
|
+
/// `from_heads` diffs from the empty document. Every head must be a change this
|
|
213
|
+
/// document contains.
|
|
214
|
+
pub fn diff(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<RArray, Error> {
|
|
215
|
+
let args = scan_args::<(Vec<String>,), (Option<Vec<String>>,), (), (), (), ()>(args)?;
|
|
216
|
+
let mut doc = rb_self.doc_mut(ruby)?;
|
|
217
|
+
let from = known_heads(ruby, &mut doc, &args.required.0)?;
|
|
218
|
+
let to = match args.optional.0 {
|
|
219
|
+
Some(heads) => known_heads(ruby, &mut doc, &heads)?,
|
|
220
|
+
None => doc.get_heads(),
|
|
221
|
+
};
|
|
222
|
+
let patches = doc.diff(&from, &to);
|
|
223
|
+
let array = ruby.ary_new_capa(patches.len());
|
|
224
|
+
for patch in &patches {
|
|
225
|
+
array.push(diff::patch(ruby, patch)?)?;
|
|
226
|
+
}
|
|
227
|
+
Ok(array)
|
|
228
|
+
}
|
|
229
|
+
|
|
209
230
|
/// `doc.generate_sync_message(sync_state)`: the next binary message for the peer
|
|
210
231
|
/// `sync_state` tracks, or nil when there is nothing to send.
|
|
211
232
|
pub fn generate_sync_message(
|
|
@@ -245,6 +266,24 @@ fn change_hash(ruby: &Ruby, hex: &str) -> Result<ChangeHash, Error> {
|
|
|
245
266
|
ChangeHash::from_str(hex).map_err(|e| error(ruby, format!("invalid change hash {hex:?}: {e}")))
|
|
246
267
|
}
|
|
247
268
|
|
|
269
|
+
/// Parses hex change hashes, rejecting any change `doc` does not contain.
|
|
270
|
+
fn known_heads(
|
|
271
|
+
ruby: &Ruby,
|
|
272
|
+
doc: &mut AutoCommit,
|
|
273
|
+
heads: &[String],
|
|
274
|
+
) -> Result<Vec<ChangeHash>, Error> {
|
|
275
|
+
heads
|
|
276
|
+
.iter()
|
|
277
|
+
.map(|hex| {
|
|
278
|
+
let hash = change_hash(ruby, hex)?;
|
|
279
|
+
if doc.get_change_meta_by_hash(&hash).is_none() {
|
|
280
|
+
return Err(error(ruby, format!("unknown change hash {hex:?}")));
|
|
281
|
+
}
|
|
282
|
+
Ok(hash)
|
|
283
|
+
})
|
|
284
|
+
.collect()
|
|
285
|
+
}
|
|
286
|
+
|
|
248
287
|
fn optional_path(ruby: &Ruby, args: &[Value]) -> Result<Vec<Value>, Error> {
|
|
249
288
|
let args = scan_args::<(), (Option<Value>,), (), (), (), ()>(args)?;
|
|
250
289
|
path::segments(args.optional.0.unwrap_or_else(|| ruby.qnil().as_value()))
|
data/ext/mechanomeld/src/lib.rs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
mod classes;
|
|
2
|
+
mod diff;
|
|
2
3
|
mod document;
|
|
3
4
|
mod errors;
|
|
4
5
|
mod path;
|
|
@@ -27,7 +28,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
27
28
|
class.define_method("rollback", method!(Document::rollback, 0))?;
|
|
28
29
|
class.define_method("save", method!(Document::save, 0))?;
|
|
29
30
|
class.define_method("heads", method!(Document::heads, 0))?;
|
|
30
|
-
class.define_method("
|
|
31
|
+
class.define_method("up_to_date_with?", method!(Document::up_to_date_with, 1))?;
|
|
32
|
+
class.define_method("diff", method!(Document::diff, -1))?;
|
|
31
33
|
class.define_method(
|
|
32
34
|
"generate_sync_message",
|
|
33
35
|
method!(Document::generate_sync_message, 1),
|
data/ext/mechanomeld/src/read.rs
CHANGED
|
@@ -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(
|
|
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)?;
|
data/lib/mechanomeld/document.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Mechanomeld
|
|
4
4
|
# Native methods (new, load, load_incremental, get, keys, length, put, delete, commit,
|
|
5
|
-
# rollback, save, heads,
|
|
6
|
-
# are defined in ext/mechanomeld/src/document.rs.
|
|
5
|
+
# rollback, save, heads, up_to_date_with?, diff, generate_sync_message,
|
|
6
|
+
# receive_sync_message) are defined in ext/mechanomeld/src/document.rs.
|
|
7
7
|
class Document
|
|
8
8
|
def self.from(hash, actor_id: nil)
|
|
9
9
|
raise ArgumentError, "Automerge document root must be a Hash" unless hash.is_a?(Hash)
|
|
@@ -40,6 +40,8 @@ module Mechanomeld
|
|
|
40
40
|
end
|
|
41
41
|
alias_method :to_hash, :to_h
|
|
42
42
|
|
|
43
|
+
alias_method :includes_heads?, :up_to_date_with?
|
|
44
|
+
|
|
43
45
|
def change(message: nil, timestamp: nil)
|
|
44
46
|
yield self
|
|
45
47
|
commit(message: message, timestamp: timestamp)
|
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jim Gay
|
|
@@ -41,6 +41,7 @@ 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
|