| author | |
| committer | |
| log | 4ea1421ffb1f6c887df24f9c459c933ddcc4df68 |
| tree | 656223862ebf064500faf08e35b0cbcc821d0e71 |
| parent | 7baef319fd7df019dca65653490704ee440341cd |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Publish recycle-bin and TOC append transactions through their durable host receipts, then confirm the exact result stamp before source retirement. Keep native synchronous entry points and share existing TOC/bin construction.
Fixes #119
Assisted-by: gpt-6.1-sol4 files changed, 328 insertions(+), 46 deletions(-)
crates/notebook/src/session.rs+129-38| ... | @@ -114,6 +114,31 @@ pub trait Storage: Send + Sync { | ... | @@ -114,6 +114,31 @@ pub trait Storage: Send + Sync { |
| 114 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>; | 114 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>; |
| 115 | } | 115 | } |
| 116 | 116 | ||
| 117 | struct Publication { | ||
| 118 | path: String, | ||
| 119 | transaction: Transaction, | ||
| 120 | } | ||
| 121 | |||
| 122 | impl Publication { | ||
| 123 | async fn commit(self, storage: &dyn Storage) -> Result<()> { | ||
| 124 | match storage.commit(&self.path, &self.transaction) { | ||
| 125 | Err(Error::Remote(error)) | ||
| 126 | if error.state == onestore::CommitState::Unknown | ||
| 127 | && error.error.kind() == io::ErrorKind::WouldBlock => | ||
| 128 | { | ||
| 129 | let expected = self.transaction.stamp(); | ||
| 130 | fs::durable().await.map_err(|error| CommitError { | ||
| 131 | state: onestore::CommitState::Unknown, | ||
| 132 | error, | ||
| 133 | })?; | ||
| 134 | storage.confirm(&self.path, &expected)?; | ||
| 135 | Ok(()) | ||
| 136 | } | ||
| 137 | result => result, | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 117 | /// A mounted notebook directory. | 142 | /// A mounted notebook directory. |
| 118 | struct Directory(PathBuf); | 143 | struct Directory(PathBuf); |
| 119 | 144 | ||
| ... | @@ -687,6 +712,17 @@ impl Notebook { | ... | @@ -687,6 +712,17 @@ impl Notebook { |
| 687 | /// Commits `edits` to the TOC at `toc`. A name it still lists for a file gone from its | 712 | /// Commits `edits` to the TOC at `toc`. A name it still lists for a file gone from its |
| 688 | /// folder passes to the file an edit gives that name; the stale entry goes. | 713 | /// folder passes to the file an edit gives that name; the stale entry goes. |
| 689 | fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> { | 714 | fn commit_toc(&self, toc: &str, edits: &[onestore::TocEdit]) -> Result<()> { |
| 715 | match self.toc_publication(toc, edits)? { | ||
| 716 | Some(commit) => self.storage.commit(&commit.path, &commit.transaction), | ||
| 717 | None => Ok(()), | ||
| 718 | } | ||
| 719 | } | ||
| 720 | |||
| 721 | fn toc_publication( | ||
| 722 | &self, | ||
| 723 | toc: &str, | ||
| 724 | edits: &[onestore::TocEdit], | ||
| 725 | ) -> Result<Option<Publication>> { | ||
| 690 | let unresolved = self | 726 | let unresolved = self |
| 691 | .folder(split(toc).0) | 727 | .folder(split(toc).0) |
| 692 | .ok() | 728 | .ok() |
| ... | @@ -714,10 +750,14 @@ impl Notebook { | ... | @@ -714,10 +750,14 @@ impl Notebook { |
| 714 | superseded.push(edit.clone()); | 750 | superseded.push(edit.clone()); |
| 715 | } | 751 | } |
| 716 | let source = self.storage.read(toc)?; | 752 | let source = self.storage.read(toc)?; |
| 717 | match onestore::edit_table_of_contents(&source, &superseded)? { | 753 | Ok( |
| 718 | Some(transaction) => self.storage.commit(toc, &transaction), | 754 | onestore::edit_table_of_contents(&source, &superseded)?.map(|transaction| { |
| 719 | None => Ok(()), | 755 | Publication { |
| 720 | } | 756 | path: toc.into(), |
| 757 | transaction, | ||
| 758 | } | ||
| 759 | }), | ||
| 760 | ) | ||
| 721 | } | 761 | } |
| 722 | 762 | ||
| 723 | /// Creates `name.one` in `folder` holding the page `page` creates, in the next of | 763 | /// Creates `name.one` in `folder` holding the page `page` creates, in the next of |
| ... | @@ -1016,6 +1056,14 @@ impl Notebook { | ... | @@ -1016,6 +1056,14 @@ impl Notebook { |
| 1016 | /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a | 1056 | /// The recycle bin's TOC path and file identity, creating the bin as OneNote does: a |
| 1017 | /// section group the root TOC lists. A bin the root TOC misses is listed again. | 1057 | /// section group the root TOC lists. A bin the root TOC misses is listed again. |
| 1018 | fn bin(&self) -> Result<(String, [u8; 16])> { | 1058 | fn bin(&self) -> Result<(String, [u8; 16])> { |
| 1059 | let (toc, identity, publication) = self.prepare_bin()?; | ||
| 1060 | if let Some(commit) = publication { | ||
| 1061 | self.storage.commit(&commit.path, &commit.transaction)?; | ||
| 1062 | } | ||
| 1063 | Ok((toc, identity)) | ||
| 1064 | } | ||
| 1065 | |||
| 1066 | fn prepare_bin(&self) -> Result<(String, [u8; 16], Option<Publication>)> { | ||
| 1019 | if let Some(entry) = self | 1067 | if let Some(entry) = self |
| 1020 | .catalog | 1068 | .catalog |
| 1021 | .unavailable | 1069 | .unavailable |
| ... | @@ -1046,18 +1094,20 @@ impl Notebook { | ... | @@ -1046,18 +1094,20 @@ impl Notebook { |
| 1046 | .file_id; | 1094 | .file_id; |
| 1047 | let (root, root_identity) = self.toc("")?; | 1095 | let (root, root_identity) = self.toc("")?; |
| 1048 | // OneNote takes a TOC placed under another parent for a new one. | 1096 | // OneNote takes a TOC placed under another parent for a new one. |
| 1049 | if !lists(&self.storage.read(&root)?, identity)? { | 1097 | let publication = if !lists(&self.storage.read(&root)?, identity)? { |
| 1050 | self.storage.place(&bin_toc, root_identity, RECYCLE_BIN)?; | 1098 | self.storage.place(&bin_toc, root_identity, RECYCLE_BIN)?; |
| 1051 | self.commit_toc( | 1099 | self.toc_publication( |
| 1052 | &root, | 1100 | &root, |
| 1053 | &[onestore::TocEdit::Add { | 1101 | &[onestore::TocEdit::Add { |
| 1054 | filename: RECYCLE_BIN.into(), | 1102 | filename: RECYCLE_BIN.into(), |
| 1055 | identity, | 1103 | identity, |
| 1056 | group: true, | 1104 | group: true, |
| 1057 | }], | 1105 | }], |
| 1058 | )?; | 1106 | )? |
| 1059 | } | 1107 | } else { |
| 1060 | Ok((bin_toc, identity)) | 1108 | None |
| 1109 | }; | ||
| 1110 | Ok((bin_toc, identity, publication)) | ||
| 1061 | } | 1111 | } |
| 1062 | 1112 | ||
| 1063 | /// Moves the section file at `path`, its folder's `entry`, into the recycle bin, under a | 1113 | /// Moves the section file at `path`, its folder's `entry`, into the recycle bin, under a |
| ... | @@ -1093,8 +1143,18 @@ impl Notebook { | ... | @@ -1093,8 +1143,18 @@ impl Notebook { |
| 1093 | /// level with its identity, title, date and creation time. The caller then deletes the | 1143 | /// level with its identity, title, date and creation time. The caller then deletes the |
| 1094 | /// pages from their section. | 1144 | /// pages from their section. |
| 1095 | pub fn recycle_pages(&mut self, pages: &[Page], author: &str) -> Result<()> { | 1145 | pub fn recycle_pages(&mut self, pages: &[Page], author: &str) -> Result<()> { |
| 1146 | crate::task::blocking(self.recycle_pages_async(pages, author))? | ||
| 1147 | } | ||
| 1148 | |||
| 1149 | /// Recycles pages after the browser host acknowledges each destination append. | ||
| 1150 | pub async fn recycle_pages_async(&mut self, pages: &[Page], author: &str) -> Result<()> { | ||
| 1096 | const DELETED: &str = "OneNote_DeletedPages.one"; | 1151 | const DELETED: &str = "OneNote_DeletedPages.one"; |
| 1097 | let (bin_toc, bin_identity) = self.bin()?; | 1152 | // A rejected host receipt leaves its append queued ahead of this retry. |
| 1153 | fs::durable().await?; | ||
| 1154 | let (bin_toc, bin_identity, publication) = self.prepare_bin()?; | ||
| 1155 | if let Some(commit) = publication { | ||
| 1156 | commit.commit(self.storage.as_ref()).await?; | ||
| 1157 | } | ||
| 1098 | let path = catalog_path(RECYCLE_BIN, DELETED); | 1158 | let path = catalog_path(RECYCLE_BIN, DELETED); |
| 1099 | if !self.storage.exists(&path) { | 1159 | if !self.storage.exists(&path) { |
| 1100 | // OneNote's "Deleted Pages" section is grey. | 1160 | // OneNote's "Deleted Pages" section is grey. |
| ... | @@ -1106,32 +1166,51 @@ impl Notebook { | ... | @@ -1106,32 +1166,51 @@ impl Notebook { |
| 1106 | let identity = onestore::Store::parse(&bytes)?.header.file_id; | 1166 | let identity = onestore::Store::parse(&bytes)?.header.file_id; |
| 1107 | if !lists(&self.storage.read(&bin_toc)?, identity)? { | 1167 | if !lists(&self.storage.read(&bin_toc)?, identity)? { |
| 1108 | self.storage.place(&path, bin_identity, DELETED)?; | 1168 | self.storage.place(&path, bin_identity, DELETED)?; |
| 1109 | self.commit_toc( | 1169 | if let Some(commit) = self.toc_publication( |
| 1110 | &bin_toc, | 1170 | &bin_toc, |
| 1111 | &[onestore::TocEdit::Add { | 1171 | &[onestore::TocEdit::Add { |
| 1112 | filename: DELETED.into(), | 1172 | filename: DELETED.into(), |
| 1113 | identity, | 1173 | identity, |
| 1114 | group: false, | 1174 | group: false, |
| 1115 | }], | 1175 | }], |
| 1116 | )?; | 1176 | )? { |
| 1177 | commit.commit(self.storage.as_ref()).await?; | ||
| 1178 | } | ||
| 1117 | bytes = self.storage.read(&path)?; | 1179 | bytes = self.storage.read(&path)?; |
| 1118 | } | 1180 | } |
| 1119 | let arena = onestore::Arena::default(); | 1181 | let transaction = { |
| 1120 | let mut section = onestore::Section::open(&arena, bytes)?; | 1182 | let arena = onestore::Arena::default(); |
| 1121 | let mut ops = Vec::new(); | 1183 | let mut section = onestore::Section::open(&arena, bytes)?; |
| 1122 | for page in pages { | 1184 | let mut replaced = Vec::new(); |
| 1123 | ops.push(moved(page, author)?); | 1185 | for (space, ..) in section.pages()? { |
| 1124 | } | 1186 | if section.page(space)?.identity.is_some_and(|identity| { |
| 1125 | section.apply( | 1187 | pages.iter().any(|page| page.identity == Some(identity)) |
| 1126 | author, | 1188 | }) { |
| 1127 | &Edit { | 1189 | replaced.push(space); |
| 1128 | at: crate::now(), | 1190 | } |
| 1129 | ops, | 1191 | } |
| 1130 | }, | 1192 | let mut ops = Vec::new(); |
| 1131 | )?; | 1193 | if !replaced.is_empty() { |
| 1132 | if let Some(transaction) = section.seal()? { | 1194 | ops.push(Op::Section(SectionOp::Delete(replaced))); |
| 1133 | self.storage.commit(&path, &transaction)?; | 1195 | } |
| 1196 | for page in pages { | ||
| 1197 | ops.push(moved(page, author)?); | ||
| 1198 | } | ||
| 1199 | section.apply( | ||
| 1200 | author, | ||
| 1201 | &Edit { | ||
| 1202 | at: crate::now(), | ||
| 1203 | ops, | ||
| 1204 | }, | ||
| 1205 | )?; | ||
| 1206 | section.seal()? | ||
| 1207 | }; | ||
| 1208 | if let Some(transaction) = transaction { | ||
| 1209 | Publication { path, transaction } | ||
| 1210 | .commit(self.storage.as_ref()) | ||
| 1211 | .await?; | ||
| 1134 | } | 1212 | } |
| 1213 | fs::durable().await?; | ||
| 1135 | self.refresh().map(drop) | 1214 | self.refresh().map(drop) |
| 1136 | } | 1215 | } |
| 1137 | 1216 | ||
| ... | @@ -1139,6 +1218,12 @@ impl Notebook { | ... | @@ -1139,6 +1218,12 @@ impl Notebook { |
| 1139 | /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there | 1218 | /// revision, as OneNote 2010 does when Undo brings deleted pages back. Pages not there |
| 1140 | /// are passed over. | 1219 | /// are passed over. |
| 1141 | pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> { | 1220 | pub fn unrecycle_pages(&mut self, identities: &[[u8; 16]]) -> Result<()> { |
| 1221 | crate::task::blocking(self.unrecycle_pages_async(identities))? | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | /// Removes recycled pages after the browser host acknowledges their deletion append. | ||
| 1225 | pub async fn unrecycle_pages_async(&mut self, identities: &[[u8; 16]]) -> Result<()> { | ||
| 1226 | fs::durable().await?; | ||
| 1142 | let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); | 1227 | let path = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); |
| 1143 | if !self.storage.exists(&path) { | 1228 | if !self.storage.exists(&path) { |
| 1144 | return Ok(()); | 1229 | return Ok(()); |
| ... | @@ -1157,18 +1242,24 @@ impl Notebook { | ... | @@ -1157,18 +1242,24 @@ impl Notebook { |
| 1157 | if binned.is_empty() { | 1242 | if binned.is_empty() { |
| 1158 | return Ok(()); | 1243 | return Ok(()); |
| 1159 | } | 1244 | } |
| 1160 | let arena = onestore::Arena::default(); | 1245 | let transaction = { |
| 1161 | let mut section = onestore::Section::open(&arena, bytes)?; | 1246 | let arena = onestore::Arena::default(); |
| 1162 | section.apply( | 1247 | let mut section = onestore::Section::open(&arena, bytes)?; |
| 1163 | "", | 1248 | section.apply( |
| 1164 | &Edit { | 1249 | "", |
| 1165 | at: crate::now(), | 1250 | &Edit { |
| 1166 | ops: vec![Op::Section(SectionOp::Delete(binned))], | 1251 | at: crate::now(), |
| 1167 | }, | 1252 | ops: vec![Op::Section(SectionOp::Delete(binned))], |
| 1168 | )?; | 1253 | }, |
| 1169 | if let Some(transaction) = section.seal()? { | 1254 | )?; |
| 1170 | self.storage.commit(&path, &transaction)?; | 1255 | section.seal()? |
| 1256 | }; | ||
| 1257 | if let Some(transaction) = transaction { | ||
| 1258 | Publication { path, transaction } | ||
| 1259 | .commit(self.storage.as_ref()) | ||
| 1260 | .await?; | ||
| 1171 | } | 1261 | } |
| 1262 | fs::durable().await?; | ||
| 1172 | self.refresh().map(drop) | 1263 | self.refresh().map(drop) |
| 1173 | } | 1264 | } |
| 1174 | 1265 |
crates/notebook/src/session/tests.rs+182-6| ... | @@ -73,14 +73,20 @@ fn renaming_an_unlisted_section_creates_its_toc_entry_and_preserves_its_neighbor | ... | @@ -73,14 +73,20 @@ fn renaming_an_unlisted_section_creates_its_toc_entry_and_preserves_its_neighbor |
| 73 | } | 73 | } |
| 74 | } | 74 | } |
| 75 | 75 | ||
| 76 | /// A mounted notebook where another writer commits `revision` to the section file just | 76 | struct FaultStorage { |
| 77 | /// before Snowbound's rewritten file takes its place. | ||
| 78 | struct Racing { | ||
| 79 | inner: Directory, | 77 | inner: Directory, |
| 80 | revision: Mutex<Option<(String, Transaction)>>, | 78 | revision: Mutex<Option<(String, Transaction)>>, |
| 79 | receipt: Option<Arc<Mutex<Receipt>>>, | ||
| 81 | } | 80 | } |
| 82 | 81 | ||
| 83 | impl Racing { | 82 | #[derive(Default)] |
| 83 | struct Receipt { | ||
| 84 | reject: Option<String>, | ||
| 85 | reject_confirmation: Option<String>, | ||
| 86 | confirmed: Vec<String>, | ||
| 87 | } | ||
| 88 | |||
| 89 | impl FaultStorage { | ||
| 84 | fn race(&self, path: &str) { | 90 | fn race(&self, path: &str) { |
| 85 | let revision = self.revision.lock().unwrap().take(); | 91 | let revision = self.revision.lock().unwrap().take(); |
| 86 | if let Some((section, transaction)) = revision { | 92 | if let Some((section, transaction)) = revision { |
| ... | @@ -90,7 +96,7 @@ impl Racing { | ... | @@ -90,7 +96,7 @@ impl Racing { |
| 90 | } | 96 | } |
| 91 | } | 97 | } |
| 92 | 98 | ||
| 93 | impl Storage for Racing { | 99 | impl Storage for FaultStorage { |
| 94 | fn discover( | 100 | fn discover( |
| 95 | &self, | 101 | &self, |
| 96 | cache: &mut discover::Cache, | 102 | cache: &mut discover::Cache, |
| ... | @@ -141,9 +147,29 @@ impl Storage for Racing { | ... | @@ -141,9 +147,29 @@ impl Storage for Racing { |
| 141 | self.inner.place(path, ancestor, name) | 147 | self.inner.place(path, ancestor, name) |
| 142 | } | 148 | } |
| 143 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { | 149 | fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { |
| 150 | if let Some(receipt) = &self.receipt { | ||
| 151 | if receipt.lock().unwrap().reject.as_deref() != Some(path) { | ||
| 152 | self.inner.commit(path, transaction)?; | ||
| 153 | } | ||
| 154 | return Err(CommitError { | ||
| 155 | state: onestore::CommitState::Unknown, | ||
| 156 | error: io::ErrorKind::WouldBlock.into(), | ||
| 157 | } | ||
| 158 | .into()); | ||
| 159 | } | ||
| 144 | self.inner.commit(path, transaction) | 160 | self.inner.commit(path, transaction) |
| 145 | } | 161 | } |
| 146 | fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> { | 162 | fn confirm(&self, path: &str, base: &Stamp) -> std::result::Result<(), CommitError> { |
| 163 | if let Some(receipt) = &self.receipt { | ||
| 164 | let mut receipt = receipt.lock().unwrap(); | ||
| 165 | receipt.confirmed.push(path.into()); | ||
| 166 | if receipt.reject_confirmation.as_deref() == Some(path) { | ||
| 167 | return Err(CommitError { | ||
| 168 | state: onestore::CommitState::Unknown, | ||
| 169 | error: io::ErrorKind::PermissionDenied.into(), | ||
| 170 | }); | ||
| 171 | } | ||
| 172 | } | ||
| 147 | self.inner.confirm(path, base) | 173 | self.inner.confirm(path, base) |
| 148 | } | 174 | } |
| 149 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { | 175 | fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { |
| ... | @@ -183,9 +209,10 @@ fn a_password_never_overwrites_a_concurrent_revision() { | ... | @@ -183,9 +209,10 @@ fn a_password_never_overwrites_a_concurrent_revision() { |
| 183 | let revision = section.seal().unwrap().unwrap(); | 209 | let revision = section.seal().unwrap().unwrap(); |
| 184 | 210 | ||
| 185 | let root = folder.canonicalize().unwrap(); | 211 | let root = folder.canonicalize().unwrap(); |
| 186 | let storage = Racing { | 212 | let storage = FaultStorage { |
| 187 | inner: Directory(root.clone()), | 213 | inner: Directory(root.clone()), |
| 188 | revision: Mutex::new(Some((path.clone(), revision))), | 214 | revision: Mutex::new(Some((path.clone(), revision))), |
| 215 | receipt: None, | ||
| 189 | }; | 216 | }; |
| 190 | let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); | 217 | let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); |
| 191 | let set = notebook.set_password(&path, None, Some("secret")); | 218 | let set = notebook.set_password(&path, None, Some("secret")); |
| ... | @@ -206,3 +233,152 @@ fn a_password_never_overwrites_a_concurrent_revision() { | ... | @@ -206,3 +233,152 @@ fn a_password_never_overwrites_a_concurrent_revision() { |
| 206 | Err(_) => assert_eq!(titles(&stored), ["Mine", "Theirs"]), | 233 | Err(_) => assert_eq!(titles(&stored), ["Mine", "Theirs"]), |
| 207 | } | 234 | } |
| 208 | } | 235 | } |
| 236 | |||
| 237 | #[test] | ||
| 238 | fn recycling_confirms_uncertain_toc_and_page_appends_and_undo() { | ||
| 239 | let temporary = tempfile::tempdir().unwrap(); | ||
| 240 | let folder = temporary.path().join("notebook"); | ||
| 241 | let cache = temporary.path().join("cache"); | ||
| 242 | let creation = PageCreation::new(None, Some("Keep this page"), "Fixture").unwrap(); | ||
| 243 | let notebook = Notebook::create(&folder, &cache, Notebook::NEW_COLOR, &creation).unwrap(); | ||
| 244 | let path = notebook.catalog().sections[0].path.clone(); | ||
| 245 | let source = notebook.read_section(&path).unwrap(); | ||
| 246 | let page = stored_pages(&source).unwrap().remove(0).page; | ||
| 247 | drop(notebook); | ||
| 248 | let root = folder.canonicalize().unwrap(); | ||
| 249 | let receipt = Arc::new(Mutex::new(Receipt::default())); | ||
| 250 | let storage = FaultStorage { | ||
| 251 | inner: Directory(root.clone()), | ||
| 252 | revision: Mutex::new(None), | ||
| 253 | receipt: Some(receipt.clone()), | ||
| 254 | }; | ||
| 255 | let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); | ||
| 256 | notebook | ||
| 257 | .recycle_pages(std::slice::from_ref(&page), "Fixture") | ||
| 258 | .unwrap(); | ||
| 259 | let deleted = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); | ||
| 260 | assert_eq!( | ||
| 261 | receipt.lock().unwrap().confirmed, | ||
| 262 | [ | ||
| 263 | TOC.to_string(), | ||
| 264 | catalog_path(RECYCLE_BIN, TOC), | ||
| 265 | deleted.clone(), | ||
| 266 | ] | ||
| 267 | ); | ||
| 268 | let binned = fs::read(folder.join(&deleted)).unwrap(); | ||
| 269 | let pages = stored_pages(&binned).unwrap(); | ||
| 270 | assert_eq!(pages.len(), 1); | ||
| 271 | assert_eq!(pages[0].page.title, page.title); | ||
| 272 | assert_eq!(pages[0].page.identity, page.identity); | ||
| 273 | assert_eq!(pages[0].page.created, page.created); | ||
| 274 | assert_eq!(pages[0].page.date_text(), page.date_text()); | ||
| 275 | let cold = Notebook::open(&folder, temporary.path().join("cold-cache")).unwrap(); | ||
| 276 | assert_eq!(cold.catalog().groups.len(), 1); | ||
| 277 | assert_eq!(cold.folder(RECYCLE_BIN).unwrap().sections.len(), 1); | ||
| 278 | assert_eq!( | ||
| 279 | stored_pages(&cold.read_section(&deleted).unwrap()) | ||
| 280 | .unwrap() | ||
| 281 | .len(), | ||
| 282 | 1 | ||
| 283 | ); | ||
| 284 | drop(cold); | ||
| 285 | notebook.unrecycle_pages(&[page.identity.unwrap()]).unwrap(); | ||
| 286 | let restored = fs::read(folder.join(&deleted)).unwrap(); | ||
| 287 | assert!( | ||
| 288 | restored.len() > binned.len(), | ||
| 289 | "Undo must append its deletion revision" | ||
| 290 | ); | ||
| 291 | assert!(stored_pages(&restored).unwrap().is_empty()); | ||
| 292 | assert_eq!(receipt.lock().unwrap().confirmed.last(), Some(&deleted)); | ||
| 293 | assert_eq!(receipt.lock().unwrap().confirmed.len(), 4); | ||
| 294 | assert_eq!(fs::read(folder.join(&path)).unwrap(), source); | ||
| 295 | } | ||
| 296 | |||
| 297 | #[test] | ||
| 298 | fn recycling_rejects_unconfirmed_appends_and_can_retry_each_stage() { | ||
| 299 | let deleted = catalog_path(RECYCLE_BIN, "OneNote_DeletedPages.one"); | ||
| 300 | for (rejected, retained) in [ | ||
| 301 | (TOC.to_string(), false), | ||
| 302 | (catalog_path(RECYCLE_BIN, TOC), false), | ||
| 303 | (deleted.clone(), false), | ||
| 304 | (deleted.clone(), true), | ||
| 305 | ] { | ||
| 306 | let temporary = tempfile::tempdir().unwrap(); | ||
| 307 | let folder = temporary.path().join("notebook"); | ||
| 308 | let cache = temporary.path().join("cache"); | ||
| 309 | let creation = PageCreation::new(None, Some("Keep this page"), "Fixture").unwrap(); | ||
| 310 | let notebook = Notebook::create(&folder, &cache, Notebook::NEW_COLOR, &creation).unwrap(); | ||
| 311 | let path = notebook.catalog().sections[0].path.clone(); | ||
| 312 | let mut source = notebook.read_section(&path).unwrap(); | ||
| 313 | let mut page = stored_pages(&source).unwrap().remove(0).page; | ||
| 314 | drop(notebook); | ||
| 315 | let root = folder.canonicalize().unwrap(); | ||
| 316 | let receipt = Arc::new(Mutex::new(Receipt { | ||
| 317 | reject: (!retained).then(|| rejected.clone()), | ||
| 318 | reject_confirmation: retained.then(|| rejected.clone()), | ||
| 319 | confirmed: Vec::new(), | ||
| 320 | })); | ||
| 321 | let storage = FaultStorage { | ||
| 322 | inner: Directory(root.clone()), | ||
| 323 | revision: Mutex::new(None), | ||
| 324 | receipt: Some(receipt.clone()), | ||
| 325 | }; | ||
| 326 | let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); | ||
| 327 | assert!( | ||
| 328 | matches!( | ||
| 329 | notebook.recycle_pages(std::slice::from_ref(&page), "Fixture"), | ||
| 330 | Err(Error::Remote(_)) | ||
| 331 | ), | ||
| 332 | "{rejected}" | ||
| 333 | ); | ||
| 334 | assert_eq!(receipt.lock().unwrap().confirmed.last(), Some(&rejected)); | ||
| 335 | assert_eq!(fs::read(folder.join(&path)).unwrap(), source); | ||
| 336 | if folder.join(&deleted).exists() { | ||
| 337 | assert_eq!( | ||
| 338 | stored_pages(&fs::read(folder.join(&deleted)).unwrap()) | ||
| 339 | .unwrap() | ||
| 340 | .len(), | ||
| 341 | usize::from(retained) | ||
| 342 | ); | ||
| 343 | } | ||
| 344 | if retained { | ||
| 345 | let arena = onestore::Arena::default(); | ||
| 346 | let mut section = onestore::Section::open(&arena, source).unwrap(); | ||
| 347 | let space = section.pages().unwrap()[0].0; | ||
| 348 | section | ||
| 349 | .apply( | ||
| 350 | "Fixture", | ||
| 351 | &Edit { | ||
| 352 | at: crate::now(), | ||
| 353 | ops: vec![Op::Page { | ||
| 354 | space, | ||
| 355 | op: onestore::op::PageOp::Color(Some(0xabcdef)), | ||
| 356 | }], | ||
| 357 | }, | ||
| 358 | ) | ||
| 359 | .unwrap(); | ||
| 360 | section | ||
| 361 | .seal() | ||
| 362 | .unwrap() | ||
| 363 | .unwrap() | ||
| 364 | .commit_file(folder.join(&path)) | ||
| 365 | .unwrap(); | ||
| 366 | source = fs::read(folder.join(&path)).unwrap(); | ||
| 367 | page = stored_pages(&source).unwrap().remove(0).page; | ||
| 368 | } | ||
| 369 | { | ||
| 370 | let mut receipt = receipt.lock().unwrap(); | ||
| 371 | receipt.reject = None; | ||
| 372 | receipt.reject_confirmation = None; | ||
| 373 | } | ||
| 374 | notebook | ||
| 375 | .recycle_pages(std::slice::from_ref(&page), "Fixture") | ||
| 376 | .unwrap(); | ||
| 377 | let binned = stored_pages(&fs::read(folder.join(&deleted)).unwrap()).unwrap(); | ||
| 378 | assert_eq!(binned.len(), 1, "{rejected}"); | ||
| 379 | assert_eq!(binned[0].page.title, page.title); | ||
| 380 | assert_eq!(binned[0].page.identity, page.identity); | ||
| 381 | assert_eq!(binned[0].page.color, page.color); | ||
| 382 | assert_eq!(fs::read(folder.join(&path)).unwrap(), source); | ||
| 383 | } | ||
| 384 | } |
crates/onestore/src/commit.rs+9| ... | @@ -529,6 +529,14 @@ impl Transaction { | ... | @@ -529,6 +529,14 @@ impl Transaction { |
| 529 | &self.base | 529 | &self.base |
| 530 | } | 530 | } |
| 531 | 531 | ||
| 532 | /// The committed image's header and length, for confirmation after an uncertain answer. | ||
| 533 | pub fn stamp(&self) -> Stamp { | ||
| 534 | Stamp { | ||
| 535 | header: self.header, | ||
| 536 | length: self.base.length + self.append.len() as u64, | ||
| 537 | } | ||
| 538 | } | ||
| 539 | |||
| 532 | /// The transaction as bytes `from_bytes` reads back, to carry it to another machine to | 540 | /// The transaction as bytes `from_bytes` reads back, to carry it to another machine to |
| 533 | /// commit: the base header and length, the new header, the appended bytes' length and | 541 | /// commit: the base header and length, the new header, the appended bytes' length and |
| 534 | /// the bytes, then each patch's offset, length and bytes. Integers are little-endian. | 542 | /// the bytes, then each patch's offset, length and bytes. Integers are little-endian. |
| ... | @@ -715,6 +723,7 @@ mod tests { | ... | @@ -715,6 +723,7 @@ mod tests { |
| 715 | let mut together = original; | 723 | let mut together = original; |
| 716 | combined.apply(&mut together).unwrap(); | 724 | combined.apply(&mut together).unwrap(); |
| 717 | assert_eq!(together, separate); | 725 | assert_eq!(together, separate); |
| 726 | assert_eq!(combined.stamp(), Stamp::of(&together).unwrap()); | ||
| 718 | } | 727 | } |
| 719 | 728 | ||
| 720 | #[test] | 729 | #[test] |
crates/snowbound/src/undo.rs+8-2| ... | @@ -454,7 +454,10 @@ impl Site { | ... | @@ -454,7 +454,10 @@ impl Site { |
| 454 | let (show, added) = self.after(&listed, &pages, show, &mut ops); | 454 | let (show, added) = self.after(&listed, &pages, show, &mut ops); |
| 455 | if !created && binned { | 455 | if !created && binned { |
| 456 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); | 456 | let pages: Vec<Page> = kept.iter().map(|kept| kept.page.clone()).collect(); |
| 457 | self.library.reopen()?.recycle_pages(&pages, &self.author)?; | 457 | self.library |
| 458 | .reopen()? | ||
| 459 | .recycle_pages_async(&pages, &self.author) | ||
| 460 | .await?; | ||
| 458 | self.replica.written_async().await?; | 461 | self.replica.written_async().await?; |
| 459 | } | 462 | } |
| 460 | self.apply(ops).await?; | 463 | self.apply(ops).await?; |
| ... | @@ -505,7 +508,10 @@ impl Site { | ... | @@ -505,7 +508,10 @@ impl Site { |
| 505 | if !created && binned { | 508 | if !created && binned { |
| 506 | let identities: Vec<[u8; 16]> = | 509 | let identities: Vec<[u8; 16]> = |
| 507 | back.iter().filter_map(|kept| kept.page.identity).collect(); | 510 | back.iter().filter_map(|kept| kept.page.identity).collect(); |
| 508 | self.library.reopen()?.unrecycle_pages(&identities)?; | 511 | self.library |
| 512 | .reopen()? | ||
| 513 | .unrecycle_pages_async(&identities) | ||
| 514 | .await?; | ||
| 509 | } | 515 | } |
| 510 | Applied { | 516 | Applied { |
| 511 | undo: Change::Delete { | 517 | undo: Change::Delete { |