Thanks for considering a contribution. loro-go is an independent pure-Go implementation of the Loro CRDT. Since v1.0 it is in maintenance: bug fixes, security fixes and new loro-crdt releases are welcome, new features mostly are not. Capacity is limited, so reading this whole document before opening anything saves everyone time.
loro-go implements the Loro Fast wire format (FastUpdates and FastSnapshot) in pure Go, with no cgo.
- In scope: byte-compatible decode and encode of the Fast format, the
serde_columnarstrategies, change-block parsing, CRDT state reconstruction for Map / List / Text / MovableList / Tree / Counter, rich-texttoDelta, the liveDocument, its version vector and frontiers with their encodings, exports from a version and of id ranges, the KV/SSTable reader, LZ4 block decompression, the cross-language fixture suite, and robustness hardening of the decoders against malformed input. - In scope, but only in
integration/: thin transports that carry already-exported update blobs over an existing network protocol, each as its own Go module.integration/matrixis the worked example. A transport may not add a wire format, negotiate sessions, or diff versions; if it needs any of those, it is the sync protocol below and out of scope for now. - Out of scope: a real-time sync server, the sync protocol itself (sessions, the messages peers exchange, awareness; the versions and exports it is built from are in scope above), native persistence (sqlite or otherwise), mobile bindings (gomobile), a time-travel / checkout API, and shallow snapshots. PRs that start any of these will be politely closed: a partial implementation is maintenance debt for a project in maintenance.
- Out of scope permanently: anything outside the Loro wire format and its CRDT semantics.
If you are unsure whether something is in scope, open an issue first.
The most useful bug reports include:
- The version (commit SHA or release tag).
- Minimal Go code that reproduces the issue.
- Expected vs observed behavior.
- If it is a byte-encoding bug: hex dumps of the input, the expected bytes, and the actual bytes, plus the
loro-crdtequivalent if you have it.
Cross-implementation interop bugs are the highest priority. If you find a byte divergence against loro-crdt that is not already covered by a fixture in testdata/fixtures/, please attach the discrepancy.
Robustness bugs (a panic, unbounded allocation, or hang on untrusted input) are treated as security-relevant; see the disclosure note at the end.
Before opening a PR:
- Run
go test ./...(all packages must stay green). - Run
go vet ./.... - Run
gofmt -l .on changed files and fix anything it lists. - Run
golangci-lint run ./...(config in.golangci.yml); CI gates on it. - Add a test that fails without the change and passes with it.
Any PR that adds or modifies a byte decoder or encoder MUST extend the fixture corpus. Code-only codec changes without fixture coverage will not be merged, because the entire value of this library is staying byte-identical to loro-crdt.
Commit message format: one short subject line under 70 characters, then a blank line, then a body explaining the why. Example:
counter container: reconstruct state as summed increments
Loro encodes counter increments in the VALUES stream as I64 (or F64 for
fractional ones) with no dedicated container in MergeState.
The state is the order-independent sum of all increments (fixture
counter: 5 + 3 - 2 = 6 reproduces).
This project uses standard git authorship: commits are attributed to the human author of the change. Please do not include tool-generated attribution trailers or co-author lines in commit messages. PRs containing such trailers will be asked to amend.
The fixture suite is the ground truth. Two harnesses produce it:
testdata/gen/drives theloro-crdtnpm package (pinned exactly to a single version) to emit binary blobs,toJSON()state, and op dumps.testdata/rustgen/emits golden column vectors from theserde_columnarcrate.
To regenerate:
cd testdata/gen && npm install && node gen.mjs && node gen_encode.mjs && node gen_scripts.mjs && node gen_versions.mjs
cd ../rustgen && cargo run > ../columnar_golden.txt
After regeneration, commit the updated fixtures and any code needed to consume them, and keep the pinned loro-crdt and serde_columnar versions exact (a caret range would let the bytes drift silently). Do not commit testdata/gen/node_modules/ or testdata/rustgen/target/ (both are gitignored).
Each integration under integration/ is a separate Go module with its own go.mod and a replace back to the repository root, so it builds against the working tree rather than the last published tag.
cd integration/matrix
go test ./... # unit tests, in-process homeserver double, no Docker
docker compose up -d # a real Dendrite homeserver on :8008
go run ./cmd/loro-matrix-demo
docker compose down -v
A transport must be proven against a real server, not only against a double, and CI runs it that way. This is not a formality: the first version of the Matrix transport passed every unit test while being rejected by an actual homeserver, and running it for real also exposed a genuine bug in the library underneath. If you add an integration, add the equivalent live check, and make its test double refuse whatever the real server refuses.
Two invariants hold for loro.MergeState and any code feeding it:
- Order independence. Merging the same set of changes in any order gives the same state.
- Idempotence. Applying the same op twice is the same as applying it once, and an op that overlaps one already applied adds only its new part; an op is identified by the whole id range it occupies, its peer and the counters of all its atoms, not by its first id. A transport may deliver a duplicate for ordinary reasons (a retry, an overlapping pagination window), so this is a property of the merge, not a duty on the caller.
Both are pinned in loro/idempotence_test.go. If you change the merge path, keep them.
A third holds for Text, List and MovableList:
- Positions mean what the author saw. An insert or delete records only an index, so it is resolved against its author's view: the elements in its causal past (the closure of its change's deps, plus the author's own earlier atoms), minus those removed by a delete atom inside that past, with rich-text mark anchors taking positions. Inserts made concurrently at the same place are then ordered by Fugue from the left and right origins their authors saw, as loro orders them. In a MovableList those are positions: a move deletes the position it moves from and adds one, and each element sits at its position with the latest writer.
loro/causal_ref_test.go is a brute-force reference for that rule, sharing no code with the merge. TestCausalRefMatchesLoro holds the reference to loro-crdt on every fixture, and TestMergeMatchesCausalReference holds the merge to the reference on random multi-peer histories. If you change the sequence merge, keep both passing.
The decoders parse fully untrusted bytes, so they are fuzzed below the checksum layer (a malicious peer can recompute a valid checksum, so the deep decoders must be robust on their own). FuzzDecodeUpdates and FuzzDecodeSnapshot start above it, so they recompute the header checksum over the mutated bytes before decoding; without that every mutation fails the checksum and nothing below it is ever reached, which is how a stack overflow in tree reconstruction went unfound. Do the same in any new target that starts from a whole blob. To run a target:
go test ./loro/ -run='^$' -fuzz=FuzzDecodeBlock -fuzztime=30s
go test ./encoding/columnar/ -run='^$' -fuzz=FuzzStrategies -fuzztime=30s
A new decoder path should never panic, allocate without bound, or hang on hostile input; it should return an error. If you add a decoder, add a fuzz target or extend an existing one, and bound any length or count taken from the input against the bytes that remain.
FuzzEncodeUpdates holds the encoder to the decoder: whatever decodes and encodes without error must decode to the same changes in the same blocks, and encode to the same bytes again. If you change what the decoder shows or what the encoder writes, keep it passing.
FuzzDecodeVersion reads hostile bytes as a version vector and as frontiers, the way a peer's sync message brings them: either is an error, or encodes and decodes back to the same entries, and a document exports from the version vector without error.
- Standard
gofmt.go vet ./...must pass. - Idiomatic Go. Generics only where the standard library already uses them.
- No cgo, anywhere, including integrations.
- The library's
go.modhas an emptyrequireblock and stays that way. Anything that needs a dependency goes in its own module underintegration/, so consumers of the library never inherit it. A PR that adds arequireto the root module will be asked to move. - The API follows semantic versioning: nothing exported changes incompatibly within v1. A fix that needs a breaking change waits for a v2, which is not planned.
- Comments: a
doc.go-style package comment and godoc on exported symbols. Short, not essays.
For robustness or security-relevant issues (panics, unbounded allocation, or hangs on untrusted input), please report privately first via GitHub's "Report a vulnerability" (the Security tab) rather than opening a public issue. Once a fix is ready we will publish the fix and a short note together.