Carries Loro CRDT updates as Matrix events, so Go services that already speak Matrix can hold a shared local-first document without adding a second network stack.
A room is the log. Each local edit is exported as a FastUpdates blob and
published as one dev.loro.update event; a peer coming back online replays the
room and merges whatever it finds. Nothing here depends on the events arriving
in order, exactly once, or before the merge runs, which is precisely why a CRDT
is the right payload for a federated transport that guarantees none of those.
This is a transport, not a sync protocol. There is no version-vector diff, no server, and no session negotiation: peers exchange whole update blobs and let the CRDT converge. That keeps the surface small enough to audit in one sitting.
github.com/Deln0r/loro-go has an empty require block and stays that way.
Pulling a Matrix client into the library would put that dependency in front of
every consumer, so the integration is its own module and you opt into it:
go get github.com/Deln0r/loro-go/integration/matrix
The demo is a check, not a printout: it exits non-zero if the peers fail to converge, or if they converge on a document that lost an edit.
cd integration/matrix
docker compose up -d # Dendrite on :8008, with alice and bob created
go run ./cmd/loro-matrix-demo
docker compose down -v # when finished
Dendrite is the Go homeserver, which is the honest thing to test a Go CRDT against: with loro-go this half of the stack is Go end to end, no cgo and no foreign toolchain anywhere in the build.
Expected output:
-- offline edits (peers cannot see each other) --
alice: notes += "alice wrote this. ", meta.author = alice
bob: notes += "bob wrote this. ", meta.reviewed = 1
-- reconnect and publish --
bob -> $Ax4hsZ... (dev.loro.update)
alice -> $WP8DwT... (dev.loro.update)
-- replay the room and merge --
alice replayed 2 change(s) from the room
bob replayed 2 change(s) from the room
alice sees: {"meta":{"author":"alice","reviewed":1},"notes":"alice wrote this. bob wrote this. "}
bob sees: {"meta":{"author":"alice","reviewed":1},"notes":"alice wrote this. bob wrote this. "}
OK: both peers converged, and both edits survived the merge.
Note the order: bob publishes first and alice second, but alice's text lands first in the merged document. Order of arrival does not decide the result.
go test ./...
These run against an in-process test double rather than Docker, so they work in CI without a homeserver. The double is written to match what Dendrite was measured to do, in both directions: a double more permissive than the real server hides bugs, and a double stricter than it fails code the server would have served. Both mistakes were made here before the current shape settled.
The pagination token. The first version of Replay paged forward with no
token, every unit test passed, and a real homeserver rejected it with
M_INVALID_PARAM, "malformed sync token". The first fix blamed the emptiness of
the token, and that was wrong: measured against Dendrite, dir=b with the token
absent or empty is served normally, because a backward page is anchored at the
live end of the timeline, while dir=f with the token absent or empty is
refused, because a forward page has no such default. The fault was the
direction. The double now enforces exactly that rule rather than a stricter one
it invented.
Where pagination ends. A walk ends when the server stops returning an end
token, not when a page comes back empty. A server may hand back an empty chunk
with a live continuation token, for instance when per-user history visibility
removed every event on that page, and stopping there silently truncates the
document while reporting success.
A duplicate-op bug in the library underneath, in two rounds. Dendrite's sync
window and its first backfill page overlap, so a peer received one update twice
and the merge corrupted the document instead of absorbing it ("hello" came
back as "hellooloolloolooellooloollooloo..."). The first fix identified an op
by its peer and its first counter, which was not enough. loro coalesces adjacent
atoms into one run, so two exports of the same document taken at different
moments share a first counter while covering different id spans: "ab" is
counter 0 length 2 and "abcd" is counter 0 length 4. Merging "abcd" after
"ab" therefore dropped "cd" outright, and reading a room forwards produced a
different document from reading it backwards. The complement was as bad: a
from-version delta re-sends a tail at a later start counter, misses a
first-counter key entirely, and reproduces the original corruption.
MergeState now treats an op as the id range it occupies and clips an
incoming op to the sub-ranges it has not already consumed. loro/idempotence_test.go
pins both halves against fixtures generated by loro-crdt itself. No fixture had
covered any of this, because each merges a fixed set of distinct changes exactly
once. It took a real transport to produce the overlaps.
- No sync protocol: no version-vector diff, no partial-history request, no awareness or presence channel.
- No end-to-end encryption. Updates are published as plaintext event content.
Publishtherefore refuses an encrypted room outright when the client has no crypto helper, and fails closed if it cannot tell: a homeserver will accept a plaintext event into an encrypted room and return a normal event id, so without that check the document would land readable in a room whose members were promised otherwise. Installing a mautrix crypto helper lifts the refusal, since mautrix then encrypts outgoing events itself. - No authorisation, and validation is not a substitute. A delete names
what it removes and carries no proof of entitlement, so any peer may delete
any other peer's content: in a Matrix room, whoever can write can empty the
document, and every check this package makes passes on the blob that does it.
Read room membership as full authority over the document. The checks buy
availability of reading, not integrity of content, and
TestValidationIsNotAuthorisationexists to keep that honest. - No defence against a homeserver that withholds or forges events.
Withheld events cost availability, which the merge tolerates; an event forged
in the name of a member the server controls falls under the authorisation
boundary above. A malformed event no longer stops a replay:
Replayreads/syncand/messagesonly as far as it needs and decodes each event on its own, so an event that is not a JSON object, or adev.loro.updatewhose content is not one, is reported in the error list while the rest of the page replays (TestReplaySurvivesAHostileEvent,FuzzCollectRaw). An earlier version handed the whole response to the client library's typed parse, which failed on such an event and lost every update on that page for good; Dendrite refuses those events on/send, so they take a hostile or non-conforming server. - No compaction. Every update stays in the room, so replay cost grows with the room's history.
Each of those is a deliberate boundary, not an oversight.