Getting Started with automerge

What is Automerge?

Automerge is a library that enables automatic merging of concurrent changes without conflicts. It’s built on the concept of Conflict-free Replicated Data Types (CRDTs), which are data structures designed to be safely replicated across multiple devices and automatically merged.

Key Benefits

Installation

# From R-universe
install.packages("automerge", repos = "https://posit-dev.r-universe.dev")

# From GitHub
pak::pak("posit-dev/automerge-r")

Basic Usage

Let’s start with the most fundamental operations:

library(automerge)

Creating a Document

doc <- am_create()
print(doc)
#> <Automerge Document>
#> Actor: c575e95ef678706a0040981fe5431d64 
#> Root keys: 0

Note: When you’re done working with a document, you can explicitly free its resources with am_close(doc) if you don’t want to wait for garbage collection.

Adding Data - Three Approaches

Automerge provides multiple ways to add data, from functional to R-idiomatic:

1. Functional API

am_put(doc, AM_ROOT, "name", "Alice")
am_put(doc, AM_ROOT, "age", 30L)
am_put(doc, AM_ROOT, "active", TRUE)
am_commit(doc, "Initial data")

am_get(doc, AM_ROOT, "name")
#> [1] "Alice"
am_get(doc, AM_ROOT, "age")
#> [1] 30

2. S3 Operators (R-idiomatic)

doc[["email"]] <- "alice@example.com"
doc[["score"]] <- 95.5

doc[["name"]]
#> [1] "Alice"
doc[["age"]]
#> [1] 30

# List all keys
names(doc)
#> [1] "active" "age"    "email"  "name"   "score"

3. Pipe-Friendly Style

doc2 <- am_create() |>
  am_put(AM_ROOT, "name", "Bob") |>
  am_put(AM_ROOT, "age", 25L) |>
  am_put(AM_ROOT, "active", TRUE) |>
  am_commit("Initial setup")

doc2 |> am_get(AM_ROOT, "name")
#> [1] "Bob"

am_close(doc2)

Working with Nested Structures

Automerge supports nested data structures (maps within maps, lists within maps, etc.).

Recursive Conversion

The simplest approach is to use R’s native list structures, which are automatically converted:

# Create document with nested structure in one call
doc3 <- am_create() |>
  am_put(
    AM_ROOT,
    "company",
    list(
      name = "Acme Corp",
      founded = 2020L,
      employees = list(
        list(name = "Alice", role = "Engineer"),
        list(name = "Bob", role = "Designer")
      ),
      office = list(
        address = list(
          street = "123 Main St",
          city = "Boston",
          zip = 02101L
        ),
        size = 5000.5
      )
    )
  ) |>
  am_commit("Add company data")

# Access nested data (verbose way)
company <- doc3[["company"]]
office <- am_get(doc3, company, "office")
address <- am_get(doc3, office, "address")
am_get(doc3, address, "city")
#> [1] "Boston"

Path-Based Access

For deep structures, path-based helpers make navigation much easier:

# Much simpler - use path-based access
am_get_path(doc3, c("company", "office", "address", "city"))
#> [1] "Boston"

# Create deep structure using paths
doc4 <- am_create()

am_put_path(doc4, c("config", "database", "host"), "localhost")
am_put_path(doc4, c("config", "database", "port"), 5432L)
am_put_path(doc4, c("config", "cache", "enabled"), TRUE)
am_put_path(doc4, c("config", "cache", "ttl"), 3600L)

# Retrieve values with paths
am_get_path(doc4, c("config", "database", "host"))
#> [1] "localhost"

am_close(doc3)
am_close(doc4)

Converting R Data Structures

Use as_automerge() to convert entire R structures at once:

# Your existing R data
config_data <- list(
  app_name = "MyApp",
  version = "1.0.0",
  database = list(
    host = "localhost",
    port = 5432L,
    credentials = list(
      user = "admin",
      password_hash = "..."
    )
  ),
  features = list("auth", "api", "websocket")
)

# Convert to Automerge document
doc5 <- as_automerge(config_data)
am_commit(doc5, "Initial configuration")

# Easy access with paths
am_get_path(doc5, c("database", "port"))
#> [1] 5432

am_close(doc5)

Working with Lists

Lists in R use 1-based indexing (standard R convention):

