fix: revert from ciborium to serde_cbor_2, make apply_patches iterative

- ciborium's deserialize_str bug (#32) caused DB deserialization to fail
  silently, nuking the database value to null on compress
- Switch to dr-bonez/cbor fork (serde_cbor_2) which has StreamDeserializer
  change_output_type support
- Propagate deserialization errors instead of falling back to null
- Convert apply_patches from recursion to iteration to prevent stack
  overflow on patches with many operations (e.g. 953 ops)
This commit is contained in:
Aiden McClelland
2026-03-09 23:42:14 -06:00
parent 003cb1dcf2
commit 10413414dc
6 changed files with 128 additions and 107 deletions

View File

@@ -438,66 +438,75 @@ fn apply_patches<'a>(
patches: &'a [PatchOperation],
undo: &mut Undo<'a>,
) -> Result<(), PatchError> {
let (patch, tail) = match patches.split_first() {
None => return Ok(()),
Some((patch, tail)) => (patch, tail),
};
let res = match *patch {
PatchOperation::Add(ref op) => {
let prev = add(doc, &op.path, op.value.clone())?;
undo.0.push(Box::new(move |doc| {
match prev {
None => remove(doc, &op.path, true).unwrap(),
Some(v) => add(doc, &op.path, v).unwrap().unwrap(),
};
}));
apply_patches(doc, tail, undo)
let initial_len = undo.0.len();
for patch in patches {
let res = match *patch {
PatchOperation::Add(ref op) => {
add(doc, &op.path, op.value.clone()).map(|prev| {
undo.0.push(Box::new(move |doc| {
match prev {
None => {
remove(doc, &op.path, true).unwrap();
}
Some(v) => {
add(doc, &op.path, v).unwrap().unwrap();
}
};
}));
})
}
PatchOperation::Remove(ref op) => {
remove(doc, &op.path, false).map(|prev| {
undo.0.push(Box::new(move |doc| {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}));
})
}
PatchOperation::Replace(ref op) => {
replace(doc, &op.path, op.value.clone()).map(|prev| {
undo.0.push(Box::new(move |doc| {
replace(doc, &op.path, prev).unwrap();
}));
})
}
PatchOperation::Move(ref op) => {
mov(doc, &op.from, &op.path, false).map(|prev| {
undo.0.push(Box::new(move |doc| {
mov(doc, &op.path, &op.from, true).unwrap();
if let Some(prev) = prev {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}
}));
})
}
PatchOperation::Copy(ref op) => {
copy(doc, &op.from, &op.path).map(|prev| {
undo.0.push(Box::new(move |doc| {
match prev {
None => {
remove(doc, &op.path, true).unwrap();
}
Some(v) => {
add(doc, &op.path, v).unwrap().unwrap();
}
};
}));
})
}
PatchOperation::Test(ref op) => {
test(doc, &op.path, &op.value).map(|()| {
undo.0.push(Box::new(move |_| ()));
})
}
};
if let Err(e) = res {
while undo.0.len() > initial_len {
undo.0.pop().unwrap()(doc);
}
return Err(e);
}
PatchOperation::Remove(ref op) => {
let prev = remove(doc, &op.path, false)?;
undo.0.push(Box::new(move |doc| {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Replace(ref op) => {
let prev = replace(doc, &op.path, op.value.clone())?;
undo.0.push(Box::new(move |doc| {
replace(doc, &op.path, prev).unwrap();
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Move(ref op) => {
let prev = mov(doc, &op.from, &op.path, false)?;
undo.0.push(Box::new(move |doc| {
mov(doc, &op.path, &op.from, true).unwrap();
if let Some(prev) = prev {
assert!(add(doc, &op.path, prev).unwrap().is_none());
}
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Copy(ref op) => {
let prev = copy(doc, &op.from, &op.path)?;
undo.0.push(Box::new(move |doc| {
match prev {
None => remove(doc, &op.path, true).unwrap(),
Some(v) => add(doc, &op.path, v).unwrap().unwrap(),
};
}));
apply_patches(doc, tail, undo)
}
PatchOperation::Test(ref op) => {
test(doc, &op.path, &op.value)?;
undo.0.push(Box::new(move |_| ()));
apply_patches(doc, tail, undo)
}
};
if res.is_err() {
undo.0.pop().unwrap()(doc);
}
res
Ok(())
}
/// Patch provided JSON document (given as `imbl_value::Value`) in place.

View File

@@ -81,3 +81,41 @@ fn revert_tests() {
fn merge_tests() {
util::run_specs("specs/merge_tests.json");
}
#[test]
fn many_ops_no_stack_overflow() {
let mut doc = json!({"items": {}});
let ops: Vec<PatchOperation> = (0..10_000)
.map(|i| {
PatchOperation::Add(AddOperation {
path: format!("/items/{i}").parse().unwrap(),
value: json!(i),
})
})
.collect();
let p = Patch(ops);
patch(&mut doc, &p).unwrap();
assert_eq!(doc["items"].as_object().unwrap().len(), 10_000);
}
#[test]
fn many_ops_undo_on_failure() {
let original = json!({"items": {}});
let mut doc = original.clone();
let mut ops: Vec<PatchOperation> = (0..1000)
.map(|i| {
PatchOperation::Add(AddOperation {
path: format!("/items/{i}").parse().unwrap(),
value: json!(i),
})
})
.collect();
// Final op fails: test against a wrong value at a valid path
ops.push(PatchOperation::Test(TestOperation {
path: "/items/0".parse().unwrap(),
value: json!("wrong"),
}));
let p = Patch(ops);
assert!(patch(&mut doc, &p).is_err());
assert_eq!(doc, original, "document should be fully restored after failed patch");
}