Store documents as Automerge state

This commit is contained in:
Eric Wendland 2026-05-22 14:54:00 +02:00
commit b01da7be4b
9 changed files with 490 additions and 73 deletions

View file

@ -1,6 +1,8 @@
use geth_types::{DocumentId, ResourceId, UnixMillis};
use serde::{Deserialize, Serialize};
pub const AUTOMERGE_STATE_FORMAT: &str = "geth.automerge-document.v1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentResource {
pub id: DocumentId,
@ -20,9 +22,18 @@ pub struct DocumentState {
#[derive(Debug, thiserror::Error)]
pub enum DocumentError {
#[error("invalid document name: {0}")]
InvalidName(String),
Name(String),
#[error("invalid document JSON state: {0}")]
InvalidState(#[from] serde_json::Error),
JsonState(#[from] serde_json::Error),
#[error("invalid Automerge document state: {0}")]
AutomergeState(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct AutomergeStateEnvelope {
format: String,
automerge_base64: String,
view_json: String,
}
pub fn validate_document_name(name: &str) -> Result<(), DocumentError> {
@ -31,7 +42,7 @@ pub fn validate_document_name(name: &str) -> Result<(), DocumentError> {
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(DocumentError::InvalidName(name.to_owned()));
return Err(DocumentError::Name(name.to_owned()));
}
Ok(())
}
@ -41,9 +52,201 @@ pub fn normalize_document_state(state_json: &str) -> Result<String, DocumentErro
Ok(serde_json::to_string(&value)?)
}
pub fn create_automerge_state(state_json: &str) -> Result<String, DocumentError> {
let value: serde_json::Value = serde_json::from_str(state_json)?;
let normalized = serde_json::to_string(&value)?;
let mut doc = automerge::Automerge::new();
{
let mut tx = doc.transaction();
write_json_object(&mut tx, &automerge::ROOT, &value)?;
tx.commit();
}
envelope_from_doc(&doc, normalized)
}
pub fn document_view_json(stored_state: &str) -> Result<String, DocumentError> {
if let Some(envelope) = parse_envelope(stored_state)? {
return Ok(envelope.view_json);
}
normalize_document_state(stored_state)
}
pub fn document_state_bytes(stored_state: &str) -> u64 {
match parse_envelope(stored_state) {
Ok(Some(envelope)) => {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(envelope.automerge_base64)
.map(|bytes| bytes.len() as u64)
.unwrap_or(stored_state.len() as u64)
}
Ok(None) | Err(_) => stored_state.len() as u64,
}
}
pub fn merge_automerge_states(local: &str, remote: &str) -> Result<String, DocumentError> {
let Some(local_envelope) = parse_envelope(local)? else {
return create_automerge_state(remote);
};
let Some(remote_envelope) = parse_envelope(remote)? else {
return create_automerge_state(&document_view_json(remote)?);
};
let mut local_doc = load_envelope_doc(&local_envelope)?;
let mut remote_doc = load_envelope_doc(&remote_envelope)?;
local_doc
.merge(&mut remote_doc)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
let view_json = serde_json::to_string(&automerge::AutoSerde::from(&local_doc))?;
envelope_from_doc(&local_doc, view_json)
}
#[must_use]
pub fn automerge_roadmap() -> &'static str {
"future documents use Automerge sync over Iroh with resource-local authorization"
"documents are stored as Automerge save bytes with a JSON view; remote sync is resource-authorized and exchanges the durable Automerge state"
}
fn parse_envelope(state: &str) -> Result<Option<AutomergeStateEnvelope>, DocumentError> {
let value: serde_json::Value = serde_json::from_str(state)?;
if value
.get("format")
.and_then(serde_json::Value::as_str)
.is_some_and(|format| format == AUTOMERGE_STATE_FORMAT)
{
return serde_json::from_value(value)
.map(Some)
.map_err(DocumentError::from);
}
Ok(None)
}
fn load_envelope_doc(
envelope: &AutomergeStateEnvelope,
) -> Result<automerge::Automerge, DocumentError> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&envelope.automerge_base64)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
automerge::Automerge::load(&bytes)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))
}
fn envelope_from_doc(
doc: &automerge::Automerge,
view_json: String,
) -> Result<String, DocumentError> {
use base64::Engine;
let envelope = AutomergeStateEnvelope {
format: AUTOMERGE_STATE_FORMAT.to_owned(),
automerge_base64: base64::engine::general_purpose::STANDARD.encode(doc.save()),
view_json,
};
serde_json::to_string(&envelope).map_err(DocumentError::from)
}
fn write_json_object(
tx: &mut automerge::transaction::Transaction<'_>,
obj: &automerge::ObjId,
value: &serde_json::Value,
) -> Result<(), DocumentError> {
match value {
serde_json::Value::Object(map) => {
for (key, value) in map {
put_json_value(tx, obj, key, value)?;
}
}
other => {
put_json_value(tx, obj, "value", other)?;
}
}
Ok(())
}
fn put_json_value(
tx: &mut automerge::transaction::Transaction<'_>,
obj: &automerge::ObjId,
key: &str,
value: &serde_json::Value,
) -> Result<(), DocumentError> {
use automerge::transaction::Transactable;
match value {
serde_json::Value::Null => tx.put(obj, key, ()),
serde_json::Value::Bool(value) => tx.put(obj, key, *value),
serde_json::Value::Number(value) => {
if let Some(value) = value.as_i64() {
tx.put(obj, key, value)
} else if let Some(value) = value.as_u64() {
tx.put(obj, key, value)
} else if let Some(value) = value.as_f64() {
tx.put(obj, key, value)
} else {
Err(automerge::AutomergeError::Fail)
}
}
serde_json::Value::String(value) => tx.put(obj, key, value.as_str()),
serde_json::Value::Array(values) => {
let list = tx
.put_object(obj, key, automerge::ObjType::List)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
for (index, value) in values.iter().enumerate() {
insert_json_value(tx, &list, index, value)?;
}
Ok(())
}
serde_json::Value::Object(map) => {
let map_obj = tx
.put_object(obj, key, automerge::ObjType::Map)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
for (child_key, value) in map {
put_json_value(tx, &map_obj, child_key, value)?;
}
Ok(())
}
}
.map_err(|error| DocumentError::AutomergeState(error.to_string()))
}
fn insert_json_value(
tx: &mut automerge::transaction::Transaction<'_>,
obj: &automerge::ObjId,
index: usize,
value: &serde_json::Value,
) -> Result<(), DocumentError> {
use automerge::transaction::Transactable;
match value {
serde_json::Value::Null => tx.insert(obj, index, ()),
serde_json::Value::Bool(value) => tx.insert(obj, index, *value),
serde_json::Value::Number(value) => {
if let Some(value) = value.as_i64() {
tx.insert(obj, index, value)
} else if let Some(value) = value.as_u64() {
tx.insert(obj, index, value)
} else if let Some(value) = value.as_f64() {
tx.insert(obj, index, value)
} else {
Err(automerge::AutomergeError::Fail)
}
}
serde_json::Value::String(value) => tx.insert(obj, index, value.as_str()),
serde_json::Value::Array(values) => {
let list = tx
.insert_object(obj, index, automerge::ObjType::List)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
for (index, value) in values.iter().enumerate() {
insert_json_value(tx, &list, index, value)?;
}
Ok(())
}
serde_json::Value::Object(map) => {
let map_obj = tx
.insert_object(obj, index, automerge::ObjType::Map)
.map_err(|error| DocumentError::AutomergeState(error.to_string()))?;
for (child_key, value) in map {
put_json_value(tx, &map_obj, child_key, value)?;
}
Ok(())
}
}
.map_err(|error| DocumentError::AutomergeState(error.to_string()))
}
#[cfg(test)]
@ -68,4 +271,29 @@ mod tests {
);
assert!(normalize_document_state("{").is_err());
}
#[test]
fn automerge_state_preserves_json_view_and_durable_bytes() {
let state = create_automerge_state(r#"{ "title": "notes", "items": [1, true, null] }"#)
.expect("automerge state");
assert_eq!(
document_view_json(&state).expect("view"),
r#"{"items":[1,true,null],"title":"notes"}"#
);
assert!(document_state_bytes(&state) > 2);
assert!(state.contains(AUTOMERGE_STATE_FORMAT));
}
#[test]
fn automerge_states_can_merge_and_render_json_view() {
let left = create_automerge_state(r#"{ "left": true }"#).expect("left state");
let right = create_automerge_state(r#"{ "right": true }"#).expect("right state");
let merged = merge_automerge_states(&left, &right).expect("merge");
let view = document_view_json(&merged).expect("merged view");
assert!(view.contains(r#""left":true"#));
assert!(view.contains(r#""right":true"#));
}
}