# Create a document with a list
doc6 <- am_create()
am_put(doc6, AM_ROOT, "items", AM_OBJ_TYPE_LIST)
items <- am_get(doc6, AM_ROOT, "items")

# Insert items
am_insert(doc6, items, 1, "first") # Insert at index 1
am_insert(doc6, items, 2, "second") # Insert at index 2
am_insert(doc6, items, 3, "third") # Insert at index 3

# Or use the "end" marker to append
am_insert(doc6, items, "end", "fourth")
am_put(doc6, items, "end", "fifth")

# Get list length
am_length(doc6, items)
#> [1] 5

# Access by index
am_get(doc6, items, 1)
#> [1] "first"
am_get(doc6, items, 2)
#> [1] "second"

am_close(doc6)

Special Automerge Types

Text Objects (for Collaborative Editing)

Regular strings use deterministic conflict resolution (one value wins). For collaborative text editing, use text objects:

doc7 <- am_create()

# Regular string (last-write-wins)
am_put(doc7, AM_ROOT, "title", "My Document")

# Text object (CRDT - supports collaborative editing)
am_put(doc7, AM_ROOT, "content", am_text("Initial content"))
text_obj <- am_get(doc7, AM_ROOT, "content")

# Text supports character-level operations
# For the text "Hello":
#  H e l l o
# 0 1 2 3 4 5  <- positions (0-based, between characters)

am_text_splice(text_obj, 8, 0, "amazing ") # Insert at position 8
am_text_content(text_obj)
#> [1] "Initial amazing content"

# For collaborative editors, use am_text_update() which computes
# and applies the minimal diff in one step:
old_text <- am_text_content(text_obj)
am_text_update(text_obj, old_text, "New content from user input")
am_text_content(text_obj)
#> [1] "New content from user input"

am_close(doc7)

Counters (for CRDT Counting)

Counters are special values that can be incremented/decremented without conflicts:

doc8 <- am_create()

# Create a counter
am_put(doc8, AM_ROOT, "score", am_counter(0))

am_counter_increment(doc8, AM_ROOT, "score", 10)
am_counter_increment(doc8, AM_ROOT, "score", 5)
am_counter_increment(doc8, AM_ROOT, "score", -3)

doc8[["score"]]
#> <Automerge Counter: 12 >

am_close(doc8)

Timestamps

POSIXct timestamps are natively supported:

doc9 <- am_create()

am_put(doc9, AM_ROOT, "created_at", Sys.time())
am_put(doc9, AM_ROOT, "updated_at", Sys.time())

doc9[["created_at"]]
#> [1] "2026-02-18 20:39:10 GMT"

am_close(doc9)

Saving and Loading Documents

Documents can be saved to binary format and loaded later:

# Save to binary format
bytes <- am_save(doc)

# Save to file
temp_file <- tempfile(fileext = ".automerge")
writeBin(bytes, temp_file)

# Load from binary
doc_loaded <- am_load(bytes)

# Or load from file
doc_from_file <- am_load(readBin(temp_file, "raw", 1e6))

# Verify data persisted
doc_from_file[["name"]]
#> [1] "Alice"

am_close(doc)
am_close(doc_loaded)
am_close(doc_from_file)

Incremental Save and Load

For long-lived documents, incremental save/load allows exchanging only the changes since the last save, rather than the entire document:

doc_inc <- am_create()
doc_inc[["v1"]] <- "first"
am_commit(doc_inc, "Version 1")

# Full save (resets incremental tracking)
full_bytes <- am_save(doc_inc)

# Make more changes
doc_inc[["v2"]] <- "second"
am_commit(doc_inc, "Version 2")

# Incremental save: only the new changes
inc_bytes <- am_save_incremental(doc_inc)
length(inc_bytes) < length(full_bytes) # TRUE - much smaller
#> [1] TRUE

# Apply incremental changes to another copy
doc_inc2 <- am_load(full_bytes)
am_load_incremental(doc_inc2, inc_bytes)
doc_inc2[["v2"]] # "second"
#> [1] "second"

am_close(doc_inc)
am_close(doc_inc2)

Document Lifecycle

Committing Changes

doc10 <- am_create()

# Make changes
doc10[["x"]] <- 1
doc10[["y"]] <- 2

# Check how many uncommitted operations exist
am_pending_ops(doc10)
#> [1] 2

