1#[path = "../../onestore/tests/support/ops.rs"]
2mod ops;
3use notebook::{Error, Replica};
4use onestore::{
5 ExGuid, RevisionIndex, Store,
6 document::{Document, Kind},
7 op::{Edit, Op, PageOp, SectionOp},
8 page::{Outline, PageObject},
9};
10use std::{
11 collections::BTreeSet,
12 fs,
13 io::ErrorKind,
14 sync::Barrier,
15 time::{Duration, Instant},
16};
17
18#[path = "support/model_ops.rs"]
19mod model_ops;
20#[path = "support/server.rs"]
21mod server;
22#[path = "../../onestore/tests/support/sweep.rs"]
23mod sweep;
24use server::snapshot;
25
26fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
27 let store = Store::parse(source).unwrap();
28 assert!(store.checksum_mismatches.is_empty());
29 let index = RevisionIndex::parse(&store).unwrap();
30 index.validate_current().unwrap();
31 let doc = Document::parse(&index).unwrap();
32 doc.spaces
33 .iter()
34 .find_map(|(sid, space)| {
35 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
36 revision
37 .nodes
38 .iter()
39 .find_map(|(oid, node)| match &node.kind {
40 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
41 _ => None,
42 })
43 })
44 .unwrap()
45}
46
47/// An edit typing `with` at the start of a text.
48fn typed(space: ExGuid, text: ExGuid, with: &str) -> Edit {
49 Edit {
50 at: model_ops::now(),
51 ops: vec![Op::Page {
52 space,
53 op: PageOp::Text {
54 text,
55 range: 0..0,
56 with: with.into(),
57 },
58 }],
59 }
60}
61
62/// A cache another thread creates while this one reads its section, as the background
63/// makes a section's offline copy while the section opens, is opened instead of refused.
64#[test]
65fn a_cache_created_meanwhile_opens() {
66 let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap();
67 let directory = tempfile::tempdir().unwrap();
68 let path = directory.path().join("raced.sqlite");
69 let replica = Replica::open_or_create(&path, None, || {
70 drop(Replica::create(&path, &source)?);
71 Ok(source.clone())
72 })
73 .unwrap();
74 assert_eq!(replica.pages().unwrap()[0].1, "Raced");
75 drop(replica);
76 let reopened = Replica::open_or_create(&path, None, || unreachable!()).unwrap();
77 assert_eq!(reopened.pages().unwrap()[0].1, "Raced");
78}
79
80#[test]
81fn cache_reopen_preserves_the_base_and_queued_edits() {
82 let dir = tempfile::tempdir().unwrap();
83 let path = dir.path().join("section.sqlite");
84 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
85 let replica = Replica::create(&path, &source).unwrap();
86 assert_eq!(snapshot(&replica), source);
87 assert!(replica.pending().unwrap().is_empty());
88 let (sid, oid, _) = target(&source);
89 let unchanged = |page: &mut onestore::page::Page| {
90 model_ops::replace_text(page, oid, 0..0, "");
91 model_ops::replace_text(page, oid, 0..4, "café");
92 };
93 assert_eq!(model_ops::save(&replica, oid, unchanged).unwrap(), None);
94 let first = model_ops::save(&replica, oid, |page| {
95 model_ops::replace_text(page, oid, 5..7, "🐈 日本語")
96 })
97 .unwrap()
98 .unwrap();
99 assert_eq!(target(&snapshot(&replica)).2, "café 🐈 日本語");
100 let edits = replica.pending().unwrap();
101 assert_eq!(edits.len(), 1);
102 assert_eq!(
103 (edits[0].id, edits[0].author.as_str()),
104 (first, model_ops::AUTHOR)
105 );
106 assert!(matches!(
107 &edits[0].edit.ops[..],
108 [Op::Page { space, op: PageOp::Text { text, range, with } }]
109 if (*space, *text, range.clone(), with.as_str()) == (sid, oid, 5..7, "🐈 日本語")
110 ));
111 let beside = |suffix: &str| {
112 let mut file = path.as_os_str().to_owned();
113 file.push(suffix);
114 std::path::PathBuf::from(file)
115 };
116 // Commits go to the write-ahead log; its index stays in memory.
117 assert!(beside("-wal").exists());
118 assert!(!beside("-shm").exists());
119 drop(replica);
120 assert!(
121 !beside("-wal").exists(),
122 "closing checkpoints and removes the log"
123 );
124 let replica = Replica::open(&path).unwrap();
125 assert_eq!(target(&snapshot(&replica)).2, "café 🐈 日本語");
126 assert_eq!(replica.pending().unwrap(), edits);
127 let second = replica
128 .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered "))
129 .unwrap();
130 assert!(second > first);
131 assert_eq!(target(&snapshot(&replica)).2, "Recovered café 🐈 日本語");
132 assert_eq!(replica.pending().unwrap().len(), 2);
133}
134
135#[test]
136fn async_edit_receipts_reopen_with_the_same_id_and_text() {
137 use std::{
138 future::Future,
139 task::{Context, Poll, Waker},
140 };
141 let dir = tempfile::tempdir().unwrap();
142 let path = dir.path().join("async.sqlite");
143 let source = onestore::create_section("Async.one", "Receipt", "Fixture").unwrap();
144 let (space, text, _) = target(&source);
145 let replica = Replica::create(&path, &source).unwrap();
146 let id = {
147 let mut receipt =
148 std::pin::pin!(replica.apply_async("Author", typed(space, text, "Durable ")));
149 match receipt
150 .as_mut()
151 .poll(&mut Context::from_waker(Waker::noop()))
152 {
153 Poll::Ready(result) => result.unwrap(),
154 Poll::Pending => panic!("Native durability must finish without a pending wait"),
155 }
156 };
157 {
158 let mut flush = std::pin::pin!(replica.written_async());
159 assert!(matches!(
160 flush.as_mut().poll(&mut Context::from_waker(Waker::noop())),
161 Poll::Ready(Ok(()))
162 ));
163 }
164 drop(replica);
165 let reopened = Replica::open(&path).unwrap();
166 assert_eq!(reopened.pending().unwrap()[0].id, id);
167 assert_eq!(target(&reopened.snapshot().unwrap()).2, "Durable Receipt");
168}
169
170#[test]
171fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() {
172 let dir = tempfile::tempdir().unwrap();
173 let path = dir.path().join("section.sqlite");
174 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
175 let replica = Replica::create(&path, &source).unwrap();
176 let (sid, oid, _) = target(&source);
177 let empty = Outline {
178 id: onestore::page::text::new_id().unwrap(),
179 title: false,
180 min_width: None,
181 layout: onestore::document::Layout {
182 x: Some(72.0),
183 y: Some(400.0),
184 ..Default::default()
185 },
186 indents: Vec::new(),
187 paragraphs: Vec::new(),
188 unsupported: Vec::new(),
189 };
190 let refused = Edit {
191 at: model_ops::now(),
192 ops: vec![
193 Op::Page {
194 space: sid,
195 op: PageOp::Text {
196 text: oid,
197 range: 0..0,
198 with: "Undone ".into(),
199 },
200 },
201 Op::Page {
202 space: sid,
203 op: PageOp::Add {
204 object: PageObject::Outline(empty),
205 before: None,
206 },
207 },
208 ],
209 };
210 assert!(matches!(
211 replica.apply("Author", refused),
212 Err(Error::Rejected(_))
213 ));
214 assert!(matches!(
215 replica.apply("Author", typed(ExGuid::default(), oid, "Elsewhere ")),
216 Err(Error::Rejected(_))
217 ));
218 assert_eq!(snapshot(&replica), source);
219 assert!(replica.pending().unwrap().is_empty());
220 drop(replica);
221 let connection = rusqlite::Connection::open(&path).unwrap();
222 connection.execute_batch("CREATE TRIGGER fail_edit BEFORE INSERT ON edits BEGIN SELECT RAISE(ABORT, 'Injected queue failure'); END;").unwrap();
223 drop(connection);
224 let replica = Replica::open(&path).unwrap();
225 assert!(replica.apply("Author", typed(sid, oid, "lost? ")).is_err());
226 assert_eq!(snapshot(&replica), source);
227 drop(replica);
228 let replica = Replica::open(&path).unwrap();
229 assert_eq!(snapshot(&replica), source);
230 assert!(replica.pending().unwrap().is_empty());
231}
232
233#[test]
234fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
235 use notebook::Recovery;
236
237 let directory = tempfile::tempdir().unwrap();
238 let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap();
239 let (space, object, _) = target(&source);
240 let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();
241 let start = Barrier::new(4);
242 std::thread::scope(|scope| {
243 for writer in 0..3 {
244 let (replica, start) = (&replica, &start);
245 scope.spawn(move || {
246 start.wait();
247 for edit in 0..20 {
248 replica
249 .apply(
250 "Fixture",
251 typed(space, object, &format!("[{writer}:{edit}] ")),
252 )
253 .unwrap();
254 }
255 });
256 }
257 start.wait();
258 for n in 0..12 {
259 let path = directory.path().join(format!("recovery-{n}.sqlite"));
260 replica.export_recovery(&path).unwrap();
261 let archive = Recovery::open(path).unwrap();
262 let pending = archive.pending().unwrap();
263 // Each edit types at the start, so the text is the queue read backwards.
264 let expected: String = pending
265 .iter()
266 .rev()
267 .map(|edit| match &edit.edit.ops[..] {
268 [
269 Op::Page {
270 op: PageOp::Text { with, .. },
271 ..
272 },
273 ] => with.as_str(),
274 other => panic!("{other:?}"),
275 })
276 .chain(["base"])
277 .collect();
278 assert_eq!(target(&archive.snapshot().unwrap()).2, expected);
279 assert_eq!(archive.remote_snapshot().unwrap(), source);
280 assert_eq!(
281 archive.summary().unwrap().queued_edits,
282 pending.len() as u64
283 );
284 assert!(archive.receipts().unwrap().is_empty());
285 }
286 });
287 assert_eq!(replica.pending().unwrap().len(), 60);
288 let content = target(&snapshot(&replica)).2;
289 for writer in 0..3 {
290 for edit in 0..20 {
291 assert_eq!(content.matches(&format!("[{writer}:{edit}] ")).count(), 1);
292 }
293 }
294}
295
296#[test]
297fn ownership_and_foreign_file_rejection_preserve_existing_data() {
298 let dir = tempfile::tempdir().unwrap();
299 let path = dir.path().join("section.sqlite");
300 assert!(Replica::open(&path).is_err());
301 assert!(!path.exists());
302 assert!(Replica::create(&path, b"invalid").is_err());
303 assert!(!path.exists());
304 let source = onestore::create_section("section.one", "Owned", "Fixture").unwrap();
305 let replica = Replica::create(&path, &source).unwrap();
306 for _ in 0..3 {
307 assert!(
308 matches!(Replica::open(&path), Err(Error::Database(error)) if error.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy))
309 );
310 assert!(
311 matches!(Replica::create(&path, &source), Err(Error::Io(error)) if error.kind() == ErrorKind::AlreadyExists)
312 );
313 assert_eq!(snapshot(&replica), source);
314 }
315 drop(replica);
316 let current: u32 = rusqlite::Connection::open(&path)
317 .unwrap()
318 .pragma_query_value(None, "user_version", |row| row.get(0))
319 .unwrap();
320 for sql in [
321 "PRAGMA application_id=0".to_owned(),
322 // Schemas 14 and 15 convert; older caches do not.
323 format!(
324 "PRAGMA application_id=1330529615; PRAGMA user_version={}",
325 current - 3
326 ),
327 format!("PRAGMA user_version={}", current + 1),
328 ] {
329 let connection = rusqlite::Connection::open(&path).unwrap();
330 connection.execute_batch(&sql).unwrap();
331 drop(connection);
332 let before = fs::read(&path).unwrap();
333 let Err(Error::Io(error)) = Replica::open(&path) else {
334 panic!("{sql}")
335 };
336 assert_eq!(error.kind(), ErrorKind::InvalidData, "{sql}");
337 if sql.contains("user_version") {
338 let written = sql.rsplit('=').next().unwrap();
339 let message = error.to_string();
340 assert!(
341 message.contains(&format!("version {written} "))
342 && message.contains(&format!("version {current}")),
343 "{message}"
344 );
345 }
346 assert!(
347 matches!(notebook::Recovery::open(&path), Err(Error::Io(error)) if error.kind() == ErrorKind::InvalidData),
348 "{sql}"
349 );
350 assert_eq!(fs::read(&path).unwrap(), before);
351 }
352 let foreign = dir.path().join("foreign.sqlite");
353 let connection = rusqlite::Connection::open(&foreign).unwrap();
354 connection
355 .execute_batch(
356 "CREATE TABLE unrelated (value TEXT); INSERT INTO unrelated VALUES ('preserve');",
357 )
358 .unwrap();
359 drop(connection);
360 let before = fs::read(&foreign).unwrap();
361 assert!(Replica::open(&foreign).is_err());
362 assert_eq!(fs::read(&foreign).unwrap(), before);
363 let incomplete = dir.path().join("incomplete.sqlite");
364 fs::write(&incomplete, []).unwrap();
365 assert!(Replica::open(&incomplete).is_err());
366 assert_eq!(fs::read(&incomplete).unwrap(), b"");
367}
368
369#[test]
370fn twelve_local_editors_queue_every_edit_once_in_order() {
371 let dir = tempfile::tempdir().unwrap();
372 let path = dir.path().join("section.sqlite");
373 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();
374 let replica = Replica::create(&path, &source).unwrap();
375 let (sid, oid, _) = target(&source);
376 let barrier = Barrier::new(12);
377 let outcomes = std::thread::scope(|scope| {
378 let handles: Vec<_> = (0..12)
379 .map(|writer| {
380 let (replica, barrier) = (&replica, &barrier);
381 scope.spawn(move || {
382 barrier.wait();
383 (0..20)
384 .map(|edit| {
385 replica
386 .apply("Fixture", typed(sid, oid, &format!("[{writer}-{edit}] ")))
387 .unwrap()
388 })
389 .collect::<Vec<_>>()
390 })
391 })
392 .collect();
393 handles
394 .into_iter()
395 .map(|handle| handle.join().unwrap())
396 .collect::<Vec<_>>()
397 });
398 for ids in &outcomes {
399 assert!(
400 ids.windows(2).all(|pair| pair[0] < pair[1]),
401 "a writer's edits keep its order"
402 );
403 }
404 let ids: BTreeSet<_> = outcomes.into_iter().flatten().collect();
405 assert_eq!(ids.len(), 240);
406 let content = target(&snapshot(&replica)).2;
407 assert!(content.ends_with("Shared café 🦀"));
408 for writer in 0..12 {
409 for edit in 0..20 {
410 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);
411 }
412 }
413 let pending = replica.pending().unwrap();
414 assert_eq!(
415 pending.iter().map(|edit| edit.id).collect::<BTreeSet<_>>(),
416 ids
417 );
418 drop(replica);
419 let reopened = Replica::open(&path).unwrap();
420 assert_eq!(target(&snapshot(&reopened)).2, content);
421 assert_eq!(reopened.pending().unwrap(), pending);
422}
423
424#[test]
425fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
426 let dir = tempfile::tempdir().unwrap();
427 let path = dir.path().join("model.sqlite");
428 let mut text = "ab🚀ab🦀 é repeated repeated".to_owned();
429 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();
430 let mut replica = Replica::create(&path, &source).unwrap();
431 let (_, object, _) = target(&source);
432 let steps = sweep::seeds(0..512, 64);
433 let mut random = 911 + steps.start;
434 let mut next = || {
435 random ^= random << 13;
436 random ^= random >> 7;
437 random ^= random << 17;
438 random
439 };
440 let mut acknowledged = Vec::new();
441 for step in steps.clone() {
442 let boundaries: Vec<_> = text
443 .char_indices()
444 .map(|(at, _)| at)
445 .chain([text.len()])
446 .collect();
447 let first = boundaries[next() as usize % boundaries.len()];
448 let last = boundaries[next() as usize % boundaries.len()];
449 let bytes = first.min(last)..first.max(last);
450 let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap()
451 ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap();
452 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
453 let mut expected = text.clone();
454 expected.replace_range(bytes, replacement);
455 let acknowledgement = model_ops::save(&replica, object, |page| {
456 model_ops::replace_text(page, object, range.clone(), replacement)
457 })
458 .unwrap();
459 match acknowledgement {
460 Some(id) => {
461 assert_ne!(text, expected);
462 assert!(acknowledged.last().is_none_or(|last| *last < id));
463 acknowledged.push(id);
464 }
465 None => assert_eq!(text, expected),
466 }
467 text = expected;
468 assert_eq!(target(&snapshot(&replica)).2, text, "step {step}");
469 if step % 37 == 0 {
470 let pending = replica.pending().unwrap();
471 drop(replica);
472 replica = Replica::open(&path).unwrap();
473 assert_eq!(target(&snapshot(&replica)).2, text);
474 assert_eq!(replica.pending().unwrap(), pending);
475 }
476 }
477 assert!(
478 acknowledged.len() as u64 * 2 > steps.end - steps.start,
479 "Most random edits change the text"
480 );
481 assert_eq!(
482 replica
483 .pending()
484 .unwrap()
485 .iter()
486 .map(|edit| edit.id)
487 .collect::<Vec<_>>(),
488 acknowledged
489 );
490}
491
492#[test]
493fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
494 let directory = tempfile::tempdir().unwrap();
495 let path = directory.path().join("parallel.sqlite");
496 let source = onestore::create_section("parallel.one", "Original", "Author").unwrap();
497 let (sid, anchor, _) = target(&source);
498 let cache = Replica::create(&path, &source).unwrap();
499 let barrier = Barrier::new(12);
500 let deadline = Instant::now() + Duration::from_secs(90);
501 let italic = |format: &mut onestore::document::Format| format.italic = Some(true);
502 let all = std::thread::scope(|scope| {
503 let handles: Vec<_> = (0..12)
504 .map(|client| {
505 let (cache, barrier) = (&cache, &barrier);
506 scope.spawn(move || {
507 let mut ids = Vec::new();
508 let mut objects = Vec::new();
509 barrier.wait();
510 for sequence in 0..4 {
511 assert!(Instant::now() < deadline, "Client {client} stopped");
512 let content = if sequence == 0 {
513 format!("Client {client}")
514 } else {
515 format!("Paragraph {client}:{sequence}")
516 };
517 let mut inserted = None;
518 let id = model_ops::save(cache, anchor, |page| {
519 let text = if sequence == 0 {
520 model_ops::insert_outline(
521 page,
522 72.0,
523 144.0 + client as f32 * 72.0,
524 &content,
525 )
526 .2
527 } else {
528 model_ops::insert_after(page, *objects.last().unwrap(), &content).1
529 };
530 let end = content.encode_utf16().count() as u32;
531 model_ops::restyle(page, text, 0..end, |format| format.italic = None);
532 model_ops::restyle(page, text, 0..6, italic);
533 inserted = Some(text);
534 })
535 .unwrap()
536 .unwrap();
537 ids.push(id);
538 objects.push(inserted.unwrap());
539 if sequence == 0 {
540 continue;
541 }
542 let text = *objects.last().unwrap();
543 ids.push(
544 cache
545 .apply(model_ops::AUTHOR, typed(sid, text, "Edited "))
546 .unwrap(),
547 );
548 }
549 (ids, objects)
550 })
551 })
552 .collect();
553 handles
554 .into_iter()
555 .map(|h| h.join().unwrap())
556 .collect::<Vec<_>>()
557 });
558 let ids: BTreeSet<_> = all
559 .iter()
560 .flat_map(|(ids, _)| ids.iter().copied())
561 .collect();
562 assert_eq!(ids.len(), 84);
563 let image = snapshot(&cache);
564 drop(cache);
565 let cache = Replica::open(&path).unwrap();
566 assert_eq!(server::pages(&snapshot(&cache)), server::pages(&image));
567 assert_eq!(
568 cache
569 .pending()
570 .unwrap()
571 .iter()
572 .map(|e| e.id)
573 .collect::<BTreeSet<_>>(),
574 ids
575 );
576 let store = Store::parse(&image).unwrap();
577 let index = RevisionIndex::parse(&store).unwrap();
578 let doc = Document::parse(&index).unwrap();
579 let s = &doc.spaces[&sid];
580 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
581 for (client, (_, objects)) in all.iter().enumerate() {
582 for (sequence, id) in objects.iter().enumerate() {
583 let wanted = if sequence == 0 {
584 format!("Client {client}")
585 } else {
586 format!("Edited Paragraph {client}:{sequence}")
587 };
588 assert!(matches!(&v.nodes[id].kind,Kind::RichText{text,..} if *text==wanted));
589 let runs = v.text_runs(*id).unwrap();
590 assert_eq!(runs[0].format.italic, Some(true));
591 assert_eq!(
592 runs[0].text,
593 if sequence == 0 {
594 "Client"
595 } else {
596 "Edited Paragr"
597 }
598 );
599 assert!(runs[1..].iter().all(|run| run.format.italic != Some(true)));
600 }
601 }
602}
603
604#[test]
605fn unrecognized_persisted_ops_are_rejected_without_dropping_fields() {
606 for kind in ["Text", "Create", "Pages", "Delete"] {
607 let directory = tempfile::tempdir().unwrap();
608 let path = directory.path().join("unknown.sqlite");
609 let first = onestore::create_section("unknown.one", "Original", "Author").unwrap();
610 let second = onestore::PageCreation::new(None, Some("Second"), "Author").unwrap();
611 let source = ops::section_op(&first, SectionOp::Create(second.clone()))
612 .unwrap()
613 .as_bytes()
614 .to_vec();
615 let (space, oid, _) = target(&source);
616 let store = Store::parse(&source).unwrap();
617 let index = RevisionIndex::parse(&store).unwrap();
618 let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0;
619 let cache = Replica::create(&path, &source).unwrap();
620 let section = |op| {
621 server::section_op(&cache, op);
622 };
623 match kind {
624 "Text" => {
625 cache.apply("Author", typed(space, oid, "New ")).unwrap();
626 }
627 "Create" => section(SectionOp::Create(
628 onestore::PageCreation::new(None, Some("Created"), "Author").unwrap(),
629 )),
630 "Pages" => section(SectionOp::Pages(vec![
631 onestore::PageEdit::set_level(sid, 2).unwrap(),
632 ])),
633 _ => section(SectionOp::Delete(vec![sid])),
634 }
635 drop(cache);
636 let encoded: String = rusqlite::Connection::open(&path)
637 .unwrap()
638 .query_row("SELECT edit FROM edits", [], |r| r.get(0))
639 .unwrap();
640 for target in ["", "/ops/0", "/ops/0/Page/op/Text", "/ops/0/Section"] {
641 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
642 let Some(object) = value.pointer_mut(target).and_then(|v| v.as_object_mut()) else {
643 continue;
644 };
645 object.insert("future_option".into(), true.into());
646 rusqlite::Connection::open(&path)
647 .unwrap()
648 .execute("UPDATE edits SET edit=?1", [value.to_string()])
649 .unwrap();
650 let before = fs::read(&path).unwrap();
651 assert!(
652 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData),
653 "{kind} {target}"
654 );
655 assert_eq!(fs::read(&path).unwrap(), before);
656 }
657 }
658}