From d5e9416ff9ec06e22aba4f69b1a29c5a7b729a2c Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 11:13:24 +0100 Subject: api: /admin/changes read ops, not diffs The new store::changed_by reads each change's own operations instead of diffing the document before and after it. Each object's path is found once per change, as it was right after that change. So the cost now grows with the size of the change, not the size of the map it touched. - It names paths the same way the diff did: a map entry by its key, and list and text edits by the list or text. - It relies on import_obj, which Automerge marks as hidden from its docs, to turn an operation's object ID back into an object. --- src/document/store.rs | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) (limited to 'src/document') diff --git a/src/document/store.rs b/src/document/store.rs index 1a235b2..f18f2fa 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -83,6 +83,53 @@ fn patch_path(patch: &automerge::Patch, lists: InLists) -> Vec { path } +/// The store paths the change `hash` touched (each once, in order), as [`changed`] has +/// them, but from its operations rather than a diff: what a diff costs grows with the +/// objects it looks into, what this costs with the change. An operation on an object +/// that isn't reachable after the change (made and deleted in it) touches nothing. +/// None if `doc` lacks the change. +pub(crate) fn changed_by( + doc: &Automerge, + hash: &automerge::ChangeHash, + lists: InLists, +) -> Option>> { + use automerge::legacy::{Key, ObjectId}; + let change = doc.get_change_by_hash(hash)?.decode(); + let heads = [*hash]; + // Each object's path as it was after the change, and whether that is a list's (as + // `InLists::Whole` names what is inside one). + let mut objects: std::collections::HashMap, bool)>> = + std::collections::HashMap::new(); + let mut paths = BTreeSet::new(); + for op in change.operations { + let object = objects.entry(op.obj.clone()).or_insert_with(|| { + let id = match &op.obj { + ObjectId::Root => automerge::ROOT, + ObjectId::Id(id) => doc.import_obj(&id.to_string()).ok()?, + }; + let mut path = Vec::new(); + for (_, prop) in doc.parents_at(&id, &heads).ok()?.visible_path()? { + match (prop, lists) { + (Prop::Map(key), _) => path.push(key), + (Prop::Seq(index), InLists::Index) => path.push(index.to_string()), + (Prop::Seq(_), InLists::Whole) => return Some((path, true)), + } + } + Some((path, false)) + }); + let Some((path, in_list)) = object else { + continue; + }; + let mut path = path.clone(); + // A map entry by its key; list and text edits by the containing object. + if let (Key::Map(key), false) = (op.key, *in_list) { + path.push(key.to_string()); + } + paths.insert(path); + } + Some(paths.into_iter().collect()) +} + /// Whether one path is within the other (a change at either touches both). pub(crate) fn paths_related(a: &[String], b: &[String]) -> bool { a.starts_with(b) || b.starts_with(a) @@ -1595,6 +1642,45 @@ mod tests { at.split('/').map(str::to_owned).collect() } + #[test] + fn changes_name_the_paths_their_operations_touch() { + fn touched( + doc: &mut Automerge, + lists: InLists, + write: impl FnOnce(&mut automerge::transaction::Transaction<'_>) -> Result<(), StoreError>, + ) -> Vec> { + try_change(doc, write).unwrap(); + let head = doc.get_heads()[0]; + changed_by(doc, &head, lists).unwrap() + } + let mut doc = Automerge::new(); + // An item by its list; what is inside it by its index. + assert_eq!( + touched(&mut doc, InLists::Index, |doc| { + write_path(doc, &path("a"), &json!({"b": 1, "list": [{"k": 1}]})) + }), + [path("a"), path("a/b"), path("a/list"), path("a/list/0/k")] + ); + assert_eq!( + touched(&mut doc, InLists::Index, |doc| { + write_path(doc, &path("a/list/0/k"), &json!(2)) + }), + [path("a/list/0/k")] + ); + assert_eq!( + touched(&mut doc, InLists::Whole, |doc| { + write_path(doc, &path("a/list/0/k"), &json!(3)) + }), + [path("a/list")] + ); + assert_eq!( + touched(&mut doc, InLists::Index, |doc| { + delete_path(doc, &path("a/b"), false) + }), + [path("a/b")] + ); + } + #[test] fn paths_go_through_lists() { let mut doc = Automerge::new(); -- cgit v1.3.1