# Commit with message
am_commit(doc10, "Add x and y coordinates")

# Make more changes
doc10[["z"]] <- 3
am_commit(doc10, "Add z coordinate")

Forking and Cloning Documents

Create independent copies:

doc11 <- am_fork(doc10)

# Changes to fork don't affect original
doc11[["w"]] <- 4
doc10[["w"]] # NULL - not in original
#> NULL

# am_clone() is another way to create an independent deep copy
doc11b <- am_clone(doc10)

# Compare documents for equality
am_equal(doc10, doc11b) # TRUE - same state
#> [1] TRUE

am_close(doc10)
am_close(doc11)
am_close(doc11b)

Merging Documents

Merge changes from one document into another:

# Create two documents
doc12 <- am_create()
doc12[["source"]] <- "doc12"
doc12[["value1"]] <- 100

doc13 <- am_create()
doc13[["source"]] <- "doc13"
doc13[["value2"]] <- 200

# Merge doc13 into doc12
am_merge(doc12, doc13)

# doc12 now has both values
doc12[["value1"]]
#> [1] 100
doc12[["value2"]]
#> [1] 200
doc12[["source"]] # One value wins deterministically for conflicting keys
#> [1] "doc13"

am_close(doc12)
am_close(doc13)

Basic Synchronization

Automerge’s key feature is automatic synchronization between documents:

# Create two peers
peer1 <- am_create()
peer1[["edited_by"]] <- "peer1"
peer1[["data1"]] <- 100
am_commit(peer1)

peer2 <- am_create()
peer2[["edited_by"]] <- "peer2"
peer2[["data2"]] <- 200
am_commit(peer2)

# Bidirectional sync (documents modified in place)
rounds <- am_sync(peer1, peer2)
rounds
#> [1] 4

# Both documents now have all data
peer1[["data1"]]
#> [1] 100
peer1[["data2"]]
#> [1] 200
peer2[["data1"]]
#> [1] 100
peer2[["data2"]]
#> [1] 200

am_close(peer1)
am_close(peer2)

Inspecting Document History

Every change in an Automerge document carries metadata: who made it, when, what message was attached, and what it depended on. You can inspect this with the change introspection functions:

doc14 <- am_create()
doc14[["title"]] <- "My Project"
am_commit(doc14, "Initial setup", Sys.time())

doc14[["version"]] <- "1.0"
am_commit(doc14, "Set version", Sys.time())

# Get the full history (returns am_change objects directly)
history <- am_get_changes(doc14)
history
#> [[1]]
#> <Automerge Change>
#> Hash: a0 e1 98 f5 ...
#> Message: Initial setup 
#> Time: 2026-02-18 20:39:11 
#> 
#> [[2]]
#> <Automerge Change>
#> Hash: 44 0f b6 f8 ...
#> Message: Set version 
#> Time: 2026-02-18 20:39:11

# Inspect each change - no parsing needed
for (i in seq_along(history)) {
  cat(
    sprintf(
      "Change %d: seq=%g, message=%s\n",
      i,
      am_change_seq(history[[i]]),
      am_change_message(history[[i]]) %||% "(none)"
    )
  )
}
#> Change 1: seq=1, message=Initial setup
#> Change 2: seq=2, message=Set version

# Extract many fields from the same change
change <- history[[2]]
am_change_hash(change)     # Unique 32-byte hash
#>  [1] 44 0f b6 f8 8e 00 82 25 d6 6a c3 ed 01 aa de 53 c3 bf a5 bc 2f 79 f9 e9 13
#> [26] c1 24 15 72 de 74 0f
am_change_message(change)  # Commit message
#> [1] "Set version"
am_change_time(change)     # Timestamp
#> [1] "2026-02-18 20:39:11 GMT"
am_change_seq(change)      # Sequence number
#> [1] 2
am_change_actor_id(change) # Who made the change
#>  [1] d8 ad cd e5 ce f4 b8 92 5c 76 d4 16 27 00 46 3b
am_change_deps(change)     # Parent change hashes
#> [[1]]
#>  [1] a0 e1 98 f5 f5 af 98 c0 1a 69 3e cd 89 b9 d9 46 2f 6d e3 a4 58 cc f9 3e 37
#> [26] 93 7a 25 22 26 2a 6e
am_change_size(change)     # Number of operations
#> [1] 1

