ygo

package module
v1.33.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: MIT Imports: 11 Imported by: 0

README

Ygo

Listed in the Yjs docs CI Coverage Go Reference Go Version Yjs Protocol Live Demo Codeberg Mirror

Pure-Go port of Yjs, the CRDT framework for collaborative applications, listed on the ports page of the Yjs documentation.

Ygo speaks the Yjs V1 and V2 wire formats byte for byte, apart from object key order, whole-number floats set from Go and a format attribute set to undefined (see Strings and values). The encoding is verified in both directions through 307 cross-language fixture scenarios generated from yjs@13.6.33. The bundled WebSocket server speaks the y-websocket protocol: JavaScript clients using y-websocket connect to it directly, and the Go client connects to the reference y-websocket server. Hocuspocus clients and servers do not interoperate with ygo today (see known gaps). No CGO; gomobile bind produces an Android AAR (built in CI) and an iOS xcframework (built by hand).

Highlights

  • Byte-level wire compatibility, verified in both directions. 307 cross-language fixture scenarios (generated from yjs@13.6.33) cover the V1 and V2 update formats, snapshots, subdocuments, undo, observers, relative positions, GC, awareness, the sync protocol, and the Any values JavaScript has and Go does not (undefined, BigInt, -0), JS to Go and Go to JS, plus 56 lib0 primitive vectors. The suite runs in CI on every push to main and every pull request, so a regression in either direction fails the build. The exceptions are the Any values listed under Strings and values.
  • Pure Go, no CGO. The CRDT core, the sync client and the server compile with CGO_ENABLED=0 for all 47 targets in go tool dist list, js/wasm and wasip1/wasm included. The SQLite-backed store in persist/sqlite, and with it yserve, builds only where modernc.org/sqlite does: not on wasm, AIX, DragonFly, illumos, Plan 9, Solaris, MIPS or big-endian ppc64 Linux, NetBSD other than amd64, or OpenBSD other than amd64 and arm64. Elsewhere, supply your own persist.Store (it is a seven-method interface). gomobile bind produces an Android AAR (built in CI on every push to main and every pull request) and an iOS xcframework (built by hand on 2026-11-10) carrying a full mobile SDK: editable Text / Map, undo, cursors, and a built-in background sync client (WebSocket + reconnect), so a Swift / Kotlin app only renders UI. No V8, no embedded JavaScript engine, no Rust FFI bridge.
  • Embeddable sync client, offline-first. The client package is a Go-native y-websocket provider: handshake, incremental updates, awareness, reconnect with backoff. With a LocalStore it persists the document to disk (pure-Go SQLite), loads it before any network so the app works offline, and syncs edits made offline up on reconnect. The building block for bots, CLI tools, server-side agents, and the mobile SDK (EnableOfflineStore).
  • The Yjs shared types. Map, Array, Text (rich-text formatting, Quill deltas, embeds), XML types, Awareness, UndoManager, Snapshots / time-travel, and Subdocuments. Not implemented: the legacy XmlHook, observers on XmlFragment and XmlElement, and Text.ObserveDeep.
  • Change observers. Map.Observe / Array.Observe / Text.Observe deliver Quill-style deltas of exactly what changed; ObserveDeep bubbles events from nested types with their path. Semantic parity with yjs's YMapEvent / YArrayEvent / YTextEvent. On mobile, ObserveChanges hands a native editor the delta as JSON.
  • One update per transaction. Doc.OnUpdate / OnUpdateV2 hand you each committed transaction as an update holding just its changes, the bytes yjs emits on doc.on('update') (known exceptions in the status table), with the transaction's origin; an append costs under 30 bytes however long the history. ApplyUpdateWithOrigin tags a peer's update so an echo guard and the UndoManager can tell it apart, and EncodeDiff catches a peer up from its own clock.
  • Compact encoding. Commit-time block squash collapses per-character edits into single items (about 1 byte per character in V1), and garbage collection frees deleted content at commit. On a 260k-edit real-world trace a document with a random client ID takes 377 KB in V1 and 160 KB in V2 (yjs: 311 KB and 160 KB; ygo's random client IDs are 53-bit, yjs 13's are 32-bit). With the same client ID, ygo and yjs produce documents of identical size in both formats.
  • Forward-looking wire handling. ygo handles 53-bit client IDs throughout (byte-verified above 2^32), the confirmed wire-level change in the yjs@14 release candidate, and decodes Skip structs in the update stream as no-op gaps (part of the wire format since yjs v13.5). Full attribution / IdMap support waits for the v14 format to stabilize.
  • Ready-to-run server: yserve. A self-hosted Yjs server in one binary, with no Node and no Redis. y-websocket clients connect unchanged (CI runs y-websocket 2.1 and 3.1 against it), and SQLite persistence and periodic document versioning are built in. The document is the URL path, slashes included; behind a proxy that keeps a path prefix, pass it as -path-prefix. It also embeds as a plain http.Handler inside an existing Go backend.
  • Scales horizontally, as a library. Several instances of the server package behind a load balancer converge on the same document through a pluggable backplane: edits and live cursors both cross instances, so two users on different servers collaborate as if on one. An in-process hub ships in the core; an opt-in NATS adapter (a separate module, so NATS stays out of the core's dependencies) spans machines, with a JetStream mode that resumes delivery across a broker reconnect or restart. Clustered instances share one Store and run the same ygo version. The yserve binary has no backplane option.
  • Federated transport over Matrix. An opt-in Matrix transport (a separate module, so mautrix stays out of a build that only wants the CRDT) turns an ordinary unencrypted Matrix room into the sync channel: peers publish updates as dev.ygo.update room events and merge what they read back, with no ygo server involved. A room is an append-only log with no ordering or exactly-once guarantee, which is exactly what Yjs updates tolerate. Room content is treated as untrusted — validated on both sides, decoded one event at a time so one bad event cannot break a whole page, encrypted rooms refused rather than silently written in the clear — though, as the module README spells out, Yjs updates do not authenticate deletes, so room membership is write access to the document. Exercised in CI against a real Dendrite on every push to main and every pull request. Its limits are documented rather than papered over.
  • EU-sovereign mirror on codeberg.org/Deln0r/ygo, auto-synced from GitHub on every push for adopters who prefer or require EU-hosted code infrastructure.

Live demo: open ygo.deln0r.com in two browser tabs and start typing. The page runs a stock y-websocket client in JavaScript against a pure-Go server.

Quick start

package main

import (
    "fmt"

    "github.com/Deln0r/ygo"
)

func main() {
    src := ygo.NewDoc()
    m := ygo.NewMap(src, "settings")

    txn := src.WriteTxn()
    m.Set(txn, "theme", "dark")
    m.Set(txn, "lang", "go")
    txn.Commit()

    // Encode the source doc's full state as wire bytes.
    update := ygo.EncodeStateAsUpdate(src)

    // Apply to a fresh peer doc — same bytes JS Yjs's Y.applyUpdate consumes.
    dst := ygo.NewDoc()
    if err := ygo.ApplyUpdate(dst, update); err != nil {
        panic(err)
    }

    dstMap := ygo.NewMap(dst, "settings")
    fmt.Println(dstMap.Get("theme")) // dark
}

For a collaborative server backend, see yserve, a stand-alone WebSocket server for y-websocket clients with SQLite persistence and document versioning, in one binary:

# CGO_ENABLED=0 makes the binary fully static on Linux
CGO_ENABLED=0 go install github.com/Deln0r/ygo/cmd/yserve@latest
yserve -addr :1234 -store data.db -version-interval 10m
Sending each change

Doc.OnUpdate hands you every committed transaction as a V1 update holding exactly that transaction's changes, the bytes of yjs's doc.on('update'), except in the pinned case listed in the status table below and for the Any values listed under Strings and values. Forward them to peers or append them to a log; an append costs a few bytes however long the document's history is. Apply what peers send with an origin, so the handler can tell their updates from yours:

type peer struct{ id string }
from := &peer{id: "ws-1"}

d.OnUpdate(func(update []byte, origin any) {
    if origin == from {
        return // came from that peer, do not send it back
    }
    broadcast(update)
})

err := ygo.ApplyUpdateWithOrigin(d, received, from)

OnUpdateV2 gives the V2 bytes, and ApplyUpdateV2WithOrigin applies V2 bytes with an origin. Handlers run at the end of the commit with the document lock held, in commit order; they must not open a transaction on the same document. To catch up a peer that has been away, send ygo.EncodeDiff(d, peerStateVector): it starts exactly at the peer's clock, as Y.encodeStateAsUpdate(doc, sv) does. Unlike yjs it does not append updates still waiting in the pending buffer for a missing dependency.

Undo / Redo

Wrap any shared types in an UndoManager to get scoped, grouped Undo / Redo:

d := ygo.NewDoc()
m := ygo.NewMap(d, "settings")

um := ygo.NewUndoManager(d, m) // watch m; defaults to local edits, 500ms grouping
defer um.Close()

txn := d.WriteTxn()
m.Set(txn, "theme", "dark")
txn.Commit()

um.Undo() // m no longer has "theme"
um.Redo() // "theme" == "dark" again

Only edits under the watched types made by a tracked origin are captured. The default tracks the nil origin, which local edits carry, but so does plain ApplyUpdate, exactly as in yjs: apply remote updates with ygo.ApplyUpdateWithOrigin(d, update, origin) to keep them out of the history, or track your own origin only (um.AddTrackedOrigin(mine), um.RemoveTrackedOrigin(nil)). Rapid edits inside the capture-timeout window collapse into one undo step; call um.StopCapturing() to force a boundary. The semantics match yjs@13.6.33's UndoManager, checked by cross-language conformance fixtures.

Snapshots / time-travel

Capture a point in a document's history and reconstruct it later. The source doc must have GC disabled so deleted content is retained:

d := ygo.NewDocWithOptions(ygo.Options{DisableGC: true})
txt := ygo.NewText(d, "t")

txn := d.WriteTxn()
txt.Insert(txn, 0, "world!")
txn.Commit()

snap := ygo.CreateSnapshot(d)         // mark this moment
saved := ygo.EncodeSnapshot(snap)     // persist it (byte-compatible with Y.encodeSnapshot)

txn = d.WriteTxn()
txt.Insert(txn, 0, "hello ")          // doc moves on
txn.Commit()

restored, _ := ygo.RestoreSnapshot(d, snap) // reconstruct the marked state
ygo.NewText(restored, "t").String()         // "world!"

The snapshot wire format (EncodeSnapshot / DecodeSnapshot) is byte-compatible with yjs@13.6.33's Y.encodeSnapshot, verified by cross-language fixtures including multi-client delete-set ordering. RestoreSnapshot mirrors Y.createDocFromSnapshot.

Subdocuments

Nest a Y.Doc inside a Map. The parent stores a reference (the subdoc's GUID); the subdocument's own content syncs as a separate update stream:

d := ygo.NewDoc()
m := ygo.NewMap(d, "m")

txn := d.WriteTxn()
sub := m.SetDoc(txn, "child") // nest a new subdocument
txn.Commit()

// after syncing the parent to another replica:
got, ok := m.GetDoc(d, "child") // got.GUID() == sub.GUID()

The ContentDoc wire format (GUID + options) is byte-compatible with yjs@13.6.33, verified by cross-language fixtures. Lifecycle events are observable via d.OnSubdocs (added / removed / loaded GUIDs per transaction); SetDocWithOptions(..., autoLoad) and subdoc.Load() drive the loaded set, so a sync provider knows which nested documents to fetch. Load is reported with the next transaction on the parent, where yjs reports it at once.

Strings and values

lib0 rejects strings that are not valid UTF-8, so ygo makes every string it is given valid at the call, before it becomes part of an item: each byte that does not start a valid UTF-8 sequence becomes U+FFFD, so UTF-16 indexes into the string you passed still point at the same places (values nested deeper than 1000 levels are not checked below that depth). An embed or format value that is not a plain value (strings, numbers, bools, nil, []byte, []any, map[string]any) is marshaled once, at the call, and stored as that JSON. A value passed to the API belongs to the document from then on: do not mutate it, or a value a read returns, after the call. Updates arriving with invalid UTF-8 are rejected (ygo.ErrInvalidUTF8); bytes an older release stored load as they were.

JavaScript's undefined, BigInt and -0 keep their identity: they read as ygo.Undefined{}, ygo.BigInt and a float64 negative zero, and are written as lib0 writes them, checked byte for byte against yjs in both directions. A BigInt set from Go reaches JavaScript as a BigInt, except in a V1 format attribute or embed: those are JSON, where a BigInt is a Number and loses digits beyond 2^53. Where ygo renders a value as JSON (gomobile), an undefined object entry is left out and an undefined array entry is null, as JSON.stringify does; a top-level undefined is null, -0 is 0, and a BigInt, where JSON.stringify throws, is a number.

Three differences remain. An object with more than one key comes back with its keys sorted, where yjs keeps their insertion order: clients online when the update arrives get the original bytes, but a client that receives the value from a Go peer's state (a late joiner, or after the server compacts) sees the sorted order. And a whole-number float64 set from Go is written as a float64, where lib0 writes a varint. A format attribute whose value is undefined itself reads as nil (null) and is written back as null, where yjs keeps the undefined.

Observing changes

Subscribe to a shared type to learn exactly what changed in each transaction, local or remote:

m := ygo.NewMap(d, "settings")
unsub := m.Observe(func(e *ygo.MapEvent) {
    for key, change := range e.Keys {
        // change.Action is "add" / "update" / "delete"; change.OldValue
        // holds the prior value for update / delete.
        fmt.Printf("%s %s\n", change.Action, key)
    }
})
defer unsub()

Array.Observe and Text.Observe deliver a Quill-style delta ({Insert, Delete, Retain} ops; Text ops carry formatting attributes). Map.ObserveDeep / Array.ObserveDeep fire for changes to nested types too, with each event's Path from the observed type to the change. The event semantics match yjs's YMapEvent / YArrayEvent / YTextEvent, cross-checked against captured reference output.

Status

Stable CRDT core, with known gaps. The CRDT engine, the V1 and V2 wire formats, and the type set above are validated bidirectionally against yjs@13.6.33 and exercised in CI on every push to main. The public API is considered stable for the v1.x line; changes follow semantic versioning, with new functionality as minor releases and breaking changes deferred to a future major.

Known gaps
  • Hocuspocus. @hocuspocus/provider cannot sync with the ygo server, and the Go client cannot sync with @hocuspocus/server (checked with provider 2.15, 3.4 and 4.8 and server 4.8). Hocuspocus starts every frame with the document name; ygo's frames start with the message type, as y-websocket's do.
  • Message size. The server reads client messages up to 4 MiB by default. A larger one (the first sync of a larger document) closes the connection with 1009, and the client reconnects and resends it in a loop. Raise ReadLimit (yserve -read-limit) above your largest document.
  • Any values. An object with more than one key comes back with its keys sorted, and a format attribute set to undefined comes back as null; see Strings and values.
  • Speed. Applying edits is about 20× slower than yjs on the same machine. See Benchmarks.
Status by layer
Layer Status
internal/lib0 varint + RLE encoding done; verified byte-equivalent vs JS lib0@0.2.119 (40 + 16 fixtures)
internal/block (Item, Content, Branch, Splice, Integrate-YATA, TrySquash, Repair, search markers) done; full YATA conflict resolution + per-branch LRU position cache
internal/store (BlockStore, ItemSlice, Materialize) done
internal/doc (Doc, Transaction, TransactionMut) done; lock semantics + root-branch registry
internal/encoding (StateVector, IdSet, Update encode/decode/apply, Pending buffer, V1 + V2 codecs) done; JS Yjs → Go proven by 33 V1 + 37 V2 fixture scenarios; Go → JS proven by 74 reverse fixtures (Map / Array / Text / XmlFragment)
internal/utf16 (UTF-16 length / byte-offset / surrogate-aware split) done
internal/types/Map (Set / Get / Delete / Has / Len / Range / Clear + SetMap / SetArray / SetText) done; nested-type construction supported
internal/types/Array (Insert / InsertRange / Push / Delete / Get / Len / Range / ToSlice + InsertMap / InsertArray / InsertText) done; nested-type construction supported
internal/types/Text (Insert / Delete / String / Length + InsertWithAttributes / Format / InsertEmbed / Range / ToDelta / ApplyDelta) done; full rich-text + Quill delta batch API
Nested-type construction (Map-in-Map, Array-in-Map, etc., to arbitrary depth) done; ContentType wire format + Repair ParentID resolution + pending-queue retry
internal/types/Xml* (XmlFragment, XmlElement, XmlText) done; ProseMirror/Tiptap/BlockNote unblocked. XmlHook (legacy) deferred.
Persistence (Store interface + modernc.org/sqlite reference impl) done; append-only update log, Flush compaction, LoadDoc / GetStateVector / GetDiff helpers; pure-Go (no CGO). The sqlite backend runs in WAL mode with a busy timeout for concurrency-safe writes
Update-level utilities (MergeUpdates / MergeUpdatesV2 / DiffUpdate / EncodeStateVectorFromUpdate) done; yjs-parity helpers to merge, diff, and compute a state vector from update bytes, for server-side compaction and diffing. EncodeStateVectorFromUpdate reads the bytes directly; the merge and diff helpers reconstruct a document internally. Merging preserves blocks whose causal ancestor is absent from the set, writing Skip runs for the holes exactly as yjs does, so an incomplete set is coalesced rather than silently truncated
y-sync protocol (internal/sync) done for y-websocket clients (Sync + Awareness + QueryAwareness). The Hocuspocus message types (Auth, Stateless, BroadcastStateless, Close, SyncStatus) are implemented on the same frames, without Hocuspocus's document-name prefix, so stock Hocuspocus clients cannot use them; per-document Auth permission scoping deferred (tech-debt)
Awareness (internal/awareness) done; LWW presence map, JSON wire payload per y-protocols, self-eviction defense, SweepOutdated, decode caps + per-room client cap + tombstone GC (DoS hardening)
server/ (WebSocket sync server) done; http.Handler mount-anywhere shape, per-doc broadcaster, persists every applied update to optional persist.Store. Connection lifecycle hooks (OnConnect / OnDisconnect / OnChange / OnLoadDocument / OnAuthenticate / OnStateless; the last two fire only for Hocuspocus message types, which stock clients do not send on these frames, so authenticate at the upgrade with OnConnect), read-only viewers (ReadOnly), resource caps (MaxConnsPerDoc / MaxDocs / MaxAwarenessClients), a send queue and writer per connection so a slow client never delays its room (one that falls behind is closed and resyncs: MaxSendQueue / MaxSendQueueBytes / WriteTimeout), a Stats load snapshot, and awareness disconnect tombstones + periodic presence sweep (AwarenessTimeout)
server/backplane (multi-instance clustering) done; pluggable pub/sub Backplane so instances behind a load balancer converge on one document, carrying both document updates and presence (relayed presence held to its own origin-partitioned share of the per-room cap, so a flood on one instance cannot starve local clients on another). In-process Memory hub in the core; the opt-in NATS adapter is a separate module (so NATS stays out of the core's dependencies) offering core-NATS at-most-once or a JetStream mode that resumes across a broker reconnect or restart. Requires a shared Store, and all instances in a cluster must run the same version. Library only: yserve has no backplane option
integration/matrix (federated transport) done; opt-in separate module carrying updates as dev.ygo.update room events over a Matrix room, no ygo server involved. Backward /messages pagination from the /sync prev_batch token (an empty page is not the end of history), per-call event-ID dedup, ygo.ValidateUpdate on both publish and read, per-event decoding so one hostile event costs one event rather than a whole page, an event-size ceiling, refusal of end-to-end-encrypted rooms, and a /messages fallback when /sync is unusable. Unit suite against a strict in-process double plus a real Dendrite in CI on every push to main and every pull request. Deliberately thin: whole blobs, no state-vector diffing, no compaction — see its README for what that costs
yserve (server binary for y-websocket clients) done; one binary with SQLite persistence (-store) and periodic document versioning (-version-interval / -keep-versions); cmd/ygo-server remains as a deprecated alias
gomobile/ (app-level mobile SDK for iOS/Android) done; pure-Go (no CGO) bindable SDK with two levels: the full shared type set — Text (UTF-16 mutators), rich text (ApplyDelta / Format / InsertEmbed — Quill deltas in, symmetric with ObserveChanges), Map and Array (typed JSON values, nested types), and XmlFragment / XmlElement / XmlText (ProseMirror / Tiptap) — plus UndoManager (tracking the origin of a WithOrigin handle, so a collaborator's update applied with ApplyUpdateWithOrigin stays out of the user's undo), cursor anchors (relative positions), live collaborator presence (ObservePresence / PresenceStates delivering remote cursors and identity as JSON), and an embedded sync Client (NewClient / Connect / Listener) so a Swift / Kotlin app edits the Doc and renders UI while sync runs in the background; plus the bytes-in/bytes-out wire layer for custom transports, with ObserveUpdates delivering each transaction's update. CI binds the Android .aar (all four ABIs) on every push to main and every pull request. gomobile bind -target=ios,iossimulator last ran by hand on 2026-11-10 (Xcode 26.5, Go 1.26.3) and produces Ygo.xcframework (arm64 + simulator); the iOS bind is not run in CI (the pure-Go package compiles in CI, guarding against CGO leak). See gomobile/README.md for commands.
V2 update encoding done; lib0 RLE primitives + column encoder/decoder + Update.{EncodeV2,DecodeV2} + public ygo.{EncodeStateAsUpdateV2,EncodeDiffV2,ApplyUpdateV2}; bidirectional cross-language fixtures vs yjs@13.6.33
Untrusted-input hardening done; every wire-supplied element count in the V1/V2 update, snapshot, id-set, and Any-content decoders is bounded against the input length, closing a length-prefix amplification DoS (a few bytes forcing a multi-terabyte allocation). Continuous fuzzing of the decode + apply paths runs nightly in CI (fuzz.yml) with the discovered crashers committed as regression corpus
dmonad/crdt-benchmarks B1-B4 port done; B1.1-B1.11 / B2.1-B2.4 / B3.1+3+4 / B4 (260k-edit real-world LaTeX trace). Baseline in BENCHMARKS.md.
UndoManager (internal/undo) done; scoped Undo / Redo over Map / Array / Text with capture-timeout grouping, tracked-origin filtering (AddTrackedOrigin / RemoveTrackedOrigin), and a Redone chain for deletion restore, deleted nested types included: the type comes back as a new copy with its content, as in yjs. A subdocument reference is not restored, on its own or inside a deleted type, where yjs restores a new reference with the same GUID and options. Cross-language conformance vs yjs@13.6.33: 27 final-state scenarios, plus 46 undo scenarios in the update-event set, nested types above all, that check every update, return value and stack state on the way
Per-transaction updates (Doc.OnUpdate / OnUpdateV2, ApplyUpdateWithOrigin) done; the yjs update / updateV2 events, emitted after GC with the transaction's origin (byte for byte apart from the pinned cases here and the Any values listed under Strings and values); EncodeDiff / EncodeDiffV2 cut the first block at the remote clock as yjs does, but leave out the pending buffer, which yjs appends. 112 cross-language scenarios, including the format cleanup yjs runs after a remote transaction and updates whose blocks wait on each other, each checked for the order of the types every transaction changes and replayed as V1, V2 and both by turns, 1 pinned divergence (a root first read as text after formatted text arrived inserts with the attributes in effect, where yjs can insert at a search marker without them)
Snapshots (CreateSnapshot / EncodeSnapshot / RestoreSnapshot) done; V1 wire format byte-compatible with yjs@13.6.33 (cross-language fixtures incl. multi-client), RestoreSnapshot mirrors Y.createDocFromSnapshot
Subdocuments (Map.SetDoc / Map.GetDoc) done; ContentDoc wire format (GUID + options) byte-compatible with yjs@13.6.33, cross-language fixtures. Lifecycle events via OnSubdocs / autoLoad / Load
Wire client-ID width 53-bit client IDs throughout (uint64 + varint), byte-verified against yjs@13.6.33 for IDs above 2^32. Forward-compatible with the wider client-ID space yjs@14 introduces
Commit-time block squash done; merges same-client adjacent-clock items at commit (~1 byte/char V1), paired with Apply-side partial-overlap slicing for correct remote integration of merged blocks
GC merging done; deleted content is freed at commit (ContentDeleted, byte-aligned with yjs) and adjacent deleted runs are merged. Deleting a nested shared type recursively collapses its whole subtree into garbage-collected runs (cross-language fixtures), matching yjs. Skipped when GC is disabled or for items an UndoManager keeps
Relative positions (cursors) done; CreateRelativePositionFromTypeIndex / CreateAbsolutePositionFromRelativePosition, binary form byte-compatible with Y.encodeRelativePosition, follows undone deletions; cross-language fixtures incl. surrogate pairs and 53-bit client IDs
Versioned persistence done; named point-in-time versions independent of the live log (persist.VersionStore: save / list / load / restore / prune), atomic restore, sqlite reference implementation
Change observers done; Map.Observe (YMapEvent: add / update / delete + oldValue), Array.Observe and Text.Observe (Quill-style insert / delete / retain delta, Text formatting-aware), Map/Array.ObserveDeep (event-path bubbling from nested types). Deltas cross-checked against captured yjs@13.6.33 output; fire on local and remote transactions, in first-change order, with one deep call per transaction, checked against yjs by 19 observer scenarios. gomobile Text/Map.ObserveChanges deliver the delta as Quill JSON

Goals

  1. Binary protocol compatibility with Yjs v13.x in both V1 and V2 wire formats. Byte-for-byte. JS clients sync with Go servers and vice versa, bidirectionally verified.
  2. Idiomatic Go API. Callbacks for events, explicit transactions, error returns.
  3. Pure Go. No CGO. gomobile bind works for iOS and Android.
  4. Pluggable persistence with modernc.org/sqlite reference implementation.
  5. Performance within 2× of yrs on dmonad/crdt-benchmarks B1-B4. Not verified: no run against native yrs exists, and on B4 ygo is about 20× slower than yjs on the same machine. See Benchmarks.

Non-goals

  • C-FFI surface. Yrs already provides this; Ygo's unique value is pure-Go native binaries.
  • Drop-in replacement for the Node.js Yjs runtime. Ygo is the Go port; use yjs itself if you want a JavaScript runtime.
  • Loro, Automerge, RGA, or other CRDT designs. Ygo implements the Yjs wire format, period.

Wire compatibility

The single most-important guarantee of this project is byte-level wire compatibility with yjs@13.x. This is enforced by 307 cross-language fixture scenarios (plus 56 lib0 primitive vectors):

  • 33 V1 forward fixtures (testdata/yjs-updates.json) — JS Yjs encodes via Y.encodeStateAsUpdate, Go decodes and applies, state matches.
  • 37 V2 forward fixtures (testdata/yjs-update-v2-fixtures.json) — same with Y.encodeStateAsUpdateV2.
  • 74 reverse fixtures (testdata/go-updates.json + go-update-v2-fixtures.json) — Go encodes via EncodeStateAsUpdate / EncodeStateAsUpdateV2, JS Yjs decodes via Y.applyUpdate / Y.applyUpdateV2, state matches.
  • 163 feature fixtures — XML (5), awareness (6), sync protocol (6), undo (27), snapshots (4), subdocuments (3), wire edge cases incl. 53-bit client IDs (3), nested-type GC (4), relative positions (11), observers (19, which observers fire and in what order), rich text (55, what inserts, embeds, formatting, deletes and Quill deltas do to a formatted text, compared as deltas, strings and events, not as bytes; the update-event set below compares rich-text updates byte for byte), Any values (20: undefined, BigInt and -0 as map values, inside arrays and objects, in a subdocument's options, and in V2 format attributes and embeds, checked as values and byte for byte both ways), all captured from the pinned JS reference and byte-compared in both directions where the feature has a Go encoder.

One more fixture set compares bytes with yjs but stays out of these totals while any of its scenarios is a pinned divergence: per-transaction updates and diffs (112 scenarios, every OnUpdate / OnUpdateV2 event and EncodeDiff / EncodeDiffV2 against state vectors that end inside a block, undo of nested types, rich-text inserts and deltas, and the format cleanup after remote transactions among them; 1 pinned). Each pin is listed with its cause in the test and fails the build when the behaviour changes, in either direction.

The fixtures regenerate from pinned yjs@13.6.33 + lib0@0.2.119 + y-protocols@1.0.7 on every CI run; git diff --exit-code testdata/ catches byte-level regressions.

Byte equality does not extend to objects with more than one key, whose keys ygo sorts, to whole-number float64s set from Go, or to a format attribute set to undefined, which ygo writes back as null (see Strings and values).

How is this different from Hocuspocus / y-websocket / y-leveldb?

Project Runtime What it provides Relationship to Ygo
yjs (npm) Node / browser The reference CRDT implementation Ygo's wire-format target
y-websocket Node Reference WebSocket server yserve is a Go-native equivalent
Hocuspocus Node Production WebSocket server with auth, persistence, extensions Not interoperable with ygo today: its frames start with the document name, ygo's do not
yrs Rust Reference Rust port Ygo's executable spec for porting decisions
y-leveldb, y-indexeddb Node / browser Persistence backends Ygo's persist/sqlite is a Go-native equivalent
Ygo Go CRDT engine + WS server + persistence in one monorepo, pure-Go for native mobile This project

If you run a y-websocket deployment and want to move the server side to Go (no Node runtime, one binary), ygo is an option; read the known gaps first. A Hocuspocus deployment cannot switch without changing its clients. If you're starting fresh and your team is comfortable with Node, Hocuspocus is the mature choice.

Benchmarks

See BENCHMARKS.md for the full table. B4 (259,778-edit real-world LaTeX paper trace) on one Apple M3, measured on 2026-11-08 with ygo v1.31.0 on Go 1.26.3 and yjs 13.6.33 on Node 26.6, all edits in one transaction with client ID 1:

Metric Ygo yjs
Apply all edits 20.3-21.4 s 1.0-1.3 s
Encoded doc size, V1 223 KB 223 KB
Encoded doc size, V2 160 KB 160 KB
Encode V1 / V2 0.8 ms / 7.5 ms 2.4 ms / 1.7 ms
Parse V1 / V2 4.6 ms / 4.8 ms 5.6 ms / 4.6 ms

How to read this:

  • Applying edits is where ygo is behind: about 20× slower than yjs on the same machine. A transaction per edit with a random client ID and an update handler, which is closer to real use, takes 23.4 s against 1.0 s.
  • Document sizes match yjs when the client IDs match. With a random client ID, ygo's V1 document is 377 KB and yjs's 311 KB, because ygo's random IDs are 53-bit and yjs 13's are 32-bit. V2 stays at 160 KB for both.
  • Encode and parse are in the same range as yjs, except V2 encode, which is about 4× slower.

No head-to-head run against native yrs exists.

Roadmap

Shipped since v1.0 (June 2026): a sync server with connection lifecycle hooks, read-only viewers, resource caps and a stats snapshot, horizontal scaling across instances with cross-cluster cursors, an offline-first Go client, WAL-backed SQLite persistence, continuous fuzzing, the yjs update-level utilities, and per-transaction update events with origins (1.22.0). See the CHANGELOG, the releases, and examples/.

Open: the known gaps above, including interoperability with Hocuspocus, a documentation site, an external security audit, and yjs v14 wire features (attribution / IdMap) once v14 reaches GA.

Per-layer port notes live in docs/yrs-port-notes/. Items intentionally deferred or partial are tracked in docs/tech-debt.md. Detailed design decisions in DESIGN.md.

Examples

Runnable examples in examples/:

  • collab-server — embed the WebSocket sync server and wire its hooks (OnConnect, ReadOnly, OnChange, OnLoadDocument), resource caps, a SQLite store, and a /stats endpoint.
  • collab-client — a Go-native sync client: connect, observe remote changes, edit, converge.
  • offline-first — client-side offline persistence with a LocalStore: usable with no network, edits survive restarts and sync up on reconnect.

The core CRDT API also has output-verified runnable examples on pkg.go.dev (Map, Array, Text, two-document sync, per-transaction updates, UndoManager).

Documentation

License

MIT. See LICENSE.

Acknowledgements

Documentation

Overview

Package ygo is a pure-Go port of the Yjs CRDT framework.

ygo is binary-protocol compatible with the npm yjs package (V1 update encoding), allowing JavaScript clients to synchronize seamlessly with Go servers and vice versa.

Pure-Go means no CGO, so gomobile bind works for iOS/Android targets.

The public API is stable for the v1.x line: new functionality lands as a minor release and breaking changes are deferred to a future major, per semantic versioning. The wire formats are validated bidirectionally against yjs on every push.

Strings and values

lib0 reads every string with a fatal UTF-8 decoder, so ygo makes every string it is given valid before it becomes part of an item: each byte that does not start a valid UTF-8 sequence becomes U+FFFD. That covers text, keys, names, values, format attributes, embeds, awareness states and the protocol strings the server and client send; UTF-16 indexes into the string a caller passed still point at the same places. Lookups by a key with such bytes find the fixed key. Values nested deeper than 1000 levels are not checked below that depth.

A value passed to the API belongs to the document from then on. Do not mutate it after the call, nor a value a read returns, nor an event's delta, nor the Branch and Item internals: the document encodes what they hold, so a change there can put invalid bytes, or other inconsistencies, on the wire. ygo does not copy values defensively: a plain value (strings, numbers, bools, nil, []byte, []any, map[string]any) is copied only where a string in it needs fixing, and an embed or format value of any other type is marshaled once, at the call, and stored as that JSON (a json.RawMessage).

See https://github.com/Deln0r/ygo for documentation and examples.

Example

Example is the basic shape: create a document, edit a shared type inside a write transaction, and read the value back.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	doc := ygo.NewDoc()
	settings := ygo.NewMap(doc, "settings")

	txn := doc.WriteTxn()
	settings.Set(txn, "theme", "dark")
	settings.Set(txn, "fontSize", int64(14))
	txn.Commit()

	fmt.Println(settings.Get("theme"))
	fmt.Println(settings.Get("fontSize"))
}
Output:
dark
14
Example (Array)

Example_array shows the shared Array: an ordered sequence you append to and read positionally.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	doc := ygo.NewDoc()
	todo := ygo.NewArray(doc, "todo")

	txn := doc.WriteTxn()
	todo.Push(txn, "buy milk", "write code")
	txn.Commit()

	fmt.Println(todo.Len())
	fmt.Println(todo.Get(0))
	fmt.Println(todo.Get(1))
}
Output:
2
buy milk
write code
Example (Sync)

Example_sync shows the core CRDT property: two documents that never talked to each other each apply the other's update and converge on the same merged state, independent of order.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	// Peer A sets a title on its copy.
	a := ygo.NewDoc()
	am := ygo.NewMap(a, "doc")
	wa := a.WriteTxn()
	am.Set(wa, "title", "Hello")
	wa.Commit()

	// Peer B sets a different key on its own copy.
	b := ygo.NewDoc()
	bm := ygo.NewMap(b, "doc")
	wb := b.WriteTxn()
	bm.Set(wb, "author", "Ada")
	wb.Commit()

	// Exchange full-state updates and apply each to the other.
	if err := ygo.ApplyUpdate(a, ygo.EncodeStateAsUpdate(b)); err != nil {
		panic(err)
	}
	if err := ygo.ApplyUpdate(b, ygo.EncodeStateAsUpdate(a)); err != nil {
		panic(err)
	}

	// Both converge to the same merged state.
	fmt.Println(am.Get("title"), am.Get("author"))
	fmt.Println(bm.Get("title"), bm.Get("author"))
}
Output:
Hello Ada
Hello Ada
Example (Text)

Example_text shows the shared Text: a collaborative string edited by index. Inserts commute so concurrent edits converge.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	doc := ygo.NewDoc()
	note := ygo.NewText(doc, "note")

	txn := doc.WriteTxn()
	_ = note.Insert(txn, 0, "world")
	_ = note.Insert(txn, 0, "hello ")
	txn.Commit()

	fmt.Println(note.String())
}
Output:
hello world
Example (Undo)

