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, pub resource: ResourceId, pub name: String, pub sync_status: String, pub state_bytes: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DocumentState { pub document: DocumentResource, pub state_json: String, pub updated_at: UnixMillis, } #[derive(Debug, thiserror::Error)] pub enum DocumentError { #[error("invalid document name: {0}")] Name(String), #[error("invalid document JSON state: {0}")] 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> { if name.is_empty() || !name .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) { return Err(DocumentError::Name(name.to_owned())); } Ok(()) } pub fn normalize_document_state(state_json: &str) -> Result { let value: serde_json::Value = serde_json::from_str(state_json)?; Ok(serde_json::to_string(&value)?) } pub fn create_automerge_state(state_json: &str) -> Result { 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 { 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 { 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 { "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, 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 { 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 { 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)] mod tests { use super::*; #[test] fn document_name_validation_rejects_paths_and_empty_names() { assert!(validate_document_name("notes").is_ok()); assert!(validate_document_name("notes.v1").is_ok()); assert!(validate_document_name("").is_err()); assert!(validate_document_name("../notes").is_err()); assert!(validate_document_name("notes/main").is_err()); assert!(validate_document_name("notes main").is_err()); } #[test] fn document_state_is_validated_and_normalized_json() { assert_eq!( normalize_document_state(r#"{ "title": "notes", "done": false }"#).expect("normalize"), r#"{"done":false,"title":"notes"}"# ); 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"#)); } }