am_close(doc14)

Time Travel: Undoing Changes

Since Automerge preserves every committed change, you can “time travel” back to any point in the document’s history. The key is am_fork(doc, heads), which creates an independent copy of the document at a specific historical state.

Reverting to a Known Checkpoint

If you know you might want to undo future changes, save the heads before making them:

doc_tt <- am_create()
doc_tt[["title"]] <- "My Report"
doc_tt[["status"]] <- "draft"
am_commit(doc_tt, "Initial draft")

# Save a checkpoint before making further changes
checkpoint <- am_get_heads(doc_tt)

# Continue editing
doc_tt[["status"]] <- "review"
doc_tt[["reviewer"]] <- "Bob"
am_commit(doc_tt, "Send for review")

doc_tt[["status"]] <- "rejected"
am_commit(doc_tt, "Rejected by reviewer")

# Oops - let's go back to the draft state
draft <- am_fork(doc_tt, checkpoint)
draft[["status"]]    # "draft"
#> [1] "draft"
draft[["reviewer"]]  # NULL - this key didn't exist yet
#> NULL

am_close(doc_tt)
am_close(draft)

Browsing History to Find the Right Point

When you don’t have a saved checkpoint, browse the history to find the change you want to revert to:

doc_hist <- am_create()

doc_hist[["data"]] <- list(x = 1, y = 2)
am_commit(doc_hist, "Add initial data")

doc_hist[["data"]] <- list(x = 1, y = 2, z = 3)
am_commit(doc_hist, "Add z coordinate")

doc_hist[["data"]] <- list(x = 10, y = 20, z = 30)
am_commit(doc_hist, "Scale all values by 10")

# Review the history
history <- am_get_changes(doc_hist)
for (i in seq_along(history)) {
  cat(sprintf("  [%d] %s\n", i, am_change_message(history[[i]])))
}
#>   [1] Add initial data
#>   [2] Add z coordinate
#>   [3] Scale all values by 10

# We want to undo the scaling - go back to change 2
target <- history[[2]]
reverted <- am_fork(doc_hist, list(am_change_hash(target)))
from_automerge(reverted)
#> $data
#> $data$x
#> [1] 1
#> 
#> $data$y
#> [1] 2
#> 
#> $data$z
#> [1] 3

am_close(doc_hist)
am_close(reverted)

Continuing from a Historical State

A forked document is fully independent, so you can make new changes from the historical state without affecting the original:

doc_branch <- am_create()
doc_branch[["text"]] <- am_text("Hello World")
am_commit(doc_branch, "Initial text")

# Save checkpoint
v1_heads <- am_get_heads(doc_branch)

text_obj <- am_get(doc_branch, AM_ROOT, "text")
am_text_update(text_obj, "Hello World", "Hello World - CONFIDENTIAL")
am_commit(doc_branch, "Add confidential marker")

# Create a public version from v1 and take it in a different direction
public <- am_fork(doc_branch, v1_heads)
public_text <- am_get(public, AM_ROOT, "text")
am_text_update(public_text, "Hello World", "Hello World - Public Draft")
am_commit(public, "Public version")

am_text_content(public_text) # "Hello World - Public Draft"
#> [1] "Hello World - Public Draft"

am_close(doc_branch)
am_close(public)

Cursor Persistence

Cursors can be serialized and restored across R sessions, which is useful for saving editor state:

doc15 <- am_create()
am_put(doc15, AM_ROOT, "content", am_text("Hello World"))
text_obj <- am_get(doc15, AM_ROOT, "content")

# Create a cursor and serialize it
cursor <- am_cursor(text_obj, 5)
cursor_bytes <- am_cursor_to_bytes(cursor)
cursor_str <- am_cursor_to_string(cursor)

# Later: restore the cursor
restored <- am_cursor_from_bytes(cursor_bytes, text_obj)
am_cursor_position(restored)  # 5
#> [1] 5

# Compare cursors
am_cursor_equal(cursor, restored)  # TRUE
#> [1] TRUE

am_close(doc15)

Next Steps

Getting Help

# Function help
?am_create
?am_put
?am_sync

# Package help
?automerge
help(package = "automerge")

# All vignettes
vignette(package = "automerge")