Store documents as Automerge state
This commit is contained in:
parent
606641dd4a
commit
b01da7be4b
9 changed files with 490 additions and 73 deletions
|
|
@ -6,6 +6,9 @@ rust-version.workspace = true
|
|||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
automerge.workspace = true
|
||||
base64.workspace = true
|
||||
hexane.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
|
|
|||
|
|
@ -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"#));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue