An event-sourced CRDT for real-valued ranges. Ranges can be added and removed from any number of replicas, in any order, over any transport — and every replica that has seen the same operations converges to the same set.
Storage and transport are both swappable. A replica can keep its operations in
a JSON Lines file, in memory, or in any backend you write against the
store.Log interface. Replicas converge by exchanging operations over whatever
transport you already have — goroutine channels, an in-process pub/sub bus,
plain HTTP, or an event database such as
KurrentDB (behind the kurrent build tag).
set, _ := eventfulranges.Open(ctx, "./example", strategy.LWW) // ./example/ranges.stream.jsonl
_, _ = set.Add(ctx, 1, 10) // [1,10]
_, _ = set.Remove(ctx, 3, 5) // cut a hole
for _, iv := range set.Ranges() {
fmt.Println(iv) // [1,3) (5,10]
}Replicas converge by exchanging operations, not by reconciling state:
// replica A and B each mutated independently ...
_ = a.ApplyAll(ctx, b.Ops())
_ = b.ApplyAll(ctx, a.Ops())
// a.Ranges() == b.Ranges()The full program is in examples/calendar. A date is
just a day number (float64), so a date range is a plain interval:
cal, _ := eventfulranges.OpenStore(ctx, memory.New(), strategy.AdditiveWins)
book, cancel := func(f, t string) { _, _ = cal.Add(ctx, days(f), days(t)) },
func(f, t string) { _, _ = cal.Remove(ctx, days(f), days(t)) }
book("2026-07-01", "2026-07-10") // Alice's vacation
book("2026-07-06", "2026-07-15") // Bob's vacation (overlaps)
cancel("2026-07-08", "2026-07-10") // Alice cuts the trip short
cal.Contains(days("2026-07-01")) // true — booked
cal.Contains(days("2026-07-08")) // false — cancelled
cal.Contains(days("2026-07-12")) // true — Bob's still awaygantt
title Shared calendar — AdditiveWins
dateFormat YYYY-MM-DD
axisFormat %m-%d
section Start (bookings)
Alice books :a, 2026-07-01, 10d
Bob books :b, 2026-07-06, 10d
section Operation
Alice cancels :crit, c, 2026-07-08, 3d
section Result
Busy (Alice, then Alice+Bob) :done, r1, 2026-07-01, 7d
Free :active, r2, 2026-07-08, 3d
Busy (Bob) :done, r3, 2026-07-11, 5d
AdditiveWins makes the busy set the union of all bookings minus all
cancellations, so concurrent edits converge no matter the order.
A replica is a store.Log plus a strategy. Three backends ship in the repo,
and you can plug in your own:
set, _ := eventfulranges.Open(ctx, "./example", strategy.LWW) // JSON Lines stream (default)
set, _ := eventfulranges.OpenStore(ctx, memory.New(), strategy.LWW) // in memory
set, _ := eventfulranges.OpenStore(ctx, myBackend, strategy.LWW) // your own store.LogOpen keeps the append-only event stream as JSON Lines
(./example/ranges.stream.jsonl) and caches the materialized view in a
sidecar snapshot (./example/ranges.snapshot.json). The stream is the source
of truth; the snapshot only fast-forwards a restart.
Transport is yours to choose, too: convergence is just shipping Ops() and
calling ApplyAll. See Demos for channels, a pub/sub bus, and HTTP,
and KurrentDB for the event-database backend.
| Strategy | Semantics |
|---|---|
LWW |
Highest (timestamp, id) wins at each point |
FWW |
Lowest (timestamp, id) wins at each point |
AdditiveWins |
Union of all additions minus union of all removals |
GrowOnly |
Union of all additions, removals ignored |
| Package | Purpose |
|---|---|
interval |
1-D open/closed intervals with canonical set algebra |
op |
The append-only operation (add / remove) |
clock |
Hybrid logical clock and Lamport clock |
strategy |
Conflict resolution: materializes ops to a set |
engine |
Concurrency-safe log + view, snapshotting |
store |
The EventStore interface (append/read/snapshot) |
store/memory, store/jsonl |
In-memory and file backends |
space |
n-dimensional generalization (half-open boxes) |
The public facade is the root package eventfulranges.
Range endpoints are float64. Integer literals convert exactly while they fit
a float64's 53-bit mantissa (|n| <= 2^53); fractional values are stored and
compared verbatim, so no rounding error accumulates. There is no
arbitrary-precision (math/big) coordinate type: endpoints beyond 2^53 round
to the nearest representable value. int64 is used only for bookkeeping —
operation timestamps and log-version counters — never as a coordinate.
- hello — simplest use, no concurrency
- local — goroutine replicas converge over channels
- pubsub — replicas converge over an in-process pub/sub bus
- network — two HTTP peers converge
- web — interactive 3D visualizer, shared live over WebSockets
go run ./demo/hellodemo/hello opens an in-memory set and prints what a single add/remove leaves
behind — the smallest possible program.
go run ./demo/localdemo/local opens three in-memory replicas, lets each mutate its own copy from
a goroutine, then floods every replica's Ops() to every other replica until
they agree. The transport is Go channels; there is no network.
go run ./demo/pubsubdemo/pubsub is the same idea through a bus: each replica subscribes to a
topic on a github.com/cskr/pubsub/v2 bus, mutates locally, and publishes its
operations. Every replica applies every broadcast it receives, so they converge
without talking to each other directly.
go run ./demo/networkdemo/network runs two replicas, each behind its own HTTP server. There is no
CRDT-specific protocol — a peer exports its log as GET /ops (JSON) and folds
someone else's log in with POST /ops. Each peer mutates its own copy, then
the two exchange logs and converge; ports come from -ports 18080,18081.
go run ./demo/webdemo/web serves an n-dimensional range-set visualizer (1–4 dimensions, with a
rotatable translucent-box 3D view and copy/pasteable CSV). Everyone connected
to the same instance shares one view: each add/remove is folded with
additive-wins semantics and broadcast over a WebSocket, so concurrent edits
converge regardless of order. Open http://localhost:8080/ui/.
One command starts it, and the other scripts cover the rest:
./scripts/demo-web.sh # start the web visualizer (open the printed URL)
./scripts/build-web.sh # (re)build the embedded UI (npm install + esbuild)
./scripts/itest-web.sh # smoke test: unit tests + server serves the UI
./scripts/e2e-web.sh # Playwright end-to-end testsEach demo has a smoke test; run them with go test ./demo/....
go run ./demo/paintdemo/paint is an infinite, shared pixel whiteboard built on the library's
n-dimensional range CRDT. Each stroke is one half-open add/remove box, so
a filled rectangle of cells is a single operation. Browsers receive the
operation log and materialize the view themselves — pure event sourcing — and
concurrent strokes converge regardless of arrival order. The share link is the
session URL, and the raw operation log is one click away as JSONL. Open
http://localhost:8081/ui/.
./scripts/demo-paint.sh # start the whiteboard (open the printed URL)
./scripts/build-paint.sh # (re)build the embedded UI (npm install + esbuild)
./scripts/itest-paint.sh # smoke test: Go tests + server serves the UI
./scripts/e2e-paint.sh # vitest unit tests + Playwright end-to-end tests- tldraw — open-source infinite canvas, real-time collaboration
- Excalidraw — infinite canvas, CRDT (Yjs) collaboration (source)
- Miro — infinite canvas, real-time collaboration
- FigJam — infinite canvas, real-time collaboration
- InfiniPaint — collaborative canvas with no zoom limit
- Endless Paper — single-user infinite canvas
- Prezi — the zoomable-canvas presentation paradigm
Everything is checked by scripts/quality-gate.sh and on CI:
gofumptformattinggolangci-lint(low-complexity and duplicate-code gates)- unit tests with the race detector and a 100% coverage gate
- property-based tests (
pgregory.net/rapid) checked against a biogo interval-tree oracle, plus Jepsen-style concurrent scenarios - fuzz smoke tests (Go native fuzzing)
- mutation testing (
gremlins, ≥80% efficacy)
Run it locally:
./scripts/test.sh # fast: unit tests + coverage report
./scripts/quality-gate.sh # full: format, lint, tests, property, fuzz, mutation
./scripts/update-dependencies.sh # bump every module to its latest deps./scripts/kurrent-up.sh # docker compose up -d (needs Docker)
./scripts/itest-kurrent.sh # integration tests (build tag kurrent)
./scripts/kurrent-down.sh