| author | |
| committer | |
| log | 59f5163e0c2a80a4addceedf0e3f4bc3b6c235c3 |
| tree | 71e646ce3ab8c16345981d6d10be7b7dbcfdc7e5 |
| parent | 09ee1d6454d9862cd2b895fea88eee8541e6caaf |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Add Operation::Page: a PageIntent records the page model the editor started
from, the model it produced and the author. Replica::save publishes the
difference locally through PreparedEdit::page and, while the newest pending
edit is an unattempted save of the same page, replaces that save instead of
queueing another publication, matching OneNote's own save interval
(evidence/m10/NATIVE-SAVE-CADENCE.md). Reconciliation compares the remote
page with the recorded model: an unchanged page publishes, a changed one is a
ContentChanged conflict that review_page resolves with a model reviewed
against the remote image. The page model and the document types it embeds
gain Clone and serde support; image payloads are identified by their id and
left out of equality and serialization.
Validation: six new sync tests (publication, coalescing, immutability of an
attempted save, conflict and review, independence between pages, reopen);
329 notebook and onestore tests, Clippy and fmt.
Assisted-by: claude-fable-5.110 files changed, 466 insertions(+), 27 deletions(-)
crates/notebook/examples/cache_probe.rs+1| ... | ... | @@ -139,6 +139,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 139 | 139 | *value as u64 - 6 |
| 140 | 140 | } |
| 141 | 141 | notebook::Operation::Split(_) |
| 142 | | notebook::Operation::Page(_) | |
| 142 | 143 | | notebook::Operation::CreatePage(_) |
| 143 | 144 | | notebook::Operation::Pages(_) |
| 144 | 145 | | notebook::Operation::Join(_) |
crates/notebook/src/lib.rs+108-1| ... | ... | @@ -8,8 +8,9 @@ pub mod smb; |
| 8 | 8 | use onestore::{ |
| 9 | 9 | ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, |
| 10 | 10 | document::{Document, Kind}, |
| 11 | page::Page, | |
| 11 | 12 | }; |
| 12 | use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; | |
| 13 | use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; | |
| 13 | 14 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; |
| 14 | 15 | |
| 15 | 16 | mod assets; |
| ... | ... | @@ -67,9 +68,20 @@ pub struct TextEdit { |
| 67 | 68 | pub replacement: String, |
| 68 | 69 | } |
| 69 | 70 | |
| 71 | /// An edited page model together with the stored model it was edited from. | |
| 72 | /// `before` is the precondition reconciliation checks against the remote page. | |
| 73 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 74 | #[serde(deny_unknown_fields)] | |
| 75 | pub struct PageIntent { | |
| 76 | pub before: Page, | |
| 77 | pub after: Page, | |
| 78 | pub author: String, | |
| 79 | } | |
| 80 | ||
| 70 | 81 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] |
| 71 | 82 | #[serde(deny_unknown_fields)] |
| 72 | 83 | pub enum Operation { |
| 84 | Page(PageIntent), | |
| 73 | 85 | CreatePage(PageCreation), |
| 74 | 86 | Pages(PageEdits), |
| 75 | 87 | Text(TextEdit), |
| ... | ... | @@ -238,6 +250,94 @@ impl Replica { |
| 238 | 250 | self.record(source, space, Operation::Insert(insertion.clone()), &edit) |
| 239 | 251 | } |
| 240 | 252 | |
| 253 | /// Durably queues an edited page model using the supplied local snapshot. | |
| 254 | /// While the newest pending edit is an unattempted save of the same page, a new save | |
| 255 | /// replaces its result instead of queueing another publication, as OneNote does | |
| 256 | /// within its own save interval. An unchanged model returns `None`. | |
| 257 | pub fn save( | |
| 258 | &self, | |
| 259 | source: &[u8], | |
| 260 | space: ExGuid, | |
| 261 | after: &Page, | |
| 262 | author: &str, | |
| 263 | ) -> Result<Option<u64>> { | |
| 264 | let prepared = PreparedEdit::page(source, space, after, author)?; | |
| 265 | if prepared.as_bytes() == source { | |
| 266 | return Ok(None); | |
| 267 | } | |
| 268 | let before = page_of(source, space)?.ok_or_else(|| { | |
| 269 | io::Error::new( | |
| 270 | io::ErrorKind::InvalidData, | |
| 271 | "The saved page is not in the local image", | |
| 272 | ) | |
| 273 | })?; | |
| 274 | { | |
| 275 | let mut connection = self | |
| 276 | .connection | |
| 277 | .lock() | |
| 278 | .map_err(|_| io::Error::other("Cache owner panicked"))?; | |
| 279 | let transaction = | |
| 280 | connection.transaction_with_behavior(TransactionBehavior::Immediate)?; | |
| 281 | let current: Vec<u8> = | |
| 282 | transaction.query_row("SELECT working FROM replica WHERE id=1", [], |row| { | |
| 283 | row.get(0) | |
| 284 | })?; | |
| 285 | if current != source { | |
| 286 | return Err(io::Error::new( | |
| 287 | io::ErrorKind::ResourceBusy, | |
| 288 | "The local snapshot changed before this edit", | |
| 289 | ) | |
| 290 | .into()); | |
| 291 | } | |
| 292 | let newest: Option<(i64, String, String)> = transaction | |
| 293 | .query_row( | |
| 294 | "SELECT id, space, operation FROM edits WHERE id=(SELECT max(id) FROM edits)", | |
| 295 | [], | |
| 296 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), | |
| 297 | ) | |
| 298 | .optional()?; | |
| 299 | if let Some((id, sid, operation)) = newest | |
| 300 | && sid == space.to_string() | |
| 301 | && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation) | |
| 302 | { | |
| 303 | let attempted: bool = transaction.query_row( | |
| 304 | "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1) OR EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1)", | |
| 305 | [id], | |
| 306 | |row| row.get(0), | |
| 307 | )?; | |
| 308 | if !attempted { | |
| 309 | head.after = after.clone(); | |
| 310 | transaction.execute( | |
| 311 | "UPDATE edits SET operation=?1 WHERE id=?2", | |
| 312 | params![ | |
| 313 | serde_json::to_string(&Operation::Page(head)) | |
| 314 | .map_err(io::Error::other)?, | |
| 315 | id | |
| 316 | ], | |
| 317 | )?; | |
| 318 | transaction.execute( | |
| 319 | "UPDATE replica SET working=?1 WHERE id=1", | |
| 320 | [prepared.as_bytes()], | |
| 321 | )?; | |
| 322 | transaction.commit()?; | |
| 323 | drop(connection); | |
| 324 | self.wake_sync(); | |
| 325 | return Ok(Some(u64::try_from(id).map_err(io::Error::other)?)); | |
| 326 | } | |
| 327 | } | |
| 328 | } | |
| 329 | self.record( | |
| 330 | source, | |
| 331 | space, | |
| 332 | Operation::Page(PageIntent { | |
| 333 | before, | |
| 334 | after: after.clone(), | |
| 335 | author: author.to_owned(), | |
| 336 | }), | |
| 337 | &prepared, | |
| 338 | ) | |
| 339 | } | |
| 340 | ||
| 241 | 341 | /// Queues a new page and its section entry with stable identities for dependent edits. |
| 242 | 342 | pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> { |
| 243 | 343 | let edit = PreparedEdit::create_page(source, page)?; |
| ... | ... | @@ -331,6 +431,13 @@ fn active_paths( |
| 331 | 431 | .collect() |
| 332 | 432 | } |
| 333 | 433 | |
| 434 | fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { | |
| 435 | let store = Store::parse(source)?; | |
| 436 | let index = RevisionIndex::parse(&store)?; | |
| 437 | let document = Document::parse(&index)?; | |
| 438 | Ok(Page::from_space(&document, space).ok()) | |
| 439 | } | |
| 440 | ||
| 334 | 441 | fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> { |
| 335 | 442 | let store = Store::parse(source)?; |
| 336 | 443 | let index = RevisionIndex::parse(&store)?; |
crates/notebook/src/sync.rs+43| ... | ... | @@ -139,6 +139,15 @@ impl Replica { |
| 139 | 139 | return Ok(Some((intent.id, EditStatus::Published { revision }))); |
| 140 | 140 | } |
| 141 | 141 | let candidate = match &intent.operation { |
| 142 | Operation::Page(edit) => page_of(&snapshot, intent.space)? | |
| 143 | .ok_or(ConflictKind::TargetUnavailable) | |
| 144 | .and_then(|current| { | |
| 145 | if current != edit.before { | |
| 146 | return Err(ConflictKind::ContentChanged); | |
| 147 | } | |
| 148 | PreparedEdit::page(&snapshot, intent.space, &edit.after, &edit.author) | |
| 149 | .map_err(|_| ConflictKind::UnsupportedEdit) | |
| 150 | }), | |
| 142 | 151 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) |
| 143 | 152 | .map_err(|_| ConflictKind::StructureChanged), |
| 144 | 153 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, |
| ... | ... | @@ -327,6 +336,13 @@ impl Replica { |
| 327 | 336 | ) |
| 328 | 337 | .into()); |
| 329 | 338 | } |
| 339 | Operation::Page(_) => { | |
| 340 | return Err(io::Error::new( | |
| 341 | io::ErrorKind::InvalidInput, | |
| 342 | "Review a page model conflict using review_page", | |
| 343 | ) | |
| 344 | .into()); | |
| 345 | } | |
| 330 | 346 | Operation::Outline(_) => { |
| 331 | 347 | return Err(io::Error::new( |
| 332 | 348 | io::ErrorKind::InvalidInput, |
| ... | ... | @@ -508,6 +524,33 @@ impl Replica { |
| 508 | 524 | }) |
| 509 | 525 | } |
| 510 | 526 | |
| 527 | /// Replaces a conflicting page save with a model reviewed against the remote page. | |
| 528 | /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed | |
| 529 | /// model must publish against the remote image, and its author is retained. | |
| 530 | pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> { | |
| 531 | self.resolve_conflict(id, local, remote, |intent| { | |
| 532 | let Operation::Page(edit) = intent.operation else { | |
| 533 | return Err(io::Error::new( | |
| 534 | io::ErrorKind::InvalidInput, | |
| 535 | "Select a page save conflict", | |
| 536 | ) | |
| 537 | .into()); | |
| 538 | }; | |
| 539 | let before = page_of(remote, intent.space)?.ok_or_else(|| { | |
| 540 | io::Error::new( | |
| 541 | io::ErrorKind::InvalidInput, | |
| 542 | "The page is no longer in the remote section", | |
| 543 | ) | |
| 544 | })?; | |
| 545 | PreparedEdit::page(remote, intent.space, after, &edit.author)?; | |
| 546 | Ok(Operation::Page(PageIntent { | |
| 547 | before, | |
| 548 | after: after.clone(), | |
| 549 | author: edit.author, | |
| 550 | })) | |
| 551 | }) | |
| 552 | } | |
| 553 | ||
| 511 | 554 | fn resolve_conflict( |
| 512 | 555 | &self, |
| 513 | 556 | id: u64, |
crates/notebook/tests/sync.rs+2| ... | ... | @@ -7,6 +7,8 @@ use std::io; |
| 7 | 7 | |
| 8 | 8 | #[path = "../../onestore/tests/support/disk.rs"] |
| 9 | 9 | mod disk; |
| 10 | #[path = "sync/model.rs"] | |
| 11 | mod model; | |
| 10 | 12 | #[path = "sync/outline.rs"] |
| 11 | 13 | mod outline; |
| 12 | 14 | #[path = "sync/page.rs"] |
crates/notebook/tests/sync/model.rs created+268| ... | ... | @@ -0,0 +1,268 @@ |
| 1 | //! Page-model saves: one intent per page until it is attempted, reconciliation against the | |
| 2 | //! remote page, reviewed conflicts, and independence between pages. | |
| 3 | ||
| 4 | use super::*; | |
| 5 | use notebook::{Operation, PageIntent}; | |
| 6 | use onestore::page::{Page, PageObject}; | |
| 7 | ||
| 8 | const OUTLINES: &[u8] = | |
| 9 | include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | |
| 10 | ||
| 11 | fn page_of(bytes: &[u8], space: ExGuid) -> Page { | |
| 12 | let store = Store::parse(bytes).unwrap(); | |
| 13 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 14 | let document = Document::parse(&index).unwrap(); | |
| 15 | Page::from_space(&document, space).unwrap() | |
| 16 | } | |
| 17 | ||
| 18 | fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { | |
| 19 | let store = Store::parse(bytes).unwrap(); | |
| 20 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 21 | let document = Document::parse(&index).unwrap(); | |
| 22 | document | |
| 23 | .pages() | |
| 24 | .unwrap() | |
| 25 | .into_iter() | |
| 26 | .find_map(|(sid, _)| { | |
| 27 | let page = Page::from_space(&document, sid).unwrap(); | |
| 28 | (page.title == title).then_some((sid, page)) | |
| 29 | }) | |
| 30 | .unwrap() | |
| 31 | } | |
| 32 | ||
| 33 | /// Appends `suffix` to the first body paragraph of the first body outline. | |
| 34 | fn append(page: &mut Page, suffix: &str) { | |
| 35 | let outline = page | |
| 36 | .objects | |
| 37 | .iter_mut() | |
| 38 | .find_map(|object| match object { | |
| 39 | PageObject::Outline(outline) => Some(outline), | |
| 40 | _ => None, | |
| 41 | }) | |
| 42 | .unwrap(); | |
| 43 | let text = outline.paragraphs[0].text_mut().unwrap(); | |
| 44 | let end = text.text.utf16_offset(text.text.text().len()).unwrap(); | |
| 45 | let format = text.text.format_at(end).unwrap().clone(); | |
| 46 | text.text | |
| 47 | .apply(onestore::page::text::Edit { | |
| 48 | range: end..end, | |
| 49 | replacement: onestore::page::Paragraph::new(suffix.into(), format), | |
| 50 | }) | |
| 51 | .unwrap(); | |
| 52 | } | |
| 53 | ||
| 54 | fn first_text(page: &Page) -> String { | |
| 55 | page.objects | |
| 56 | .iter() | |
| 57 | .find_map(|object| match object { | |
| 58 | PageObject::Outline(outline) => { | |
| 59 | Some(outline.paragraphs[0].text().unwrap().text.text().to_owned()) | |
| 60 | } | |
| 61 | _ => None, | |
| 62 | }) | |
| 63 | .unwrap() | |
| 64 | } | |
| 65 | ||
| 66 | #[test] | |
| 67 | fn a_saved_page_model_publishes_and_reads_back() { | |
| 68 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 69 | let directory = tempfile::tempdir().unwrap(); | |
| 70 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | |
| 71 | append(&mut after, " saved 🦀"); | |
| 72 | let id = cache | |
| 73 | .save(OUTLINES, space, &after, "Model author") | |
| 74 | .unwrap() | |
| 75 | .unwrap(); | |
| 76 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | |
| 77 | assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); | |
| 78 | let pending = cache.pending().unwrap(); | |
| 79 | assert_eq!(pending.len(), 1); | |
| 80 | let Operation::Page(PageIntent { | |
| 81 | before, | |
| 82 | after: stored, | |
| 83 | author, | |
| 84 | }) = &pending[0].operation | |
| 85 | else { | |
| 86 | panic!() | |
| 87 | }; | |
| 88 | assert_eq!( | |
| 89 | (before, stored, author.as_str()), | |
| 90 | (&page_of(OUTLINES, space), &after, "Model author") | |
| 91 | ); | |
| 92 | let mut server = Server::new(OUTLINES); | |
| 93 | assert!( | |
| 94 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | |
| 95 | ); | |
| 96 | assert_eq!(page_of(&server.durable, space), after); | |
| 97 | assert_eq!(server.publications, 1); | |
| 98 | assert!(cache.pending().unwrap().is_empty()); | |
| 99 | assert_eq!( | |
| 100 | cache | |
| 101 | .save(&cache.snapshot().unwrap(), space, &after, "Model author") | |
| 102 | .unwrap(), | |
| 103 | None | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | #[test] | |
| 108 | fn saves_before_an_attempt_coalesce_into_one_publication() { | |
| 109 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 110 | let directory = tempfile::tempdir().unwrap(); | |
| 111 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | |
| 112 | append(&mut after, " one"); | |
| 113 | let id = cache | |
| 114 | .save(OUTLINES, space, &after, "Model author") | |
| 115 | .unwrap() | |
| 116 | .unwrap(); | |
| 117 | let mut later = page_of(&cache.snapshot().unwrap(), space); | |
| 118 | append(&mut later, " two"); | |
| 119 | assert_eq!( | |
| 120 | cache | |
| 121 | .save(&cache.snapshot().unwrap(), space, &later, "Model author") | |
| 122 | .unwrap(), | |
| 123 | Some(id) | |
| 124 | ); | |
| 125 | let pending = cache.pending().unwrap(); | |
| 126 | assert_eq!(pending.len(), 1); | |
| 127 | let Operation::Page(intent) = &pending[0].operation else { | |
| 128 | panic!() | |
| 129 | }; | |
| 130 | assert_eq!(intent.before, page_of(OUTLINES, space)); | |
| 131 | assert_eq!(intent.after, later); | |
| 132 | let mut server = Server::new(OUTLINES); | |
| 133 | assert!( | |
| 134 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | |
| 135 | ); | |
| 136 | assert_eq!(server.publications, 1); | |
| 137 | assert_eq!( | |
| 138 | first_text(&page_of(&server.durable, space)), | |
| 139 | first_text(&later) | |
| 140 | ); | |
| 141 | let mut third = page_of(&cache.snapshot().unwrap(), space); | |
| 142 | append(&mut third, " three"); | |
| 143 | let next = cache | |
| 144 | .save(&cache.snapshot().unwrap(), space, &third, "Model author") | |
| 145 | .unwrap() | |
| 146 | .unwrap(); | |
| 147 | assert!(next > id); | |
| 148 | } | |
| 149 | ||
| 150 | #[test] | |
| 151 | fn an_attempted_save_is_never_rewritten() { | |
| 152 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 153 | let directory = tempfile::tempdir().unwrap(); | |
| 154 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | |
| 155 | append(&mut after, " uncertain"); | |
| 156 | let id = cache | |
| 157 | .save(OUTLINES, space, &after, "Model author") | |
| 158 | .unwrap() | |
| 159 | .unwrap(); | |
| 160 | let mut server = Server::new(OUTLINES); | |
| 161 | server.fault = Fault::UnknownAfter; | |
| 162 | assert!(cache.sync_once(&mut server).is_err()); | |
| 163 | assert!(matches!( | |
| 164 | cache.status(id).unwrap(), | |
| 165 | Some(EditStatus::AwaitingConfirmation { .. }) | |
| 166 | )); | |
| 167 | let mut more = page_of(&cache.snapshot().unwrap(), space); | |
| 168 | append(&mut more, " more"); | |
| 169 | let next = cache | |
| 170 | .save(&cache.snapshot().unwrap(), space, &more, "Model author") | |
| 171 | .unwrap() | |
| 172 | .unwrap(); | |
| 173 | assert!(next > id); | |
| 174 | let pending = cache.pending().unwrap(); | |
| 175 | assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]); | |
| 176 | let Operation::Page(first) = &pending[0].operation else { | |
| 177 | panic!() | |
| 178 | }; | |
| 179 | assert_eq!(first.after, after); | |
| 180 | let Operation::Page(second) = &pending[1].operation else { | |
| 181 | panic!() | |
| 182 | }; | |
| 183 | assert_eq!((&second.before, &second.after), (&after, &more)); | |
| 184 | } | |
| 185 | ||
| 186 | #[test] | |
| 187 | fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { | |
| 188 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 189 | let directory = tempfile::tempdir().unwrap(); | |
| 190 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | |
| 191 | append(&mut after, " local"); | |
| 192 | let id = cache | |
| 193 | .save(OUTLINES, space, &after, "Model author") | |
| 194 | .unwrap() | |
| 195 | .unwrap(); | |
| 196 | let mut remote_page = page_of(OUTLINES, space); | |
| 197 | append(&mut remote_page, " remote"); | |
| 198 | let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); | |
| 199 | let mut server = Server::new(remote.as_bytes()); | |
| 200 | assert_eq!( | |
| 201 | cache.sync_once(&mut server).unwrap(), | |
| 202 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | |
| 203 | ); | |
| 204 | assert_eq!(server.publications, 0); | |
| 205 | assert!(cache.pending().unwrap().len() == 1); | |
| 206 | let local = cache.snapshot().unwrap(); | |
| 207 | let observed = cache.remote_snapshot().unwrap(); | |
| 208 | assert_eq!(observed, remote.as_bytes()); | |
| 209 | let mut reviewed = page_of(&observed, space); | |
| 210 | append(&mut reviewed, " local"); | |
| 211 | assert!( | |
| 212 | cache | |
| 213 | .review_page(id + 1, &local, &observed, &reviewed) | |
| 214 | .is_err() | |
| 215 | ); | |
| 216 | assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err()); | |
| 217 | cache.review_page(id, &local, &observed, &reviewed).unwrap(); | |
| 218 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | |
| 219 | assert!( | |
| 220 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | |
| 221 | ); | |
| 222 | assert_eq!( | |
| 223 | first_text(&page_of(&server.durable, space)), | |
| 224 | first_text(&reviewed) | |
| 225 | ); | |
| 226 | assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local")); | |
| 227 | } | |
| 228 | ||
| 229 | #[test] | |
| 230 | fn remote_changes_to_other_pages_do_not_conflict() { | |
| 231 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 232 | let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf"); | |
| 233 | let directory = tempfile::tempdir().unwrap(); | |
| 234 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | |
| 235 | append(&mut after, " local"); | |
| 236 | let id = cache | |
| 237 | .save(OUTLINES, space, &after, "Model author") | |
| 238 | .unwrap() | |
| 239 | .unwrap(); | |
| 240 | append(&mut other_page, " elsewhere"); | |
| 241 | let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); | |
| 242 | let mut server = Server::new(remote.as_bytes()); | |
| 243 | assert!( | |
| 244 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | |
| 245 | ); | |
| 246 | assert_eq!(page_of(&server.durable, space), after); | |
| 247 | assert_eq!(page_of(&server.durable, other), other_page); | |
| 248 | assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page); | |
| 249 | } | |
| 250 | ||
| 251 | #[test] | |
| 252 | fn pending_saves_survive_reopening_the_cache() { | |
| 253 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | |
| 254 | let directory = tempfile::tempdir().unwrap(); | |
| 255 | let path = directory.path().join("cache.sqlite"); | |
| 256 | let cache = Replica::create(&path, OUTLINES).unwrap(); | |
| 257 | append(&mut after, " durable"); | |
| 258 | let id = cache | |
| 259 | .save(OUTLINES, space, &after, "Model author") | |
| 260 | .unwrap() | |
| 261 | .unwrap(); | |
| 262 | let pending = cache.pending().unwrap(); | |
| 263 | drop(cache); | |
| 264 | let reopened = Replica::open(&path).unwrap(); | |
| 265 | assert_eq!(reopened.pending().unwrap(), pending); | |
| 266 | assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending)); | |
| 267 | assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after); | |
| 268 | } |
crates/onestore/src/document.rs+11-7| ... | ... | @@ -7,11 +7,15 @@ use crate::{ |
| 7 | 7 | Error, ExGuid, FileDataReference, IdStream, Object, ObjectData, Property, PropertySets, |
| 8 | 8 | RevisionIndex, Store, Value, bytes::Cursor, |
| 9 | 9 | }; |
| 10 | use serde::Serialize; | |
| 10 | use serde::{Deserialize, Serialize}; | |
| 11 | 11 | use std::collections::{BTreeMap, BTreeSet}; |
| 12 | 12 | |
| 13 | 13 | type Result<T> = std::result::Result<T, Error>; |
| 14 | 14 | |
| 15 | fn no_ciphertext<'a>() -> &'a [u8] { | |
| 16 | &[] | |
| 17 | } | |
| 18 | ||
| 15 | 19 | fn invalid(message: &'static str) -> Error { |
| 16 | 20 | Error { offset: 0, message } |
| 17 | 21 | } |
| ... | ... | @@ -73,7 +77,7 @@ pub struct Element<'a> { |
| 73 | 77 | pub extra: Vec<Vec<Field<'a>>>, |
| 74 | 78 | } |
| 75 | 79 | |
| 76 | #[derive(Debug, Default, Clone, PartialEq, Serialize)] | |
| 80 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] | |
| 77 | 81 | pub struct Layout { |
| 78 | 82 | pub x: Option<f32>, |
| 79 | 83 | pub y: Option<f32>, |
| ... | ... | @@ -86,7 +90,7 @@ pub struct Layout { |
| 86 | 90 | |
| 87 | 91 | macro_rules! format_fields { |
| 88 | 92 | ($($field:ident: $value:ty),* $(,)?) => { |
| 89 | #[derive(Debug, Default, Clone, PartialEq, Serialize)] | |
| 93 | #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] | |
| 90 | 94 | pub struct Format { $(pub $field: Option<$value>),* } |
| 91 | 95 | |
| 92 | 96 | impl Format { |
| ... | ... | @@ -123,7 +127,7 @@ format_fields! { |
| 123 | 127 | list_spacing: f32, |
| 124 | 128 | } |
| 125 | 129 | |
| 126 | #[derive(Debug, PartialEq, Serialize)] | |
| 130 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 127 | 131 | pub struct TextRun { |
| 128 | 132 | pub start: u32, |
| 129 | 133 | pub end: u32, |
| ... | ... | @@ -251,7 +255,7 @@ impl Revision<'_> { |
| 251 | 255 | } |
| 252 | 256 | } |
| 253 | 257 | |
| 254 | #[derive(Debug, Clone, PartialEq, Serialize)] | |
| 258 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 255 | 259 | /// Tag dates count seconds since 1980-01-01 UTC; status retains the ActionItemStatus bits. |
| 256 | 260 | pub struct Tag { |
| 257 | 261 | pub definition: Option<ExGuid>, |
| ... | ... | @@ -266,7 +270,7 @@ pub struct Tag { |
| 266 | 270 | pub extra_set: usize, |
| 267 | 271 | } |
| 268 | 272 | |
| 269 | #[derive(Debug, PartialEq, Serialize)] | |
| 273 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 270 | 274 | #[serde(tag = "type")] |
| 271 | 275 | pub enum Kind<'a> { |
| 272 | 276 | Section { |
| ... | ... | @@ -395,7 +399,7 @@ pub enum Kind<'a> { |
| 395 | 399 | color: Option<u32>, |
| 396 | 400 | }, |
| 397 | 401 | Encrypted { |
| 398 | #[serde(serialize_with = "hex")] | |
| 402 | #[serde(serialize_with = "hex", skip_deserializing, default = "no_ciphertext")] | |
| 399 | 403 | ciphertext: &'a [u8], |
| 400 | 404 | }, |
| 401 | 405 | Unknown, |
crates/onestore/src/files.rs+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | use crate::{Error, Object, ObjectData, Reference, Store, bytes::Cursor}; |
| 2 | 2 | |
| 3 | #[derive(Debug, PartialEq, Eq, serde::Serialize)] | |
| 3 | #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 4 | 4 | pub enum FileDataReference { |
| 5 | 5 | Internal([u8; 16]), |
| 6 | 6 | External(String), |
crates/onestore/src/page/mod.rs+26-15| ... | ... | @@ -15,13 +15,13 @@ pub(crate) mod write; |
| 15 | 15 | pub use text::Paragraph; |
| 16 | 16 | |
| 17 | 17 | /// The role of a title-outline paragraph that displays the page's creation date or time. |
| 18 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 19 | 19 | pub enum DateField { |
| 20 | 20 | Date = 0, |
| 21 | 21 | Time = 1, |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | #[derive(Debug, PartialEq)] | |
| 24 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 25 | 25 | pub struct Page { |
| 26 | 26 | pub title: String, |
| 27 | 27 | /// FILETIME ticks from the page's TopologyCreationTimeStamp. |
| ... | ... | @@ -31,13 +31,13 @@ pub struct Page { |
| 31 | 31 | pub definitions: BTreeMap<ExGuid, Definition>, |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | #[derive(Debug, PartialEq)] | |
| 34 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 35 | 35 | pub struct Definition { |
| 36 | 36 | pub kind: Kind<'static>, |
| 37 | 37 | pub format: Format, |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | #[derive(Debug, PartialEq)] | |
| 40 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 41 | 41 | pub enum PageObject { |
| 42 | 42 | Outline(Outline), |
| 43 | 43 | Title(Title), |
| ... | ... | @@ -73,7 +73,7 @@ impl PageObject { |
| 73 | 73 | } |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | #[derive(Debug, PartialEq)] | |
| 76 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 77 | 77 | pub struct Title { |
| 78 | 78 | pub id: ExGuid, |
| 79 | 79 | pub date: Option<ExGuid>, |
| ... | ... | @@ -81,7 +81,7 @@ pub struct Title { |
| 81 | 81 | pub outlines: Vec<Outline>, |
| 82 | 82 | } |
| 83 | 83 | |
| 84 | #[derive(Clone, Debug, PartialEq)] | |
| 84 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 85 | 85 | pub struct Outline { |
| 86 | 86 | pub id: ExGuid, |
| 87 | 87 | pub title: bool, |
| ... | ... | @@ -92,7 +92,7 @@ pub struct Outline { |
| 92 | 92 | pub unsupported: Vec<Unsupported>, |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | #[derive(Clone, Debug, PartialEq)] | |
| 95 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 96 | 96 | pub struct PageParagraph { |
| 97 | 97 | pub id: ExGuid, |
| 98 | 98 | pub parent: Option<ExGuid>, |
| ... | ... | @@ -105,14 +105,14 @@ pub struct PageParagraph { |
| 105 | 105 | pub collapsed: bool, |
| 106 | 106 | } |
| 107 | 107 | |
| 108 | #[derive(Clone, Debug, PartialEq)] | |
| 108 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 109 | 109 | pub enum ParagraphContent { |
| 110 | 110 | Text(TextObject), |
| 111 | 111 | Table(Table), |
| 112 | 112 | Unsupported(Unsupported), |
| 113 | 113 | } |
| 114 | 114 | |
| 115 | #[derive(Clone, Debug, PartialEq)] | |
| 115 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 116 | 116 | pub struct Table { |
| 117 | 117 | pub id: ExGuid, |
| 118 | 118 | pub columns: Vec<TableColumn>, |
| ... | ... | @@ -122,19 +122,19 @@ pub struct Table { |
| 122 | 122 | pub tags: Vec<Tag>, |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | #[derive(Clone, Debug, PartialEq)] | |
| 125 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 126 | 126 | pub struct TableColumn { |
| 127 | 127 | pub width: f32, |
| 128 | 128 | pub locked: bool, |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | #[derive(Clone, Debug, PartialEq)] | |
| 131 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 132 | 132 | pub struct TableRow { |
| 133 | 133 | pub id: ExGuid, |
| 134 | 134 | pub cells: Vec<TableCell>, |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | #[derive(Clone, Debug, PartialEq)] | |
| 137 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 138 | 138 | pub struct TableCell { |
| 139 | 139 | pub id: ExGuid, |
| 140 | 140 | pub layout: Layout, |
| ... | ... | @@ -144,7 +144,7 @@ pub struct TableCell { |
| 144 | 144 | pub unsupported: Vec<Unsupported>, |
| 145 | 145 | } |
| 146 | 146 | |
| 147 | #[derive(Clone, Debug, PartialEq)] | |
| 147 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 148 | 148 | pub struct TextObject { |
| 149 | 149 | pub id: ExGuid, |
| 150 | 150 | pub date_field: Option<DateField>, |
| ... | ... | @@ -152,16 +152,27 @@ pub struct TextObject { |
| 152 | 152 | pub tags: Vec<Tag>, |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | #[derive(Debug, PartialEq)] | |
| 155 | /// Payload bytes are identified by `id`, so equality and serialization leave them out. | |
| 156 | #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] | |
| 156 | 157 | pub struct Image { |
| 157 | 158 | pub id: ExGuid, |
| 158 | 159 | pub layout: Layout, |
| 160 | #[serde(skip)] | |
| 159 | 161 | pub bytes: Option<Arc<[u8]>>, |
| 160 | 162 | pub alt: Option<String>, |
| 161 | 163 | pub background: bool, |
| 162 | 164 | } |
| 163 | 165 | |
| 164 | #[derive(Clone, Debug, PartialEq)] | |
| 166 | impl PartialEq for Image { | |
| 167 | fn eq(&self, other: &Self) -> bool { | |
| 168 | self.id == other.id | |
| 169 | && self.layout == other.layout | |
| 170 | && self.alt == other.alt | |
| 171 | && self.background == other.background | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 165 | 176 | pub struct Unsupported { |
| 166 | 177 | pub id: ExGuid, |
| 167 | 178 | pub jcid: u32, |
crates/onestore/src/page/text.rs+2-2| ... | ... | @@ -1,14 +1,14 @@ |
| 1 | 1 | use crate::{ExGuid, document::Format}; |
| 2 | 2 | use std::{fmt, ops::Range}; |
| 3 | 3 | |
| 4 | #[derive(Clone, Debug, PartialEq)] | |
| 4 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 5 | 5 | pub struct Span { |
| 6 | 6 | /// Exclusive UTF-8 boundary; the start is the preceding span's end. |
| 7 | 7 | pub end: usize, |
| 8 | 8 | pub format: Format, |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | #[derive(Clone, Debug, PartialEq)] | |
| 11 | #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] | |
| 12 | 12 | /// Editable text styles are coalesced independently of serialized run boundaries. |
| 13 | 13 | pub struct Paragraph { |
| 14 | 14 | text: String, |
crates/onestore/src/page/write.rs+4-1| ... | ... | @@ -222,7 +222,10 @@ impl Lowering<'_> { |
| 222 | 222 | page.objects |
| 223 | 223 | .iter() |
| 224 | 224 | .filter_map(|object| match object { |
| 225 | PageObject::Image(image) => Some(format!("{image:?}")), | |
| 225 | PageObject::Image(image) => Some(format!( | |
| 226 | "{:?} {:?} {:?} {:?}", | |
| 227 | image.id, image.layout, image.alt, image.background | |
| 228 | )), | |
| 226 | 229 | PageObject::Unsupported(unsupported) => Some(format!("{unsupported:?}")), |
| 227 | 230 | PageObject::Title(title) => Some(format!( |
| 228 | 231 | "{:?} {:?} {:?} {:?}", |