Example_undo shows the built-in UndoManager: track a shared type, edit it, then step backward and forward through the edit history.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	doc := ygo.NewDoc()
	m := ygo.NewMap(doc, "doc")
	undo := ygo.NewUndoManager(doc, m)

	txn := doc.WriteTxn()
	m.Set(txn, "title", "draft")
	txn.Commit()
	fmt.Println(m.Get("title"))

	undo.Undo()
	fmt.Println(m.Has("title"))

	undo.Redo()
	fmt.Println(m.Get("title"))
}
Output:
draft
false
draft
Example (Updates)

Example_updates shows Doc.OnUpdate: every committed transaction arrives as one update holding just that transaction's changes, the bytes yjs emits on doc.on('update'). A sync provider forwards its own and skips what a peer sent, which it recognises by the origin it applied it with.

package main

import (
	"fmt"

	"github.com/Deln0r/ygo"
)

func main() {
	type peer struct{ name string }
	remote := &peer{name: "ws-1"}

	d := ygo.NewDoc()
	text := ygo.NewText(d, "notes")
	var outgoing [][]byte
	d.OnUpdate(func(update []byte, origin any) {
		if origin == remote {
			return // came from that peer, do not send it back
		}
		outgoing = append(outgoing, update)
	})

	for _, s := range []string{"hello", " world"} {
		txn := d.WriteTxn()
		if err := text.Insert(txn, text.Length(), s); err != nil {
			panic(err)
		}
		txn.Commit()
	}

	// The peer replays what was sent, then appends "!" itself.
	p := ygo.NewDoc()
	pt := ygo.NewText(p, "notes")
	for _, u := range outgoing {
		if err := ygo.ApplyUpdate(p, u); err != nil {
			panic(err)
		}
	}
	since := ygo.EncodeStateVector(p)
	txn := p.WriteTxn()
	if err := pt.Insert(txn, pt.Length(), "!"); err != nil {
		panic(err)
	}
	txn.Commit()
	fromPeer, err := ygo.EncodeDiff(p, since)
	if err != nil {
		panic(err)
	}
	if err := ygo.ApplyUpdateWithOrigin(d, fromPeer, remote); err != nil {
		panic(err)
	}

	fmt.Println(len(outgoing), "updates sent")
	fmt.Println(text.String())
}
Output:
2 updates sent
hello world!

Index

Examples

Constants

View Source
const (
	ChunkString = types.ChunkString
	ChunkEmbed  = types.ChunkEmbed
)

Text.Range emits chunks of either of these kinds.

View Source
const DefaultAwarenessTimeout = awareness.DefaultTimeout

DefaultAwarenessTimeout is the y-protocols convention for stale awareness-entry eviction (30 seconds). Pass to Awareness.SweepOutdated.

View Source
const MaxClientID = doc.MaxClientID

MaxClientID is the upper bound on Doc.ClientID values (2^53 - 1, matching JS Yjs's safe-integer range).

View Source
const Version = "1.33.0"

Version is the ygo release this source tree corresponds to. It is set as part of the release ceremony, so a checkout of main between releases carries the last released value.

Variables

View Source
var ErrInvalidUTF8 = lib0.ErrInvalidUTF8

ErrInvalidUTF8 is wrapped by the errors ApplyUpdate, ApplyUpdateV2, ValidateUpdate, MergeUpdates, MergeUpdatesV2, Awareness.Apply and DecodeRelativePosition return when a string in the bytes is not valid UTF-8. lib0 reads every string with a fatal UTF-8 decoder, so yjs rejects the same bytes; accepting them would let one update keep every JS client of a document from loading it. Test with errors.Is.

View Source
var ErrSnapshotGC = errors.New("ygo: RestoreSnapshot requires the source Doc to have GC disabled (NewDocWithOptions with DisableGC: true)")

ErrSnapshotGC is returned by RestoreSnapshot when the source Doc has garbage collection enabled, which can discard content a snapshot needs to reconstruct.

View Source
var ErrSplitSurrogate = lib0.ErrSplitSurrogate

ErrSplitSurrogate is wrapped by the error ApplyUpdateV2 and MergeUpdatesV2 return for a V2 update whose string column cuts a surrogate pair in two: two JS strings, one ending in a lone high surrogate and the next starting with a lone low one, which yjs accepts. A Go string cannot hold a lone surrogate, and replacing the halves with U+FFFD would change keys and names yjs keeps apart, so ygo refuses the update. The V1 encoding of the same document (which writes the halves as U+FFFD) applies.

Functions

func ApplyUpdate

func ApplyUpdate(d *Doc, raw []byte) error

ApplyUpdate decodes raw and integrates it into d. Items whose dependencies the local store has not yet seen - an unseen Origin, RightOrigin or Parent, or a gap in the item's own client clock - queue in the per-doc pending buffer and drain automatically on subsequent ApplyUpdate calls that satisfy them.

It decodes ONE update and ignores any bytes after it, so a buffer holding two appended updates applies only the first and loses the second with no error (yjs behaves identically). Run ValidateUpdate first on anything you did not encode yourself.

Use HasPending / MissingSV to inspect the queue.

func ApplyUpdateV2

func ApplyUpdateV2(d *Doc, raw []byte) error

ApplyUpdateV2 decodes V2 wire bytes and integrates them into d. Pending-buffer semantics identical to ApplyUpdate (V1) — items missing causal dependencies queue silently and drain on subsequent ApplyUpdate / ApplyUpdateV2 calls.

Same trailing-byte behaviour as ApplyUpdate: one update is read and the rest of the buffer is ignored. There is no strict validator for V2 bytes, because the V2 decoder does not report a remainder - so for V2 the check has to be "I encoded this myself".

V1 and V2 are NOT wire-interchangeable, and the two mistakes fail differently. ApplyUpdateV2 on V1 bytes errors loudly. ApplyUpdate on V2 bytes does NOT: the V1 decoder reads the V2 prefix as a complete, empty update and returns nil, so the call is a silent no-op (measured). There is no autodetect; the caller must know which version they have via the surrounding transport metadata.

func ApplyUpdateV2WithOrigin added in v1.22.0

func ApplyUpdateV2WithOrigin(d *Doc, raw []byte, origin any) error

ApplyUpdateV2WithOrigin is ApplyUpdateV2 with origin as the transaction's Origin; see ApplyUpdateWithOrigin.

func ApplyUpdateWithOrigin added in v1.22.0

func ApplyUpdateWithOrigin(d *Doc, raw []byte, origin any) error

ApplyUpdateWithOrigin is ApplyUpdate with origin as the transaction's Origin, the third argument of yjs Y.applyUpdate. Observers, OnUpdate handlers and UndoManager origin filters see it, which is how a sync provider keeps from sending a peer's update back out:

d.OnUpdate(func(update []byte, origin any) {
	if origin != peer {
		broadcast(update)
	}
})
err := ygo.ApplyUpdateWithOrigin(d, fromPeer, peer)

origin must be a comparable value (a string, a pointer, a struct of comparable fields) if an UndoManager tracks origins by it.

func DiffUpdate added in v1.13.0

func DiffUpdate(update, remoteSV []byte) ([]byte, error)

DiffUpdate returns the part of a V1 update that a peer identified by remoteSV (a wire-encoded state vector, e.g. from EncodeStateVector or EncodeStateVectorFromUpdate) is missing. Mirrors yjs diffUpdate: trim a stored update before sending it to a peer that already has part of it.

It reconstructs the update's state and diffs against remoteSV, cutting the first block of each client at the remote clock like EncodeDiff. The update is integrated first, so blocks commit-time squash can merge come out merged, where yjs diffUpdate keeps the input's blocks. Blocks whose dependencies are absent from the input are DROPPED rather than carried: unlike MergeUpdates, this does not preserve the pending buffer. That is fine for the intended use - trimming a self-contained update before sending it - and lossy for anything else, including the output of MergeUpdates over an incomplete set, which by design can contain blocks that do not integrate on their own. Do not use DiffUpdate to re-trim such an update.

func EncodeDiff

func EncodeDiff(d *Doc, remoteSVBytes []byte) ([]byte, error)

EncodeDiff returns the wire-encoded V1 update covering the blocks d has that the remote (per remoteSVBytes) does not. A nil remoteSVBytes is treated as the empty SV — emit everything. Each client's first block is cut at the remote's clock, so the bytes are those of Y.encodeStateAsUpdate(doc, sv), except that yjs also appends updates its pending buffer holds; the delete set is the whole document's, as there. For one transaction's changes alone, use Doc.OnUpdate.

remoteSVBytes is the V1 wire-encoded form of the remote's state vector (the same shape EncodeStateVector produces).

func EncodeDiffV2

func EncodeDiffV2(d *Doc, remoteSVBytes []byte) ([]byte, error)

EncodeDiffV2 is the V2 analogue of EncodeDiff. State-vector argument shape is identical (still V1 wire-encoded SV); only the outgoing update bytes use the V2 column layout.

func EncodeRelativePosition added in v1.1.0

func EncodeRelativePosition(rpos RelativePosition) ([]byte, error)

EncodeRelativePosition serialises rpos to the yjs binary form (byte-compatible with Y.encodeRelativePosition). Errors only on a zero-value rpos with no anchor set.

func EncodeSnapshot added in v0.10.0

func EncodeSnapshot(s Snapshot) []byte

EncodeSnapshot returns the V1 wire encoding of s, byte-compatible with yjs `Y.encodeSnapshot`.

func EncodeStateAsUpdate

func EncodeStateAsUpdate(d *Doc) []byte

EncodeStateAsUpdate returns the wire-encoded V1 update carrying the doc's full state. Apply to a fresh peer doc to bring it up to speed in one shot; same bytes interoperate with JS Yjs's Y.encodeStateAsUpdate.

func EncodeStateAsUpdateV2

func EncodeStateAsUpdateV2(d *Doc) []byte

EncodeStateAsUpdateV2 returns the wire-encoded V2 update carrying the doc's full state. V2 is the column-oriented alternative wire format used by Y.encodeStateAsUpdateV2 / Hocuspocus-V2 paths and some adopters' on-disk persistence layers (y-leveldb, y-indexeddb, SQLite/Postgres Hocuspocus adapters).

V1 and V2 are NOT wire-interchangeable — see ApplyUpdateV2.

func EncodeStateVector

func EncodeStateVector(d *Doc) []byte

EncodeStateVector returns the wire-encoded V1 state vector of d. Sync-protocol callers send this to peers as "here's what I have; send me everything else."

func EncodeStateVectorFromUpdate added in v1.13.0

func EncodeStateVectorFromUpdate(update []byte) ([]byte, error)

EncodeStateVectorFromUpdate computes the state vector a V1 update advances to, directly from the update bytes without reconstructing a document. Mirrors yjs encodeStateVectorFromUpdate: index or diff stored updates server-side without loading the full document into memory.

func EqualSnapshots added in v0.10.0

func EqualSnapshots(a, b Snapshot) bool

EqualSnapshots reports whether two snapshots are identical.

func HasPending

func HasPending(d *Doc) bool

HasPending reports whether d has any queued items awaiting causal dependencies.

func MergeUpdates

func MergeUpdates(updates [][]byte) ([]byte, error)

MergeUpdates coalesces V1 update blobs into a single equivalent V1 update. Returns nil for an empty input - nil is not itself a valid update, so guard the length before feeding the result onward.

Nothing is dropped. A block whose causal dependencies are absent from the merged set is preserved, and the clocks nobody supplied come out as Skip runs, which is how the wire format expresses a hole and what yjs mergeUpdates emits for the same input. Two consequences worth knowing: the output may contain Skip runs where an ordinary update would not, and applying it to an empty document may leave part of it queued in the pending buffer until the missing history arrives. Carrying is not integrating.

Used by persistence layers for compaction (Flush) and by transports that want to batch-coalesce updates before sending.

func MergeUpdatesV2 added in v1.13.0

func MergeUpdatesV2(updates [][]byte) ([]byte, error)

MergeUpdatesV2 is the V2 counterpart of MergeUpdates, with the same contract: nothing is dropped, holes come out as Skip runs, and the result may not integrate fully into an empty document until the missing history arrives. Returns nil for an empty input.

The two formats carry a Skip's length differently - V2 puts it in the rest stream rather than the len column - so the emitters are separate code paths with separate tests.

func MissingSV

func MissingSV(d *Doc) []byte

MissingSV returns the wire-encoded V1 state vector to ask a peer with for what d's pending buffer waits on: for each client a queued block or delete needs, the clock d has for it. A peer's EncodeDiff against it carries everything from there on. An empty result means the queue is empty.

Sync-protocol callers send this to peers as a re-fetch request, in place of the document's state vector.

func ValidateUpdate added in v1.18.0

func ValidateUpdate(raw []byte) error

ValidateUpdate returns an error unless raw is a well-formed V1 update that is consumed in its entirety, WITHOUT integrating it into any document. Use it on updates that arrive from somewhere you do not control.

It is stricter than ApplyUpdate in the one way that matters for untrusted input: ApplyUpdate decodes a single update and silently ignores whatever bytes follow it. So `append(a, b...)` applies only a, loses b, and reports no error anywhere - the reference implementation (yjs 13.6.32, measured) behaves the same, which is why ApplyUpdate keeps that behaviour and this separate check exists. Combine updates with MergeUpdates, never with append. Note the boundary: MergeUpdates returns nil for an empty batch, and nil is not a valid update, so guard the length before validating.

Validating costs a decode, not an integrate: it does not build a document or run conflict resolution. It does allocate in proportion to the input, so a network-facing caller should bound the payload size before calling rather than treating this as a free filter on unbounded bytes.

V1 only. The V2 decoder does not report a trailing remainder, so there is no equivalent strict check for V2 bytes; passing V2 bytes here is reported as such rather than misdiagnosed.

Types

type AbsolutePosition added in v1.1.0

type AbsolutePosition = types.AbsolutePosition

AbsolutePosition is a resolved RelativePosition: the shared type's branch and the current numeric index within it.

func CreateAbsolutePositionFromRelativePosition added in v1.1.0

func CreateAbsolutePositionFromRelativePosition(d *Doc, rpos RelativePosition) (AbsolutePosition, bool)

CreateAbsolutePositionFromRelativePosition resolves rpos against the current state of d. ok is false when the anchor refers to state this replica has not yet seen or to a garbage-collected range.

Acquires the doc's locks internally; calling it while holding an open Transaction / TransactionMut on the same doc from the same goroutine deadlocks (Go RWMutex is not re-entrant).

type Array

type Array = types.Array

Shared-type wrappers — re-exported from internal/types.

func ArrayIn added in v1.31.0

func ArrayIn(txn *TransactionMut, name string) *Array

func NewArray

func NewArray(d *Doc, name string) *Array

type ArrayDeltaOp added in v1.2.0

type ArrayDeltaOp = types.ArrayDeltaOp

ArrayDeltaOp is one op of an array change delta: exactly one of Insert (values), Delete (count), or Retain (count) is set.

type ArrayEvent added in v1.2.0

type ArrayEvent = types.ArrayEvent

ArrayEvent is delivered to Array observers (Array.Observe) after a transaction that changed the array. Delta is the Quill-style change description. Semantic parity with yjs YArrayEvent.

type Attrs

type Attrs = types.Attrs

Attrs is the format-attribute map used by Text's rich-text API and DeltaOp. Keys are arbitrary strings; values are JSON-serializable scalars. A nil value clears the attribute on the affected range.

type Awareness

type Awareness = awareness.Awareness

Awareness tracks per-client ephemeral state (cursors, names, selections). Independent of any Doc; the local clientID is passed at construction. Embedders typically pair an Awareness with a Doc and use d.ClientID() as the awareness clientID.

func NewAwareness

func NewAwareness(clientID uint64) *Awareness

NewAwareness returns a fresh Awareness for the given local clientID. Use d.ClientID() to keep the awareness layer in sync with the doc.

type BigInt added in v1.33.0

type BigInt = block.BigInt

BigInt is a JavaScript BigInt, carried by lib0 as a signed 64-bit integer. It is not an int64, which is a JavaScript number: a BigInt a peer stored reads as BigInt and is written back as one, and a BigInt set from Go reaches JavaScript as a BigInt. In a V1 format attribute or embed, which is JSON, it is written as a number and every reader gets a float64.

type Branch

type Branch = block.Branch

Branch is the low-level shared-data container the types layer wraps. Most callers should use the typed constructors (NewMap, NewArray, NewText, NewXmlFragment) rather than building Branches directly. Re-exported here for advanced cases (custom shared-type implementations, observability code).

type ChunkKind

type ChunkKind = types.ChunkKind

ChunkKind discriminates the variants emitted by Text.Range.

type DeltaOp

type DeltaOp = types.DeltaOp

DeltaOp is one Quill-style delta operation produced by Text.ToDelta and (future) consumed by Text.ApplyDelta.

type Doc

type Doc = doc.Doc

Doc is a single CRDT replica — the local view of a collaborative document. Construct with NewDoc; mutate via WriteTxn; read via ReadTxn. See the doc-comment on the underlying type for the full concurrency contract.

func NewDoc

func NewDoc() *Doc

NewDoc returns a fresh Doc with default options and a random client identifier.

func NewDocWithOptions

func NewDocWithOptions(opts Options) *Doc

NewDocWithOptions returns a fresh Doc with the given options.

func RestoreSnapshot added in v0.10.0

func RestoreSnapshot(d *Doc, snap Snapshot) (*Doc, error)

RestoreSnapshot reconstructs the document state that d had at the moment snap was taken, returning it as a new Doc. Byte-equivalent to yjs `Y.createDocFromSnapshot`.

d must have been created with GC disabled (ygo.NewDocWithOptions with DisableGC: true); otherwise deleted content the snapshot references may have been collected and RestoreSnapshot returns ErrSnapshotGC. The returned Doc also has GC disabled so it can itself be snapshotted.

The source Doc is not logically changed (the reconstruction may split blocks internally, which is transparent to readers).

type KeyChange added in v1.2.0

type KeyChange = types.KeyChange

KeyChange describes one map key's change in a MapEvent: Action is "add", "update", or "delete"; OldValue is the prior value (nil for "add").

type Map

type Map = types.Map

Shared-type wrappers — re-exported from internal/types.

func MapIn added in v1.31.0

func MapIn(txn *TransactionMut, name string) *Map

MapIn, ArrayIn, TextIn, XmlFragmentIn, XmlElementIn and XmlTextIn are NewMap and the others for use inside an open transaction: they find or create the root through txn, under the lock txn already holds, as yjs getMap does inside transact (yrs get_or_insert_map on a transaction). The type they return is the same as the document forms return for the same name.

func NewMap

func NewMap(d *Doc, name string) *Map

NewMap, NewArray, NewText, NewXmlFragment return wrappers bound to the root branch with the given name in d. The branch is lazily created on first call; subsequent calls with the same name return wrappers pointing at the same underlying state.

Per-branch type discipline: a branch should be used as ONE type (Map OR Array OR Text OR XML). Mixing types on the same root branch produces undefined behaviour.

These take the document's write lock to find and type the root, so another goroutine's transaction never sees a root half typed. Inside an open transaction on the same document they would block forever: use MapIn, ArrayIn, TextIn and the other *In forms there, as yjs allows getMap inside transact.

type MapEvent added in v1.2.0

type MapEvent = types.MapEvent

MapEvent is delivered to Map observers (Map.Observe) after a transaction that changed the map, describing which keys changed and how. Byte-for-byte semantic parity with yjs YMapEvent.

type Options

type Options = doc.Options

Options bundles per-Doc settings (deterministic ClientID, GC disable). The zero value is the recommended configuration.

type RelativePosition added in v1.1.0

type RelativePosition = types.RelativePosition

RelativePosition is a cursor/selection anchor attached to the document model rather than a numeric index, so it stays on the same logical character as concurrent edits land. Create one with CreateRelativePositionFromTypeIndex, ship it between peers with EncodeRelativePosition (byte-compatible with Y.encodeRelativePosition), and resolve it back to an index with CreateAbsolutePositionFromRelativePosition.

func CreateRelativePositionFromTypeIndex added in v1.1.0

func CreateRelativePositionFromTypeIndex(t SharedType, index uint64, assoc int64) (RelativePosition, error)

CreateRelativePositionFromTypeIndex anchors index within the shared type t (any wrapper: Map, Array, Text, Xml*). index counts UTF-16 code units for Text, elements for Array. assoc >= 0 sticks to the character after the position (default for cursors), assoc < 0 to the character before it.

func DecodeRelativePosition added in v1.1.0

func DecodeRelativePosition(buf []byte) (RelativePosition, error)

DecodeRelativePosition parses the yjs binary form.

type SharedType added in v1.1.0

type SharedType = UndoScope

SharedType is any shared-type wrapper (Map, Array, Text, XmlFragment, XmlElement, XmlText). Alias of UndoScope; both name the same one-method interface.

type Snapshot added in v0.10.0

type Snapshot = encoding.Snapshot

Snapshot is a point-in-time marker of a document's history (the deleted ID ranges plus per-client clock heads as of the snapshot). Encode it with EncodeSnapshot for storage; reconstruct the document state it captured with RestoreSnapshot (see the Snapshot doc).

For time-travel to work, create the doc with GC disabled (ygo.NewDocWithOptions with DisableGC) so deleted content the snapshot references is retained.

func CreateSnapshot added in v0.10.0

func CreateSnapshot(d *Doc) Snapshot

CreateSnapshot captures the current state of d as a Snapshot. Byte-compatible with yjs `Y.snapshot(doc)`.

func DecodeSnapshot added in v0.10.0

func DecodeSnapshot(buf []byte) (Snapshot, error)

DecodeSnapshot parses a V1 snapshot produced by EncodeSnapshot or yjs `Y.encodeSnapshot`.

type SubdocsEvent added in v1.0.0

type SubdocsEvent = doc.SubdocsEvent

SubdocsEvent carries the subdocument lifecycle changes of one transaction: GUIDs added, removed, and loaded. Observe with Doc.OnSubdocs.

type Text

type Text = types.Text

Shared-type wrappers — re-exported from internal/types.

func NewText

func NewText(d *Doc, name string) *Text

func TextIn added in v1.31.0

func TextIn(txn *TransactionMut, name string) *Text

type TextEvent added in v1.2.0

type TextEvent = types.TextEvent

TextEvent is delivered to Text observers (Text.Observe) after a transaction that changed the text. Delta is the formatting-aware Quill-style change description. Semantic parity with yjs YTextEvent.

type Transaction

type Transaction = doc.Transaction

Transaction is a read-only transaction holding the doc's read lock for its lifetime. Created by Doc.ReadTxn; released by Close.

type TransactionMut

type TransactionMut = doc.TransactionMut

TransactionMut is a write transaction holding the doc's write lock. Created by Doc.WriteTxn; released by Commit.

type Undefined added in v1.33.0

type Undefined = block.Undefined

Undefined is a JavaScript undefined stored as a map value or inside an Any array or object. It is not nil, which is null: Map.Get returns Undefined{} for a key yjs set to undefined, and Map.Set(txn, k, Undefined{}) stores one. It encodes as lib0's undefined, so JavaScript peers read undefined.

type UndoManager added in v0.10.0

type UndoManager = undo.UndoManager

UndoManager records local mutations under a scope of shared types and reverses them with Undo / Redo. It subscribes to the doc's transaction lifecycle; close it with Close when no longer needed.

Scope is given as the typed wrappers themselves (a Map, Array, Text, or XML type). Only mutations under one of the scoped types, made by a tracked origin, are captured. The default tracks the nil origin: local edits made without one, but also updates applied with plain ApplyUpdate, exactly as yjs does. Apply a peer's updates with ApplyUpdateWithOrigin to keep them out of the history, or track only your own origin (AddTrackedOrigin, RemoveTrackedOrigin(nil)). Bursty edits within the capture-timeout window collapse into a single undo step.

m := ygo.NewMap(d, "settings")
um := ygo.NewUndoManager(d, m)
defer um.Close()
// ... edits to m ...
um.Undo() // reverts the last captured step

func NewUndoManager added in v0.10.0

func NewUndoManager(d *Doc, scope ...UndoScope) *UndoManager

NewUndoManager creates an UndoManager on d watching the given scope of shared types. At least one scope type is required.

um := ygo.NewUndoManager(d, myMap, myArray)

func NewUndoManagerWithOptions added in v0.10.0

func NewUndoManagerWithOptions(d *Doc, opts UndoManagerOptions, scope ...UndoScope) *UndoManager

NewUndoManagerWithOptions is NewUndoManager with explicit options.

type UndoManagerOptions added in v0.10.0

type UndoManagerOptions = undo.Options

UndoManagerOptions configures an UndoManager. A zero value selects the defaults: 500 ms capture timeout, track the nil origin only.

type UndoScope added in v0.10.0

type UndoScope interface {
	Branch() *Branch
}

UndoScope is anything an UndoManager can watch. Every shared-type wrapper (Map, Array, Text, XmlFragment, XmlElement, XmlText) satisfies it via its Branch method.

type XmlElement

type XmlElement = types.XmlElement

Shared-type wrappers — re-exported from internal/types.

func NewXmlElement

func NewXmlElement(d *Doc, name string) *XmlElement

NewXmlElement wraps a branch as an XmlElement. Typically used for root-level XML where the branch was constructed via d.Branch(name); nested elements should be constructed via XmlFragment.InsertXmlElement / XmlElement.InsertXmlElement which set TypeRef and Name automatically.

func XmlElementIn added in v1.31.0

func XmlElementIn(txn *TransactionMut, name string) *XmlElement

type XmlFragment

type XmlFragment = types.XmlFragment

Shared-type wrappers — re-exported from internal/types.

func NewXmlFragment

func NewXmlFragment(d *Doc, name string) *XmlFragment

func XmlFragmentIn added in v1.31.0

func XmlFragmentIn(txn *TransactionMut, name string) *XmlFragment

type XmlText

type XmlText = types.XmlText

Shared-type wrappers — re-exported from internal/types.

func NewXmlText

func NewXmlText(d *Doc, name string) *XmlText

func XmlTextIn added in v1.31.0

func XmlTextIn(txn *TransactionMut, name string) *XmlText

Directories

Path Synopsis
Package benchmarks ports the dmonad/crdt-benchmarks B1-B4 workload suite to ygo.
Package benchmarks ports the dmonad/crdt-benchmarks B1-B4 workload suite to ygo.
Package client is a Yjs sync provider over WebSocket: the Go equivalent of y-websocket's WebsocketProvider, for yserve and the reference y-websocket server.
Package client is a Yjs sync provider over WebSocket: the Go equivalent of y-websocket's WebsocketProvider, for yserve and the reference y-websocket server.
cmd
gen-go-fixtures command
gen-go-fixtures generates reverse-direction wire-format fixtures: Go encodes Doc state via EncodeStateAsUpdate (V1) and EncodeStateAsUpdateV2, captures the bytes + expected state, and writes them as JSON.
gen-go-fixtures generates reverse-direction wire-format fixtures: Go encodes Doc state via EncodeStateAsUpdate (V1) and EncodeStateAsUpdateV2, captures the bytes + expected state, and writes them as JSON.
ygo-server command
Command ygo-server is the legacy name of the stand-alone WebSocket sync server for ygo documents.
Command ygo-server is the legacy name of the stand-alone WebSocket sync server for ygo documents.
yload command
yload is a WebSocket load generator for a ygo/yserve deployment, built on the public ygo client.
yload is a WebSocket load generator for a ygo/yserve deployment, built on the public ygo client.
yserve command
Command yserve is a self-hosted Yjs sync server in one binary, with no Node runtime and no Redis; built with CGO_ENABLED=0 it is fully static.
Command yserve is a self-hosted Yjs sync server in one binary, with no Node runtime and no Redis; built with CGO_ENABLED=0 it is fully static.
examples
collab-client command
Command collab-client is a runnable example of the ygo Go-native sync client.
Command collab-client is a runnable example of the ygo Go-native sync client.
collab-server command
Command collab-server is a runnable example of embedding the ygo WebSocket sync server in your own Go backend and wiring its library-only extension points: connection lifecycle hooks, read-only viewers, an on-change side effect, first-load seeding, resource caps, and a live stats endpoint.
Command collab-server is a runnable example of embedding the ygo WebSocket sync server in your own Go backend and wiring its library-only extension points: connection lifecycle hooks, read-only viewers, an on-change side effect, first-load seeding, resource caps, and a live stats endpoint.
offline-first command
Command offline-first demonstrates the ygo client's offline-first persistence.
Command offline-first demonstrates the ygo client's offline-first persistence.
Package gomobile is the bytes-in/bytes-out subset of the ygo API designed to survive `gomobile bind`.
Package gomobile is the bytes-in/bytes-out subset of the ygo API designed to survive `gomobile bind`.
integration
matrix module
internal
anyval
Package anyval checks and fixes the plain values ygo stores in items: the value kinds the Any encoder writes, and values written as JSON.
Package anyval checks and fixes the plain values ygo stores in items: the value kinds the Any encoder writes, and values written as JSON.
awareness
Package awareness implements the y-protocols Awareness layer — the ephemeral, presence-style sibling of the document CRDT used to track per-client transient state like cursor positions, user names, and selection ranges.
Package awareness implements the y-protocols Awareness layer — the ephemeral, presence-style sibling of the document CRDT used to track per-client transient state like cursor positions, user names, and selection ranges.
block
Package block defines the building blocks of a Yjs document.
Package block defines the building blocks of a Yjs document.
doc
Package doc owns the Doc and Transaction types — the document container plus its mutation-lifecycle wrapper.
Package doc owns the Doc and Transaction types — the document container plus its mutation-lifecycle wrapper.
encoding
Package encoding implements the V1 wire format yrs and JS Yjs use for state vectors, delete sets, and document updates.
Package encoding implements the V1 wire format yrs and JS Yjs use for state vectors, delete sets, and document updates.
lib0
Package lib0 implements the binary encoding format used by Yjs.
Package lib0 implements the binary encoding format used by Yjs.
store
Package store implements the per-client block storage that owns the memory for every Item in a Yjs document.
Package store implements the per-client block storage that owns the memory for every Item in a Yjs document.
sync
Package sync implements the y-protocols/sync wire format and the Hocuspocus outer message envelope.
Package sync implements the y-protocols/sync wire format and the Hocuspocus outer message envelope.
types
Package types holds the user-facing shared CRDT collection types: Map, Array, Text, XmlElement / XmlFragment / XmlText.
Package types holds the user-facing shared CRDT collection types: Map, Array, Text, XmlElement / XmlFragment / XmlText.
undo
Package undo implements the Yjs UndoManager semantics in pure Go.
Package undo implements the Yjs UndoManager semantics in pure Go.
utf16
Package utf16 provides UTF-16 code-unit length and offset helpers over Go's UTF-8 strings.
Package utf16 provides UTF-16 code-unit length and offset helpers over Go's UTF-8 strings.
Package persist defines the storage contract for ygo documents and provides helpers that turn stored update logs back into live Docs.
Package persist defines the storage contract for ygo documents and provides helpers that turn stored update logs back into live Docs.
sqlite
Package sqlite is the reference persist.Store implementation backed by modernc.org/sqlite.
Package sqlite is the reference persist.Store implementation backed by modernc.org/sqlite.
Package server implements a WebSocket sync server for ygo documents that speaks the y-websocket protocol.
Package server implements a WebSocket sync server for ygo documents that speaks the y-websocket protocol.
backplane
Package backplane fans document updates between ygo server instances that share a document, so several horizontally-scaled servers behind a load balancer converge on the same document.
Package backplane fans document updates between ygo server instances that share a document, so several horizontally-scaled servers behind a load balancer converge on the same document.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL