authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 01:58:18-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
log2e2f9466aec89c2ab7fc03378e3cbf975ffd7f9d
tree93307229874c363d3309af701541597e23dc46cf
parent59f5163e0c2a80a4addceedf0e3f4bc3b6c235c3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: merge concurrent page edits during reconciliation

When the remote page changed since a save was made, reconcile by a three-way merge of page models: the remote page keeps everything it changed and the local changes are re-applied wherever the two sides touched different objects, fields or text ranges. Same-paragraph edits reuse the banded text rebase to place the local replacement inside the remote text; a paragraph removed on one side and edited on the other, overlapping text ranges, both sides moving an outline, or both sides reordering the same container remain ContentChanged conflicts for review_page. Validation: five sync tests (different paragraphs, same paragraph with and without overlap, local insertion beside a remote deletion, remote deletion of the edited paragraph, outline moves against remote text edits and moves) plus a text-merge unit test; Clippy and fmt. Assisted-by: claude-fable-5.1

4 files changed, 724 insertions(+), 4 deletions(-)

crates/notebook/src/lib.rs+1
......@@ -15,6 +15,7 @@ use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Durati
1515
1616mod assets;
1717mod formatting;
18mod merge;
1819mod outline;
1920mod pages;
2021mod paragraph;
crates/notebook/src/merge.rs created+458
......@@ -0,0 +1,458 @@
1//! Three-way merge of page models: the remote page keeps everything it changed, and the
2//! local changes (`base` → `ours`) are re-applied wherever the two sides touched
3//! different objects, fields or text ranges. Any overlap is a conflict for review.
4
5use onestore::{
6 ExGuid,
7 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, text::Edit},
8};
9use std::collections::{BTreeMap, BTreeSet};
10
11pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {
12 if ours == base {
13 return Some(theirs.clone());
14 }
15 if theirs == base || ours == theirs {
16 return Some(ours.clone());
17 }
18 if ours.created != base.created || ours.margin_origin != base.margin_origin {
19 return None;
20 }
21 fn by_id(page: &Page) -> BTreeMap<ExGuid, &PageObject> {
22 page.objects
23 .iter()
24 .map(|object| (object.id(), object))
25 .collect()
26 }
27 let (b, o, t) = (by_id(base), by_id(ours), by_id(theirs));
28 let mut result: Vec<PageObject> = Vec::new();
29 for object in &theirs.objects {
30 let id = object.id();
31 match (b.get(&id), o.get(&id)) {
32 (Some(before), Some(after)) => match (before, after, object) {
33 (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => {
34 result.push(PageObject::Outline(merge_outline(x, y, z)?));
35 }
36 _ => {
37 if after != before {
38 return None;
39 }
40 result.push(object.clone());
41 }
42 },
43 (Some(before), None) => {
44 // Removed locally; keep only if the remote left it untouched.
45 if object != *before {
46 return None;
47 }
48 }
49 (None, _) => result.push(object.clone()),
50 }
51 }
52 for (index, object) in ours.objects.iter().enumerate() {
53 let id = object.id();
54 if b.contains_key(&id) {
55 if !t.contains_key(&id) {
56 // Removed remotely; a local edit to it cannot be placed.
57 if b[&id] != object {
58 return None;
59 }
60 }
61 continue;
62 }
63 if t.contains_key(&id) {
64 return None;
65 }
66 let PageObject::Outline(_) = object else {
67 return None;
68 };
69 let predecessor = ours.objects[..index]
70 .iter()
71 .rev()
72 .map(PageObject::id)
73 .find(|other| result.iter().any(|r| r.id() == *other));
74 let at = match predecessor {
75 Some(other) => result.iter().position(|r| r.id() == other).unwrap() + 1,
76 None => 0,
77 };
78 let at = at.min(
79 result
80 .iter()
81 .position(|r| matches!(r, PageObject::Title(_)))
82 .unwrap_or(result.len()),
83 );
84 result.insert(at, object.clone());
85 }
86 let order = |objects: &[PageObject], keep: &dyn Fn(ExGuid) -> bool| -> Vec<ExGuid> {
87 objects
88 .iter()
89 .filter(|object| matches!(object, PageObject::Outline(_)) && keep(object.id()))
90 .map(PageObject::id)
91 .collect()
92 };
93 let common = |id: ExGuid| b.contains_key(&id) && o.contains_key(&id) && t.contains_key(&id);
94 let (base_order, our_order, their_order) = (
95 order(&base.objects, &common),
96 order(&ours.objects, &common),
97 order(&theirs.objects, &common),
98 );
99 if our_order != base_order {
100 if their_order != base_order && their_order != our_order {
101 return None;
102 }
103 result = reorder(result, &our_order);
104 }
105 Some(Page {
106 title: theirs.title.clone(),
107 created: theirs.created,
108 margin_origin: theirs.margin_origin,
109 objects: result,
110 definitions: theirs.definitions.clone(),
111 })
112}
113
114/// Places the objects named in `order` into the slots those objects occupy in `objects`,
115/// leaving every other object where it is.
116fn reorder(objects: Vec<PageObject>, order: &[ExGuid]) -> Vec<PageObject> {
117 let mut slots: Vec<Option<PageObject>> = objects.into_iter().map(Some).collect();
118 let positions: Vec<usize> = slots
119 .iter()
120 .enumerate()
121 .filter(|(_, slot)| {
122 slot.as_ref()
123 .is_some_and(|object| order.contains(&object.id()))
124 })
125 .map(|(i, _)| i)
126 .collect();
127 let mut taken: BTreeMap<ExGuid, PageObject> = positions
128 .iter()
129 .map(|&i| {
130 let object = slots[i].take().unwrap();
131 (object.id(), object)
132 })
133 .collect();
134 for (slot, id) in positions.into_iter().zip(order) {
135 slots[slot] = taken.remove(id);
136 }
137 slots.into_iter().flatten().collect()
138}
139
140fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> {
141 if ours == base {
142 Some(theirs.clone())
143 } else if theirs == base || theirs == ours {
144 Some(ours.clone())
145 } else {
146 None
147 }
148}
149
150fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> {
151 if ours.title != base.title
152 || ours.min_width != base.min_width
153 || ours.indents != base.indents
154 || ours.unsupported != base.unsupported
155 {
156 return None;
157 }
158 let mut layout = theirs.layout.clone();
159 let (x, y) = pick(
160 &(base.layout.x, base.layout.y),
161 &(ours.layout.x, ours.layout.y),
162 &(theirs.layout.x, theirs.layout.y),
163 )?;
164 let (max_width, user_set) = pick(
165 &(base.layout.max_width, base.layout.width_set_by_user),
166 &(ours.layout.max_width, ours.layout.width_set_by_user),
167 &(theirs.layout.max_width, theirs.layout.width_set_by_user),
168 )?;
169 layout.x = x;
170 layout.y = y;
171 layout.max_width = max_width;
172 layout.width_set_by_user = user_set;
173 Some(Outline {
174 id: theirs.id,
175 title: theirs.title,
176 min_width: theirs.min_width,
177 layout,
178 indents: theirs.indents.clone(),
179 paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?,
180 unsupported: theirs.unsupported.clone(),
181 })
182}
183
184type Children = BTreeMap<Option<ExGuid>, Vec<ExGuid>>;
185
186fn children(list: &[PageParagraph]) -> Children {
187 let mut children: Children = BTreeMap::new();
188 for paragraph in list {
189 children
190 .entry(paragraph.parent)
191 .or_default()
192 .push(paragraph.id);
193 }
194 children
195}
196
197fn merge_paragraphs(
198 base: &[PageParagraph],
199 ours: &[PageParagraph],
200 theirs: &[PageParagraph],
201) -> Option<Vec<PageParagraph>> {
202 fn index(list: &[PageParagraph]) -> BTreeMap<ExGuid, &PageParagraph> {
203 list.iter().map(|p| (p.id, p)).collect()
204 }
205 let (b, o, t) = (index(base), index(ours), index(theirs));
206 let mut merged: BTreeMap<ExGuid, PageParagraph> = BTreeMap::new();
207 let mut removed = BTreeSet::new();
208 for id in b.keys().chain(o.keys()).chain(t.keys()) {
209 if merged.contains_key(id) || removed.contains(id) {
210 continue;
211 }
212 let paragraph = match (b.get(id), o.get(id), t.get(id)) {
213 (Some(x), Some(y), Some(z)) => merge_paragraph(x, y, z)?,
214 (Some(x), None, Some(z)) => {
215 if z != x {
216 return None;
217 }
218 removed.insert(*id);
219 continue;
220 }
221 (Some(x), Some(y), None) => {
222 if y != x {
223 return None;
224 }
225 removed.insert(*id);
226 continue;
227 }
228 (Some(_), None, None) => {
229 removed.insert(*id);
230 continue;
231 }
232 (None, Some(_), Some(_)) => return None,
233 (None, Some(y), None) => (*y).clone(),
234 (None, None, Some(z)) => (*z).clone(),
235 (None, None, None) => unreachable!(),
236 };
237 merged.insert(*id, paragraph);
238 }
239 // Child order per container: the remote order unless only we reordered it.
240 let (bc, oc, tc) = (children(base), children(ours), children(theirs));
241 let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new();
242 let containers: BTreeSet<Option<ExGuid>> = bc
243 .keys()
244 .chain(oc.keys())
245 .chain(tc.keys())
246 .copied()
247 .collect();
248 for container in containers {
249 let live = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> {
250 ids.map(|ids| {
251 ids.iter()
252 .copied()
253 .filter(|id| merged.contains_key(id))
254 .collect()
255 })
256 .unwrap_or_default()
257 };
258 let (base_list, our_list, their_list) = (
259 live(bc.get(&container)),
260 live(oc.get(&container)),
261 live(tc.get(&container)),
262 );
263 let common = |list: &[ExGuid]| -> Vec<ExGuid> {
264 list.iter()
265 .copied()
266 .filter(|id| {
267 base_list.contains(id) && our_list.contains(id) && their_list.contains(id)
268 })
269 .collect()
270 };
271 let (base_common, our_common, their_common) =
272 (common(&base_list), common(&our_list), common(&their_list));
273 let mut list: Vec<ExGuid> = their_list.clone();
274 if our_common != base_common {
275 if their_common != base_common && their_common != our_common {
276 return None;
277 }
278 let slots: Vec<usize> = list
279 .iter()
280 .enumerate()
281 .filter(|(_, id)| our_common.contains(id))
282 .map(|(i, _)| i)
283 .collect();
284 for (slot, id) in slots.into_iter().zip(&our_common) {
285 list[slot] = *id;
286 }
287 }
288 // Paragraphs we added go after the predecessor they follow in our list.
289 for (at, id) in our_list.iter().enumerate() {
290 if list.contains(id) {
291 continue;
292 }
293 if !b.contains_key(id) && merged[id].parent == container {
294 let predecessor = our_list[..at]
295 .iter()
296 .rev()
297 .find(|other| list.contains(other));
298 let position =
299 predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1);
300 list.insert(position, *id);
301 }
302 }
303 for id in &list {
304 if merged[id].parent != container {
305 return None;
306 }
307 }
308 order.insert(container, list);
309 }
310 let mut out = Vec::new();
311 let mut pending: Vec<ExGuid> = order.get(&None).cloned().unwrap_or_default();
312 pending.reverse();
313 while let Some(id) = pending.pop() {
314 let paragraph = merged.remove(&id)?;
315 out.push(paragraph);
316 if let Some(children) = order.get(&Some(id)) {
317 pending.extend(children.iter().rev().copied());
318 }
319 }
320 if !merged.is_empty() {
321 return None;
322 }
323 Some(out)
324}
325
326fn merge_paragraph(
327 base: &PageParagraph,
328 ours: &PageParagraph,
329 theirs: &PageParagraph,
330) -> Option<PageParagraph> {
331 if ours.lists != base.lists
332 || ours.tags != base.tags
333 || ours.style != base.style
334 || ours.format != base.format
335 {
336 return None;
337 }
338 let (parent, level) = pick(
339 &(base.parent, base.level),
340 &(ours.parent, ours.level),
341 &(theirs.parent, theirs.level),
342 )?;
343 let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?;
344 let content = match (&base.content, &ours.content, &theirs.content) {
345 (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) => {
346 if x.id != y.id || x.id != z.id || y.date_field != x.date_field || y.tags != x.tags {
347 return None;
348 }
349 let mut text = z.clone();
350 text.text = merge_text(&x.text, &y.text, &z.text)?;
351 ParagraphContent::Text(text)
352 }
353 (x, y, z) => {
354 if y != x {
355 return None;
356 }
357 z.clone()
358 }
359 };
360 Some(PageParagraph {
361 id: theirs.id,
362 parent,
363 level,
364 style: theirs.style,
365 format: theirs.format.clone(),
366 content,
367 lists: theirs.lists.clone(),
368 tags: theirs.tags.clone(),
369 collapsed,
370 })
371}
372
373/// Re-applies our replacement inside the remote text when its range maps unambiguously.
374fn merge_text(
375 base: &onestore::page::Paragraph,
376 ours: &onestore::page::Paragraph,
377 theirs: &onestore::page::Paragraph,
378) -> Option<onestore::page::Paragraph> {
379 if let Some(picked) = pick(base, ours, theirs) {
380 return Some(picked);
381 }
382 if base.text() == ours.text() || base.text() == theirs.text() {
383 // Formatting-only changes on both sides cannot be attributed to ranges.
384 return None;
385 }
386 let (b, o) = (base.text(), ours.text());
387 let prefix = b
388 .char_indices()
389 .zip(o.chars())
390 .take_while(|((_, x), y)| x == y)
391 .map(|((i, x), _)| i + x.len_utf8())
392 .last()
393 .unwrap_or(0);
394 let suffix = b[prefix..]
395 .chars()
396 .rev()
397 .zip(o[prefix..].chars().rev())
398 .take_while(|(x, y)| x == y)
399 .map(|(x, _)| x.len_utf8())
400 .sum::<usize>();
401 let start = base.utf16_offset(prefix).ok()?;
402 let end = base.utf16_offset(b.len() - suffix).ok()?;
403 let inserted = ours.utf16_offset(o.len() - suffix).ok()?;
404 let replacement = ours.slice(start..inserted).ok()?;
405 let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?;
406 let mut merged = theirs.clone();
407 merged
408 .apply(Edit {
409 range: mapped,
410 replacement,
411 })
412 .ok()?;
413 Some(merged)
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use onestore::page::Paragraph;
420
421 fn text(s: &str) -> Paragraph {
422 Paragraph::new(s.into(), Default::default())
423 }
424
425 #[test]
426 fn same_paragraph_edits_merge_when_their_ranges_do_not_overlap() {
427 let base = text("one two three");
428 let ours = text("one two three four");
429 assert_eq!(
430 merge_text(&base, &ours, &text("zero one two three"))
431 .unwrap()
432 .text(),
433 "zero one two three four"
434 );
435 assert_eq!(
436 merge_text(&base, &ours, &text("one 2 three"))
437 .unwrap()
438 .text(),
439 "one 2 three four"
440 );
441 assert_eq!(
442 merge_text(&base, &text("one two THREE"), &text("one two 3")),
443 None
444 );
445 assert_eq!(
446 merge_text(&base, &text("one two three"), &text("x"))
447 .unwrap()
448 .text(),
449 "x"
450 );
451 assert_eq!(
452 merge_text(&base, &text("y"), &text("one two three"))
453 .unwrap()
454 .text(),
455 "y"
456 );
457 }
458}
crates/notebook/src/sync.rs+7-4
......@@ -142,10 +142,13 @@ impl Replica {
142142 Operation::Page(edit) => page_of(&snapshot, intent.space)?
143143 .ok_or(ConflictKind::TargetUnavailable)
144144 .and_then(|current| {
145 if current != edit.before {
146 return Err(ConflictKind::ContentChanged);
147 }
148 PreparedEdit::page(&snapshot, intent.space, &edit.after, &edit.author)
145 let target = if current == edit.before {
146 edit.after.clone()
147 } else {
148 crate::merge::merge(&edit.before, &edit.after, &current)
149 .ok_or(ConflictKind::ContentChanged)?
150 };
151 PreparedEdit::page(&snapshot, intent.space, &target, &edit.author)
149152 .map_err(|_| ConflictKind::UnsupportedEdit)
150153 }),
151154 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
crates/notebook/tests/sync/model.rs+258
......@@ -266,3 +266,261 @@ fn pending_saves_survive_reopening_the_cache() {
266266 assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending));
267267 assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after);
268268}
269
270/// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`.
271fn edit_paragraph(page: &mut Page, index: usize, range: std::ops::Range<u32>, replacement: &str) {
272 let outline = page
273 .objects
274 .iter_mut()
275 .find_map(|object| match object {
276 PageObject::Outline(outline) => Some(outline),
277 _ => None,
278 })
279 .unwrap();
280 let text = outline.paragraphs[index].text_mut().unwrap();
281 let format = text.text.format_at(range.start).unwrap().clone();
282 text.text
283 .apply(onestore::page::text::Edit {
284 range,
285 replacement: onestore::page::Paragraph::new(replacement.into(), format),
286 })
287 .unwrap();
288}
289
290fn texts(page: &Page) -> Vec<String> {
291 page.objects
292 .iter()
293 .find_map(|object| match object {
294 PageObject::Outline(outline) => Some(
295 outline
296 .paragraphs
297 .iter()
298 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
299 .collect(),
300 ),
301 _ => None,
302 })
303 .unwrap()
304}
305
306fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
307 let mut page = page_of(OUTLINES, space);
308 change(&mut page);
309 Server::new(
310 PreparedEdit::page(OUTLINES, space, &page, "Native author")
311 .unwrap()
312 .as_bytes(),
313 )
314}
315
316#[test]
317fn concurrent_edits_to_different_paragraphs_merge() {
318 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
319 let directory = tempfile::tempdir().unwrap();
320 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
321 edit_paragraph(&mut after, 0, 0..0, "Local ");
322 let id = cache
323 .save(OUTLINES, space, &after, "Model author")
324 .unwrap()
325 .unwrap();
326 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
327 assert!(
328 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
329 );
330 let published = texts(&page_of(&server.durable, space));
331 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
332 assert!(published[2].starts_with("Remote Trailing"), "{published:?}");
333 assert_eq!(
334 cache
335 .status(id)
336 .unwrap()
337 .map(|s| matches!(s, EditStatus::Published { .. })),
338 Some(true)
339 );
340}
341
342#[test]
343fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
344 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
345 let directory = tempfile::tempdir().unwrap();
346 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
347 append(&mut after, " local");
348 let id = cache
349 .save(OUTLINES, space, &after, "Model author")
350 .unwrap()
351 .unwrap();
352 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
353 assert!(
354 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
355 );
356 assert_eq!(
357 texts(&page_of(&server.durable, space))[0],
358 "Remote Anchor local"
359 );
360
361 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
362 let mut after = page_of(OUTLINES, space);
363 edit_paragraph(&mut after, 0, 0..6, "Local");
364 let id = cache
365 .save(OUTLINES, space, &after, "Model author")
366 .unwrap()
367 .unwrap();
368 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
369 assert_eq!(
370 cache.sync_once(&mut server).unwrap(),
371 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
372 );
373 assert_eq!(server.publications, 0);
374}
375
376#[test]
377fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
378 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
379 let directory = tempfile::tempdir().unwrap();
380 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
381 let template = texts(&after);
382 assert_eq!(template.len(), 3);
383 {
384 let outline = after
385 .objects
386 .iter_mut()
387 .find_map(|object| match object {
388 PageObject::Outline(outline) => Some(outline),
389 _ => None,
390 })
391 .unwrap();
392 let mut fresh = outline.paragraphs[0].clone();
393 fresh.id = onestore::page::text::new_id().unwrap();
394 fresh.style = None;
395 let text = fresh.text_mut().unwrap();
396 text.id = onestore::page::text::new_id().unwrap();
397 text.text = onestore::page::Paragraph::new(
398 "Inserted locally".into(),
399 text.text.format_at(0).unwrap().clone(),
400 );
401 outline.paragraphs.insert(1, fresh);
402 }
403 let id = cache
404 .save(OUTLINES, space, &after, "Model author")
405 .unwrap()
406 .unwrap();
407 let mut server = remote_with(space, |page| {
408 let outline = page
409 .objects
410 .iter_mut()
411 .find_map(|object| match object {
412 PageObject::Outline(outline) => Some(outline),
413 _ => None,
414 })
415 .unwrap();
416 outline.paragraphs.pop();
417 });
418 assert!(
419 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
420 );
421 assert_eq!(
422 texts(&page_of(&server.durable, space)),
423 vec![
424 template[0].clone(),
425 "Inserted locally".to_owned(),
426 template[1].clone()
427 ]
428 );
429}
430
431#[test]
432fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
433 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
434 let directory = tempfile::tempdir().unwrap();
435 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
436 edit_paragraph(&mut after, 2, 0..0, "Local ");
437 let id = cache
438 .save(OUTLINES, space, &after, "Model author")
439 .unwrap()
440 .unwrap();
441 let mut server = remote_with(space, |page| {
442 let outline = page
443 .objects
444 .iter_mut()
445 .find_map(|object| match object {
446 PageObject::Outline(outline) => Some(outline),
447 _ => None,
448 })
449 .unwrap();
450 outline.paragraphs.pop();
451 });
452 assert_eq!(
453 cache.sync_once(&mut server).unwrap(),
454 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
455 );
456}
457
458#[test]
459fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
460 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
461 let directory = tempfile::tempdir().unwrap();
462 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
463 let outline_id = {
464 let PageObject::Outline(outline) = after
465 .objects
466 .iter_mut()
467 .find(|o| matches!(o, PageObject::Outline(_)))
468 .unwrap()
469 else {
470 unreachable!()
471 };
472 outline.layout.x = Some(200.0);
473 outline.layout.y = Some(300.0);
474 outline.id
475 };
476 let id = cache
477 .save(OUTLINES, space, &after, "Model author")
478 .unwrap()
479 .unwrap();
480 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
481 assert!(
482 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
483 );
484 let published = page_of(&server.durable, space);
485 let PageObject::Outline(outline) = published
486 .objects
487 .iter()
488 .find(|o| o.id() == outline_id)
489 .unwrap()
490 else {
491 unreachable!()
492 };
493 assert_eq!(
494 (outline.layout.x, outline.layout.y),
495 (Some(200.0), Some(300.0))
496 );
497 assert!(
498 outline.paragraphs[0]
499 .text()
500 .unwrap()
501 .text
502 .text()
503 .starts_with("Remote ")
504 );
505
506 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
507 let id = cache
508 .save(OUTLINES, space, &after, "Model author")
509 .unwrap()
510 .unwrap();
511 let mut server = remote_with(space, |page| {
512 let PageObject::Outline(outline) = page
513 .objects
514 .iter_mut()
515 .find(|o| matches!(o, PageObject::Outline(_)))
516 .unwrap()
517 else {
518 unreachable!()
519 };
520 outline.layout.x = Some(50.0);
521 });
522 assert_eq!(
523 cache.sync_once(&mut server).unwrap(),
524 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
525 );
526}