1//! Page creation reconciliation: dependent body saves, uncertain publication, lost anchors,
2//! and deterministic multi-actor schedules.
3
4#[path = "../../onestore/tests/support/ops.rs"]
5mod ops;
6
7use notebook::{EditStatus, Recovery, Replica};
8use onestore::op::SectionOp;
9use onestore::{
10 ExGuid, PageCreation, RevisionIndex, Store,
11 document::{Document, Kind},
12};
13use std::collections::BTreeMap;
14
15#[path = "support/server.rs"]
16mod server;
17use server::*;
18#[path = "../../onestore/tests/support/disk.rs"]
19mod disk;
20#[path = "support/model_ops.rs"]
21mod model_ops;
22#[path = "support/page_schedule.rs"]
23mod page_schedule;
24use page_schedule::body_outline;
25#[path = "../../onestore/tests/support/sweep.rs"]
26mod sweep;
27
28#[test]
29fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {
30 for seed in sweep::seeds(0..24, 1) {
31 let mut random = seed + 1956;
32 let mut input = Vec::new();
33 for step in 0..48 {
34 random ^= random << 13;
35 random ^= random >> 7;
36 random ^= random << 17;
37 let mut action = random.to_le_bytes();
38 if step < 12 {
39 action[0] = step;
40 action[1] = 1;
41 }
42 input.extend_from_slice(&action);
43 }
44 page_schedule::run(&input);
45 }
46}
47
48#[test]
49fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
50 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
51 let directory = tempfile::tempdir().unwrap();
52 let mut server = Server::new(&source);
53 let mut expected = Vec::new();
54 for actor in 0..12 {
55 let path = directory.path().join(format!("{actor}.sqlite"));
56 let cache = Replica::create(&path, &source).unwrap();
57 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
58 let id = section_op(&cache, SectionOp::Create(page.clone()));
59 let mut created = cache.page(page.space()).unwrap();
60 let body = body_outline(&mut created, "Body");
61 let save = model_ops::save_as(&cache, page.space(), &created, model_ops::AUTHOR)
62 .unwrap()
63 .unwrap();
64 let dependent = model_ops::save(&cache, body, |page| {
65 model_ops::replace_text(page, body, 4..4, &format!(" {actor}"))
66 })
67 .unwrap()
68 .unwrap();
69 assert!(id < save && save < dependent);
70 let original = cache.pending().unwrap();
71 assert_eq!(original.len(), 3);
72 assert!(
73 matches!(&original[0].edit.ops[..], [onestore::op::Op::Section(SectionOp::Create(retained))] if retained == &page)
74 );
75 let local = snapshot(&cache);
76 drop(cache);
77 let cache = Replica::open(&path).unwrap();
78 assert_eq!(cache.pending().unwrap(), original);
79 assert_eq!(pages(&snapshot(&cache)), pages(&local));
80 assert!(matches!(
81 cache.sync_once(&mut server).unwrap().edit,
82 Some((published, EditStatus::Published { .. })) if published == dependent
83 ));
84 for edit in original {
85 assert!(matches!(
86 cache.status(edit.id).unwrap(),
87 Some(EditStatus::Published { .. })
88 ));
89 }
90 expected.push((page.space(), page.object(), body, format!("Body {actor}")));
91 }
92 let store = Store::parse(&server.durable).unwrap();
93 let index = RevisionIndex::parse(&store).unwrap();
94 let document = Document::parse(&index).unwrap();
95 let pages = document.pages().unwrap();
96 assert_eq!(pages.len(), 13);
97 for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) {
98 assert_eq!(*actual, (*sid, *page));
99 let space = &document.spaces[sid];
100 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
101 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
102 }
103 assert_eq!(server.publications, 12);
104 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
105 std::fs::create_dir(&output).unwrap();
106 std::fs::write(
107 std::path::Path::new(&output).join("pages.one"),
108 &server.durable,
109 )
110 .unwrap();
111 }
112}
113
114#[test]
115fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
116 for fault in [
117 Fault::UnknownBefore,
118 Fault::UnknownAfter,
119 Fault::PanicBefore,
120 Fault::PanicAfter,
121 ] {
122 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
123 let directory = tempfile::tempdir().unwrap();
124 let path = directory.path().join("pages.sqlite");
125 let cache = Replica::create(&path, &source).unwrap();
126 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();
127 let id = section_op(&cache, SectionOp::Create(page.clone()));
128 let local = snapshot(&cache);
129 let mut server = Server::new(&source);
130 server.fault = fault;
131 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
132 cache.sync_once(&mut server)
133 }));
134 let attempted = cache.status(id).unwrap().unwrap();
135 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
136 let archive = directory.path().join("recovery.sqlite");
137 cache.export_recovery(&archive).unwrap();
138 assert_eq!(
139 Recovery::open(&archive).unwrap().status(id).unwrap(),
140 Some(attempted.clone())
141 );
142 drop(cache);
143 let db = rusqlite::Connection::open(&path).unwrap();
144 let encoded: String = db
145 .query_row(
146 "SELECT revisions FROM batches WHERE attempted=1",
147 [],
148 |row| row.get(0),
149 )
150 .unwrap();
151 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
152 assert_eq!(revisions.len(), 2);
153 assert!(revisions.contains_key(&page.space()));
154 drop(db);
155 let cache = Replica::open(&path).unwrap();
156 let observed = server.visible.clone();
157 let result = cache.sync_once(&mut server).unwrap().edit.unwrap();
158 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
159 assert_eq!(server.publications, 1);
160 assert_eq!(server.confirmations, usize::from(visible));
161 if visible {
162 assert!(matches!(result.1, EditStatus::Published { .. }));
163 assert!(snapshot(&cache) == observed);
164 assert!(observed[..212] == server.durable[..212]);
165 assert!(observed[252..] == server.durable[252..]);
166 } else {
167 assert_eq!(result, (id, attempted.clone()));
168 assert_eq!(pages(&snapshot(&cache)), pages(&local));
169 }
170 }
171}
172
173#[test]
174fn surviving_page_revision_alone_does_not_confirm_section_publication() {
175 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
176 let directory = tempfile::tempdir().unwrap();
177 let path = directory.path().join("pages.sqlite");
178 let cache = Replica::create(&path, &source).unwrap();
179 let page = PageCreation::new(None, None, "Author").unwrap();
180 let id = section_op(&cache, SectionOp::Create(page.clone()));
181 let mut server = Server::new(&source);
182 server.fault = Fault::UnknownAfter;
183 assert!(cache.sync_once(&mut server).is_err());
184 let attempted = cache.status(id).unwrap().unwrap();
185 drop(cache);
186 let db = rusqlite::Connection::open(&path).unwrap();
187 let encoded: String = db
188 .query_row(
189 "SELECT revisions FROM batches WHERE attempted=1",
190 [],
191 |row| row.get(0),
192 )
193 .unwrap();
194 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
195 drop(db);
196 let complete = server.visible.clone();
197 let (&section, &retired) = revisions
198 .iter()
199 .find(|(sid, _)| **sid != page.space())
200 .unwrap();
201 let store = Store::parse(&complete).unwrap();
202 let mut positions = Vec::new();
203 for list in store.lists.values() {
204 for node in &list.nodes {
205 if node.id == 0x1e
206 && node.payload[..16] == retired.guid
207 && node.payload[16..20] == retired.n.to_le_bytes()
208 {
209 positions.push(node.offset);
210 }
211 }
212 }
213 assert_eq!(positions.len(), 1);
214 // Replacing only the revision identity models maintenance retiring the section proof.
215 let at = positions[0] + 4;
216 server.visible[at] ^= 0x40;
217 // A rewrite publishes a new file version (MS-ONESTORE 2.3.1).
218 server.visible[212] ^= 1;
219 let store = Store::parse(&server.visible).unwrap();
220 let index = RevisionIndex::parse(&store).unwrap();
221 index.validate_current().unwrap();
222 assert!(!index.spaces[&section].revisions.contains_key(&retired));
223 assert!(
224 index.spaces[&page.space()]
225 .revisions
226 .contains_key(&revisions[&page.space()])
227 );
228 let cache = Replica::open(&path).unwrap();
229 assert_eq!(
230 cache.sync_once(&mut server).unwrap().edit,
231 Some((id, attempted.clone()))
232 );
233 assert_eq!(server.confirmations, 0);
234 assert_eq!(server.publications, 1);
235 server.visible = complete;
236 assert!(matches!(
237 cache.sync_once(&mut server).unwrap().edit,
238 Some((_, EditStatus::Published { .. }))
239 ));
240 assert_eq!(server.publications, 1);
241}
242
243/// A page created before a page the remote moved goes before the next page the remote left
244/// in place, its content under the identities it was made with.
245#[test]
246fn a_moved_page_anchor_places_the_page_without_regenerating_dependent_identities() {
247 let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
248 let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
249 let store = Store::parse(source).unwrap();
250 let index = RevisionIndex::parse(&store).unwrap();
251 let pages = Document::parse(&index).unwrap().pages().unwrap();
252 let directory = tempfile::tempdir().unwrap();
253 let path = directory.path().join("pages.sqlite");
254 let cache = Replica::create(&path, source).unwrap();
255 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
256 let id = section_op(&cache, SectionOp::Create(page.clone()));
257 let mut created = cache.page(page.space()).unwrap();
258 let body = body_outline(&mut created, "Retained body");
259 let save = model_ops::save_as(&cache, page.space(), &created, "Author")
260 .unwrap()
261 .unwrap();
262 let local = snapshot(&cache);
263 let mut server = Server::new(remote);
264 assert!(matches!(
265 cache.sync_once(&mut server).unwrap().edit,
266 Some((published, EditStatus::Published { .. })) if published == save
267 ));
268 assert!(matches!(
269 cache.status(id).unwrap(),
270 Some(EditStatus::Published { .. })
271 ));
272 assert_eq!(server.publications, 1);
273 assert_eq!(
274 model_ops::page_of(&server.durable, page.space()),
275 model_ops::page_of(&local, page.space())
276 );
277 let arena = onestore::Arena::default();
278 let listed = onestore::Section::open(&arena, server.durable.clone())
279 .unwrap()
280 .pages()
281 .unwrap();
282 // The remote made `child` and `grandchild` subpages; `Renamed` stayed.
283 let at = listed
284 .iter()
285 .position(|listed| listed.0 == pages[6].0)
286 .unwrap();
287 assert_eq!(listed[at - 1].0, page.space());
288 let store = Store::parse(&server.durable).unwrap();
289 let index = RevisionIndex::parse(&store).unwrap();
290 let document = Document::parse(&index).unwrap();
291 let space = &document.spaces[&page.space()];
292 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
293 assert!(matches!(&view.nodes[&body].kind,
294 Kind::RichText { text, .. } if text == "Retained body"));
295}
296
297/// Edits of a page the remote removed put the local version back where it stood.
298#[test]
299fn a_page_the_remote_removed_comes_back_where_it_stood() {
300 let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
301 let order = |image: &[u8]| -> Vec<(ExGuid, String, u32)> {
302 let arena = onestore::Arena::default();
303 onestore::Section::open(&arena, image.to_vec())
304 .unwrap()
305 .pages()
306 .unwrap()
307 };
308 let pages = order(source);
309 // A subpage without subpages of its own and with a page after it, so it has a position
310 // and a level to return to.
311 let at = (0..pages.len() - 1)
312 .find(|at| {
313 pages[*at].2 > 1
314 && pages[*at + 1].2 <= pages[*at].2
315 && model_ops::page_of(source, pages[*at].0)
316 .objects
317 .iter()
318 .any(|object| matches!(object, onestore::page::PageObject::Outline(_)))
319 })
320 .expect("a subpage with body text");
321 let (space, _, level) = pages[at].clone();
322 let text = model_ops::page_of(source, space)
323 .objects
324 .iter()
325 .find_map(|object| match object {
326 onestore::page::PageObject::Outline(outline) => {
327 outline.paragraphs[0].text().map(|t| t.id)
328 }
329 _ => None,
330 })
331 .unwrap();
332 let directory = tempfile::tempdir().unwrap();
333 let cache = Replica::create(directory.path().join("pages.sqlite"), source).unwrap();
334 model_ops::save(&cache, text, |page| {
335 model_ops::replace_text(page, text, 0..0, "Kept ")
336 })
337 .unwrap()
338 .unwrap();
339 let local = cache.page(space).unwrap();
340 let removed = ops::section_op(source, SectionOp::Delete([space].to_vec()))
341 .unwrap()
342 .as_bytes()
343 .to_vec();
344 let mut server = Server::new(&removed);
345 assert!(matches!(
346 cache.sync_once(&mut server).unwrap().edit,
347 Some((_, EditStatus::Published { .. }))
348 ));
349 assert_eq!(
350 server.publications, 1,
351 "a removed page is not edited in place"
352 );
353 let published = order(&server.durable);
354 assert!(published.iter().all(|(page, ..)| *page != space));
355 assert_eq!(published.len(), pages.len());
356 let (restored, title, restored_level) = published[at].clone();
357 assert_eq!(
358 (title.as_str(), restored_level),
359 (pages[at].1.as_str(), level)
360 );
361 let page = model_ops::page_of(&server.durable, restored);
362 let texts = |page: &onestore::page::Page| -> Vec<String> {
363 page.objects
364 .iter()
365 .filter_map(|object| match object {
366 onestore::page::PageObject::Outline(outline) => Some(outline),
367 _ => None,
368 })
369 .flat_map(|outline| &outline.paragraphs)
370 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
371 .collect()
372 };
373 assert_eq!(texts(&page), texts(&local));
374 assert!(texts(&page)[0].starts_with("Kept "));
375 let others: Vec<_> = published
376 .iter()
377 .map(|(page, ..)| *page)
378 .filter(|page| *page != restored)
379 .collect();
380 let before: Vec<_> = pages
381 .iter()
382 .map(|(page, ..)| *page)
383 .filter(|page| *page != space)
384 .collect();
385 assert_eq!(others, before);
386 assert_eq!(pages_of(&cache), published);
387}
388
389fn pages_of(cache: &Replica) -> Vec<(ExGuid, String, u32)> {
390 cache.pages().unwrap()
391}