mechanomeld 0.1.1-x86_64-linux

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: 664132a1c219f2a6bb45b5ee806e021728ff9e44c0638bc34ec697c195eeccf3
4
+ data.tar.gz: a366cce5bf9f2dbe5d5a66bfd3348ac7920af5a89638cd32213ad87a356b7dbd
5
+ SHA512:
6
+ metadata.gz: 381b73f53f663f69dd40a7e339e43d308456d18a056c6487fd0f192b6ee3c3b9e2170c9b2ef86701e1a01a23a60ba98af02b6242688019758bf2dccbf34a83b3
7
+ data.tar.gz: 63584e963a9845ac188a51fae3bd4fe8ba4ff275ac1cd2726c3ca914833741f7cc4369b1f19813a049eafff5d765cbc9b4f2226af56acc41cb4d49c0b7c53331
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/).
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
+
23
+ ## [0.1.0] - 2026-09-17
24
+
25
+ ### Added
26
+
27
+ - Initial release
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)
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Mechanomeld
2
+
3
+ Read and write [Automerge](https://automerge.org) documents from Ruby. Mechanomeld is a native extension around the Rust `automerge` crate, so documents round-trip with JavaScript's `@automerge/automerge`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bundle add mechanomeld
9
+ ```
10
+
11
+ Precompiled gems are published for arm64/x86_64 macOS and Linux on Ruby 3.2–4.0. Other platforms build from source and need a Rust toolchain.
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "mechanomeld"
17
+
18
+ doc = Mechanomeld::Document.load(File.binread("todos.automerge"))
19
+ doc.get(["todos", 0, "title"]) # => #<Mechanomeld::Text "Buy milk">
20
+ doc.to_h # => {"todos" => [{"title" => #<Mechanomeld::Text "Buy milk">, ...}]}
21
+ doc.keys # => ["todos"]
22
+ doc.length("todos") # => 1
23
+
24
+ doc = Mechanomeld::Document.from({"title" => Mechanomeld::Text.new("Groceries"), "items" => []})
25
+ doc.change(message: "Add milk") do |d|
26
+ d["count"] = Mechanomeld::Counter.new(1)
27
+ end
28
+ File.binwrite("groceries.automerge", doc.save)
29
+ ```
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
+
78
+ ### Values
79
+
80
+ | Automerge | Ruby |
81
+ |---|---|
82
+ | map / list | `Hash` (String keys) / `Array` |
83
+ | text object | `Mechanomeld::Text` |
84
+ | string | `String` |
85
+ | int / f64 / boolean / null | `Integer` / `Float` / `true`, `false` / `nil` |
86
+ | counter / uint | `Mechanomeld::Counter` / `Mechanomeld::Uint` |
87
+ | timestamp | `Mechanomeld::Timestamp` (milliseconds since the epoch) |
88
+ | bytes | `Mechanomeld::Bytes` |
89
+
90
+ JavaScript stores strings as text objects by default, so they read as `Mechanomeld::Text`. A Ruby `String` is written as an Automerge string, which JavaScript reads as an `ImmutableString`; write `Mechanomeld::Text.new("...")` to create text JavaScript reads as a plain string.
91
+
92
+ Errors from Automerge raise `Mechanomeld::Error`.
93
+
94
+ ## Development
95
+
96
+ Tools are pinned in `mise.toml`.
97
+
98
+ ```bash
99
+ mise trust
100
+ mise install
101
+ mise run setup # bundle install, npm ci for fixtures
102
+ mise run test # compile the extension and run the tests
103
+ mise run test:interop # check JavaScript reads Ruby-written documents
104
+ mise run fixtures # regenerate test/fixtures/*.automerge, heads.json, and repo/
105
+ ```
106
+
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`.
108
+
109
+ ## Releasing
110
+
111
+ Releases run from the **Release gem to RubyGems.org** GitHub Actions workflow. Changelog entries and version bumps come from commit trailers (`Added:`, `Changed:`, `Fixed:`, `Version: minor`, ...). Run it with `dry_run` first.
112
+
113
+ ## Contributing
114
+
115
+ Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/mechanomeld.
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+ require "rb_sys/extensiontask"
6
+ require "reissue/gem"
7
+
8
+ GEMSPEC = Gem::Specification.load("mechanomeld.gemspec")
9
+
10
+ # No platform list: cross-gem builds set RUBY_TARGET, which rb_sys reads.
11
+ RbSys::ExtensionTask.new("mechanomeld", GEMSPEC) do |ext|
12
+ ext.lib_dir = "lib/mechanomeld"
13
+ end
14
+
15
+ Minitest::TestTask.create
16
+ task test: :compile
17
+
18
+ Reissue::Task.create :reissue do |task|
19
+ task.version_file = "lib/mechanomeld/version.rb"
20
+ task.fragment = :git
21
+ # The post-release bump goes on a pushed branch; the shared release workflow opens its PR.
22
+ # (0.5.1's default, stated explicitly: it is the "Option A" the shared workflow expects.)
23
+ task.push_reissue = :branch
24
+ end
25
+
26
+ task default: :test
@@ -0,0 +1 @@
1
+ 4a15ef106109b7d61c0c5f78cfc6f1599300b62a5410dbeb7e673cecd69731907125e006507dfc2b03eea8d6a738031569e5f430adcc0cb051fd36d7bdfcee4e
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mechanomeld
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)
6
+ # are defined in ext/mechanomeld/src/document.rs.
7
+ class Document
8
+ def self.from(hash, actor_id: nil)
9
+ raise ArgumentError, "Automerge document root must be a Hash" unless hash.is_a?(Hash)
10
+
11
+ doc = new(actor_id: actor_id)
12
+ hash.each { |key, value| doc.put([key], value) }
13
+ doc.commit
14
+ doc
15
+ end
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
+
30
+ def [](path)
31
+ get(path)
32
+ end
33
+
34
+ def []=(path, value)
35
+ put(path, value)
36
+ end
37
+
38
+ def to_h
39
+ get([])
40
+ end
41
+ alias_method :to_hash, :to_h
42
+
43
+ def change(message: nil, timestamp: nil)
44
+ yield self
45
+ commit(message: message, timestamp: timestamp)
46
+ self
47
+ rescue Exception
48
+ rollback
49
+ raise
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mechanomeld
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mechanomeld
4
+ # A typed Automerge value that has no natural Ruby equivalent.
5
+ class Scalar
6
+ attr_reader :value
7
+
8
+ def initialize(value)
9
+ @value = value
10
+ end
11
+
12
+ def ==(other)
13
+ other.class == self.class && other.value == value
14
+ end
15
+ alias_method :eql?, :==
16
+
17
+ def hash
18
+ [self.class, value].hash
19
+ end
20
+
21
+ def inspect
22
+ "#<#{self.class.name} #{value.inspect}>"
23
+ end
24
+ end
25
+
26
+ class Counter < Scalar; end
27
+
28
+ # Milliseconds since the Unix epoch.
29
+ class Timestamp < Scalar; end
30
+
31
+ class Uint < Scalar; end
32
+
33
+ class Bytes < Scalar
34
+ def initialize(value)
35
+ super(String(value).b)
36
+ end
37
+ end
38
+
39
+ # A collaborative text object, as opposed to a plain String value.
40
+ class Text < Scalar
41
+ def initialize(value = "")
42
+ super(String(value))
43
+ end
44
+
45
+ def to_s
46
+ value
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mechanomeld
4
+ VERSION = "0.1.1"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mechanomeld/version"
4
+ require_relative "mechanomeld/error"
5
+ require_relative "mechanomeld/scalars"
6
+
7
+ # Precompiled gems ship one binary per Ruby minor version; source builds put it in lib/mechanomeld.
8
+ begin
9
+ RUBY_VERSION =~ /(\d+\.\d+)/
10
+ require_relative "mechanomeld/#{Regexp.last_match(1)}/mechanomeld"
11
+ rescue LoadError
12
+ require_relative "mechanomeld/mechanomeld"
13
+ end
14
+
15
+ require_relative "mechanomeld/document"
@@ -0,0 +1,4 @@
1
+ module Mechanomeld
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mechanomeld
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: x86_64-linux
6
+ authors:
7
+ - Jim Gay
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A native extension wrapping the Rust automerge crate so Ruby can load,
14
+ read, create, and save Automerge documents shared with JavaScript.
15
+ email:
16
+ - jim@saturnflyer.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - README.md
23
+ - Rakefile
24
+ - checksums/mechanomeld-0.1.0.gem.sha512
25
+ - lib/mechanomeld.rb
26
+ - lib/mechanomeld/3.2/mechanomeld.so
27
+ - lib/mechanomeld/3.3/mechanomeld.so
28
+ - lib/mechanomeld/3.4/mechanomeld.so
29
+ - lib/mechanomeld/4.0/mechanomeld.so
30
+ - lib/mechanomeld/document.rb
31
+ - lib/mechanomeld/error.rb
32
+ - lib/mechanomeld/scalars.rb
33
+ - lib/mechanomeld/version.rb
34
+ - sig/mechanomeld.rbs
35
+ homepage: https://github.com/SOFware/mechanomeld
36
+ licenses: []
37
+ metadata:
38
+ allowed_push_host: https://rubygems.org
39
+ homepage_uri: https://github.com/SOFware/mechanomeld
40
+ source_code_uri: https://github.com/SOFware/mechanomeld
41
+ changelog_uri: https://github.com/SOFware/mechanomeld/blob/main/CHANGELOG.md
42
+ post_install_message:
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '3.2'
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: 4.1.dev
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.5.23
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: Read and write Automerge documents from Ruby
64
+ test_files: []