| author | |
| committer | |
| log | c830e2971fbbcfeb96cfb5286d43d88e0edff1d5 |
| tree | 1629ab84ffd4ebf8e5e7b41359a66a83cb8f3d52 |
| parent | 0c15feff0434402e533f3d088940143f3bb4cfa5 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
A move reads the page together with its conflict pages and versions, which no copy carries,
and retires it only while all three are as read, so a conflict page kept or a version made
during the move is no longer deleted with the page. The API is named for moves:
Replica::moving reads the page, and Replica::retire deletes it, refusing with "The page
changed while it moved." otherwise.
Once the copy has landed, any failure to retire the original, whether a change or a cache
that cannot write, leaves the page in both sections. The desktop then shows "Page kept in
both sections" with the reason and records no step that cannot be taken back, and an Undo
of a move that ends this way shows the copy it brought back. iOS reports the same through
the copied field of sb_library_move_page and its own alert. A desktop move no longer fails
after its copy when the target section still has edits waiting for the cache: the section
is kept open, retrying them, rather than closed.
Refs #138
Assisted-by: claude-opus-5.513 files changed, 395 insertions(+), 126 deletions(-)
Cargo.lock+1| ... | @@ -3649,6 +3649,7 @@ dependencies = [ | ... | @@ -3649,6 +3649,7 @@ dependencies = [ |
| 3649 | "png", | 3649 | "png", |
| 3650 | "pollster", | 3650 | "pollster", |
| 3651 | "ring", | 3651 | "ring", |
| 3652 | "rusqlite", | ||
| 3652 | "rustc-demangle", | 3653 | "rustc-demangle", |
| 3653 | "rustls-native-certs", | 3654 | "rustls-native-certs", |
| 3654 | "sctk-adwaita", | 3655 | "sctk-adwaita", |
apps/ios/Snowbound/Arranging.swift+6-1| ... | @@ -12,6 +12,8 @@ private struct MovedPage: Decodable { | ... | @@ -12,6 +12,8 @@ private struct MovedPage: Decodable { |
| 12 | let added: String? | 12 | let added: String? |
| 13 | let before: String? | 13 | let before: String? |
| 14 | let level: Int | 14 | let level: Int |
| 15 | /// Why the page also stayed where it was: the move only copied it. | ||
| 16 | let copied: String? | ||
| 15 | } | 17 | } |
| 16 | 18 | ||
| 17 | /// The catalog path of the folder holding the section or group at `path`; "" for the | 19 | /// The catalog path of the folder holding the section or group at `path`; "" for the |
| ... | @@ -155,6 +157,7 @@ enum Arranging { | ... | @@ -155,6 +157,7 @@ enum Arranging { |
| 155 | section.reloadRows() | 157 | section.reloadRows() |
| 156 | } | 158 | } |
| 157 | if moved == nil { failed("Can’t Move Page") } | 159 | if moved == nil { failed("Can’t Move Page") } |
| 160 | if let copied = moved?.copied { failed("Page Kept in Both Sections", copied) } | ||
| 158 | } | 161 | } |
| 159 | } | 162 | } |
| 160 | 163 | ||
| ... | @@ -244,7 +247,9 @@ enum Arranging { | ... | @@ -244,7 +247,9 @@ enum Arranging { |
| 244 | release() | 247 | release() |
| 245 | source.reloadRows() | 248 | source.reloadRows() |
| 246 | target.reloadRows() | 249 | target.reloadRows() |
| 247 | if !succeeded { | 250 | if let copied = reported.lazy.compactMap(\.copied).first { |
| 251 | failed("Page Kept in Both Sections", copied) | ||
| 252 | } else if !succeeded { | ||
| 248 | failed("Can’t Move All Pages", reported.isEmpty | 253 | failed("Can’t Move All Pages", reported.isEmpty |
| 249 | ? "Check both sections, then try moving the remaining pages again." | 254 | ? "Check both sections, then try moving the remaining pages again." |
| 250 | : "Check both sections. Use \(undoing ? "Redo" : "Undo") to reverse completed moves, or try moving the remaining pages again.") | 255 | : "Check both sections. Use \(undoing ? "Redo" : "Undo") to reverse completed moves, or try moving the remaining pages again.") |
crates/mobile/src/library.rs+16-10| ... | @@ -1387,9 +1387,9 @@ impl Library { | ... | @@ -1387,9 +1387,9 @@ impl Library { |
| 1387 | let at = (listed.iter().position(|(listed, ..)| *listed == space)) | 1387 | let at = (listed.iter().position(|(listed, ..)| *listed == space)) |
| 1388 | .ok_or("The page is not listed")?; | 1388 | .ok_or("The page is not listed")?; |
| 1389 | let next = listed.get(at + 1).map(|(next, ..)| *next); | 1389 | let next = listed.get(at + 1).map(|(next, ..)| *next); |
| 1390 | Ok((section.page(space)?, (next, listed[at].2))) | 1390 | Ok((section.replica().moving(space)?, (next, listed[at].2))) |
| 1391 | })?; | 1391 | })?; |
| 1392 | let import = session::moved(&page, author)?; | 1392 | let import = session::moved(page.page(), author)?; |
| 1393 | let Op::Section(SectionOp::Import { creation, .. }) = &import else { | 1393 | let Op::Section(SectionOp::Import { creation, .. }) = &import else { |
| 1394 | unreachable!("notebook::session::moved imports"); | 1394 | unreachable!("notebook::session::moved imports"); |
| 1395 | }; | 1395 | }; |
| ... | @@ -1415,7 +1415,7 @@ impl Library { | ... | @@ -1415,7 +1415,7 @@ impl Library { |
| 1415 | }; | 1415 | }; |
| 1416 | Ok(section.replica().apply(author, edit)?) | 1416 | Ok(section.replica().apply(author, edit)?) |
| 1417 | })?; | 1417 | })?; |
| 1418 | let added = self.with_section(from, |section| { | 1418 | let retired = self.with_section(from, |section| { |
| 1419 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; | 1419 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; |
| 1420 | let added = match section.pages()?.as_slice() { | 1420 | let added = match section.pages()?.as_slice() { |
| 1421 | [(only, ..)] if *only == space => { | 1421 | [(only, ..)] if *only == space => { |
| ... | @@ -1430,17 +1430,20 @@ impl Library { | ... | @@ -1430,17 +1430,20 @@ impl Library { |
| 1430 | at: filetime(), | 1430 | at: filetime(), |
| 1431 | ops, | 1431 | ops, |
| 1432 | }; | 1432 | }; |
| 1433 | section | 1433 | section.replica().retire(author, edit, page)?; |
| 1434 | .replica() | ||
| 1435 | .apply_unchanged(author, edit, space, page)?; | ||
| 1436 | Ok(added) | 1434 | Ok(added) |
| 1437 | })?; | 1435 | }); |
| 1438 | let id = |space: Option<ExGuid>| space.map(|space| space.to_string()); | 1436 | let id = |space: Option<ExGuid>| space.map(|space| space.to_string()); |
| 1437 | let (added, copied) = match retired { | ||
| 1438 | Ok(added) => (id(added), None), | ||
| 1439 | Err(error) => (None, Some(error.to_string())), | ||
| 1440 | }; | ||
| 1439 | Ok(MovedPage { | 1441 | Ok(MovedPage { |
| 1440 | page: arrived.to_string(), | 1442 | page: arrived.to_string(), |
| 1441 | added: id(added), | 1443 | added, |
| 1442 | before: id(was.0), | 1444 | before: id(was.0), |
| 1443 | level: was.1, | 1445 | level: was.1, |
| 1446 | copied, | ||
| 1444 | }) | 1447 | }) |
| 1445 | } | 1448 | } |
| 1446 | 1449 | ||
| ... | @@ -1481,6 +1484,8 @@ pub(crate) struct MovedPage { | ... | @@ -1481,6 +1484,8 @@ pub(crate) struct MovedPage { |
| 1481 | pub(crate) added: Option<String>, | 1484 | pub(crate) added: Option<String>, |
| 1482 | pub(crate) before: Option<String>, | 1485 | pub(crate) before: Option<String>, |
| 1483 | pub(crate) level: u32, | 1486 | pub(crate) level: u32, |
| 1487 | /// Why the page also stayed where it was: the move only copied it. | ||
| 1488 | pub(crate) copied: Option<String>, | ||
| 1484 | } | 1489 | } |
| 1485 | 1490 | ||
| 1486 | #[derive(serde::Serialize)] | 1491 | #[derive(serde::Serialize)] |
| ... | @@ -1937,8 +1942,9 @@ pub unsafe extern "C" fn sb_library_new_section( | ... | @@ -1937,8 +1942,9 @@ pub unsafe extern "C" fn sb_library_new_section( |
| 1937 | /// before page `before` at `level`, or last with `before` null; `discard`, unless null, is | 1942 | /// before page `before` at `level`, or last with `before` null; `discard`, unless null, is |
| 1938 | /// the page `to` gained when this page left it. Returns JSON with `page`, its id there, | 1943 | /// the page `to` gained when this page left it. Returns JSON with `page`, its id there, |
| 1939 | /// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and | 1944 | /// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and |
| 1940 | /// `level`, where it was in `from`; null when it cannot move. Writes to both sections, so | 1945 | /// `level`, where it was in `from`, and `copied`, why it also stayed in `from` where the move |
| 1941 | /// call it off the main thread. | 1946 | /// only copied it, or null; null when it cannot move. Writes to both sections, so call it |
| 1947 | /// off the main thread. | ||
| 1942 | /// | 1948 | /// |
| 1943 | /// # Safety | 1949 | /// # Safety |
| 1944 | /// Every string is NUL-terminated UTF-8; `before` and `discard` may be null. | 1950 | /// Every string is NUL-terminated UTF-8; `before` and `discard` may be null. |
crates/mobile/src/tests.rs+58-3| ... | @@ -1813,7 +1813,7 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { | ... | @@ -1813,7 +1813,7 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1813 | "Clover Test", | 1813 | "Clover Test", |
| 1814 | date, | 1814 | date, |
| 1815 | ) | 1815 | ) |
| 1816 | .map(|_| ()) | 1816 | .map(|moved| moved.copied) |
| 1817 | .map_err(|error| error.to_string()) | 1817 | .map_err(|error| error.to_string()) |
| 1818 | }); | 1818 | }); |
| 1819 | holding.recv_timeout(Duration::from_secs(10)).unwrap(); | 1819 | holding.recv_timeout(Duration::from_secs(10)).unwrap(); |
| ... | @@ -1830,8 +1830,8 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { | ... | @@ -1830,8 +1830,8 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1830 | } | 1830 | } |
| 1831 | features.shared.section.written().unwrap(); | 1831 | features.shared.section.written().unwrap(); |
| 1832 | drop(release); | 1832 | drop(release); |
| 1833 | let moved = move_page.join().unwrap(); | 1833 | let copied = move_page.join().unwrap().unwrap(); |
| 1834 | assert_eq!(moved.is_ok(), !edited, "{moved:?}"); | 1834 | assert_eq!(copied.is_some(), edited, "{copied:?}"); |
| 1835 | drop(features); | 1835 | drop(features); |
| 1836 | library.close(Duration::from_secs(20)).unwrap(); | 1836 | library.close(Duration::from_secs(20)).unwrap(); |
| 1837 | let arena = onestore::Arena::default(); | 1837 | let arena = onestore::Arena::default(); |
| ... | @@ -1853,6 +1853,58 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { | ... | @@ -1853,6 +1853,58 @@ fn a_page_edited_while_it_moves_stays_with_its_edit() { |
| 1853 | } | 1853 | } |
| 1854 | } | 1854 | } |
| 1855 | 1855 | ||
| 1856 | /// A page whose section cannot write as it leaves stays there beside the copy that arrived, | ||
| 1857 | /// and the move says so; both files hold it once the notebook closes. | ||
| 1858 | #[test] | ||
| 1859 | fn a_page_its_section_cannot_retire_stays_beside_its_copy() { | ||
| 1860 | vfs::install(); | ||
| 1861 | let _exclusive = vfs::EXCLUSIVE | ||
| 1862 | .lock() | ||
| 1863 | .unwrap_or_else(std::sync::PoisonError::into_inner); | ||
| 1864 | let (directory, root, library, empty) = notebook_open("Empty.one"); | ||
| 1865 | let features = open(&library, "Features.one"); | ||
| 1866 | let space = space(&features, "Task controls"); | ||
| 1867 | drop(features); | ||
| 1868 | let file = std::fs::read(root.join("Features.one")).unwrap(); | ||
| 1869 | let identity = onestore::RevisionIndex::parse(&onestore::Store::parse(&file).unwrap()) | ||
| 1870 | .unwrap() | ||
| 1871 | .root | ||
| 1872 | .guid; | ||
| 1873 | let location = notebook::location::local(&root).unwrap(); | ||
| 1874 | let replica = | ||
| 1875 | notebook::location::folder(&directory.path().join("cache"), &location).join(format!( | ||
| 1876 | "{}.sqlite", | ||
| 1877 | (identity.iter()) | ||
| 1878 | .map(|byte| format!("{byte:02x}")) | ||
| 1879 | .collect::<String>() | ||
| 1880 | )); | ||
| 1881 | *vfs::FAILING.lock().unwrap() = Some(replica.canonicalize().unwrap()); | ||
| 1882 | let date = ["Thursday, October 1, 2026", "9:00 AM"]; | ||
| 1883 | let moved = library.move_page( | ||
| 1884 | "Features.one", | ||
| 1885 | space, | ||
| 1886 | "Empty.one", | ||
| 1887 | None, | ||
| 1888 | 1, | ||
| 1889 | None, | ||
| 1890 | "Clover Test", | ||
| 1891 | date, | ||
| 1892 | ); | ||
| 1893 | *vfs::FAILING.lock().unwrap() = None; | ||
| 1894 | assert!(moved.unwrap().copied.is_some()); | ||
| 1895 | drop(empty); | ||
| 1896 | library.close(Duration::from_secs(20)).unwrap(); | ||
| 1897 | let titles = |name: &str| { | ||
| 1898 | let arena = onestore::Arena::default(); | ||
| 1899 | let stored = onestore::read_file(root.join(name)).unwrap(); | ||
| 1900 | let mut section = onestore::Section::open(&arena, stored).unwrap(); | ||
| 1901 | (section.pages().unwrap().into_iter()) | ||
| 1902 | .filter(|(_, title, _)| title == "Task controls") | ||
| 1903 | .count() | ||
| 1904 | }; | ||
| 1905 | assert_eq!((titles("Features.one"), titles("Empty.one")), (1, 1)); | ||
| 1906 | } | ||
| 1907 | |||
| 1856 | /// A page the section it moves to refuses stays where it was, and the move fails. | 1908 | /// A page the section it moves to refuses stays where it was, and the move fails. |
| 1857 | #[test] | 1909 | #[test] |
| 1858 | fn a_page_the_other_section_refuses_stays() { | 1910 | fn a_page_the_other_section_refuses_stays() { |
| ... | @@ -3386,6 +3438,9 @@ fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reloa | ... | @@ -3386,6 +3438,9 @@ fn reader_dropped_and_synchronous_failed_apply_callbacks_never_release_the_reloa |
| 3386 | 3438 | ||
| 3387 | #[test] | 3439 | #[test] |
| 3388 | fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() { | 3440 | fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_failure() { |
| 3441 | let _exclusive = vfs::EXCLUSIVE | ||
| 3442 | .lock() | ||
| 3443 | .unwrap_or_else(std::sync::PoisonError::into_inner); | ||
| 3389 | for select in [false, true] { | 3444 | for select in [false, true] { |
| 3390 | let directory = tempfile::tempdir().unwrap(); | 3445 | let directory = tempfile::tempdir().unwrap(); |
| 3391 | let file = directory.path().join("Reader fault.one"); | 3446 | let file = directory.path().join("Reader fault.one"); |
crates/notebook/README.md+5-4| ... | @@ -38,10 +38,11 @@ assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); | ... | @@ -38,10 +38,11 @@ assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id)); |
| 38 | # } | 38 | # } |
| 39 | ``` | 39 | ``` |
| 40 | 40 | ||
| 41 | `apply_unchanged(author, edit, space, page)` applies the edit only while page `space` | 41 | A move across sections reads the page with `moving(space)`, together with its conflict pages |
| 42 | still holds `page`, checked on the section thread in order with every other edit, and | 42 | and versions, which no copy carries; copies it there; then retires it here with |
| 43 | refuses it as `Rejected` otherwise: a move across sections copies a page there, then retires | 43 | `retire(author, edit, moving)`, which applies the edit only while all of them are as read, |
| 44 | it here this way, so an edit made since the copy keeps the page. | 44 | checked on the section thread in order with every other edit, and refuses it as `Rejected` |
| 45 | otherwise, so an edit made since the copy keeps the page. | ||
| 45 | 46 | ||
| 46 | Page reads never wait for the sync thread: a rebase or reread of the queue runs on a | 47 | Page reads never wait for the sync thread: a rebase or reread of the queue runs on a |
| 47 | new thread while the current one keeps answering `page` and `pages` from the section as | 48 | new thread while the current one keeps answering `page` and `pages` from the section as |
crates/notebook/src/lib.rs+31-21| ... | @@ -88,6 +88,22 @@ type Result<T> = std::result::Result<T, Error>; | ... | @@ -88,6 +88,22 @@ type Result<T> = std::result::Result<T, Error>; |
| 88 | 88 | ||
| 89 | const APPLICATION_ID: u32 = 0x4f4e454f; | 89 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 90 | 90 | ||
| 91 | /// A page as a move copies it (`Replica::moving`), with its conflict pages and versions, which | ||
| 92 | /// stay behind, read together. | ||
| 93 | #[derive(Debug, PartialEq)] | ||
| 94 | pub struct Moving { | ||
| 95 | space: ExGuid, | ||
| 96 | page: Page, | ||
| 97 | conflicts: Vec<onestore::ConflictPage>, | ||
| 98 | versions: Vec<onestore::PageVersion>, | ||
| 99 | } | ||
| 100 | |||
| 101 | impl Moving { | ||
| 102 | pub fn page(&self) -> &Page { | ||
| 103 | &self.page | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 91 | /// A locally durable edit; its ID remains stable across cache reopen. | 107 | /// A locally durable edit; its ID remains stable across cache reopen. |
| 92 | #[derive(Debug, Clone, PartialEq)] | 108 | #[derive(Debug, Clone, PartialEq)] |
| 93 | pub struct PendingEdit { | 109 | pub struct PendingEdit { |
| ... | @@ -308,39 +324,33 @@ impl Replica { | ... | @@ -308,39 +324,33 @@ impl Replica { |
| 308 | edit, | 324 | edit, |
| 309 | reply, | 325 | reply, |
| 310 | waiting: None, | 326 | waiting: None, |
| 311 | unchanged: None, | 327 | moving: None, |
| 312 | }) | 328 | }) |
| 313 | }) | 329 | }) |
| 314 | .await | 330 | .await |
| 315 | } | 331 | } |
| 316 | 332 | ||
| 317 | /// `apply`, provided page `space` still holds `page`; refused as `Rejected` otherwise. | 333 | /// Page `space` as a move copies it, read at once with what the copy leaves behind. |
| 318 | pub fn apply_unchanged( | 334 | pub fn moving(&self, space: ExGuid) -> Result<Moving> { |
| 319 | &self, | 335 | self.ask(|reply| working::Request::Moving { space, reply }) |
| 320 | author: &str, | ||
| 321 | edit: Edit, | ||
| 322 | space: ExGuid, | ||
| 323 | page: Page, | ||
| 324 | ) -> Result<u64> { | ||
| 325 | task::blocking(self.apply_unchanged_async(author, edit, space, page))? | ||
| 326 | } | 336 | } |
| 327 | 337 | ||
| 328 | /// `apply_async`, provided page `space` still holds `page`, checked where edits apply in | 338 | /// `retire_async`, blocking. |
| 329 | /// order: a move retires the page it copied this way, keeping any edit made since. | 339 | pub fn retire(&self, author: &str, edit: Edit, moving: Moving) -> Result<u64> { |
| 330 | pub async fn apply_unchanged_async( | 340 | task::blocking(self.retire_async(author, edit, moving))? |
| 331 | &self, | 341 | } |
| 332 | author: &str, | 342 | |
| 333 | edit: Edit, | 343 | /// Applies `edit`, which deletes the page `moving` read once its copy has landed elsewhere, |
| 334 | space: ExGuid, | 344 | /// only while the page and what it leaves behind are as read: checked in order with every |
| 335 | page: Page, | 345 | /// other edit, so none made since is deleted with it. One changed is refused as `Rejected`. |
| 336 | ) -> Result<u64> { | 346 | pub async fn retire_async(&self, author: &str, edit: Edit, moving: Moving) -> Result<u64> { |
| 337 | self.ask_async(|reply| { | 347 | self.ask_async(|reply| { |
| 338 | working::Request::Apply(working::Accepted { | 348 | working::Request::Apply(working::Accepted { |
| 339 | author: author.to_owned(), | 349 | author: author.to_owned(), |
| 340 | edit, | 350 | edit, |
| 341 | reply, | 351 | reply, |
| 342 | waiting: None, | 352 | waiting: None, |
| 343 | unchanged: Some((space, page)), | 353 | moving: Some(Box::new(moving)), |
| 344 | }) | 354 | }) |
| 345 | }) | 355 | }) |
| 346 | .await | 356 | .await |
| ... | @@ -360,7 +370,7 @@ impl Replica { | ... | @@ -360,7 +370,7 @@ impl Replica { |
| 360 | edit, | 370 | edit, |
| 361 | reply, | 371 | reply, |
| 362 | waiting: Some(waiting), | 372 | waiting: Some(waiting), |
| 363 | unchanged: None, | 373 | moving: None, |
| 364 | })) | 374 | })) |
| 365 | } | 375 | } |
| 366 | 376 |
crates/notebook/src/session.rs+1-2| ... | @@ -2565,8 +2565,7 @@ impl Section { | ... | @@ -2565,8 +2565,7 @@ impl Section { |
| 2565 | } | 2565 | } |
| 2566 | 2566 | ||
| 2567 | /// Removes pages or conflict pages permanently, queued like the user's own edits (a | 2567 | /// Removes pages or conflict pages permanently, queued like the user's own edits (a |
| 2568 | /// move across sections imports a page there, then retires it here with | 2568 | /// move across sections imports a page there, then retires it here with `Replica::retire`). |
| 2569 | /// `Replica::apply_unchanged`). | ||
| 2570 | pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> { | 2569 | pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> { |
| 2571 | crate::task::blocking(self.delete_pages_async(pages))? | 2570 | crate::task::blocking(self.delete_pages_async(pages))? |
| 2572 | } | 2571 | } |
crates/notebook/src/working.rs+30-7| ... | @@ -4,7 +4,7 @@ | ... | @@ -4,7 +4,7 @@ |
| 4 | //! and, when the remote changed, to rebase the queue. In the browser, which has one thread, | 4 | //! and, when the remote changed, to rebase the queue. In the browser, which has one thread, |
| 5 | //! the section is served as each request is sent, and a rebuild runs as it is asked for. | 5 | //! the section is served as each request is sent, and a rebuild runs as it is asked for. |
| 6 | 6 | ||
| 7 | use crate::{Result, base, lock, merge, queue, worker::Signal}; | 7 | use crate::{Moving, Result, base, lock, merge, queue, worker::Signal}; |
| 8 | use onestore::{ | 8 | use onestore::{ |
| 9 | Arena, ExGuid, Section, Transaction, | 9 | Arena, ExGuid, Section, Transaction, |
| 10 | op::{Edit, OpError}, | 10 | op::{Edit, OpError}, |
| ... | @@ -42,6 +42,10 @@ pub(crate) enum Request { | ... | @@ -42,6 +42,10 @@ pub(crate) enum Request { |
| 42 | version: ExGuid, | 42 | version: ExGuid, |
| 43 | reply: Reply<Page>, | 43 | reply: Reply<Page>, |
| 44 | }, | 44 | }, |
| 45 | Moving { | ||
| 46 | space: ExGuid, | ||
| 47 | reply: Reply<Moving>, | ||
| 48 | }, | ||
| 45 | /// Answers once the edits before it are written, optionally awaiting host durability. | 49 | /// Answers once the edits before it are written, optionally awaiting host durability. |
| 46 | Flush { | 50 | Flush { |
| 47 | reply: Reply<()>, | 51 | reply: Reply<()>, |
| ... | @@ -89,6 +93,7 @@ impl Request { | ... | @@ -89,6 +93,7 @@ impl Request { |
| 89 | Self::Conflicts { reply } => reply(Err(error())), | 93 | Self::Conflicts { reply } => reply(Err(error())), |
| 90 | Self::Versions { reply } => reply(Err(error())), | 94 | Self::Versions { reply } => reply(Err(error())), |
| 91 | Self::Version { reply, .. } => reply(Err(error())), | 95 | Self::Version { reply, .. } => reply(Err(error())), |
| 96 | Self::Moving { reply, .. } => reply(Err(error())), | ||
| 92 | Self::Flush { reply, .. } => reply(Err(error())), | 97 | Self::Flush { reply, .. } => reply(Err(error())), |
| 93 | Self::Seal { reply } => reply(Err(error())), | 98 | Self::Seal { reply } => reply(Err(error())), |
| 94 | Self::Rebase { reply, .. } => reply(Err(error())), | 99 | Self::Rebase { reply, .. } => reply(Err(error())), |
| ... | @@ -482,7 +487,7 @@ pub(crate) struct Accepted { | ... | @@ -482,7 +487,7 @@ pub(crate) struct Accepted { |
| 482 | /// Set where nobody blocks on the reply: a failed write keeps the edit and reports here. | 487 | /// Set where nobody blocks on the reply: a failed write keeps the edit and reports here. |
| 483 | pub waiting: Option<Notice>, | 488 | pub waiting: Option<Notice>, |
| 484 | /// A page as a move copied it, which the edit applies only while it still holds. | 489 | /// A page as a move copied it, which the edit applies only while it still holds. |
| 485 | pub unchanged: Option<(ExGuid, Page)>, | 490 | pub moving: Option<Box<Moving>>, |
| 486 | } | 491 | } |
| 487 | 492 | ||
| 488 | struct Working<'a> { | 493 | struct Working<'a> { |
| ... | @@ -553,6 +558,7 @@ impl<'a> Working<'a> { | ... | @@ -553,6 +558,7 @@ impl<'a> Working<'a> { |
| 553 | version, | 558 | version, |
| 554 | reply, | 559 | reply, |
| 555 | } => reply(self.section.version(space, version).map_err(Into::into)), | 560 | } => reply(self.section.version(space, version).map_err(Into::into)), |
| 561 | Request::Moving { space, reply } => reply(self.moving(space)), | ||
| 556 | Request::Handover(to) => { | 562 | Request::Handover(to) => { |
| 557 | let mut waiting = held.take().unwrap_or_default(); | 563 | let mut waiting = held.take().unwrap_or_default(); |
| 558 | waiting.extend(burst.drain(..).chain(later.drain(..))); | 564 | waiting.extend(burst.drain(..).chain(later.drain(..))); |
| ... | @@ -613,6 +619,10 @@ impl<'a> Working<'a> { | ... | @@ -613,6 +619,10 @@ impl<'a> Working<'a> { |
| 613 | reply(self.section.version(space, version).map_err(Into::into)); | 619 | reply(self.section.version(space, version).map_err(Into::into)); |
| 614 | false | 620 | false |
| 615 | } | 621 | } |
| 622 | Request::Moving { space, reply } => { | ||
| 623 | reply(self.moving(space)); | ||
| 624 | false | ||
| 625 | } | ||
| 616 | Request::Flush { reply, durable } => { | 626 | Request::Flush { reply, durable } => { |
| 617 | #[cfg(target_arch = "wasm32")] | 627 | #[cfg(target_arch = "wasm32")] |
| 618 | if durable { | 628 | if durable { |
| ... | @@ -699,15 +709,28 @@ impl<'a> Working<'a> { | ... | @@ -699,15 +709,28 @@ impl<'a> Working<'a> { |
| 699 | .is_ok() | 709 | .is_ok() |
| 700 | } | 710 | } |
| 701 | 711 | ||
| 712 | /// Page `space` as a move copies it, with the conflict pages and versions it leaves. | ||
| 713 | fn moving(&mut self, space: ExGuid) -> Result<Moving> { | ||
| 714 | fn of<T>(space: ExGuid, listed: Vec<(ExGuid, Vec<T>)>) -> Vec<T> { | ||
| 715 | (listed.into_iter()) | ||
| 716 | .find_map(|(page, of)| (page == space).then_some(of)) | ||
| 717 | .unwrap_or_default() | ||
| 718 | } | ||
| 719 | Ok(Moving { | ||
| 720 | space, | ||
| 721 | page: self.section.page(space)?, | ||
| 722 | conflicts: of(space, self.section.conflicts()?), | ||
| 723 | versions: of(space, self.section.versions()?), | ||
| 724 | }) | ||
| 725 | } | ||
| 726 | |||
| 702 | /// Applies an edit, keeping it to be written with the burst. A refused edit is answered | 727 | /// Applies an edit, keeping it to be written with the burst. A refused edit is answered |
| 703 | /// here; true when it failed part way and the section must be reread. | 728 | /// here; true when it failed part way and the section must be reread. |
| 704 | fn accept(&mut self, accepted: Accepted) -> bool { | 729 | fn accept(&mut self, accepted: Accepted) -> bool { |
| 705 | if let Some((space, page)) = &accepted.unchanged | 730 | if let Some(moving) = &accepted.moving |
| 706 | && !self.section.page(*space).is_ok_and(|now| now == *page) | 731 | && !self.moving(moving.space).is_ok_and(|now| now == **moving) |
| 707 | { | 732 | { |
| 708 | let changed = OpError::StructureChanged( | 733 | let changed = OpError::StructureChanged("The page changed while it moved."); |
| 709 | "The page changed while it moved. Both sections now have a copy.", | ||
| 710 | ); | ||
| 711 | (accepted.reply)(Err(changed.into())); | 734 | (accepted.reply)(Err(changed.into())); |
| 712 | return false; | 735 | return false; |
| 713 | } | 736 | } |
crates/notebook/tests/support/vfs.rs+2| ... | @@ -34,6 +34,8 @@ static REAL: OnceLock<usize> = OnceLock::new(); | ... | @@ -34,6 +34,8 @@ static REAL: OnceLock<usize> = OnceLock::new(); |
| 34 | static WAL: OnceLock<(usize, usize, usize)> = OnceLock::new(); | 34 | static WAL: OnceLock<(usize, usize, usize)> = OnceLock::new(); |
| 35 | /// While set, every write to this database's WAL fails, for WALs opened while it was set. | 35 | /// While set, every write to this database's WAL fails, for WALs opened while it was set. |
| 36 | pub static FAILING: Mutex<Option<PathBuf>> = Mutex::new(None); | 36 | pub static FAILING: Mutex<Option<PathBuf>> = Mutex::new(None); |
| 37 | /// Held by a test for as long as it sets `FAILING`, which one test sets at a time. | ||
| 38 | pub static EXCLUSIVE: Mutex<()> = Mutex::new(()); | ||
| 37 | 39 | ||
| 38 | fn fire(fault: u8) -> bool { | 40 | fn fire(fault: u8) -> bool { |
| 39 | let fired = ARMED.fetch_and(!fault, Ordering::SeqCst) & fault != 0; | 41 | let fired = ARMED.fetch_and(!fault, Ordering::SeqCst) & fault != 0; |
crates/notebook/tests/sync_pages.rs+61-5| ... | @@ -745,8 +745,9 @@ fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { | ... | @@ -745,8 +745,9 @@ fn page_moves_and_a_removed_page_merge_as_onenote_merges_them() { |
| 745 | } | 745 | } |
| 746 | 746 | ||
| 747 | /// A page a move copied leaves only as it was copied: an edit made here since, published or | 747 | /// A page a move copied leaves only as it was copied: an edit made here since, published or |
| 748 | /// not, or another writer's that a rebase took, keeps it; edits to other pages, published or | 748 | /// not, another writer's that a rebase took, or a conflict page kept under it keeps it; edits |
| 749 | /// rebased, do not. Either way the queue reopens as it was left and publishes it. | 749 | /// to other pages, published or rebased, do not. Either way the queue reopens as it was left |
| 750 | /// and publishes it. | ||
| 750 | #[test] | 751 | #[test] |
| 751 | fn a_moved_page_leaves_only_as_it_was_copied() { | 752 | fn a_moved_page_leaves_only_as_it_was_copied() { |
| 752 | // The first two pages holding text. | 753 | // The first two pages holding text. |
| ... | @@ -774,7 +775,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { | ... | @@ -774,7 +775,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 774 | server.durable.clone_from(&server.visible); | 775 | server.durable.clone_from(&server.visible); |
| 775 | } | 776 | } |
| 776 | type Between = fn(&Replica, &mut Server, [(ExGuid, ExGuid); 2]); | 777 | type Between = fn(&Replica, &mut Server, [(ExGuid, ExGuid); 2]); |
| 777 | let schedules: [(&str, bool, Between); 5] = [ | 778 | let schedules: [(&str, bool, Between); 6] = [ |
| 778 | ("unrelated", true, |cache, server, [_, other]| { | 779 | ("unrelated", true, |cache, server, [_, other]| { |
| 779 | here(cache, other); | 780 | here(cache, other); |
| 780 | cache.sync_once(server).unwrap(); | 781 | cache.sync_once(server).unwrap(); |
| ... | @@ -801,13 +802,33 @@ fn a_moved_page_leaves_only_as_it_was_copied() { | ... | @@ -801,13 +802,33 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 801 | cache.sync_once(server).unwrap(); | 802 | cache.sync_once(server).unwrap(); |
| 802 | }, | 803 | }, |
| 803 | ), | 804 | ), |
| 805 | ( | ||
| 806 | "kept as a conflict page", | ||
| 807 | false, | ||
| 808 | |cache, _, [(moving, _), _]| { | ||
| 809 | let page = cache.page(moving).unwrap(); | ||
| 810 | let none = std::collections::BTreeSet::new(); | ||
| 811 | let kept = | ||
| 812 | notebook::session::conflict(moving, &page, &none, "Keeper", true).unwrap(); | ||
| 813 | let at = 133_000_000_000_000_001; | ||
| 814 | cache | ||
| 815 | .apply( | ||
| 816 | "Keeper", | ||
| 817 | onestore::op::Edit { | ||
| 818 | at, | ||
| 819 | ops: vec![kept], | ||
| 820 | }, | ||
| 821 | ) | ||
| 822 | .unwrap(); | ||
| 823 | }, | ||
| 824 | ), | ||
| 804 | ]; | 825 | ]; |
| 805 | for (schedule, leaves, between) in schedules { | 826 | for (schedule, leaves, between) in schedules { |
| 806 | let directory = tempfile::tempdir().unwrap(); | 827 | let directory = tempfile::tempdir().unwrap(); |
| 807 | let path = directory.path().join("moving.sqlite"); | 828 | let path = directory.path().join("moving.sqlite"); |
| 808 | let cache = Replica::create(&path, SOURCE).unwrap(); | 829 | let cache = Replica::create(&path, SOURCE).unwrap(); |
| 809 | let mut server = Server::new(SOURCE); | 830 | let mut server = Server::new(SOURCE); |
| 810 | let copied = cache.page(moving).unwrap(); | 831 | let copied = cache.moving(moving).unwrap(); |
| 811 | between( | 832 | between( |
| 812 | &cache, | 833 | &cache, |
| 813 | &mut server, | 834 | &mut server, |
| ... | @@ -818,7 +839,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { | ... | @@ -818,7 +839,7 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 818 | at: 133_000_000_000_000_002, | 839 | at: 133_000_000_000_000_002, |
| 819 | ops: vec![Op::Section(SectionOp::Delete(vec![moving]))], | 840 | ops: vec![Op::Section(SectionOp::Delete(vec![moving]))], |
| 820 | }; | 841 | }; |
| 821 | let retired = cache.apply_unchanged("Mover", delete, moving, copied); | 842 | let retired = cache.retire("Mover", delete, copied); |
| 822 | assert_eq!(retired.is_ok(), leaves, "{schedule}: {retired:?}"); | 843 | assert_eq!(retired.is_ok(), leaves, "{schedule}: {retired:?}"); |
| 823 | assert!( | 844 | assert!( |
| 824 | leaves || matches!(retired, Err(notebook::Error::Rejected(_))), | 845 | leaves || matches!(retired, Err(notebook::Error::Rejected(_))), |
| ... | @@ -837,3 +858,38 @@ fn a_moved_page_leaves_only_as_it_was_copied() { | ... | @@ -837,3 +858,38 @@ fn a_moved_page_leaves_only_as_it_was_copied() { |
| 837 | assert!(cache.pending().unwrap().is_empty(), "{schedule}"); | 858 | assert!(cache.pending().unwrap().is_empty(), "{schedule}"); |
| 838 | } | 859 | } |
| 839 | } | 860 | } |
| 861 | |||
| 862 | /// A version of a page deleted while the page moves keeps the page, which no copy carries | ||
| 863 | /// the versions of. | ||
| 864 | #[test] | ||
| 865 | fn a_moved_page_leaves_only_with_the_versions_it_had() { | ||
| 866 | let source = | ||
| 867 | include_bytes!("../../../corpus/page-versions/native/step-02/notebook/History.one"); | ||
| 868 | let directory = tempfile::tempdir().unwrap(); | ||
| 869 | let cache = Replica::create(directory.path().join("history.sqlite"), source).unwrap(); | ||
| 870 | let (moving, versions) = cache.versions().unwrap().remove(0); | ||
| 871 | let copied = cache.moving(moving).unwrap(); | ||
| 872 | section_op( | ||
| 873 | &cache, | ||
| 874 | SectionOp::DeleteVersions { | ||
| 875 | page: moving, | ||
| 876 | versions: vec![versions[0].context], | ||
| 877 | }, | ||
| 878 | ); | ||
| 879 | let delete = onestore::op::Edit { | ||
| 880 | at: 133_000_000_000_000_002, | ||
| 881 | ops: vec![Op::Section(SectionOp::Delete(vec![moving]))], | ||
| 882 | }; | ||
| 883 | let retired = cache.retire("Mover", delete, copied); | ||
| 884 | assert!( | ||
| 885 | matches!(retired, Err(notebook::Error::Rejected(_))), | ||
| 886 | "{retired:?}" | ||
| 887 | ); | ||
| 888 | assert!( | ||
| 889 | cache | ||
| 890 | .pages() | ||
| 891 | .unwrap() | ||
| 892 | .iter() | ||
| 893 | .any(|(space, ..)| *space == moving) | ||
| 894 | ); | ||
| 895 | } |
crates/snowbound/Cargo.toml+2| ... | @@ -126,3 +126,5 @@ x11rb = "0.13.2" | ... | @@ -126,3 +126,5 @@ x11rb = "0.13.2" |
| 126 | [dev-dependencies] | 126 | [dev-dependencies] |
| 127 | # Draws the pictures of the video recording the tests store. | 127 | # Draws the pictures of the video recording the tests store. |
| 128 | image = { version = "0.25", default-features = false } | 128 | image = { version = "0.25", default-features = false } |
| 129 | # Fails a replica's writes, as a full disk does (`notebook/tests/support/vfs.rs`). | ||
| 130 | rusqlite = { version = "=0.40.2", features = ["bundled", "backup"] } |
crates/snowbound/src/main.rs+4| ... | @@ -94,6 +94,10 @@ mod undo; | ... | @@ -94,6 +94,10 @@ mod undo; |
| 94 | mod unpack; | 94 | mod unpack; |
| 95 | mod unread; | 95 | mod unread; |
| 96 | mod update; | 96 | mod update; |
| 97 | /// Fails a replica's writes on demand, for tests of what a full disk does. | ||
| 98 | #[cfg(test)] | ||
| 99 | #[path = "../../notebook/tests/support/vfs.rs"] | ||
| 100 | mod vfs; | ||
| 97 | #[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] | 101 | #[cfg_attr(target_arch = "wasm32", path = "watch_web.rs")] |
| 98 | mod watch; | 102 | mod watch; |
| 99 | 103 |
crates/snowbound/src/undo.rs+178-73| ... | @@ -351,7 +351,8 @@ struct Site { | ... | @@ -351,7 +351,8 @@ struct Site { |
| 351 | 351 | ||
| 352 | /// A change to pages done: the change taking it back, the page to show, and pages gone. | 352 | /// A change to pages done: the change taking it back, the page to show, and pages gone. |
| 353 | struct Applied { | 353 | struct Applied { |
| 354 | undo: Change, | 354 | /// What takes it back; where a move only copied its page, why the page also stayed. |
| 355 | undo: Result<Change, String>, | ||
| 355 | show: ExGuid, | 356 | show: ExGuid, |
| 356 | gone: Vec<ExGuid>, | 357 | gone: Vec<ExGuid>, |
| 357 | } | 358 | } |
| ... | @@ -415,6 +416,19 @@ impl Site { | ... | @@ -415,6 +416,19 @@ impl Site { |
| 415 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) | 416 | entry_of(self.library.catalog()?, identity).map(|(_, path)| path) |
| 416 | } | 417 | } |
| 417 | 418 | ||
| 419 | /// Closes `section`, at catalog `path`, once its edits are written; while some wait to be, | ||
| 420 | /// keeps it open, where they are tried again until they are. | ||
| 421 | async fn release(&self, path: &str, section: notebook::session::Section) { | ||
| 422 | match section.replica().written_async().await { | ||
| 423 | Ok(()) => { | ||
| 424 | if let Err(error) = section.close_async().await { | ||
| 425 | eprintln!("Synchronization stopped: {error}"); | ||
| 426 | } | ||
| 427 | } | ||
| 428 | Err(_) => self.library.keep(path, section), | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 418 | /// Does `change` to the section's pages, `shown` the page open; `None` where what it | 432 | /// Does `change` to the section's pages, `shown` the page open; `None` where what it |
| 419 | /// changes is gone or holds what it did not make, which it leaves alone. | 433 | /// changes is gone or holds what it did not make, which it leaves alone. |
| 420 | async fn change( | 434 | async fn change( |
| ... | @@ -468,11 +482,11 @@ impl Site { | ... | @@ -468,11 +482,11 @@ impl Site { |
| 468 | } | 482 | } |
| 469 | self.apply(ops).await?; | 483 | self.apply(ops).await?; |
| 470 | Applied { | 484 | Applied { |
| 471 | undo: Change::Restore { | 485 | undo: Ok(Change::Restore { |
| 472 | pages: kept, | 486 | pages: kept, |
| 473 | added, | 487 | added, |
| 474 | created, | 488 | created, |
| 475 | }, | 489 | }), |
| 476 | show, | 490 | show, |
| 477 | gone: pages, | 491 | gone: pages, |
| 478 | } | 492 | } |
| ... | @@ -519,11 +533,11 @@ impl Site { | ... | @@ -519,11 +533,11 @@ impl Site { |
| 519 | self.library = Arc::new(self.library.with(notebook)); | 533 | self.library = Arc::new(self.library.with(notebook)); |
| 520 | } | 534 | } |
| 521 | Applied { | 535 | Applied { |
| 522 | undo: Change::Delete { | 536 | undo: Ok(Change::Delete { |
| 523 | pages: spaces.clone(), | 537 | pages: spaces.clone(), |
| 524 | show: Some(shown), | 538 | show: Some(shown), |
| 525 | created, | 539 | created, |
| 526 | }, | 540 | }), |
| 527 | show: spaces[0], | 541 | show: spaces[0], |
| 528 | gone, | 542 | gone, |
| 529 | } | 543 | } |
| ... | @@ -536,13 +550,13 @@ impl Site { | ... | @@ -536,13 +550,13 @@ impl Site { |
| 536 | self.apply(vec![Op::Section(SectionOp::Pages(edits))]) | 550 | self.apply(vec![Op::Section(SectionOp::Pages(edits))]) |
| 537 | .await?; | 551 | .await?; |
| 538 | Applied { | 552 | Applied { |
| 539 | undo: Change::Arrange { | 553 | undo: Ok(Change::Arrange { |
| 540 | order: listed | 554 | order: listed |
| 541 | .iter() | 555 | .iter() |
| 542 | .map(|(space, _, level)| (*space, *level)) | 556 | .map(|(space, _, level)| (*space, *level)) |
| 543 | .collect(), | 557 | .collect(), |
| 544 | moved, | 558 | moved, |
| 545 | }, | 559 | }), |
| 546 | show, | 560 | show, |
| 547 | gone: Vec::new(), | 561 | gone: Vec::new(), |
| 548 | } | 562 | } |
| ... | @@ -554,11 +568,11 @@ impl Site { | ... | @@ -554,11 +568,11 @@ impl Site { |
| 554 | let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?; | 568 | let op = crate::rename::retitled(&self.replica.page(page)?, page, to.clone())?; |
| 555 | self.apply(vec![op]).await?; | 569 | self.apply(vec![op]).await?; |
| 556 | Applied { | 570 | Applied { |
| 557 | undo: Change::Retitle { | 571 | undo: Ok(Change::Retitle { |
| 558 | page, | 572 | page, |
| 559 | from: to, | 573 | from: to, |
| 560 | to: from, | 574 | to: from, |
| 561 | }, | 575 | }), |
| 562 | show: page, | 576 | show: page, |
| 563 | gone: Vec::new(), | 577 | gone: Vec::new(), |
| 564 | } | 578 | } |
| ... | @@ -567,8 +581,8 @@ impl Site { | ... | @@ -567,8 +581,8 @@ impl Site { |
| 567 | let (Some(from), Some(path)) = (at(page), self.section_path(target)) else { | 581 | let (Some(from), Some(path)) = (at(page), self.section_path(target)) else { |
| 568 | return Ok(None); | 582 | return Ok(None); |
| 569 | }; | 583 | }; |
| 570 | let sent = self.replica.page(page)?; | 584 | let sent = self.replica.moving(page)?; |
| 571 | let import = notebook::session::moved(&sent, &self.author)?; | 585 | let import = notebook::session::moved(sent.page(), &self.author)?; |
| 572 | let arrived = imported(&import); | 586 | let arrived = imported(&import); |
| 573 | let before = listed[from + 1..].first().map(|(space, ..)| *space); | 587 | let before = listed[from + 1..].first().map(|(space, ..)| *space); |
| 574 | let level = listed[from].2; | 588 | let level = listed[from].2; |
| ... | @@ -585,24 +599,29 @@ impl Site { | ... | @@ -585,24 +599,29 @@ impl Site { |
| 585 | }, | 599 | }, |
| 586 | ) | 600 | ) |
| 587 | .await?; | 601 | .await?; |
| 588 | section.close_async().await?; | 602 | self.release(&path, section).await; |
| 589 | let edit = Edit { | 603 | let edit = Edit { |
| 590 | at: crate::filetime(), | 604 | at: crate::filetime(), |
| 591 | ops, | 605 | ops, |
| 592 | }; | 606 | }; |
| 593 | self.replica | 607 | let retired = self.replica.retire_async(&self.author, edit, sent).await; |
| 594 | .apply_unchanged_async(&self.author, edit, page, sent) | 608 | match retired { |
| 595 | .await?; | 609 | Ok(_) => Applied { |
| 596 | Applied { | 610 | undo: Ok(Change::Return { |
| 597 | undo: Change::Return { | 611 | page: arrived, |
| 598 | page: arrived, | 612 | target, |
| 599 | target, | 613 | before, |
| 600 | before, | 614 | level, |
| 601 | level, | 615 | added, |
| 602 | added, | 616 | }), |
| 617 | show, | ||
| 618 | gone: vec![page], | ||
| 619 | }, | ||
| 620 | Err(error) => Applied { | ||
| 621 | undo: Err(crate::plain(&error, "page")), | ||
| 622 | show: page, | ||
| 623 | gone: Vec::new(), | ||
| 603 | }, | 624 | }, |
| 604 | show, | ||
| 605 | gone: vec![page], | ||
| 606 | } | 625 | } |
| 607 | } | 626 | } |
| 608 | Change::Return { | 627 | Change::Return { |
| ... | @@ -616,15 +635,12 @@ impl Site { | ... | @@ -616,15 +635,12 @@ impl Site { |
| 616 | return Ok(None); | 635 | return Ok(None); |
| 617 | }; | 636 | }; |
| 618 | let section = self.library.open_async(&path, || {}).await?; | 637 | let section = self.library.open_async(&path, || {}).await?; |
| 619 | let sent = match section.pages()?.iter().any(|(listed, ..)| *listed == page) { | 638 | if !(section.pages()?.iter()).any(|(listed, ..)| *listed == page) { |
| 620 | true => Some(section.page(page)?), | 639 | self.release(&path, section).await; |
| 621 | false => None, | ||
| 622 | }; | ||
| 623 | let Some(sent) = sent else { | ||
| 624 | section.close_async().await?; | ||
| 625 | return Ok(None); | 640 | return Ok(None); |
| 626 | }; | 641 | } |
| 627 | let import = notebook::session::moved(&sent, &self.author)?; | 642 | let sent = section.replica().moving(page)?; |
| 643 | let import = notebook::session::moved(sent.page(), &self.author)?; | ||
| 628 | let back = imported(&import); | 644 | let back = imported(&import); |
| 629 | let mut ops = vec![ | 645 | let mut ops = vec![ |
| 630 | import, | 646 | import, |
| ... | @@ -640,13 +656,14 @@ impl Site { | ... | @@ -640,13 +656,14 @@ impl Site { |
| 640 | at: crate::filetime(), | 656 | at: crate::filetime(), |
| 641 | ops: vec![Op::Section(SectionOp::Delete(vec![page]))], | 657 | ops: vec![Op::Section(SectionOp::Delete(vec![page]))], |
| 642 | }; | 658 | }; |
| 643 | section | 659 | let retired = section |
| 644 | .replica() | 660 | .replica() |
| 645 | .apply_unchanged_async(&self.author, delete, page, sent) | 661 | .retire_async(&self.author, delete, sent) |
| 646 | .await?; | 662 | .await; |
| 647 | section.close_async().await?; | 663 | self.release(&path, section).await; |
| 648 | Applied { | 664 | Applied { |
| 649 | undo: Change::Send { page: back, target }, | 665 | undo: (retired.map(|_| Change::Send { page: back, target })) |
| 666 | .map_err(|error| crate::plain(&error, "page")), | ||
| 650 | show: back, | 667 | show: back, |
| 651 | gone, | 668 | gone, |
| 652 | } | 669 | } |
| ... | @@ -1152,12 +1169,17 @@ impl State { | ... | @@ -1152,12 +1169,17 @@ impl State { |
| 1152 | }; | 1169 | }; |
| 1153 | }; | 1170 | }; |
| 1154 | state.undo.forget(&applied.gone); | 1171 | state.undo.forget(&applied.gone); |
| 1155 | let undo = Action { | 1172 | match applied.undo { |
| 1156 | notebook, | 1173 | Ok(change) => { |
| 1157 | section: Some(section), | 1174 | let undo = Action { |
| 1158 | change: applied.undo, | 1175 | notebook, |
| 1159 | }; | 1176 | section: Some(section), |
| 1160 | state.undo.note(taken.map(|(redo, _)| redo), undo); | 1177 | change, |
| 1178 | }; | ||
| 1179 | state.undo.note(taken.map(|(redo, _)| redo), undo); | ||
| 1180 | } | ||
| 1181 | Err(copied) => crate::platform::alert("Page kept in both sections", &copied), | ||
| 1182 | } | ||
| 1161 | state.edited(Vec::new()); | 1183 | state.edited(Vec::new()); |
| 1162 | Ok(()) | 1184 | Ok(()) |
| 1163 | }))); | 1185 | }))); |
| ... | @@ -1503,6 +1525,24 @@ mod tests { | ... | @@ -1503,6 +1525,24 @@ mod tests { |
| 1503 | ) | 1525 | ) |
| 1504 | } | 1526 | } |
| 1505 | 1527 | ||
| 1528 | /// The titles of the section at `path`, opened again. | ||
| 1529 | fn stored(&self, path: &str) -> Vec<(String, u32)> { | ||
| 1530 | let section = self.library.open(path, || {}).unwrap(); | ||
| 1531 | let titles = (section.pages().unwrap().into_iter()) | ||
| 1532 | .map(|(_, title, level)| (title, level)) | ||
| 1533 | .collect(); | ||
| 1534 | section.close().unwrap(); | ||
| 1535 | titles | ||
| 1536 | } | ||
| 1537 | |||
| 1538 | /// The replica of the section at `path`, which `vfs::FAILING` names. | ||
| 1539 | fn replica(&self, path: &str) -> std::path::PathBuf { | ||
| 1540 | let Ok(Some(notebook)) = &self.library.notebook else { | ||
| 1541 | panic!("a notebook") | ||
| 1542 | }; | ||
| 1543 | notebook.replica_path(path).unwrap().canonicalize().unwrap() | ||
| 1544 | } | ||
| 1545 | |||
| 1506 | /// Does `change`, as the app's thread does, returning what takes it back. | 1546 | /// Does `change`, as the app's thread does, returning what takes it back. |
| 1507 | fn change(&self, change: Change) -> Option<Applied> { | 1547 | fn change(&self, change: Change) -> Option<Applied> { |
| 1508 | let shown = self.pages()[0].0; | 1548 | let shown = self.pages()[0].0; |
| ... | @@ -1554,7 +1594,7 @@ mod tests { | ... | @@ -1554,7 +1594,7 @@ mod tests { |
| 1554 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | 1594 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1555 | assert_eq!(deleted.show, fixture.space("Third")); | 1595 | assert_eq!(deleted.show, fixture.space("Third")); |
| 1556 | assert_eq!(fixture.binned(), ["Second"]); | 1596 | assert_eq!(fixture.binned(), ["Second"]); |
| 1557 | let restored = pollster::block_on(site.change(deleted.undo, deleted.show)) | 1597 | let restored = pollster::block_on(site.change(deleted.undo.unwrap(), deleted.show)) |
| 1558 | .unwrap() | 1598 | .unwrap() |
| 1559 | .unwrap(); | 1599 | .unwrap(); |
| 1560 | assert!(!Arc::ptr_eq(&site.library, &deleted_library)); | 1600 | assert!(!Arc::ptr_eq(&site.library, &deleted_library)); |
| ... | @@ -1562,19 +1602,19 @@ mod tests { | ... | @@ -1562,19 +1602,19 @@ mod tests { |
| 1562 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | 1602 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); |
| 1563 | assert_eq!(restored.show, fixture.space("Second")); | 1603 | assert_eq!(restored.show, fixture.space("Second")); |
| 1564 | assert!(fixture.binned().is_empty()); | 1604 | assert!(fixture.binned().is_empty()); |
| 1565 | let again = pollster::block_on(site.change(restored.undo, restored.show)) | 1605 | let again = pollster::block_on(site.change(restored.undo.unwrap(), restored.show)) |
| 1566 | .unwrap() | 1606 | .unwrap() |
| 1567 | .unwrap(); | 1607 | .unwrap(); |
| 1568 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | 1608 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1569 | assert_eq!(fixture.binned(), ["Second"]); | 1609 | assert_eq!(fixture.binned(), ["Second"]); |
| 1570 | 1610 | ||
| 1571 | // Put back elsewhere first, the page stays as that device left it. | 1611 | // Put back elsewhere first, the page stays as that device left it. |
| 1572 | let Change::Restore { pages, .. } = &again.undo else { | 1612 | let Ok(Change::Restore { pages, .. }) = &again.undo else { |
| 1573 | panic!() | 1613 | panic!() |
| 1574 | }; | 1614 | }; |
| 1575 | let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap(); | 1615 | let copy = notebook::session::moved(&pages[0].page, AUTHOR).unwrap(); |
| 1576 | fixture.edit(vec![copy]); | 1616 | fixture.edit(vec![copy]); |
| 1577 | assert!(fixture.change(again.undo).is_none()); | 1617 | assert!(fixture.change(again.undo.unwrap()).is_none()); |
| 1578 | assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"])); | 1618 | assert_eq!(fixture.titles(), titled(&["First", "Third", "Second"])); |
| 1579 | } | 1619 | } |
| 1580 | 1620 | ||
| ... | @@ -1598,13 +1638,13 @@ mod tests { | ... | @@ -1598,13 +1638,13 @@ mod tests { |
| 1598 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); | 1638 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); |
| 1599 | assert_eq!(removed.show, first); | 1639 | assert_eq!(removed.show, first); |
| 1600 | assert!(fixture.binned().is_empty()); | 1640 | assert!(fixture.binned().is_empty()); |
| 1601 | let back = fixture.change(removed.undo).unwrap(); | 1641 | let back = fixture.change(removed.undo.unwrap()).unwrap(); |
| 1602 | assert_eq!(fixture.titles(), titled(&["First", "Second", ""])); | 1642 | assert_eq!(fixture.titles(), titled(&["First", "Second", ""])); |
| 1603 | let Change::Delete { pages, .. } = &back.undo else { | 1643 | let Ok(Change::Delete { pages, .. }) = &back.undo else { |
| 1604 | panic!() | 1644 | panic!() |
| 1605 | }; | 1645 | }; |
| 1606 | fixture.retitle(pages[0], "Written elsewhere"); | 1646 | fixture.retitle(pages[0], "Written elsewhere"); |
| 1607 | assert!(fixture.change(back.undo).is_none()); | 1647 | assert!(fixture.change(back.undo.unwrap()).is_none()); |
| 1608 | assert_eq!( | 1648 | assert_eq!( |
| 1609 | fixture.titles(), | 1649 | fixture.titles(), |
| 1610 | titled(&["First", "Second", "Written elsewhere"]) | 1650 | titled(&["First", "Second", "Written elsewhere"]) |
| ... | @@ -1637,7 +1677,7 @@ mod tests { | ... | @@ -1637,7 +1677,7 @@ mod tests { |
| 1637 | let arranged = fixture.change(arrange).unwrap(); | 1677 | let arranged = fixture.change(arrange).unwrap(); |
| 1638 | assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"])); | 1678 | assert_eq!(fixture.titles(), titled(&["A", "B", "C", "D"])); |
| 1639 | assert_eq!(arranged.show, a); | 1679 | assert_eq!(arranged.show, a); |
| 1640 | fixture.change(arranged.undo).unwrap(); | 1680 | fixture.change(arranged.undo.unwrap()).unwrap(); |
| 1641 | fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]); | 1681 | fixture.edit(vec![Op::Section(SectionOp::Delete(vec![b]))]); |
| 1642 | fixture | 1682 | fixture |
| 1643 | .change(Change::Arrange { | 1683 | .change(Change::Arrange { |
| ... | @@ -1673,23 +1713,23 @@ mod tests { | ... | @@ -1673,23 +1713,23 @@ mod tests { |
| 1673 | .unwrap(); | 1713 | .unwrap(); |
| 1674 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | 1714 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1675 | assert_eq!(in_b(&fixture), ["", "Second"]); | 1715 | assert_eq!(in_b(&fixture), ["", "Second"]); |
| 1676 | let returned = fixture.change(sent.undo).unwrap(); | 1716 | let returned = fixture.change(sent.undo.unwrap()).unwrap(); |
| 1677 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | 1717 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); |
| 1678 | assert_eq!(in_b(&fixture), [""]); | 1718 | assert_eq!(in_b(&fixture), [""]); |
| 1679 | let sent = fixture.change(returned.undo).unwrap(); | 1719 | let sent = fixture.change(returned.undo.unwrap()).unwrap(); |
| 1680 | let Change::Return { page, .. } = &sent.undo else { | 1720 | let Ok(Change::Return { page, .. }) = &sent.undo else { |
| 1681 | panic!() | 1721 | panic!() |
| 1682 | }; | 1722 | }; |
| 1683 | let section = fixture.library.open("B.one", || {}).unwrap(); | 1723 | let section = fixture.library.open("B.one", || {}).unwrap(); |
| 1684 | section.delete_pages(&[*page]).unwrap(); | 1724 | section.delete_pages(&[*page]).unwrap(); |
| 1685 | section.close().unwrap(); | 1725 | section.close().unwrap(); |
| 1686 | assert!(fixture.change(sent.undo).is_none()); | 1726 | assert!(fixture.change(sent.undo.unwrap()).is_none()); |
| 1687 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); | 1727 | assert_eq!(fixture.titles(), titled(&["First", "Third"])); |
| 1688 | } | 1728 | } |
| 1689 | 1729 | ||
| 1690 | /// A page edited while it is on its way to another section stays, with the edit, beside | 1730 | /// A page edited while it is on its way to another section stays, with the edit, beside |
| 1691 | /// the copy that arrived there; an edit to another page meanwhile lets it go. Both hold | 1731 | /// the copy that arrived there, with nothing for Undo to take back; an edit to another page |
| 1692 | /// once each section is opened again. | 1732 | /// meanwhile lets it go. Both hold once each section is opened again. |
| 1693 | #[test] | 1733 | #[test] |
| 1694 | fn a_page_edited_while_it_is_sent_stays_with_its_edit() { | 1734 | fn a_page_edited_while_it_is_sent_stays_with_its_edit() { |
| 1695 | struct Unreached; | 1735 | struct Unreached; |
| ... | @@ -1731,39 +1771,104 @@ mod tests { | ... | @@ -1731,39 +1771,104 @@ mod tests { |
| 1731 | let shown = fixture.pages()[0].0; | 1771 | let shown = fixture.pages()[0].0; |
| 1732 | let sending = std::thread::spawn(move || { | 1772 | let sending = std::thread::spawn(move || { |
| 1733 | pollster::block_on(site.change(Change::Send { page, target }, shown)) | 1773 | pollster::block_on(site.change(Change::Send { page, target }, shown)) |
| 1734 | .map(|applied| applied.is_some()) | ||
| 1735 | .map_err(|error| error.to_string()) | 1774 | .map_err(|error| error.to_string()) |
| 1736 | }); | 1775 | }); |
| 1776 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); | ||
| 1737 | while !(arrived.pages().unwrap().iter()).any(|(_, title, _)| title == "Second") { | 1777 | while !(arrived.pages().unwrap().iter()).any(|(_, title, _)| title == "Second") { |
| 1778 | assert!( | ||
| 1779 | std::time::Instant::now() < deadline, | ||
| 1780 | "the copy never arrived" | ||
| 1781 | ); | ||
| 1738 | std::thread::sleep(std::time::Duration::from_millis(5)); | 1782 | std::thread::sleep(std::time::Duration::from_millis(5)); |
| 1739 | } | 1783 | } |
| 1740 | fixture.retitle(fixture.space(edited), &format!("{edited} edited")); | 1784 | fixture.retitle(fixture.space(edited), &format!("{edited} edited")); |
| 1741 | drop(release); | 1785 | drop(release); |
| 1742 | drop(arrived); | 1786 | drop(arrived); |
| 1743 | let sent = sending.join().unwrap(); | 1787 | let sent = sending.join().unwrap().unwrap().unwrap(); |
| 1744 | 1788 | ||
| 1745 | let stayed = match edited { | 1789 | let stayed = match edited { |
| 1746 | "Second" => { | 1790 | "Second" => { |
| 1747 | assert!(sent.is_err(), "{sent:?}"); | 1791 | assert!(sent.undo.is_err()); |
| 1748 | titled(&["First", "Second edited", "Third"]) | 1792 | titled(&["First", "Second edited", "Third"]) |
| 1749 | } | 1793 | } |
| 1750 | _ => { | 1794 | _ => { |
| 1751 | assert_eq!(sent, Ok(true)); | 1795 | assert!(sent.undo.is_ok()); |
| 1752 | titled(&["First", "Third edited"]) | 1796 | titled(&["First", "Third edited"]) |
| 1753 | } | 1797 | } |
| 1754 | }; | 1798 | }; |
| 1755 | fixture.section.take().unwrap().close().unwrap(); | 1799 | fixture.section.take().unwrap().close().unwrap(); |
| 1756 | let titles = |path: &str| { | 1800 | assert_eq!(fixture.stored("New Section 1.one"), stayed); |
| 1757 | let section = fixture.library.open(path, || {}).unwrap(); | 1801 | assert_eq!(fixture.stored("B.one"), titled(&["", "Second"])); |
| 1758 | let titles: Vec<(String, u32)> = (section.pages().unwrap().into_iter()) | 1802 | } |
| 1759 | .map(|(_, title, level)| (title, level)) | 1803 | } |
| 1760 | .collect(); | 1804 | |
| 1761 | section.close().unwrap(); | 1805 | use crate::vfs; |
| 1762 | titles | 1806 | |
| 1763 | }; | 1807 | /// A page Undo brings back stays in the section it was sent to as well where that one |
| 1764 | assert_eq!(titles("New Section 1.one"), stayed); | 1808 | /// cannot write, with nothing left for Redo; both hold once each is opened again. |
| 1765 | assert_eq!(titles("B.one"), titled(&["", "Second"])); | 1809 | #[test] |
| 1810 | fn a_page_brought_back_where_the_cache_fails_stays_in_both_sections() { | ||
| 1811 | vfs::install(); | ||
| 1812 | let _exclusive = vfs::EXCLUSIVE | ||
| 1813 | .lock() | ||
| 1814 | .unwrap_or_else(std::sync::PoisonError::into_inner); | ||
| 1815 | let mut fixture = Fixture::new("return-fault", &["First", "Second", "Third"]); | ||
| 1816 | let target = fixture.library.section_identity("B.one").unwrap(); | ||
| 1817 | let sent = (fixture.change(Change::Send { | ||
| 1818 | page: fixture.space("Second"), | ||
| 1819 | target, | ||
| 1820 | })) | ||
| 1821 | .unwrap(); | ||
| 1822 | *vfs::FAILING.lock().unwrap() = Some(fixture.replica("B.one")); | ||
| 1823 | let back = fixture.change(sent.undo.unwrap()); | ||
| 1824 | *vfs::FAILING.lock().unwrap() = None; | ||
| 1825 | assert!(back.unwrap().undo.is_err()); | ||
| 1826 | assert_eq!(fixture.titles(), titled(&["First", "Second", "Third"])); | ||
| 1827 | fixture.section.take().unwrap().close().unwrap(); | ||
| 1828 | let stayed = titled(&["First", "Second", "Third"]); | ||
| 1829 | assert_eq!(fixture.stored("New Section 1.one"), stayed); | ||
| 1830 | assert_eq!(fixture.stored("B.one"), titled(&["", "Second"])); | ||
| 1831 | } | ||
| 1832 | |||
| 1833 | /// A section a move leaves with edits waiting for the cache stays open, writing them once | ||
| 1834 | /// it can, rather than closing without them. | ||
| 1835 | #[test] | ||
| 1836 | fn a_section_left_with_edits_waiting_keeps_them() { | ||
| 1837 | vfs::install(); | ||
| 1838 | let _exclusive = vfs::EXCLUSIVE | ||
| 1839 | .lock() | ||
| 1840 | .unwrap_or_else(std::sync::PoisonError::into_inner); | ||
| 1841 | let fixture = Fixture::new("release", &["First"]); | ||
| 1842 | fixture | ||
| 1843 | .library | ||
| 1844 | .open("B.one", || {}) | ||
| 1845 | .unwrap() | ||
| 1846 | .close() | ||
| 1847 | .unwrap(); | ||
| 1848 | *vfs::FAILING.lock().unwrap() = Some(fixture.replica("B.one")); | ||
| 1849 | let b = fixture.library.open("B.one", || {}).unwrap(); | ||
| 1850 | let space = b.pages().unwrap()[0].0; | ||
| 1851 | let op = crate::rename::retitled(&b.page(space).unwrap(), space, "Waiting".into()); | ||
| 1852 | let edit = Edit { | ||
| 1853 | at: crate::filetime(), | ||
| 1854 | ops: vec![op.unwrap()], | ||
| 1855 | }; | ||
| 1856 | b.apply(AUTHOR, edit).unwrap(); | ||
| 1857 | let replica = Arc::clone(b.replica()); | ||
| 1858 | assert!(replica.written().is_err()); | ||
| 1859 | pollster::block_on(fixture.site().release("B.one", b)); | ||
| 1860 | *vfs::FAILING.lock().unwrap() = None; | ||
| 1861 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); | ||
| 1862 | while replica.written().is_err() { | ||
| 1863 | assert!( | ||
| 1864 | std::time::Instant::now() < deadline, | ||
| 1865 | "the edit was never written" | ||
| 1866 | ); | ||
| 1867 | std::thread::sleep(std::time::Duration::from_millis(50)); | ||
| 1766 | } | 1868 | } |
| 1869 | drop(replica); | ||
| 1870 | fixture.library.close_kept(); | ||
| 1871 | assert_eq!(fixture.stored("B.one"), titled(&["Waiting"])); | ||
| 1767 | } | 1872 | } |
| 1768 | 1873 | ||
| 1769 | /// A title given in the page list goes back while the page still has it. | 1874 | /// A title given in the page list goes back while the page still has it. |
| ... | @@ -1780,7 +1885,7 @@ mod tests { | ... | @@ -1780,7 +1885,7 @@ mod tests { |
| 1780 | .unwrap(); | 1885 | .unwrap(); |
| 1781 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); | 1886 | assert_eq!(fixture.titles(), titled(&["First", "Second"])); |
| 1782 | fixture.retitle(page, "Changed elsewhere"); | 1887 | fixture.retitle(page, "Changed elsewhere"); |
| 1783 | assert!(fixture.change(back.undo).is_none()); | 1888 | assert!(fixture.change(back.undo.unwrap()).is_none()); |
| 1784 | } | 1889 | } |
| 1785 | 1890 | ||
| 1786 | /// Carries out `change` as `manage::State::restructure` does. | 1891 | /// Carries out `change` as `manage::State::restructure` does. |
| ... | @@ -1886,7 +1991,7 @@ mod tests { | ... | @@ -1886,7 +1991,7 @@ mod tests { |
| 1886 | let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]); | 1991 | let mut fixture = Fixture::new("export", &["First", "Second", "Third", "Fourth"]); |
| 1887 | let back = |fixture: &Fixture, change| { | 1992 | let back = |fixture: &Fixture, change| { |
| 1888 | let done = fixture.change(change).unwrap(); | 1993 | let done = fixture.change(change).unwrap(); |
| 1889 | fixture.change(done.undo).unwrap() | 1994 | fixture.change(done.undo.unwrap()).unwrap() |
| 1890 | }; | 1995 | }; |
| 1891 | back( | 1996 | back( |
| 1892 | &fixture, | 1997 | &fixture, |