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.
Let’s start with the most fundamental operations:
doc <- am_create()
print(doc)
#> <Automerge Document>
#> Actor: c575e95ef678706a0040981fe5431d64
#> Root keys: 0Note: 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.
Automerge provides multiple ways to add data, from functional to R-idiomatic:
Automerge supports nested data structures (maps within maps, lists within maps, etc.).
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"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)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)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)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 are special values that can be incremented/decremented without conflicts:
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)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)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)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)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)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)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.
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)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)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)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)