use serde::{Deserialize, Serialize};
use crate::author::Author;
use crate::session::SessionOp;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpLogEntry {
pub revision: u64,
#[serde(default)]
pub author: Author,
pub ops: Vec<SessionOp>,
#[serde(default)]
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
pub fn encode_line(entry: &OpLogEntry) -> Result<String, serde_json::Error> {
serde_json::to_string(entry)
}
pub fn decode_line(line: &str) -> Result<OpLogEntry, serde_json::Error> {
serde_json::from_str(line)
}
pub fn canonical_json(value: &serde_json::Value) -> String {
debug_assert!(canonical_by_construction(value));
serde_json::to_string(value).expect("a Value always serializes")
}
fn canonical_by_construction(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Object(map) => {
map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
&& map.values().all(canonical_by_construction)
}
serde_json::Value::Array(items) => items.iter().all(canonical_by_construction),
_ => true,
}
}
pub fn compute_entry_id(entry: &OpLogEntry) -> Result<String, serde_json::Error> {
let mut value = serde_json::to_value(entry)?;
if let Some(object) = value.as_object_mut() {
object.remove("id");
}
Ok(format!(
"sha256:{}",
crate::hash::sha256_hex(canonical_json(&value).as_bytes(),)
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate as wt;
#[test]
fn entry_roundtrip() {
let entry = OpLogEntry {
revision: 7,
author: Author {
peer: Some(3),
name: "maya".into(),
},
ops: vec![SessionOp::Edit(Box::new(wt::EditOp::spawn(
wt::WorldEntity::new(1, "lighthouse"),
)))],
timestamp_ms: 1_700_000_000_000,
id: None,
parent: None,
message: None,
};
let line = encode_line(&entry).unwrap();
assert!(!line.contains('\n'));
let back = decode_line(&line).unwrap();
assert_eq!(back.revision, 7);
assert_eq!(back.author.name, "maya");
assert_eq!(back.ops.len(), 1);
}
#[test]
fn canonical_json_matches_the_cross_language_golden() {
let entry: serde_json::Value = serde_json::json!({
"revision": 7,
"timestamp_ms": 1790000000123u64,
"author": {"peer": 3, "name": "maya"},
"ops": [{"SpawnEntity": {"entity": {"id": 1, "name": "beacon"}}}],
"parent": "e6"
});
assert_eq!(
canonical_json(&entry),
"{\"author\":{\"name\":\"maya\",\"peer\":3},\
\"ops\":[{\"SpawnEntity\":{\"entity\":{\"id\":1,\"name\":\"beacon\"}}}],\
\"parent\":\"e6\",\"revision\":7,\"timestamp_ms\":1790000000123}"
);
assert_eq!(
format!(
"sha256:{}",
crate::hash::sha256_hex(canonical_json(&entry).as_bytes())
),
"sha256:4b754af615abd0b36a11f6bec2753eedb7be6492080d8ed8ba6e50464dfaffa2"
);
}
#[test]
fn entry_identity_excludes_the_id_and_includes_the_parent() {
let entry = OpLogEntry {
revision: 7,
timestamp_ms: 1_790_000_000_123,
author: Author {
peer: Some(3),
name: "maya".into(),
},
ops: vec![SessionOp::Edit(Box::new(wt::EditOp::spawn(
wt::WorldEntity::new(1, "beacon"),
)))],
id: Some("anything at all".into()),
parent: Some("e6".into()),
message: None,
};
let id_here = compute_entry_id(&entry).unwrap();
assert_eq!(
id_here,
"sha256:51a49c473ba75e925dcccf1b9578fb163bba841833d7d1d578cc77388c0102b0"
);
let id_other = compute_entry_id(&OpLogEntry {
id: Some("a different one entirely".into()),
..entry.clone()
})
.unwrap();
assert_eq!(id_other, id_here);
let forked = OpLogEntry {
parent: Some("e9".into()),
..entry
};
assert_ne!(compute_entry_id(&forked).unwrap(), id_here);
}
}