Skip to content

Automerge Format

Automerge is a CRDT library that uses the Peritext mark model and Kleppmann block markers for collaborative rich text — the same theoretical foundation as RelationalText. Because the two systems share the same underlying model, the feature mapping is 1:1 and no semantic translation is required.

RelationalText supports two integration modes:

  • Snapshot — import or export an Automerge document at a point in time
  • Change-stream — live bidirectional sync between an Automerge document and a RelationalText document

Functions

ts
import {
  fromAutomerge,
  toAutomerge,
  ensureAutomergeLexicon,
  createBridgeFromRT,
  applyRTMutationToAutomerge,
  automergePathToRTMutations,
  applyRTMutations,
} from 'relational-text/automerge'

TIP

The Automerge adapter uses dedicated fromAutomerge / toAutomerge functions rather than the generic from() / to() registry, because Automerge documents are not strings — they are CRDT objects that require a property path argument.

Snapshot Integration

Use snapshot mode when you want to import an existing Automerge document into RelationalText for rendering or conversion, or export a RelationalText document back into an Automerge document.

fromAutomerge(doc, path): Document

Import an Automerge document into RelationalText.

  • doc — an Automerge document object
  • path — array of property keys leading to the rich text field (e.g. ['text'] for doc.text)
ts
import * as A from '@automerge/automerge'
import { fromAutomerge, ensureAutomergeLexicon } from 'relational-text/automerge'

ensureAutomergeLexicon()

const amDoc = A.from({ text: new A.RawString('Hello **world**') })
const rtDoc = fromAutomerge(amDoc, ['text'])

toAutomerge(rtDoc, doc, path): void

Export a RelationalText document into an Automerge document in-place. Must be called inside an A.change callback.

  • rtDoc — the RelationalText Document to export
  • doc — the mutable Automerge document (inside a change callback)
  • path — property path to the rich text field
ts
import * as A from '@automerge/automerge'
import { toAutomerge } from 'relational-text/automerge'

const updated = A.change(amDoc, d => toAutomerge(rtDoc, d, ['text']))

ensureAutomergeLexicon(): void

Register the Automerge lexicon (org.automerge.richtext.facet types). Called automatically by fromAutomerge() and toAutomerge() on first use. Safe to call multiple times — subsequent calls are no-ops.

Change-Stream Integration

Use change-stream mode for live collaborative editing where changes from one system must be continuously reflected in the other.

Setup

ts
import * as A from '@automerge/automerge'
import {
  createBridgeFromRT,
  applyRTMutationToAutomerge,
  automergePathToRTMutations,
  applyRTMutations,
} from 'relational-text/automerge'

// Create a bridge that tracks both sides
const bridge = createBridgeFromRT(rtDoc)

Applying a local RelationalText edit

When the user edits the RelationalText document, convert the mutation to Automerge change bytes for replication to peers:

ts
const { bridge: newBridge, changes } = applyRTMutationToAutomerge(bridge, {
  op: 'insert', bytePos: 5, text: ' world'
})
// `changes` is Uint8Array[] — send these bytes to connected peers

Applying incoming Automerge changes

When remote Automerge changes arrive, convert the Automerge patches to RelationalText mutations and apply them:

ts
// Apply remote changes to the Automerge side
const [newAmDoc] = A.applyChanges(bridge.amDoc, incomingChanges)

// Diff the old and new heads to get the list of patches
const patches = A.diff(
  newAmDoc,
  A.getHeads(bridge.amDoc),
  A.getHeads(newAmDoc)
)

// Convert Automerge patches to RelationalText mutations
const mutations = automergePathToRTMutations(patches, bridge.rtDoc, ['text'])

// Apply mutations to the RelationalText document
const updatedRtDoc = applyRTMutations(bridge.rtDoc, mutations)

API summary

FunctionDescription
createBridgeFromRT(rtDoc)Create a Bridge object from an existing RT document
applyRTMutationToAutomerge(bridge, mutation)Apply one RT mutation; returns { bridge, changes: Uint8Array[] }
automergePathToRTMutations(patches, rtDoc, path)Convert Automerge patches at path to RT mutations
applyRTMutations(rtDoc, mutations)Apply an array of RT mutations to a document

Feature Mapping

AutomergeRelationalText feature
mark bold: trueorg.automerge.richtext.facet#bold
mark italic: trueorg.automerge.richtext.facet#italic
mark strikethrough: trueorg.automerge.richtext.facet#strikethrough
mark code: trueorg.automerge.richtext.facet#code
mark link: "https://..."org.automerge.richtext.facet#link with attrs.href
block marker Map object\uFFFC / \n block facet

Block markers use the Kleppmann model: the first block in a document is represented by \uFFFC (U+FFFC, 3 bytes) and subsequent blocks by \n (1 byte). Block content is the text between consecutive markers. This matches RelationalText's native block model exactly.

Position Encoding

UTF-16 vs UTF-8 byte offsets

Automerge WASM internally uses UTF-16 code unit indices; RelationalText uses UTF-8 byte offsets. The change-stream adapter (applyRTMutationToAutomerge, automergePathToRTMutations) handles this conversion automatically using utf16IndexToByteOffset and byteOffsetToUtf16Index.

If you are accessing bridge.amDoc directly and computing positions manually, you must perform this conversion yourself.

Examples

Snapshot round-trip

ts
import * as A from '@automerge/automerge'
import { to } from 'relational-text/registry'
import { fromAutomerge, toAutomerge, ensureAutomergeLexicon } from 'relational-text/automerge'

ensureAutomergeLexicon()

// Import and render
const amDoc = A.from({ text: new A.RawString('Hello world') })
const rtDoc = fromAutomerge(amDoc, ['text'])
const html = to('html', rtDoc)

// Edit in RelationalText and write back
const edited = rtDoc.addMark(6, 11, { name: 'bold' })
const saved = A.change(amDoc, d => toAutomerge(edited, d, ['text']))

Live sync between two peers

ts
import * as A from '@automerge/automerge'
import {
  createBridgeFromRT,
  applyRTMutationToAutomerge,
  automergePathToRTMutations,
  applyRTMutations,
} from 'relational-text/automerge'
import { Document } from 'relational-text'

let bridge = createBridgeFromRT(Document.fromText('Hello'))

// Local insert: user types " world" at position 5
const { bridge: b2, changes } = applyRTMutationToAutomerge(bridge, {
  op: 'insert', bytePos: 5, text: ' world'
})
bridge = b2
sendToNetwork(changes) // send Uint8Array[] to peers

// Remote changes arrive from a peer
onNetworkChanges(incomingChanges => {
  const [newAmDoc] = A.applyChanges(bridge.amDoc, incomingChanges)
  const patches = A.diff(newAmDoc, A.getHeads(bridge.amDoc), A.getHeads(newAmDoc))
  const mutations = automergePathToRTMutations(patches, bridge.rtDoc, ['text'])
  bridge = { ...bridge, amDoc: newAmDoc, rtDoc: applyRTMutations(bridge.rtDoc, mutations) }
  rerenderEditor(bridge.rtDoc)
})