authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 02:02:03-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logcc5b6a1bca8a59e2c06208c9086e7b002f0e95df
tree29e15310e4918861a3a9608f78a186567619185b
parent2e2f9466aec89c2ab7fc03378e3cbf975ffd7f9d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

refactor: replace per-operation intents with page saves

The notebook replica no longer records typed editing operations. The editor saves whole page models through Replica::save, which diffs the model against the stored page, writes the difference into the working image and queues one Operation::Page { before, after, author }; create_page, pages and delete_pages remain the explicit section operations. The per-operation modules (paragraph, formatting, outline, tree) and their conflict-review entry points are gone, replaced by review_page. Saves coalesce the way OneNote autosave does: a save for the same page as the newest unattempted intent replaces its model under the same id. An attempted or conflicting intent is never rewritten, and neither is the intent a running synchronization step has selected but not yet recorded an attempt for; that window would otherwise publish a stale model over an acknowledged save. Reconciliation of a page intent against a changed remote page is a three-way merge that picks each field per side: outline layout (position; width, its user flag and the wrap reservation as one group), paragraph style, lists, tags, parent and level, text content, and non-text content such as table cells. A local text edit re-applies over a remote formatting-only change of the same paragraph when its own formatting outside the replaced range is untouched. Child order takes the remote list and re-places the paragraphs the local side moved (the members outside a longest common subsequence with the stored order); a paragraph both sides moved, or that the local side moved and the remote removed or reparented, is a conflict rather than a silent loss. Paragraphs reparented under a locally added paragraph, as a split of a parent does, are placed under it. The cache schema is version 12 and any other version is refused, naming both versions, for caches and recovery archives alike; migrations return once the application is usable end to end. The offline-v4 corpus and the migration tests are removed. Tests are re-expressed on page saves through tests/support/model_ops.rs and split into one binary per subject (sync, sync_worker, sync_paragraph, sync_formatting, sync_outline with tree, sync_page, sync_pages, sync_model) sharing tests/support/server.rs. Tests whose subject was a per-operation mechanism (typed rebases, retired-format attribute confirmation, insertion placement review, tree attempt bookkeeping) are deleted. The tree schedule driver and the offline_paragraph/offline_tree fuzz targets are replaced by a page-model schedule (tests/support/model_schedule.rs, fuzz target offline_model). The per-operation document workload of the smb_offline_client example and its Python history check are removed pending a page-model design. The native reconciliation exporters still write the paragraph-edit manifests; the outline-edit and paragraph-edit corpus records and their Python expectations reflect the per-operation runs and are regenerated when the native gates rerun on intents. Verification: cargo fmt, cargo clippy --workspace --all-features --all-targets, cargo nextest run --workspace --all-features (431 passed, 32 skipped, 1 expectation fixed afterwards and rerun), doctests, fuzz crate check, Python lane (162 passed). Assisted-by: claude-fable-5.1

44 files changed, 6527 insertions(+), 9663 deletions(-)

corpus/offline-v4/README.md deleted-15
......@@ -1,15 +0,0 @@
1# Original schema-4 offline state
2
3These databases were produced by `onestore-offline` at revision `2244711b`,
4before the external-media table and schema-5 migration existed. Tests copy the
5live database before opening it; the archive remains read-only.
6
7[`producer.rs`](producer.rs) records the original producer. Run it against that
8revision's core/offline crates, from a checkout whose `corpus/offline-v4` does
9not exist. It uses the public native external-asset fixture, queues a text edit,
10simulates a publication with a lost response, queues a dependent text edit, and
11exports the complete recovery archive. Generated revision IDs vary between runs.
12
13[`provenance.json`](provenance.json) records the original source/database hashes
14and the retained uncertain and pending edit IDs. No credentials or personal
15notebook content are included.
corpus/offline-v4/live.sqlite deleted
Binary files a/corpus/offline-v4/live.sqlite and /dev/null differ
corpus/offline-v4/producer.rs deleted-33
......@@ -1,33 +0,0 @@
1use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}};
2use onestore_offline::{Remote, Replica, Recovery};
3use std::{fs, io, path::Path};
4struct LostReply(Vec<u8>);
5impl Remote for LostReply {
6 fn read(&mut self) -> io::Result<Vec<u8>> { Ok(self.0.clone()) }
7 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
8 self.0 = edit.as_bytes().to_vec();
9 Err(CommitError { state: CommitState::Unknown, error: io::ErrorKind::ConnectionReset.into() })
10 }
11 fn confirm(&mut self, _: &[u8]) -> Result<(), CommitError> { panic!("No confirmation is expected") }
12}
13fn main() -> Result<(), Box<dyn std::error::Error>> {
14 let root = Path::new("corpus/offline-v4"); fs::create_dir(root)?;
15 let source = fs::read("corpus/native-external-assets/notebook/synthetic.one")?;
16 let store = Store::parse(&source)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?;
17 let (space, object) = document.spaces.iter().find_map(|(sid, space)| {
18 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
19 view.nodes.iter().find_map(|(oid,node)| matches!(&node.kind,Kind::RichText { text, .. } if text == "Native before 🦀").then_some((*sid,*oid)))
20 }).unwrap();
21 let cache = Replica::create(root.join("live.sqlite"), &source)?;
22 let first = cache.edit_text(&source, space, object, 0..0, "queued ")?.unwrap();
23 assert!(cache.sync_once(&mut LostReply(source.clone())).is_err());
24 let working = cache.snapshot()?;
25 let second = cache.edit_text(&working, space, object, 0..0, "dependent ")?.unwrap();
26 cache.export_recovery(root.join("recovery.sqlite"))?;
27 let recovery = Recovery::open(root.join("recovery.sqlite"))?;
28 assert_eq!(cache.pending()?, recovery.pending()?);
29 assert_eq!(recovery.summary()?.queued_edits, 2);
30 assert_eq!(recovery.summary()?.uncertain_edits, 1);
31 println!("Original schema-4 producer: IDs {first}, {second}; {:?}", recovery.summary()?);
32 Ok(())
33}
corpus/offline-v4/provenance.json deleted-20
......@@ -1,20 +0,0 @@
1{
2 "producer_revision": "2244711b",
3 "producer": "corpus/offline-v4/producer.rs",
4 "source_sha256": {
5 "crates/onestore-offline/src/lib.rs": "f4b6bdc9b8269e43cc8c1d69f5becb3140905f520438da53fb96ce1663352f22",
6 "crates/onestore-offline/src/schema.rs": "b20d6c127b32dc378c54ef8179583782d37b79496c4056899d053881034b9963",
7 "crates/onestore-offline/src/sync.rs": "3967d304c42550350c7fe2659317d665b8c4d7cdfd42f3eb013180ca91177442",
8 "crates/onestore-offline/src/recovery.rs": "1301b649085670e847b4593bc308f9a1e25713a99b5333d4fd217014720d29c3"
9 },
10 "files": {
11 "recovery.sqlite": "2946f7b2f2636b4dc80b2d3fd930dfe1d5bdf3cafd591d5c33d50be209685a76",
12 "live.sqlite": "ff0b2ad1141398c10913d3f573ba909aa7cc6e7b3fb809ecd3d9869b689ff970"
13 },
14 "queued_ids": [
15 1,
16 2
17 ],
18 "uncertain_id": 1,
19 "archive_version": 4
20}
corpus/offline-v4/recovery.sqlite deleted
Binary files a/corpus/offline-v4/recovery.sqlite and /dev/null differ
crates/notebook/README.md+87-165
......@@ -2,52 +2,65 @@
22
33The application-facing crate: notebook discovery, a durable local replica with
44reconnect reconciliation, external-asset caching, recovery export and optional
5embedded SMB access. Durable local editing for a OneNote file: the current
6foundation stores a complete working image and typed editing intents in a local
7SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns
8automatic polling and reconnects. Local success does not
9acknowledge publication to a shared notebook.
5embedded SMB access. The replica stores a complete working image of one section
6and the editor's intents in a local SQLite database. `sync_once` provides a
7reconciliation step and `start_sync` owns automatic polling and reconnects. Local
8success does not acknowledge publication to a shared notebook.
9
10## Saving pages
11
12The editor works on `onestore::page::Page` values and saves whole pages. `save`
13diffs the supplied model against the page stored in the supplied local snapshot,
14writes the difference into the working image and queues one
15`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
16edit started from and is the precondition reconciliation checks. Text, styles,
17paragraph structure, outline layout, insertions and deletions are all differences
18between `before` and `after`; the library never sees editor operations.
1019
1120```no_run
12use onestore::ExGuid;
21use onestore::{ExGuid, page::Page};
1322use notebook::Replica;
14# fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid)
23# fn example(path: &std::path::Path, source: &[u8], space: ExGuid, edited: &Page)
1524# -> Result<(), Box<dyn std::error::Error>> {
16// `space` and `text` are identities from the supplied section's document model.
25// `space` identifies the page; `edited` is the editor's current model of it.
1726let cache = Replica::create(path, source)?;
1827let snapshot = cache.snapshot()?;
19let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?;
28let local_id = cache.save(&snapshot, space, edited, "Author")?;
2029drop(cache);
2130
2231let reopened = Replica::open(path)?;
23let current = reopened.snapshot()?;
2432let pending = reopened.pending()?;
2533assert_eq!(pending.last().map(|edit| edit.id), local_id);
2634# Ok(())
2735# }
2836```
2937
30`insert` accepts the core library's `Insertion` value and durably retains its
31intent identities. `Insertion::with_formatting` queues text and
32its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies
33the new text for subsequent offline edits. Pending entries expose
34`Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`,
35`Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`,
36`Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)`,
37`Operation::Tree(TreeEdit)`, `Operation::CreatePage(PageCreation)` and
38`Operation::Pages(PageEdits)` through their
39`operation` field. Synchronization applies these in queue order, so an inserted
40outline can precede its paragraphs and their later edits. Missing anchors or
41existing insertion identities preserve a conflict and the complete local image.
38Saves coalesce the way OneNote's own autosave does: while the newest queued intent
39for the same page has not been attempted, a later save replaces its `after` model
40under the same ID. Once an intent has been attempted or holds a conflict it is
41never rewritten, nor is the intent a synchronization step has selected for
42publication; the next save then queues a new intent. A save whose model equals
43the stored page returns `None`. `PageIntent::text_change` describes an intent that
44changes exactly one paragraph's text as the text object, its previous text, the
45replaced UTF-16 range and the replacement.
46
47Share one `Replica` between application threads. Each save compares its supplied
48snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
49The intent and its resulting image commit together. Keep the cache on a local
50filesystem: the connection holds exclusive ownership between transactions, and a
51second open fails busy. No network wait occurs in a local save. After a database
52error, reopen and inspect the durable state before retrying.
53
54## Pages of a section
4255
4356`create_page` accepts the core `PageCreation` intent and queues its page space
44and section entry as one publication. Subsequent outlines and title edits use
45the intent's stable identities immediately after local acknowledgement.
46Independent page additions rebase against the current section order; duplicate
47titles remain distinct. An unavailable or no-longer-leading insertion anchor
48produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote,
49before)` reviews a replacement anchor against both cache images while retaining
50the new page and dependent object identities. Existing page identities require
57and section entry as one publication. Subsequent saves of the new page use the
58intent's stable identities immediately after local acknowledgement. Independent
59page additions rebase against the current section order; duplicate titles remain
60distinct. An unavailable or no-longer-leading insertion anchor produces
61`StructureChanged`. `rebase_page_creation_conflict(id, local, remote, before)`
62reviews a replacement anchor against both cache images while retaining the new
63page and dependent object identities. Existing page identities require
5164reconciliation; a matching page alone does not establish a receipt.
5265
5366`pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic
......@@ -57,149 +70,59 @@ edits. Every requested level remains explicit: a competing remote level produces
5770the selected page's position relative to surviving observed pages; an unchanged
5871or already-satisfied position can proceed, and new remote pages remain present.
5972Indistinguishable competing moves retain a conflict and the complete local image.
60
6173`rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both
6274cache images. Use `PageEdit::reposition(position, level)` on the retained intents
6375to revise anchors or indentation; replacing page or allocated series identities
64is rejected. Dependent edits remain queued. Schema 11 adds the durable batch;
65schema 10 migration preserves existing multi-space publication evidence unchanged.
66Attempt evidence includes every changed space plus the receipt space when that
67space is unchanged. An existing section revision alone cannot confirm a page edit.
68The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md)
76is rejected. Dependent edits remain queued. Attempt evidence includes every
77changed space plus the receipt space when that space is unchanged. An existing
78section revision alone cannot confirm a page edit. The
79[offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md)
6980contains native comparisons, reproduction commands and stateful sanitizer seeds.
7081
71`split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents.
72Splits retain allocated identities when the original UTF-16 boundary rebases;
73dependent edits can address `ParagraphSplit::text_object()` immediately after
74local acknowledgement. Both operations retain observed parent paths, children,
75indentation, paragraph/list state and tags. Changed structure produces
76`StructureChanged`. Remote text changes must leave unambiguous boundary mappings;
77current remote character styles are preserved. Reconciliation does not silently
78discard a newly added right tag or move an unobserved child.
79
80`rebase_conflict` accepts a zero-length reviewed range for a split, preserving its
81allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original
82join against current images. It rejects a change in which text identity survives,
83because dependent edits still address that identity. As with other conflict
84reviews, stale images, pending operations and uncertain attempts cannot be rebased.
85An uncertain structural operation requires its original attempted revision for
86confirmation; matching text or structure does not establish a receipt.
87
88`rebase_paragraph_conflict(id, local, remote, parent, before)` and
89`rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement
90placements for the oldest insertion conflict. They preserve creation time, text,
91author and object identities, keeping later edits attached to their original targets.
92The images must still match the cache; the new placement must be valid in the remote
93image. Paragraph intents cannot become outlines or vice versa. Pending edits and
94uncertain publication attempts cannot be repositioned through conflict review.
95
96```no_run
97use onestore::{ExGuid, Insertion, TextAttribute};
98use notebook::{EditStatus, Replica};
99# fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid)
100# -> Result<(), Box<dyn std::error::Error>> {
101let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")?
102 .with_formatting(0..7, &[TextAttribute::Bold(true)])?;
103let id = cache.insert(&cache.snapshot()?, space, &outline)?;
104if let Some(id) = id {
105 // A running worker may already have advanced this state.
106 match cache.status(id)? {
107 Some(EditStatus::Published { revision }) => println!("{revision}"),
108 state => println!("{state:?}"),
109 }
110}
111# Ok(())
112# }
113```
82`delete_pages(snapshot, pages)` queues the permanent removal of page spaces;
83a page already absent remotely produces `TargetUnavailable`.
84
85## Reconciliation
86
87`sync_once(&mut remote)` processes the oldest pending intent through a `Remote`
88implementation, returning its ID and `EditStatus`. When the remote page still
89equals `before`, the intent publishes as prepared. When the remote page changed,
90a three-way merge keeps everything the remote changed and re-applies the local
91changes wherever the two sides touched different objects, fields or text ranges:
92a local text edit merges with a remote edit elsewhere in the same paragraph, an
93outline move merges with a remote text change, a new paragraph survives a remote
94deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with
95the complete local image and the last observed remote image returned by
96`remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a
97page the writer can no longer express retains `UnsupportedEdit`.
98
99Publication attempts are recorded before network I/O. A retained attempt
100requires its recorded revision to remain present in the remote, or the remote
101page to equal the intent's `after` model exactly, followed by comparison,
102flushing and refreshed header version metadata before acknowledgement. Otherwise
103the attempt remains `AwaitingConfirmation`; an uncertain publication is never
104replayed. A durable `Published` receipt survives reopening. Transport errors
105return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error
106to distinguish a retained attempt from a pending edit or receipt. The error return
107does not roll back a locally acknowledged intent.
114108
115`format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent
116retains the observed text and selected attribute values. Remote text changes must
117leave an unambiguous range; independent remote attributes merge, while competing
118values preserve `FormattingChanged`. A remote value that already matches the
119requested value is accepted. Enabling superscript or subscript also checks the
120opposite attribute that the operation clears. If the remote image already satisfies
121the whole operation, guarded confirmation still precedes a durable receipt.
122
123`outline` accepts a target object and the core `onestore::OutlineEdit`: outline
124position/width or a paragraph's saved collapse default. It retains the target's
125ancestry and the properties the operation changes. Independent content, formatting
126and layout properties merge; competing selected values produce `LayoutChanged`,
127and changed ancestry produces `StructureChanged`. Width changes also check the
128reserved wrapping width that they clear. Missing targets preserve the local branch.
129`rebase_layout_conflict(id, local, remote)` explicitly reviews the original change
130against both current images, preserving dependent intents. An uncertain layout
131attempt requires its original revision; converged values cannot establish its receipt.
132
133`tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote
134text, formatting and descendants. A competing ancestor, indentation or sibling
135crossing produces `StructureChanged`; unrelated sibling insertions/deletions can
136merge. An anchor must remain a direct child of the requested destination. An
137already satisfied move still requires guarded confirmation before acknowledgement.
138Deletion compares the selected raw property graph, including referenced styles,
139tags, unknown fields and internal attachments. Changed content produces
140`ContentChanged`; property order, CompactID numbering and modification timestamps
141do not affect that comparison. Immutable records compare by content, and empty
142child lists compare equally to absent child lists. Mutable object identities
143remain significant. Missing targets retain `TargetUnavailable`.
144`rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against
145both current images. An emptied cell's replacement paragraph/text identities must
146remain the same before replay or review; later queued edits keep their targets.
147Uncertain tree attempts require their original revision for confirmation, even
148when an independent move or deletion has the same visible effect.
149
150Recognized earlier caches migrate transactionally to version ten. Publication
151attempts retain every changed space's revision; legacy attempts retain their
152single-space evidence. Earlier recovery archives remain readable without migration.
153Version-eight
154deletion observations retain their original identity-sensitive preconditions;
155explicit conflict review upgrades them to the immutable-content comparison.
156The migration retains images, local IDs, publication attempts, conflicts,
157receipts and the autoincrement sequence; it does not reuse acknowledged IDs when
158the pending queue is empty.
159
160Share one `Replica` between application threads. Each edit compares its supplied
161snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
162The intent and its resulting image commit together. No-op edits return `None`.
163Keep the cache on a local filesystem: the connection holds exclusive ownership
164between transactions, and a second open fails busy. No network wait occurs in a
165local edit. After a database error, reopen and inspect the durable state before
166retrying.
167
168`sync_once(&mut remote)` processes the oldest pending edit through a `Remote`
169implementation, returning its ID and `EditStatus`. A durable `Published` receipt
170survives reopening. Publication attempts are recorded before network I/O; a retained
171attempt requires every recorded revision to remain present, followed by comparison,
172flushing and refreshed header version
173metadata before acknowledgement. If a formatting attempt's revision is missing,
174the complete requested effect can instead be confirmed on a uniquely aligned
175range; its receipt identifies that confirmed current revision. Otherwise the
176missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain
177publication. Overlapping or ambiguous edits retain `Conflict` status, their complete
178local image and the last observed remote image returned by `remote_snapshot`.
179Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)`
180after the error to distinguish a retained attempt from a pending edit or receipt.
181The error return does not roll back a locally acknowledged intent.
182
183Rebasing accepts only character mappings shared by every minimum insertion/deletion
184alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded
185alignment search also leaves a conflict when it cannot establish a unique mapping.
186109The current operation processes one queue head; while edits remain, `snapshot`
187110preserves the complete local working image. An empty queue can refresh from the
188remote image. Synchronization holds a separate owner lock, so local edits can
111remote image. Synchronization holds a separate owner lock, so local saves can
189112continue during network waits; competing synchronization calls return `WouldBlock`.
190113
191`rebase_conflict(id, local, remote, range)` lets a caller explicitly place the
192oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image.
193The supplied images must still match `snapshot()` and `remote_snapshot()`.
194It preserves the original replacement or requested attributes, intent ID, complete local working image
195and every later intent; the selected range and remote paragraph become the
196intent's new comparison base in one local transaction. Formatting also captures
197the reviewed attribute values as its new precondition. This operation performs
198no network I/O, clears the conflict to `Pending`, and wakes the worker.
199Publication still reads the latest remote image and uses exact guarded comparison;
200another overlapping remote edit can produce a new conflict. An uncertain attempt
201cannot be rebased, and selecting text that already equals the replacement does
202not create a publication acknowledgement.
114`review_page(id, local, remote, after)` resolves the oldest page conflict with a
115model the user reviewed against the current remote page: the intent's `before`
116becomes that remote page and its `after` the reviewed model, under the same ID,
117in one local transaction. The supplied images must still match `snapshot()` and
118`remote_snapshot()`. Review performs no network I/O, clears the conflict to
119`Pending` and wakes the worker; publication still reads the latest remote image,
120so another overlapping remote edit can produce a new conflict. Pending intents
121and uncertain attempts cannot be reviewed.
122
123Opening recognizes the application identity and the current schema version only;
124caches and archives written by other versions are refused unchanged. Until the
125application is usable end to end there are no migrations.
203126
204127With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an
205128`notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root
......@@ -273,7 +196,7 @@ the library continues to preserve conflicts requiring an explicit decision.
273196
274197## Recovery archives
275198
276`export_recovery(new_path)` captures both complete notebook images, the typed
199`export_recovery(new_path)` captures both complete notebook images, the intent
277200queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
278201SQLite snapshot. It refuses existing destinations and leaves the live queue
279202unchanged. Export to a local directory from a background thread: copying holds
......@@ -322,7 +245,6 @@ identity return `AssetChanged` and preserve the previous download.
322245
323246Downloads do not change pending edits, publication attempts or receipts. Recovery
324247archives include cached media and expose the same bounded `cached_asset` lookup.
325Schema-4 archives remain readable without migration and contain no media cache.
326248
327249## Discovery
328250
......@@ -378,7 +300,7 @@ Paths are relative to the share. The read limit bounds the complete physical
378300snapshot. Call from a background thread outside a Tokio runtime. Use identities
379301from the document and the same snapshot with `Client::commit_text` or
380302`Client::commit_property_bytes`; their errors retain `onestore::CommitState`.
381`PreparedEdit::{text,insert,format}` separate preparation from I/O: inspect the immutable image
303`PreparedEdit::page` separates preparation from I/O: inspect the immutable image
382304and persist the intended revision identity before `Client::commit_prepared`.
383305`Client::confirm_snapshot` compares and flushes an observed image, then refreshes
384306its header version metadata without adding a revision. The caller must first
crates/notebook/examples/cache_probe.rs+131-84
......@@ -1,7 +1,8 @@
1use notebook::Replica;
1use notebook::{Operation, Replica};
22use onestore::{
3 ExGuid, Insertion, RevisionIndex, Store, TextAttribute,
3 ExGuid, RevisionIndex, Store,
44 document::{Document, Kind},
5 page::{Page, PageObject, Paragraph, text::Edit},
56};
67use std::{
78 io::{self, BufRead, Write},
......@@ -85,68 +86,70 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
8586 let mut ids = Vec::new();
8687 let mut font_size = None;
8788 for pending in cache.pending()? {
88 let operation = match pending.operation {
89 notebook::Operation::Text(edit) => {
90 assert_eq!(operation_kind, "text");
91 assert_eq!(edit.object, target);
92 assert_eq!(edit.before, expected);
93 assert_eq!(
94 edit.range,
95 0..u32::try_from(expected.encode_utf16().count())?
96 );
97 let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?;
89 let Operation::Page(intent) = &pending.operation else {
90 panic!("Unexpected operation for this fixture")
91 };
92 let operation = match operation_kind.as_str() {
93 "text" => {
94 let (object, before, range, replacement) =
95 intent.text_change().expect("one replaced paragraph");
96 assert_eq!(object, target);
97 assert_eq!(before, expected);
98 assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?);
99 let operation: u64 = replacement.split_once(':').unwrap().0.parse()?;
98100 expected = payload(operation, size);
99 assert_eq!(edit.replacement, expected);
101 assert_eq!(replacement, expected);
100102 operation
101103 }
102 notebook::Operation::Insert(insertion) => {
103 assert_eq!(operation_kind, "insert");
104 let Kind::RichText { text, .. } =
105 &revision.nodes[&insertion.text_object()].kind
106 else {
107 panic!("Missing inserted text")
104 "insert" => {
105 let added: Vec<_> = intent
106 .after
107 .objects
108 .iter()
109 .filter(|object| {
110 !intent.before.objects.iter().any(|o| o.id() == object.id())
111 })
112 .collect();
113 let [PageObject::Outline(outline)] = added.as_slice() else {
114 panic!("Expected one added outline")
108115 };
116 let text = outline.paragraphs[0].text().unwrap().text.text();
109117 let operation: u64 = text.split_once(':').unwrap().0.parse()?;
110 assert_eq!(*text, payload(operation, size));
111 assert_eq!(serde_json::to_value(&insertion)?["text"], *text);
112 let outline = &revision.nodes[&insertion.object()];
113 assert!(matches!(outline.kind, Kind::Outline { .. }));
118 assert_eq!(text, payload(operation, size));
114119 assert_eq!(
115120 (outline.layout.x, outline.layout.y),
116121 (Some(144.0), Some(operation as f32 * 72.0))
117122 );
118 let (_, page) = document
119 .pages()?
120 .into_iter()
121 .find(|(space, _)| *space == sid)
122 .unwrap();
123 assert!(revision.nodes[&page].children.contains(&insertion.object()));
123 assert!(revision.nodes.contains_key(&outline.id));
124124 operation
125125 }
126 notebook::Operation::Format(edit) => {
127 assert_eq!(operation_kind, "format");
128 assert_eq!(edit.object, target);
129 assert_eq!(edit.before, expected);
130 assert_eq!(
131 edit.range,
132 0..u32::try_from(expected.encode_utf16().count())?
126 "format" => {
127 let paragraph = intent
128 .after
129 .objects
130 .iter()
131 .find_map(|object| match object {
132 PageObject::Outline(outline) => outline
133 .paragraphs
134 .iter()
135 .find(|p| p.text().is_some_and(|t| t.id == target)),
136 _ => None,
137 })
138 .expect("formatted paragraph");
139 let text = paragraph.text().unwrap();
140 assert_eq!(text.text.text(), expected);
141 let value = text.text.spans()[0].format.font_size.expect("font size");
142 assert!(
143 text.text
144 .spans()
145 .iter()
146 .all(|s| s.format.font_size == Some(value))
133147 );
134 let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else {
135 panic!("Unexpected formatting intent")
136 };
137 assert!((7.0..=130.0).contains(value) && value.fract() == 0.0);
138 font_size = Some(*value);
139 *value as u64 - 6
140 }
141 notebook::Operation::Split(_)
142 | notebook::Operation::Page(_)
143 | notebook::Operation::CreatePage(_)
144 | notebook::Operation::Pages(_)
145 | notebook::Operation::Join(_)
146 | notebook::Operation::Outline(_)
147 | notebook::Operation::Tree(_) => {
148 panic!("Unexpected operation for this fixture")
148 assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0);
149 font_size = Some(value);
150 value as u64 - 6
149151 }
152 _ => unreachable!(),
150153 };
151154 assert!(operations.last().is_none_or(|last| *last < operation));
152155 assert!(ids.last().is_none_or(|last| *last < pending.id));
......@@ -193,41 +196,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
193196 let replacement = payload(operation, size);
194197 println!("editing {operation}");
195198 io::stdout().flush()?;
196 let id = match operation_kind.as_str() {
197 "text" => cache.edit_text(
198 &source,
199 sid,
200 oid,
201 0..text.encode_utf16().count().try_into()?,
202 &replacement,
203 )?,
204 "insert" => {
205 let store = Store::parse(&source)?;
206 let index = RevisionIndex::parse(&store)?;
207 let document = Document::parse(&index)?;
208 let (sid, page) = document.pages()?[0];
209 let insertion = Insertion::outline(
210 page,
211 144.0,
212 operation as f32 * 72.0,
213 &replacement,
214 "Fixture",
215 )?;
216 cache.insert(&source, sid, &insertion)?
199 let (space, page) = {
200 let store = Store::parse(&source)?;
201 let index = RevisionIndex::parse(&store)?;
202 let document = Document::parse(&index)?;
203 let mut page = Page::from_space(&document, sid)?;
204 fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph {
205 page.objects
206 .iter_mut()
207 .find_map(|object| match object {
208 PageObject::Outline(outline) => outline
209 .paragraphs
210 .iter_mut()
211 .find(|p| p.text().is_some_and(|t| t.id == oid)),
212 _ => None,
213 })
214 .expect("target paragraph")
217215 }
218 "format" => {
219 assert!((1..=124).contains(&operation));
220 cache.format(
221 &source,
222 sid,
223 oid,
224 0..text.encode_utf16().count().try_into()?,
225 &[TextAttribute::FontSize(6.0 + operation as f32)],
226 )?
216 let end = u32::try_from(text.encode_utf16().count())?;
217 match operation_kind.as_str() {
218 "text" => {
219 let target = target_paragraph(&mut page, oid).text_mut().unwrap();
220 let format = target.text.format_at(0)?.clone();
221 target.text.apply(Edit {
222 range: 0..end,
223 replacement: Paragraph::new(replacement.clone(), format),
224 })?;
225 }
226 "insert" => {
227 let template = target_paragraph(&mut page, oid).clone();
228 let mut fresh = template.clone();
229 fresh.id = onestore::page::text::new_id()?;
230 fresh.parent = None;
231 fresh.level = 1;
232 fresh.lists.clear();
233 fresh.tags.clear();
234 fresh.style = None;
235 let format = template.text().unwrap().text.format_at(0)?.clone();
236 fresh.content =
237 onestore::page::ParagraphContent::Text(onestore::page::TextObject {
238 id: onestore::page::text::new_id()?,
239 date_field: None,
240 text: Paragraph::new(replacement.clone(), format),
241 tags: Vec::new(),
242 });
243 let outline = onestore::page::Outline {
244 id: onestore::page::text::new_id()?,
245 title: false,
246 min_width: None,
247 layout: onestore::document::Layout {
248 x: Some(144.0),
249 y: Some(operation as f32 * 72.0),
250 ..Default::default()
251 },
252 indents: Vec::new(),
253 paragraphs: vec![fresh],
254 unsupported: Vec::new(),
255 };
256 let at = page
257 .objects
258 .iter()
259 .position(|o| matches!(o, PageObject::Title(_)))
260 .unwrap_or(page.objects.len());
261 page.objects.insert(at, PageObject::Outline(outline));
262 }
263 "format" => {
264 assert!((1..=124).contains(&operation));
265 let target = target_paragraph(&mut page, oid).text_mut().unwrap();
266 let mut format = target.text.format_at(0)?.clone();
267 format.font_size = Some(6.0 + operation as f32);
268 target.text.apply(Edit {
269 range: 0..end,
270 replacement: Paragraph::new(text.clone(), format),
271 })?;
272 }
273 _ => unreachable!(),
227274 }
228 _ => unreachable!(),
229 }
230 .unwrap();
275 (sid, page)
276 };
277 let id = cache.save(&source, space, &page, "Fixture")?.unwrap();
231278 if acknowledgement == "unack" {
232279 println!("durable {operation} {id}");
233280 } else {
crates/notebook/examples/recovery_probe.rs+29-8
......@@ -98,7 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>
9898 json!({"event":"state", "status":status, "revision":revision,
9999 "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(),
100100 "pending":cache.pending()?.iter().map(|pending|match &pending.operation {
101 notebook::Operation::Text(edit) => json!({"id":pending.id,"before":edit.before,"replacement":edit.replacement,"range":[edit.range.start,edit.range.end]}),
101 notebook::Operation::Page(intent) => match intent.text_change() {
102 Some((_, before, range, replacement)) => json!({"id":pending.id,"before":before,"replacement":replacement,"range":[range.start,range.end]}),
103 None => json!({"id":pending.id,"operation":"page"}),
104 },
102105 operation => json!({"id":pending.id,"operation":operation}),
103106 }).collect::<Vec<_>>() })
104107 );
......@@ -124,14 +127,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124127 drop(remote);
125128 let cache = Replica::create(root.join("cache.sqlite"), &source)?;
126129 let at = u32::try_from(target.text.encode_utf16().count())?;
130 let mut page = {
131 let store = onestore::Store::parse(&source)?;
132 let index = onestore::RevisionIndex::parse(&store)?;
133 let document = onestore::document::Document::parse(&index)?;
134 onestore::page::Page::from_space(&document, target.space)?
135 };
136 let paragraph = page
137 .objects
138 .iter_mut()
139 .find_map(|object| match object {
140 onestore::page::PageObject::Outline(outline) => outline
141 .paragraphs
142 .iter_mut()
143 .find(|p| p.text().is_some_and(|t| t.id == target.object)),
144 _ => None,
145 })
146 .ok_or("Target paragraph is not on its page")?
147 .text_mut()
148 .unwrap();
149 let format = paragraph.text.format_at(at)?.clone();
150 paragraph.text.apply(onestore::page::text::Edit {
151 range: at..at,
152 replacement: onestore::page::Paragraph::new(" [offline-recovery]".into(), format),
153 })?;
127154 assert_eq!(
128 cache.edit_text(
129 &source,
130 target.space,
131 target.object,
132 at..at,
133 " [offline-recovery]"
134 )?,
155 cache.save(&source, target.space, &page, "Fixture")?,
135156 Some(1)
136157 );
137158 phase("local-after");
crates/notebook/examples/smb_offline_client.rs+67-319
......@@ -4,8 +4,7 @@ mod concurrent;
44use notebook::smb::{Client, Credentials};
55use notebook::{EditStatus, Error, Remote, Replica, SmbRemote};
66use onestore::{
7 CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit,
8 RevisionIndex, Store, TextAttribute, TreeEdit, document::Document,
7 CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, page::Paragraph,
98};
109use serde_json::json;
1110use std::{
......@@ -41,19 +40,6 @@ impl DocumentView {
4140 }
4241}
4342
44const DOCUMENT_OPERATIONS: [&str; 10] = [
45 "insert",
46 "format",
47 "text",
48 "split",
49 "right_text",
50 "nest",
51 "unnest",
52 "join",
53 "tail_split",
54 "delete",
55];
56
5743fn now() -> u128 {
5844 SystemTime::now()
5945 .duration_since(UNIX_EPOCH)
......@@ -282,6 +268,56 @@ fn tokens(text: &str) -> Option<Vec<&str>> {
282268}
283269
284270// This owned workload explicitly resolves append conflicts after all retained tokens.
271fn page_with(
272 bytes: &[u8],
273 space: ExGuid,
274 text: ExGuid,
275) -> Result<onestore::page::Page, Box<dyn std::error::Error>> {
276 let store = Store::parse(bytes)?;
277 let index = RevisionIndex::parse(&store)?;
278 let document = onestore::document::Document::parse(&index)?;
279 let page = onestore::page::Page::from_space(&document, space)?;
280 if paragraph_mut(&mut page.clone(), text).is_none() {
281 return Err("The target text is not on its page".into());
282 }
283 Ok(page)
284}
285
286fn paragraph_mut(
287 page: &mut onestore::page::Page,
288 text: ExGuid,
289) -> Option<&mut onestore::page::TextObject> {
290 for object in &mut page.objects {
291 let outlines: Vec<&mut onestore::page::Outline> = match object {
292 onestore::page::PageObject::Outline(outline) => vec![outline],
293 onestore::page::PageObject::Title(title) => title.outlines.iter_mut().collect(),
294 _ => Vec::new(),
295 };
296 for outline in outlines {
297 if let Some(paragraph) = outline
298 .paragraphs
299 .iter_mut()
300 .find(|p| p.text().is_some_and(|t| t.id == text))
301 {
302 return paragraph.text_mut();
303 }
304 }
305 }
306 None
307}
308
309fn append_to(page: &mut onestore::page::Page, text: ExGuid, at: u32, token: &str) {
310 let target = paragraph_mut(page, text).expect("target text is on its page");
311 let format = target.text.format_at(at).unwrap().clone();
312 target
313 .text
314 .apply(onestore::page::text::Edit {
315 range: at..at,
316 replacement: Paragraph::new(token.into(), format),
317 })
318 .unwrap();
319}
320
285321fn append_position(before: &str, current: &str, token: &str) -> Option<u32> {
286322 let original = tokens(before)?;
287323 let present = tokens(current)?;
......@@ -295,162 +331,6 @@ fn append_position(before: &str, current: &str, token: &str) -> Option<u32> {
295331 u32::try_from(current.encode_utf16().count()).ok()
296332}
297333
298fn queue_document(
299 cache: &Replica,
300 actor: &str,
301 operation: usize,
302 parent: Option<ExGuid>,
303 deadline: Instant,
304) -> Result<(ExGuid, [u64; DOCUMENT_OPERATIONS.len()]), Box<dyn std::error::Error>> {
305 let source = cache.snapshot()?;
306 let store = Store::parse(&source)?;
307 let index = RevisionIndex::parse(&store)?;
308 let document = Document::parse(&index)?;
309 let (space, page) = document.pages()?[0];
310 let text = format!("Document {actor}:{operation} 🦀");
311 let insertion = if operation.is_multiple_of(2) {
312 let column: u32 = actor
313 .strip_prefix('w')
314 .ok_or("Missing writer number")?
315 .parse()?;
316 Insertion::outline(
317 page,
318 144.0 + column as f32 * 240.0,
319 144.0 + operation as f32 * 72.0,
320 &text,
321 "Offline document writer",
322 )?
323 } else {
324 Insertion::paragraph(
325 parent.ok_or("Missing prior outline")?,
326 None,
327 &text,
328 "Offline document writer",
329 )?
330 }
331 .with_formatting(
332 0..u32::try_from(text.encode_utf16().count())?,
333 &[
334 TextAttribute::FontSize(13.5),
335 TextAttribute::Color(Some([0x44, 0x55, 0x66])),
336 ],
337 )?;
338 let parent = if operation.is_multiple_of(2) {
339 insertion.object()
340 } else {
341 parent.unwrap()
342 };
343 let end = u32::try_from(text.encode_utf16().count())?;
344 let split = ParagraphSplit::new(insertion.text_object(), end - 2, "Offline document writer")?;
345 let tail_split =
346 ParagraphSplit::new(insertion.text_object(), end + 1, "Offline document writer")?;
347 let join = ParagraphJoin::new(
348 insertion.text_object(),
349 split.text_object(),
350 "Offline document writer",
351 )?;
352 let attributes = [
353 TextAttribute::Bold(true),
354 TextAttribute::FontSize(18.0 + (operation % 9) as f32),
355 TextAttribute::Color(Some([0x12, 0x34, 0x56])),
356 ];
357 let mut ids = [0; DOCUMENT_OPERATIONS.len()];
358 for (step, id) in ids.iter_mut().enumerate() {
359 let kind = DOCUMENT_OPERATIONS[step];
360 let tree = match kind {
361 "nest" => {
362 let source = cache.snapshot()?;
363 let store = Store::parse(&source)?;
364 let index = RevisionIndex::parse(&store)?;
365 let document = Document::parse(&index)?;
366 let section = &document.spaces[&space];
367 let view = &section.revisions[&section.contexts[&ExGuid::default()]];
368 let paragraph = view
369 .nodes
370 .iter()
371 .find(|(_, node)| node.content == [insertion.text_object()])
372 .map(|(id, _)| *id)
373 .ok_or("Missing inserted paragraph")?;
374 Some(TreeEdit::move_to(
375 split.object(),
376 paragraph,
377 None,
378 "Offline document writer",
379 )?)
380 }
381 "unnest" => Some(TreeEdit::move_to(
382 split.object(),
383 parent,
384 None,
385 "Offline document writer",
386 )?),
387 "delete" => Some(TreeEdit::delete(
388 tail_split.object(),
389 "Offline document writer",
390 )?),
391 _ => None,
392 };
393 let range = match kind {
394 "text" => end - 3..end,
395 "split" => end - 2..end - 2,
396 "tail_split" => end + 1..end + 1,
397 "right_text" => 0..2,
398 _ => 1..end - 2,
399 };
400 let target = if kind == "right_text" {
401 split.text_object()
402 } else if kind == "delete" {
403 tail_split.text_object()
404 } else {
405 insertion.text_object()
406 };
407 let replacement = match kind {
408 "text" => Some(" e\u{301}🐈"),
409 "right_text" => Some("B🦋"),
410 _ => None,
411 };
412 loop {
413 if Instant::now() >= deadline {
414 return Err("Document queue timed out; cache retained".into());
415 }
416 let source = cache.snapshot()?;
417 let started = now();
418 let result = match kind {
419 "insert" => cache.insert(&source, space, &insertion),
420 "format" => cache.format(&source, space, target, range.clone(), &attributes),
421 "text" | "right_text" => {
422 cache.edit_text(&source, space, target, range.clone(), replacement.unwrap())
423 }
424 "split" => cache.split(&source, space, &split),
425 "tail_split" => cache.split(&source, space, &tail_split),
426 "join" => cache.join(&source, space, &join),
427 "nest" | "unnest" | "delete" => cache.tree(&source, space, tree.as_ref().unwrap()),
428 _ => unreachable!(),
429 };
430 match result {
431 Ok(Some(acknowledged)) => {
432 *id = acknowledged;
433 println!(
434 "{}",
435 json!({"event":"local_document_commit","id":acknowledged,"operation":operation,"kind":kind,
436 "space":space.to_string(),"object":target.to_string(),"document":insertion.text_object().to_string(),
437 "text":text,"insertion":if kind=="insert" {Some(&insertion)} else {None},
438 "split":match kind { "split" => Some(&split), "tail_split" => Some(&tail_split), _ => None },
439 "tree":tree,
440 "joined":if kind=="join" {Some(join.texts().map(|id| id.to_string()))} else {None},
441 "range":[range.start,range.end],"attributes":attributes,"replacement":replacement,
442 "started_us":started,"finished_us":now()})
443 );
444 break;
445 }
446 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
447 other => return Err(format!("Unexpected document queue result: {other:?}").into()),
448 }
449 }
450 }
451 Ok((parent, ids))
452}
453
454334fn main() -> Result<(), Box<dyn std::error::Error>> {
455335 let args: Vec<_> = env::args().skip(1).collect();
456336 if args.len() != 7
......@@ -495,7 +375,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
495375 let cache = Arc::new(Replica::create(&cache_path, &source)?);
496376 println!(
497377 "{}",
498 json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":documents,"document_graph":documents,"document_kinds":if documents {DOCUMENT_OPERATIONS.as_slice()} else {&[]}})
378 json!({"event":"ready", "pid":std::process::id(), "actor":args[2], "offline":true, "document_operations":false,"document_graph":documents,"document_kinds":[]})
499379 );
500380 while !Path::new(&args[4]).exists() {
501381 if Instant::now() >= deadline {
......@@ -529,8 +409,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
529409 }
530410 })?;
531411 let mut ids = Vec::new();
532 let mut document_ids = std::collections::BTreeSet::new();
533 let mut document_parent = None;
534412 let result = (|| -> Result<(), Box<dyn std::error::Error>> {
535413 let mut generated = 0;
536414 let mut received = 0;
......@@ -547,7 +425,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
547425 Some(EditStatus::Published { revision }) => {
548426 println!(
549427 "{}",
550 json!({"event":if document_ids.contains(&ids[received]) {"document_receipt"} else {"remote_receipt"}, "id":ids[received], "revision":revision.to_string(), "at_us":now()})
428 json!({"event":"remote_receipt", "id":ids[received], "revision":revision.to_string(), "at_us":now()})
551429 );
552430 received += 1;
553431 }
......@@ -564,8 +442,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
564442 .is_some_and(|directory| !directory.join("offline-outage-down").exists())
565443 {
566444 1
567 } else if documents && outage.is_some() {
568 8 * (1 + DOCUMENT_OPERATIONS.len())
569445 } else {
570446 8
571447 };
......@@ -575,25 +451,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
575451 let at = u32::try_from(target.text.encode_utf16().count())?;
576452 let token = format!(" [{}:{}]", args[2], generated);
577453 let started = now();
578 match cache.edit_text(&source, target.space, target.object, at..at, &token) {
454 let mut page = page_with(&source, target.space, target.object)?;
455 append_to(&mut page, target.object, at, &token);
456 match cache.save(&source, target.space, &page, "Offline document writer") {
579457 Ok(Some(id)) => {
580458 println!(
581459 "{}",
582460 json!({"event":"local_commit", "id":id, "operation":generated, "space":target.space.to_string(), "object":target.object.to_string(), "before":target.text, "token":token, "started_us":started, "finished_us":now()})
583461 );
584462 ids.push(id);
585 if documents {
586 let (parent, added) = queue_document(
587 &cache,
588 &args[2],
589 generated,
590 document_parent,
591 deadline,
592 )?;
593 document_parent = Some(parent);
594 document_ids.extend(added);
595 ids.extend(added);
596 }
597463 generated += 1;
598464 }
599465 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
......@@ -606,20 +472,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
606472 let local = cache.snapshot()?;
607473 let remote = cache.remote_snapshot()?;
608474 let current = view(&remote)?;
609 let notebook::Operation::Text(edit) = &intent.operation else {
475 let notebook::Operation::Page(edit) = &intent.operation else {
610476 return Err(format!(
611 "Document intent {} requires review: {:?}",
477 "Intent {} requires review: {:?}",
612478 intent.id,
613479 cache.status(intent.id)?
614480 )
615481 .into());
616482 };
617 let at = append_position(&edit.before, &current.text, &edit.replacement)
483 let (object, before, _, token) = edit
484 .text_change()
485 .ok_or("A review expects one appended token")?;
486 let at = append_position(&before, &current.text, &token)
618487 .ok_or("Append model disagrees with retained history")?;
619 match cache.rebase_conflict(intent.id, &local, &remote, at..at) {
488 let mut reviewed = page_with(&remote, intent.space, object)?;
489 append_to(&mut reviewed, object, at, &token);
490 match cache.review_page(intent.id, &local, &remote, &reviewed) {
620491 Ok(()) => println!(
621492 "{}",
622 json!({"event":"reviewed_append", "id":intent.id, "before":edit.before, "remote":current.text, "token":edit.replacement, "at_us":now()})
493 json!({"event":"reviewed_append", "id":intent.id, "before":before, "remote":current.text, "token":token, "at_us":now()})
623494 ),
624495 Err(Error::Io(error))
625496 if [
......@@ -658,7 +529,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
658529 };
659530 println!(
660531 "{}",
661 json!({"event":if document_ids.contains(&id) {"reopened_document_receipt"} else {"reopened_receipt"}, "id":id, "revision":revision.to_string()})
532 json!({"event":"reopened_receipt", "id":id, "revision":revision.to_string()})
662533 );
663534 }
664535 println!(
......@@ -699,133 +570,10 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() {
699570 );
700571}
701572
702#[cfg(test)]
703#[path = "../../onestore/tests/support/disk.rs"]
704mod disk;
705
706573#[cfg(test)]
707574mod tests {
708575 use super::*;
709576
710 #[test]
711 fn document_workload_retains_dependencies_and_receipts_across_reopen() {
712 struct Server {
713 disk: disk::Disk,
714 lost_reply: bool,
715 }
716 impl Remote for Server {
717 fn read(&mut self) -> io::Result<Vec<u8>> {
718 Ok(self.disk.visible.clone())
719 }
720 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
721 edit.commit(&mut self.disk)?;
722 if std::mem::take(&mut self.lost_reply) {
723 Err(CommitError {
724 state: CommitState::Unknown,
725 error: io::Error::from(io::ErrorKind::ConnectionAborted),
726 })
727 } else {
728 Ok(())
729 }
730 }
731 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
732 onestore::confirm_snapshot(&mut self.disk, snapshot)
733 }
734 }
735 let source =
736 onestore::create_section("workload.one", "Concurrent edits:", "Author").unwrap();
737 let directory = tempfile::tempdir().unwrap();
738 let path = directory.path().join("cache.sqlite");
739 let mut cache = Replica::create(&path, &source).unwrap();
740 let mut remote = Traced {
741 remote: Server {
742 disk: disk::Disk {
743 visible: source.clone(),
744 durable: source.clone(),
745 operation: 0,
746 fail_at: None,
747 write_limit: 97,
748 random: 1,
749 },
750 lost_reply: false,
751 },
752 before: None,
753 pause: None,
754 documents: true,
755 };
756 println!(
757 "{}",
758 json!({"event":"ready", "actor":"w0", "offline":true,
759 "document_operations":true, "document_graph":true, "document_kinds":DOCUMENT_OPERATIONS})
760 );
761 let deadline = Instant::now() + Duration::from_secs(30);
762 let mut parent = None;
763 let mut ids = Vec::new();
764 for operation in 0..2 {
765 let (outline, added) =
766 queue_document(&cache, "w0", operation, parent, deadline).unwrap();
767 parent = Some(outline);
768 ids.extend(added);
769 drop(cache);
770 cache = Replica::open(&path).unwrap();
771 }
772 let local = cache.snapshot().unwrap();
773 assert_eq!(
774 cache.pending().unwrap().len(),
775 2 * DOCUMENT_OPERATIONS.len()
776 );
777 for (step, id) in ids.iter().enumerate() {
778 remote.remote.lost_reply = matches!(
779 DOCUMENT_OPERATIONS[step % DOCUMENT_OPERATIONS.len()],
780 "split" | "nest" | "unnest" | "join" | "tail_split" | "delete"
781 );
782 let result = cache.sync_once(&mut remote);
783 let state = if result.is_err() {
784 assert!(matches!(
785 result,
786 Err(Error::Remote(CommitError {
787 state: CommitState::Unknown,
788 ..
789 }))
790 ));
791 drop(cache);
792 cache = Replica::open(&path).unwrap();
793 cache.sync_once(&mut remote).unwrap().unwrap()
794 } else {
795 result.unwrap().unwrap()
796 };
797 assert_eq!(state.0, *id);
798 let EditStatus::Published { revision } = state.1 else {
799 panic!("{state:?}")
800 };
801 println!(
802 "{}",
803 json!({"event":"document_receipt", "id":id, "revision":revision.to_string(), "at_us":now()})
804 );
805 drop(cache);
806 cache = Replica::open(&path).unwrap();
807 assert_eq!(
808 cache.status(*id).unwrap(),
809 Some(EditStatus::Published { revision })
810 );
811 if step + 1 < ids.len() {
812 assert_eq!(cache.snapshot().unwrap(), local);
813 }
814 }
815 assert!(cache.pending().unwrap().is_empty());
816 for id in ids {
817 let Some(EditStatus::Published { revision }) = cache.status(id).unwrap() else {
818 panic!()
819 };
820 println!(
821 "{}",
822 json!({"event":"reopened_document_receipt", "id":id, "revision":revision.to_string()})
823 );
824 }
825 remote.read().unwrap();
826 println!("{}", json!({"event":"done"}));
827 }
828
829577 #[test]
830578 fn publication_differences_retain_removed_text_and_graph_identities() {
831579 let before = DocumentView {
crates/notebook/src/assets.rs-4
......@@ -99,10 +99,6 @@ fn referenced(connection: &Connection, key: &str) -> Result<bool> {
9999}
100100
101101pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> {
102 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
103 if version < 5 {
104 return Ok(None);
105 }
106102 let length: Option<i64> = connection
107103 .query_row(
108104 "SELECT length(data) FROM assets WHERE name=?1",
crates/notebook/src/formatting.rs deleted-203
......@@ -1,203 +0,0 @@
1use super::*;
2use onestore::TextAttribute;
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use std::collections::BTreeMap;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9struct Span {
10 end: u32,
11 values: Vec<Value>,
12}
13
14/// A formatting intent and the text/attribute values observed before local publication.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct FormatEdit {
18 pub object: ExGuid,
19 pub before: String,
20 pub range: Range<u32>,
21 pub attributes: Vec<TextAttribute>,
22 observed: Vec<Span>,
23}
24
25fn fields(attributes: &[TextAttribute]) -> BTreeMap<&'static str, Value> {
26 let mut fields = BTreeMap::new();
27 for attribute in attributes {
28 let (name, value) = match attribute {
29 TextAttribute::Bold(v) => ("bold", json!(v)),
30 TextAttribute::Italic(v) => ("italic", json!(v)),
31 TextAttribute::Underline(v) => ("underline", json!(v)),
32 TextAttribute::Strike(v) => ("strike", json!(v)),
33 TextAttribute::Superscript(v) => {
34 if *v {
35 fields.insert("subscript", json!(false));
36 }
37 ("superscript", json!(v))
38 }
39 TextAttribute::Subscript(v) => {
40 if *v {
41 fields.insert("superscript", json!(false));
42 }
43 ("subscript", json!(v))
44 }
45 TextAttribute::Font(v) => ("font", json!(v)),
46 TextAttribute::FontSize(v) => ("font_size", json!(v)),
47 TextAttribute::Color(v) | TextAttribute::Highlight(v) => (
48 if matches!(attribute, TextAttribute::Color(_)) {
49 "color"
50 } else {
51 "highlight"
52 },
53 json!(v.map_or(0xff000000, |[r, g, b]| u32::from_le_bytes([r, g, b, 0]))),
54 ),
55 };
56 fields.insert(name, value);
57 }
58 fields
59}
60
61fn observe(
62 source: &[u8],
63 space: ExGuid,
64 object: ExGuid,
65 range: Range<u32>,
66 fields: &BTreeMap<&str, Value>,
67) -> Result<Vec<Span>> {
68 let store = Store::parse(source)?;
69 let index = RevisionIndex::parse(&store)?;
70 let document = Document::parse(&index)?;
71 let revision = document.active(space)?;
72 let mut spans: Vec<Span> = Vec::new();
73 let mut start = 0;
74 for run in revision.text_runs(object)? {
75 let end =
76 start + u32::try_from(run.text.encode_utf16().count()).map_err(io::Error::other)?;
77 if (start < range.end && range.start < end) || (start == 0 && end == 0 && range == (0..0)) {
78 let format = serde_json::to_value(run.format).map_err(io::Error::other)?;
79 let values = fields
80 .iter()
81 .map(|(name, desired)| {
82 let value = &format[*name];
83 if value.is_null() && desired.is_boolean() {
84 json!(false)
85 } else if value.is_null() && matches!(*name, "color" | "highlight") {
86 json!(0xff000000_u32)
87 } else {
88 value.clone()
89 }
90 })
91 .collect::<Vec<_>>();
92 let end = end.min(range.end) - range.start;
93 if let Some(last) = spans.last_mut().filter(|s| s.values == values) {
94 last.end = end;
95 } else {
96 spans.push(Span { end, values });
97 }
98 }
99 start = end;
100 }
101 Ok(spans)
102}
103
104impl Replica {
105 /// Durably records a visual-formatting change and its observed attribute values.
106 /// Independent remote attributes can merge; competing values preserve a conflict.
107 pub fn format(
108 &self,
109 source: &[u8],
110 space: ExGuid,
111 object: ExGuid,
112 range: Range<u32>,
113 attributes: &[TextAttribute],
114 ) -> Result<Option<u64>> {
115 let (edit, prepared) = FormatEdit::capture(source, space, object, range, attributes)?;
116 self.record(source, space, Operation::Format(edit), &prepared)
117 }
118}
119
120impl FormatEdit {
121 pub(crate) fn capture<'a>(
122 source: &'a [u8],
123 space: ExGuid,
124 object: ExGuid,
125 range: Range<u32>,
126 attributes: &[TextAttribute],
127 ) -> Result<(Self, PreparedEdit<'a>)> {
128 let prepared = PreparedEdit::format(source, space, object, range.clone(), attributes)?;
129 let before = paragraph(source, space, object)?.ok_or_else(|| {
130 io::Error::new(
131 io::ErrorKind::InvalidData,
132 "Prepared formatting has no text target",
133 )
134 })?;
135 let observed = observe(source, space, object, range.clone(), &fields(attributes))?;
136 let edit = Self {
137 object,
138 before,
139 range,
140 attributes: attributes.to_vec(),
141 observed,
142 };
143 Ok((edit, prepared))
144 }
145
146 pub(crate) fn prepare<'a>(
147 &self,
148 snapshot: &'a [u8],
149 space: ExGuid,
150 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
151 let Some(text) = paragraph(snapshot, space, self.object)? else {
152 return Ok(Err(ConflictKind::TargetUnavailable));
153 };
154 let Some(range) = rebase::rebase(&self.before, &text, self.range.clone()) else {
155 return Ok(Err(ConflictKind::TextChanged));
156 };
157 let prepared = match PreparedEdit::format(
158 snapshot,
159 space,
160 self.object,
161 range.clone(),
162 &self.attributes,
163 ) {
164 Ok(prepared) => prepared,
165 Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)),
166 };
167 let desired = fields(&self.attributes);
168 let observed = observe(snapshot, space, self.object, range.clone(), &desired)?;
169 let wanted: Vec<_> = desired.values().collect();
170 for spans in [&self.observed, &observed] {
171 if spans.is_empty()
172 || (!range.is_empty() && spans[0].end == 0)
173 || spans.last().unwrap().end != range.end - range.start
174 || spans.windows(2).any(|s| s[0].end >= s[1].end)
175 || spans.iter().any(|s| s.values.len() != wanted.len())
176 {
177 return Ok(Err(ConflictKind::UnsupportedEdit));
178 }
179 }
180 let (mut before, mut after) = (0, 0);
181 while before < self.observed.len() && after < observed.len() {
182 let old = &self.observed[before];
183 let current = &observed[after];
184 if old
185 .values
186 .iter()
187 .zip(&current.values)
188 .zip(&wanted)
189 .any(|((old, new), wanted)| new != old && new != *wanted)
190 {
191 return Ok(Err(ConflictKind::FormattingChanged));
192 }
193 let end = old.end.min(current.end);
194 if old.end == end {
195 before += 1;
196 }
197 if current.end == end {
198 after += 1;
199 }
200 }
201 Ok(Ok(prepared))
202 }
203}
crates/notebook/src/lib.rs+100-121
......@@ -6,29 +6,27 @@ pub mod discover;
66pub mod smb;
77
88use onestore::{
9 ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store,
9 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
1010 document::{Document, Kind},
1111 page::Page,
1212};
1313use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params};
14use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration};
14use std::{
15 fs::OpenOptions,
16 io,
17 path::Path,
18 sync::{Mutex, atomic::AtomicI64},
19 time::Duration,
20};
1521
1622mod assets;
17mod formatting;
1823mod merge;
19mod outline;
2024mod pages;
21mod paragraph;
2225mod rebase;
2326mod recovery;
2427mod schema;
25mod tree;
26pub use formatting::FormatEdit;
27pub use outline::OutlineEdit;
2828pub use pages::PageEdits;
29pub use paragraph::{JoinEdit, SplitEdit};
3029pub use recovery::{Recovery, RecoverySummary};
31pub use tree::TreeEdit;
3230mod sync;
3331pub use sync::{ConflictKind, EditStatus, Remote};
3432mod worker;
......@@ -57,17 +55,7 @@ pub enum Error {
5755type Result<T> = std::result::Result<T, Error>;
5856
5957const APPLICATION_ID: u32 = 0x4f4e454f;
60const SCHEMA_VERSION: u32 = 11;
61
62/// Text and its observed precondition, retained across cache reopen and rebasing.
63#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct TextEdit {
66 pub object: ExGuid,
67 pub before: String,
68 pub range: Range<u32>,
69 pub replacement: String,
70}
58const SCHEMA_VERSION: u32 = 12;
7159
7260/// An edited page model together with the stored model it was edited from.
7361/// `before` is the precondition reconciliation checks against the remote page.
......@@ -79,19 +67,80 @@ pub struct PageIntent {
7967 pub author: String,
8068}
8169
70impl PageIntent {
71 /// Describes the intent as one paragraph's text replacement, when that is all it changes:
72 /// the text object, its text before, the replaced UTF-16 range and the replacement.
73 pub fn text_change(&self) -> Option<(ExGuid, String, std::ops::Range<u32>, String)> {
74 fn texts(page: &Page) -> Vec<(ExGuid, &onestore::page::Paragraph)> {
75 let mut out = Vec::new();
76 for object in &page.objects {
77 let outlines: Vec<&onestore::page::Outline> = match object {
78 onestore::page::PageObject::Outline(outline) => vec![outline],
79 onestore::page::PageObject::Title(title) => title.outlines.iter().collect(),
80 _ => Vec::new(),
81 };
82 for outline in outlines {
83 for paragraph in &outline.paragraphs {
84 if let Some(text) = paragraph.text() {
85 out.push((text.id, &text.text));
86 }
87 }
88 }
89 }
90 out
91 }
92 let (before, after) = (texts(&self.before), texts(&self.after));
93 if before.len() != after.len() {
94 return None;
95 }
96 let mut changed = None;
97 for ((id, x), (other, y)) in before.iter().zip(&after) {
98 if id != other {
99 return None;
100 }
101 if x.text() != y.text() {
102 if changed.is_some() {
103 return None;
104 }
105 changed = Some((*id, *x, *y));
106 }
107 }
108 let (id, x, y) = changed?;
109 let (b, o) = (x.text(), y.text());
110 let prefix = b
111 .char_indices()
112 .zip(o.chars())
113 .take_while(|((_, c), d)| c == d)
114 .map(|((i, c), _)| i + c.len_utf8())
115 .last()
116 .unwrap_or(0);
117 let suffix = b[prefix..]
118 .chars()
119 .rev()
120 .zip(o[prefix..].chars().rev())
121 .take_while(|(c, d)| c == d)
122 .map(|(c, _)| c.len_utf8())
123 .sum::<usize>();
124 let start = x.utf16_offset(prefix).ok()?;
125 let end = x.utf16_offset(b.len() - suffix).ok()?;
126 Some((
127 id,
128 b.to_owned(),
129 start..end,
130 o[prefix..o.len() - suffix].to_owned(),
131 ))
132 }
133}
134
82135#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
83136#[serde(deny_unknown_fields)]
84137pub enum Operation {
138 /// An edited page model; body content is edited only through this intent.
85139 Page(PageIntent),
86140 CreatePage(PageCreation),
87141 Pages(PageEdits),
88 Text(TextEdit),
89 Insert(Insertion),
90 Format(FormatEdit),
91 Split(SplitEdit),
92 Join(JoinEdit),
93 Outline(OutlineEdit),
94 Tree(TreeEdit),
142 /// Permanent removal of explicitly selected page spaces from the section.
143 DeletePages(Vec<ExGuid>),
95144}
96145
97146/// A locally acknowledged intent; its ID remains stable across cache reopen.
......@@ -107,6 +156,8 @@ pub struct PendingEdit {
107156pub struct Replica {
108157 connection: Mutex<Connection>,
109158 synchronization: Mutex<()>,
159 /// The intent a synchronization step selected for publication, or zero.
160 in_flight: AtomicI64,
110161 worker: Mutex<std::sync::Weak<worker::Signal>>,
111162}
112163
......@@ -164,24 +215,28 @@ impl Replica {
164215 schema::create(&transaction)?;
165216 transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?;
166217 } else {
167 if application != APPLICATION_ID || !(1..=SCHEMA_VERSION).contains(&version) {
218 if application != APPLICATION_ID {
219 return Err(
220 io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into(),
221 );
222 }
223 if version != SCHEMA_VERSION {
224 // Older caches are refused rather than migrated until the application is
225 // usable end to end; the queue must be drained by the build that wrote it.
168226 return Err(io::Error::new(
169227 io::ErrorKind::InvalidData,
170 "Unrecognized cache or unsupported schema version",
228 format!("Cache schema version {version} is not the supported version {SCHEMA_VERSION}"),
171229 )
172230 .into());
173231 }
174232 validate_images(&transaction)?;
175 if version < SCHEMA_VERSION {
176 schema::migrate(&transaction, version)?;
177 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
178 }
179233 pending(&transaction)?;
180234 }
181235 transaction.commit()?;
182236 Ok(Self {
183237 connection: Mutex::new(connection),
184238 synchronization: Mutex::new(()),
239 in_flight: AtomicI64::new(0),
185240 worker: Mutex::new(std::sync::Weak::new()),
186241 })
187242 }
......@@ -207,50 +262,6 @@ impl Replica {
207262 pending(&connection)
208263 }
209264
210 /// Atomically records an intent and its resulting local image; returns its durable ID.
211 /// Unchanged text returns `None`. A stale image returns `Io(ResourceBusy)`.
212 /// On synchronization, replacement text inherits the remote style at the rebased start.
213 /// After a database error, reopen and inspect the cache before retrying the edit.
214 pub fn edit_text(
215 &self,
216 source: &[u8],
217 space: ExGuid,
218 object: ExGuid,
219 range: Range<u32>,
220 replacement: &str,
221 ) -> Result<Option<u64>> {
222 let edit = PreparedEdit::text(source, space, object, range.clone(), replacement)?;
223 let before = paragraph(source, space, object)?.ok_or_else(|| {
224 io::Error::new(
225 io::ErrorKind::InvalidData,
226 "Prepared edit has no text target",
227 )
228 })?;
229 self.record(
230 source,
231 space,
232 Operation::Text(TextEdit {
233 object,
234 before,
235 range,
236 replacement: replacement.to_owned(),
237 }),
238 &edit,
239 )
240 }
241
242 /// Durably queues a validated insertion with its stable object identities.
243 /// Uses the same snapshot and local-acknowledgement contract as `edit_text`.
244 pub fn insert(
245 &self,
246 source: &[u8],
247 space: ExGuid,
248 insertion: &Insertion,
249 ) -> Result<Option<u64>> {
250 let edit = PreparedEdit::insert(source, space, insertion)?;
251 self.record(source, space, Operation::Insert(insertion.clone()), &edit)
252 }
253
254265 /// Durably queues an edited page model using the supplied local snapshot.
255266 /// While the newest pending edit is an unattempted save of the same page, a new save
256267 /// replaces its result instead of queueing another publication, as OneNote does
......@@ -297,7 +308,10 @@ impl Replica {
297308 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
298309 )
299310 .optional()?;
311 // The intent a step is publishing has no attempt row yet; rewriting it would lose
312 // this save under the published bytes.
300313 if let Some((id, sid, operation)) = newest
314 && id != self.in_flight.load(std::sync::atomic::Ordering::Acquire)
301315 && sid == space.to_string()
302316 && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation)
303317 {
......@@ -350,6 +364,14 @@ impl Replica {
350364 )
351365 }
352366
367 /// Queues the permanent removal of explicitly selected pages; the batch is republished
368 /// only while every selected page still exists remotely.
369 pub fn delete_pages(&self, source: &[u8], pages: &[ExGuid]) -> Result<Option<u64>> {
370 let edit = PreparedEdit::delete_pages_permanently(source, pages)?;
371 let space = validate(source)?;
372 self.record(source, space, Operation::DeletePages(pages.to_vec()), &edit)
373 }
374
353375 fn record(
354376 &self,
355377 source: &[u8],
......@@ -403,35 +425,6 @@ impl Replica {
403425 }
404426}
405427
406fn active_paths(
407 view: &onestore::document::Revision<'_>,
408 pages: &[ExGuid],
409 objects: &[ExGuid],
410) -> Option<Vec<Vec<ExGuid>>> {
411 let parents = view.parents(pages).ok()?;
412 objects
413 .iter()
414 .map(|object| {
415 if !parents.contains_key(object) && !pages.contains(object) {
416 return None;
417 }
418 let mut path = Vec::new();
419 let mut at = *object;
420 while !pages.contains(&at) {
421 let [parent] = parents.get(&at)?.as_slice() else {
422 return None;
423 };
424 if path.len() >= view.nodes.len() {
425 return None;
426 }
427 path.push(*parent);
428 at = *parent;
429 }
430 Some(path)
431 })
432 .collect()
433}
434
435428fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> {
436429 let store = Store::parse(source)?;
437430 let index = RevisionIndex::parse(&store)?;
......@@ -439,20 +432,6 @@ fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> {
439432 Ok(Page::from_space(&document, space).ok())
440433}
441434
442fn paragraph(source: &[u8], space: ExGuid, object: ExGuid) -> Result<Option<String>> {
443 let store = Store::parse(source)?;
444 let index = RevisionIndex::parse(&store)?;
445 let document = Document::parse(&index)?;
446 let node = document
447 .active(space)
448 .ok()
449 .and_then(|revision| revision.nodes.get(&object));
450 Ok(match node.map(|node| &node.kind) {
451 Some(Kind::RichText { text, .. }) => Some(text.clone()),
452 _ => None,
453 })
454}
455
456435fn validate(source: &[u8]) -> Result<ExGuid> {
457436 let store = Store::parse(source)?;
458437 if !store.checksum_mismatches.is_empty() {
crates/notebook/src/merge.rs+153-92
......@@ -33,12 +33,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {
3333 (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => {
3434 result.push(PageObject::Outline(merge_outline(x, y, z)?));
3535 }
36 _ => {
37 if after != before {
38 return None;
39 }
40 result.push(object.clone());
41 }
36 _ => result.push(pick(before, after, &object)?.clone()),
4237 },
4338 (Some(before), None) => {
4439 // Removed locally; keep only if the remote left it untouched.
......@@ -148,36 +143,42 @@ fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> {
148143}
149144
150145fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> {
151 if ours.title != base.title
152 || ours.min_width != base.min_width
153 || ours.indents != base.indents
154 || ours.unsupported != base.unsupported
155 {
156 return None;
157 }
146 let title = pick(&base.title, &ours.title, &theirs.title)?;
147 let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?;
148 let indents = pick(&base.indents, &ours.indents, &theirs.indents)?;
149 let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?;
158150 let mut layout = theirs.layout.clone();
159151 let (x, y) = pick(
160152 &(base.layout.x, base.layout.y),
161153 &(ours.layout.x, ours.layout.y),
162154 &(theirs.layout.x, theirs.layout.y),
163155 )?;
164 let (max_width, user_set) = pick(
165 &(base.layout.max_width, base.layout.width_set_by_user),
166 &(ours.layout.max_width, ours.layout.width_set_by_user),
167 &(theirs.layout.max_width, theirs.layout.width_set_by_user),
156 // A user-set width clears the wrap reservation, so the three travel together.
157 let width = |layout: &onestore::document::Layout| {
158 (
159 layout.max_width,
160 layout.width_set_by_user,
161 layout.reserved_width,
162 )
163 };
164 let (max_width, user_set, reserved) = pick(
165 &width(&base.layout),
166 &width(&ours.layout),
167 &width(&theirs.layout),
168168 )?;
169169 layout.x = x;
170170 layout.y = y;
171171 layout.max_width = max_width;
172172 layout.width_set_by_user = user_set;
173 layout.reserved_width = reserved;
173174 Some(Outline {
174175 id: theirs.id,
175 title: theirs.title,
176 min_width: theirs.min_width,
176 title,
177 min_width,
177178 layout,
178 indents: theirs.indents.clone(),
179 indents,
179180 paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?,
180 unsupported: theirs.unsupported.clone(),
181 unsupported,
181182 })
182183}
183184
......@@ -236,7 +237,7 @@ fn merge_paragraphs(
236237 };
237238 merged.insert(*id, paragraph);
238239 }
239 // Child order per container: the remote order unless only we reordered it.
240 // Child order per container: the remote order with our repositioned paragraphs re-placed.
240241 let (bc, oc, tc) = (children(base), children(ours), children(theirs));
241242 let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new();
242243 let containers: BTreeSet<Option<ExGuid>> = bc
......@@ -255,55 +256,47 @@ fn merge_paragraphs(
255256 })
256257 .unwrap_or_default()
257258 };
258 let (base_list, our_list, their_list) = (
259 live(bc.get(&container)),
260 live(oc.get(&container)),
261 live(tc.get(&container)),
259 let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container)));
260 let of_base = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> {
261 ids.map(|ids| {
262 ids.iter()
263 .copied()
264 .filter(|id| b.contains_key(id))
265 .collect()
266 })
267 .unwrap_or_default()
268 };
269 let (base_ids, our_ids, their_ids) = (
270 of_base(bc.get(&container)),
271 of_base(oc.get(&container)),
272 of_base(tc.get(&container)),
262273 );
263 let common = |list: &[ExGuid]| -> Vec<ExGuid> {
264 list.iter()
265 .copied()
266 .filter(|id| {
267 base_list.contains(id) && our_list.contains(id) && their_list.contains(id)
268 })
269 .collect()
274 let ours_moved = if our_ids == base_ids || our_ids == their_ids {
275 BTreeSet::new()
276 } else {
277 moved(&base_ids, &our_ids)
270278 };
271 let (base_common, our_common, their_common) =
272 (common(&base_list), common(&our_list), common(&their_list));
273 let mut list: Vec<ExGuid> = their_list.clone();
274 if our_common != base_common {
275 if their_common != base_common && their_common != our_common {
276 return None;
277 }
278 let slots: Vec<usize> = list
279 .iter()
280 .enumerate()
281 .filter(|(_, id)| our_common.contains(id))
282 .map(|(i, _)| i)
283 .collect();
284 for (slot, id) in slots.into_iter().zip(&our_common) {
285 list[slot] = *id;
286 }
279 // A paragraph we repositioned that the remote also moved, reparented or removed
280 // has two destinations; neither side's placement can be assumed.
281 let mut theirs_touched = moved(&base_ids, &their_ids);
282 theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id)));
283 if ours_moved.iter().any(|id| theirs_touched.contains(id)) {
284 return None;
287285 }
288 // Paragraphs we added go after the predecessor they follow in our list.
286 let mut list: Vec<ExGuid> = their_list.clone();
287 list.retain(|id| merged[id].parent == container);
288 // Paragraphs we repositioned, added or brought here follow their predecessor in our list.
289289 for (at, id) in our_list.iter().enumerate() {
290 if list.contains(id) {
290 if merged[id].parent != container || (list.contains(id) && !ours_moved.contains(id)) {
291291 continue;
292292 }
293 if !b.contains_key(id) && merged[id].parent == container {
294 let predecessor = our_list[..at]
295 .iter()
296 .rev()
297 .find(|other| list.contains(other));
298 let position =
299 predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1);
300 list.insert(position, *id);
301 }
302 }
303 for id in &list {
304 if merged[id].parent != container {
305 return None;
306 }
293 list.retain(|other| other != id);
294 let predecessor = our_list[..at]
295 .iter()
296 .rev()
297 .find(|other| list.contains(other));
298 let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1);
299 list.insert(position, *id);
307300 }
308301 order.insert(container, list);
309302 }
......@@ -323,49 +316,84 @@ fn merge_paragraphs(
323316 Some(out)
324317}
325318
319/// The members of `list` outside a longest common subsequence with `base`: the paragraphs
320/// a reorder of the shared members had to move.
321fn moved(base: &[ExGuid], list: &[ExGuid]) -> BTreeSet<ExGuid> {
322 let a: Vec<ExGuid> = base
323 .iter()
324 .copied()
325 .filter(|id| list.contains(id))
326 .collect();
327 let b: Vec<ExGuid> = list
328 .iter()
329 .copied()
330 .filter(|id| base.contains(id))
331 .collect();
332 let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1];
333 for i in (0..a.len()).rev() {
334 for j in (0..b.len()).rev() {
335 table[i][j] = if a[i] == b[j] {
336 table[i + 1][j + 1] + 1
337 } else {
338 table[i + 1][j].max(table[i][j + 1])
339 };
340 }
341 }
342 let mut kept = BTreeSet::new();
343 let (mut i, mut j) = (0, 0);
344 while i < a.len() && j < b.len() {
345 if a[i] == b[j] {
346 kept.insert(a[i]);
347 i += 1;
348 j += 1;
349 } else if table[i + 1][j] >= table[i][j + 1] {
350 i += 1;
351 } else {
352 j += 1;
353 }
354 }
355 b.into_iter().filter(|id| !kept.contains(id)).collect()
356}
357
326358fn merge_paragraph(
327359 base: &PageParagraph,
328360 ours: &PageParagraph,
329361 theirs: &PageParagraph,
330362) -> Option<PageParagraph> {
331 if ours.lists != base.lists
332 || ours.tags != base.tags
333 || ours.style != base.style
334 || ours.format != base.format
335 {
336 return None;
337 }
363 let lists = pick(&base.lists, &ours.lists, &theirs.lists)?;
364 let tags = pick(&base.tags, &ours.tags, &theirs.tags)?;
365 let style = pick(&base.style, &ours.style, &theirs.style)?;
366 let format = pick(&base.format, &ours.format, &theirs.format)?;
338367 let (parent, level) = pick(
339368 &(base.parent, base.level),
340369 &(ours.parent, ours.level),
341370 &(theirs.parent, theirs.level),
342371 )?;
343372 let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?;
344 let content = match (&base.content, &ours.content, &theirs.content) {
345 (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) => {
346 if x.id != y.id || x.id != z.id || y.date_field != x.date_field || y.tags != x.tags {
347 return None;
348 }
349 let mut text = z.clone();
350 text.text = merge_text(&x.text, &y.text, &z.text)?;
351 ParagraphContent::Text(text)
352 }
353 (x, y, z) => {
354 if y != x {
355 return None;
373 let content = match pick(&base.content, &ours.content, &theirs.content) {
374 Some(picked) => picked,
375 None => match (&base.content, &ours.content, &theirs.content) {
376 (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z))
377 if x.id == y.id && x.id == z.id =>
378 {
379 let mut text = z.clone();
380 text.date_field = pick(&x.date_field, &y.date_field, &z.date_field)?;
381 text.tags = pick(&x.tags, &y.tags, &z.tags)?;
382 text.text = merge_text(&x.text, &y.text, &z.text)?;
383 ParagraphContent::Text(text)
356384 }
357 z.clone()
358 }
385 _ => return None,
386 },
359387 };
360388 Some(PageParagraph {
361389 id: theirs.id,
362390 parent,
363391 level,
364 style: theirs.style,
365 format: theirs.format.clone(),
392 style,
393 format,
366394 content,
367 lists: theirs.lists.clone(),
368 tags: theirs.tags.clone(),
395 lists,
396 tags,
369397 collapsed,
370398 })
371399}
......@@ -379,8 +407,8 @@ fn merge_text(
379407 if let Some(picked) = pick(base, ours, theirs) {
380408 return Some(picked);
381409 }
382 if base.text() == ours.text() || base.text() == theirs.text() {
383 // Formatting-only changes on both sides cannot be attributed to ranges.
410 if base.text() == ours.text() {
411 // A local formatting-only change cannot be attributed to a range of the remote text.
384412 return None;
385413 }
386414 let (b, o) = (base.text(), ours.text());
......@@ -402,6 +430,15 @@ fn merge_text(
402430 let end = base.utf16_offset(b.len() - suffix).ok()?;
403431 let inserted = ours.utf16_offset(o.len() - suffix).ok()?;
404432 let replacement = ours.slice(start..inserted).ok()?;
433 let our_end = ours.utf16_offset(o.len()).ok()?;
434 let base_end = base.utf16_offset(b.len()).ok()?;
435 if (start > 0 && ours.slice(0..start).ok()? != base.slice(0..start).ok()?)
436 || (end < base_end
437 && ours.slice(inserted..our_end).ok()? != base.slice(end..base_end).ok()?)
438 {
439 // Formatting changed outside the replaced range; the remote text would hide it.
440 return None;
441 }
405442 let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?;
406443 let mut merged = theirs.clone();
407444 merged
......@@ -454,5 +491,29 @@ mod tests {
454491 .text(),
455492 "y"
456493 );
494 let bold = onestore::document::Format {
495 bold: Some(true),
496 ..Default::default()
497 };
498 let remote = Paragraph::new("one two three".into(), bold);
499 let merged = merge_text(&base, &text("one two three four"), &remote).unwrap();
500 assert_eq!(merged.text(), "one two three four");
501 assert_eq!(merged.format_at(0).unwrap(), remote.format_at(0).unwrap());
502 assert_eq!(
503 merge_text(&base, &remote, &text("one two three four")),
504 None
505 );
506 assert_eq!(
507 merge_text(&base, &text("one two"), &text("X one two three"))
508 .unwrap()
509 .text(),
510 "X one two"
511 );
512 assert_eq!(
513 merge_text(&base, &text("two three"), &text("one two three!"))
514 .unwrap()
515 .text(),
516 "two three!"
517 );
457518 }
458519}
crates/notebook/src/outline.rs deleted-138
......@@ -1,138 +0,0 @@
1use super::*;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct Observed {
8 path: Vec<ExGuid>,
9 values: Vec<Value>,
10}
11
12/// A layout or saved expansion intent with its original ancestry and property values.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct OutlineEdit {
16 pub object: ExGuid,
17 pub change: onestore::OutlineEdit,
18 observed: Observed,
19}
20
21fn observe(
22 source: &[u8],
23 space: ExGuid,
24 object: ExGuid,
25 change: onestore::OutlineEdit,
26) -> Result<Option<Observed>> {
27 let store = Store::parse(source)?;
28 let index = RevisionIndex::parse(&store)?;
29 let document = Document::parse(&index)?;
30 let Ok(view) = document.active(space) else {
31 return Ok(None);
32 };
33 let pages = document.pages_in(space)?;
34 if pages.len() != 1 {
35 return Ok(None);
36 }
37 let Some(mut paths) = active_paths(view, &pages, &[object]) else {
38 return Ok(None);
39 };
40 let path = paths.pop().unwrap();
41 let node = &view.nodes[&object];
42 let values = match (change, &node.kind) {
43 (onestore::OutlineEdit::Position { .. }, Kind::Outline { .. }) => {
44 vec![json!(node.layout.x), json!(node.layout.y)]
45 }
46 (onestore::OutlineEdit::Width { .. }, Kind::Outline { .. }) => {
47 vec![
48 json!(node.layout.max_width),
49 json!(node.layout.width_set_by_user.unwrap_or(false)),
50 json!(node.layout.reserved_width),
51 ]
52 }
53 (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => {
54 vec![json!(collapse_state.unwrap_or(0))]
55 }
56 _ => return Ok(None),
57 };
58 Ok(Some(Observed { path, values }))
59}
60
61impl Replica {
62 /// Durably records layout or saved expansion changes, preserving independent remote edits.
63 pub fn outline(
64 &self,
65 source: &[u8],
66 space: ExGuid,
67 object: ExGuid,
68 change: onestore::OutlineEdit,
69 ) -> Result<Option<u64>> {
70 let (edit, prepared) = OutlineEdit::capture(source, space, object, change)?;
71 self.record(source, space, Operation::Outline(edit), &prepared)
72 }
73}
74
75impl OutlineEdit {
76 pub(crate) fn capture<'a>(
77 source: &'a [u8],
78 space: ExGuid,
79 object: ExGuid,
80 change: onestore::OutlineEdit,
81 ) -> Result<(Self, PreparedEdit<'a>)> {
82 let prepared = PreparedEdit::outline(source, space, object, change)?;
83 let observed = observe(source, space, object, change)?.ok_or_else(|| {
84 io::Error::new(
85 io::ErrorKind::InvalidData,
86 "Prepared outline edit has no active target",
87 )
88 })?;
89 Ok((
90 Self {
91 object,
92 change,
93 observed,
94 },
95 prepared,
96 ))
97 }
98
99 pub(crate) fn prepare<'a>(
100 &self,
101 snapshot: &'a [u8],
102 space: ExGuid,
103 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
104 let Some(current) = observe(snapshot, space, self.object, self.change)? else {
105 return Ok(Err(ConflictKind::TargetUnavailable));
106 };
107 if current.path != self.observed.path {
108 return Ok(Err(ConflictKind::StructureChanged));
109 }
110 let prepared = match PreparedEdit::outline(snapshot, space, self.object, self.change) {
111 Ok(prepared) => prepared,
112 Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)),
113 };
114 let wanted =
115 observe(prepared.as_bytes(), space, self.object, self.change)?.ok_or_else(|| {
116 io::Error::new(
117 io::ErrorKind::InvalidData,
118 "Prepared outline edit lost its target",
119 )
120 })?;
121 if self.observed.values.len() != current.values.len()
122 || wanted.values.len() != current.values.len()
123 {
124 return Ok(Err(ConflictKind::UnsupportedEdit));
125 }
126 if self
127 .observed
128 .values
129 .iter()
130 .zip(&current.values)
131 .zip(&wanted.values)
132 .any(|((before, current), wanted)| current != before && current != wanted)
133 {
134 return Ok(Err(ConflictKind::LayoutChanged));
135 }
136 Ok(Ok(prepared))
137 }
138}
crates/notebook/src/paragraph.rs deleted-200
......@@ -1,200 +0,0 @@
1use super::*;
2use onestore::{ParagraphJoin, ParagraphSplit};
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8struct Observed {
9 text: String,
10 structure: Value,
11}
12
13/// A stable split intent and the paragraph state observed before local publication.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct SplitEdit {
17 pub intent: ParagraphSplit,
18 observed: Observed,
19}
20
21/// A join intent and both paragraphs' observed text, placement and tag ownership.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct JoinEdit {
25 pub intent: ParagraphJoin,
26 observed: [Observed; 2],
27}
28
29fn observe(source: &[u8], space: ExGuid, texts: &[ExGuid]) -> Result<Option<Vec<Observed>>> {
30 let store = Store::parse(source)?;
31 let index = RevisionIndex::parse(&store)?;
32 let document = Document::parse(&index)?;
33 let Ok(view) = document.active(space) else {
34 return Ok(None);
35 };
36 let pages = document.pages_in(space)?;
37 let Some(paths) = active_paths(view, &pages, texts) else {
38 return Ok(None);
39 };
40 let mut observed = Vec::new();
41 for (text, path) in texts.iter().zip(paths) {
42 let node = &view.nodes[text];
43 let Kind::RichText { text: content, .. } = &node.kind else {
44 return Ok(None);
45 };
46 let Some(paragraph) = path.first().and_then(|id| view.nodes.get(id)) else {
47 return Ok(None);
48 };
49 let Kind::Paragraph { lists, .. } = &paragraph.kind else {
50 return Ok(None);
51 };
52 if paragraph.content != [*text] {
53 return Ok(None);
54 }
55 let lists: Vec<_> = lists.iter().map(|id| (*id, &view.nodes[id].kind)).collect();
56 observed.push(Observed {
57 text: content.clone(),
58 structure: json!({
59 "path": path,
60 "children": paragraph.children,
61 "child_level": paragraph.child_level,
62 "paragraph": paragraph.kind,
63 "lists": lists,
64 "tags": node.tags,
65 }),
66 });
67 }
68 Ok(Some(observed))
69}
70
71impl Replica {
72 /// Durably queues a split with stable new identities and observed structural preconditions.
73 pub fn split(
74 &self,
75 source: &[u8],
76 space: ExGuid,
77 intent: &ParagraphSplit,
78 ) -> Result<Option<u64>> {
79 let (operation, prepared) = SplitEdit::capture(source, space, intent)?;
80 self.record(source, space, Operation::Split(operation), &prepared)
81 }
82
83 /// Durably queues a join; changed placement, children or tags require conflict review.
84 pub fn join(
85 &self,
86 source: &[u8],
87 space: ExGuid,
88 intent: &ParagraphJoin,
89 ) -> Result<Option<u64>> {
90 let (operation, prepared) = JoinEdit::capture(source, space, intent)?;
91 self.record(source, space, Operation::Join(operation), &prepared)
92 }
93}
94
95impl SplitEdit {
96 pub(crate) fn capture<'a>(
97 source: &'a [u8],
98 space: ExGuid,
99 intent: &ParagraphSplit,
100 ) -> Result<(Self, PreparedEdit<'a>)> {
101 let prepared = PreparedEdit::split(source, space, intent)?;
102 let Some(mut observed) = observe(source, space, &[intent.position().0])? else {
103 return Err(io::Error::new(
104 io::ErrorKind::InvalidData,
105 "Select text in an active paragraph",
106 )
107 .into());
108 };
109 Ok((
110 Self {
111 intent: intent.clone(),
112 observed: observed.remove(0),
113 },
114 prepared,
115 ))
116 }
117
118 pub(crate) fn prepare<'a>(
119 &self,
120 source: &'a [u8],
121 space: ExGuid,
122 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
123 let (text, offset) = self.intent.position();
124 let Some(observed) = observe(source, space, &[text])? else {
125 return Ok(Err(ConflictKind::TargetUnavailable));
126 };
127 if observed[0].structure != self.observed.structure {
128 return Ok(Err(ConflictKind::StructureChanged));
129 }
130 let Some(range) = rebase::rebase(&self.observed.text, &observed[0].text, offset..offset)
131 else {
132 return Ok(Err(ConflictKind::TextChanged));
133 };
134 Ok(
135 PreparedEdit::split(source, space, &self.intent.reposition(range.start))
136 .map_err(|_| ConflictKind::UnsupportedEdit),
137 )
138 }
139}
140
141impl JoinEdit {
142 pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> {
143 let (reviewed, _) = Self::capture(source, space, &self.intent)?;
144 if reviewed.observed[0].text.is_empty() != self.observed[0].text.is_empty() {
145 return Err(io::Error::new(
146 io::ErrorKind::InvalidInput,
147 "The join would change its surviving text identity",
148 )
149 .into());
150 }
151 Ok(reviewed)
152 }
153
154 pub(crate) fn capture<'a>(
155 source: &'a [u8],
156 space: ExGuid,
157 intent: &ParagraphJoin,
158 ) -> Result<(Self, PreparedEdit<'a>)> {
159 let prepared = PreparedEdit::join(source, space, intent)?;
160 let Some(observed) = observe(source, space, &intent.texts())? else {
161 return Err(io::Error::new(
162 io::ErrorKind::InvalidData,
163 "Select text in two active paragraphs",
164 )
165 .into());
166 };
167 Ok((
168 Self {
169 intent: intent.clone(),
170 observed: observed.try_into().unwrap(),
171 },
172 prepared,
173 ))
174 }
175
176 pub(crate) fn prepare<'a>(
177 &self,
178 source: &'a [u8],
179 space: ExGuid,
180 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
181 let Some(observed) = observe(source, space, &self.intent.texts())? else {
182 return Ok(Err(ConflictKind::TargetUnavailable));
183 };
184 for (i, (old, new)) in self.observed.iter().zip(&observed).enumerate() {
185 if old.structure != new.structure {
186 return Ok(Err(ConflictKind::StructureChanged));
187 }
188 let at = if i == 0 {
189 u32::try_from(old.text.encode_utf16().count()).map_err(io::Error::other)?
190 } else {
191 0
192 };
193 if rebase::rebase(&old.text, &new.text, at..at).is_none() {
194 return Ok(Err(ConflictKind::TextChanged));
195 }
196 }
197 Ok(PreparedEdit::join(source, space, &self.intent)
198 .map_err(|_| ConflictKind::UnsupportedEdit))
199 }
200}
crates/notebook/src/recovery.rs+14-12
......@@ -31,10 +31,17 @@ impl Recovery {
3131 let application: u32 =
3232 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
3333 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
34 if application != RECOVERY_ID || !(4..=SCHEMA_VERSION).contains(&version) {
34 if application != RECOVERY_ID {
35 return Err(
36 io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(),
37 );
38 }
39 if version != SCHEMA_VERSION {
3540 return Err(io::Error::new(
3641 io::ErrorKind::InvalidData,
37 "Unrecognized recovery archive or unsupported schema version",
42 format!(
43 "Archive schema version {version} is not the supported version {SCHEMA_VERSION}"
44 ),
3845 )
3946 .into());
4047 }
......@@ -138,16 +145,11 @@ impl Replica {
138145}
139146
140147fn summary(connection: &Connection) -> Result<RecoverySummary> {
141 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
142 let (cached_assets, cached_asset_bytes) = if version < 5 {
143 (0, 0)
144 } else {
145 connection.query_row(
146 "SELECT count(*),coalesce(sum(length(data)),0) FROM assets",
147 [],
148 |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)),
149 )?
150 };
148 let (cached_assets, cached_asset_bytes) = connection.query_row(
149 "SELECT count(*),coalesce(sum(length(data)),0) FROM assets",
150 [],
151 |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)),
152 )?;
151153 Ok(connection.query_row(
152154 "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts),
153155 (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts),
crates/notebook/src/schema.rs+2-110
......@@ -1,9 +1,9 @@
11use super::*;
2use rusqlite::{OptionalExtension, Transaction};
2use rusqlite::Transaction;
33
44const CONFLICTS: &str = "CREATE TABLE conflicts (
55 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 6)
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)
77) STRICT;";
88
99const ASSETS: &str = "CREATE TABLE assets (
......@@ -33,111 +33,3 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
3333 transaction.execute_batch(ASSETS)?;
3434 Ok(())
3535}
36
37pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> {
38 if version >= 10 {
39 return Ok(());
40 }
41 if version >= 3 {
42 transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?;
43 transaction.execute_batch(CONFLICTS)?;
44 transaction.execute_batch(
45 "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;",
46 )?;
47 if version < 5 {
48 transaction.execute_batch(ASSETS)?;
49 }
50 transaction.execute_batch("ALTER TABLE attempt RENAME COLUMN revision TO revisions;")?;
51 migrate_attempts(transaction)?;
52 return Ok(());
53 }
54
55 let mut query = transaction.prepare(
56 "SELECT id, space, object, before_text, start, end, replacement FROM edits ORDER BY id",
57 )?;
58 let mut rows = query.query([])?;
59 let mut edits = Vec::new();
60 while let Some(row) = rows.next()? {
61 edits.push(PendingEdit {
62 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
63 space: row.get::<_, String>(1)?.parse()?,
64 operation: Operation::Text(TextEdit {
65 object: row.get::<_, String>(2)?.parse()?,
66 before: row.get(3)?,
67 range: row.get(4)?..row.get(5)?,
68 replacement: row.get(6)?,
69 }),
70 });
71 }
72 drop(rows);
73 drop(query);
74 let sequence: i64 = transaction
75 .query_row(
76 "SELECT seq FROM sqlite_sequence WHERE name='edits'",
77 [],
78 |row| row.get(0),
79 )
80 .optional()?
81 .unwrap_or(0);
82 let (mut attempts, mut conflicts, mut receipts) = (Vec::new(), Vec::new(), Vec::new());
83 if version == 2 {
84 let mut query = transaction.prepare("SELECT edit_id, revision FROM attempt")?;
85 attempts = query
86 .query_map([], |row| {
87 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
88 })?
89 .collect::<std::result::Result<_, _>>()?;
90 let mut query = transaction.prepare("SELECT edit_id, kind FROM conflicts")?;
91 conflicts = query
92 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?
93 .collect::<std::result::Result<_, _>>()?;
94 let mut query = transaction.prepare("SELECT edit_id, revision FROM receipts")?;
95 receipts = query
96 .query_map([], |row| {
97 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
98 })?
99 .collect::<std::result::Result<_, _>>()?;
100 transaction
101 .execute_batch("DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts;")?;
102 }
103 transaction.execute_batch("DROP TABLE edits;")?;
104 create(transaction)?;
105 for edit in edits {
106 transaction.execute(
107 "INSERT INTO edits(id,space,operation) VALUES (?1,?2,?3)",
108 params![
109 i64::try_from(edit.id).map_err(io::Error::other)?,
110 edit.space.to_string(),
111 serde_json::to_string(&edit.operation).map_err(io::Error::other)?
112 ],
113 )?;
114 }
115 // A drained queue must not reuse IDs belonging to existing durable receipts.
116 transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?;
117 transaction.execute(
118 "INSERT INTO sqlite_sequence(name,seq) VALUES ('edits',?1)",
119 [sequence],
120 )?;
121 for (id, revision) in attempts {
122 transaction.execute(
123 "INSERT INTO attempt VALUES (1,?1,?2)",
124 params![id, revision],
125 )?;
126 }
127 for (id, kind) in conflicts {
128 transaction.execute("INSERT INTO conflicts VALUES (?1,?2)", params![id, kind])?;
129 }
130 for (id, revision) in receipts {
131 transaction.execute("INSERT INTO receipts VALUES (?1,?2)", params![id, revision])?;
132 }
133 migrate_attempts(transaction)?;
134 Ok(())
135}
136
137fn migrate_attempts(transaction: &Transaction<'_>) -> Result<()> {
138 transaction.execute_batch(
139 "UPDATE attempt SET revisions=json_object(
140 (SELECT space FROM edits WHERE edits.id=attempt.edit_id), revisions);",
141 )?;
142 Ok(())
143}
crates/notebook/src/sync.rs+50-266
......@@ -3,7 +3,7 @@ use onestore::{CommitError, CommitState};
33use rusqlite::OptionalExtension;
44use std::{
55 collections::BTreeMap,
6 sync::{MutexGuard, TryLockError},
6 sync::{MutexGuard, TryLockError, atomic::Ordering},
77};
88
99/// A single remote file with fresh reads and native-compatible guarded publication.
......@@ -17,13 +17,14 @@ pub trait Remote {
1717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1818#[repr(i64)]
1919pub enum ConflictKind {
20 TextChanged = 0,
21 TargetUnavailable = 1,
22 UnsupportedEdit = 2,
23 FormattingChanged = 3,
24 StructureChanged = 4,
25 LayoutChanged = 5,
26 ContentChanged = 6,
20 /// The edited page or a selected page no longer exists remotely.
21 TargetUnavailable = 0,
22 /// The remote image no longer accepts the intent's publication.
23 UnsupportedEdit = 1,
24 /// Page order or indentation changed remotely in a way the batch cannot absorb.
25 StructureChanged = 2,
26 /// The remote page changed where the local model changed too; review is required.
27 ContentChanged = 3,
2728}
2829
2930#[derive(Debug, Clone, Copy, PartialEq, Eq)]
......@@ -65,6 +66,13 @@ impl Replica {
6566 /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect.
6667 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> {
6768 let _owner = self.sync_owner()?;
69 struct Step<'a>(&'a Replica);
70 impl Drop for Step<'_> {
71 fn drop(&mut self) {
72 self.0.in_flight.store(0, Ordering::Release);
73 }
74 }
75 let _step = Step(self);
6876 let snapshot = remote.read().map_err(Error::RemoteIo)?;
6977 let identity = validate(&snapshot)?;
7078 let (intent, attempted) = {
......@@ -93,6 +101,10 @@ impl Replica {
93101 transaction.commit()?;
94102 return Ok(None);
95103 };
104 self.in_flight.store(
105 i64::try_from(intent.id).map_err(io::Error::other)?,
106 Ordering::Release,
107 );
96108 let attempted = transaction
97109 .query_row(
98110 "SELECT revisions FROM attempt WHERE edit_id=?1",
......@@ -115,10 +127,11 @@ impl Replica {
115127 .get(space)
116128 .is_some_and(|space| space.revisions.contains_key(revision))
117129 }) {
130 // A retired attempt confirms only when its complete effect is observed.
118131 let satisfied = match &intent.operation {
119 Operation::Format(edit) if revisions.len() == 1 => edit
120 .prepare(&snapshot, intent.space)?
121 .is_ok_and(|prepared| prepared.as_bytes() == snapshot),
132 Operation::Page(edit) if revisions.len() == 1 => {
133 page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after)
134 }
122135 _ => false,
123136 };
124137 if !satisfied {
......@@ -154,31 +167,27 @@ impl Replica {
154167 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
155168 .map_err(|_| ConflictKind::StructureChanged),
156169 Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?,
157 Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?,
158 Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?,
159 Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?,
160 Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?,
161 Operation::Tree(edit) => edit.prepare(&snapshot, intent.space)?,
162 Operation::Insert(insertion) => {
163 PreparedEdit::insert(&snapshot, intent.space, insertion)
164 .map_err(|_| ConflictKind::UnsupportedEdit)
170 Operation::DeletePages(pages) => {
171 let store = Store::parse(&snapshot)?;
172 let index = RevisionIndex::parse(&store)?;
173 if pages.iter().any(|page| {
174 index
175 .spaces
176 .get(page)
177 .is_none_or(|space| space.labels.is_empty())
178 }) || Document::parse(&index)?
179 .pages()?
180 .iter()
181 .filter(|(sid, _)| pages.contains(sid))
182 .count()
183 != pages.len()
184 {
185 Err(ConflictKind::TargetUnavailable)
186 } else {
187 PreparedEdit::delete_pages_permanently(&snapshot, pages)
188 .map_err(|_| ConflictKind::UnsupportedEdit)
189 }
165190 }
166 Operation::Text(edit) => paragraph(&snapshot, intent.space, edit.object)?
167 .ok_or(ConflictKind::TargetUnavailable)
168 .and_then(|text| {
169 crate::rebase::rebase(&edit.before, &text, edit.range.clone())
170 .ok_or(ConflictKind::TextChanged)
171 })
172 .and_then(|range| {
173 PreparedEdit::text(
174 &snapshot,
175 intent.space,
176 edit.object,
177 range,
178 &edit.replacement,
179 )
180 .map_err(|_| ConflictKind::UnsupportedEdit)
181 }),
182191 };
183192 let prepared = match candidate {
184193 Ok(prepared) => prepared,
......@@ -267,124 +276,6 @@ impl Replica {
267276 Ok(Some((intent.id, EditStatus::Published { revision })))
268277 }
269278
270 /// Places the oldest text, formatting or split conflict at a reviewed remote UTF-16 range.
271 /// The requested replacement/attributes, local image, intent ID and later edits are preserved.
272 /// Both supplied images must match `snapshot` and `remote_snapshot`; stale review
273 /// returns `Io(ResourceBusy)`. Uncertain publication attempts cannot be rebased.
274 pub fn rebase_conflict(
275 &self,
276 id: u64,
277 local: &[u8],
278 remote: &[u8],
279 range: Range<u32>,
280 ) -> Result<()> {
281 self.resolve_conflict(id, local, remote, |intent| {
282 Ok(match intent.operation {
283 Operation::Text(mut edit) => {
284 let prepared = PreparedEdit::text(
285 remote,
286 intent.space,
287 edit.object,
288 range.clone(),
289 &edit.replacement,
290 )?;
291 if prepared.as_bytes() == remote {
292 return Err(io::Error::new(
293 io::ErrorKind::InvalidInput,
294 "The selected range already contains the replacement",
295 )
296 .into());
297 }
298 edit.before =
299 paragraph(remote, intent.space, edit.object)?.ok_or_else(|| {
300 io::Error::new(
301 io::ErrorKind::InvalidData,
302 "The remote text target is unavailable",
303 )
304 })?;
305 edit.range = range;
306 Operation::Text(edit)
307 }
308 Operation::Format(edit) => Operation::Format(
309 FormatEdit::capture(
310 remote,
311 intent.space,
312 edit.object,
313 range,
314 &edit.attributes,
315 )?
316 .0,
317 ),
318 Operation::Split(edit) => {
319 if !range.is_empty() {
320 return Err(io::Error::new(
321 io::ErrorKind::InvalidInput,
322 "Choose a single split boundary",
323 )
324 .into());
325 }
326 Operation::Split(
327 crate::paragraph::SplitEdit::capture(
328 remote,
329 intent.space,
330 &edit.intent.reposition(range.start),
331 )?
332 .0,
333 )
334 }
335 Operation::Join(_) => {
336 return Err(io::Error::new(
337 io::ErrorKind::InvalidInput,
338 "Review the paragraph join using rebase_join_conflict",
339 )
340 .into());
341 }
342 Operation::Page(_) => {
343 return Err(io::Error::new(
344 io::ErrorKind::InvalidInput,
345 "Review a page model conflict using review_page",
346 )
347 .into());
348 }
349 Operation::Outline(_) => {
350 return Err(io::Error::new(
351 io::ErrorKind::InvalidInput,
352 "Review layout changes using rebase_layout_conflict",
353 )
354 .into());
355 }
356 Operation::Tree(_) => {
357 return Err(io::Error::new(
358 io::ErrorKind::InvalidInput,
359 "Review the subtree edit using rebase_tree_conflict",
360 )
361 .into());
362 }
363 Operation::Insert(_) => {
364 return Err(io::Error::new(
365 io::ErrorKind::InvalidInput,
366 "Insertion conflicts require a placement, not a text range",
367 )
368 .into());
369 }
370 Operation::CreatePage(_) => {
371 return Err(io::Error::new(
372 io::ErrorKind::InvalidInput,
373 "Review page placement using rebase_page_creation_conflict",
374 )
375 .into());
376 }
377 Operation::Pages(_) => {
378 return Err(io::Error::new(
379 io::ErrorKind::InvalidInput,
380 "Review page edits using rebase_pages_conflict",
381 )
382 .into());
383 }
384 })
385 })
386 }
387
388279 /// Repositions an unattempted page-creation conflict, retaining dependent object identities.
389280 pub fn rebase_page_creation_conflict(
390281 &self,
......@@ -431,102 +322,6 @@ impl Replica {
431322 })
432323 }
433324
434 /// Reviews a join against both current cache images, retaining its original text identities.
435 /// The surviving text identity must remain the same for dependent edits.
436 pub fn rebase_join_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> {
437 self.resolve_conflict(id, local, remote, |intent| {
438 let Operation::Join(edit) = intent.operation else {
439 return Err(io::Error::new(
440 io::ErrorKind::InvalidInput,
441 "Select a paragraph join conflict",
442 )
443 .into());
444 };
445 Ok(Operation::Join(edit.review(remote, intent.space)?))
446 })
447 }
448
449 /// Repositions the oldest paragraph insertion conflict against reviewed cache images.
450 /// Object identities and dependent edits are retained; uncertain attempts cannot be moved.
451 pub fn rebase_paragraph_conflict(
452 &self,
453 id: u64,
454 local: &[u8],
455 remote: &[u8],
456 parent: ExGuid,
457 before: Option<ExGuid>,
458 ) -> Result<()> {
459 self.resolve_conflict(id, local, remote, |intent| {
460 let Operation::Insert(insertion) = intent.operation else {
461 return Err(io::Error::new(
462 io::ErrorKind::InvalidInput,
463 "Select a paragraph insertion conflict",
464 )
465 .into());
466 };
467 let insertion = insertion.reposition_paragraph(parent, before)?;
468 PreparedEdit::insert(remote, intent.space, &insertion)?;
469 Ok(Operation::Insert(insertion))
470 })
471 }
472
473 /// Repositions the oldest outline insertion conflict against reviewed cache images.
474 /// Object identities and dependent edits are retained; uncertain attempts cannot be moved.
475 pub fn rebase_outline_conflict(
476 &self,
477 id: u64,
478 local: &[u8],
479 remote: &[u8],
480 page: ExGuid,
481 x: f32,
482 y: f32,
483 ) -> Result<()> {
484 self.resolve_conflict(id, local, remote, |intent| {
485 let Operation::Insert(insertion) = intent.operation else {
486 return Err(io::Error::new(
487 io::ErrorKind::InvalidInput,
488 "Select an outline insertion conflict",
489 )
490 .into());
491 };
492 let insertion = insertion.reposition_outline(page, x, y)?;
493 PreparedEdit::insert(remote, intent.space, &insertion)?;
494 Ok(Operation::Insert(insertion))
495 })
496 }
497
498 /// Reviews the original layout intent against both current cache images.
499 /// Competing property values are replaced only after this explicit review.
500 pub fn rebase_layout_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> {
501 self.resolve_conflict(id, local, remote, |intent| {
502 let Operation::Outline(edit) = intent.operation else {
503 return Err(io::Error::new(
504 io::ErrorKind::InvalidInput,
505 "Select an outline layout conflict",
506 )
507 .into());
508 };
509 Ok(Operation::Outline(
510 OutlineEdit::capture(remote, intent.space, edit.object, edit.change)?.0,
511 ))
512 })
513 }
514
515 /// Reviews the original subtree intent against both current cache images.
516 /// Replacement paragraph identities must remain unchanged for dependent edits.
517 pub fn rebase_tree_conflict(&self, id: u64, local: &[u8], remote: &[u8]) -> Result<()> {
518 self.resolve_conflict(id, local, remote, |intent| {
519 let Operation::Tree(edit) = intent.operation else {
520 return Err(io::Error::new(
521 io::ErrorKind::InvalidInput,
522 "Select a subtree move or deletion conflict",
523 )
524 .into());
525 };
526 Ok(Operation::Tree(edit.review(remote, intent.space)?))
527 })
528 }
529
530325 /// Replaces a conflicting page save with a model reviewed against the remote page.
531326 /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed
532327 /// model must publish against the remote image, and its author is retained.
......@@ -661,32 +456,21 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
661456 revision: revision.parse()?,
662457 }));
663458 }
664 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
665 let column = if version < 10 {
666 "revision"
667 } else {
668 "revisions"
669 };
670459 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(
671 &format!("SELECT attempt.{column}, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1"), [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?;
460 "SELECT attempt.revisions, conflicts.kind, edits.space FROM edits LEFT JOIN attempt ON attempt.edit_id=edits.id LEFT JOIN conflicts ON conflicts.edit_id=edits.id WHERE edits.id=?1", [id], |row| Ok((row.get(0)?,row.get(1)?, row.get(2)?))).optional()?;
672461 Ok(match record {
673462 None => None,
674463 Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation {
675 revision: if version < 10 {
676 revision.parse()?
677 } else {
464 revision: {
678465 let space = space.parse()?;
679466 attempted_revisions(&revision, space)?[&space]
680467 },
681468 }),
682469 Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind {
683 0 => ConflictKind::TextChanged,
684 1 => ConflictKind::TargetUnavailable,
685 2 => ConflictKind::UnsupportedEdit,
686 3 => ConflictKind::FormattingChanged,
687 4 => ConflictKind::StructureChanged,
688 5 => ConflictKind::LayoutChanged,
689 6 => ConflictKind::ContentChanged,
470 0 => ConflictKind::TargetUnavailable,
471 1 => ConflictKind::UnsupportedEdit,
472 2 => ConflictKind::StructureChanged,
473 3 => ConflictKind::ContentChanged,
690474 _ => {
691475 return Err(io::Error::new(
692476 io::ErrorKind::InvalidData,
crates/notebook/src/tree.rs deleted-541
......@@ -1,541 +0,0 @@
1use super::*;
2use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(untagged, deny_unknown_fields)]
9enum Content {
10 Legacy([u8; 32]),
11 Semantic { sha256: [u8; 32] },
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct Observed {
17 path: Vec<(ExGuid, u8)>,
18 siblings: BTreeMap<ExGuid, bool>,
19 destination: Vec<(ExGuid, u8)>,
20 content: Option<Content>,
21}
22
23/// A subtree intent with observed placement, deletion content and replacement identities.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct TreeEdit {
27 pub intent: onestore::TreeEdit,
28 observed: Observed,
29 created: BTreeSet<ExGuid>,
30}
31
32fn fingerprint(
33 store: &Store<'_>,
34 raw: &ResolvedRevision<'_>,
35 root: ExGuid,
36 legacy: bool,
37) -> Result<[u8; 32]> {
38 let mut objects = BTreeMap::new();
39 let mut visiting = BTreeSet::new();
40 let mut pending = vec![(root, false)];
41 while let Some((id, complete)) = pending.pop() {
42 if objects.contains_key(&id) {
43 continue;
44 }
45 let object = &raw.objects[&id];
46 let references = object.references()?;
47 if !complete {
48 if !visiting.insert(id) {
49 return Err(io::Error::new(
50 io::ErrorKind::InvalidData,
51 "Subtree property references form a cycle",
52 )
53 .into());
54 }
55 pending.push((id, true));
56 pending.extend(references.objects.iter().map(|id| (*id, false)));
57 continue;
58 }
59 visiting.remove(&id);
60 let mut hash = Sha256::new();
61 hash.update(object.jcid.to_le_bytes());
62 match object.data {
63 ObjectData::Properties(bytes) => {
64 hash.update([0]);
65 let properties = PropertySets::parse(bytes)?;
66 let mut object_ids = references.objects.into_iter();
67 let mut spaces = references.object_spaces.into_iter();
68 let mut contexts = references.contexts.into_iter();
69 let mut fields = Vec::new();
70 for set in &properties.sets {
71 let mut values = BTreeMap::new();
72 for property in set {
73 let mut value = Sha256::new();
74 match &property.value {
75 Value::NoData => {}
76 Value::Bytes(bytes) => value.update(bytes),
77 Value::References {
78 stream,
79 compact_ids,
80 } => {
81 let references = match stream {
82 IdStream::Objects => &mut object_ids,
83 IdStream::ObjectSpaces => &mut spaces,
84 IdStream::Contexts => &mut contexts,
85 };
86 for reference in references.take(compact_ids.len() / 4) {
87 if !legacy && *stream == IdStream::Objects {
88 let immutable =
89 raw.objects[&reference].jcid & 0x100000 != 0;
90 value.update([u8::from(immutable)]);
91 if !immutable {
92 value.update(reference.guid);
93 value.update(reference.n.to_le_bytes());
94 }
95 value.update(objects[&reference]);
96 } else {
97 value.update(reference.guid);
98 value.update(reference.n.to_le_bytes());
99 }
100 }
101 }
102 Value::Sets(_) => {}
103 }
104 if property.id == 0x14001d7a
105 || (!legacy
106 && property.id == 0x24001c20
107 && matches!(&property.value, Value::References { compact_ids, .. } if compact_ids.is_empty()))
108 {
109 continue;
110 }
111 values.insert(property.id, value);
112 }
113 fields.push(values);
114 }
115 // Arena indices and CompactIDs can change without changing property content.
116 let mut sets = vec![[0; 32]; properties.sets.len()];
117 for at in (0..properties.sets.len()).rev() {
118 for property in &properties.sets[at] {
119 if let Value::Sets(children) = &property.value {
120 let value = fields[at].get_mut(&property.id).unwrap();
121 for child in children.clone() {
122 value.update(sets[child]);
123 }
124 }
125 }
126 let mut set = Sha256::new();
127 for (id, value) in &fields[at] {
128 set.update(id.to_le_bytes());
129 set.update(value.clone().finalize());
130 }
131 sets[at] = set.finalize().into();
132 }
133 hash.update(sets[0]);
134 }
135 ObjectData::File {
136 reference,
137 extension,
138 } => {
139 hash.update([1]);
140 hash.update(Sha256::digest(reference));
141 hash.update(Sha256::digest(extension));
142 if let Some(FileDataReference::Internal(guid)) = object.file_reference()? {
143 hash.update(Sha256::digest(store.file_data(guid)?));
144 }
145 }
146 ObjectData::Encrypted(_) => unreachable!("references rejected encrypted content"),
147 }
148 objects.insert(id, <[u8; 32]>::from(hash.finalize()));
149 }
150 if !legacy {
151 return Ok(objects[&root]);
152 }
153 let mut hash = Sha256::new();
154 for (id, value) in objects {
155 hash.update(id.guid);
156 hash.update(id.n.to_le_bytes());
157 hash.update(value);
158 }
159 Ok(hash.finalize().into())
160}
161
162fn observe(
163 source: &[u8],
164 space: ExGuid,
165 intent: &onestore::TreeEdit,
166 legacy: bool,
167) -> Result<Option<Observed>> {
168 let store = Store::parse(source)?;
169 let index = RevisionIndex::parse(&store)?;
170 let document = Document::parse(&index)?;
171 let Ok(view) = document.active(space) else {
172 return Ok(None);
173 };
174 let pages = document.pages_in(space)?;
175 if pages.len() != 1 {
176 return Ok(None);
177 }
178 let object = intent.object();
179 let mut targets = vec![object];
180 if let Some((parent, _)) = intent.destination() {
181 targets.push(parent);
182 }
183 let Some(paths) = active_paths(view, &pages, &targets) else {
184 return Ok(None);
185 };
186 let Some(parent) = paths[0].first() else {
187 return Ok(None);
188 };
189 let children = &view.nodes[parent].children;
190 let Some(position) = children.iter().position(|id| *id == object) else {
191 return Ok(None);
192 };
193 let levels = |path: &[ExGuid]| {
194 path.iter()
195 .map(|id| (*id, view.nodes[id].child_level.unwrap_or(1)))
196 .collect::<Vec<_>>()
197 };
198 let destination = if let Some((parent, _)) = intent.destination() {
199 levels(&[&[parent], paths[1].as_slice()].concat())
200 } else {
201 Vec::new()
202 };
203 Ok(Some(Observed {
204 path: levels(&paths[0]),
205 siblings: children
206 .iter()
207 .enumerate()
208 .filter_map(|(at, id)| (*id != object).then_some((*id, at < position)))
209 .collect(),
210 destination,
211 content: if intent.destination().is_none() {
212 let sha256 = fingerprint(&store, &index.resolve_active(space)?, object, legacy)?;
213 Some(if legacy {
214 Content::Legacy(sha256)
215 } else {
216 Content::Semantic { sha256 }
217 })
218 } else {
219 None
220 },
221 }))
222}
223
224fn mutable_objects(source: &[u8], space: ExGuid) -> Result<BTreeSet<ExGuid>> {
225 let store = Store::parse(source)?;
226 let index = RevisionIndex::parse(&store)?;
227 let raw = index.resolve_active(space)?;
228 Ok(raw
229 .reachable()?
230 .into_iter()
231 .filter(|id| raw.objects[id].jcid & 0x100000 == 0)
232 .collect())
233}
234
235impl Replica {
236 /// Queues a move or deletion, retaining content changes for explicit deletion review.
237 pub fn tree(
238 &self,
239 source: &[u8],
240 space: ExGuid,
241 intent: &onestore::TreeEdit,
242 ) -> Result<Option<u64>> {
243 let (edit, prepared) = TreeEdit::capture(source, space, intent)?;
244 self.record(source, space, Operation::Tree(edit), &prepared)
245 }
246}
247
248impl TreeEdit {
249 pub(crate) fn capture<'a>(
250 source: &'a [u8],
251 space: ExGuid,
252 intent: &onestore::TreeEdit,
253 ) -> Result<(Self, PreparedEdit<'a>)> {
254 let prepared = PreparedEdit::tree(source, space, intent)?;
255 let observed = observe(source, space, intent, false)?.ok_or_else(|| {
256 io::Error::new(
257 io::ErrorKind::InvalidData,
258 "Prepared subtree edit has no active target",
259 )
260 })?;
261 let before = mutable_objects(source, space)?;
262 let after = mutable_objects(prepared.as_bytes(), space)?;
263 Ok((
264 Self {
265 intent: intent.clone(),
266 observed,
267 created: &after - &before,
268 },
269 prepared,
270 ))
271 }
272
273 pub(crate) fn prepare<'a>(
274 &self,
275 source: &'a [u8],
276 space: ExGuid,
277 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
278 let Some(current) = observe(
279 source,
280 space,
281 &self.intent,
282 matches!(self.observed.content, Some(Content::Legacy(_))),
283 )?
284 else {
285 return Ok(Err(ConflictKind::TargetUnavailable));
286 };
287 let prepared = match PreparedEdit::tree(source, space, &self.intent) {
288 Ok(prepared) => prepared,
289 Err(_) => return Ok(Err(ConflictKind::UnsupportedEdit)),
290 };
291 let after = mutable_objects(prepared.as_bytes(), space)?;
292 if prepared.as_bytes() == source {
293 return Ok(if self.created.is_subset(&after) {
294 Ok(prepared)
295 } else {
296 Err(ConflictKind::StructureChanged)
297 });
298 }
299 if current.path != self.observed.path
300 || current.destination != self.observed.destination
301 || current.siblings.iter().any(|(id, before)| {
302 self.observed
303 .siblings
304 .get(id)
305 .is_some_and(|was_before| before != was_before)
306 })
307 || &after - &mutable_objects(source, space)? != self.created
308 {
309 return Ok(Err(ConflictKind::StructureChanged));
310 }
311 if current.content != self.observed.content {
312 return Ok(Err(ConflictKind::ContentChanged));
313 }
314 Ok(Ok(prepared))
315 }
316
317 pub(crate) fn review(&self, source: &[u8], space: ExGuid) -> Result<Self> {
318 let (reviewed, _) = Self::capture(source, space, &self.intent)?;
319 if reviewed.created != self.created {
320 return Err(io::Error::new(
321 io::ErrorKind::InvalidInput,
322 "The subtree edit would change its replacement paragraph identities",
323 )
324 .into());
325 }
326 Ok(reviewed)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use onestore::Object;
334 use std::sync::Arc;
335
336 fn set(fields: &[(u32, Vec<u8>)]) -> Vec<u8> {
337 let mut bytes = u16::try_from(fields.len()).unwrap().to_le_bytes().to_vec();
338 for (id, _) in fields {
339 bytes.extend(id.to_le_bytes());
340 }
341 for (_, value) in fields {
342 bytes.extend(value);
343 }
344 bytes
345 }
346
347 #[test]
348 fn deletion_fingerprint_resolves_references_and_nested_sets_without_storage_order() {
349 let source = onestore::create_section("fingerprint.one", "Original", "Author").unwrap();
350 let store = Store::parse(&source).unwrap();
351 let root = ExGuid {
352 guid: [1; 16],
353 n: 1,
354 };
355 let child = ExGuid {
356 guid: [2; 16],
357 n: 7,
358 };
359 let hash = |reverse: bool, global: u32, timestamp: u32, change: usize, immutable: bool| {
360 let mut fields = vec![
361 (0x14001d7a, timestamp.to_le_bytes().to_vec()),
362 (0x20000001, vec![]),
363 (
364 0x44000002,
365 set(&[(0x14000003, (u32::from(change == 1)).to_le_bytes().to_vec())]),
366 ),
367 (
368 0x44000004,
369 set(&[(if change == 2 { 0x08000005 } else { 0x88000005 }, vec![])]),
370 ),
371 ];
372 if change != 5 {
373 fields.push((0x24001c20, 0_u32.to_le_bytes().to_vec()));
374 }
375 if change == 6 {
376 fields.push((0x24000007, 0_u32.to_le_bytes().to_vec()));
377 }
378 if reverse {
379 fields.reverse();
380 }
381 let mut bytes = 0x80000001_u32.to_le_bytes().to_vec();
382 bytes.extend(((global << 8) | child.n).to_le_bytes());
383 bytes.extend(set(&fields));
384 let mut child_bytes = 0x80000000_u32.to_le_bytes().to_vec();
385 child_bytes.extend(set(&[(
386 0x14000006,
387 (u32::from(change == 3)).to_le_bytes().to_vec(),
388 )]));
389 let ids = Arc::new(BTreeMap::from([(
390 global,
391 if change == 4 { [3; 16] } else { child.guid },
392 )]));
393 let target = ExGuid {
394 guid: ids[&global],
395 ..child
396 };
397 let raw = ResolvedRevision {
398 roots: BTreeMap::from([(1, root)]),
399 objects: BTreeMap::from([
400 (
401 root,
402 Object {
403 jcid: 0x6000d,
404 reference_count: 1,
405 data: ObjectData::Properties(&bytes),
406 global_ids: ids.clone(),
407 },
408 ),
409 (
410 target,
411 Object {
412 jcid: if immutable { 0x12004d } else { 0x6000e },
413 reference_count: 1,
414 data: ObjectData::Properties(&child_bytes),
415 global_ids: ids,
416 },
417 ),
418 ]),
419 };
420 fingerprint(&store, &raw, root, false).unwrap()
421 };
422 let original = hash(false, 0, 1, 0, false);
423 assert_eq!(original, hash(true, 137, 2, 0, false));
424 assert_eq!(original, hash(true, 137, 2, 5, false));
425 assert_ne!(original, hash(true, 137, 2, 6, false));
426 for change in 1..=4 {
427 assert_ne!(original, hash(true, 137, 2, change, false));
428 }
429 let immutable = hash(false, 0, 1, 0, true);
430 assert_eq!(immutable, hash(true, 137, 2, 4, true));
431 for change in 1..=3 {
432 assert_ne!(immutable, hash(true, 137, 2, change, true));
433 }
434 }
435
436 #[test]
437 fn version_eight_deletion_observations_survive_migration_and_upgrade_only_after_review() {
438 struct Server(Vec<u8>);
439 impl Remote for Server {
440 fn read(&mut self) -> io::Result<Vec<u8>> {
441 Ok(self.0.clone())
442 }
443 fn publish(
444 &mut self,
445 edit: &PreparedEdit<'_>,
446 ) -> std::result::Result<(), onestore::CommitError> {
447 self.0 = edit.as_bytes().to_vec();
448 Ok(())
449 }
450 fn confirm(
451 &mut self,
452 snapshot: &[u8],
453 ) -> std::result::Result<(), onestore::CommitError> {
454 assert!(self.0 == snapshot);
455 Ok(())
456 }
457 }
458 let source = onestore::create_section("legacy.one", "AlphaOmega", "Author").unwrap();
459 let store = Store::parse(&source).unwrap();
460 let index = RevisionIndex::parse(&store).unwrap();
461 let document = Document::parse(&index).unwrap();
462 let (sid, _) = document.pages().unwrap()[0];
463 let view = document.active(sid).unwrap();
464 let text = *view.nodes.iter().find(|(_, node)| matches!(&node.kind, Kind::RichText { text, .. } if text == "AlphaOmega")).unwrap().0;
465 let directory = tempfile::tempdir().unwrap();
466 let path = directory.path().join("legacy.sqlite");
467 let cache = Replica::create(&path, &source).unwrap();
468 cache
469 .format(
470 &source,
471 sid,
472 text,
473 0..10,
474 &[onestore::TextAttribute::Bold(true)],
475 )
476 .unwrap();
477 let split = onestore::ParagraphSplit::new(text, 5, "Author").unwrap();
478 cache
479 .split(&cache.snapshot().unwrap(), sid, &split)
480 .unwrap();
481 let before = cache.snapshot().unwrap();
482 let intent = onestore::TreeEdit::delete(split.object(), "Author").unwrap();
483 let deletion = cache.tree(&before, sid, &intent).unwrap().unwrap();
484 let dependent = cache
485 .edit_text(&cache.snapshot().unwrap(), sid, text, 0..0, "Local ")
486 .unwrap()
487 .unwrap();
488 let mut queue = cache.pending().unwrap();
489 let Operation::Tree(edit) = &mut queue[2].operation else {
490 panic!()
491 };
492 edit.observed = observe(&before, sid, &intent, true).unwrap().unwrap();
493 let serialized = serde_json::to_string(&queue[2].operation).unwrap();
494 let local = cache.snapshot().unwrap();
495 drop(cache);
496 let db = Connection::open(&path).unwrap();
497 db.execute(
498 "UPDATE edits SET operation=?1 WHERE id=?2",
499 params![serialized, i64::try_from(deletion).unwrap()],
500 )
501 .unwrap();
502 db.pragma_update(None, "user_version", 8).unwrap();
503 db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision;")
504 .unwrap();
505 drop(db);
506 let cache = Replica::open(&path).unwrap();
507 assert_eq!(cache.pending().unwrap(), queue);
508 assert!(cache.snapshot().unwrap() == local);
509 let mut server = Server(source);
510 for _ in 0..2 {
511 assert!(matches!(
512 cache.sync_once(&mut server).unwrap(),
513 Some((_, EditStatus::Published { .. }))
514 ));
515 }
516 assert_eq!(
517 cache.sync_once(&mut server).unwrap(),
518 Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged)))
519 );
520 cache
521 .rebase_tree_conflict(deletion, &local, &server.0)
522 .unwrap();
523 let queue = cache.pending().unwrap();
524 let Operation::Tree(edit) = &queue[0].operation else {
525 panic!()
526 };
527 assert!(matches!(
528 edit.observed.content,
529 Some(Content::Semantic { .. })
530 ));
531 for id in [deletion, dependent] {
532 assert!(
533 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
534 );
535 }
536 assert_eq!(
537 paragraph(&server.0, sid, text).unwrap().as_deref(),
538 Some("Local Alpha")
539 );
540 }
541}
crates/notebook/tests/assets.rs+70-54
......@@ -1,4 +1,9 @@
11use notebook::{EditStatus, Error, Operation, Recovery, Replica};
2use onestore::{
3 ExGuid, RevisionIndex, Store,
4 document::Document,
5 page::{Page, PageObject},
6};
27use std::{
38 fs, io,
49 path::Path,
......@@ -6,14 +11,54 @@ use std::{
611 time::Duration,
712};
813
14#[path = "support/model_ops.rs"]
15mod model_ops;
16
917const FIXTURE: &str = "../../corpus/native-external-assets/notebook";
10const LEGACY: &str = "../../corpus/offline-v4/live.sqlite";
1118
12fn copied_cache(root: &Path) -> Replica {
19/// The first body text object of the section referencing the fixture's media.
20fn first_text(bytes: &[u8]) -> ExGuid {
21 let store = Store::parse(bytes).unwrap();
22 let index = RevisionIndex::parse(&store).unwrap();
23 let document = Document::parse(&index).unwrap();
24 document
25 .pages()
26 .unwrap()
27 .into_iter()
28 .find_map(|(space, _)| {
29 Page::from_space(&document, space)
30 .ok()?
31 .objects
32 .iter()
33 .find_map(|object| match object {
34 PageObject::Outline(outline) => outline
35 .paragraphs
36 .iter()
37 .find_map(|p| p.text().map(|t| t.id)),
38 _ => None,
39 })
40 })
41 .unwrap()
42}
43
44/// A cache of the media fixture with a queued text save and a queued page creation.
45fn queued_cache(root: &Path) -> Replica {
1346 let path = root.join("cache.sqlite");
1447 assert!(!path.exists());
15 fs::copy(LEGACY, &path).unwrap();
16 Replica::open(path).unwrap()
48 let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap();
49 let cache = Replica::create(path, &source).unwrap();
50 let text = first_text(&source);
51 model_ops::save(&cache, text, |page| {
52 model_ops::replace_text(page, text, 0..0, "Queued ")
53 })
54 .unwrap()
55 .unwrap();
56 let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap();
57 cache
58 .create_page(&cache.snapshot().unwrap(), &page)
59 .unwrap()
60 .unwrap();
61 cache
1762}
1863
1964fn payload(size: usize) -> (String, Vec<u8>) {
......@@ -39,10 +84,9 @@ impl notebook::discover::Source for Payload {
3984}
4085
4186#[test]
42fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() {
87fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() {
4388 let directory = tempfile::tempdir().unwrap();
44 let original = fs::read(LEGACY).unwrap();
45 let cache = copied_cache(directory.path());
89 let cache = queued_cache(directory.path());
4690 let working = cache.snapshot().unwrap();
4791 let remote = cache.remote_snapshot().unwrap();
4892 let pending = cache.pending().unwrap();
......@@ -51,10 +95,7 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact
5195 [1, 2]
5296 );
5397 let uncertain = cache.status(1).unwrap();
54 assert!(matches!(
55 uncertain,
56 Some(EditStatus::AwaitingConfirmation { .. })
57 ));
98 assert_eq!(uncertain, Some(EditStatus::Pending));
5899 let mut source = notebook::discover::Local::open(FIXTURE).unwrap();
59100 for size in [0, 1024] {
60101 let (name, bytes) = payload(size);
......@@ -99,30 +140,12 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact
99140 );
100141 assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes));
101142 }
102 assert_eq!(fs::read(LEGACY).unwrap(), original);
103}
104
105#[test]
106fn an_original_version_four_archive_remains_read_only_and_has_no_cached_media() {
107 let path = "../../corpus/offline-v4/recovery.sqlite";
108 let before = fs::read(path).unwrap();
109 let archive = Recovery::open(path).unwrap();
110 assert_eq!(archive.pending().unwrap().len(), 2);
111 assert!(matches!(
112 archive.status(1).unwrap(),
113 Some(EditStatus::AwaitingConfirmation { .. })
114 ));
115 assert_eq!(archive.summary().unwrap().cached_assets, 0);
116 assert_eq!(archive.summary().unwrap().cached_asset_bytes, 0);
117 assert!(archive.cached_asset(&payload(0).0, 0).unwrap().is_none());
118 drop(archive);
119 assert_eq!(fs::read(path).unwrap(), before);
120143}
121144
122145#[test]
123146fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() {
124147 let directory = tempfile::tempdir().unwrap();
125 let cache = copied_cache(directory.path());
148 let cache = queued_cache(directory.path());
126149 let (name, bytes) = payload(1024);
127150 let mut source = Payload(Some(Ok(bytes.clone())));
128151 cache
......@@ -154,7 +177,7 @@ fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payl
154177#[test]
155178fn unreferenced_payloads_are_rejected_before_io_or_local_changes() {
156179 let directory = tempfile::tempdir().unwrap();
157 let cache = copied_cache(directory.path());
180 let cache = queued_cache(directory.path());
158181 let mut unused = Payload(None);
159182 assert!(
160183 matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100),
......@@ -186,7 +209,7 @@ fn download_network_wait_does_not_block_local_edits() {
186209 }
187210 }
188211 let directory = tempfile::tempdir().unwrap();
189 let cache = copied_cache(directory.path());
212 let cache = queued_cache(directory.path());
190213 let (name, bytes) = payload(1024);
191214 let (entered, waiting) = mpsc::channel();
192215 let (release, released) = mpsc::channel();
......@@ -202,30 +225,26 @@ fn download_network_wait_does_not_block_local_edits() {
202225 .unwrap()
203226 });
204227 waiting.recv_timeout(Duration::from_secs(5)).unwrap();
205 let pending = cache.pending().unwrap();
206 let Operation::Text(edit) = &pending[0].operation else {
207 panic!()
208 };
209 let id = cache
210 .edit_text(
211 &cache.snapshot().unwrap(),
212 pending[0].space,
213 edit.object,
214 0..0,
215 "during download ",
216 )
217 .unwrap()
218 .unwrap();
228 let text = first_text(&cache.snapshot().unwrap());
229 let id = model_ops::save(&cache, text, |page| {
230 model_ops::replace_text(page, text, 0..0, "during download ")
231 })
232 .unwrap()
233 .unwrap();
219234 release.send(()).unwrap();
220235 assert_eq!(download.join().unwrap(), bytes);
221236 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
237 let Operation::Page(intent) = &cache.pending().unwrap()[2].operation else {
238 panic!()
239 };
240 assert_eq!(intent.text_change().unwrap().3, "during download ");
222241 });
223242}
224243
225244#[test]
226245fn concurrent_downloads_publish_one_immutable_cache_entry() {
227246 let directory = tempfile::tempdir().unwrap();
228 let cache = copied_cache(directory.path());
247 let cache = queued_cache(directory.path());
229248 let (name, bytes) = payload(1024);
230249 let ready = Barrier::new(8);
231250 std::thread::scope(|scope| {
......@@ -287,7 +306,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() {
287306#[test]
288307fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() {
289308 let directory = tempfile::tempdir().unwrap();
290 drop(copied_cache(directory.path()));
309 drop(queued_cache(directory.path()));
291310 let path = directory.path().join("cache.sqlite");
292311 let connection = rusqlite::Connection::open(&path).unwrap();
293312 connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap();
......@@ -346,7 +365,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() {
346365 }
347366 }
348367 let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap();
349 let cache = copied_cache(Path::new(&root));
368 let cache = queued_cache(Path::new(&root));
350369 let (name, bytes) = payload(1024);
351370 if phase == "download" {
352371 cache
......@@ -391,10 +410,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() {
391410 let expected = (phase != "download").then_some(bytes);
392411 assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected);
393412 assert_eq!(cache.pending().unwrap().len(), 2);
394 assert!(matches!(
395 cache.status(1).unwrap(),
396 Some(EditStatus::AwaitingConfirmation { .. })
397 ));
413 assert_eq!(cache.status(1).unwrap(), Some(EditStatus::Pending));
398414 if phase == "archive" {
399415 let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap();
400416 assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected);
......@@ -409,7 +425,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() {
409425#[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"]
410426fn live_smb_downloads_survive_disconnect_and_cache_reopen() {
411427 let root = tempfile::tempdir().unwrap();
412 let cache = copied_cache(root.path());
428 let cache = queued_cache(root.path());
413429 let client = notebook::smb::Client::connect(
414430 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
415431 "agent",
crates/notebook/tests/cache.rs+261-543
......@@ -1,7 +1,8 @@
1use notebook::{Error, Replica};
1use notebook::{Error, Operation, Replica};
22use onestore::{
33 ExGuid, RevisionIndex, Store,
44 document::{Document, Kind},
5 page::{Outline, PageObject},
56};
67use std::{
78 collections::BTreeSet,
......@@ -11,6 +12,9 @@ use std::{
1112 time::{Duration, Instant},
1213};
1314
15#[path = "support/model_ops.rs"]
16mod model_ops;
17
1418fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
1519 let store = Store::parse(source).unwrap();
1620 assert!(store.checksum_mismatches.is_empty());
......@@ -32,6 +36,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
3236 .unwrap()
3337}
3438
39fn text_of(page: &onestore::page::Page, text: ExGuid) -> String {
40 model_ops::paragraph_with(page, text)
41 .unwrap()
42 .text()
43 .unwrap()
44 .text
45 .text()
46 .to_owned()
47}
48
49fn intent(replica: &Replica, at: usize) -> notebook::PageIntent {
50 match &replica.pending().unwrap()[at].operation {
51 Operation::Page(intent) => intent.clone(),
52 other => panic!("{other:?}"),
53 }
54}
55
3556#[test]
3657fn cache_reopen_preserves_exact_images_and_intents() {
3758 let dir = tempfile::tempdir().unwrap();
......@@ -41,68 +62,85 @@ fn cache_reopen_preserves_exact_images_and_intents() {
4162 assert_eq!(replica.snapshot().unwrap(), source);
4263 assert!(replica.pending().unwrap().is_empty());
4364 let (sid, oid, _) = target(&source);
44 assert_eq!(
45 replica.edit_text(&source, sid, oid, 0..0, "").unwrap(),
46 None
47 );
48 assert_eq!(
49 replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(),
50 None
51 );
52 let first = replica
53 .edit_text(&source, sid, oid, 5..7, "🐈 日本語")
54 .unwrap()
55 .unwrap();
65 let unchanged = |page: &mut onestore::page::Page| {
66 model_ops::replace_text(page, oid, 0..0, "");
67 model_ops::replace_text(page, oid, 0..4, "café");
68 };
69 assert_eq!(model_ops::save(&replica, oid, unchanged).unwrap(), None);
70 let first = model_ops::save(&replica, oid, |page| {
71 model_ops::replace_text(page, oid, 5..7, "🐈 日本語")
72 })
73 .unwrap()
74 .unwrap();
5675 let edited = replica.snapshot().unwrap();
5776 assert_eq!(target(&edited).2, "café 🐈 日本語");
5877 let intents = replica.pending().unwrap();
5978 assert_eq!(intents.len(), 1);
60 assert_eq!(intents[0].id, first);
61 let notebook::Operation::Text(first_edit) = &intents[0].operation else {
62 panic!()
63 };
64 assert_eq!(first_edit.before, "café 🦀");
65 assert_eq!(first_edit.range, 5..7);
66 assert_eq!(first_edit.replacement, "🐈 日本語");
67 assert_eq!((intents[0].space, first_edit.object), (sid, oid));
79 assert_eq!((intents[0].id, intents[0].space), (first, sid));
80 let first_intent = intent(&replica, 0);
81 assert_eq!(
82 first_intent.text_change(),
83 Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into()))
84 );
85 assert_eq!(first_intent.author, model_ops::AUTHOR);
6886 drop(replica);
6987 let replica = Replica::open(&path).unwrap();
7088 assert_eq!(replica.snapshot().unwrap(), edited);
7189 assert_eq!(replica.pending().unwrap(), intents);
72 let second = replica
73 .edit_text(&edited, sid, oid, 0..0, "Recovered ")
74 .unwrap()
75 .unwrap();
76 assert!(second > first);
77 let notebook::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation else {
78 panic!()
79 };
80 assert_eq!(second_edit.before, "café 🐈 日本語");
90 let second = model_ops::save(&replica, oid, |page| {
91 model_ops::replace_text(page, oid, 0..0, "Recovered ")
92 })
93 .unwrap()
94 .unwrap();
95 assert_eq!(second, first, "an unattempted save is replaced in place");
96 let coalesced = intent(&replica, 0);
97 assert_eq!(coalesced.before, first_intent.before);
98 assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語");
99 assert_eq!(
100 target(&replica.snapshot().unwrap()).2,
101 "Recovered café 🐈 日本語"
102 );
81103}
82104
83105#[test]
84fn failed_edits_preserve_both_intent_queue_and_working_image() {
106fn failed_saves_preserve_both_intent_queue_and_working_image() {
85107 let dir = tempfile::tempdir().unwrap();
86108 let path = dir.path().join("section.sqlite");
87109 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
88110 let replica = Replica::create(&path, &source).unwrap();
89111 let (sid, oid, _) = target(&source);
90 for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] {
91 assert!(
92 replica
93 .edit_text(&source, sid, oid, range, replacement)
94 .is_err()
95 );
96 assert_eq!(replica.snapshot().unwrap(), source);
97 assert!(replica.pending().unwrap().is_empty());
98 }
112 let (_, mut page) = model_ops::locate(&source, oid);
113 let mut empty = Outline {
114 id: onestore::page::text::new_id().unwrap(),
115 title: false,
116 min_width: None,
117 layout: Default::default(),
118 indents: Vec::new(),
119 paragraphs: Vec::new(),
120 unsupported: Vec::new(),
121 };
122 empty.layout.x = Some(72.0);
123 empty.layout.y = Some(400.0);
124 page.objects.push(PageObject::Outline(empty));
125 assert!(replica.save(&source, sid, &page, "Author").is_err());
126 let (_, mut page) = model_ops::locate(&source, oid);
127 model_ops::replace_text(&mut page, oid, 0..0, "Elsewhere ");
128 let foreign = ExGuid::default();
129 assert!(replica.save(&source, foreign, &page, "Author").is_err());
130 assert_eq!(replica.snapshot().unwrap(), source);
131 assert!(replica.pending().unwrap().is_empty());
99132 drop(replica);
100133 let connection = rusqlite::Connection::open(&path).unwrap();
101134 connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap();
102135 drop(connection);
103136 let replica = Replica::open(&path).unwrap();
104137 assert!(matches!(
105 replica.edit_text(&source, sid, oid, 0..0, "lost? "),
138 model_ops::save(&replica, oid, |page| model_ops::replace_text(
139 page,
140 oid,
141 0..0,
142 "lost? "
143 )),
106144 Err(Error::Database(_))
107145 ));
108146 drop(replica);
......@@ -113,11 +151,11 @@ fn failed_edits_preserve_both_intent_queue_and_working_image() {
113151
114152#[test]
115153fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
116 use notebook::{Operation, Recovery};
154 use notebook::Recovery;
117155
118156 let directory = tempfile::tempdir().unwrap();
119157 let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap();
120 let (space, object, _) = target(&source);
158 let (_, object, _) = target(&source);
121159 let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();
122160 let start = Barrier::new(4);
123161 std::thread::scope(|scope| {
......@@ -127,14 +165,10 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
127165 start.wait();
128166 for edit in 0..20 {
129167 loop {
130 let source = replica.snapshot().unwrap();
131 match replica.edit_text(
132 &source,
133 space,
134 object,
135 0..0,
136 &format!("[{writer}:{edit}] "),
137 ) {
168 let marker = format!("[{writer}:{edit}] ");
169 match model_ops::save(replica, object, |page| {
170 model_ops::replace_text(page, object, 0..0, &marker)
171 }) {
138172 Ok(Some(_)) => break,
139173 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => {
140174 continue;
......@@ -151,16 +185,20 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
151185 replica.export_recovery(&path).unwrap();
152186 let archive = Recovery::open(path).unwrap();
153187 let pending = archive.pending().unwrap();
154 let mut expected = String::new();
155 for edit in pending.iter().rev() {
156 let Operation::Text(edit) = &edit.operation else {
157 panic!()
158 };
159 assert_eq!(edit.range, 0..0);
160 expected.push_str(&edit.replacement);
188 let snapshot = target(&archive.snapshot().unwrap()).2;
189 match pending.as_slice() {
190 [] => assert_eq!(snapshot, "base"),
191 [single] => {
192 let Operation::Page(intent) = &single.operation else {
193 panic!()
194 };
195 let (id, before, range, replacement) = intent.text_change().unwrap();
196 assert_eq!((id, before.as_str(), range), (object, "base", 0..0));
197 assert_eq!(snapshot, format!("{replacement}base"));
198 assert_eq!(text_of(&intent.after, object), snapshot);
199 }
200 more => panic!("saves to one page coalesce: {more:?}"),
161201 }
162 expected.push_str("base");
163 assert_eq!(target(&archive.snapshot().unwrap()).2, expected);
164202 assert_eq!(archive.remote_snapshot().unwrap(), source);
165203 assert_eq!(
166204 archive.summary().unwrap().queued_edits,
......@@ -169,7 +207,13 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
169207 assert!(archive.receipts().unwrap().is_empty());
170208 }
171209 });
172 assert_eq!(replica.pending().unwrap().len(), 60);
210 assert_eq!(replica.pending().unwrap().len(), 1);
211 let content = target(&replica.snapshot().unwrap()).2;
212 for writer in 0..3 {
213 for edit in 0..20 {
214 assert_eq!(content.matches(&format!("[{writer}:{edit}] ")).count(), 1);
215 }
216 }
173217}
174218
175219#[test]
......@@ -192,15 +236,39 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
192236 assert_eq!(replica.snapshot().unwrap(), source);
193237 }
194238 drop(replica);
239 let current: u32 = rusqlite::Connection::open(&path)
240 .unwrap()
241 .pragma_query_value(None, "user_version", |row| row.get(0))
242 .unwrap();
195243 for sql in [
196 "PRAGMA application_id=0",
197 "PRAGMA application_id=1330529615; PRAGMA user_version=99",
244 "PRAGMA application_id=0".to_owned(),
245 format!(
246 "PRAGMA application_id=1330529615; PRAGMA user_version={}",
247 current - 1
248 ),
249 format!("PRAGMA user_version={}", current + 1),
198250 ] {
199251 let connection = rusqlite::Connection::open(&path).unwrap();
200 connection.execute_batch(sql).unwrap();
252 connection.execute_batch(&sql).unwrap();
201253 drop(connection);
202254 let before = fs::read(&path).unwrap();
203 assert!(Replica::open(&path).is_err());
255 let Err(Error::Io(error)) = Replica::open(&path) else {
256 panic!("{sql}")
257 };
258 assert_eq!(error.kind(), ErrorKind::InvalidData, "{sql}");
259 if sql.contains("user_version") {
260 let written = sql.rsplit('=').next().unwrap();
261 let message = error.to_string();
262 assert!(
263 message.contains(&format!("version {written} "))
264 && message.contains(&format!("version {current}")),
265 "{message}"
266 );
267 }
268 assert!(
269 matches!(notebook::Recovery::open(&path), Err(Error::Io(error)) if error.kind() == ErrorKind::InvalidData),
270 "{sql}"
271 );
204272 assert_eq!(fs::read(&path).unwrap(), before);
205273 }
206274 let foreign = dir.path().join("foreign.sqlite");
......@@ -221,7 +289,7 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
221289}
222290
223291#[test]
224fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() {
292fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
225293 let dir = tempfile::tempdir().unwrap();
226294 let path = dir.path().join("section.sqlite");
227295 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();
......@@ -234,11 +302,11 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement()
234302 .map(|writer| {
235303 let (replica, source, barrier) = (&replica, &source, &barrier);
236304 scope.spawn(move || {
305 let (_, mut page) = model_ops::locate(source, oid);
306 model_ops::replace_text(&mut page, oid, 0..0, &format!("[initial-{writer}] "));
237307 barrier.wait();
238 let first =
239 replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] "));
240308 let mut ids = Vec::new();
241 let first_won = match first {
309 let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) {
242310 Ok(Some(id)) => {
243311 ids.push(id);
244312 true
......@@ -252,14 +320,10 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement()
252320 Instant::now() < deadline,
253321 "Writer {writer} stopped progressing at {edit}"
254322 );
255 let source = replica.snapshot().unwrap();
256 match replica.edit_text(
257 &source,
258 sid,
259 oid,
260 0..0,
261 &format!("[{writer}-{edit}] "),
262 ) {
323 let marker = format!("[{writer}-{edit}] ");
324 match model_ops::save(replica, oid, |page| {
325 model_ops::replace_text(page, oid, 0..0, &marker)
326 }) {
263327 Ok(Some(id)) => {
264328 ids.push(id);
265329 break;
......@@ -281,48 +345,39 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement()
281345 });
282346 assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1);
283347 let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect();
284 assert_eq!(ids.len(), 241);
348 assert_eq!(ids.len(), 1, "every save replaced the unattempted head");
285349 let final_bytes = replica.snapshot().unwrap();
286350 let content = target(&final_bytes).2;
287 let mut expected = "Shared café 🦀".to_owned();
288 for pending in replica.pending().unwrap() {
289 let notebook::Operation::Text(edit) = pending.operation else {
290 panic!()
291 };
292 assert_eq!(edit.before, expected);
293 assert_eq!(edit.range, 0..0);
294 expected.insert_str(0, &edit.replacement);
295 }
296 assert_eq!(content, expected);
351 let pending = replica.pending().unwrap();
352 assert_eq!(pending.len(), 1);
353 assert_eq!(pending[0].id, *ids.first().unwrap());
354 let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap();
355 assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0));
356 assert_eq!(content, format!("{replacement}Shared café 🦀"));
357 assert_eq!(content.matches("[initial-").count(), 1);
297358 for writer in 0..12 {
298359 for edit in 0..20 {
299360 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);
300361 }
301362 }
302 assert_eq!(
303 replica
304 .pending()
305 .unwrap()
306 .iter()
307 .map(|edit| edit.id)
308 .collect::<BTreeSet<_>>(),
309 ids
310 );
363 let (_, mut stale) = model_ops::locate(&source, oid);
364 model_ops::replace_text(&mut stale, oid, 0..0, "stale ");
311365 assert!(
312 matches!(replica.edit_text(&source, sid, oid, 0..0, ""), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
366 matches!(replica.save(&source, sid, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
313367 );
314368 drop(replica);
315369 let reopened = Replica::open(&path).unwrap();
316370 assert_eq!(reopened.snapshot().unwrap(), final_bytes);
317 assert_eq!(reopened.pending().unwrap().len(), 241);
371 assert_eq!(reopened.pending().unwrap(), pending);
318372}
319373
320374#[test]
321375fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
322376 let dir = tempfile::tempdir().unwrap();
323377 let path = dir.path().join("model.sqlite");
324 let mut text = "ab🚀ab🦀 é repeated repeated".to_owned();
325 let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap();
378 let original = "ab🚀ab🦀 é repeated repeated".to_owned();
379 let mut text = original.clone();
380 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();
326381 let mut replica = Replica::create(&path, &source).unwrap();
327382 let (space, object, _) = target(&source);
328383 let mut random = 911_u64;
......@@ -332,8 +387,9 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
332387 random ^= random << 17;
333388 random
334389 };
335 let mut intents = Vec::new();
336 for step in 0..1024 {
390 let mut acknowledged = 0;
391 let mut pending = Vec::new();
392 for step in 0..512 {
337393 let boundaries: Vec<_> = text
338394 .char_indices()
339395 .map(|(at, _)| at)
......@@ -344,283 +400,110 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
344400 let bytes = first.min(last)..first.max(last);
345401 let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap()
346402 ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap();
347 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
403 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
348404 let mut expected = text.clone();
349405 expected.replace_range(bytes, replacement);
350 let acknowledgement = replica
351 .edit_text(&source, space, object, range.clone(), replacement)
352 .unwrap();
406 let before = replica.snapshot().unwrap();
407 let acknowledgement = model_ops::save(&replica, object, |page| {
408 model_ops::replace_text(page, object, range.clone(), replacement)
409 })
410 .unwrap();
353411 if let Some(id) = acknowledgement {
354412 assert_ne!(text, expected);
355 assert!(
356 intents
357 .last()
358 .is_none_or(|edit: &notebook::PendingEdit| edit.id < id)
359 );
360 intents.push(notebook::PendingEdit {
361 id,
362 space,
363 operation: notebook::Operation::Text(notebook::TextEdit {
364 object,
365 before: text,
366 range,
367 replacement: replacement.into(),
368 }),
369 });
370 assert!(
371 matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
372 );
413 acknowledged += 1;
414 pending = replica.pending().unwrap();
415 assert_eq!(pending.len(), 1);
416 assert_eq!((pending[0].id, pending[0].space), (1, space));
417 let saved = intent(&replica, 0);
418 assert_eq!(id, 1);
419 assert_eq!(text_of(&saved.before, object), original);
420 assert_eq!(text_of(&saved.after, object), expected);
421 if step % 37 == 0 {
422 let (_, mut stale) = model_ops::locate(&before, object);
423 model_ops::replace_text(&mut stale, object, 0..0, "stale");
424 assert!(
425 matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
426 );
427 }
373428 } else {
374429 assert_eq!(text, expected);
375430 }
376431 text = expected;
377 source = replica.snapshot().unwrap();
432 let source = replica.snapshot().unwrap();
378433 assert_eq!(target(&source).2, text, "seed 911, step {step}");
379434 if step % 37 == 0 {
380435 drop(replica);
381436 replica = Replica::open(&path).unwrap();
382437 assert_eq!(replica.snapshot().unwrap(), source);
383 assert_eq!(replica.pending().unwrap(), intents);
438 assert_eq!(replica.pending().unwrap(), pending);
384439 }
385440 }
386 assert!(
387 intents.len() > 512,
388 "The history checkpoint boundary was not exercised"
389 );
441 assert!(acknowledged > 256, "Most random edits change the text");
442 let source = replica.snapshot().unwrap();
390443 drop(replica);
391444 let replica = Replica::open(&path).unwrap();
392 assert_eq!(replica.pending().unwrap(), intents);
445 assert_eq!(replica.pending().unwrap(), pending);
393446 assert_eq!(replica.snapshot().unwrap(), source);
394447}
395448
396#[test]
397#[ignore = "migrates a fresh copy of a retained version-two or version-three cache"]
398fn migrate_retained_cache_copy() {
399 use notebook::{EditStatus, Operation, PendingEdit, TextEdit};
400 let source = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_SOURCE").unwrap());
401 let output = std::path::PathBuf::from(std::env::var_os("ONESTORE_MIGRATION_OUTPUT").unwrap());
402 assert!(source.is_absolute() && output.is_absolute());
403 let mut original = fs::File::open(&source).unwrap();
404 let mut destination = fs::OpenOptions::new()
405 .write(true)
406 .create_new(true)
407 .open(&output)
408 .unwrap();
409 std::io::copy(&mut original, &mut destination).unwrap();
410 destination.sync_all().unwrap();
411 drop(destination);
412 let db = rusqlite::Connection::open(&output).unwrap();
413 let version = db
414 .pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0))
415 .unwrap();
416 assert!(matches!(version, 2 | 3));
417 if std::env::var_os("ONESTORE_MIGRATION_ROLLBACK").is_some() {
418 assert_eq!(version, 2);
419 db.pragma_update(None, "foreign_keys", false).unwrap();
420 db.execute(
421 "INSERT INTO attempt VALUES (1,999999,'{00000001-0000-0000-0000-000000000000},1')",
422 [],
423 )
424 .unwrap();
425 drop(db);
426 let before = fs::read(&output).unwrap();
427 assert!(
428 matches!(Replica::open(&output), Err(Error::Database(rusqlite::Error::SqliteFailure(error, _))) if error.extended_code == 787)
429 );
430 assert_eq!(fs::read(&output).unwrap(), before);
431 let db = rusqlite::Connection::open(&output).unwrap();
432 assert_eq!(
433 db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0))
434 .unwrap(),
435 2
436 );
437 println!("migration: rollback preserved {} bytes", before.len());
438 return;
439 }
440 if std::env::var_os("ONESTORE_MIGRATION_CONFLICT").is_some() {
441 db.execute("INSERT INTO conflicts SELECT min(id),0 FROM edits", [])
442 .unwrap();
443 }
444 let (base, working): (Vec<u8>, Vec<u8>) = db
445 .query_row("SELECT base,working FROM replica", [], |r| {
446 Ok((r.get(0)?, r.get(1)?))
447 })
448 .unwrap();
449 let sequence: i64 = db
450 .query_row(
451 "SELECT seq FROM sqlite_sequence WHERE name='edits'",
452 [],
453 |r| r.get(0),
454 )
455 .unwrap();
456 let pending: Vec<PendingEdit> =
457 if version == 2 {
458 let mut query = db
459 .prepare("SELECT id,space,object,before_text,start,end,replacement FROM edits ORDER BY id")
460 .unwrap();
461 let pending: Vec<PendingEdit> = query
462 .query_map([], |r| {
463 Ok(PendingEdit {
464 id: u64::try_from(r.get::<_, i64>(0)?).unwrap(),
465 space: r.get::<_, String>(1)?.parse().unwrap(),
466 operation: Operation::Text(TextEdit {
467 object: r.get::<_, String>(2)?.parse().unwrap(),
468 before: r.get(3)?,
469 range: r.get(4)?..r.get(5)?,
470 replacement: r.get(6)?,
471 }),
472 })
473 })
474 .unwrap()
475 .collect::<Result<_, _>>()
476 .unwrap();
477 drop(query);
478 pending
479 } else {
480 let mut query = db
481 .prepare("SELECT id,space,operation FROM edits ORDER BY id")
482 .unwrap();
483 query
484 .query_map([], |r| {
485 Ok(PendingEdit {
486 id: u64::try_from(r.get::<_, i64>(0)?).unwrap(),
487 space: r.get::<_, String>(1)?.parse().unwrap(),
488 operation: serde_json::from_str(&r.get::<_, String>(2)?).unwrap(),
489 })
490 })
491 .unwrap()
492 .collect::<Result<_, _>>()
493 .unwrap()
494 };
495 let mut states = std::collections::BTreeMap::new();
496 for edit in &pending {
497 states.insert(edit.id, EditStatus::Pending);
498 }
499 for table in ["receipts", "attempt"] {
500 let mut query = db
501 .prepare(&format!("SELECT edit_id,revision FROM {table}"))
502 .unwrap();
503 for row in query
504 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
505 .unwrap()
506 {
507 let (id, rid) = row.unwrap();
508 let revision = rid.parse().unwrap();
509 states.insert(
510 u64::try_from(id).unwrap(),
511 if table == "receipts" {
512 EditStatus::Published { revision }
513 } else {
514 EditStatus::AwaitingConfirmation { revision }
515 },
516 );
517 }
518 }
519 let mut query = db.prepare("SELECT edit_id,kind FROM conflicts").unwrap();
520 for row in query
521 .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, u32>(1)?)))
522 .unwrap()
523 {
524 let (id, kind) = row.unwrap();
525 assert_eq!(kind, 0);
526 states.insert(
527 u64::try_from(id).unwrap(),
528 EditStatus::Conflict(notebook::ConflictKind::TextChanged),
529 );
530 }
531 drop(query);
532 drop(db);
533 let cache = Replica::open(&output).unwrap();
534 assert_eq!(cache.snapshot().unwrap(), working);
535 assert_eq!(cache.remote_snapshot().unwrap(), base);
536 assert_eq!(cache.pending().unwrap(), pending);
537 for (id, status) in &states {
538 assert_eq!(cache.status(*id).unwrap(), Some(*status));
539 }
540 drop(cache);
541 let cache = Replica::open(&output).unwrap();
542 assert_eq!(cache.pending().unwrap(), pending);
543 let store = Store::parse(&working).unwrap();
544 let index = RevisionIndex::parse(&store).unwrap();
545 let doc = Document::parse(&index).unwrap();
546 let (sid, page) = doc.pages().unwrap()[0];
547 let insertion = onestore::Insertion::outline(
548 page,
549 144.0,
550 720.0,
551 "After cache migration",
552 "Migration author",
553 )
554 .unwrap();
555 let next = cache.insert(&working, sid, &insertion).unwrap().unwrap();
556 assert_eq!(next, u64::try_from(sequence + 1).unwrap());
557 let updated = cache.snapshot().unwrap();
558 drop(cache);
559 let cache = Replica::open(&output).unwrap();
560 assert_eq!(cache.snapshot().unwrap(), updated);
561 assert_eq!(
562 cache.pending().unwrap().last().unwrap().operation,
563 Operation::Insert(insertion)
564 );
565 println!(
566 "migration: {}",
567 serde_json::json!({"pending":pending.len(),"retained_statuses":states.len(),"next_id":next,"base_bytes":base.len(),"working_bytes":working.len()})
568 );
569}
570
571449#[test]
572450fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
573 use onestore::Insertion;
574451 let directory = tempfile::tempdir().unwrap();
575452 let path = directory.path().join("parallel.sqlite");
576453 let source = onestore::create_section("parallel.one", "Original", "Author").unwrap();
577 let store = Store::parse(&source).unwrap();
578 let index = RevisionIndex::parse(&store).unwrap();
579 let doc = Document::parse(&index).unwrap();
580 let (sid, page) = doc.pages().unwrap()[0];
454 let (sid, anchor, _) = target(&source);
581455 let cache = Replica::create(&path, &source).unwrap();
582456 let barrier = Barrier::new(12);
583457 let deadline = Instant::now() + Duration::from_secs(90);
458 let italic = |format: &mut onestore::document::Format| format.italic = Some(true);
584459 let all = std::thread::scope(|scope| {
585460 let handles: Vec<_> = (0..12)
586461 .map(|client| {
587462 let (cache, barrier) = (&cache, &barrier);
588463 scope.spawn(move || {
589 let outline = Insertion::outline(
590 page,
591 72.0,
592 144.0 + client as f32 * 72.0,
593 &format!("Client {client}"),
594 "Author",
595 )
596 .unwrap();
597464 let mut ids = Vec::new();
598465 let mut objects = Vec::new();
599466 barrier.wait();
600467 for sequence in 0..4 {
601 let insertion = if sequence == 0 {
602 outline.clone()
468 let content = if sequence == 0 {
469 format!("Client {client}")
603470 } else {
604 Insertion::paragraph(
605 outline.object(),
606 None,
607 &format!("Paragraph {client}:{sequence}"),
608 "Author",
609 )
610 .unwrap()
611 }
612 .with_formatting(0..6, &[onestore::TextAttribute::Italic(true)])
613 .unwrap();
471 format!("Paragraph {client}:{sequence}")
472 };
473 let mut inserted = None;
614474 loop {
615475 assert!(
616476 Instant::now() < deadline,
617477 "Client {client} stopped at insertion {sequence}"
618478 );
619 let snapshot = cache.snapshot().unwrap();
620 match cache.insert(&snapshot, sid, &insertion) {
479 let result = model_ops::save(cache, anchor, |page| {
480 let text = if sequence == 0 {
481 model_ops::insert_outline(
482 page,
483 72.0,
484 144.0 + client as f32 * 72.0,
485 &content,
486 )
487 .2
488 } else {
489 model_ops::insert_after(
490 page,
491 *objects.last().unwrap(),
492 &content,
493 )
494 .1
495 };
496 let end = content.encode_utf16().count() as u32;
497 model_ops::restyle(page, text, 0..end, |format| {
498 format.italic = None
499 });
500 model_ops::restyle(page, text, 0..6, italic);
501 inserted = Some(text);
502 });
503 match result {
621504 Ok(Some(id)) => {
622505 ids.push(id);
623 objects.push(insertion.text_object());
506 objects.push(inserted.unwrap());
624507 break;
625508 }
626509 Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {}
......@@ -630,19 +513,15 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
630513 if sequence == 0 {
631514 continue;
632515 }
516 let text = *objects.last().unwrap();
633517 loop {
634518 assert!(
635519 Instant::now() < deadline,
636520 "Client {client} stopped at text {sequence}"
637521 );
638 let snapshot = cache.snapshot().unwrap();
639 match cache.edit_text(
640 &snapshot,
641 sid,
642 insertion.text_object(),
643 0..0,
644 "Edited ",
645 ) {
522 match model_ops::save(cache, text, |page| {
523 model_ops::replace_text(page, text, 0..0, "Edited ")
524 }) {
646525 Ok(Some(id)) => {
647526 ids.push(id);
648527 break;
......@@ -665,7 +544,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
665544 .iter()
666545 .flat_map(|(ids, _)| ids.iter().copied())
667546 .collect();
668 assert_eq!(ids.len(), 84);
547 assert_eq!(all.iter().map(|(ids, _)| ids.len()).sum::<usize>(), 84);
548 assert_eq!(ids.len(), 1);
669549 let snapshot = cache.snapshot().unwrap();
670550 drop(cache);
671551 let cache = Replica::open(&path).unwrap();
......@@ -709,69 +589,41 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
709589
710590#[test]
711591fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
712 for operation in ["Text", "Insert", "Format", "Split", "Join", "Outline"] {
592 for operation in ["Page", "CreatePage", "Pages", "DeletePages"] {
713593 let directory = tempfile::tempdir().unwrap();
714594 let path = directory.path().join("unknown.sqlite");
715 let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap();
716 let (sid, oid, _) = target(&source);
717 let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap();
718 if operation == "Join" {
719 source = onestore::PreparedEdit::split(&source, sid, &split)
720 .unwrap()
721 .as_bytes()
722 .to_vec();
723 }
595 let first = onestore::create_section("unknown.one", "Original", "Author").unwrap();
596 let second = onestore::PageCreation::new(None, Some("Second"), "Author").unwrap();
597 let source = onestore::PreparedEdit::create_page(&first, &second)
598 .unwrap()
599 .as_bytes()
600 .to_vec();
601 let (_, oid, _) = target(&source);
602 let store = Store::parse(&source).unwrap();
603 let index = RevisionIndex::parse(&store).unwrap();
604 let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0;
724605 let cache = Replica::create(&path, &source).unwrap();
725 if operation == "Insert" {
726 let store = Store::parse(&source).unwrap();
727 let index = RevisionIndex::parse(&store).unwrap();
728 let doc = Document::parse(&index).unwrap();
729 let (_, page) = doc.pages().unwrap()[0];
730 let insertion =
731 onestore::Insertion::outline(page, 144.0, 144.0, "Inserted", "Author").unwrap();
732 cache.insert(&source, sid, &insertion).unwrap();
733 } else if operation == "Outline" {
734 let store = Store::parse(&source).unwrap();
735 let index = RevisionIndex::parse(&store).unwrap();
736 let doc = Document::parse(&index).unwrap();
737 let space = &doc.spaces[&sid];
738 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
739 let paragraph = *view
740 .nodes
741 .iter()
742 .find(|(_, node)| node.content == [oid])
606 match operation {
607 "Page" => {
608 model_ops::save(&cache, oid, |page| {
609 model_ops::replace_text(page, oid, 0..0, "New ")
610 })
743611 .unwrap()
744 .0;
745 cache
746 .outline(
747 &source,
748 sid,
749 paragraph,
750 onestore::OutlineEdit::Collapsed(true),
751 )
752 .unwrap();
753 } else if operation == "Text" {
754 cache.edit_text(&source, sid, oid, 0..0, "New ").unwrap();
755 } else if operation == "Split" {
756 cache.split(&source, sid, &split).unwrap();
757 } else if operation == "Join" {
758 cache
759 .join(
760 &source,
761 sid,
762 &onestore::ParagraphJoin::new(oid, split.text_object(), "Author").unwrap(),
763 )
764 .unwrap();
765 } else {
766 cache
767 .format(
768 &source,
769 sid,
770 oid,
771 0..4,
772 &[onestore::TextAttribute::Bold(true)],
773 )
774612 .unwrap();
613 }
614 "CreatePage" => {
615 let page = onestore::PageCreation::new(None, Some("Created"), "Author").unwrap();
616 cache.create_page(&source, &page).unwrap().unwrap();
617 }
618 "Pages" => {
619 cache
620 .pages(&source, &[onestore::PageEdit::set_level(sid, 2).unwrap()])
621 .unwrap()
622 .unwrap();
623 }
624 _ => {
625 cache.delete_pages(&source, &[sid]).unwrap().unwrap();
626 }
775627 }
776628 drop(cache);
777629 let db = rusqlite::Connection::open(&path).unwrap();
......@@ -779,153 +631,19 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
779631 .query_row("SELECT operation FROM edits", [], |r| r.get(0))
780632 .unwrap();
781633 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
782 value[operation]["future_option"] = true.into();
634 if value[operation].is_object() {
635 value[operation]["future_option"] = true.into();
636 } else {
637 value["future_option"] = true.into();
638 }
783639 db.execute("UPDATE edits SET operation=?1", [value.to_string()])
784640 .unwrap();
785 if matches!(operation, "Text" | "Insert") {
786 db.execute_batch("ALTER TABLE attempt RENAME COLUMN revisions TO revision; DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap();
787 }
788641 drop(db);
789642 let before = fs::read(&path).unwrap();
790643 assert!(
791 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData)
644 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData),
645 "{operation}"
792646 );
793647 assert_eq!(fs::read(&path).unwrap(), before);
794648 }
795649}
796
797#[test]
798fn prior_schema_migrations_retain_queue_evidence_assets_and_enable_content_conflicts() {
799 for (version, ceiling) in [(5, 3), (6, 4), (7, 5), (8, 6), (9, 6)] {
800 use notebook::{ConflictKind, EditStatus, Recovery};
801 use sha2::{Digest, Sha256};
802 let directory = tempfile::tempdir().unwrap();
803 let path = directory.path().join("previous.sqlite");
804 let source = onestore::create_section("migration.one", "Original", "Author").unwrap();
805 let (sid, oid, _) = target(&source);
806 let cache = Replica::create(&path, &source).unwrap();
807 let first = cache
808 .edit_text(&source, sid, oid, 0..0, "New ")
809 .unwrap()
810 .unwrap();
811 let second = cache
812 .format(
813 &cache.snapshot().unwrap(),
814 sid,
815 oid,
816 0..3,
817 &[onestore::TextAttribute::Bold(true)],
818 )
819 .unwrap()
820 .unwrap();
821 let queue = cache.pending().unwrap();
822 let local = cache.snapshot().unwrap();
823 let store = Store::parse(&local).unwrap();
824 let index = RevisionIndex::parse(&store).unwrap();
825 let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
826 drop(cache);
827 let db = rusqlite::Connection::open(&path).unwrap();
828 let current_version: u32 = db
829 .pragma_query_value(None, "user_version", |row| row.get(0))
830 .unwrap();
831 let asset = "00112233-4455-6677-8899-aabbccddeeff.onebin";
832 let data = b"retained media";
833 db.execute(
834 "INSERT INTO assets VALUES (?1,?2,?3)",
835 rusqlite::params![asset, data.as_slice(), Sha256::digest(data).as_slice()],
836 )
837 .unwrap();
838 db.execute_batch(&format!(
839 "ALTER TABLE attempt RENAME COLUMN revisions TO revision;
840 DROP TABLE conflicts; CREATE TABLE conflicts (
841 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
842 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND {ceiling})) STRICT;
843 PRAGMA user_version={version};
844 UPDATE sqlite_sequence SET seq=1000 WHERE name='edits';"
845 ))
846 .unwrap();
847 db.execute(
848 "INSERT INTO conflicts VALUES (?1,3)",
849 [i64::try_from(second).unwrap()],
850 )
851 .unwrap();
852 db.execute(
853 "INSERT INTO attempt VALUES (1,?1,?2)",
854 rusqlite::params![i64::try_from(first).unwrap(), revision.to_string()],
855 )
856 .unwrap();
857 db.execute(
858 "INSERT INTO receipts VALUES (999,?1)",
859 [revision.to_string()],
860 )
861 .unwrap();
862 drop(db);
863 let archive = directory.path().join("legacy-recovery.sqlite");
864 std::fs::copy(&path, &archive).unwrap();
865 let archive_db = rusqlite::Connection::open(&archive).unwrap();
866 archive_db
867 .pragma_update(None, "application_id", 0x4f4e4552)
868 .unwrap();
869 drop(archive_db);
870 let original_archive = std::fs::read(&archive).unwrap();
871 let recovery = Recovery::open(&archive).unwrap();
872 assert_eq!(
873 recovery.status(first).unwrap(),
874 Some(EditStatus::AwaitingConfirmation { revision })
875 );
876 assert_eq!(recovery.pending().unwrap(), queue);
877 drop(recovery);
878 assert!(std::fs::read(&archive).unwrap() == original_archive);
879 let cache = Replica::open(&path).unwrap();
880 assert_eq!(cache.pending().unwrap(), queue);
881 assert_eq!(
882 cache.cached_asset(asset, 1024).unwrap(),
883 Some(data.to_vec())
884 );
885 assert!(cache.snapshot().unwrap() == local);
886 assert!(cache.remote_snapshot().unwrap() == source);
887 assert_eq!(
888 cache.status(first).unwrap(),
889 Some(EditStatus::AwaitingConfirmation { revision })
890 );
891 assert_eq!(
892 cache.status(second).unwrap(),
893 Some(EditStatus::Conflict(ConflictKind::FormattingChanged))
894 );
895 assert_eq!(
896 cache.status(999).unwrap(),
897 Some(EditStatus::Published { revision })
898 );
899 let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap();
900 assert_eq!(cache.split(&local, sid, &split).unwrap(), Some(1001));
901 let pending = cache.pending().unwrap();
902 let archive = directory.path().join("recovery.sqlite");
903 cache.export_recovery(&archive).unwrap();
904 let recovery = Recovery::open(&archive).unwrap();
905 assert_eq!(recovery.pending().unwrap(), pending);
906 assert_eq!(
907 recovery.cached_asset(asset, 1024).unwrap(),
908 Some(data.to_vec())
909 );
910 assert_eq!(
911 recovery.status(first).unwrap(),
912 cache.status(first).unwrap()
913 );
914 assert_eq!(recovery.receipts().unwrap().get(&999), Some(&revision));
915 drop(cache);
916 let db = rusqlite::Connection::open(&path).unwrap();
917 assert_eq!(
918 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
919 .unwrap(),
920 current_version
921 );
922 db.execute("INSERT INTO conflicts VALUES (1001,6)", [])
923 .unwrap();
924 drop(db);
925 let cache = Replica::open(&path).unwrap();
926 assert_eq!(
927 cache.status(1001).unwrap(),
928 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
929 );
930 }
931}
crates/notebook/tests/support/model_ops.rs created+245
......@@ -0,0 +1,245 @@
1//! Expresses the editor's actions as page-model edits saved through the replica, so tests
2//! can state intents in terms of paragraphs and ranges.
3#![allow(dead_code)]
4
5use notebook::Replica;
6use onestore::{
7 ExGuid, RevisionIndex, Store,
8 document::{Document, Format},
9 page::{
10 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
11 text::{Edit, new_id},
12 },
13};
14use std::ops::Range;
15
16pub const AUTHOR: &str = "Offline author";
17
18pub fn page_of(bytes: &[u8], space: ExGuid) -> Page {
19 let store = Store::parse(bytes).unwrap();
20 let index = RevisionIndex::parse(&store).unwrap();
21 let document = Document::parse(&index).unwrap();
22 Page::from_space(&document, space).unwrap()
23}
24
25/// The page space and page model containing the text object.
26pub fn locate(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) {
27 let store = Store::parse(bytes).unwrap();
28 let index = RevisionIndex::parse(&store).unwrap();
29 let document = Document::parse(&index).unwrap();
30 document
31 .pages()
32 .unwrap()
33 .into_iter()
34 .find_map(|(space, _)| {
35 let page = Page::from_space(&document, space).ok()?;
36 paragraph_with(&page, text)
37 .is_some()
38 .then_some((space, page))
39 })
40 .expect("text object belongs to an active page")
41}
42
43pub fn outlines_mut(page: &mut Page) -> Vec<&mut Outline> {
44 let mut out = Vec::new();
45 for object in &mut page.objects {
46 match object {
47 PageObject::Outline(outline) => out.push(outline),
48 PageObject::Title(title) => out.extend(title.outlines.iter_mut()),
49 _ => {}
50 }
51 }
52 out
53}
54
55pub fn paragraph_with(page: &Page, text: ExGuid) -> Option<&PageParagraph> {
56 page.objects.iter().find_map(|object| {
57 let outlines: Vec<&Outline> = match object {
58 PageObject::Outline(outline) => vec![outline],
59 PageObject::Title(title) => title.outlines.iter().collect(),
60 _ => Vec::new(),
61 };
62 outlines
63 .into_iter()
64 .flat_map(|o| o.paragraphs.iter())
65 .find(|p| p.text().is_some_and(|t| t.id == text))
66 })
67}
68
69fn text_mut(page: &mut Page, text: ExGuid) -> &mut TextObject {
70 outlines_mut(page)
71 .into_iter()
72 .flat_map(|o| o.paragraphs.iter_mut())
73 .find(|p| p.text().is_some_and(|t| t.id == text))
74 .and_then(|p| p.text_mut())
75 .expect("text object is on the page")
76}
77
78/// Replaces a UTF-16 range of a text object in the model, inheriting the format at its start.
79pub fn replace_text(page: &mut Page, text: ExGuid, range: Range<u32>, replacement: &str) {
80 let target = text_mut(page, text);
81 let format = target.text.format_at(range.start).unwrap().clone();
82 target
83 .text
84 .apply(Edit {
85 range,
86 replacement: Paragraph::new(replacement.into(), format),
87 })
88 .unwrap();
89}
90
91/// Rewrites the formats over a UTF-16 range of a text object.
92pub fn restyle(page: &mut Page, text: ExGuid, range: Range<u32>, change: impl Fn(&mut Format)) {
93 let target = text_mut(page, text);
94 let slice = target.text.slice(range.clone()).unwrap();
95 let mut runs = Vec::new();
96 let mut from = 0;
97 for span in slice.spans() {
98 let mut format = span.format.clone();
99 change(&mut format);
100 runs.push((slice.text()[from..span.end].to_owned(), format));
101 from = span.end;
102 }
103 target
104 .text
105 .apply(Edit {
106 range,
107 replacement: Paragraph::from_runs(runs),
108 })
109 .unwrap();
110}
111
112/// A plain new paragraph carrying fresh identities and the template's resolved format.
113pub fn fresh_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
114 let mut paragraph = template.clone();
115 paragraph.id = new_id().unwrap();
116 paragraph.parent = None;
117 paragraph.level = 1;
118 paragraph.lists.clear();
119 paragraph.tags.clear();
120 paragraph.style = None;
121 paragraph.collapsed = false;
122 let format = template.text().unwrap().text.format_at(0).unwrap().clone();
123 paragraph.content = ParagraphContent::Text(TextObject {
124 id: new_id().unwrap(),
125 date_field: None,
126 text: Paragraph::new(text.into(), format),
127 tags: Vec::new(),
128 });
129 paragraph
130}
131
132/// Inserts a plain paragraph after `after` (or first) in the outline containing it, at the
133/// same level and parent, returning the new paragraph and text identities.
134pub fn insert_after(page: &mut Page, after: ExGuid, text: &str) -> (ExGuid, ExGuid) {
135 for outline in outlines_mut(page) {
136 if let Some(at) = outline
137 .paragraphs
138 .iter()
139 .position(|p| p.text().is_some_and(|t| t.id == after))
140 {
141 let mut fresh = fresh_paragraph(&outline.paragraphs[at], text);
142 fresh.parent = outline.paragraphs[at].parent;
143 fresh.level = outline.paragraphs[at].level;
144 let ids = (fresh.id, fresh.text().unwrap().id);
145 // Skip the anchor's descendants so the new paragraph follows its whole subtree.
146 let mut end = at + 1;
147 while end < outline.paragraphs.len()
148 && outline.paragraphs[end].level > outline.paragraphs[at].level
149 {
150 end += 1;
151 }
152 outline.paragraphs.insert(end, fresh);
153 return ids;
154 }
155 }
156 panic!("anchor text is on the page");
157}
158
159/// Adds a body outline with one plain paragraph at the given point coordinates.
160pub fn insert_outline(page: &mut Page, x: f32, y: f32, text: &str) -> (ExGuid, ExGuid, ExGuid) {
161 let template = page
162 .objects
163 .iter()
164 .find_map(|object| match object {
165 PageObject::Outline(outline) => outline
166 .paragraphs
167 .iter()
168 .find(|p| p.text().is_some())
169 .cloned(),
170 _ => None,
171 })
172 .expect("a text paragraph to copy formatting from");
173 let paragraph = fresh_paragraph(&template, text);
174 let ids = (
175 new_id().unwrap(),
176 paragraph.id,
177 paragraph.text().unwrap().id,
178 );
179 let outline = Outline {
180 id: ids.0,
181 title: false,
182 min_width: None,
183 layout: onestore::document::Layout {
184 x: Some(x),
185 y: Some(y),
186 ..Default::default()
187 },
188 indents: Vec::new(),
189 paragraphs: vec![paragraph],
190 unsupported: Vec::new(),
191 };
192 let at = page
193 .objects
194 .iter()
195 .position(|o| matches!(o, PageObject::Title(_)))
196 .unwrap_or(page.objects.len());
197 page.objects.insert(at, PageObject::Outline(outline));
198 ids
199}
200
201/// Removes a paragraph and its descendants.
202pub fn delete_paragraph(page: &mut Page, text: ExGuid) {
203 for outline in outlines_mut(page) {
204 if let Some(at) = outline
205 .paragraphs
206 .iter()
207 .position(|p| p.text().is_some_and(|t| t.id == text))
208 {
209 let level = outline.paragraphs[at].level;
210 let mut end = at + 1;
211 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
212 end += 1;
213 }
214 outline.paragraphs.drain(at..end);
215 return;
216 }
217 }
218 panic!("text is on the page");
219}
220
221/// Saves `edit` applied to the page containing `text`, using the replica's current image.
222pub fn save(
223 cache: &Replica,
224 text: ExGuid,
225 edit: impl FnOnce(&mut Page),
226) -> Result<Option<u64>, notebook::Error> {
227 let source = cache.snapshot()?;
228 let (space, mut page) = locate(&source, text);
229 edit(&mut page);
230 cache.save(&source, space, &page, AUTHOR)
231}
232
233/// Reviews a conflicting save by applying `edit` to the current remote page.
234pub fn review(
235 cache: &Replica,
236 id: u64,
237 text: ExGuid,
238 edit: impl FnOnce(&mut Page),
239) -> Result<(), notebook::Error> {
240 let local = cache.snapshot()?;
241 let remote = cache.remote_snapshot()?;
242 let (_, mut page) = locate(&remote, text);
243 edit(&mut page);
244 cache.review_page(id, &local, &remote, &page)
245}
crates/notebook/tests/support/model_schedule.rs created+371
......@@ -0,0 +1,371 @@
1//! A deterministic multi-actor schedule over page-model saves: several replicas edit one
2//! page offline, publish through a fault-injecting remote, reopen, and review conflicts.
3
4use crate::model_ops::{self, AUTHOR};
5use crate::server::{Fault, Server};
6use notebook::{EditStatus, Operation, Remote, Replica};
7use onestore::{
8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store,
9 document::Document,
10 page::{Outline, Page, PageObject},
11};
12use std::{io, sync::LazyLock};
13
14static SOURCE: LazyLock<(Vec<u8>, ExGuid)> = LazyLock::new(|| {
15 let source =
16 onestore::create_section("model-schedule.one", "Original 🦀 é 0", "Author").unwrap();
17 let store = Store::parse(&source).unwrap();
18 let index = RevisionIndex::parse(&store).unwrap();
19 let document = Document::parse(&index).unwrap();
20 let space = document.pages().unwrap()[0].0;
21 let mut page = Page::from_space(&document, space).unwrap();
22 let mut anchor = body(&page).paragraphs[0].text().unwrap().id;
23 for at in 1..8 {
24 anchor = model_ops::insert_after(&mut page, anchor, &format!("Original 🦀 é {at}")).1;
25 }
26 let source = PreparedEdit::page(&source, space, &page, "Author")
27 .unwrap()
28 .as_bytes()
29 .to_vec();
30 (source, space)
31});
32
33fn body(page: &Page) -> &Outline {
34 page.objects
35 .iter()
36 .find_map(|object| match object {
37 PageObject::Outline(outline) => Some(outline),
38 _ => None,
39 })
40 .expect("the page keeps one body outline")
41}
42
43/// Everything the schedule asserts about a page, free of the identities the writers mint.
44fn shape(page: &Page) -> Vec<String> {
45 let outline = body(page);
46 let mut out = vec![format!("{:?}", outline.layout)];
47 out.extend(outline.paragraphs.iter().map(|paragraph| {
48 format!(
49 "{} {} {} {:?}",
50 paragraph.level,
51 u8::from(paragraph.collapsed),
52 u8::from(paragraph.parent.is_some()),
53 paragraph.text().map(|text| text.text.text()),
54 )
55 }));
56 out
57}
58
59/// Paragraph and text identities of the body outline with their text, in model order.
60fn rows(bytes: &[u8]) -> Vec<(ExGuid, ExGuid, String)> {
61 let page = model_ops::page_of(bytes, SOURCE.1);
62 body(&page)
63 .paragraphs
64 .iter()
65 .filter_map(|paragraph| {
66 let text = paragraph.text()?;
67 Some((paragraph.id, text.id, text.text.text().to_owned()))
68 })
69 .collect()
70}
71
72/// Moves a paragraph and its descendants immediately before `anchor`, or last among
73/// `parent`'s children; `anchor` supplies its own parent and level.
74pub(crate) fn move_subtree(
75 page: &mut Page,
76 paragraph: ExGuid,
77 parent: Option<ExGuid>,
78 anchor: Option<ExGuid>,
79) {
80 for outline in model_ops::outlines_mut(page) {
81 let Some(at) = outline.paragraphs.iter().position(|p| p.id == paragraph) else {
82 continue;
83 };
84 let level = outline.paragraphs[at].level;
85 let mut end = at + 1;
86 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
87 end += 1;
88 }
89 if parent
90 .into_iter()
91 .chain(anchor)
92 .any(|id| outline.paragraphs[at..end].iter().any(|p| p.id == id))
93 {
94 return;
95 }
96 let mut subtree: Vec<_> = outline.paragraphs.drain(at..end).collect();
97 let (destination, parent, depth) = match anchor {
98 Some(anchor) => {
99 let Some(at) = outline.paragraphs.iter().position(|p| p.id == anchor) else {
100 return;
101 };
102 let target = &outline.paragraphs[at];
103 (at, target.parent, target.level)
104 }
105 None => match parent {
106 Some(parent) => {
107 let Some(at) = outline.paragraphs.iter().position(|p| p.id == parent) else {
108 return;
109 };
110 let level = outline.paragraphs[at].level;
111 let mut end = at + 1;
112 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
113 end += 1;
114 }
115 (end, Some(parent), level + 1)
116 }
117 None => (outline.paragraphs.len(), None, 1),
118 },
119 };
120 subtree[0].parent = parent;
121 for descendant in &mut subtree {
122 descendant.level = descendant.level + depth - level;
123 }
124 outline.paragraphs.splice(destination..destination, subtree);
125 return;
126 }
127}
128
129/// Asserts the publication invariants that survive the page model, then delegates.
130struct Session<'a> {
131 server: &'a mut Server,
132 intent: Option<(u64, Operation)>,
133 /// The intent was already attempted, so its publication must never be repeated.
134 retired: bool,
135 publications: usize,
136}
137
138impl Remote for Session<'_> {
139 fn read(&mut self) -> io::Result<Vec<u8>> {
140 self.server.read()
141 }
142
143 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
144 assert!(!self.retired, "a retired attempt was replayed");
145 self.publications += 1;
146 assert_eq!(self.publications, 1);
147 let Some((_, Operation::Page(intent))) = &self.intent else {
148 panic!("the schedule queues page saves only")
149 };
150 if model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before {
151 assert_eq!(
152 shape(&model_ops::page_of(edit.as_bytes(), SOURCE.1)),
153 shape(&intent.after)
154 );
155 }
156 self.server.publish(edit)
157 }
158
159 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
160 self.server.confirm(snapshot)
161 }
162}
163
164fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> {
165 cache
166 .pending()
167 .unwrap()
168 .iter()
169 .map(|edit| (edit.id, cache.status(edit.id).unwrap()))
170 .collect()
171}
172
173/// Fails unless the durable remote holds `revision` in the edited page's space.
174fn durable(server: &Server, revision: ExGuid) {
175 let store = Store::parse(&server.durable).unwrap();
176 let index = RevisionIndex::parse(&store).unwrap();
177 assert!(index.spaces[&SOURCE.1].revisions.contains_key(&revision));
178}
179
180pub fn run(input: &[u8]) {
181 let (source, space) = &*SOURCE;
182 let directory = tempfile::tempdir().unwrap();
183 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
184 let mut server = Server::new(source);
185 for step in input.chunks_exact(8).take(48) {
186 let actor = usize::from(step[0]) % replicas.len();
187 let path = directory.path().join(format!("{actor}.sqlite"));
188 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());
189 let snapshot = cache.snapshot().unwrap();
190 let pending = cache.pending().unwrap();
191 let local = rows(&snapshot);
192 match step[1] % 8 {
193 0..=2 => {
194 let row = local[usize::from(step[2]) % local.len()].clone();
195 let other = local[usize::from(step[4]) % local.len()].clone();
196 let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 {
197 0 if !row.2.is_empty() => Box::new(move |page| {
198 model_ops::replace_text(
199 page,
200 row.1,
201 0..1,
202 &char::from(b'A' + step[5] % 26).to_string(),
203 );
204 }),
205 1 => Box::new(move |page| {
206 model_ops::insert_after(page, row.1, "Inserted 🦋 é");
207 }),
208 2 => Box::new(move |page| {
209 move_subtree(page, row.0, (step[5] & 1 != 0).then_some(other.0), None);
210 }),
211 3 => Box::new(move |page| model_ops::delete_paragraph(page, row.1)),
212 4 => Box::new(move |page| {
213 let outline = model_ops::outlines_mut(page).swap_remove(0);
214 if step[5] & 1 == 0 {
215 outline.layout.x = Some(f32::from(step[5]) + 36.0);
216 outline.layout.y = Some(f32::from(step[6]) + 36.0);
217 } else {
218 outline.layout.max_width = Some(f32::from(step[6]) + 72.0);
219 outline.layout.width_set_by_user = Some(step[6] & 2 == 0);
220 }
221 }),
222 5 if !row.2.is_empty() => Box::new(move |page| {
223 model_ops::restyle(page, row.1, 0..1, |format| {
224 format.font_size = Some(f32::from(step[5] % 20) + 12.0);
225 });
226 }),
227 _ => Box::new(|_| {}),
228 };
229 let mut after = model_ops::page_of(&snapshot, *space);
230 change(&mut after);
231 match cache.save(&snapshot, *space, &after, AUTHOR) {
232 Ok(id) => {
233 let next = cache.pending().unwrap();
234 let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> {
235 edits.iter().map(|edit| edit.id).collect()
236 };
237 assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]);
238 assert!(next.len() <= pending.len() + 1);
239 assert_eq!(id.is_some(), cache.snapshot().unwrap() != snapshot);
240 assert_eq!(
241 shape(&model_ops::page_of(&cache.snapshot().unwrap(), *space)),
242 shape(&after)
243 );
244 }
245 Err(_) => {
246 assert_eq!(cache.snapshot().unwrap(), snapshot);
247 assert_eq!(cache.pending().unwrap(), pending);
248 }
249 }
250 }
251 3 | 4 => {
252 server.fault = match step[2] % 8 {
253 0 => Fault::Before,
254 1 => Fault::UnknownBefore,
255 2 => Fault::UnknownAfter,
256 3 => Fault::Committed,
257 4 => Fault::Confirm,
258 5 => Fault::ConfirmCommitted,
259 _ => Fault::None,
260 };
261 let head = pending.first().map(|edit| edit.id);
262 let result = {
263 let mut session = Session {
264 retired: head.is_some_and(|id| {
265 matches!(
266 cache.status(id).unwrap(),
267 Some(EditStatus::AwaitingConfirmation { .. })
268 )
269 }),
270 intent: pending
271 .first()
272 .map(|edit| (edit.id, edit.operation.clone())),
273 server: &mut server,
274 publications: 0,
275 };
276 cache.sync_once(&mut session)
277 };
278 server.fault = Fault::None;
279 let next = cache.pending().unwrap();
280 if next.len() == pending.len() {
281 assert_eq!(next, pending);
282 if !next.is_empty() {
283 assert_eq!(cache.snapshot().unwrap(), snapshot);
284 }
285 } else {
286 assert_eq!(next, pending[1..]);
287 let Some(EditStatus::Published { revision }) =
288 cache.status(head.unwrap()).unwrap()
289 else {
290 panic!("an intent leaves the queue only with its receipt")
291 };
292 durable(&server, revision);
293 }
294 if next.is_empty() && result.is_ok() {
295 assert_eq!(cache.snapshot().unwrap(), cache.remote_snapshot().unwrap());
296 }
297 }
298 5 => {
299 let remote = rows(&server.visible);
300 let row = remote[usize::from(step[2]) % remote.len()].clone();
301 if !row.2.is_empty() {
302 let mut page = model_ops::page_of(&server.visible, *space);
303 model_ops::replace_text(
304 &mut page,
305 row.1,
306 0..1,
307 &char::from(b'a' + step[3] % 26).to_string(),
308 );
309 let visible = server.visible.clone();
310 PreparedEdit::page(&visible, *space, &page, "Remote")
311 .unwrap()
312 .commit(&mut server)
313 .unwrap();
314 }
315 }
316 6 => {
317 if let Some(head) = pending.first()
318 && matches!(
319 cache.status(head.id).unwrap(),
320 Some(EditStatus::Conflict(_))
321 )
322 {
323 let remote = cache.remote_snapshot().unwrap();
324 let published = rows(&remote);
325 let row = published[usize::from(step[2]) % published.len()].clone();
326 let mut after = model_ops::page_of(&remote, *space);
327 if !row.2.is_empty() {
328 model_ops::replace_text(&mut after, row.1, 0..1, "R");
329 }
330 if cache
331 .review_page(head.id, &snapshot, &remote, &after)
332 .is_ok()
333 {
334 assert_eq!(cache.status(head.id).unwrap(), Some(EditStatus::Pending));
335 let (result, publications) = {
336 let mut session = Session {
337 intent: Some((
338 head.id,
339 cache.pending().unwrap()[0].operation.clone(),
340 )),
341 retired: false,
342 server: &mut server,
343 publications: 0,
344 };
345 (cache.sync_once(&mut session), session.publications)
346 };
347 assert!(publications <= 1);
348 if let Ok(Some((id, EditStatus::Published { revision }))) = result {
349 assert_eq!(id, head.id);
350 durable(&server, revision);
351 assert!(cache.pending().unwrap().iter().all(|e| e.id != id));
352 }
353 } else {
354 assert_eq!(cache.pending().unwrap(), pending);
355 }
356 }
357 }
358 _ => {
359 let recorded = statuses(cache);
360 replicas[actor] = None;
361 let cache = Replica::open(&path).unwrap();
362 assert_eq!(cache.snapshot().unwrap(), snapshot);
363 assert_eq!(cache.pending().unwrap(), pending);
364 assert_eq!(statuses(&cache), recorded);
365 replicas[actor] = Some(cache);
366 }
367 }
368 rows(&server.durable);
369 rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap());
370 }
371}
crates/notebook/tests/support/page_schedule.rs+83-11
......@@ -1,11 +1,17 @@
11use crate::disk;
22use notebook::{EditStatus, Operation, Remote, Replica};
33use onestore::{
4 CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
5 document::{Document, Kind},
4 CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
5 document::{Document, Format, Kind, Layout},
6 page::{
7 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
8 text::new_id,
9 },
610};
711use std::{io, sync::LazyLock};
812
13const BODY: &str = "Body 🦋 é";
14
915#[path = "../../../onestore/tests/support/current.rs"]
1016mod current;
1117
......@@ -41,6 +47,73 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> {
4147 .collect()
4248}
4349
50/// Adds a body outline holding one plain paragraph, returning its text identity. A page the
51/// section just created has no body text for `model_ops::insert_outline` to copy formatting from.
52pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {
53 // The writer creates outline text with the store's default style; the model must state it.
54 let format = Format {
55 font: Some("Calibri".to_owned()),
56 font_size: Some(11.0),
57 language: Some(0x409),
58 ..Default::default()
59 };
60 let content = TextObject {
61 id: new_id().unwrap(),
62 date_field: None,
63 text: Paragraph::new(text.into(), format),
64 tags: Vec::new(),
65 };
66 let id = content.id;
67 let outline = Outline {
68 id: new_id().unwrap(),
69 title: false,
70 min_width: None,
71 layout: Layout {
72 x: Some(36.0),
73 y: Some(36.0),
74 ..Default::default()
75 },
76 indents: Vec::new(),
77 paragraphs: vec![PageParagraph {
78 id: new_id().unwrap(),
79 parent: None,
80 level: 1,
81 style: None,
82 format: Default::default(),
83 content: ParagraphContent::Text(content),
84 lists: Vec::new(),
85 tags: Vec::new(),
86 collapsed: false,
87 }],
88 unsupported: Vec::new(),
89 };
90 let at = page
91 .objects
92 .iter()
93 .position(|object| matches!(object, PageObject::Title(_)))
94 .unwrap_or(page.objects.len());
95 page.objects.insert(at, PageObject::Outline(outline));
96 id
97}
98
99/// The text identity of the first body outline's first paragraph.
100fn body_text(page: &Page) -> ExGuid {
101 page.objects
102 .iter()
103 .find_map(|object| match object {
104 PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id),
105 _ => None,
106 })
107 .unwrap()
108}
109
110fn page_of(source: &[u8], space: ExGuid) -> Page {
111 let store = Store::parse(source).unwrap();
112 let index = RevisionIndex::parse(&store).unwrap();
113 let document = Document::parse(&index).unwrap();
114 Page::from_space(&document, space).unwrap()
115}
116
44117struct Session<'a> {
45118 disk: &'a mut disk::Disk,
46119 operation: Option<&'a Operation>,
......@@ -68,7 +141,8 @@ impl Remote for Session<'_> {
68141 }
69142 }
70143 }
71 Operation::Insert(insertion) => {
144 Operation::Page(intent) => {
145 let body = body_text(&intent.after);
72146 let store = Store::parse(edit.as_bytes()).unwrap();
73147 let index = RevisionIndex::parse(&store).unwrap();
74148 let document = Document::parse(&index).unwrap();
......@@ -77,13 +151,11 @@ impl Remote for Session<'_> {
77151 .values()
78152 .filter_map(|space| {
79153 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
80 view.nodes.get(&insertion.text_object())
154 view.nodes.get(&body)
81155 })
82156 .collect();
83157 assert_eq!(matching.len(), 1);
84 assert!(
85 matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é")
86 );
158 assert!(matches!(&matching[0].kind, Kind::RichText { text, .. } if text == BODY));
87159 }
88160 _ => panic!(),
89161 }
......@@ -129,11 +201,11 @@ pub fn run(input: &[u8]) {
129201 cache.create_page(&snapshot, &page).unwrap().unwrap();
130202 owned[actor].push((page.space(), page.object()));
131203 if step[1] % 8 == 1 {
132 let insertion =
133 Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author")
134 .unwrap();
204 let source = cache.snapshot().unwrap();
205 let mut model = page_of(&source, page.space());
206 body_outline(&mut model, BODY);
135207 cache
136 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
208 .save(&source, page.space(), &model, "Author")
137209 .unwrap()
138210 .unwrap();
139211 }
crates/notebook/tests/support/server.rs created+132
......@@ -0,0 +1,132 @@
1//! A fault-injecting in-memory remote for reconciliation tests.
2#![allow(dead_code)]
3
4use notebook::Remote;
5use onestore::{
6 CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store,
7 document::{Document, Kind},
8};
9use std::io;
10
11#[derive(Clone, Copy, Default)]
12pub enum Fault {
13 #[default]
14 None,
15 Before,
16 UnknownBefore,
17 UnknownAfter,
18 Committed,
19 PanicBefore,
20 PanicAfter,
21 Confirm,
22 ConfirmCommitted,
23}
24
25pub struct Server {
26 pub visible: Vec<u8>,
27 pub durable: Vec<u8>,
28 pub fault: Fault,
29 pub publications: usize,
30 pub confirmations: usize,
31}
32
33impl Server {
34 pub fn new(source: &[u8]) -> Self {
35 Self {
36 visible: source.to_vec(),
37 durable: source.to_vec(),
38 fault: Fault::None,
39 publications: 0,
40 confirmations: 0,
41 }
42 }
43}
44
45pub fn failure(state: CommitState) -> CommitError {
46 CommitError {
47 state,
48 error: io::Error::from(io::ErrorKind::ConnectionAborted),
49 }
50}
51
52impl CommitIo for Server {
53 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
54 let offset = usize::try_from(offset).unwrap();
55 let size = output.len().min(self.visible.len().saturating_sub(offset));
56 if size > 0 {
57 output[..size].copy_from_slice(&self.visible[offset..offset + size]);
58 }
59 Ok(size)
60 }
61 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
62 let offset = usize::try_from(offset).unwrap();
63 self.visible
64 .resize(self.visible.len().max(offset + bytes.len()), 0);
65 self.visible[offset..offset + bytes.len()].copy_from_slice(bytes);
66 Ok(bytes.len())
67 }
68 fn flush(&mut self) -> io::Result<()> {
69 self.durable.clone_from(&self.visible);
70 Ok(())
71 }
72}
73
74impl Remote for Server {
75 fn read(&mut self) -> io::Result<Vec<u8>> {
76 Ok(self.visible.clone())
77 }
78 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
79 self.publications += 1;
80 let fault = std::mem::take(&mut self.fault);
81 match fault {
82 Fault::Before => return Err(failure(CommitState::NotCommitted)),
83 Fault::UnknownBefore => return Err(failure(CommitState::Unknown)),
84 Fault::PanicBefore => panic!("Terminated before remote I/O"),
85 _ => {}
86 }
87 let old = self.durable.clone();
88 edit.commit(self)?;
89 match fault {
90 Fault::UnknownAfter => {
91 self.durable = old;
92 Err(failure(CommitState::Unknown))
93 }
94 Fault::Committed => Err(failure(CommitState::Committed)),
95 Fault::PanicAfter => panic!("Terminated after remote publication"),
96 _ => Ok(()),
97 }
98 }
99 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
100 self.confirmations += 1;
101 if matches!(self.fault, Fault::Confirm) {
102 self.fault = Fault::None;
103 return Err(failure(CommitState::Unknown));
104 }
105 onestore::confirm_snapshot(self, snapshot)?;
106 if matches!(self.fault, Fault::ConfirmCommitted) {
107 self.fault = Fault::None;
108 return Err(failure(CommitState::Committed));
109 }
110 Ok(())
111 }
112}
113
114pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {
115 let store = Store::parse(source).unwrap();
116 assert!(store.checksum_mismatches.is_empty());
117 let index = RevisionIndex::parse(&store).unwrap();
118 index.validate_current().unwrap();
119 let doc = Document::parse(&index).unwrap();
120 doc.spaces
121 .iter()
122 .find_map(|(sid, space)| {
123 space.revisions[&space.contexts[&ExGuid::default()]]
124 .nodes
125 .iter()
126 .find_map(|(oid, node)| match &node.kind {
127 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
128 _ => None,
129 })
130 })
131 .unwrap()
132}
crates/notebook/tests/support/tree_schedule.rs deleted-271
......@@ -1,271 +0,0 @@
1use crate::disk;
2use notebook::{EditStatus, Operation, Remote, Replica};
3use onestore::{
4 CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit,
5 document::{Document, Kind},
6};
7use std::{io, sync::LazyLock};
8
9static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
10 let mut source =
11 onestore::create_section("tree-schedule.one", "Original 🦀 é 0", "Author").unwrap();
12 let store = Store::parse(&source).unwrap();
13 let index = RevisionIndex::parse(&store).unwrap();
14 let document = Document::parse(&index).unwrap();
15 let (sid, page) = document.pages().unwrap()[0];
16 let space = &document.spaces[&sid];
17 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
18 let outline = *view.nodes[&page]
19 .children
20 .iter()
21 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
22 .unwrap();
23 for at in 1..12 {
24 let insert =
25 Insertion::paragraph(outline, None, &format!("Original 🦀 é {at}"), "Author").unwrap();
26 source = PreparedEdit::insert(&source, sid, &insert)
27 .unwrap()
28 .as_bytes()
29 .to_vec();
30 }
31 (source, sid, outline)
32});
33
34fn rows(source: &[u8]) -> Vec<(ExGuid, ExGuid, String)> {
35 let (_, sid, outline) = &*SOURCE;
36 let store = Store::parse(source).unwrap();
37 assert!(store.checksum_mismatches.is_empty());
38 let index = RevisionIndex::parse(&store).unwrap();
39 index.validate_current().unwrap();
40 let document = Document::parse(&index).unwrap();
41 let space = &document.spaces[sid];
42 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
43 let (_, page) = document
44 .pages()
45 .unwrap()
46 .into_iter()
47 .find(|(space, _)| space == sid)
48 .unwrap();
49 if !view.nodes[&page].children.contains(outline) {
50 return Vec::new();
51 }
52 let node = &view.nodes[outline];
53 let mut rows = Vec::new();
54 for paragraph in &node.children {
55 let node = &view.nodes[paragraph];
56 assert!(node.children.is_empty());
57 let [text] = node.content.as_slice() else {
58 panic!()
59 };
60 let Kind::RichText { text: value, .. } = &view.nodes[text].kind else {
61 panic!()
62 };
63 rows.push((*paragraph, *text, value.clone()));
64 }
65 rows
66}
67
68struct Session<'a> {
69 disk: &'a mut disk::Disk,
70 operation: Option<&'a Operation>,
71}
72
73impl Remote for Session<'_> {
74 fn read(&mut self) -> io::Result<Vec<u8>> {
75 Ok(self.disk.visible.clone())
76 }
77 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
78 let before = rows(&self.disk.visible);
79 let mut expected = before.clone();
80 match self.operation.unwrap() {
81 Operation::Tree(edit) => {
82 let at = expected
83 .iter()
84 .position(|(id, _, _)| *id == edit.intent.object())
85 .unwrap();
86 let row = expected.remove(at);
87 if let Some((parent, anchor)) = edit.intent.destination() {
88 assert_eq!(parent, SOURCE.2);
89 let at = anchor
90 .map(|anchor| {
91 expected
92 .iter()
93 .position(|(id, _, _)| *id == anchor)
94 .unwrap()
95 })
96 .unwrap_or(expected.len());
97 expected.insert(at, row);
98 }
99 }
100 Operation::Text(edit) => {
101 assert_eq!(edit.range, 0..1);
102 let (_, _, text) = expected
103 .iter_mut()
104 .find(|(_, id, _)| *id == edit.object)
105 .unwrap();
106 text.replace_range(0..1, &edit.replacement);
107 }
108 Operation::Format(format) => {
109 let [onestore::TextAttribute::FontSize(size)] = format.attributes.as_slice() else {
110 panic!()
111 };
112 let store = Store::parse(edit.as_bytes()).unwrap();
113 let index = RevisionIndex::parse(&store).unwrap();
114 let document = Document::parse(&index).unwrap();
115 let space = &document.spaces[&SOURCE.1];
116 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
117 assert_eq!(
118 view.text_runs(format.object).unwrap()[0].format.font_size,
119 Some(*size)
120 );
121 }
122 _ => panic!(),
123 }
124 assert_eq!(rows(edit.as_bytes()), expected);
125 let result = edit.commit(self.disk);
126 let durable = rows(&self.disk.durable);
127 assert!(durable == before || durable == expected);
128 if result.is_ok() {
129 assert_eq!(durable, expected);
130 }
131 result
132 }
133 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
134 onestore::confirm_snapshot(self.disk, snapshot)
135 }
136}
137
138pub fn run(input: &[u8]) {
139 let (source, sid, outline) = &*SOURCE;
140 let directory = tempfile::tempdir().unwrap();
141 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
142 let mut disk = disk::Disk {
143 visible: source.clone(),
144 durable: source.clone(),
145 operation: 0,
146 fail_at: None,
147 write_limit: 4096,
148 random: 1,
149 };
150 for step in input.chunks_exact(8).take(48) {
151 let actor = usize::from(step[0]) % replicas.len();
152 let path = directory.path().join(format!("{actor}.sqlite"));
153 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());
154 let snapshot = cache.snapshot().unwrap();
155 let pending = cache.pending().unwrap();
156 let local = rows(&snapshot);
157 match step[1] % 8 {
158 0..=2 if !local.is_empty() => {
159 let target = usize::from(step[2]) % local.len();
160 let id = match step[1] % 8 {
161 0 => {
162 let before =
163 (step[3] & 1 == 0).then(|| local[usize::from(step[4]) % local.len()].0);
164 cache
165 .tree(
166 &snapshot,
167 *sid,
168 &TreeEdit::move_to(local[target].0, *outline, before, "Offline")
169 .unwrap(),
170 )
171 .unwrap()
172 }
173 1 => cache
174 .tree(
175 &snapshot,
176 *sid,
177 &TreeEdit::delete(local[target].0, "Offline").unwrap(),
178 )
179 .unwrap(),
180 _ if step[3] & 1 == 0 => cache
181 .edit_text(
182 &snapshot,
183 *sid,
184 local[target].1,
185 0..1,
186 &char::from(b'A' + step[3] % 26).to_string(),
187 )
188 .unwrap(),
189 _ => cache
190 .format(
191 &snapshot,
192 *sid,
193 local[target].1,
194 0..1,
195 &[onestore::TextAttribute::FontSize(
196 12.0 + f32::from(step[3] % 20),
197 )],
198 )
199 .unwrap(),
200 };
201 let next = cache.pending().unwrap();
202 assert_eq!(next[..pending.len()], pending);
203 assert_eq!(next.len(), pending.len() + usize::from(id.is_some()));
204 }
205 3 | 4 => {
206 disk.operation = 0;
207 disk.fail_at = (step[4] != 0)
208 .then_some(usize::from(u16::from_le_bytes([step[4], step[5]])) % 2048 + 1);
209 disk.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 };
210 disk.random = u64::from(step[7]) + 1;
211 let _ = cache.sync_once(&mut Session {
212 disk: &mut disk,
213 operation: pending.first().map(|p| &p.operation),
214 });
215 let next = cache.pending().unwrap();
216 if next.len() == pending.len() {
217 assert_eq!(next, pending);
218 } else {
219 assert_eq!(next, pending[1..]);
220 let Some(EditStatus::Published { revision }) =
221 cache.status(pending[0].id).unwrap()
222 else {
223 panic!()
224 };
225 let store = Store::parse(&disk.durable).unwrap();
226 let index = RevisionIndex::parse(&store).unwrap();
227 assert!(index.spaces[sid].revisions.contains_key(&revision));
228 }
229 if !next.is_empty() {
230 assert!(cache.snapshot().unwrap() == snapshot);
231 }
232 disk.visible.clone_from(&disk.durable);
233 }
234 5 => {
235 let mut expected = rows(&disk.visible);
236 if !expected.is_empty() {
237 let target = usize::from(step[2]) % expected.len();
238 let (_, text, content) = &mut expected[target];
239 let at = u32::try_from(content.encode_utf16().count()).unwrap();
240 content.push('R');
241 let snapshot = disk.visible.clone();
242 let edit = PreparedEdit::text(&snapshot, *sid, *text, at..at, "R").unwrap();
243 disk.fail_at = None;
244 edit.commit(&mut disk).unwrap();
245 assert_eq!(rows(&disk.durable), expected);
246 }
247 }
248 _ => {
249 if step[1] % 8 == 6
250 && let Some(first) = pending.first()
251 && matches!(first.operation, Operation::Tree(_))
252 && matches!(
253 cache.status(first.id).unwrap(),
254 Some(EditStatus::Conflict(_))
255 )
256 {
257 let remote = cache.remote_snapshot().unwrap();
258 let _ = cache.rebase_tree_conflict(first.id, &snapshot, &remote);
259 }
260 let pending = cache.pending().unwrap();
261 drop(replicas[actor].take());
262 let cache = Replica::open(&path).unwrap();
263 assert!(cache.snapshot().unwrap() == snapshot);
264 assert_eq!(cache.pending().unwrap(), pending);
265 replicas[actor] = Some(cache);
266 }
267 }
268 rows(&disk.durable);
269 rows(&replicas[actor].as_ref().unwrap().snapshot().unwrap());
270 }
271}
crates/notebook/tests/sync.rs+156-3262
......@@ -1,1142 +1,44 @@
1use notebook::{ConflictKind, EditStatus, Error, Remote, Replica};
2use onestore::{
3 CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store,
4 document::{Document, Kind},
5};
6use std::io;
7
8#[path = "../../onestore/tests/support/disk.rs"]
9mod disk;
10#[path = "sync/model.rs"]
11mod model;
12#[path = "sync/outline.rs"]
13mod outline;
14#[path = "sync/page.rs"]
15mod page;
16#[path = "support/page_schedule.rs"]
17mod page_schedule;
18#[path = "sync/pages.rs"]
19mod pages;
20#[path = "sync/tree.rs"]
21mod tree;
22#[path = "support/tree_schedule.rs"]
23mod tree_schedule;
24
25mod paragraph {
26 use super::*;
27 use onestore::{Insertion, ParagraphJoin, ParagraphSplit, TextAttribute};
28
29 fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid) {
30 let source = onestore::create_section("paragraph.one", "ab🦀cd", "Fixture").unwrap();
31 let (sid, left, _) = text(&source);
32 let store = Store::parse(&source).unwrap();
33 let index = RevisionIndex::parse(&store).unwrap();
34 let doc = Document::parse(&index).unwrap();
35 let space = &doc.spaces[&sid];
36 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
37 let parent = *view
38 .nodes
39 .iter()
40 .find(|(_, node)| node.content == [left])
41 .unwrap()
42 .0;
43 (source, sid, left, parent)
44 }
45
46 fn native_reconciliation(keyboard: bool) -> (Vec<u8>, Vec<serde_json::Value>) {
47 let source = include_bytes!(
48 "../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one"
49 );
50 let native: &[u8] = if keyboard {
51 include_bytes!(
52 "../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one"
53 )
54 } else {
55 include_bytes!(
56 "../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one"
57 )
58 };
59 let store = Store::parse(source).unwrap();
60 let index = RevisionIndex::parse(&store).unwrap();
61 let document = Document::parse(&index).unwrap();
62 let mut server = Server::new(native);
63 let mut recorded = Vec::new();
64 let mut counts = [0; 3];
65 for (sid, page) in document.pages().unwrap() {
66 let space = &document.spaces[&sid];
67 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
68 let Kind::Metadata {
69 title: Some(name), ..
70 } = &view.nodes[&view.roots[&2]].kind
71 else {
72 continue;
73 };
74 if !name.starts_with("Split ") && !name.starts_with("Join ") {
75 continue;
76 }
77 let outline = view.nodes[&page]
78 .children
79 .iter()
80 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
81 .unwrap();
82 let children = &view.nodes[outline].children;
83 let left = view.nodes[&children[0]].content[0];
84 let split = name.starts_with("Split ");
85 let adoption = name == "Join empty adoption";
86 let directory = tempfile::tempdir().unwrap();
87 let path = directory.path().join("native-reconciliation.sqlite");
88 let mut cache = Replica::create(&path, source).unwrap();
89 let (id, target, intent) = if split {
90 let intent = ParagraphSplit::new(left, 2, "Offline author").unwrap();
91 (
92 cache.split(source, sid, &intent).unwrap().unwrap(),
93 intent.text_object(),
94 serde_json::to_value(intent).unwrap(),
95 )
96 } else {
97 let right = view.nodes[&children[1]].content[0];
98 let intent = ParagraphJoin::new(left, right, "Offline author").unwrap();
99 (
100 cache.join(source, sid, &intent).unwrap().unwrap(),
101 if adoption { right } else { left },
102 serde_json::to_value(intent).unwrap(),
103 )
104 };
105 drop(cache);
106 cache = Replica::open(&path).unwrap();
107 let at = if split {
108 2
109 } else if adoption {
110 1
111 } else {
112 4
113 };
114 let dependent = cache
115 .edit_text(
116 &cache.snapshot().unwrap(),
117 sid,
118 target,
119 at..at + 1,
120 if adoption { "I" } else { "C" },
121 )
122 .unwrap()
123 .unwrap();
124 let local = cache.snapshot().unwrap();
125 let pending = cache.pending().unwrap();
126 drop(cache);
127 cache = Replica::open(&path).unwrap();
128 assert_eq!(cache.snapshot().unwrap(), local);
129 assert_eq!(cache.pending().unwrap(), pending);
130 let previous = server.publications;
131 let remote = server.visible.clone();
132 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
133 assert_eq!(actual, id);
134 assert_eq!(cache.snapshot().unwrap(), local);
135 if !keyboard {
136 counts[2] += 1;
137 assert_eq!(
138 status,
139 EditStatus::Conflict(ConflictKind::TargetUnavailable),
140 "{name}"
141 );
142 assert_eq!(server.publications, previous);
143 assert_eq!(server.visible, remote);
144 drop(cache);
145 cache = Replica::open(&path).unwrap();
146 assert_eq!(cache.status(id).unwrap(), Some(status));
147 assert_eq!(cache.snapshot().unwrap(), local);
148 assert_eq!(cache.pending().unwrap(), pending);
149 continue;
150 }
151 let automatic = ["prefix", "format", "sibling"]
152 .iter()
153 .any(|suffix| name.ends_with(suffix));
154 if automatic {
155 counts[0] += 1;
156 assert!(
157 matches!(status, EditStatus::Published { .. }),
158 "{name}: {status:?}"
159 );
160 } else {
161 let expected = if name.ends_with("boundary") || adoption {
162 ConflictKind::TextChanged
163 } else {
164 ConflictKind::StructureChanged
165 };
166 assert_eq!(status, EditStatus::Conflict(expected), "{name}");
167 assert_eq!(server.publications, previous);
168 assert_eq!(server.visible, remote);
169 assert_eq!(cache.pending().unwrap(), pending);
170 drop(cache);
171 cache = Replica::open(&path).unwrap();
172 assert_eq!(cache.status(id).unwrap(), Some(status));
173 assert_eq!(cache.snapshot().unwrap(), local);
174 assert_eq!(cache.pending().unwrap(), pending);
175 if adoption || name == "Join remote list" {
176 counts[2] += 1;
177 assert!(cache.rebase_join_conflict(id, &local, &remote).is_err());
178 assert_eq!(cache.pending().unwrap(), pending);
179 assert_eq!(cache.snapshot().unwrap(), local);
180 assert_eq!(cache.status(id).unwrap(), Some(status));
181 recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent, "outcome": "retained_conflict"}));
182 continue;
183 }
184 counts[1] += 1;
185 if split {
186 let offset = if name.ends_with("boundary") { 3 } else { 2 };
187 cache
188 .rebase_conflict(id, &local, &remote, offset..offset)
189 .unwrap();
190 let notebook::Operation::Split(edit) = &cache.pending().unwrap()[0].operation
191 else {
192 panic!()
193 };
194 assert_eq!(
195 edit.intent,
196 serde_json::from_value::<ParagraphSplit>(intent.clone())
197 .unwrap()
198 .reposition(offset)
199 );
200 } else {
201 cache.rebase_join_conflict(id, &local, &remote).unwrap();
202 }
203 drop(cache);
204 cache = Replica::open(&path).unwrap();
205 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
206 assert_eq!(actual, id);
207 assert!(
208 matches!(status, EditStatus::Published { .. }),
209 "{name}: {status:?}"
210 );
211 }
212 drop(cache);
213 cache = Replica::open(&path).unwrap();
214 let first_receipt = cache.status(id).unwrap().unwrap();
215 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
216 assert_eq!(actual, dependent);
217 assert!(
218 matches!(status, EditStatus::Published { .. }),
219 "{name} dependent: {status:?}"
220 );
221 assert_eq!(server.publications, previous + 2);
222 drop(cache);
223 cache = Replica::open(&path).unwrap();
224 assert_eq!(cache.status(id).unwrap(), Some(first_receipt));
225 assert_eq!(cache.status(dependent).unwrap(), Some(status));
226 assert!(cache.pending().unwrap().is_empty());
227 assert_eq!(cache.snapshot().unwrap(), server.durable);
228 let EditStatus::Published { revision } = first_receipt else {
229 panic!()
230 };
231 let EditStatus::Published {
232 revision: dependent_revision,
233 } = status
234 else {
235 panic!()
236 };
237 recorded.push(serde_json::json!({"case": name, "space": sid, "intent": intent,
238 "outcome": if automatic { "automatic" } else { "reviewed" }, "receipt": revision, "dependent_receipt": dependent_revision}));
239 }
240 assert_eq!(counts, if keyboard { [6, 8, 2] } else { [0, 0, 16] });
241 assert_eq!(server.publications, if keyboard { 28 } else { 0 });
242 (server.durable, recorded)
243 }
244
245 #[test]
246 fn native_com_replacement_preserves_every_local_paragraph_branch() {
247 native_reconciliation(false);
248 }
249
250 #[test]
251 fn native_changes_commute_or_retain_reviewable_paragraph_dependencies() {
252 native_reconciliation(true);
253 }
254
255 #[test]
256 #[ignore = "exports native-controlled reconciliation for cold OneNote validation"]
257 fn export_native_reconciliation() {
258 let output = std::path::PathBuf::from(
259 std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(),
260 );
261 assert!(output.is_absolute());
262 std::fs::create_dir(&output).unwrap();
263 let (bytes, cases) = native_reconciliation(true);
264 std::fs::create_dir(output.join("candidate")).unwrap();
265 std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap();
266 std::fs::write(
267 output.join("manifest.json"),
268 serde_json::to_vec_pretty(&cases).unwrap(),
269 )
270 .unwrap();
271 }
272
273 #[test]
274 fn native_splits_retain_independent_local_identities_and_changed_boundaries() {
275 let source = include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one");
276 let native = include_bytes!("../../../corpus/paragraph-edit/split/notebook/synthetic.one");
277 let manifest: serde_json::Value =
278 serde_json::from_str(include_str!("../../../corpus/paragraph-edit/manifest.json"))
279 .unwrap();
280 let store = Store::parse(source).unwrap();
281 let index = RevisionIndex::parse(&store).unwrap();
282 let document = Document::parse(&index).unwrap();
283 let mut accepted = 0;
284 let mut conflicts = 0;
285 for case in manifest["cases"].as_array().unwrap() {
286 if case["case"] == "Split before hyperlink" {
287 continue;
288 }
289 let left: ExGuid = serde_json::from_value(case["original_text"].clone()).unwrap();
290 let native_right: ExGuid = serde_json::from_value(case["new_text"].clone()).unwrap();
291 let (sid, _) = document
292 .pages()
293 .unwrap()
294 .into_iter()
295 .find(|(sid, _)| {
296 let space = &document.spaces[sid];
297 space.revisions[&space.contexts[&ExGuid::default()]]
298 .nodes
299 .contains_key(&left)
300 })
301 .unwrap();
302 let split = ParagraphSplit::new(
303 left,
304 u32::try_from(case["offset_utf16"].as_u64().unwrap()).unwrap(),
305 "Offline author",
306 )
307 .unwrap();
308 let directory = tempfile::tempdir().unwrap();
309 let path = directory.path().join("native-split.sqlite");
310 let mut cache = Replica::create(&path, source).unwrap();
311 let id = cache.split(source, sid, &split).unwrap().unwrap();
312 let current = cache.snapshot().unwrap();
313 let dependent = cache
314 .edit_text(
315 &current,
316 sid,
317 split.text_object(),
318 0..0,
319 "Local dependent edit: ",
320 )
321 .unwrap()
322 .unwrap();
323 let local = cache.snapshot().unwrap();
324 let pending = cache.pending().unwrap();
325 let mut server = Server::new(native);
326 let (published, status) = cache.sync_once(&mut server).unwrap().unwrap();
327 assert_eq!(published, id);
328 assert_eq!(cache.snapshot().unwrap(), local);
329 if matches!(case["case"].as_str().unwrap(), "Split end" | "Split empty") {
330 accepted += 1;
331 assert!(
332 matches!(status, EditStatus::Published { .. }),
333 "{}: {status:?}",
334 case["case"]
335 );
336 drop(cache);
337 cache = Replica::open(&path).unwrap();
338 assert_eq!(cache.sync_once(&mut server).unwrap().unwrap().0, dependent);
339 assert_eq!(server.publications, 2);
340 let store = Store::parse(&server.visible).unwrap();
341 let index = RevisionIndex::parse(&store).unwrap();
342 let document = Document::parse(&index).unwrap();
343 let space = &document.spaces[&sid];
344 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
345 let original: ExGuid =
346 serde_json::from_value(case["original_paragraph"].clone()).unwrap();
347 let native_paragraph: ExGuid =
348 serde_json::from_value(case["new_paragraph"].clone()).unwrap();
349 let parent = view
350 .nodes
351 .values()
352 .find(|node| node.children.contains(&original))
353 .unwrap();
354 let position = parent
355 .children
356 .iter()
357 .position(|id| *id == original)
358 .unwrap();
359 assert_eq!(
360 &parent.children[position..position + 3],
361 &[original, split.object(), native_paragraph]
362 );
363 assert!(
364 matches!(&view.nodes[&native_right].kind, Kind::RichText {text,..} if text.is_empty())
365 );
366 assert!(
367 matches!(&view.nodes[&split.text_object()].kind, Kind::RichText {text,..} if text == "Local dependent edit: ")
368 );
369 } else {
370 conflicts += 1;
371 assert!(
372 matches!(
373 status,
374 EditStatus::Conflict(
375 ConflictKind::TextChanged | ConflictKind::StructureChanged
376 )
377 ),
378 "{}: {status:?}",
379 case["case"]
380 );
381 assert_eq!(server.publications, 0);
382 assert_eq!(cache.pending().unwrap(), pending);
383 drop(cache);
384 cache = Replica::open(&path).unwrap();
385 assert_eq!(cache.snapshot().unwrap(), local);
386 assert_eq!(cache.pending().unwrap(), pending);
387 assert_eq!(cache.status(id).unwrap(), Some(status));
388 }
389 }
390 assert_eq!((accepted, conflicts), (2, 10));
391 }
392
393 #[test]
394 fn native_joins_do_not_acknowledge_or_discard_an_independent_local_branch() {
395 let source = include_bytes!(
396 "../../../corpus/paragraph-edit/join-tags/before/notebook/synthetic.one"
397 );
398 let native = include_bytes!(
399 "../../../corpus/paragraph-edit/join-tags/joined/notebook/synthetic.one"
400 );
401 let store = Store::parse(source).unwrap();
402 let index = RevisionIndex::parse(&store).unwrap();
403 let document = Document::parse(&index).unwrap();
404 let mut cases = 0;
405 for (sid, page) in document.pages().unwrap() {
406 let space = &document.spaces[&sid];
407 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
408 let Kind::Metadata {
409 title: Some(name), ..
410 } = &view.nodes[&view.roots[&2]].kind
411 else {
412 continue;
413 };
414 if !name.starts_with("Join ") {
415 continue;
416 }
417 cases += 1;
418 let outline = view.nodes[&page]
419 .children
420 .iter()
421 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
422 .unwrap();
423 let children = &view.nodes[outline].children;
424 let left = view.nodes[&children[0]].content[0];
425 let right = view.nodes[&children[1]].content[0];
426 let Kind::RichText {
427 text: left_text, ..
428 } = &view.nodes[&left].kind
429 else {
430 panic!()
431 };
432 let survivor = if left_text.is_empty() { right } else { left };
433 let directory = tempfile::tempdir().unwrap();
434 let path = directory.path().join("native-join.sqlite");
435 let mut cache = Replica::create(&path, source).unwrap();
436 let join = ParagraphJoin::new(left, right, "Offline author").unwrap();
437 let id = cache.join(source, sid, &join).unwrap().unwrap();
438 let current = cache.snapshot().unwrap();
439 cache
440 .edit_text(&current, sid, survivor, 0..0, "Local dependent edit: ")
441 .unwrap()
442 .unwrap();
443 let local = cache.snapshot().unwrap();
444 let pending = cache.pending().unwrap();
445 let mut server = Server::new(native);
446 let (conflicted, status) = cache.sync_once(&mut server).unwrap().unwrap();
447 assert_eq!(conflicted, id);
448 assert!(
449 matches!(
450 status,
451 EditStatus::Conflict(ConflictKind::TargetUnavailable)
452 ),
453 "{name}: {status:?}"
454 );
455 assert_eq!(server.publications, 0);
456 assert_eq!(cache.snapshot().unwrap(), local);
457 assert_eq!(cache.pending().unwrap(), pending);
458 drop(cache);
459 cache = Replica::open(&path).unwrap();
460 assert_eq!(cache.snapshot().unwrap(), local);
461 assert_eq!(cache.pending().unwrap(), pending);
462 assert_eq!(cache.status(id).unwrap(), Some(status));
463 }
464 assert_eq!(cases, 3);
465 }
466
467 #[test]
468 fn split_join_dependencies_reopen_and_rebase_with_remote_text_and_styles() {
469 let (source, sid, left, parent) = fixture();
470 let directory = tempfile::tempdir().unwrap();
471 let path = directory.path().join("paragraph.sqlite");
472 let mut cache = Replica::create(&path, &source).unwrap();
473 let split = ParagraphSplit::new(left, 2, "Local").unwrap();
474 let child = Insertion::paragraph(split.object(), None, "Child", "Local").unwrap();
475 let join = ParagraphJoin::new(left, split.text_object(), "Local").unwrap();
476 let mut ids = Vec::new();
477 for step in 0..6 {
478 let current = cache.snapshot().unwrap();
479 let id = match step {
480 0 => cache.split(&current, sid, &split),
481 1 => cache.edit_text(&current, sid, split.text_object(), 0..2, "🦋"),
482 2 => cache.format(
483 &current,
484 sid,
485 split.text_object(),
486 2..3,
487 &[TextAttribute::Bold(true)],
488 ),
489 3 => cache.insert(&current, sid, &child),
490 4 => cache.join(&current, sid, &join),
491 5 => cache.edit_text(&current, sid, left, 5..6, "D"),
492 _ => unreachable!(),
493 }
494 .unwrap()
495 .unwrap();
496 ids.push(id);
497 let saved = cache.snapshot().unwrap();
498 let pending = cache.pending().unwrap();
499 drop(cache);
500 cache = Replica::open(&path).unwrap();
501 assert_eq!(cache.snapshot().unwrap(), saved);
502 assert_eq!(cache.pending().unwrap(), pending);
503 }
504 let remote = onestore::replace_text(&source, sid, left, 0..0, "Z").unwrap();
505 let remote =
506 PreparedEdit::format(&remote, sid, left, 1..2, &[TextAttribute::Italic(true)]).unwrap();
507 let mut server = Server::new(remote.as_bytes());
508 let local = cache.snapshot().unwrap();
509 for (i, id) in ids.iter().enumerate() {
510 let (published, state) = cache.sync_once(&mut server).unwrap().unwrap();
511 assert_eq!(published, *id);
512 assert!(matches!(state, EditStatus::Published { .. }), "{state:?}");
513 drop(cache);
514 cache = Replica::open(&path).unwrap();
515 assert_eq!(cache.status(*id).unwrap(), Some(state));
516 if i != ids.len() - 1 {
517 assert_eq!(cache.snapshot().unwrap(), local);
518 }
519 }
520 assert_eq!(server.publications, ids.len());
521 assert_eq!(server.visible, server.durable);
522 assert_eq!(cache.snapshot().unwrap(), server.durable);
523 let store = Store::parse(&server.durable).unwrap();
524 let index = RevisionIndex::parse(&store).unwrap();
525 index.validate_current().unwrap();
526 let doc = Document::parse(&index).unwrap();
527 let space = &doc.spaces[&sid];
528 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
529 assert!(
530 matches!(&view.nodes[&left].kind, Kind::RichText { text, .. } if text == "Zab🦋cD")
531 );
532 assert_eq!(view.nodes[&parent].children, [child.object()]);
533 assert_eq!(view.nodes[&parent].content, [left]);
534 let runs = view.text_runs(left).unwrap();
535 let chars: Vec<_> = runs
536 .iter()
537 .flat_map(|run| {
538 run.text
539 .chars()
540 .map(|c| (c, run.format.bold, run.format.italic))
541 })
542 .collect();
543 assert_eq!(chars.iter().find(|v| v.0 == 'a').unwrap().2, Some(true));
544 assert_eq!(chars.iter().find(|v| v.0 == 'c').unwrap().1, Some(true));
545 }
546
547 #[test]
548 fn changed_split_children_require_fresh_review_without_reallocating_dependents() {
549 let (source, sid, left, parent) = fixture();
550 let directory = tempfile::tempdir().unwrap();
551 let path = directory.path().join("paragraph.sqlite");
552 let cache = Replica::create(&path, &source).unwrap();
553 let split = ParagraphSplit::new(left, 2, "Local").unwrap();
554 let id = cache.split(&source, sid, &split).unwrap().unwrap();
555 let dependent = cache
556 .edit_text(
557 &cache.snapshot().unwrap(),
558 sid,
559 split.text_object(),
560 0..2,
561 "🦋",
562 )
563 .unwrap()
564 .unwrap();
565 let local = cache.snapshot().unwrap();
566 let child = Insertion::paragraph(parent, None, "Remote child", "Remote").unwrap();
567 let remote = PreparedEdit::insert(&source, sid, &child).unwrap();
568 let mut server = Server::new(remote.as_bytes());
569 assert_eq!(
570 cache.sync_once(&mut server).unwrap(),
571 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
572 );
573 assert_eq!(server.publications, 0);
574 assert_eq!(cache.snapshot().unwrap(), local);
575 assert!(
576 cache
577 .rebase_conflict(id, &source, &server.visible, 2..2)
578 .is_err()
579 );
580 assert!(
581 cache
582 .rebase_conflict(id, &local, &server.visible, 2..3)
583 .is_err()
584 );
585 cache
586 .rebase_conflict(id, &local, &server.visible, 2..2)
587 .unwrap();
588 let pending = cache.pending().unwrap();
589 let notebook::Operation::Split(edit) = &pending[0].operation else {
590 panic!()
591 };
592 assert_eq!(edit.intent, split);
593 assert_eq!(pending[1].id, dependent);
594 drop(cache);
595 let cache = Replica::open(&path).unwrap();
596 for expected in [id, dependent] {
597 let (actual, state) = cache.sync_once(&mut server).unwrap().unwrap();
598 assert_eq!(actual, expected);
599 assert!(matches!(state, EditStatus::Published { .. }));
600 }
601 let store = Store::parse(&server.durable).unwrap();
602 let index = RevisionIndex::parse(&store).unwrap();
603 let doc = Document::parse(&index).unwrap();
604 let space = &doc.spaces[&sid];
605 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
606 assert_eq!(view.nodes[&split.object()].children, [child.object()]);
607 assert!(
608 matches!(&view.nodes[&split.text_object()].kind, Kind::RichText { text, .. } if text == "🦋cd")
609 );
610 }
611
612 #[test]
613 fn uncertain_paragraph_operations_keep_the_original_attempt_across_reopen() {
614 for join in [false, true] {
615 for fault in [
616 Fault::Before,
617 Fault::UnknownBefore,
618 Fault::UnknownAfter,
619 Fault::Committed,
620 ] {
621 let (source, sid, left, _) = fixture();
622 let split = ParagraphSplit::new(left, 2, "Local").unwrap();
623 let source = if join {
624 PreparedEdit::split(&source, sid, &split)
625 .unwrap()
626 .as_bytes()
627 .to_vec()
628 } else {
629 source
630 };
631 let directory = tempfile::tempdir().unwrap();
632 let path = directory.path().join("paragraph.sqlite");
633 let cache = Replica::create(&path, &source).unwrap();
634 let id = if join {
635 cache.join(
636 &source,
637 sid,
638 &ParagraphJoin::new(left, split.text_object(), "Local").unwrap(),
639 )
640 } else {
641 cache.split(&source, sid, &split)
642 }
643 .unwrap()
644 .unwrap();
645 let local = cache.snapshot().unwrap();
646 let intent = cache.pending().unwrap();
647 let mut server = Server::new(&source);
648 server.fault = fault;
649 assert!(cache.sync_once(&mut server).is_err());
650 let state = cache.status(id).unwrap().unwrap();
651 drop(cache);
652 let cache = Replica::open(&path).unwrap();
653 assert_eq!(cache.status(id).unwrap(), Some(state));
654 if matches!(fault, Fault::UnknownBefore | Fault::UnknownAfter) {
655 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
656 assert_eq!(cache.pending().unwrap(), intent);
657 assert_eq!(cache.snapshot().unwrap(), local);
658 if matches!(fault, Fault::UnknownBefore) {
659 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state)));
660 assert_eq!(server.publications, 1);
661 continue;
662 }
663 server.fault = Fault::Confirm;
664 assert!(cache.sync_once(&mut server).is_err());
665 assert_eq!(cache.status(id).unwrap(), Some(state));
666 let (_, state) = cache.sync_once(&mut server).unwrap().unwrap();
667 assert!(matches!(state, EditStatus::Published { .. }));
668 assert_eq!(server.publications, 1);
669 } else if matches!(fault, Fault::Before) {
670 assert_eq!(state, EditStatus::Pending);
671 assert!(matches!(
672 cache.sync_once(&mut server).unwrap().unwrap().1,
673 EditStatus::Published { .. }
674 ));
675 assert_eq!(server.publications, 2);
676 } else {
677 assert!(matches!(state, EditStatus::Published { .. }));
678 assert_eq!(server.publications, 1);
679 }
680 assert!(cache.pending().unwrap().is_empty());
681 let saved = cache.snapshot().unwrap();
682 if matches!(fault, Fault::UnknownAfter) {
683 // Confirmation refreshes version metadata after reading the acknowledged snapshot.
684 assert!(saved[..212] == server.durable[..212]);
685 assert!(saved[252..] == server.durable[252..]);
686 assert_eq!(
687 Store::parse(&server.durable).unwrap().header.generation,
688 Store::parse(&saved).unwrap().header.generation + 1
689 );
690 } else {
691 assert!(saved == server.durable);
692 }
693 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
694 assert!(cache.snapshot().unwrap() == server.durable);
695 }
696 }
697 }
698
699 #[test]
700 fn join_reviews_preserve_survivors_and_require_current_child_placement() {
701 for empty in [false, true] {
702 let (source, sid, left, _) = fixture();
703 let split = ParagraphSplit::new(left, if empty { 0 } else { 2 }, "Local").unwrap();
704 let source = PreparedEdit::split(&source, sid, &split)
705 .unwrap()
706 .as_bytes()
707 .to_vec();
708 let directory = tempfile::tempdir().unwrap();
709 let path = directory.path().join("join.sqlite");
710 let cache = Replica::create(&path, &source).unwrap();
711 let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap();
712 let id = cache.join(&source, sid, &intent).unwrap().unwrap();
713 let survivor = if empty { split.text_object() } else { left };
714 let dependent = cache
715 .edit_text(&cache.snapshot().unwrap(), sid, survivor, 0..0, "Local ")
716 .unwrap()
717 .unwrap();
718 let local = cache.snapshot().unwrap();
719 let child =
720 Insertion::paragraph(split.object(), None, "Remote child", "Remote").unwrap();
721 let remote = PreparedEdit::insert(&source, sid, &child)
722 .unwrap()
723 .as_bytes()
724 .to_vec();
725 let mut server = Server::new(&remote);
726 assert_eq!(
727 cache.sync_once(&mut server).unwrap(),
728 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
729 );
730 assert!(cache.rebase_join_conflict(id, &source, &remote).is_err());
731 assert_eq!(server.publications, 0);
732 cache.rebase_join_conflict(id, &local, &remote).unwrap();
733 assert!(
734 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
735 );
736 assert!(
737 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent)
738 );
739 let store = Store::parse(&server.durable).unwrap();
740 let index = RevisionIndex::parse(&store).unwrap();
741 let doc = Document::parse(&index).unwrap();
742 let space = &doc.spaces[&sid];
743 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
744 let parent = view
745 .nodes
746 .values()
747 .find(|node| node.content == [survivor])
748 .unwrap();
749 assert_eq!(parent.children, [child.object()]);
750 assert!(
751 matches!(&view.nodes[&survivor].kind, Kind::RichText { text, .. } if text == "Local ab🦀cd")
752 );
753 }
754
755 let (source, sid, left, _) = fixture();
756 let split = ParagraphSplit::new(left, 0, "Local").unwrap();
757 let source = PreparedEdit::split(&source, sid, &split)
758 .unwrap()
759 .as_bytes()
760 .to_vec();
761 let directory = tempfile::tempdir().unwrap();
762 let path = directory.path().join("identity.sqlite");
763 let cache = Replica::create(&path, &source).unwrap();
764 let intent = ParagraphJoin::new(left, split.text_object(), "Local").unwrap();
765 let id = cache.join(&source, sid, &intent).unwrap().unwrap();
766 let local = cache.snapshot().unwrap();
767 let remote = onestore::replace_text(&source, sid, left, 0..0, "Remote").unwrap();
768 let mut server = Server::new(&remote);
769 assert_eq!(
770 cache.sync_once(&mut server).unwrap(),
771 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
772 );
773 assert!(cache.rebase_join_conflict(id, &local, &remote).is_err());
774 assert!(cache.snapshot().unwrap() == local);
775 assert_eq!(server.publications, 0);
776 assert_eq!(cache.pending().unwrap().len(), 1);
777 }
778
779 #[test]
780 fn overlapping_clients_retain_each_local_branch_without_replaying_removed_targets() {
781 let (source, sid, left, _) = fixture();
782 let split = ParagraphSplit::new(left, 2, "Seed").unwrap();
783 let source = PreparedEdit::split(&source, sid, &split)
784 .unwrap()
785 .as_bytes()
786 .to_vec();
787 let directory = tempfile::tempdir().unwrap();
788 let first = Replica::create(directory.path().join("first.sqlite"), &source).unwrap();
789 let second_path = directory.path().join("second.sqlite");
790 let second = Replica::create(&second_path, &source).unwrap();
791 let join = ParagraphJoin::new(left, split.text_object(), "First").unwrap();
792 first.join(&source, sid, &join).unwrap();
793 let right_split = ParagraphSplit::new(split.text_object(), 2, "Second").unwrap();
794 let id = second.split(&source, sid, &right_split).unwrap().unwrap();
795 second
796 .edit_text(
797 &second.snapshot().unwrap(),
798 sid,
799 right_split.text_object(),
800 0..0,
801 "Second ",
802 )
803 .unwrap();
804 let local = second.snapshot().unwrap();
805 let pending = second.pending().unwrap();
806 let mut server = Server::new(&source);
807 assert!(matches!(
808 first.sync_once(&mut server).unwrap().unwrap().1,
809 EditStatus::Published { .. }
810 ));
811 assert_eq!(
812 second.sync_once(&mut server).unwrap(),
813 Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable)))
814 );
815 drop(second);
816 let second = Replica::open(&second_path).unwrap();
817 assert_eq!(second.pending().unwrap(), pending);
818 assert!(second.snapshot().unwrap() == local);
819 assert_eq!(server.publications, 1);
820 assert_eq!(
821 second.status(id).unwrap(),
822 Some(EditStatus::Conflict(ConflictKind::TargetUnavailable))
823 );
824 }
825
826 #[test]
827 #[ignore = "exports reconciled paragraph edits for independent native validation"]
828 fn export_native_offline_paragraphs() {
829 use std::{fs, path::PathBuf};
830 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus/paragraph-edit");
831 let output = PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap());
832 assert!(output.is_absolute());
833 fs::create_dir(&output).unwrap();
834 for (name, source, manifest, split) in [
835 (
836 "splits",
837 "before/notebook/synthetic.one",
838 "rust-split/manifest.json",
839 true,
840 ),
841 (
842 "joins",
843 "split/notebook/synthetic.one",
844 "rust-join/split/manifest.json",
845 false,
846 ),
847 (
848 "inheritance",
849 "join-edges/before/notebook/synthetic.one",
850 "rust-join/inheritance/manifest.json",
851 false,
852 ),
853 (
854 "tags",
855 "join-tags/before/notebook/synthetic.one",
856 "rust-join/tags/manifest.json",
857 false,
858 ),
859 ] {
860 let source = fs::read(root.join(source)).unwrap();
861 let folder = output.join(name);
862 fs::create_dir(&folder).unwrap();
863 let path = folder.join("cache.sqlite");
864 let mut cache = Replica::create(&path, &source).unwrap();
865 let mut server = Server::new(&source);
866 let manifest: serde_json::Value =
867 serde_json::from_slice(&fs::read(root.join(manifest)).unwrap()).unwrap();
868 let cases = if split { &manifest["cases"] } else { &manifest };
869 let mut recorded = Vec::new();
870 for case in cases
871 .as_array()
872 .unwrap()
873 .iter()
874 .filter(|case| case.get("intent").is_some())
875 {
876 let local = cache.snapshot().unwrap();
877 let (text, offset) = if split {
878 serde_json::from_value::<ParagraphSplit>(case["intent"].clone())
879 .unwrap()
880 .position()
881 } else {
882 (
883 serde_json::from_value::<ParagraphJoin>(case["intent"].clone())
884 .unwrap()
885 .texts()[0],
886 1,
887 )
888 };
889 let store = Store::parse(&local).unwrap();
890 let index = RevisionIndex::parse(&store).unwrap();
891 let doc = Document::parse(&index).unwrap();
892 let (sid, space) = doc
893 .spaces
894 .iter()
895 .find(|(_, space)| {
896 space.revisions[&space.contexts[&ExGuid::default()]]
897 .nodes
898 .contains_key(&text)
899 })
900 .unwrap();
901 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
902 let Kind::RichText { text: content, .. } = &view.nodes[&text].kind else {
903 panic!()
904 };
905 let remote_prefix = offset > 0 && !content.is_empty();
906 assert!(!content.contains('☂'));
907 if remote_prefix {
908 server.visible =
909 onestore::replace_text(&server.visible, *sid, text, 0..0, "☂").unwrap();
910 server.durable.clone_from(&server.visible);
911 }
912 let id = if split {
913 cache.split(
914 &local,
915 *sid,
916 &serde_json::from_value(case["intent"].clone()).unwrap(),
917 )
918 } else {
919 cache.join(
920 &local,
921 *sid,
922 &serde_json::from_value(case["intent"].clone()).unwrap(),
923 )
924 }
925 .unwrap()
926 .unwrap();
927 recorded.push(serde_json::json!({"case": case["case"], "id": id,
928 "space": sid, "intent": case["intent"], "remote_prefix": remote_prefix}));
929 drop(cache);
930 cache = Replica::open(&path).unwrap();
931 }
932 for case in &mut recorded {
933 let id = case["id"].as_u64().unwrap();
934 if id % 2 == 0 {
935 server.fault = Fault::UnknownAfter;
936 assert!(cache.sync_once(&mut server).is_err());
937 assert!(matches!(
938 cache.status(id).unwrap(),
939 Some(EditStatus::AwaitingConfirmation { .. })
940 ));
941 drop(cache);
942 cache = Replica::open(&path).unwrap();
943 }
944 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
945 assert_eq!(actual, id);
946 let EditStatus::Published { revision } = status else {
947 panic!("{name}: {status:?}")
948 };
949 case["revision"] = serde_json::to_value(revision).unwrap();
950 }
951 assert_eq!(server.publications, recorded.len());
952 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
953 assert!(cache.snapshot().unwrap() == server.durable);
954 cache
955 .export_recovery(folder.join("recovery.sqlite"))
956 .unwrap();
957 fs::create_dir(folder.join("candidate")).unwrap();
958 fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap();
959 fs::write(
960 folder.join("manifest.json"),
961 serde_json::to_vec_pretty(&recorded).unwrap(),
962 )
963 .unwrap();
964 }
965 }
966}
967
968#[derive(Clone, Copy, Default)]
969enum Fault {
970 #[default]
971 None,
972 Before,
973 UnknownBefore,
974 UnknownAfter,
975 Committed,
976 PanicBefore,
977 PanicAfter,
978 Confirm,
979 ConfirmCommitted,
980}
981
982struct Server {
983 visible: Vec<u8>,
984 durable: Vec<u8>,
985 fault: Fault,
986 publications: usize,
987 confirmations: usize,
988}
989
990impl Server {
991 fn new(source: &[u8]) -> Self {
992 Self {
993 visible: source.to_vec(),
994 durable: source.to_vec(),
995 fault: Fault::None,
996 publications: 0,
997 confirmations: 0,
998 }
999 }
1000}
1001
1002fn failure(state: CommitState) -> CommitError {
1003 CommitError {
1004 state,
1005 error: io::Error::from(io::ErrorKind::ConnectionAborted),
1006 }
1007}
1008
1009impl CommitIo for Server {
1010 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
1011 let offset = usize::try_from(offset).unwrap();
1012 let size = output.len().min(self.visible.len().saturating_sub(offset));
1013 if size > 0 {
1014 output[..size].copy_from_slice(&self.visible[offset..offset + size]);
1015 }
1016 Ok(size)
1017 }
1018 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
1019 let offset = usize::try_from(offset).unwrap();
1020 self.visible
1021 .resize(self.visible.len().max(offset + bytes.len()), 0);
1022 self.visible[offset..offset + bytes.len()].copy_from_slice(bytes);
1023 Ok(bytes.len())
1024 }
1025 fn flush(&mut self) -> io::Result<()> {
1026 self.durable.clone_from(&self.visible);
1027 Ok(())
1028 }
1029}
1030
1031impl Remote for Server {
1032 fn read(&mut self) -> io::Result<Vec<u8>> {
1033 Ok(self.visible.clone())
1034 }
1035 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
1036 self.publications += 1;
1037 let fault = std::mem::take(&mut self.fault);
1038 match fault {
1039 Fault::Before => return Err(failure(CommitState::NotCommitted)),
1040 Fault::UnknownBefore => return Err(failure(CommitState::Unknown)),
1041 Fault::PanicBefore => panic!("Terminated before remote I/O"),
1042 _ => {}
1043 }
1044 let old = self.durable.clone();
1045 edit.commit(self)?;
1046 match fault {
1047 Fault::UnknownAfter => {
1048 self.durable = old;
1049 Err(failure(CommitState::Unknown))
1050 }
1051 Fault::Committed => Err(failure(CommitState::Committed)),
1052 Fault::PanicAfter => panic!("Terminated after remote publication"),
1053 _ => Ok(()),
1054 }
1055 }
1056 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
1057 self.confirmations += 1;
1058 if matches!(self.fault, Fault::Confirm) {
1059 self.fault = Fault::None;
1060 return Err(failure(CommitState::Unknown));
1061 }
1062 onestore::confirm_snapshot(self, snapshot)?;
1063 if matches!(self.fault, Fault::ConfirmCommitted) {
1064 self.fault = Fault::None;
1065 return Err(failure(CommitState::Committed));
1066 }
1067 Ok(())
1068 }
1069}
1//! Replica protocol: durable receipts, retired attempts, conflict review and contention.
10702
1071fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {
1072 let store = Store::parse(source).unwrap();
1073 assert!(store.checksum_mismatches.is_empty());
1074 let index = RevisionIndex::parse(&store).unwrap();
1075 index.validate_current().unwrap();
1076 let doc = Document::parse(&index).unwrap();
1077 doc.spaces
1078 .iter()
1079 .find_map(|(sid, space)| {
1080 space.revisions[&space.contexts[&ExGuid::default()]]
1081 .nodes
1082 .iter()
1083 .find_map(|(oid, node)| match &node.kind {
1084 Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())),
1085 _ => None,
1086 })
1087 })
1088 .unwrap()
3use notebook::{ConflictKind, EditStatus, Error, Remote, Replica};
4use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, page::Page};
5use std::{io, ops::Range};
6
7#[path = "support/server.rs"]
8mod server;
9use server::*;
10#[path = "support/model_ops.rs"]
11mod model_ops;
12
13/// The page holding `text`, with a UTF-16 range of that text replaced.
14fn edited(bytes: &[u8], text: ExGuid, range: Range<u32>, replacement: &str) -> Page {
15 let (_, mut page) = model_ops::locate(bytes, text);
16 model_ops::replace_text(&mut page, text, range, replacement);
17 page
18}
19
20/// Saves a replacement of `range` in the page holding `text`.
21fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {
22 model_ops::save(cache, text, |page| {
23 model_ops::replace_text(page, text, range, replacement)
24 })
25 .unwrap()
108926}
109027
109128#[test]
1092fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becoming_a_writer() {
29fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becoming_a_writer() {
109330 use notebook::{Recovery, RecoverySummary};
1094 use onestore::{Insertion, TextAttribute};
109531
109632 let directory = tempfile::tempdir().unwrap();
109733 let path = directory.path().join("live.sqlite");
109834 let archive_path = directory.path().join("recovery.sqlite");
109935 let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap();
1100 let (space, object, _) = text(&source);
36 let (_, object, _) = text(&source);
110137 let cache = Replica::create(&path, &source).unwrap();
1102 let published = cache
1103 .edit_text(&source, space, object, 0..0, "Published ")
1104 .unwrap()
1105 .unwrap();
38 let published = save(&cache, object, 0..0, "Published ").unwrap();
110639 let mut server = Server::new(&source);
110740 let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap();
1108 let source = cache.snapshot().unwrap();
1109 let store = Store::parse(&source).unwrap();
1110 let index = RevisionIndex::parse(&store).unwrap();
1111 let document = Document::parse(&index).unwrap();
1112 let (page_space, page) = document.pages().unwrap()[0];
1113 let outline = Insertion::outline(page, 100.0, 200.0, "Outline", "Fixture").unwrap();
1114 let inserted = cache
1115 .insert(&source, page_space, &outline)
1116 .unwrap()
1117 .unwrap();
1118 let paragraph = Insertion::paragraph(outline.object(), None, "Recovery 🦀", "Fixture").unwrap();
1119 cache
1120 .insert(&cache.snapshot().unwrap(), page_space, &paragraph)
1121 .unwrap();
1122 cache
1123 .format(
1124 &cache.snapshot().unwrap(),
1125 page_space,
1126 paragraph.text_object(),
1127 0..3,
1128 &[TextAttribute::Bold(true)],
1129 )
1130 .unwrap();
1131 cache
1132 .edit_text(
1133 &cache.snapshot().unwrap(),
1134 page_space,
1135 paragraph.text_object(),
1136 0..0,
1137 "Pending ",
1138 )
1139 .unwrap();
41 let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap();
114042 server.fault = Fault::UnknownBefore;
114143 assert!(matches!(
114244 cache.sync_once(&mut server),
......@@ -1145,10 +47,17 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom
114547 ..
114648 }))
114749 ));
50 save(&cache, object, 0..0, "Queued ").unwrap();
51 let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap();
52 cache
53 .create_page(&cache.snapshot().unwrap(), &created)
54 .unwrap()
55 .unwrap();
56 save(&cache, object, 0..0, "Last ").unwrap();
114857 let working = cache.snapshot().unwrap();
114958 let remote = cache.remote_snapshot().unwrap();
115059 let pending = cache.pending().unwrap();
1151 let uncertain = cache.status(inserted).unwrap();
60 let uncertain = cache.status(attempted).unwrap();
115261 let source_file = std::fs::read(&path).unwrap();
115362 let summary = RecoverySummary {
115463 queued_edits: 4,
......@@ -1173,7 +82,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom
117382 assert_eq!(archive.remote_snapshot().unwrap(), remote);
117483 assert_eq!(archive.pending().unwrap(), pending);
117584 assert_eq!(archive.status(published).unwrap(), Some(receipt));
1176 assert_eq!(archive.status(inserted).unwrap(), uncertain);
85 assert_eq!(archive.status(attempted).unwrap(), uncertain);
117786 for edit in &pending {
117887 assert_eq!(
117988 archive.status(edit.id).unwrap(),
......@@ -1195,9 +104,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom
1195104 }
1196105 assert_eq!(std::fs::read(&path).unwrap(), source_file);
1197106 assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes);
1198 cache
1199 .edit_text(&working, space, object, 0..0, "Later ")
1200 .unwrap();
107 save(&cache, object, 0..0, "Later ").unwrap();
1201108 assert_eq!(archive.snapshot().unwrap(), working);
1202109 assert_eq!(archive.pending().unwrap(), pending);
1203110 drop(archive);
......@@ -1238,16 +145,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
1238145 let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap();
1239146 let (space, object, _) = text(&source);
1240147 let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();
1241 let id = cache
1242 .edit_text(&source, space, object, 0..8, "Local")
1243 .unwrap()
1244 .unwrap();
148 let id = save(&cache, object, 0..8, "Local").unwrap();
1245149 let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap();
1246150 let mut server = Server::new(&changed);
1247151 let outcome = cache.sync_once(&mut server).unwrap().unwrap();
1248152 assert_eq!(
1249153 outcome,
1250 (id, EditStatus::Conflict(ConflictKind::TextChanged))
154 (id, EditStatus::Conflict(ConflictKind::ContentChanged))
1251155 );
1252156 let path = directory.path().join("conflict.sqlite");
1253157 cache.export_recovery(&path).unwrap();
......@@ -1274,16 +178,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
1274178}
1275179
1276180#[test]
1277fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() {
181fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
1278182 let dir = tempfile::tempdir().unwrap();
1279183 let path = dir.path().join("cache.sqlite");
1280184 let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap();
1281185 let (sid, oid, _) = text(&source);
1282186 let cache = Replica::create(&path, &source).unwrap();
1283 let id = cache
1284 .edit_text(&source, sid, oid, 2..4, "🐈")
1285 .unwrap()
1286 .unwrap();
187 let id = save(&cache, oid, 2..4, "🐈").unwrap();
1287188 let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap();
1288189 let mut server = Server::new(&remote);
1289190 let outcome = cache.sync_once(&mut server).unwrap().unwrap();
......@@ -1298,11 +199,7 @@ fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() {
1298199 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
1299200 assert_eq!(server.publications, 1);
1300201 assert_eq!(cache.status(id + 1).unwrap(), None);
1301 let source = cache.snapshot().unwrap();
1302 let next = cache
1303 .edit_text(&source, sid, oid, 0..0, "Later ")
1304 .unwrap()
1305 .unwrap();
202 let next = save(&cache, oid, 0..0, "Later ").unwrap();
1306203 assert!(next > id);
1307204}
1308205
......@@ -1313,16 +210,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() {
1313210 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1314211 let (sid, oid, _) = text(&source);
1315212 let cache = Replica::create(&path, &source).unwrap();
1316 let id = cache
1317 .edit_text(&source, sid, oid, 1..2, "L")
1318 .unwrap()
1319 .unwrap();
213 let id = save(&cache, oid, 1..2, "L").unwrap();
1320214 let local = cache.snapshot().unwrap();
1321215 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
1322216 let mut server = Server::new(&remote);
1323217 assert_eq!(
1324218 cache.sync_once(&mut server).unwrap(),
1325 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
219 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
1326220 );
1327221 assert_eq!(server.publications, 0);
1328222 drop(cache);
......@@ -1332,7 +226,7 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() {
1332226 assert_eq!(cache.pending().unwrap().len(), 1);
1333227 assert_eq!(
1334228 cache.status(id).unwrap(),
1335 Some(EditStatus::Conflict(ConflictKind::TextChanged))
229 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
1336230 );
1337231}
1338232
......@@ -1347,12 +241,9 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() {
1347241 let dir = tempfile::tempdir().unwrap();
1348242 let path = dir.path().join("cache.sqlite");
1349243 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1350 let (sid, oid, _) = text(&source);
244 let (_, oid, _) = text(&source);
1351245 let cache = Replica::create(&path, &source).unwrap();
1352 let id = cache
1353 .edit_text(&source, sid, oid, 0..0, "Once ")
1354 .unwrap()
1355 .unwrap();
246 let id = save(&cache, oid, 0..0, "Once ").unwrap();
1356247 let mut server = Server::new(&source);
1357248 server.fault = fault;
1358249 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
......@@ -1383,12 +274,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {
1383274 let dir = tempfile::tempdir().unwrap();
1384275 let path = dir.path().join("cache.sqlite");
1385276 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1386 let (sid, oid, _) = text(&source);
277 let (_, oid, _) = text(&source);
1387278 let cache = Replica::create(&path, &source).unwrap();
1388 let id = cache
1389 .edit_text(&source, sid, oid, 0..0, "Once ")
1390 .unwrap()
1391 .unwrap();
279 let id = save(&cache, oid, 0..0, "Once ").unwrap();
1392280 let mut server = Server::new(&source);
1393281 server.fault = Fault::UnknownAfter;
1394282 assert!(cache.sync_once(&mut server).is_err());
......@@ -1412,12 +300,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {
1412300fn confirmation_cleanup_failure_still_records_a_durable_receipt() {
1413301 let dir = tempfile::tempdir().unwrap();
1414302 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1415 let (sid, oid, _) = text(&source);
303 let (_, oid, _) = text(&source);
1416304 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
1417 let id = cache
1418 .edit_text(&source, sid, oid, 0..0, "Once ")
1419 .unwrap()
1420 .unwrap();
305 let id = save(&cache, oid, 0..0, "Once ").unwrap();
1421306 let mut server = Server::new(&source);
1422307 server.fault = Fault::UnknownAfter;
1423308 assert!(cache.sync_once(&mut server).is_err());
......@@ -1441,12 +326,9 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
1441326 let dir = tempfile::tempdir().unwrap();
1442327 let path = dir.path().join("cache.sqlite");
1443328 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1444 let (sid, oid, _) = text(&source);
329 let (_, oid, _) = text(&source);
1445330 let cache = Replica::create(&path, &source).unwrap();
1446 let id = cache
1447 .edit_text(&source, sid, oid, 0..0, "Once ")
1448 .unwrap()
1449 .unwrap();
331 let id = save(&cache, oid, 0..0, "Once ").unwrap();
1450332 let mut server = Server::new(&source);
1451333 server.fault = fault;
1452334 assert!(matches!(
......@@ -1478,12 +360,9 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {
1478360 let dir = tempfile::tempdir().unwrap();
1479361 let path = dir.path().join("cache.sqlite");
1480362 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1481 let (sid, oid, _) = text(&source);
363 let (_, oid, _) = text(&source);
1482364 let cache = Replica::create(&path, &source).unwrap();
1483 let id = cache
1484 .edit_text(&source, sid, oid, 0..0, "Once ")
1485 .unwrap()
1486 .unwrap();
365 let id = save(&cache, oid, 0..0, "Once ").unwrap();
1487366 drop(cache);
1488367 let db = rusqlite::Connection::open(&path).unwrap();
1489368 db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap();
......@@ -1550,12 +429,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1550429 for phase in ["read", "publish", "confirm"] {
1551430 let dir = tempfile::tempdir().unwrap();
1552431 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1553 let (sid, oid, _) = text(&source);
432 let (_, oid, _) = text(&source);
1554433 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
1555 let first = cache
1556 .edit_text(&source, sid, oid, 0..0, "First ")
1557 .unwrap()
1558 .unwrap();
434 let first = save(&cache, oid, 0..0, "First ").unwrap();
1559435 let mut server = Server::new(&source);
1560436 if phase == "confirm" {
1561437 server.fault = Fault::UnknownAfter;
......@@ -1583,8 +459,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1583459 assert!(
1584460 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)
1585461 );
462 let reviewed = edited(&source, oid, 0..0, "Reviewed ");
1586463 assert!(
1587 matches!(cache.rebase_conflict(first, &[], &[], 0..0), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock)
464 matches!(cache.review_page(first, &[], &[], &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock)
1588465 );
1589466 let started = std::time::Instant::now();
1590467 let handles: Vec<_> = (0..12)
......@@ -1592,14 +469,10 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1592469 let cache = &cache;
1593470 scope.spawn(move || {
1594471 loop {
1595 let snapshot = cache.snapshot().unwrap();
1596 match cache.edit_text(
1597 &snapshot,
1598 sid,
1599 oid,
1600 0..0,
1601 &format!("[{writer}] "),
1602 ) {
472 let marker = format!("[{writer}] ");
473 match model_ops::save(cache, oid, |page| {
474 model_ops::replace_text(page, oid, 0..0, &marker)
475 }) {
1603476 Ok(Some(id)) => break id,
1604477 Err(Error::Io(error))
1605478 if error.kind() == io::ErrorKind::ResourceBusy => {}
......@@ -1613,7 +486,12 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1613486 .into_iter()
1614487 .map(|handle| handle.join().unwrap())
1615488 .collect();
1616 assert_eq!(ids.len(), 12);
489 assert_eq!(ids.len(), 1, "saves to one page coalesce");
490 assert_eq!(
491 ids.contains(&first),
492 phase == "read",
493 "saves coalesce into the head only until a step selects it"
494 );
1617495 assert!(
1618496 started.elapsed() < std::time::Duration::from_secs(2),
1619497 "Local edits waited for remote {phase}"
......@@ -1621,16 +499,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1621499 resume_tx.send(()).unwrap();
1622500 running.join().unwrap()
1623501 });
1624 assert_eq!(cache.pending().unwrap().len(), 12);
502 assert_eq!(cache.pending().unwrap().len(), usize::from(phase != "read"));
1625503 let expected = text(&cache.snapshot().unwrap()).2;
1626 for _ in 0..12 {
504 for writer in 0..12 {
505 assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1);
506 }
507 while !cache.pending().unwrap().is_empty() {
1627508 assert!(matches!(
1628509 cache.sync_once(&mut server).unwrap(),
1629510 Some((_, EditStatus::Published { .. }))
1630511 ));
1631512 }
1632 assert!(cache.pending().unwrap().is_empty());
1633 assert_eq!(server.publications, 13);
513 assert_eq!(server.publications, if phase == "read" { 1 } else { 2 });
1634514 assert_eq!(text(&server.durable).2, expected);
1635515 assert_eq!(cache.snapshot().unwrap(), server.durable);
1636516 }
......@@ -1640,13 +520,13 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
1640520fn unrelated_remote_files_never_replace_a_local_cache() {
1641521 let dir = tempfile::tempdir().unwrap();
1642522 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
1643 let (sid, oid, _) = text(&source);
523 let (_, oid, _) = text(&source);
1644524 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
1645525 let other = onestore::create_section("other.one", "abc", "Fixture").unwrap();
1646526 let mut server = Server::new(&other);
1647527 for pending in [false, true] {
1648528 if pending {
1649 cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap();
529 save(&cache, oid, 0..0, "Local ").unwrap();
1650530 }
1651531 let before = cache.snapshot().unwrap();
1652532 assert!(
......@@ -1659,126 +539,72 @@ fn unrelated_remote_files_never_replace_a_local_cache() {
1659539}
1660540
1661541#[test]
1662fn version_one_cache_migration_preserves_images_intents_and_local_ids() {
542fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() {
1663543 let dir = tempfile::tempdir().unwrap();
1664544 let path = dir.path().join("cache.sqlite");
1665 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
545 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1666546 let (sid, oid, _) = text(&source);
1667547 let cache = Replica::create(&path, &source).unwrap();
1668 let id = cache
1669 .edit_text(&source, sid, oid, 0..0, "Local ")
1670 .unwrap()
1671 .unwrap();
1672 let snapshot = cache.snapshot().unwrap();
548 let first = save(&cache, oid, 1..2, "L").unwrap();
549 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
550 let mut server = Server::new(&remote);
551 assert_eq!(
552 cache.sync_once(&mut server).unwrap(),
553 Some((first, EditStatus::Conflict(ConflictKind::ContentChanged)))
554 );
555 let mut dependent = None;
556 for n in 0..12 {
557 dependent = save(&cache, oid, 0..0, &format!("[{n}] "));
558 }
559 let dependent = dependent.unwrap();
560 assert_ne!(dependent, first, "a conflicted save is never rewritten");
561 let local = cache.snapshot().unwrap();
1673562 let pending = cache.pending().unwrap();
1674 drop(cache);
1675 let db = rusqlite::Connection::open(&path).unwrap();
1676 let current_version: u32 = db
1677 .pragma_query_value(None, "user_version", |row| row.get(0))
1678 .unwrap();
1679 db.execute_batch(
1680 "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets;
1681 CREATE TABLE edits (
1682 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL,
1683 object TEXT NOT NULL, before_text TEXT NOT NULL,
1684 start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295),
1685 end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL
1686 ) STRICT; PRAGMA user_version=1;",
1687 )
563 assert_eq!(pending.len(), 2);
564 model_ops::review(&cache, first, oid, |page| {
565 model_ops::replace_text(page, oid, 1..2, "L")
566 })
1688567 .unwrap();
1689 db.execute("INSERT INTO edits(id,space,object,before_text,start,end,replacement) VALUES (?1,?2,?3,'abc',0,0,'Local ')",rusqlite::params![i64::try_from(id).unwrap(),sid.to_string(),oid.to_string()]).unwrap();
1690 drop(db);
1691 let cache = Replica::open(&path).unwrap();
1692 assert_eq!(cache.snapshot().unwrap(), snapshot);
1693 assert_eq!(cache.pending().unwrap(), pending);
1694 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
1695 let mut server = Server::new(&source);
1696 assert!(matches!(
1697 cache.sync_once(&mut server).unwrap(),
1698 Some((_, EditStatus::Published { .. }))
1699 ));
1700 drop(cache);
1701 let db = rusqlite::Connection::open(&path).unwrap();
568 assert_eq!(cache.snapshot().unwrap(), local);
569 assert_eq!(cache.remote_snapshot().unwrap(), remote);
570 let reviewed = cache.pending().unwrap();
571 assert_eq!(reviewed[1..], pending[1..]);
572 assert_eq!(reviewed[0].id, first);
573 let notebook::Operation::Page(intent) = &reviewed[0].operation else {
574 panic!()
575 };
1702576 assert_eq!(
1703 db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
1704 .unwrap(),
1705 current_version
577 intent.text_change(),
578 Some((oid, "aRcZ".into(), 1..2, "L".into()))
1706579 );
1707}
1708
1709#[test]
1710fn reviewed_conflict_rebase_preserves_twelve_dependent_edits_and_survives_reopen() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let path = dir.path().join("cache.sqlite");
1713 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1714 let (sid, oid, _) = text(&source);
1715 let cache = Replica::create(&path, &source).unwrap();
1716 let first = cache
1717 .edit_text(&source, sid, oid, 1..2, "L")
1718 .unwrap()
1719 .unwrap();
1720 for n in 0..12 {
1721 cache
1722 .edit_text(
1723 &cache.snapshot().unwrap(),
1724 sid,
1725 oid,
1726 0..0,
1727 &format!("[{n}] "),
1728 )
1729 .unwrap();
1730 }
1731 let local = cache.snapshot().unwrap();
1732 let pending = cache.pending().unwrap();
1733 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
1734 let mut server = Server::new(&remote);
1735 assert_eq!(
1736 cache.sync_once(&mut server).unwrap(),
1737 Some((first, EditStatus::Conflict(ConflictKind::TextChanged)))
1738 );
1739 cache.rebase_conflict(first, &local, &remote, 1..2).unwrap();
1740 assert_eq!(cache.snapshot().unwrap(), local);
1741 assert_eq!(cache.remote_snapshot().unwrap(), remote);
1742 let rebased = cache.pending().unwrap();
1743 assert_eq!(&rebased[1..], &pending[1..]);
1744 assert_eq!(rebased[0].id, first);
1745 let notebook::Operation::Text(updated) = &rebased[0].operation else {
1746 panic!()
1747 };
1748 let notebook::Operation::Text(previous) = &pending[0].operation else {
1749 panic!()
1750 };
1751 assert_eq!(updated.replacement, previous.replacement);
1752 assert_eq!(updated.before, "aRcZ");
580 assert_eq!(intent.author, model_ops::AUTHOR);
1753581 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));
1754582 drop(cache);
1755583 let cache = Replica::open(&path).unwrap();
1756 assert_eq!(cache.pending().unwrap(), rebased);
584 assert_eq!(cache.pending().unwrap(), reviewed);
1757585 assert_eq!(cache.snapshot().unwrap(), local);
1758586 for intent in &pending {
1759587 assert!(
1760588 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id)
1761589 );
1762590 }
1763 assert_eq!(server.publications, 13);
591 assert_eq!(server.publications, 2);
1764592 assert_eq!(text(&server.durable).2, text(&local).2 + "Z");
1765593 assert_eq!(cache.snapshot().unwrap(), server.durable);
1766594 assert!(cache.pending().unwrap().is_empty());
1767595}
1768596
1769597#[test]
1770fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() {
598fn conflict_review_rejects_stale_images_and_nonconflicting_states() {
1771599 let dir = tempfile::tempdir().unwrap();
1772600 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1773601 let (sid, oid, _) = text(&source);
1774602 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
1775 let id = cache
1776 .edit_text(&source, sid, oid, 1..2, "L")
1777 .unwrap()
1778 .unwrap();
603 let id = save(&cache, oid, 1..2, "L").unwrap();
1779604 let local = cache.snapshot().unwrap();
605 let pending_review = edited(&source, oid, 1..2, "L");
1780606 assert!(
1781 matches!(cache.rebase_conflict(id, &local, &source, 1..2), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
607 matches!(cache.review_page(id, &local, &source, &pending_review), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
1782608 );
1783609 let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap();
1784610 let mut server = Server::new(&remote);
......@@ -1787,20 +613,15 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states
1787613 Some((_, EditStatus::Conflict(_)))
1788614 ));
1789615 let pending = cache.pending().unwrap();
1790 for range in [1..2, 100..101] {
1791 assert!(matches!(
1792 cache.rebase_conflict(id, &local, &remote, range),
1793 Err(Error::Document(_))
1794 ));
1795 assert_eq!(cache.pending().unwrap(), pending);
1796 }
616 let reviewed = edited(&remote, oid, 2..3, "L");
1797617 assert!(
1798 matches!(cache.rebase_conflict(id + 1, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
618 matches!(cache.review_page(id + 1, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
1799619 );
1800 cache.edit_text(&local, sid, oid, 0..0, "Later ").unwrap();
620 assert_eq!(cache.pending().unwrap(), pending);
621 save(&cache, oid, 0..0, "Later ").unwrap();
1801622 let changed = cache.snapshot().unwrap();
1802623 assert!(
1803 matches!(cache.rebase_conflict(id, &local, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
624 matches!(cache.review_page(id, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
1804625 );
1805626 let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap();
1806627 server.visible = new_remote.clone();
......@@ -1810,14 +631,17 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states
1810631 Some((_, EditStatus::Conflict(_)))
1811632 ));
1812633 assert!(
1813 matches!(cache.rebase_conflict(id, &changed, &remote, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
634 matches!(cache.review_page(id, &changed, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
1814635 );
1815636 assert_eq!(cache.snapshot().unwrap(), changed);
1816637 assert_eq!(cache.pending().unwrap()[0], pending[0]);
1817638 assert_eq!(server.publications, 0);
1818639
1819640 let current = cache.remote_snapshot().unwrap();
1820 cache.rebase_conflict(id, &changed, &current, 2..3).unwrap();
641 let reviewed = edited(&current, oid, 2..3, "L");
642 cache
643 .review_page(id, &changed, &current, &reviewed)
644 .unwrap();
1821645 server.fault = Fault::UnknownBefore;
1822646 assert!(
1823647 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown)
......@@ -1825,7 +649,7 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states
1825649 let attempted = cache.status(id).unwrap();
1826650 let pending = cache.pending().unwrap();
1827651 assert!(
1828 matches!(cache.rebase_conflict(id, &changed, &current, 2..3), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
652 matches!(cache.review_page(id, &changed, &current, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
1829653 );
1830654 assert_eq!(cache.status(id).unwrap(), attempted);
1831655 assert_eq!(cache.pending().unwrap(), pending);
......@@ -1833,16 +657,13 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states
1833657}
1834658
1835659#[test]
1836fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() {
660fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() {
1837661 let dir = tempfile::tempdir().unwrap();
1838662 let path = dir.path().join("cache.sqlite");
1839663 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1840664 let (sid, oid, _) = text(&source);
1841665 let cache = Replica::create(&path, &source).unwrap();
1842 let id = cache
1843 .edit_text(&source, sid, oid, 1..2, "L")
1844 .unwrap()
1845 .unwrap();
666 let id = save(&cache, oid, 1..2, "L").unwrap();
1846667 let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap();
1847668 let mut server = Server::new(&remote);
1848669 cache.sync_once(&mut server).unwrap();
......@@ -1854,8 +675,9 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution()
1854675 connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap();
1855676 drop(connection);
1856677 let cache = Replica::open(&path).unwrap();
678 let reviewed = edited(&remote, oid, 2..3, "L");
1857679 assert!(matches!(
1858 cache.rebase_conflict(id, &local, &remote, 2..3),
680 cache.review_page(id, &local, &remote, &reviewed),
1859681 Err(Error::Database(_))
1860682 ));
1861683 drop(cache);
......@@ -1868,7 +690,7 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution()
1868690}
1869691
1870692#[test]
1871fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() {
693fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
1872694 let dir = tempfile::tempdir().unwrap();
1873695 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();
1874696 let mut seed = 911_u64;
......@@ -1889,20 +711,9 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace
1889711 .unwrap();
1890712 let (sid, oid, _) = text(&source);
1891713 let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap();
1892 let id = cache
1893 .edit_text(&source, sid, oid, start..end, "λ🦊μ")
1894 .unwrap()
1895 .unwrap();
1896 let dependent = cache
1897 .edit_text(
1898 &cache.snapshot().unwrap(),
1899 sid,
1900 oid,
1901 start + 1..start + 3,
1902 "🐕",
1903 )
1904 .unwrap()
1905 .unwrap();
714 let id = save(&cache, oid, start..end, "λ🦊μ").unwrap();
715 let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap();
716 assert_eq!(dependent, id, "case {case}, seed {seed}");
1906717 let mut remote_text = original.clone();
1907718 remote_text.splice(at..at + 1, "Ω🐈π".chars());
1908719 let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix;
......@@ -1915,28 +726,20 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace
1915726 )
1916727 .unwrap();
1917728 let mut server = Server::new(&remote);
1918 assert!(
1919 matches!(
1920 cache.sync_once(&mut server).unwrap(),
1921 Some((_, EditStatus::Conflict(_)))
1922 ),
729 assert_eq!(
730 cache.sync_once(&mut server).unwrap(),
731 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
1923732 "case {case}, seed {seed}"
1924733 );
1925734 let at_remote = start + prefix.len() as u32;
1926 cache
1927 .rebase_conflict(
1928 id,
1929 &cache.snapshot().unwrap(),
1930 &remote,
1931 at_remote..at_remote + 4,
1932 )
1933 .unwrap();
1934 for expected in [id, dependent] {
1935 assert!(
1936 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected),
1937 "case {case}, seed {seed}"
1938 );
1939 }
735 model_ops::review(&cache, id, oid, |page| {
736 model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ")
737 })
738 .unwrap();
739 assert!(
740 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id),
741 "case {case}, seed {seed}"
742 );
1940743 let mut expected = original.clone();
1941744 expected.splice(at..at + 1, "λ🐕μ".chars());
1942745 let expected = prefix + &expected.iter().collect::<String>() + &suffix;
......@@ -1945,13 +748,13 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace
1945748 expected,
1946749 "case {case}, seed {seed}"
1947750 );
1948 assert_eq!(server.publications, 2);
751 assert_eq!(server.publications, 1);
1949752 assert!(cache.pending().unwrap().is_empty());
1950753 }
1951754}
1952755
1953756#[test]
1954fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() {
757fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
1955758 struct ChangedAfterRead {
1956759 server: Server,
1957760 change: Option<Vec<u8>>,
......@@ -1977,10 +780,7 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement()
1977780 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
1978781 let (sid, oid, _) = text(&source);
1979782 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
1980 let id = cache
1981 .edit_text(&source, sid, oid, 1..2, "L")
1982 .unwrap()
1983 .unwrap();
783 let id = save(&cache, oid, 1..2, "L").unwrap();
1984784 let local = cache.snapshot().unwrap();
1985785 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
1986786 let mut remote = ChangedAfterRead {
......@@ -1991,9 +791,10 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement()
1991791 cache.sync_once(&mut remote).unwrap(),
1992792 Some((_, EditStatus::Conflict(_)))
1993793 ));
1994 cache
1995 .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2)
1996 .unwrap();
794 model_ops::review(&cache, id, oid, |page| {
795 model_ops::replace_text(page, oid, 1..2, "L")
796 })
797 .unwrap();
1997798 let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap();
1998799 if after_read {
1999800 remote.change = Some(changed.clone());
......@@ -2009,1925 +810,18 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement()
2009810 }
2010811 assert_eq!(
2011812 cache.sync_once(&mut remote).unwrap(),
2012 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
813 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
2013814 );
2014815 assert_eq!(remote.server.durable, changed);
2015816 assert_eq!(remote.server.visible, changed);
2016817 assert_eq!(remote.server.publications, usize::from(after_read));
2017818 assert_eq!(cache.snapshot().unwrap(), local);
2018 let notebook::Operation::Text(edit) = &cache.pending().unwrap()[0].operation else {
819 let notebook::Operation::Page(intent) = &cache.pending().unwrap()[0].operation else {
2019820 panic!()
2020821 };
2021 assert_eq!(edit.replacement, "L");
2022 }
2023}
2024
2025mod worker {
2026 use super::*;
2027 use std::{
2028 sync::{Arc, Mutex, mpsc},
2029 time::{Duration, Instant},
2030 };
2031
2032 #[derive(Clone)]
2033 struct Shared(Arc<Mutex<Server>>);
2034
2035 impl Remote for Shared {
2036 fn read(&mut self) -> io::Result<Vec<u8>> {
2037 self.0.lock().unwrap().read()
2038 }
2039 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
2040 self.0.lock().unwrap().publish(edit)
2041 }
2042 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
2043 self.0.lock().unwrap().confirm(snapshot)
2044 }
2045 }
2046
2047 #[test]
2048 fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
2049 struct Session {
2050 shared: Shared,
2051 fail_read: bool,
2052 }
2053 impl Remote for Session {
2054 fn read(&mut self) -> io::Result<Vec<u8>> {
2055 if self.fail_read {
2056 return Err(io::ErrorKind::ConnectionReset.into());
2057 }
2058 self.shared.read()
2059 }
2060 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
2061 self.shared.publish(edit)
2062 }
2063 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
2064 self.shared.confirm(snapshot)
2065 }
2066 }
2067 let dir = tempfile::tempdir().unwrap();
2068 let path = dir.path().join("cache.sqlite");
2069 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2070 let (sid, oid, _) = text(&source);
2071 let cache = Arc::new(Replica::create(&path, &source).unwrap());
2072 let id = cache
2073 .edit_text(&source, sid, oid, 1..2, "🦀")
2074 .unwrap()
2075 .unwrap();
2076 let mut server = Server::new(&source);
2077 server.fault = Fault::UnknownAfter;
2078 let server = Arc::new(Mutex::new(server));
2079 let shared = Shared(Arc::clone(&server));
2080 let (connected_tx, connected_rx) = mpsc::channel();
2081 let (observed_tx, observed_rx) = mpsc::channel();
2082 let mut connections = 0;
2083 let worker = cache
2084 .start_sync(
2085 Duration::from_millis(10),
2086 move || {
2087 connections += 1;
2088 connected_tx.send(connections).unwrap();
2089 if connections <= 2 {
2090 return Err(io::ErrorKind::ConnectionRefused.into());
2091 }
2092 Ok(Session {
2093 shared: shared.clone(),
2094 fail_read: connections == 3,
2095 })
2096 },
2097 move |result| {
2098 observed_tx
2099 .send(result.as_ref().copied().map_err(|error| error.to_string()))
2100 .unwrap();
2101 },
2102 )
2103 .unwrap();
2104 let mut errors = 0;
2105 let published = loop {
2106 match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
2107 Err(_) => errors += 1,
2108 Ok(Some((actual, status @ EditStatus::Published { .. }))) => {
2109 assert_eq!(actual, id);
2110 break status;
2111 }
2112 other => panic!("Unexpected result: {other:?}"),
2113 }
2114 };
2115 worker.stop().unwrap();
2116 assert_eq!(errors, 4);
2117 assert_eq!(connected_rx.try_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
2118 let server = server.lock().unwrap();
2119 assert_eq!(server.publications, 1);
2120 assert_eq!(server.confirmations, 1);
2121 assert_eq!(text(&server.durable).2, "a🦀c");
2122 drop(cache);
2123 let cache = Replica::open(&path).unwrap();
2124 assert_eq!(cache.status(id).unwrap(), Some(published));
2125 let cached = cache.snapshot().unwrap();
2126 // Confirmation changes reader-notification fields without changing the committed graph.
2127 assert_eq!(cached[..212], server.durable[..212]);
2128 assert_eq!(cached[252..], server.durable[252..]);
2129 let cached_generation = Store::parse(&cached).unwrap().header.generation;
2130 let remote_generation = Store::parse(&server.durable).unwrap().header.generation;
2131 if cached_generation == remote_generation {
2132 assert_eq!(cached, server.durable);
2133 } else {
2134 assert_eq!(cached_generation.checked_add(1), Some(remote_generation));
2135 }
2136 }
2137
2138 #[test]
2139 fn local_edits_wake_an_idle_worker_and_coalesced_notifications_drain_twelve_writers() {
2140 let dir = tempfile::tempdir().unwrap();
2141 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2142 let (sid, oid, _) = text(&source);
2143 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2144 let server = Arc::new(Mutex::new(Server::new(&source)));
2145 let shared = Shared(Arc::clone(&server));
2146 let (observed_tx, observed_rx) = mpsc::channel();
2147 let (resume_tx, resume_rx) = mpsc::channel();
2148 let mut first = true;
2149 let worker = cache
2150 .start_sync(
2151 Duration::from_secs(3600),
2152 move || Ok(shared.clone()),
2153 move |result| {
2154 observed_tx.send(*result.as_ref().unwrap()).unwrap();
2155 if first {
2156 first = false;
2157 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
2158 }
2159 },
2160 )
2161 .unwrap();
2162 assert_eq!(
2163 observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
2164 None
2165 );
2166 let started = Instant::now();
2167 let edits = std::thread::scope(|scope| {
2168 (0..12)
2169 .map(|writer| {
2170 let cache = &cache;
2171 scope.spawn(move || {
2172 let replacement = format!("[{writer}] ");
2173 loop {
2174 let snapshot = cache.snapshot().unwrap();
2175 match cache.edit_text(&snapshot, sid, oid, 0..0, &replacement) {
2176 Ok(Some(id)) => break (id, replacement),
2177 Err(Error::Io(error))
2178 if error.kind() == io::ErrorKind::ResourceBusy => {}
2179 other => panic!("Unexpected local outcome: {other:?}"),
2180 }
2181 }
2182 })
2183 })
2184 .collect::<Vec<_>>()
2185 .into_iter()
2186 .map(|join| join.join().unwrap())
2187 .collect::<std::collections::BTreeMap<_, _>>()
2188 });
2189 resume_tx.send(()).unwrap();
2190 let mut published = std::collections::BTreeSet::new();
2191 while published.len() < 12 {
2192 if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
2193 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
2194 assert!(published.insert(id));
2195 }
2196 }
2197 assert!(
2198 started.elapsed() < Duration::from_secs(5),
2199 "Edits waited for the hourly poll"
2200 );
2201 worker.stop().unwrap();
2202 assert_eq!(published, edits.keys().copied().collect());
2203 let expected = edits.values().rev().cloned().collect::<String>() + "abc";
2204 let server = server.lock().unwrap();
2205 assert_eq!(text(&server.durable).2, expected);
2206 assert_eq!(server.publications, 12);
2207 assert_eq!(cache.snapshot().unwrap(), server.durable);
2208 assert!(cache.pending().unwrap().is_empty());
2209 }
2210
2211 #[test]
2212 fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded()
2213 {
2214 struct Paused {
2215 shared: Shared,
2216 entered: mpsc::Sender<()>,
2217 resume: mpsc::Receiver<()>,
2218 }
2219 impl Remote for Paused {
2220 fn read(&mut self) -> io::Result<Vec<u8>> {
2221 self.shared.read()
2222 }
2223 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
2224 self.entered.send(()).unwrap();
2225 self.resume.recv_timeout(Duration::from_secs(5)).unwrap();
2226 self.shared.publish(edit)
2227 }
2228 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
2229 self.shared.confirm(snapshot)
2230 }
2231 }
2232 let dir = tempfile::tempdir().unwrap();
2233 let path = dir.path().join("cache.sqlite");
2234 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2235 let (sid, oid, _) = text(&source);
2236 let cache = Arc::new(Replica::create(&path, &source).unwrap());
2237 let id = cache
2238 .edit_text(&source, sid, oid, 0..0, "L ")
2239 .unwrap()
2240 .unwrap();
2241 let mut server = Server::new(&source);
2242 server.fault = Fault::UnknownAfter;
2243 let server = Arc::new(Mutex::new(server));
2244 let shared = Shared(Arc::clone(&server));
2245 let (entered_tx, entered_rx) = mpsc::channel();
2246 let (resume_tx, resume_rx) = mpsc::channel();
2247 let mut session = Some(Paused {
2248 shared,
2249 entered: entered_tx,
2250 resume: resume_rx,
2251 });
2252 let worker = cache
2253 .start_sync(
2254 Duration::from_secs(3600),
2255 move || Ok(session.take().unwrap()),
2256 |_| {},
2257 )
2258 .unwrap();
2259 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
2260 let stopped = Instant::now();
2261 drop(worker);
2262 assert!(stopped.elapsed() < Duration::from_millis(500));
2263 let shared = Shared(Arc::clone(&server));
2264 let start = cache.start_sync(
2265 Duration::from_secs(3600),
2266 move || Ok(shared.clone()),
2267 |_| {},
2268 );
2269 assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock));
2270 let weak = Arc::downgrade(&cache);
2271 drop(cache);
2272 resume_tx.send(()).unwrap();
2273 while weak.upgrade().is_some() {
2274 assert!(stopped.elapsed() < Duration::from_secs(5));
2275 std::thread::sleep(Duration::from_millis(1));
2276 }
2277 let cache = Arc::new(Replica::open(&path).unwrap());
2278 assert!(matches!(
2279 cache.status(id).unwrap(),
2280 Some(EditStatus::AwaitingConfirmation { .. })
2281 ));
2282 assert_eq!(server.lock().unwrap().publications, 1);
2283 let shared = Shared(Arc::clone(&server));
2284 let (tx, rx) = mpsc::channel();
2285 let worker = cache
2286 .start_sync(
2287 Duration::from_secs(3600),
2288 move || Ok(shared.clone()),
2289 move |result| {
2290 tx.send(*result.as_ref().unwrap()).unwrap();
2291 },
2292 )
2293 .unwrap();
2294 assert!(
2295 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
2296 );
2297 worker.stop().unwrap();
2298 let server = server.lock().unwrap();
2299 assert_eq!(server.publications, 1);
2300 assert_eq!(server.confirmations, 1);
2301 assert_eq!(text(&server.durable).2, "L abc");
2302 }
2303
2304 #[test]
2305 fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() {
2306 let dir = tempfile::tempdir().unwrap();
2307 let path = dir.path().join("cache.sqlite");
2308 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2309 let (sid, oid, _) = text(&source);
2310 let cache = Replica::create(&path, &source).unwrap();
2311 let id = cache
2312 .edit_text(&source, sid, oid, 0..0, "L ")
2313 .unwrap()
2314 .unwrap();
2315 drop(cache);
2316 let connection = rusqlite::Connection::open(&path).unwrap();
2317 connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap();
2318 drop(connection);
2319 let cache = Arc::new(Replica::open(&path).unwrap());
2320 let server = Arc::new(Mutex::new(Server::new(&source)));
2321 let shared = Shared(Arc::clone(&server));
2322 let (tx, rx) = mpsc::channel();
2323 let worker = cache
2324 .start_sync(
2325 Duration::from_millis(1),
2326 move || Ok(shared.clone()),
2327 move |result| {
2328 tx.send(matches!(result, Err(Error::Database(_)))).unwrap();
2329 },
2330 )
2331 .unwrap();
2332 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
2333 assert!(matches!(worker.stop(), Err(Error::Database(_))));
2334 assert!(rx.try_iter().next().is_none());
2335 assert_eq!(server.lock().unwrap().publications, 0);
2336 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
2337 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
2338 }
2339
2340 #[test]
2341 fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
2342 for uncertain in [false, true] {
2343 let dir = tempfile::tempdir().unwrap();
2344 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2345 let (sid, oid, _) = text(&source);
2346 let cache =
2347 Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2348 let id = cache
2349 .edit_text(&source, sid, oid, 1..2, "L")
2350 .unwrap()
2351 .unwrap();
2352 let local = cache.snapshot().unwrap();
2353 let mut server = if uncertain {
2354 Server::new(&source)
2355 } else {
2356 Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap())
2357 };
2358 if uncertain {
2359 server.fault = Fault::UnknownBefore;
2360 }
2361 let server = Arc::new(Mutex::new(server));
2362 let shared = Shared(Arc::clone(&server));
2363 let (tx, rx) = mpsc::channel();
2364 let worker = cache
2365 .start_sync(
2366 Duration::from_millis(10),
2367 move || Ok(shared.clone()),
2368 move |result| {
2369 tx.send(result.as_ref().copied().map_err(|_| ())).unwrap();
2370 },
2371 )
2372 .unwrap();
2373 if uncertain {
2374 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
2375 }
2376 let mut previous = None;
2377 let started = Instant::now();
2378 for _ in 0..5 {
2379 let (actual, status) = rx
2380 .recv_timeout(Duration::from_secs(5))
2381 .unwrap()
2382 .unwrap()
2383 .unwrap();
2384 assert_eq!(actual, id);
2385 if uncertain {
2386 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
2387 } else {
2388 assert_eq!(status, EditStatus::Conflict(ConflictKind::TextChanged));
2389 }
2390 if let Some(previous) = previous {
2391 assert_eq!(previous, status);
2392 }
2393 previous = Some(status);
2394 }
2395 assert!(
2396 started.elapsed() >= Duration::from_millis(30),
2397 "Worker spun instead of waiting between retries"
2398 );
2399 worker.stop().unwrap();
2400 assert_eq!(cache.snapshot().unwrap(), local);
2401 assert_eq!(cache.pending().unwrap().len(), 1);
2402 let server = server.lock().unwrap();
2403 assert_eq!(server.publications, usize::from(uncertain));
2404 assert_eq!(server.confirmations, 0);
2405 }
2406 }
2407
2408 #[test]
2409 fn reachability_notification_retries_without_waiting_for_the_poll() {
2410 let dir = tempfile::tempdir().unwrap();
2411 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2412 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2413 let (tx, rx) = mpsc::channel();
2414 let worker = cache
2415 .start_sync(
2416 Duration::from_secs(3600),
2417 || -> io::Result<Shared> { Err(io::ErrorKind::NotConnected.into()) },
2418 move |result| {
2419 tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap();
2420 },
2421 )
2422 .unwrap();
2423 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
2424 worker.wake();
2425 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
2426 worker.stop().unwrap();
2427 assert!(rx.try_iter().next().is_none());
2428 }
2429
2430 #[test]
2431 fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {
2432 let dir = tempfile::tempdir().unwrap();
2433 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2434 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2435 let (entered_tx, entered_rx) = mpsc::channel();
2436 let (resume_tx, resume_rx) = mpsc::channel();
2437 let (tx, rx) = mpsc::channel();
2438 // An invalid image makes any unexpected read observable as an error callback.
2439 let shared = Shared(Arc::new(Mutex::new(Server::new(&[]))));
2440 let worker = cache
2441 .start_sync(
2442 Duration::from_secs(3600),
2443 move || {
2444 entered_tx.send(()).unwrap();
2445 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
2446 Ok(shared.clone())
2447 },
2448 move |_| {
2449 tx.send(()).unwrap();
2450 },
2451 )
2452 .unwrap();
2453 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
2454 drop(worker);
2455 let weak = Arc::downgrade(&cache);
2456 drop(cache);
2457 resume_tx.send(()).unwrap();
2458 let started = Instant::now();
2459 while weak.upgrade().is_some() {
2460 assert!(started.elapsed() < Duration::from_secs(5));
2461 std::thread::sleep(Duration::from_millis(1));
2462 }
2463822 assert_eq!(
2464 rx.recv_timeout(Duration::from_secs(5)),
2465 Err(mpsc::RecvTimeoutError::Disconnected)
2466 );
2467 }
2468
2469 #[test]
2470 fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
2471 let dir = tempfile::tempdir().unwrap();
2472 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2473 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2474 for interval in [Duration::ZERO, Duration::MAX] {
2475 assert!(
2476 matches!(cache.start_sync(interval, || -> io::Result<Shared> { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
2477 );
2478 }
2479 let shared = Shared(Arc::new(Mutex::new(Server::new(&source))));
2480 let first = shared.clone();
2481 let (tx, rx) = mpsc::channel();
2482 let worker = cache
2483 .start_sync(
2484 Duration::from_secs(3600),
2485 move || Ok(first.clone()),
2486 move |_| {
2487 tx.send(()).unwrap();
2488 panic!("Test observer panic");
2489 },
2490 )
2491 .unwrap();
2492 rx.recv_timeout(Duration::from_secs(5)).unwrap();
2493 assert!(matches!(worker.stop(), Err(Error::Io(_))));
2494 let (tx, rx) = mpsc::channel();
2495 let worker = cache
2496 .start_sync(
2497 Duration::from_secs(3600),
2498 move || Ok(shared.clone()),
2499 move |result| {
2500 tx.send(*result.as_ref().unwrap()).unwrap();
2501 },
2502 )
2503 .unwrap();
2504 assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None);
2505 worker.stop().unwrap();
2506 assert_eq!(cache.snapshot().unwrap(), source);
2507 }
2508
2509 #[test]
2510 fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
2511 let dir = tempfile::tempdir().unwrap();
2512 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
2513 let (sid, oid, _) = text(&source);
2514 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2515 let id = cache
2516 .edit_text(&source, sid, oid, 1..2, "L")
2517 .unwrap()
2518 .unwrap();
2519 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
2520 let server = Arc::new(Mutex::new(Server::new(&remote)));
2521 let shared = Shared(Arc::clone(&server));
2522 let (tx, rx) = mpsc::channel();
2523 let (resume_tx, resume_rx) = mpsc::channel();
2524 let mut first = true;
2525 let worker = cache
2526 .start_sync(
2527 Duration::from_secs(3600),
2528 move || Ok(shared.clone()),
2529 move |result| {
2530 tx.send(*result.as_ref().unwrap()).unwrap();
2531 if first {
2532 first = false;
2533 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
2534 }
2535 },
2536 )
2537 .unwrap();
2538 assert_eq!(
2539 rx.recv_timeout(Duration::from_secs(5)).unwrap(),
2540 Some((id, EditStatus::Conflict(ConflictKind::TextChanged)))
2541 );
2542 cache
2543 .rebase_conflict(
2544 id,
2545 &cache.snapshot().unwrap(),
2546 &cache.remote_snapshot().unwrap(),
2547 1..2,
2548 )
2549 .unwrap();
2550 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
2551 resume_tx.send(()).unwrap();
2552 assert!(
2553 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
2554 );
2555 worker.stop().unwrap();
2556 assert!(cache.pending().unwrap().is_empty());
2557 let server = server.lock().unwrap();
2558 assert_eq!(server.publications, 1);
2559 assert_eq!(text(&server.durable).2, "aLc");
2560 assert_eq!(cache.snapshot().unwrap(), server.durable);
2561 }
2562
2563 #[test]
2564 fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
2565 struct Busy {
2566 server: Server,
2567 reads: usize,
2568 writes: usize,
2569 }
2570 impl Remote for Busy {
2571 fn read(&mut self) -> io::Result<Vec<u8>> {
2572 self.reads += 1;
2573 match self.reads {
2574 1 => Err(io::ErrorKind::WouldBlock.into()),
2575 2 => Err(io::ErrorKind::ResourceBusy.into()),
2576 _ => self.server.read(),
2577 }
2578 }
2579 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
2580 self.writes += 1;
2581 match self.writes {
2582 1 | 2 => Err(CommitError {
2583 state: CommitState::NotCommitted,
2584 error: if self.writes == 1 {
2585 io::ErrorKind::WouldBlock
2586 } else {
2587 io::ErrorKind::ResourceBusy
2588 }
2589 .into(),
2590 }),
2591 _ => self.server.publish(edit),
2592 }
2593 }
2594 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
2595 self.server.confirm(source)
2596 }
2597 }
2598 let dir = tempfile::tempdir().unwrap();
2599 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2600 let (sid, oid, _) = text(&source);
2601 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2602 let id = cache
2603 .edit_text(&source, sid, oid, 0..0, "L ")
2604 .unwrap()
2605 .unwrap();
2606 let mut remote = Some(Busy {
2607 server: Server::new(&source),
2608 reads: 0,
2609 writes: 0,
2610 });
2611 let (tx, rx) = mpsc::channel();
2612 let worker = cache
2613 .start_sync(
2614 Duration::from_millis(1),
2615 move || Ok(remote.take().expect("Contention caused a reconnect")),
2616 move |result| {
2617 tx.send(result.as_ref().copied().map_err(|error| error.to_string()))
2618 .unwrap();
2619 },
2620 )
2621 .unwrap();
2622 for _ in 0..4 {
2623 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
2624 }
2625 assert!(
2626 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
2627 );
2628 worker.stop().unwrap();
2629 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
2630 assert!(cache.pending().unwrap().is_empty());
2631 }
2632 #[test]
2633 fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
2634 struct BusyOnce(Server);
2635 impl Remote for BusyOnce {
2636 fn read(&mut self) -> io::Result<Vec<u8>> {
2637 self.0.read()
2638 }
2639 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
2640 let server = &mut self.0;
2641 if server.publications == 0 {
2642 server.publications += 1;
2643 return Err(CommitError {
2644 state: CommitState::NotCommitted,
2645 error: io::ErrorKind::ResourceBusy.into(),
2646 });
2647 }
2648 server.publish(edit)
2649 }
2650 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
2651 self.0.confirm(source)
2652 }
2653 }
2654 let dir = tempfile::tempdir().unwrap();
2655 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
2656 let (sid, oid, _) = text(&source);
2657 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
2658 let first = cache
2659 .edit_text(&source, sid, oid, 0..0, "L ")
2660 .unwrap()
2661 .unwrap();
2662 let mut remote = Some(BusyOnce(Server::new(&source)));
2663 let observed = Arc::clone(&cache);
2664 let (tx, rx) = mpsc::channel();
2665 let mut failed = false;
2666 let worker = cache
2667 .start_sync(
2668 Duration::from_secs(3600),
2669 move || Ok(remote.take().expect("Contention must retain the session")),
2670 move |result| match result {
2671 Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => {
2672 assert!(!failed);
2673 failed = true;
2674 let source = observed.snapshot().unwrap();
2675 let second = observed
2676 .edit_text(&source, sid, oid, 0..0, "Q ")
2677 .unwrap()
2678 .unwrap();
2679 tx.send((second, None)).unwrap();
2680 }
2681 Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(),
2682 Ok(None) => {}
2683 other => panic!("Unexpected worker result: {other:?}"),
2684 },
2685 )
2686 .unwrap();
2687 let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
2688 assert_eq!(status, None);
2689 for expected in [first, second] {
2690 let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
2691 assert_eq!(id, expected);
2692 assert!(matches!(status, Some(EditStatus::Published { .. })));
2693 }
2694 worker.stop().unwrap();
2695 assert!(cache.pending().unwrap().is_empty());
2696 assert_eq!(text(&cache.snapshot().unwrap()).2, "Q L abc");
2697 }
2698}
2699
2700#[test]
2701fn offline_insertions_survive_reopen_rebase_and_dependent_text_edits() {
2702 use onestore::Insertion;
2703 let dir = tempfile::tempdir().unwrap();
2704 let path = dir.path().join("insert.sqlite");
2705 let source = onestore::create_section("insert.one", "Original", "Author").unwrap();
2706 let (sid, original, _) = text(&source);
2707 let store = Store::parse(&source).unwrap();
2708 let index = RevisionIndex::parse(&store).unwrap();
2709 let doc = Document::parse(&index).unwrap();
2710 let (_, page) = doc.pages().unwrap()[0];
2711 let cache = Replica::create(&path, &source).unwrap();
2712 let outline = Insertion::outline(page, 72.0, 144.0, "Offline outline", "Offline author")
2713 .unwrap()
2714 .with_formatting(0..7, &[onestore::TextAttribute::Italic(true)])
2715 .unwrap();
2716 let first = cache.insert(&source, sid, &outline).unwrap().unwrap();
2717 let snapshot = cache.snapshot().unwrap();
2718 let paragraph = Insertion::paragraph(
2719 outline.object(),
2720 None,
2721 "Offline paragraph 🦀",
2722 "Offline author",
2723 )
2724 .unwrap()
2725 .with_formatting(8..17, &[onestore::TextAttribute::Bold(true)])
2726 .unwrap();
2727 let second = cache.insert(&snapshot, sid, &paragraph).unwrap().unwrap();
2728 let third = cache
2729 .edit_text(
2730 &cache.snapshot().unwrap(),
2731 sid,
2732 paragraph.text_object(),
2733 0..0,
2734 "Edited ",
2735 )
2736 .unwrap()
2737 .unwrap();
2738 let local = cache.snapshot().unwrap();
2739 let pending = cache.pending().unwrap();
2740 drop(cache);
2741 let cache = Replica::open(&path).unwrap();
2742 assert_eq!(cache.snapshot().unwrap(), local);
2743 assert_eq!(cache.pending().unwrap(), pending);
2744 let remote = onestore::replace_text(&source, sid, original, 0..0, "Remote ").unwrap();
2745 let mut server = Server::new(&remote);
2746 for id in [first, second, third] {
2747 assert!(
2748 matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..})) if observed==id)
823 intent.text_change(),
824 Some((oid, "aRc".into(), 1..2, "L".into()))
2749825 );
2750826 }
2751 assert_eq!(server.publications, 3);
2752 assert!(cache.pending().unwrap().is_empty());
2753 assert_eq!(cache.snapshot().unwrap(), server.durable);
2754 let store = Store::parse(&server.durable).unwrap();
2755 let index = RevisionIndex::parse(&store).unwrap();
2756 let doc = Document::parse(&index).unwrap();
2757 let s = &doc.spaces[&sid];
2758 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
2759 for (id, wanted) in [
2760 (original, "Remote Original"),
2761 (outline.text_object(), "Offline outline"),
2762 (paragraph.text_object(), "Edited Offline paragraph 🦀"),
2763 ] {
2764 assert!(matches!(&v.nodes[&id].kind,Kind::RichText{text,..} if text==wanted));
2765 }
2766 let outline_runs = v.text_runs(outline.text_object()).unwrap();
2767 assert_eq!(outline_runs[0].text, "Offline");
2768 assert_eq!(outline_runs[0].format.italic, Some(true));
2769 let runs = v.text_runs(paragraph.text_object()).unwrap();
2770 assert_eq!(runs[1].text, "paragraph");
2771 assert_eq!(runs[1].format.bold, Some(true));
2772 assert_eq!(
2773 v.nodes[&outline.object()].children.last(),
2774 Some(&paragraph.object())
2775 );
2776}
2777
2778#[test]
2779fn uncertain_insertions_reconcile_the_original_revision_without_duplicate_objects() {
2780 use onestore::Insertion;
2781 for fault in [
2782 Fault::Before,
2783 Fault::UnknownBefore,
2784 Fault::UnknownAfter,
2785 Fault::PanicBefore,
2786 Fault::PanicAfter,
2787 Fault::Committed,
2788 ] {
2789 let dir = tempfile::tempdir().unwrap();
2790 let path = dir.path().join("uncertain-insert.sqlite");
2791 let source = onestore::create_section("insert.one", "Original", "Author").unwrap();
2792 let store = Store::parse(&source).unwrap();
2793 let index = RevisionIndex::parse(&store).unwrap();
2794 let doc = Document::parse(&index).unwrap();
2795 let (sid, page) = doc.pages().unwrap()[0];
2796 let cache = Replica::create(&path, &source).unwrap();
2797 let insertion = Insertion::outline(page, 144.0, 144.0, "Uncertain insertion", "Author")
2798 .unwrap()
2799 .with_formatting(0..9, &[onestore::TextAttribute::Bold(true)])
2800 .unwrap()
2801 .with_formatting(10..19, &[onestore::TextAttribute::Italic(true)])
2802 .unwrap();
2803 let id = cache.insert(&source, sid, &insertion).unwrap().unwrap();
2804 let local = cache.snapshot().unwrap();
2805 let mut server = Server::new(&source);
2806 server.fault = fault;
2807 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2808 cache.sync_once(&mut server)
2809 }));
2810 drop(cache);
2811 let cache = Replica::open(&path).unwrap();
2812 assert_eq!(server.publications, 1);
2813 if matches!(fault, Fault::UnknownBefore | Fault::PanicBefore) {
2814 let status = cache.status(id).unwrap();
2815 assert!(matches!(
2816 status,
2817 Some(EditStatus::AwaitingConfirmation { .. })
2818 ));
2819 for _ in 0..5 {
2820 assert_eq!(
2821 cache.sync_once(&mut server).unwrap(),
2822 status.map(|state| (id, state))
2823 );
2824 }
2825 assert_eq!(server.publications, 1);
2826 assert_eq!(server.durable, source);
2827 assert_eq!(cache.snapshot().unwrap(), local);
2828 } else {
2829 if !matches!(fault, Fault::Committed) {
2830 assert!(matches!(
2831 cache.sync_once(&mut server).unwrap(),
2832 Some((_, EditStatus::Published { .. }))
2833 ));
2834 }
2835 assert_eq!(
2836 server.publications,
2837 if matches!(fault, Fault::Before) { 2 } else { 1 }
2838 );
2839 assert_eq!(
2840 server.confirmations,
2841 usize::from(matches!(fault, Fault::UnknownAfter | Fault::PanicAfter))
2842 );
2843 let store = Store::parse(&server.durable).unwrap();
2844 let index = RevisionIndex::parse(&store).unwrap();
2845 let doc = Document::parse(&index).unwrap();
2846 let s = &doc.spaces[&sid];
2847 let v = &s.revisions[&s.contexts[&ExGuid::default()]];
2848 assert_eq!(v.nodes.values().filter(|node|matches!(&node.kind,Kind::RichText{text,..} if text=="Uncertain insertion")).count(),1);
2849 assert!(v.nodes.contains_key(&insertion.object()));
2850 let runs = v.text_runs(insertion.text_object()).unwrap();
2851 assert_eq!(runs.len(), 3);
2852 assert_eq!(runs[0].text, "Uncertain");
2853 assert_eq!(runs[0].format.bold, Some(true));
2854 assert_eq!(runs[1].text, " ");
2855 assert_ne!(runs[1].format.bold, Some(true));
2856 assert_ne!(runs[1].format.italic, Some(true));
2857 assert_eq!(runs[2].text, "insertion");
2858 assert_eq!(runs[2].format.italic, Some(true));
2859 assert!(cache.pending().unwrap().is_empty());
2860 }
2861 }
2862}
2863
2864#[test]
2865fn cross_run_text_rebases_preserve_remote_styles_and_survive_lost_replies() {
2866 use onestore::TextAttribute as A;
2867 for fault in [Fault::None, Fault::UnknownAfter, Fault::PanicAfter] {
2868 let directory = tempfile::tempdir().unwrap();
2869 let path = directory.path().join("cross-run.sqlite");
2870 let plain = onestore::create_section("cross.one", "ab🦀cde\u{301}fg", "Author").unwrap();
2871 let (sid, oid, _) = text(&plain);
2872 let bold = PreparedEdit::format(&plain, sid, oid, 1..4, &[A::Bold(true)]).unwrap();
2873 let source = PreparedEdit::format(bold.as_bytes(), sid, oid, 4..7, &[A::Italic(true)])
2874 .unwrap()
2875 .as_bytes()
2876 .to_vec();
2877 let cache = Replica::create(&path, &source).unwrap();
2878 let first = cache
2879 .edit_text(&source, sid, oid, 2..7, "日本語")
2880 .unwrap()
2881 .unwrap();
2882 let local = cache.snapshot().unwrap();
2883 let second = cache
2884 .edit_text(&local, sid, oid, 3..4, "🐈")
2885 .unwrap()
2886 .unwrap();
2887 let pending = cache.pending().unwrap();
2888 drop(cache);
2889 let cache = Replica::open(&path).unwrap();
2890 assert_eq!(cache.pending().unwrap(), pending);
2891 let prefix = PreparedEdit::text(&source, sid, oid, 0..0, "Prefix ").unwrap();
2892 let remote =
2893 PreparedEdit::format(prefix.as_bytes(), sid, oid, 9..11, &[A::Underline(true)])
2894 .unwrap();
2895 let mut server = Server::new(remote.as_bytes());
2896 server.fault = fault;
2897 let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2898 cache.sync_once(&mut server)
2899 }));
2900 if matches!(fault, Fault::None) {
2901 assert!(
2902 matches!(attempted.unwrap().unwrap(), Some((id, EditStatus::Published { .. })) if id == first)
2903 );
2904 } else {
2905 assert!(matches!(
2906 cache.status(first).unwrap(),
2907 Some(EditStatus::AwaitingConfirmation { .. })
2908 ));
2909 }
2910 drop(cache);
2911 let cache = Replica::open(&path).unwrap();
2912 while let Some((_, status)) = cache.sync_once(&mut server).unwrap() {
2913 assert!(matches!(status, EditStatus::Published { .. }));
2914 }
2915 assert_eq!(server.publications, 2);
2916 assert!(matches!(
2917 cache.status(second).unwrap(),
2918 Some(EditStatus::Published { .. })
2919 ));
2920 assert_eq!(text(&server.durable).2, "Prefix ab日🐈語\u{301}fg");
2921 assert_eq!(cache.snapshot().unwrap(), server.durable);
2922 let store = Store::parse(&server.durable).unwrap();
2923 let index = RevisionIndex::parse(&store).unwrap();
2924 let document = Document::parse(&index).unwrap();
2925 let space = &document.spaces[&sid];
2926 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
2927 let runs = revision.text_runs(oid).unwrap();
2928 let replacement = runs.iter().find(|run| run.text == "日🐈語").unwrap();
2929 assert_eq!(replacement.format.bold, Some(true));
2930 assert_eq!(replacement.format.underline, Some(true));
2931 assert_ne!(replacement.format.italic, Some(true));
2932 let suffix = runs.last().unwrap();
2933 assert_eq!(suffix.text, "\u{301}fg");
2934 assert_ne!(suffix.format.bold, Some(true));
2935 assert_ne!(suffix.format.underline, Some(true));
2936 assert_ne!(suffix.format.italic, Some(true));
2937 }
2938}
2939
2940#[test]
2941fn offline_formatting_rebases_text_and_merges_independent_attributes() {
2942 use onestore::TextAttribute as A;
2943 let directory = tempfile::tempdir().unwrap();
2944 let path = directory.path().join("format.sqlite");
2945 let source = onestore::create_section("format.one", "ab🦀cd", "Author").unwrap();
2946 let (sid, id, _) = text(&source);
2947 let cache = Replica::create(&path, &source).unwrap();
2948 let local = cache
2949 .format(&source, sid, id, 2..4, &[A::Bold(true)])
2950 .unwrap()
2951 .unwrap();
2952 let pending = cache.pending().unwrap();
2953 drop(cache);
2954 let cache = Replica::open(&path).unwrap();
2955 assert_eq!(cache.pending().unwrap(), pending);
2956 let moved = PreparedEdit::text(&source, sid, id, 0..0, "Prefix ").unwrap();
2957 let styled =
2958 PreparedEdit::format(moved.as_bytes(), sid, id, 9..11, &[A::Italic(true)]).unwrap();
2959 let mut server = Server::new(styled.as_bytes());
2960 assert!(
2961 matches!(cache.sync_once(&mut server).unwrap(),Some((observed,EditStatus::Published{..}))if observed==local)
2962 );
2963 let store = Store::parse(&server.durable).unwrap();
2964 let index = RevisionIndex::parse(&store).unwrap();
2965 let doc = Document::parse(&index).unwrap();
2966 let space = &doc.spaces[&sid];
2967 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
2968 let runs = view.text_runs(id).unwrap();
2969 let selected = runs.iter().find(|r| r.text == "🦀").unwrap();
2970 assert_eq!(selected.format.bold, Some(true));
2971 assert_eq!(selected.format.italic, Some(true));
2972 assert_eq!(
2973 runs.iter().map(|r| r.text).collect::<String>(),
2974 "Prefix ab🦀cd"
2975 );
2976}
2977
2978#[test]
2979fn competing_font_changes_remain_preserved_conflicts() {
2980 use onestore::TextAttribute as A;
2981 let directory = tempfile::tempdir().unwrap();
2982 let path = directory.path().join("conflict.sqlite");
2983 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
2984 let (sid, id, _) = text(&source);
2985 let cache = Replica::create(&path, &source).unwrap();
2986 let local_id = cache
2987 .format(&source, sid, id, 1..5, &[A::FontSize(14.0)])
2988 .unwrap()
2989 .unwrap();
2990 let local = cache.snapshot().unwrap();
2991 let pending = cache.pending().unwrap();
2992 let remote = PreparedEdit::format(&source, sid, id, 2..4, &[A::FontSize(18.0)]).unwrap();
2993 let mut server = Server::new(remote.as_bytes());
2994 for _ in 0..3 {
2995 assert_eq!(
2996 cache.sync_once(&mut server).unwrap(),
2997 Some((
2998 local_id,
2999 EditStatus::Conflict(ConflictKind::FormattingChanged)
3000 ))
3001 );
3002 }
3003 assert_eq!(server.publications, 0);
3004 assert_eq!(server.confirmations, 0);
3005 assert_eq!(cache.snapshot().unwrap(), local);
3006 assert_eq!(cache.pending().unwrap(), pending);
3007 drop(cache);
3008 let cache = Replica::open(&path).unwrap();
3009 assert_eq!(
3010 cache.status(local_id).unwrap(),
3011 Some(EditStatus::Conflict(ConflictKind::FormattingChanged))
3012 );
3013 assert_eq!(cache.snapshot().unwrap(), local);
3014}
3015
3016#[test]
3017fn independently_satisfied_formatting_requires_confirmation_before_a_receipt() {
3018 use onestore::TextAttribute as A;
3019 let directory = tempfile::tempdir().unwrap();
3020 let path = directory.path().join("satisfied.sqlite");
3021 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
3022 let (sid, id, _) = text(&source);
3023 let cache = Replica::create(&path, &source).unwrap();
3024 let local_id = cache
3025 .format(&source, sid, id, 1..5, &[A::Bold(true)])
3026 .unwrap()
3027 .unwrap();
3028 let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::Bold(true)]).unwrap();
3029 let mut server = Server::new(remote.as_bytes());
3030 server.durable = source.clone();
3031 server.fault = Fault::Confirm;
3032 assert!(
3033 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
3034 );
3035 assert_eq!(cache.status(local_id).unwrap(), Some(EditStatus::Pending));
3036 assert_eq!(server.publications, 0);
3037 assert_eq!(server.durable, source);
3038 drop(cache);
3039 let cache = Replica::open(&path).unwrap();
3040 assert!(
3041 matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==local_id)
3042 );
3043 assert_eq!(server.publications, 0);
3044 assert_eq!(server.confirmations, 2);
3045 assert_ne!(server.durable, source);
3046 assert!(cache.pending().unwrap().is_empty());
3047}
3048
3049#[test]
3050fn retired_format_attempts_require_the_complete_durable_effect_without_replay() {
3051 use onestore::TextAttribute as A;
3052 for (complete, fault) in [
3053 (true, Fault::None),
3054 (true, Fault::Confirm),
3055 (true, Fault::ConfirmCommitted),
3056 (false, Fault::None),
3057 ] {
3058 let directory = tempfile::tempdir().unwrap();
3059 let path = directory.path().join("retired-format.sqlite");
3060 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
3061 let (sid, object, _) = text(&source);
3062 let cache = Replica::create(&path, &source).unwrap();
3063 let id = cache
3064 .format(
3065 &source,
3066 sid,
3067 object,
3068 1..5,
3069 &[A::Bold(true), A::FontSize(18.0)],
3070 )
3071 .unwrap()
3072 .unwrap();
3073 let local = cache.snapshot().unwrap();
3074 let mut server = Server::new(&source);
3075 server.fault = Fault::UnknownAfter;
3076 assert!(cache.sync_once(&mut server).is_err());
3077 let attempted = cache.status(id).unwrap();
3078 let Some(EditStatus::AwaitingConfirmation { revision: retired }) = attempted else {
3079 panic!()
3080 };
3081 drop(cache);
3082 let prefix = PreparedEdit::text(&source, sid, object, 0..0, "prefix ").unwrap();
3083 let mut attributes = vec![A::Bold(true), A::Italic(true)];
3084 if complete {
3085 attributes.push(A::FontSize(18.0));
3086 }
3087 server.visible = PreparedEdit::format(prefix.as_bytes(), sid, object, 8..12, &attributes)
3088 .unwrap()
3089 .as_bytes()
3090 .to_vec();
3091 let store = Store::parse(&server.visible).unwrap();
3092 let index = RevisionIndex::parse(&store).unwrap();
3093 let current = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
3094 assert!(!index.spaces[&sid].revisions.contains_key(&retired));
3095 let cache = Replica::open(&path).unwrap();
3096 server.fault = fault;
3097 let result = cache.sync_once(&mut server);
3098 if !complete {
3099 assert_eq!(result.unwrap(), attempted.map(|s| (id, s)));
3100 assert_eq!(server.confirmations, 0);
3101 assert_eq!(cache.snapshot().unwrap(), local);
3102 assert_eq!(server.durable, source);
3103 } else {
3104 if matches!(fault, Fault::Confirm) {
3105 assert!(matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Unknown));
3106 assert_eq!(cache.status(id).unwrap(), attempted);
3107 assert_eq!(cache.snapshot().unwrap(), local);
3108 assert_eq!(server.durable, source);
3109 let complete = server.visible.clone();
3110 server.visible =
3111 PreparedEdit::format(&complete, sid, object, 8..12, &[A::Bold(false)])
3112 .unwrap()
3113 .as_bytes()
3114 .to_vec();
3115 assert_eq!(
3116 cache.sync_once(&mut server).unwrap(),
3117 attempted.map(|s| (id, s))
3118 );
3119 assert_eq!(server.confirmations, 1);
3120 assert_eq!(cache.snapshot().unwrap(), local);
3121 server.visible = complete;
3122 cache.sync_once(&mut server).unwrap();
3123 } else if matches!(fault, Fault::ConfirmCommitted) {
3124 assert!(
3125 matches!(result, Err(Error::Remote(e)) if e.state == CommitState::Committed)
3126 );
3127 } else {
3128 assert_eq!(
3129 result.unwrap(),
3130 Some((id, EditStatus::Published { revision: current }))
3131 );
3132 }
3133 assert_ne!(current, retired);
3134 assert_eq!(
3135 cache.status(id).unwrap(),
3136 Some(EditStatus::Published { revision: current })
3137 );
3138 assert!(cache.pending().unwrap().is_empty());
3139 assert_eq!(text(&server.durable).2, "prefix abcdef");
3140 assert_ne!(server.durable, source);
3141 drop(cache);
3142 let cache = Replica::open(&path).unwrap();
3143 assert_eq!(
3144 cache.status(id).unwrap(),
3145 Some(EditStatus::Published { revision: current })
3146 );
3147 }
3148 assert_eq!(server.publications, 1);
3149 }
3150}
3151
3152#[test]
3153fn retired_text_with_equal_plaintext_but_a_different_surviving_run_remains_uncertain() {
3154 use onestore::TextAttribute;
3155
3156 let directory = tempfile::tempdir().unwrap();
3157 let path = directory.path().join("repeated.sqlite");
3158 let plain = onestore::create_section("repeated.one", "aa", "Fixture").unwrap();
3159 let (space, object, _) = text(&plain);
3160 let source = PreparedEdit::format(&plain, space, object, 0..1, &[TextAttribute::Bold(true)])
3161 .unwrap()
3162 .as_bytes()
3163 .to_vec();
3164 let cache = Replica::create(&path, &source).unwrap();
3165 let id = cache
3166 .edit_text(&source, space, object, 0..1, "")
3167 .unwrap()
3168 .unwrap();
3169 let local = cache.snapshot().unwrap();
3170 let mut server = Server::new(&source);
3171 server.fault = Fault::UnknownAfter;
3172 assert!(cache.sync_once(&mut server).is_err());
3173 let attempt = cache.status(id).unwrap();
3174 server.visible = onestore::replace_text(&source, space, object, 1..2, "").unwrap();
3175 assert_eq!(text(&server.visible).2, text(&local).2);
3176 let bold = |bytes: &[u8]| {
3177 let store = Store::parse(bytes).unwrap();
3178 let index = RevisionIndex::parse(&store).unwrap();
3179 let document = Document::parse(&index).unwrap();
3180 let space = &document.spaces[&space];
3181 space.revisions[&space.contexts[&ExGuid::default()]]
3182 .text_runs(object)
3183 .unwrap()
3184 .into_iter()
3185 .find(|run| !run.text.is_empty())
3186 .unwrap()
3187 .format
3188 .bold
3189 };
3190 assert_eq!(bold(&server.visible), Some(true));
3191 assert_ne!(bold(&server.visible), bold(&local));
3192 drop(cache);
3193 let cache = Replica::open(&path).unwrap();
3194 assert_eq!(
3195 cache.sync_once(&mut server).unwrap(),
3196 attempt.map(|state| (id, state))
3197 );
3198 assert_eq!(server.publications, 1);
3199 assert_eq!(server.confirmations, 0);
3200 assert_eq!(cache.snapshot().unwrap(), local);
3201}
3202
3203#[test]
3204fn uncertain_formatting_keeps_the_original_attempt_and_never_replays() {
3205 use onestore::TextAttribute as A;
3206 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
3207 let directory = tempfile::tempdir().unwrap();
3208 let path = directory.path().join("unknown-format.sqlite");
3209 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
3210 let (sid, id, _) = text(&source);
3211 let cache = Replica::create(&path, &source).unwrap();
3212 let local_id = cache
3213 .format(&source, sid, id, 1..5, &[A::Bold(true)])
3214 .unwrap()
3215 .unwrap();
3216 let mut server = Server::new(&source);
3217 server.fault = fault;
3218 assert!(
3219 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
3220 );
3221 let status = cache.status(local_id).unwrap();
3222 drop(cache);
3223 let cache = Replica::open(&path).unwrap();
3224 if matches!(fault, Fault::UnknownBefore) {
3225 for _ in 0..4 {
3226 assert_eq!(
3227 cache.sync_once(&mut server).unwrap(),
3228 status.map(|s| (local_id, s))
3229 );
3230 }
3231 assert_eq!(server.confirmations, 0);
3232 } else {
3233 assert!(matches!(
3234 cache.sync_once(&mut server).unwrap(),
3235 Some((_, EditStatus::Published { .. }))
3236 ));
3237 assert_eq!(server.confirmations, 1);
3238 }
3239 assert_eq!(server.publications, 1);
3240 }
3241}
3242
3243#[test]
3244fn reviewed_format_conflicts_preserve_dependent_edits_and_recheck_later_remote_changes() {
3245 use onestore::TextAttribute as A;
3246 for changed_again in [false, true] {
3247 let directory = tempfile::tempdir().unwrap();
3248 let path = directory.path().join("review-format.sqlite");
3249 let source = onestore::create_section("format.one", "abcdef", "Author").unwrap();
3250 let (sid, id, _) = text(&source);
3251 let cache = Replica::create(&path, &source).unwrap();
3252 let first = cache
3253 .format(&source, sid, id, 1..5, &[A::FontSize(14.0)])
3254 .unwrap()
3255 .unwrap();
3256 let second = cache
3257 .edit_text(&cache.snapshot().unwrap(), sid, id, 2..2, "X")
3258 .unwrap()
3259 .unwrap();
3260 let local = cache.snapshot().unwrap();
3261 let pending = cache.pending().unwrap();
3262 let remote = PreparedEdit::format(&source, sid, id, 1..5, &[A::FontSize(18.0)]).unwrap();
3263 let mut server = Server::new(remote.as_bytes());
3264 assert_eq!(
3265 cache.sync_once(&mut server).unwrap(),
3266 Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged)))
3267 );
3268 assert!(
3269 matches!(cache.rebase_conflict(first,&source,remote.as_bytes(),1..5),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
3270 );
3271 cache
3272 .rebase_conflict(first, &local, remote.as_bytes(), 1..5)
3273 .unwrap();
3274 assert_eq!(cache.snapshot().unwrap(), local);
3275 assert_eq!(cache.pending().unwrap()[1], pending[1]);
3276 drop(cache);
3277 let cache = Replica::open(&path).unwrap();
3278 if changed_again {
3279 let newer =
3280 PreparedEdit::format(&server.visible, sid, id, 1..5, &[A::FontSize(20.0)]).unwrap();
3281 server = Server::new(newer.as_bytes());
3282 assert_eq!(
3283 cache.sync_once(&mut server).unwrap(),
3284 Some((first, EditStatus::Conflict(ConflictKind::FormattingChanged)))
3285 );
3286 assert_eq!(server.publications, 0);
3287 assert_eq!(cache.snapshot().unwrap(), local);
3288 } else {
3289 for expected in [first, second] {
3290 assert!(
3291 matches!(cache.sync_once(&mut server).unwrap(),Some((id,EditStatus::Published{..}))if id==expected)
3292 );
3293 }
3294 assert_eq!(text(&server.durable).2, "abXcdef");
3295 assert!(cache.pending().unwrap().is_empty());
3296 }
3297 }
3298}
3299
3300#[test]
3301fn superscript_reconciliation_checks_the_implicit_subscript_change() {
3302 use onestore::TextAttribute as A;
3303 let directory = tempfile::tempdir().unwrap();
3304 let source = onestore::create_section("script.one", "abc", "Author").unwrap();
3305 let (sid, id, _) = text(&source);
3306 let cache = Replica::create(directory.path().join("script.sqlite"), &source).unwrap();
3307 let local = cache
3308 .format(&source, sid, id, 0..3, &[A::Superscript(true)])
3309 .unwrap()
3310 .unwrap();
3311 let remote = PreparedEdit::format(&source, sid, id, 0..3, &[A::Subscript(true)]).unwrap();
3312 let mut server = Server::new(remote.as_bytes());
3313 assert_eq!(
3314 cache.sync_once(&mut server).unwrap(),
3315 Some((local, EditStatus::Conflict(ConflictKind::FormattingChanged)))
3316 );
3317 assert_eq!(server.publications, 0);
3318}
3319
3320#[test]
3321fn seeded_formatting_reconciliation_matches_a_character_model() {
3322 use onestore::TextAttribute as A;
3323 #[derive(Clone, Debug, PartialEq)]
3324 struct Style {
3325 size: f32,
3326 color: u32,
3327 bold: bool,
3328 italic: bool,
3329 }
3330 let mut conflicts = 0;
3331 let mut published = 0;
3332 let mut satisfied_parts = 0;
3333 for seed in 1_u64..=128 {
3334 let mut random = seed;
3335 let mut next = || {
3336 random = random
3337 .wrapping_mul(6364136223846793005)
3338 .wrapping_add(1442695040888963407);
3339 random >> 32
3340 };
3341 let directory = tempfile::tempdir().unwrap();
3342 let path = directory.path().join("model.sqlite");
3343 let mut source =
3344 onestore::create_section("model.one", "abcdefghijklmnopqrstuvwxyz012345", "Author")
3345 .unwrap();
3346 let (sid, object, original_text) = text(&source);
3347 let mut baseline = vec![
3348 Style {
3349 size: 11.0,
3350 color: 0xff000000,
3351 bold: false,
3352 italic: false
3353 };
3354 32
3355 ];
3356 for _ in 0..6 {
3357 let start = next() as usize % 32;
3358 let end = start + 1 + next() as usize % (32 - start);
3359 let size = [12.0, 14.0, 18.0][next() as usize % 3];
3360 let color: u32 = [0xabcdef, 0x987654, 0xff000000][next() as usize % 3];
3361 let bold = next() % 2 == 0;
3362 source = PreparedEdit::format(
3363 &source,
3364 sid,
3365 object,
3366 start as u32..end as u32,
3367 &[
3368 A::FontSize(size),
3369 A::Color((color != 0xff000000).then(|| {
3370 let b = color.to_le_bytes();
3371 [b[0], b[1], b[2]]
3372 })),
3373 A::Bold(bold),
3374 ],
3375 )
3376 .unwrap()
3377 .as_bytes()
3378 .to_vec();
3379 for style in &mut baseline[start..end] {
3380 style.size = size;
3381 style.color = color;
3382 style.bold = bold;
3383 }
3384 }
3385 let start = next() as usize % 24;
3386 let end = start + 2 + next() as usize % (31 - start);
3387 let attribute = match seed % 3 {
3388 0 => A::FontSize(21.0),
3389 1 => A::Color(Some([0x12, 0x34, 0x56])),
3390 _ => A::Bold(!baseline[start].bold),
3391 };
3392 let cache = Replica::create(&path, &source).unwrap();
3393 let id = cache
3394 .format(
3395 &source,
3396 sid,
3397 object,
3398 start as u32..end as u32,
3399 std::slice::from_ref(&attribute),
3400 )
3401 .unwrap()
3402 .unwrap();
3403 let local = cache.snapshot().unwrap();
3404 let mut remote = source.clone();
3405 let mut expected = baseline.clone();
3406 for step in 0..8 {
3407 let left = next() as usize % 32;
3408 let right = left + 1 + next() as usize % (32 - left);
3409 let update = if step % 3 == 0 {
3410 attribute.clone()
3411 } else {
3412 match next() % 4 {
3413 0 => A::FontSize([11.0, 14.0, 18.0, 21.0][next() as usize % 4]),
3414 1 => A::Color(Some([0x99, 0x88, 0x77])),
3415 2 => A::Bold(next() % 2 == 0),
3416 _ => A::Italic(true),
3417 }
3418 };
3419 remote = PreparedEdit::format(
3420 &remote,
3421 sid,
3422 object,
3423 left as u32..right as u32,
3424 std::slice::from_ref(&update),
3425 )
3426 .unwrap()
3427 .as_bytes()
3428 .to_vec();
3429 for style in &mut expected[left..right] {
3430 match update {
3431 A::FontSize(value) => style.size = value,
3432 A::Color(Some([r, g, b])) => style.color = u32::from_le_bytes([r, g, b, 0]),
3433 A::Bold(value) => style.bold = value,
3434 A::Italic(value) => style.italic = value,
3435 _ => unreachable!(),
3436 }
3437 }
3438 }
3439 let conflict = (start..end).any(|i| match attribute {
3440 A::FontSize(value) => expected[i].size != baseline[i].size && expected[i].size != value,
3441 A::Color(_) => expected[i].color != baseline[i].color && expected[i].color != 0x563412,
3442 A::Bold(value) => expected[i].bold != baseline[i].bold && expected[i].bold != value,
3443 _ => unreachable!(),
3444 });
3445 drop(cache);
3446 let cache = Replica::open(&path).unwrap();
3447 let mut server = Server::new(&remote);
3448 let result = cache.sync_once(&mut server).unwrap().unwrap();
3449 assert_eq!(result.0, id, "seed {seed}");
3450 if conflict {
3451 conflicts += 1;
3452 assert_eq!(
3453 result.1,
3454 EditStatus::Conflict(ConflictKind::FormattingChanged),
3455 "seed {seed}"
3456 );
3457 assert_eq!(server.publications, 0, "seed {seed}");
3458 assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}");
3459 continue;
3460 }
3461 published += 1;
3462 assert!(
3463 matches!(result.1, EditStatus::Published { .. }),
3464 "seed {seed}: {result:?}"
3465 );
3466 for style in &mut expected[start..end] {
3467 match attribute {
3468 A::FontSize(value) => {
3469 satisfied_parts += usize::from(style.size == value);
3470 style.size = value;
3471 }
3472 A::Color(_) => {
3473 satisfied_parts += usize::from(style.color == 0x563412);
3474 style.color = 0x563412;
3475 }
3476 A::Bold(value) => {
3477 satisfied_parts += usize::from(style.bold == value);
3478 style.bold = value;
3479 }
3480 _ => unreachable!(),
3481 }
3482 }
3483 assert_eq!(text(&server.durable).2, original_text, "seed {seed}");
3484 let store = Store::parse(&server.durable).unwrap();
3485 let index = RevisionIndex::parse(&store).unwrap();
3486 let doc = Document::parse(&index).unwrap();
3487 let space = &doc.spaces[&sid];
3488 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
3489 let actual: Vec<_> = revision
3490 .text_runs(object)
3491 .unwrap()
3492 .iter()
3493 .flat_map(|run| {
3494 std::iter::repeat_n(
3495 Style {
3496 size: run.format.font_size.unwrap(),
3497 color: run.format.color.unwrap_or(0xff000000),
3498 bold: run.format.bold.unwrap_or(false),
3499 italic: run.format.italic.unwrap_or(false),
3500 },
3501 run.text.chars().count(),
3502 )
3503 })
3504 .collect();
3505 assert_eq!(actual, expected, "seed {seed}");
3506 drop(cache);
3507 let cache = Replica::open(&path).unwrap();
3508 assert_eq!(cache.status(id).unwrap(), Some(result.1));
3509 let snapshot = cache.snapshot().unwrap();
3510 assert_eq!(snapshot.len(), server.durable.len(), "seed {seed}");
3511 assert!(
3512 snapshot
3513 .iter()
3514 .zip(&server.durable)
3515 .enumerate()
3516 .all(|(offset, (a, b))| a == b || (212..252).contains(&offset)),
3517 "seed {seed}: only confirmation version metadata may change"
3518 );
3519 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
3520 assert!(
3521 cache.snapshot().unwrap() == server.durable,
3522 "seed {seed}: refreshed snapshot"
3523 );
3524 }
3525 assert!(
3526 conflicts >= 16 && published >= 32 && satisfied_parts >= 128,
3527 "conflicts={conflicts}, published={published}, already desired characters={satisfied_parts}"
3528 );
3529 println!(
3530 "128 seeds: {conflicts} conflicts, {published} publications, {satisfied_parts} already desired characters"
3531 );
3532}
3533
3534#[test]
3535fn inserted_empty_text_retains_formatting_and_dependent_text_across_sync() {
3536 use onestore::{Insertion, TextAttribute as A};
3537 let directory = tempfile::tempdir().unwrap();
3538 let path = directory.path().join("empty.sqlite");
3539 let source = onestore::create_section("empty.one", "Original", "Author").unwrap();
3540 let store = Store::parse(&source).unwrap();
3541 let index = RevisionIndex::parse(&store).unwrap();
3542 let doc = Document::parse(&index).unwrap();
3543 let (sid, page) = doc.pages().unwrap()[0];
3544 let insertion = Insertion::outline(page, 144.0, 144.0, "", "Author").unwrap();
3545 let cache = Replica::create(&path, &source).unwrap();
3546 let first = cache.insert(&source, sid, &insertion).unwrap().unwrap();
3547 let second = cache
3548 .format(
3549 &cache.snapshot().unwrap(),
3550 sid,
3551 insertion.text_object(),
3552 0..0,
3553 &[A::Bold(true), A::FontSize(18.0)],
3554 )
3555 .unwrap()
3556 .unwrap();
3557 let third = cache
3558 .edit_text(
3559 &cache.snapshot().unwrap(),
3560 sid,
3561 insertion.text_object(),
3562 0..0,
3563 "Typed 🦀",
3564 )
3565 .unwrap()
3566 .unwrap();
3567 let pending = cache.pending().unwrap();
3568 drop(cache);
3569 let cache = Replica::open(&path).unwrap();
3570 assert_eq!(cache.pending().unwrap(), pending);
3571 let mut server = Server::new(&source);
3572 for id in [first, second, third] {
3573 let result = cache.sync_once(&mut server).unwrap();
3574 assert!(
3575 matches!(result,Some((actual,EditStatus::Published{..}))if actual==id),
3576 "{result:?}"
3577 );
3578 }
3579 let store = Store::parse(&server.durable).unwrap();
3580 let index = RevisionIndex::parse(&store).unwrap();
3581 let doc = Document::parse(&index).unwrap();
3582 let space = &doc.spaces[&sid];
3583 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
3584 let runs = revision.text_runs(insertion.text_object()).unwrap();
3585 assert_eq!(runs.iter().map(|r| r.text).collect::<String>(), "Typed 🦀");
3586 assert!(
3587 runs.iter()
3588 .all(|r| r.format.bold == Some(true) && r.format.font_size == Some(18.0))
3589 );
3590 assert_eq!(cache.snapshot().unwrap(), server.durable);
3591}
3592
3593#[test]
3594fn every_visual_attribute_rebases_with_an_independent_remote_attribute() {
3595 use onestore::TextAttribute as A;
3596 use serde_json::json;
3597 let mut cases = Vec::new();
3598 for value in [false, true] {
3599 cases.extend([
3600 (A::Bold(!value), A::Bold(value), "bold", json!(value)),
3601 (A::Italic(!value), A::Italic(value), "italic", json!(value)),
3602 (
3603 A::Underline(!value),
3604 A::Underline(value),
3605 "underline",
3606 json!(value),
3607 ),
3608 (A::Strike(!value), A::Strike(value), "strike", json!(value)),
3609 (
3610 A::Superscript(!value),
3611 A::Superscript(value),
3612 "superscript",
3613 json!(value),
3614 ),
3615 (
3616 A::Subscript(!value),
3617 A::Subscript(value),
3618 "subscript",
3619 json!(value),
3620 ),
3621 ]);
3622 }
3623 cases.extend([
3624 (
3625 A::Font("Georgia".into()),
3626 A::Font("Arial".into()),
3627 "font",
3628 json!("Arial"),
3629 ),
3630 (
3631 A::FontSize(11.0),
3632 A::FontSize(18.0),
3633 "font_size",
3634 json!(18.0),
3635 ),
3636 (
3637 A::Color(None),
3638 A::Color(Some([1, 2, 3])),
3639 "color",
3640 json!(0x030201),
3641 ),
3642 (
3643 A::Color(Some([1, 2, 3])),
3644 A::Color(None),
3645 "color",
3646 json!(0xff000000_u32),
3647 ),
3648 (
3649 A::Highlight(None),
3650 A::Highlight(Some([4, 5, 6])),
3651 "highlight",
3652 json!(0x060504),
3653 ),
3654 (
3655 A::Highlight(Some([4, 5, 6])),
3656 A::Highlight(None),
3657 "highlight",
3658 json!(0xff000000_u32),
3659 ),
3660 ]);
3661 for (baseline, desired, field, value) in cases {
3662 let directory = tempfile::tempdir().unwrap();
3663 let path = directory.path().join("attribute.sqlite");
3664 let source = onestore::create_section("attribute.one", "abc", "Author").unwrap();
3665 let (sid, object, _) = text(&source);
3666 let source = PreparedEdit::format(&source, sid, object, 0..3, &[baseline])
3667 .unwrap()
3668 .as_bytes()
3669 .to_vec();
3670 let cache = Replica::create(&path, &source).unwrap();
3671 let id = cache
3672 .format(&source, sid, object, 0..3, &[desired])
3673 .unwrap()
3674 .unwrap();
3675 let moved = PreparedEdit::text(&source, sid, object, 0..0, "Z").unwrap();
3676 let (other, other_field, other_value) = if field == "font_size" {
3677 (A::Italic(true), "italic", json!(true))
3678 } else {
3679 (A::FontSize(22.0), "font_size", json!(22.0))
3680 };
3681 let remote = PreparedEdit::format(moved.as_bytes(), sid, object, 1..4, &[other]).unwrap();
3682 let mut server = Server::new(remote.as_bytes());
3683 drop(cache);
3684 let cache = Replica::open(&path).unwrap();
3685 let result = cache.sync_once(&mut server).unwrap();
3686 assert!(
3687 matches!(result,Some((observed,EditStatus::Published{..}))if observed==id),
3688 "{field}={value}: {result:?}"
3689 );
3690 assert_eq!(text(&server.durable).2, "Zabc");
3691 let store = Store::parse(&server.durable).unwrap();
3692 let index = RevisionIndex::parse(&store).unwrap();
3693 let doc = Document::parse(&index).unwrap();
3694 let space = &doc.spaces[&sid];
3695 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
3696 let mut position = 0;
3697 for run in revision.text_runs(object).unwrap() {
3698 let format = serde_json::to_value(run.format).unwrap();
3699 for _ in run.text.chars() {
3700 if position > 0 {
3701 assert_eq!(format[field], value, "{field}, character {position}");
3702 assert_eq!(
3703 format[other_field], other_value,
3704 "{field}, character {position}"
3705 );
3706 }
3707 position += 1;
3708 }
3709 }
3710 assert_eq!(position, 4);
3711 }
3712}
3713
3714#[test]
3715fn reviewed_insertion_placements_preserve_identity_and_dependent_operations() {
3716 use notebook::Operation;
3717 use onestore::{Insertion, TextAttribute as A};
3718 for outline_case in [false, true] {
3719 let directory = tempfile::tempdir().unwrap();
3720 let path = directory.path().join("placement.sqlite");
3721 let base = onestore::create_section("placement.one", "Original", "Author").unwrap();
3722 let (sid, _, _) = text(&base);
3723 let store = Store::parse(&base).unwrap();
3724 let index = RevisionIndex::parse(&store).unwrap();
3725 let doc = Document::parse(&index).unwrap();
3726 let (_, page) = doc.pages().unwrap()[0];
3727 let space = &doc.spaces[&sid];
3728 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
3729 let parent = *view.nodes[&page]
3730 .children
3731 .iter()
3732 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
3733 .unwrap();
3734 let anchor = Insertion::paragraph(parent, None, "Temporary anchor", "Author").unwrap();
3735 let source = PreparedEdit::insert(&base, sid, &anchor)
3736 .unwrap()
3737 .as_bytes()
3738 .to_vec();
3739 let insertion = if outline_case {
3740 Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap()
3741 } else {
3742 Insertion::paragraph(parent, Some(anchor.object()), "Offline", "Author").unwrap()
3743 }
3744 .with_formatting(0..7, &[A::Italic(true)])
3745 .unwrap();
3746 let cache = Replica::create(&path, &source).unwrap();
3747 let first = cache.insert(&source, sid, &insertion).unwrap().unwrap();
3748 let second = cache
3749 .format(
3750 &cache.snapshot().unwrap(),
3751 sid,
3752 insertion.text_object(),
3753 0..7,
3754 &[A::Bold(true)],
3755 )
3756 .unwrap()
3757 .unwrap();
3758 let third = cache
3759 .edit_text(
3760 &cache.snapshot().unwrap(),
3761 sid,
3762 insertion.text_object(),
3763 7..7,
3764 " 🦀",
3765 )
3766 .unwrap()
3767 .unwrap();
3768 let local = cache.snapshot().unwrap();
3769 let pending = cache.pending().unwrap();
3770 let mut server = Server::new(&base);
3771 if outline_case {
3772 drop(cache);
3773 let db = rusqlite::Connection::open(&path).unwrap();
3774 db.execute(
3775 "INSERT INTO conflicts VALUES (?1,2)",
3776 [i64::try_from(first).unwrap()],
3777 )
3778 .unwrap();
3779 db.execute("UPDATE replica SET base=?1", [&base]).unwrap();
3780 drop(db);
3781 } else {
3782 assert_eq!(
3783 cache.sync_once(&mut server).unwrap(),
3784 Some((first, EditStatus::Conflict(ConflictKind::UnsupportedEdit)))
3785 );
3786 drop(cache);
3787 }
3788 let cache = Replica::open(&path).unwrap();
3789 if outline_case {
3790 assert!(
3791 cache
3792 .rebase_paragraph_conflict(first, &local, &base, parent, None)
3793 .is_err()
3794 );
3795 assert!(
3796 cache
3797 .rebase_outline_conflict(first, &local, &base, page, f32::NAN, 288.0)
3798 .is_err()
3799 );
3800 assert!(
3801 matches!(cache.rebase_outline_conflict(first,&source,&base,page,288.0,360.0),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
3802 );
3803 cache
3804 .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0)
3805 .unwrap();
3806 } else {
3807 assert!(
3808 cache
3809 .rebase_outline_conflict(first, &local, &base, page, 288.0, 360.0)
3810 .is_err()
3811 );
3812 assert!(
3813 cache
3814 .rebase_paragraph_conflict(first, &local, &base, parent, Some(anchor.object()))
3815 .is_err()
3816 );
3817 assert!(
3818 matches!(cache.rebase_paragraph_conflict(first,&source,&base,parent,None),Err(Error::Io(e))if e.kind()==io::ErrorKind::ResourceBusy)
3819 );
3820 cache
3821 .rebase_paragraph_conflict(first, &local, &base, parent, None)
3822 .unwrap();
3823 }
3824 assert_eq!(cache.snapshot().unwrap(), local);
3825 assert_eq!(cache.pending().unwrap()[1..], pending[1..]);
3826 let Operation::Insert(rebased) = cache.pending().unwrap().remove(0).operation else {
3827 panic!()
3828 };
3829 assert_eq!(rebased.object(), insertion.object());
3830 assert_eq!(rebased.text_object(), insertion.text_object());
3831 assert_eq!(
3832 serde_json::to_value(&rebased).unwrap()["formats"],
3833 serde_json::to_value(&insertion).unwrap()["formats"]
3834 );
3835 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));
3836 drop(cache);
3837 let cache = Replica::open(&path).unwrap();
3838 for id in [first, second, third] {
3839 let result = cache.sync_once(&mut server).unwrap();
3840 assert!(
3841 matches!(result,Some((actual,EditStatus::Published{..}))if actual==id),
3842 "{result:?}"
3843 );
3844 }
3845 let store = Store::parse(&server.durable).unwrap();
3846 let index = RevisionIndex::parse(&store).unwrap();
3847 let doc = Document::parse(&index).unwrap();
3848 let space = &doc.spaces[&sid];
3849 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
3850 let runs = view.text_runs(insertion.text_object()).unwrap();
3851 assert_eq!(
3852 runs.iter().map(|r| r.text).collect::<String>(),
3853 "Offline 🦀"
3854 );
3855 assert!(runs.iter().all(|r| r.format.bold == Some(true)));
3856 assert!(runs.iter().all(|r| r.format.italic == Some(true)));
3857 if outline_case {
3858 assert_eq!(
3859 (
3860 view.nodes[&insertion.object()].layout.x,
3861 view.nodes[&insertion.object()].layout.y
3862 ),
3863 (Some(288.0), Some(360.0))
3864 );
3865 } else {
3866 assert_eq!(
3867 view.nodes[&parent].children.last(),
3868 Some(&insertion.object())
3869 );
3870 }
3871 assert!(!view.nodes.contains_key(&anchor.object()));
3872 assert_eq!(server.publications, 3);
3873 assert!(cache.pending().unwrap().is_empty());
3874 assert_eq!(cache.snapshot().unwrap(), server.durable);
3875 }
3876}
3877
3878#[test]
3879fn placement_reviews_cannot_replace_pending_or_uncertain_attempts() {
3880 use onestore::Insertion;
3881 for outline_case in [false, true] {
3882 let directory = tempfile::tempdir().unwrap();
3883 let path = directory.path().join("attempt-placement.sqlite");
3884 let source = onestore::create_section("placement.one", "Original", "Author").unwrap();
3885 let store = Store::parse(&source).unwrap();
3886 let index = RevisionIndex::parse(&store).unwrap();
3887 let doc = Document::parse(&index).unwrap();
3888 let (sid, page) = doc.pages().unwrap()[0];
3889 let space = &doc.spaces[&sid];
3890 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
3891 let parent = *view.nodes[&page]
3892 .children
3893 .iter()
3894 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
3895 .unwrap();
3896 let insertion = if outline_case {
3897 Insertion::outline(page, 144.0, 144.0, "Offline", "Author").unwrap()
3898 } else {
3899 Insertion::paragraph(parent, None, "Offline", "Author").unwrap()
3900 };
3901 let cache = Replica::create(&path, &source).unwrap();
3902 let id = cache.insert(&source, sid, &insertion).unwrap().unwrap();
3903 let local = cache.snapshot().unwrap();
3904 let pending = cache.pending().unwrap();
3905 let mut server = Server::new(&source);
3906 for attempted in [false, true] {
3907 if attempted {
3908 server.fault = Fault::UnknownBefore;
3909 assert!(
3910 matches!(cache.sync_once(&mut server),Err(Error::Remote(e))if e.state==CommitState::Unknown)
3911 );
3912 }
3913 let state = cache.status(id).unwrap();
3914 let result = if outline_case {
3915 cache.rebase_outline_conflict(id, &local, &source, page, 288.0, 360.0)
3916 } else {
3917 cache.rebase_paragraph_conflict(id, &local, &source, parent, None)
3918 };
3919 assert!(matches!(result,Err(Error::Io(e))if e.kind()==io::ErrorKind::InvalidInput));
3920 assert_eq!(cache.pending().unwrap(), pending);
3921 assert_eq!(cache.status(id).unwrap(), state);
3922 assert_eq!(cache.snapshot().unwrap(), local);
3923 }
3924 drop(cache);
3925 let cache = Replica::open(&path).unwrap();
3926 assert_eq!(cache.pending().unwrap(), pending);
3927 assert!(matches!(
3928 cache.sync_once(&mut server).unwrap(),
3929 Some((_, EditStatus::AwaitingConfirmation { .. }))
3930 ));
3931 assert_eq!(server.publications, 1);
3932 }
3933827}
crates/notebook/tests/sync/model.rs deleted-526
......@@ -1,526 +0,0 @@
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
4use super::*;
5use notebook::{Operation, PageIntent};
6use onestore::page::{Page, PageObject};
7
8const OUTLINES: &[u8] =
9 include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
10
11fn 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
18fn 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.
34fn 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
54fn 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]
67fn 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]
108fn 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]
151fn 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]
187fn 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]
230fn 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]
252fn 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}
269
270/// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`.
271fn edit_paragraph(page: &mut Page, index: usize, range: std::ops::Range<u32>, replacement: &str) {
272 let outline = page
273 .objects
274 .iter_mut()
275 .find_map(|object| match object {
276 PageObject::Outline(outline) => Some(outline),
277 _ => None,
278 })
279 .unwrap();
280 let text = outline.paragraphs[index].text_mut().unwrap();
281 let format = text.text.format_at(range.start).unwrap().clone();
282 text.text
283 .apply(onestore::page::text::Edit {
284 range,
285 replacement: onestore::page::Paragraph::new(replacement.into(), format),
286 })
287 .unwrap();
288}
289
290fn texts(page: &Page) -> Vec<String> {
291 page.objects
292 .iter()
293 .find_map(|object| match object {
294 PageObject::Outline(outline) => Some(
295 outline
296 .paragraphs
297 .iter()
298 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
299 .collect(),
300 ),
301 _ => None,
302 })
303 .unwrap()
304}
305
306fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
307 let mut page = page_of(OUTLINES, space);
308 change(&mut page);
309 Server::new(
310 PreparedEdit::page(OUTLINES, space, &page, "Native author")
311 .unwrap()
312 .as_bytes(),
313 )
314}
315
316#[test]
317fn concurrent_edits_to_different_paragraphs_merge() {
318 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
319 let directory = tempfile::tempdir().unwrap();
320 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
321 edit_paragraph(&mut after, 0, 0..0, "Local ");
322 let id = cache
323 .save(OUTLINES, space, &after, "Model author")
324 .unwrap()
325 .unwrap();
326 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
327 assert!(
328 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
329 );
330 let published = texts(&page_of(&server.durable, space));
331 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
332 assert!(published[2].starts_with("Remote Trailing"), "{published:?}");
333 assert_eq!(
334 cache
335 .status(id)
336 .unwrap()
337 .map(|s| matches!(s, EditStatus::Published { .. })),
338 Some(true)
339 );
340}
341
342#[test]
343fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
344 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
345 let directory = tempfile::tempdir().unwrap();
346 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
347 append(&mut after, " local");
348 let id = cache
349 .save(OUTLINES, space, &after, "Model author")
350 .unwrap()
351 .unwrap();
352 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
353 assert!(
354 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
355 );
356 assert_eq!(
357 texts(&page_of(&server.durable, space))[0],
358 "Remote Anchor local"
359 );
360
361 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
362 let mut after = page_of(OUTLINES, space);
363 edit_paragraph(&mut after, 0, 0..6, "Local");
364 let id = cache
365 .save(OUTLINES, space, &after, "Model author")
366 .unwrap()
367 .unwrap();
368 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
369 assert_eq!(
370 cache.sync_once(&mut server).unwrap(),
371 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
372 );
373 assert_eq!(server.publications, 0);
374}
375
376#[test]
377fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
378 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
379 let directory = tempfile::tempdir().unwrap();
380 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
381 let template = texts(&after);
382 assert_eq!(template.len(), 3);
383 {
384 let outline = after
385 .objects
386 .iter_mut()
387 .find_map(|object| match object {
388 PageObject::Outline(outline) => Some(outline),
389 _ => None,
390 })
391 .unwrap();
392 let mut fresh = outline.paragraphs[0].clone();
393 fresh.id = onestore::page::text::new_id().unwrap();
394 fresh.style = None;
395 let text = fresh.text_mut().unwrap();
396 text.id = onestore::page::text::new_id().unwrap();
397 text.text = onestore::page::Paragraph::new(
398 "Inserted locally".into(),
399 text.text.format_at(0).unwrap().clone(),
400 );
401 outline.paragraphs.insert(1, fresh);
402 }
403 let id = cache
404 .save(OUTLINES, space, &after, "Model author")
405 .unwrap()
406 .unwrap();
407 let mut server = remote_with(space, |page| {
408 let outline = page
409 .objects
410 .iter_mut()
411 .find_map(|object| match object {
412 PageObject::Outline(outline) => Some(outline),
413 _ => None,
414 })
415 .unwrap();
416 outline.paragraphs.pop();
417 });
418 assert!(
419 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
420 );
421 assert_eq!(
422 texts(&page_of(&server.durable, space)),
423 vec![
424 template[0].clone(),
425 "Inserted locally".to_owned(),
426 template[1].clone()
427 ]
428 );
429}
430
431#[test]
432fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
433 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
434 let directory = tempfile::tempdir().unwrap();
435 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
436 edit_paragraph(&mut after, 2, 0..0, "Local ");
437 let id = cache
438 .save(OUTLINES, space, &after, "Model author")
439 .unwrap()
440 .unwrap();
441 let mut server = remote_with(space, |page| {
442 let outline = page
443 .objects
444 .iter_mut()
445 .find_map(|object| match object {
446 PageObject::Outline(outline) => Some(outline),
447 _ => None,
448 })
449 .unwrap();
450 outline.paragraphs.pop();
451 });
452 assert_eq!(
453 cache.sync_once(&mut server).unwrap(),
454 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
455 );
456}
457
458#[test]
459fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
460 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
461 let directory = tempfile::tempdir().unwrap();
462 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
463 let outline_id = {
464 let PageObject::Outline(outline) = after
465 .objects
466 .iter_mut()
467 .find(|o| matches!(o, PageObject::Outline(_)))
468 .unwrap()
469 else {
470 unreachable!()
471 };
472 outline.layout.x = Some(200.0);
473 outline.layout.y = Some(300.0);
474 outline.id
475 };
476 let id = cache
477 .save(OUTLINES, space, &after, "Model author")
478 .unwrap()
479 .unwrap();
480 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
481 assert!(
482 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
483 );
484 let published = page_of(&server.durable, space);
485 let PageObject::Outline(outline) = published
486 .objects
487 .iter()
488 .find(|o| o.id() == outline_id)
489 .unwrap()
490 else {
491 unreachable!()
492 };
493 assert_eq!(
494 (outline.layout.x, outline.layout.y),
495 (Some(200.0), Some(300.0))
496 );
497 assert!(
498 outline.paragraphs[0]
499 .text()
500 .unwrap()
501 .text
502 .text()
503 .starts_with("Remote ")
504 );
505
506 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
507 let id = cache
508 .save(OUTLINES, space, &after, "Model author")
509 .unwrap()
510 .unwrap();
511 let mut server = remote_with(space, |page| {
512 let PageObject::Outline(outline) = page
513 .objects
514 .iter_mut()
515 .find(|o| matches!(o, PageObject::Outline(_)))
516 .unwrap()
517 else {
518 unreachable!()
519 };
520 outline.layout.x = Some(50.0);
521 });
522 assert_eq!(
523 cache.sync_once(&mut server).unwrap(),
524 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
525 );
526}
crates/notebook/tests/sync/outline.rs deleted-719
......@@ -1,719 +0,0 @@
1use super::*;
2use onestore::OutlineEdit as Change;
3
4pub(super) fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid, ExGuid) {
5 let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap();
6 let (sid, text, _) = text(&source);
7 let store = Store::parse(&source).unwrap();
8 let index = RevisionIndex::parse(&store).unwrap();
9 let document = Document::parse(&index).unwrap();
10 let space = &document.spaces[&sid];
11 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
12 let paragraph = *view
13 .nodes
14 .iter()
15 .find(|(_, node)| node.content == [text])
16 .unwrap()
17 .0;
18 let outline = *view
19 .nodes
20 .iter()
21 .find(|(_, node)| node.children.contains(&paragraph))
22 .unwrap()
23 .0;
24 (source, sid, outline, paragraph, text)
25}
26
27pub(super) fn node(source: &[u8], sid: ExGuid, object: ExGuid) -> serde_json::Value {
28 let store = Store::parse(source).unwrap();
29 let index = RevisionIndex::parse(&store).unwrap();
30 index.validate_current().unwrap();
31 let document = Document::parse(&index).unwrap();
32 let space = &document.spaces[&sid];
33 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
34 serde_json::to_value(&view.nodes[&object]).unwrap()
35}
36
37#[test]
38fn layout_and_dependent_text_survive_reopen_and_independent_remote_formatting() {
39 let (source, sid, outline, paragraph, text_id) = fixture();
40 for (object, change) in [
41 (outline, Change::Position { x: 180.0, y: 216.0 }),
42 (
43 outline,
44 Change::Width {
45 points: 144.0,
46 user_set: true,
47 },
48 ),
49 (paragraph, Change::Collapsed(true)),
50 ] {
51 let directory = tempfile::tempdir().unwrap();
52 let path = directory.path().join("cache.sqlite");
53 let cache = Replica::create(&path, &source).unwrap();
54 let id = cache
55 .outline(&source, sid, object, change)
56 .unwrap()
57 .unwrap();
58 let local = cache.snapshot().unwrap();
59 let expected = node(&local, sid, object);
60 let dependent = cache
61 .edit_text(&local, sid, text_id, 0..0, "Local ")
62 .unwrap()
63 .unwrap();
64 let pending = cache.pending().unwrap();
65 let local = cache.snapshot().unwrap();
66 drop(cache);
67 let cache = Replica::open(&path).unwrap();
68 assert_eq!(cache.pending().unwrap(), pending);
69 assert!(cache.snapshot().unwrap() == local);
70 let remote = PreparedEdit::format(
71 &source,
72 sid,
73 text_id,
74 0..8,
75 &[onestore::TextAttribute::Bold(true)],
76 )
77 .unwrap();
78 let mut server = Server::new(remote.as_bytes());
79 assert!(
80 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
81 );
82 let actual = node(&server.durable, sid, object);
83 assert_eq!(actual["layout"], expected["layout"]);
84 assert_eq!(actual["kind"], expected["kind"]);
85 assert_eq!(cache.pending().unwrap().len(), 1);
86 drop(cache);
87 let cache = Replica::open(&path).unwrap();
88 assert!(
89 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==dependent)
90 );
91 assert_eq!(text(&server.durable).2, "Local Original 🦀 é");
92 assert!(cache.pending().unwrap().is_empty());
93 let store = Store::parse(&server.durable).unwrap();
94 let index = RevisionIndex::parse(&store).unwrap();
95 let document = Document::parse(&index).unwrap();
96 let space = &document.spaces[&sid];
97 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
98 assert!(view.text_runs(text_id).unwrap()[0].format.bold.unwrap());
99 assert_eq!(server.publications, 2);
100 drop(cache);
101 let cache = Replica::open(&path).unwrap();
102 for id in [id, dependent] {
103 assert!(matches!(
104 cache.status(id).unwrap(),
105 Some(EditStatus::Published { .. })
106 ));
107 }
108 }
109}
110
111#[test]
112fn rebased_outline_movement_uses_remote_title_text_and_confirms_after_reopen() {
113 let (source, sid, outline, _, text_id) = fixture();
114 let store = Store::parse(&source).unwrap();
115 let index = RevisionIndex::parse(&store).unwrap();
116 let document = Document::parse(&index).unwrap();
117 let (_, page) = document.pages().unwrap()[0];
118 let space = &document.spaces[&sid];
119 let metadata = space.revisions[&space.contexts[&ExGuid::default()]].roots[&2];
120 let second = onestore::Insertion::outline(page, 144.0, 36.0, "Second", "Author").unwrap();
121 let source = PreparedEdit::insert(&source, sid, &second)
122 .unwrap()
123 .as_bytes()
124 .to_vec();
125 let directory = tempfile::tempdir().unwrap();
126 let path = directory.path().join("titles.sqlite");
127 let cache = Replica::create(&path, &source).unwrap();
128 let movement = cache
129 .outline(
130 &source,
131 sid,
132 outline,
133 Change::Position { x: 216.0, y: 72.0 },
134 )
135 .unwrap()
136 .unwrap();
137 let local = cache.snapshot().unwrap();
138 assert_eq!(node(&local, sid, metadata)["kind"]["title"], "Second");
139 let dependent = cache
140 .edit_text(&local, sid, text_id, 0..0, "Local ")
141 .unwrap()
142 .unwrap();
143 drop(cache);
144 let remote =
145 PreparedEdit::text(&source, sid, second.text_object(), 0..6, "Remote second 🐈").unwrap();
146 let mut server = Server::new(remote.as_bytes());
147 server.fault = Fault::UnknownAfter;
148 let cache = Replica::open(&path).unwrap();
149 assert!(cache.sync_once(&mut server).is_err());
150 assert!(matches!(
151 cache.status(movement).unwrap(),
152 Some(EditStatus::AwaitingConfirmation { .. })
153 ));
154 assert_eq!(
155 node(&server.durable, sid, metadata)["kind"]["title"],
156 "Original 🦀 é"
157 );
158 assert_eq!(
159 node(&server.visible, sid, metadata)["kind"]["title"],
160 "Remote second 🐈"
161 );
162 drop(cache);
163 for id in [movement, dependent] {
164 let cache = Replica::open(&path).unwrap();
165 assert!(
166 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
167 );
168 assert_eq!(
169 node(&server.durable, sid, metadata)["kind"]["title"],
170 "Remote second 🐈"
171 );
172 assert_eq!(
173 node(&server.durable, sid, page)["kind"]["alternate_title"],
174 "Remote second 🐈"
175 );
176 }
177 let cache = Replica::open(&path).unwrap();
178 assert!(cache.pending().unwrap().is_empty());
179 assert_eq!(
180 node(&cache.snapshot().unwrap(), sid, text_id)["kind"]["text"],
181 "Local Original 🦀 é"
182 );
183 assert_eq!(server.publications, 2);
184}
185
186#[test]
187fn competing_layout_requires_current_review_and_preserves_dependent_edits() {
188 let (source, sid, outline, _, text_id) = fixture();
189 for (local_change, remote_change) in [
190 (
191 Change::Position { x: 180.0, y: 216.0 },
192 Change::Position { x: 288.0, y: 216.0 },
193 ),
194 (
195 Change::Width {
196 points: 144.0,
197 user_set: true,
198 },
199 Change::Width {
200 points: 216.0,
201 user_set: false,
202 },
203 ),
204 ] {
205 let directory = tempfile::tempdir().unwrap();
206 let path = directory.path().join("cache.sqlite");
207 let cache = Replica::create(&path, &source).unwrap();
208 let id = cache
209 .outline(&source, sid, outline, local_change)
210 .unwrap()
211 .unwrap();
212 let dependent = cache
213 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
214 .unwrap()
215 .unwrap();
216 let local = cache.snapshot().unwrap();
217 let queue = cache.pending().unwrap();
218 let remote = PreparedEdit::outline(&source, sid, outline, remote_change).unwrap();
219 let mut server = Server::new(remote.as_bytes());
220 assert_eq!(
221 cache.sync_once(&mut server).unwrap(),
222 Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged)))
223 );
224 assert_eq!(server.publications, 0);
225 assert_eq!(cache.pending().unwrap(), queue);
226 assert!(cache.snapshot().unwrap() == local);
227 assert!(
228 cache
229 .rebase_layout_conflict(id, &source, &server.visible)
230 .is_err()
231 );
232 assert!(cache.rebase_layout_conflict(id, &local, &source).is_err());
233 assert!(
234 cache
235 .rebase_conflict(id, &local, &server.visible, 0..0)
236 .is_err()
237 );
238 drop(cache);
239 let cache = Replica::open(&path).unwrap();
240 assert_eq!(
241 cache.status(id).unwrap(),
242 Some(EditStatus::Conflict(ConflictKind::LayoutChanged))
243 );
244 cache
245 .rebase_layout_conflict(id, &local, &server.visible)
246 .unwrap();
247 let reviewed = cache.pending().unwrap();
248 assert_eq!(reviewed[1], queue[1]);
249 assert!(cache.snapshot().unwrap() == local);
250 drop(cache);
251 let cache = Replica::open(&path).unwrap();
252 assert_eq!(cache.pending().unwrap(), reviewed);
253 for id in [id, dependent] {
254 assert!(
255 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
256 );
257 }
258 assert_eq!(
259 node(&server.durable, sid, outline)["layout"],
260 node(&local, sid, outline)["layout"]
261 );
262 assert_eq!(text(&server.durable).2, "Local Original 🦀 é");
263 }
264}
265
266#[test]
267fn independent_axes_converge_without_overwriting_competing_values() {
268 let (source, sid, outline, _, _) = fixture();
269 let directory = tempfile::tempdir().unwrap();
270 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
271 let original = node(&source, sid, outline);
272 let local = Change::Position { x: 180.0, y: 216.0 };
273 let id = cache
274 .outline(&source, sid, outline, local)
275 .unwrap()
276 .unwrap();
277 let remote = PreparedEdit::outline(
278 &source,
279 sid,
280 outline,
281 Change::Position {
282 x: original["layout"]["x"].as_f64().unwrap() as f32,
283 y: 216.0,
284 },
285 )
286 .unwrap();
287 let mut server = Server::new(remote.as_bytes());
288 assert!(
289 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
290 );
291 let actual = node(&server.durable, sid, outline);
292 assert_eq!(actual["layout"]["x"], 180.0);
293 assert_eq!(actual["layout"]["y"], 216.0);
294}
295
296#[test]
297fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() {
298 let (source, sid, outline, paragraph, _) = fixture();
299 let directory = tempfile::tempdir().unwrap();
300 std::thread::scope(|scope| {
301 let mut writers = Vec::new();
302 for actor in 0..12 {
303 let source = &source;
304 let path = directory.path().join(format!("{actor}.sqlite"));
305 writers.push(scope.spawn(move || {
306 let cache = Replica::create(&path, source).unwrap();
307 for round in 0..4 {
308 for (object, change) in [
309 (
310 outline,
311 Change::Position {
312 x: (actor + 2) as f32 * 36.0,
313 y: (round + 2) as f32 * 36.0,
314 },
315 ),
316 (
317 outline,
318 Change::Width {
319 points: (actor + round + 2) as f32 * 36.0,
320 user_set: true,
321 },
322 ),
323 (paragraph, Change::Collapsed((actor + round) % 2 != 0)),
324 ] {
325 assert!(
326 cache
327 .outline(&cache.snapshot().unwrap(), sid, object, change)
328 .unwrap()
329 .is_some()
330 );
331 }
332 }
333 assert_eq!(cache.pending().unwrap().len(), 12);
334 }));
335 }
336 for writer in writers {
337 writer.join().unwrap();
338 }
339 });
340 let mut server = Server::new(&source);
341 let mut expected_layout = node(&source, sid, outline)["layout"].clone();
342 let mut expected_collapse = None;
343 let mut reviewed = 0;
344 for round in 0..12 {
345 for actor in 0..12 {
346 let path = directory.path().join(format!("{actor}.sqlite"));
347 let cache = Replica::open(&path).unwrap();
348 let first = cache.pending().unwrap()[0].clone();
349 let notebook::Operation::Outline(edit) = first.operation else {
350 panic!()
351 };
352 let local = cache.snapshot().unwrap();
353 let result = cache.sync_once(&mut server).unwrap();
354 if result == Some((first.id, EditStatus::Conflict(ConflictKind::LayoutChanged))) {
355 let remote = cache.remote_snapshot().unwrap();
356 cache
357 .rebase_layout_conflict(first.id, &local, &remote)
358 .unwrap();
359 drop(cache);
360 let cache = Replica::open(&path).unwrap();
361 assert!(
362 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == first.id)
363 );
364 reviewed += 1;
365 } else {
366 assert!(
367 matches!(result, Some((id, EditStatus::Published { .. })) if id == first.id)
368 );
369 }
370 match edit.change {
371 Change::Position { x, y } => {
372 expected_layout["x"] = x.into();
373 expected_layout["y"] = y.into();
374 }
375 Change::Width { points, user_set } => {
376 expected_layout["max_width"] = points.into();
377 expected_layout["width_set_by_user"] = user_set.into();
378 }
379 Change::Collapsed(value) => expected_collapse = Some(u8::from(value)),
380 }
381 assert_eq!(
382 node(&server.durable, sid, outline)["layout"],
383 expected_layout
384 );
385 assert_eq!(
386 node(&server.durable, sid, paragraph)["kind"]["collapse_state"],
387 serde_json::json!(expected_collapse)
388 );
389 assert_eq!(text(&server.durable).2, "Original 🦀 é");
390 }
391 for actor in 0..12 {
392 let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap();
393 assert_eq!(cache.pending().unwrap().len(), 11 - round);
394 }
395 }
396 assert!(reviewed >= 12);
397 for actor in 0..12 {
398 let cache = Replica::open(directory.path().join(format!("{actor}.sqlite"))).unwrap();
399 assert!(cache.pending().unwrap().is_empty());
400 for id in 1..=12 {
401 assert!(matches!(
402 cache.status(id).unwrap(),
403 Some(EditStatus::Published { .. })
404 ));
405 }
406 }
407}
408
409#[test]
410fn uncertain_layout_is_confirmed_by_revision_and_never_by_converged_values() {
411 let (source, sid, outline, _, text_id) = fixture();
412 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
413 let before = matches!(fault, Fault::UnknownBefore);
414 let directory = tempfile::tempdir().unwrap();
415 let path = directory.path().join("cache.sqlite");
416 let cache = Replica::create(&path, &source).unwrap();
417 let change = Change::Width {
418 points: 144.0,
419 user_set: true,
420 };
421 let id = cache
422 .outline(&source, sid, outline, change)
423 .unwrap()
424 .unwrap();
425 cache
426 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
427 .unwrap();
428 let local = cache.snapshot().unwrap();
429 let mut server = Server::new(&source);
430 server.fault = fault;
431 assert!(cache.sync_once(&mut server).is_err());
432 let status = cache.status(id).unwrap().unwrap();
433 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
434 drop(cache);
435 let cache = Replica::open(&path).unwrap();
436 if before {
437 let converged = PreparedEdit::outline(&source, sid, outline, change).unwrap();
438 server.visible = converged.as_bytes().to_vec();
439 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));
440 assert!(
441 cache
442 .rebase_layout_conflict(id, &local, &server.visible)
443 .is_err()
444 );
445 assert_eq!(cache.pending().unwrap().len(), 2);
446 assert!(cache.snapshot().unwrap() == local);
447 let archive = directory.path().join("recovery.sqlite");
448 cache.export_recovery(&archive).unwrap();
449 let recovery = notebook::Recovery::open(&archive).unwrap();
450 assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap());
451 assert_eq!(recovery.status(id).unwrap(), Some(status));
452 assert_eq!(server.confirmations, 0);
453 } else {
454 assert!(
455 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
456 );
457 assert_eq!(
458 node(&server.durable, sid, outline)["layout"]["max_width"],
459 144.0
460 );
461 assert_eq!(server.confirmations, 1);
462 }
463 assert_eq!(server.publications, 1);
464 }
465}
466
467#[test]
468fn native_moves_deletions_and_layout_changes_merge_or_retain_explicit_conflicts() {
469 let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
470 let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one");
471 let store = Store::parse(source).unwrap();
472 let index = RevisionIndex::parse(&store).unwrap();
473 let document = Document::parse(&index).unwrap();
474 let mut server = Server::new(native);
475 let mut counts = [0; 3];
476 let mut records = Vec::new();
477 for (sid, page) in document.pages().unwrap() {
478 let space = &document.spaces[&sid];
479 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
480 let Kind::Metadata {
481 title: Some(name), ..
482 } = &view.nodes[&view.roots[&2]].kind
483 else {
484 continue;
485 };
486 if name == "Unicode rich text" {
487 continue;
488 }
489 let outlines: Vec<_> = view.nodes[&page]
490 .children
491 .iter()
492 .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
493 .copied()
494 .collect();
495 if outlines.len() != 2 {
496 continue;
497 }
498 let outline = outlines[0];
499 let mut pending = vec![outline];
500 let mut target = None;
501 while let Some(id) = pending.pop() {
502 let node = &view.nodes[&id];
503 pending.extend(&node.children);
504 if node.content.first().is_some_and(|text| matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))) {
505 target = Some(id);
506 }
507 }
508 let target = target.unwrap();
509 let text_id = view.nodes[&view.nodes[&outlines[1]].children[0]].content[0];
510 let (object, change, conflict) = match name.as_str() {
511 "Move outline" => (
512 outline,
513 Change::Position { x: 252.0, y: 288.0 },
514 Some(ConflictKind::LayoutChanged),
515 ),
516 "Resize outline" | "Automatic outline size" => (
517 outline,
518 Change::Width {
519 points: 252.0,
520 user_set: true,
521 },
522 Some(ConflictKind::LayoutChanged),
523 ),
524 "Delete outline" => (
525 outline,
526 Change::Width {
527 points: 252.0,
528 user_set: true,
529 },
530 Some(ConflictKind::TargetUnavailable),
531 ),
532 "Delete leaf" | "Delete subtree" | "Delete only paragraph" => (
533 target,
534 Change::Collapsed(true),
535 Some(ConflictKind::TargetUnavailable),
536 ),
537 "Indent subtree" | "Outdent subtree" => (
538 target,
539 Change::Collapsed(true),
540 Some(ConflictKind::StructureChanged),
541 ),
542 "Expand subtree" => (target, Change::Collapsed(false), None),
543 "Collapse subtree" | "Move leaf down" | "Move subtree down" | "Move subtree up" => {
544 (target, Change::Collapsed(true), None)
545 }
546 _ => panic!("Unexpected native case {name}"),
547 };
548 let expected_layout = if conflict == Some(ConflictKind::TargetUnavailable) {
549 None
550 } else {
551 let mut layout = node(&server.visible, sid, object)["layout"].clone();
552 match change {
553 Change::Position { x, y } => {
554 layout["x"] = x.into();
555 layout["y"] = y.into();
556 }
557 Change::Width { points, user_set } => {
558 layout["max_width"] = points.into();
559 layout["width_set_by_user"] = user_set.into();
560 }
561 Change::Collapsed(_) => {}
562 }
563 Some(layout)
564 };
565 let directory = tempfile::tempdir().unwrap();
566 let path = directory.path().join("cache.sqlite");
567 let cache = Replica::create(&path, source).unwrap();
568 let id = cache.outline(source, sid, object, change).unwrap().unwrap();
569 let dependent = cache
570 .edit_text(&cache.snapshot().unwrap(), sid, text_id, 0..0, "Local ")
571 .unwrap()
572 .unwrap();
573 let local = cache.snapshot().unwrap();
574 let queue = cache.pending().unwrap();
575 let result = cache.sync_once(&mut server).unwrap().unwrap();
576 let mut retained = false;
577 if let Some(kind) = conflict {
578 assert_eq!(result, (id, EditStatus::Conflict(kind)), "{name}");
579 assert_eq!(cache.pending().unwrap(), queue);
580 assert!(cache.snapshot().unwrap() == local);
581 drop(cache);
582 let cache = Replica::open(&path).unwrap();
583 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Conflict(kind)));
584 if kind == ConflictKind::TargetUnavailable {
585 assert!(
586 cache
587 .rebase_layout_conflict(id, &local, &server.visible)
588 .is_err()
589 );
590 counts[2] += 1;
591 retained = true;
592 } else {
593 cache
594 .rebase_layout_conflict(id, &local, &server.visible)
595 .unwrap();
596 drop(cache);
597 let cache = Replica::open(&path).unwrap();
598 assert!(
599 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
600 );
601 counts[1] += 1;
602 }
603 } else {
604 assert!(
605 matches!(result, (n, EditStatus::Published { .. }) if n == id),
606 "{name}"
607 );
608 counts[0] += 1;
609 drop(cache);
610 }
611 let cache = Replica::open(&path).unwrap();
612 if retained {
613 assert_eq!(cache.pending().unwrap(), queue);
614 assert!(cache.snapshot().unwrap() == local);
615 } else {
616 assert!(
617 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == dependent)
618 );
619 assert_eq!(
620 node(&server.durable, sid, object)["layout"],
621 expected_layout.unwrap(),
622 "{name}"
623 );
624 if matches!(change, Change::Collapsed(_)) {
625 assert_eq!(
626 node(&server.durable, sid, object)["kind"]["collapse_state"],
627 node(&local, sid, object)["kind"]["collapse_state"]
628 );
629 }
630 assert!(
631 node(&server.durable, sid, text_id)["kind"]["text"]
632 .as_str()
633 .unwrap()
634 .starts_with("Local ")
635 );
636 drop(cache);
637 let cache = Replica::open(&path).unwrap();
638 for id in [id, dependent] {
639 assert!(matches!(
640 cache.status(id).unwrap(),
641 Some(EditStatus::Published { .. })
642 ));
643 }
644 }
645 records.push(serde_json::json!({"name": name, "space": sid, "object": object, "change": change, "retained": retained, "dependent_text": text_id}));
646 }
647 assert_eq!(counts, [5, 5, 4]);
648 assert_eq!(server.publications, 18);
649 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") {
650 let output = std::path::PathBuf::from(output);
651 assert!(output.is_absolute());
652 std::fs::create_dir(&output).unwrap();
653 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
654 std::fs::write(
655 output.with_extension("json"),
656 serde_json::to_vec_pretty(&records).unwrap(),
657 )
658 .unwrap();
659 }
660}
661
662#[test]
663fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
664 let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
665 let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one");
666 let store = Store::parse(source).unwrap();
667 let index = RevisionIndex::parse(&store).unwrap();
668 let document = Document::parse(&index).unwrap();
669 let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| {
670 let space = &document.spaces[sid];
671 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
672 matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(name), .. } if name=="Move outline")
673 }).unwrap();
674 let space = &document.spaces[&sid];
675 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
676 let outline = *view.nodes[&page]
677 .children
678 .iter()
679 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
680 .unwrap();
681 let old = node(source, sid, outline);
682 let remote = node(native, sid, outline);
683 assert_eq!(old["layout"]["max_width"], remote["layout"]["max_width"]);
684 assert_eq!(
685 old["layout"]["width_set_by_user"],
686 remote["layout"]["width_set_by_user"]
687 );
688 assert!(remote["layout"]["reserved_width"].is_number());
689 let directory = tempfile::tempdir().unwrap();
690 let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap();
691 let id = cache
692 .outline(
693 source,
694 sid,
695 outline,
696 Change::Width {
697 points: 144.0,
698 user_set: true,
699 },
700 )
701 .unwrap()
702 .unwrap();
703 let mut server = Server::new(native);
704 assert_eq!(
705 cache.sync_once(&mut server).unwrap(),
706 Some((id, EditStatus::Conflict(ConflictKind::LayoutChanged)))
707 );
708 cache
709 .rebase_layout_conflict(id, &cache.snapshot().unwrap(), &server.visible)
710 .unwrap();
711 assert!(
712 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n==id)
713 );
714 let after = node(&server.durable, sid, outline);
715 assert_eq!(after["layout"]["x"], remote["layout"]["x"]);
716 assert_eq!(after["layout"]["y"], remote["layout"]["y"]);
717 assert_eq!(after["layout"]["max_width"], 144.0);
718 assert!(after["layout"]["reserved_width"].is_null());
719}
crates/notebook/tests/sync/page.rs deleted-350
......@@ -1,350 +0,0 @@
1use super::*;
2use notebook::{Operation, Recovery};
3use onestore::{Insertion, PageCreation};
4use std::collections::BTreeMap;
5
6#[test]
7fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {
8 for seed in 0..24_u64 {
9 let mut random = seed + 1956;
10 let mut input = Vec::new();
11 for step in 0..48 {
12 random ^= random << 13;
13 random ^= random >> 7;
14 random ^= random << 17;
15 let mut action = random.to_le_bytes();
16 if step < 12 {
17 action[0] = step;
18 action[1] = 1;
19 }
20 input.extend_from_slice(&action);
21 }
22 page_schedule::run(&input);
23 }
24}
25
26#[test]
27fn version_two_uncertain_text_migrates_without_replay_or_lost_receipts() {
28 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
29 let (sid, oid, _) = text(&source);
30 let directory = tempfile::tempdir().unwrap();
31 let path = directory.path().join("legacy.sqlite");
32 let cache = Replica::create(&path, &source).unwrap();
33 let id = cache
34 .edit_text(&source, sid, oid, 0..0, "Old ")
35 .unwrap()
36 .unwrap();
37 let snapshot = cache.snapshot().unwrap();
38 let pending = cache.pending().unwrap();
39 let store = Store::parse(&snapshot).unwrap();
40 let index = RevisionIndex::parse(&store).unwrap();
41 let revision = index.spaces[&sid].labels[&(ExGuid::default(), 1)];
42 drop(cache);
43 let db = rusqlite::Connection::open(&path).unwrap();
44 db.execute_batch(
45 "DROP TABLE edits; DROP TABLE assets;
46 CREATE TABLE edits (
47 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL,
48 object TEXT NOT NULL, before_text TEXT NOT NULL,
49 start INTEGER NOT NULL CHECK(start BETWEEN 0 AND 4294967295),
50 end INTEGER NOT NULL CHECK(end BETWEEN start AND 4294967295), replacement TEXT NOT NULL
51 ) STRICT;
52 ALTER TABLE attempt RENAME COLUMN revisions TO revision;
53 PRAGMA user_version=2;",
54 )
55 .unwrap();
56 db.execute(
57 "INSERT INTO edits VALUES (?1,?2,?3,'Original',0,0,'Old ')",
58 rusqlite::params![i64::try_from(id).unwrap(), sid.to_string(), oid.to_string()],
59 )
60 .unwrap();
61 db.execute(
62 "INSERT INTO attempt VALUES (1,?1,?2)",
63 rusqlite::params![i64::try_from(id).unwrap(), revision.to_string()],
64 )
65 .unwrap();
66 db.execute(
67 "INSERT INTO receipts VALUES (1000,?1)",
68 [revision.to_string()],
69 )
70 .unwrap();
71 db.execute("UPDATE sqlite_sequence SET seq=1000 WHERE name='edits'", [])
72 .unwrap();
73 drop(db);
74 let cache = Replica::open(&path).unwrap();
75 assert_eq!(cache.pending().unwrap(), pending);
76 assert_eq!(
77 cache.status(id).unwrap(),
78 Some(EditStatus::AwaitingConfirmation { revision })
79 );
80 assert_eq!(
81 cache.status(1000).unwrap(),
82 Some(EditStatus::Published { revision })
83 );
84 assert!(cache.snapshot().unwrap() == snapshot);
85 let mut server = Server::new(&snapshot);
86 assert_eq!(
87 cache.sync_once(&mut server).unwrap(),
88 Some((id, EditStatus::Published { revision }))
89 );
90 assert_eq!(server.publications, 0);
91 assert_eq!(server.confirmations, 1);
92 assert_eq!(
93 cache
94 .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Next ")
95 .unwrap(),
96 Some(1001)
97 );
98}
99
100#[test]
101fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
102 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
103 let directory = tempfile::tempdir().unwrap();
104 let mut server = Server::new(&source);
105 let mut expected = Vec::new();
106 for actor in 0..12 {
107 let path = directory.path().join(format!("{actor}.sqlite"));
108 let cache = Replica::create(&path, &source).unwrap();
109 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
110 let id = cache.create_page(&source, &page).unwrap().unwrap();
111 let insertion = Insertion::outline(page.object(), 36.0, 36.0, "Body", "Author").unwrap();
112 cache
113 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
114 .unwrap()
115 .unwrap();
116 cache
117 .edit_text(
118 &cache.snapshot().unwrap(),
119 page.space(),
120 insertion.text_object(),
121 4..4,
122 &format!(" {actor}"),
123 )
124 .unwrap()
125 .unwrap();
126 let original = cache.pending().unwrap();
127 assert!(
128 matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page)
129 );
130 let local = cache.snapshot().unwrap();
131 drop(cache);
132 let cache = Replica::open(&path).unwrap();
133 assert_eq!(cache.pending().unwrap(), original);
134 assert_eq!(cache.snapshot().unwrap(), local);
135 for edit in original {
136 assert!(matches!(cache.sync_once(&mut server).unwrap(),
137 Some((published, EditStatus::Published { .. })) if published == edit.id));
138 }
139 assert!(matches!(
140 cache.status(id).unwrap(),
141 Some(EditStatus::Published { .. })
142 ));
143 expected.push((
144 page.space(),
145 page.object(),
146 insertion.text_object(),
147 format!("Body {actor}"),
148 ));
149 }
150 let store = Store::parse(&server.durable).unwrap();
151 let index = RevisionIndex::parse(&store).unwrap();
152 let document = Document::parse(&index).unwrap();
153 let pages = document.pages().unwrap();
154 assert_eq!(pages.len(), 13);
155 for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) {
156 assert_eq!(*actual, (*sid, *page));
157 let space = &document.spaces[sid];
158 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
159 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
160 }
161 assert_eq!(server.publications, 36);
162 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
163 std::fs::create_dir(&output).unwrap();
164 std::fs::write(
165 std::path::Path::new(&output).join("pages.one"),
166 &server.durable,
167 )
168 .unwrap();
169 }
170}
171
172#[test]
173fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
174 for fault in [
175 Fault::UnknownBefore,
176 Fault::UnknownAfter,
177 Fault::PanicBefore,
178 Fault::PanicAfter,
179 ] {
180 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
181 let directory = tempfile::tempdir().unwrap();
182 let path = directory.path().join("pages.sqlite");
183 let cache = Replica::create(&path, &source).unwrap();
184 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();
185 let id = cache.create_page(&source, &page).unwrap().unwrap();
186 let local = cache.snapshot().unwrap();
187 let mut server = Server::new(&source);
188 server.fault = fault;
189 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
190 cache.sync_once(&mut server)
191 }));
192 let attempted = cache.status(id).unwrap().unwrap();
193 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
194 let archive = directory.path().join("recovery.sqlite");
195 cache.export_recovery(&archive).unwrap();
196 assert_eq!(
197 Recovery::open(&archive).unwrap().status(id).unwrap(),
198 Some(attempted)
199 );
200 drop(cache);
201 let db = rusqlite::Connection::open(&path).unwrap();
202 let encoded: String = db
203 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
204 .unwrap();
205 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
206 assert_eq!(revisions.len(), 2);
207 assert!(revisions.contains_key(&page.space()));
208 drop(db);
209 let cache = Replica::open(&path).unwrap();
210 let observed = server.visible.clone();
211 let result = cache.sync_once(&mut server).unwrap().unwrap();
212 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
213 assert_eq!(server.publications, 1);
214 assert_eq!(server.confirmations, usize::from(visible));
215 if visible {
216 assert!(matches!(result.1, EditStatus::Published { .. }));
217 assert!(cache.snapshot().unwrap() == observed);
218 assert!(observed[..212] == server.durable[..212]);
219 assert!(observed[252..] == server.durable[252..]);
220 } else {
221 assert_eq!(result, (id, attempted));
222 assert_eq!(cache.snapshot().unwrap(), local);
223 }
224 }
225}
226
227#[test]
228fn surviving_page_revision_alone_does_not_confirm_section_publication() {
229 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
230 let directory = tempfile::tempdir().unwrap();
231 let path = directory.path().join("pages.sqlite");
232 let cache = Replica::create(&path, &source).unwrap();
233 let page = PageCreation::new(None, None, "Author").unwrap();
234 let id = cache.create_page(&source, &page).unwrap().unwrap();
235 let mut server = Server::new(&source);
236 server.fault = Fault::UnknownAfter;
237 assert!(cache.sync_once(&mut server).is_err());
238 let attempted = cache.status(id).unwrap().unwrap();
239 drop(cache);
240 let db = rusqlite::Connection::open(&path).unwrap();
241 let encoded: String = db
242 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
243 .unwrap();
244 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
245 drop(db);
246 let complete = server.visible.clone();
247 let (&section, &retired) = revisions
248 .iter()
249 .find(|(sid, _)| **sid != page.space())
250 .unwrap();
251 let store = Store::parse(&complete).unwrap();
252 let mut positions = Vec::new();
253 for list in store.lists.values() {
254 for node in &list.nodes {
255 if node.id == 0x1e
256 && node.payload[..16] == retired.guid
257 && node.payload[16..20] == retired.n.to_le_bytes()
258 {
259 positions.push(node.offset);
260 }
261 }
262 }
263 assert_eq!(positions.len(), 1);
264 // Replacing only the revision identity models maintenance retiring the section proof.
265 let at = positions[0] + 4;
266 server.visible[at] ^= 0x40;
267 let store = Store::parse(&server.visible).unwrap();
268 let index = RevisionIndex::parse(&store).unwrap();
269 index.validate_current().unwrap();
270 assert!(!index.spaces[&section].revisions.contains_key(&retired));
271 assert!(
272 index.spaces[&page.space()]
273 .revisions
274 .contains_key(&revisions[&page.space()])
275 );
276 let cache = Replica::open(&path).unwrap();
277 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));
278 assert_eq!(server.confirmations, 0);
279 assert_eq!(server.publications, 1);
280 server.visible = complete;
281 assert!(matches!(
282 cache.sync_once(&mut server).unwrap(),
283 Some((_, EditStatus::Published { .. }))
284 ));
285 assert_eq!(server.publications, 1);
286}
287
288#[test]
289fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() {
290 let source =
291 include_bytes!("../../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
292 let remote =
293 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
294 let store = Store::parse(source).unwrap();
295 let index = RevisionIndex::parse(&store).unwrap();
296 let pages = Document::parse(&index).unwrap().pages().unwrap();
297 let directory = tempfile::tempdir().unwrap();
298 let path = directory.path().join("pages.sqlite");
299 let cache = Replica::create(&path, source).unwrap();
300 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
301 let id = cache.create_page(source, &page).unwrap().unwrap();
302 let insertion =
303 Insertion::outline(page.object(), 36.0, 36.0, "Retained body", "Author").unwrap();
304 cache
305 .insert(&cache.snapshot().unwrap(), page.space(), &insertion)
306 .unwrap()
307 .unwrap();
308 let local = cache.snapshot().unwrap();
309 let pending = cache.pending().unwrap();
310 let mut server = Server::new(remote);
311 assert_eq!(
312 cache.sync_once(&mut server).unwrap(),
313 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
314 );
315 assert!(
316 cache
317 .rebase_page_creation_conflict(id, source, remote, None)
318 .is_err()
319 );
320 assert!(
321 cache
322 .rebase_page_creation_conflict(id, &local, source, None)
323 .is_err()
324 );
325 cache
326 .rebase_page_creation_conflict(id, &local, remote, None)
327 .unwrap();
328 assert_eq!(cache.snapshot().unwrap(), local);
329 assert_eq!(cache.pending().unwrap()[1], pending[1]);
330 let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else {
331 panic!()
332 };
333 assert_eq!(*reviewed, page.reposition(None).unwrap());
334 drop(cache);
335 let cache = Replica::open(&path).unwrap();
336 for _ in 0..2 {
337 assert!(matches!(
338 cache.sync_once(&mut server).unwrap(),
339 Some((_, EditStatus::Published { .. }))
340 ));
341 }
342 assert_eq!(server.publications, 2);
343 let store = Store::parse(&server.durable).unwrap();
344 let index = RevisionIndex::parse(&store).unwrap();
345 let document = Document::parse(&index).unwrap();
346 let space = &document.spaces[&page.space()];
347 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
348 assert!(matches!(&view.nodes[&insertion.text_object()].kind,
349 Kind::RichText { text, .. } if text == "Retained body"));
350}
crates/notebook/tests/sync/pages.rs deleted-631
......@@ -1,631 +0,0 @@
1use super::*;
2use notebook::{Operation, Recovery};
3use onestore::{PageEdit, PagePosition};
4use std::collections::BTreeMap;
5
6const SOURCE: &[u8] =
7 include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
8
9fn order(source: &[u8]) -> Vec<(ExGuid, u32)> {
10 let store = Store::parse(source).unwrap();
11 let index = RevisionIndex::parse(&store).unwrap();
12 index.validate_current().unwrap();
13 let document = Document::parse(&index).unwrap();
14 document
15 .pages()
16 .unwrap()
17 .iter()
18 .map(|(sid, _)| {
19 let space = &document.spaces[sid];
20 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
21 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
22 panic!()
23 };
24 (*sid, level.unwrap_or(1))
25 })
26 .collect()
27}
28
29fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> {
30 let store = Store::parse(source).unwrap();
31 let index = RevisionIndex::parse(&store).unwrap();
32 let document = Document::parse(&index).unwrap();
33 let mut texts = BTreeMap::new();
34 for (sid, space) in &document.spaces {
35 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
36 for (oid, node) in &view.nodes {
37 if let Kind::RichText { text, .. } = &node.kind {
38 texts.insert((*sid, *oid), text.clone());
39 }
40 }
41 }
42 texts
43}
44
45#[test]
46fn atomic_page_batches_survive_reopen_with_dependent_text() {
47 let directory = tempfile::tempdir().unwrap();
48 let path = directory.path().join("pages.sqlite");
49 let cache = Replica::create(&path, SOURCE).unwrap();
50 let before = order(SOURCE);
51 let edits: Vec<_> = before[3..6]
52 .iter()
53 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
54 .collect();
55 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
56 assert!(cache.pages(SOURCE, &edits).is_err());
57 let mut expected_text = texts(SOURCE);
58 let (&(sid, oid), original) = expected_text
59 .iter()
60 .find(|(_, text)| text.starts_with("Body parent"))
61 .unwrap();
62 let changed = format!("Offline {original}");
63 expected_text.insert((sid, oid), changed);
64 cache
65 .edit_text(&cache.snapshot().unwrap(), sid, oid, 0..0, "Offline ")
66 .unwrap()
67 .unwrap();
68 let queue = cache.pending().unwrap();
69 assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits));
70 let local = cache.snapshot().unwrap();
71 drop(cache);
72 let cache = Replica::open(&path).unwrap();
73 assert_eq!(cache.pending().unwrap(), queue);
74 assert_eq!(cache.snapshot().unwrap(), local);
75 let mut server = Server::new(SOURCE);
76 for edit in queue {
77 assert!(matches!(cache.sync_once(&mut server).unwrap(),
78 Some((published, EditStatus::Published { .. })) if published == edit.id));
79 }
80 let expected = [
81 before[..3].to_vec(),
82 before[6..].to_vec(),
83 before[3..6].to_vec(),
84 ]
85 .concat();
86 assert_eq!(order(&server.durable), expected);
87 assert_eq!(texts(&server.durable), expected_text);
88 assert!(matches!(
89 cache.status(id).unwrap(),
90 Some(EditStatus::Published { .. })
91 ));
92 assert_eq!(server.publications, 2);
93}
94
95#[test]
96fn indentation_only_edits_preserve_remote_page_movement() {
97 let directory = tempfile::tempdir().unwrap();
98 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
99 let before = order(SOURCE);
100 let sid = before[4].0;
101 let id = cache
102 .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()])
103 .unwrap()
104 .unwrap();
105 let mut server = Server::new(SOURCE);
106 PreparedEdit::pages(
107 SOURCE,
108 &[PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()],
109 )
110 .unwrap()
111 .commit(&mut server)
112 .unwrap();
113 let mut expected = order(&server.durable);
114 expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3;
115 assert!(matches!(cache.sync_once(&mut server).unwrap(),
116 Some((published, EditStatus::Published { .. })) if published == id));
117 assert_eq!(order(&server.durable), expected);
118}
119
120#[test]
121fn competing_page_moves_require_current_review_and_retain_intent_identities() {
122 let directory = tempfile::tempdir().unwrap();
123 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
124 let pages = order(SOURCE);
125 let sid = pages[6].0;
126 let original = PageEdit::move_to(sid, None, 1).unwrap();
127 let id = cache
128 .pages(SOURCE, std::slice::from_ref(&original))
129 .unwrap()
130 .unwrap();
131 let mut server = Server::new(SOURCE);
132 PreparedEdit::pages(
133 SOURCE,
134 &[PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()],
135 )
136 .unwrap()
137 .commit(&mut server)
138 .unwrap();
139 let remote = server.visible.clone();
140 let local = cache.snapshot().unwrap();
141 assert_eq!(
142 cache.sync_once(&mut server).unwrap(),
143 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
144 );
145 assert_eq!(server.publications, 0);
146 assert_eq!(cache.snapshot().unwrap(), local);
147 let revised = original
148 .reposition(PagePosition::Before(Some(pages[2].0)), 1)
149 .unwrap();
150 let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap();
151 assert!(
152 cache
153 .rebase_pages_conflict(id, &local, &remote, &[fresh])
154 .is_err()
155 );
156 assert!(
157 cache
158 .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised))
159 .is_err()
160 );
161 assert!(
162 cache
163 .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised))
164 .is_err()
165 );
166 cache
167 .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised))
168 .unwrap();
169 assert!(matches!(&cache.pending().unwrap()[0].operation,
170 Operation::Pages(batch) if batch.edits == [revised.clone()]));
171 let expected = PreparedEdit::pages(&remote, &[revised]).unwrap();
172 assert!(matches!(cache.sync_once(&mut server).unwrap(),
173 Some((published, EditStatus::Published { .. })) if published == id));
174 assert_eq!(order(&server.durable), order(expected.as_bytes()));
175}
176
177#[test]
178fn one_competing_level_prevents_publication_of_the_whole_batch() {
179 let directory = tempfile::tempdir().unwrap();
180 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
181 let pages = order(SOURCE);
182 let id = cache
183 .pages(
184 SOURCE,
185 &[
186 PageEdit::set_level(pages[4].0, 3).unwrap(),
187 PageEdit::set_level(pages[5].0, 2).unwrap(),
188 ],
189 )
190 .unwrap()
191 .unwrap();
192 let local = cache.snapshot().unwrap();
193 let mut server = Server::new(SOURCE);
194 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()])
195 .unwrap()
196 .commit(&mut server)
197 .unwrap();
198 let remote = server.durable.clone();
199 assert_eq!(
200 cache.sync_once(&mut server).unwrap(),
201 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
202 );
203 assert_eq!(server.publications, 0);
204 assert_eq!(server.durable, remote);
205 assert_eq!(cache.snapshot().unwrap(), local);
206}
207
208#[test]
209fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
210 for fault in [
211 Fault::UnknownBefore,
212 Fault::UnknownAfter,
213 Fault::PanicBefore,
214 Fault::PanicAfter,
215 ] {
216 let directory = tempfile::tempdir().unwrap();
217 let path = directory.path().join("pages.sqlite");
218 let mut cache = Replica::create(&path, SOURCE).unwrap();
219 let pages = order(SOURCE);
220 let edits = [
221 PageEdit::set_level(pages[4].0, 3).unwrap(),
222 PageEdit::set_level(pages[5].0, 2).unwrap(),
223 ];
224 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
225 let local = cache.snapshot().unwrap();
226 let queue = cache.pending().unwrap();
227 let mut server = Server::new(SOURCE);
228 server.fault = fault;
229 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
230 cache.sync_once(&mut server)
231 }));
232 drop(cache);
233 cache = Replica::open(&path).unwrap();
234 let attempted = cache.status(id).unwrap().unwrap();
235 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
236 let archive = directory.path().join("recovery.sqlite");
237 cache.export_recovery(&archive).unwrap();
238 let recovery = Recovery::open(&archive).unwrap();
239 assert_eq!(recovery.pending().unwrap(), queue);
240 assert_eq!(recovery.status(id).unwrap(), Some(attempted));
241 assert_eq!(recovery.snapshot().unwrap(), local);
242 assert!(
243 cache
244 .rebase_pages_conflict(id, &local, SOURCE, &edits)
245 .is_err()
246 );
247 let result = cache.sync_once(&mut server).unwrap().unwrap();
248 assert_eq!(server.publications, 1);
249 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
250 assert!(matches!(result.1, EditStatus::Published { .. }));
251 assert_eq!(server.confirmations, 1);
252 } else {
253 assert_eq!(result, (id, attempted));
254 assert_eq!(server.confirmations, 0);
255 }
256 }
257}
258
259#[test]
260fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
261 let before = order(SOURCE);
262 let sid = before[4].0;
263 let store = Store::parse(SOURCE).unwrap();
264 let index = RevisionIndex::parse(&store).unwrap();
265 let root = index.root;
266 let doc = Document::parse(&index).unwrap();
267 let space = &doc.spaces[&root];
268 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
269 let copy = *view.nodes.iter().find(|(_, node)|
270 matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child"))
271 .unwrap().0;
272 let source =
273 onestore::replace_property_bytes(SOURCE, root, copy, 0x14001dff, &3_u32.to_le_bytes())
274 .unwrap();
275 let directory = tempfile::tempdir().unwrap();
276 let path = directory.path().join("pages.sqlite");
277 let cache = Replica::create(&path, &source).unwrap();
278 let id = cache
279 .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()])
280 .unwrap()
281 .unwrap();
282 let mut server = Server::new(&source);
283 server.fault = Fault::UnknownAfter;
284 assert!(cache.sync_once(&mut server).is_err());
285 let attempted = cache.status(id).unwrap().unwrap();
286 drop(cache);
287 let db = rusqlite::Connection::open(&path).unwrap();
288 let encoded: String = db
289 .query_row("SELECT revisions FROM attempt", [], |r| r.get(0))
290 .unwrap();
291 let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
292 assert_eq!(proofs.len(), 2);
293 let store = Store::parse(&source).unwrap();
294 let index = RevisionIndex::parse(&store).unwrap();
295 assert_eq!(
296 proofs[&root],
297 index.spaces[&root].labels[&(ExGuid::default(), 1)]
298 );
299 assert!(!index.spaces[&sid].revisions.contains_key(&proofs[&sid]));
300 drop(db);
301 let cache = Replica::open(&path).unwrap();
302 let complete = server.visible.clone();
303 server.visible = source;
304 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));
305 assert_eq!(server.confirmations, 0);
306 server.visible = complete;
307 assert!(matches!(
308 cache.sync_once(&mut server).unwrap(),
309 Some((_, EditStatus::Published { .. }))
310 ));
311 assert_eq!(server.publications, 1);
312 assert_eq!(server.confirmations, 1);
313}
314
315#[test]
316fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
317 let directory = tempfile::tempdir().unwrap();
318 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
319 let pages = order(SOURCE);
320 let id = cache
321 .pages(
322 SOURCE,
323 &[
324 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),
325 PageEdit::set_level(pages[4].0, 3).unwrap(),
326 ],
327 )
328 .unwrap()
329 .unwrap();
330 let mut server = Server::new(SOURCE);
331 PreparedEdit::pages(
332 SOURCE,
333 &[PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()],
334 )
335 .unwrap()
336 .commit(&mut server)
337 .unwrap();
338 assert_eq!(
339 cache.sync_once(&mut server).unwrap(),
340 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
341 );
342 assert_eq!(server.publications, 0);
343}
344
345#[test]
346fn convergent_page_indentation_requires_confirmation_without_republishing() {
347 let directory = tempfile::tempdir().unwrap();
348 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
349 let sid = order(SOURCE)[4].0;
350 let id = cache
351 .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
352 .unwrap()
353 .unwrap();
354 let mut server = Server::new(SOURCE);
355 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
356 .unwrap()
357 .commit(&mut server)
358 .unwrap();
359 server.fault = Fault::Confirm;
360 assert!(cache.sync_once(&mut server).is_err());
361 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
362 assert_eq!(server.publications, 0);
363 assert!(matches!(cache.sync_once(&mut server).unwrap(),
364 Some((published, EditStatus::Published { .. })) if published == id));
365 assert_eq!(server.publications, 0);
366 assert_eq!(server.confirmations, 2);
367}
368
369#[test]
370fn schema_ten_migration_preserves_multi_space_uncertainty() {
371 let directory = tempfile::tempdir().unwrap();
372 let path = directory.path().join("pages.sqlite");
373 let cache = Replica::create(&path, SOURCE).unwrap();
374 let page = onestore::PageCreation::new(None, Some("Migration"), "Author").unwrap();
375 let id = cache.create_page(SOURCE, &page).unwrap().unwrap();
376 let mut server = Server::new(SOURCE);
377 server.fault = Fault::UnknownBefore;
378 assert!(cache.sync_once(&mut server).is_err());
379 let local = cache.snapshot().unwrap();
380 let queue = cache.pending().unwrap();
381 let status = cache.status(id).unwrap();
382 drop(cache);
383 let db = rusqlite::Connection::open(&path).unwrap();
384 db.pragma_update(None, "user_version", 10).unwrap();
385 let proofs: String = db
386 .query_row("SELECT revisions FROM attempt", [], |r| r.get(0))
387 .unwrap();
388 drop(db);
389 let cache = Replica::open(&path).unwrap();
390 assert_eq!(cache.snapshot().unwrap(), local);
391 assert_eq!(cache.pending().unwrap(), queue);
392 assert_eq!(cache.status(id).unwrap(), status);
393 assert_eq!(
394 cache.sync_once(&mut server).unwrap(),
395 Some((id, status.unwrap()))
396 );
397 assert_eq!(server.publications, 1);
398 drop(cache);
399 let db = rusqlite::Connection::open(&path).unwrap();
400 assert_eq!(
401 db.query_row("SELECT revisions FROM attempt", [], |r| r
402 .get::<_, String>(0))
403 .unwrap(),
404 proofs
405 );
406 assert_eq!(
407 db.pragma_query_value(None, "user_version", |r| r.get::<_, u32>(0))
408 .unwrap(),
409 11
410 );
411}
412
413#[test]
414fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
415 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
416 .join("../../corpus/page-lifecycle/movement");
417 let provenance: serde_json::Value =
418 serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap();
419 let phases = provenance["cold"].as_object().unwrap();
420 assert_eq!(phases.len(), 9);
421 let original = order(SOURCE);
422 let original_text = texts(SOURCE);
423 let (&(text_space, text_object), body) = original_text
424 .iter()
425 .find(|(_, text)| text.starts_with("Body parent"))
426 .unwrap();
427 let mut expected_text = original_text.clone();
428 expected_text.insert((text_space, text_object), format!("Offline {body}"));
429 let mut counts = [0; 2];
430 for mode in ["indent", "group"] {
431 for phase in phases.keys() {
432 let directory = tempfile::tempdir().unwrap();
433 let path = directory.path().join("pages.sqlite");
434 let cache = Replica::create(&path, SOURCE).unwrap();
435 let edits = if mode == "indent" {
436 vec![PageEdit::set_level(original[4].0, 3).unwrap()]
437 } else {
438 original[3..6]
439 .iter()
440 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
441 .collect()
442 };
443 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
444 let text_id = cache
445 .edit_text(
446 &cache.snapshot().unwrap(),
447 text_space,
448 text_object,
449 0..0,
450 "Offline ",
451 )
452 .unwrap()
453 .unwrap();
454 let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap();
455 let mut server = Server::new(&native);
456 let local = cache.snapshot().unwrap();
457 let expected_conflict = if mode == "indent" {
458 matches!(
459 phase.as_str(),
460 "04-subpage-move" | "07-promoted-root" | "08-collapsed-group-move"
461 )
462 } else {
463 !matches!(
464 phase.as_str(),
465 "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two"
466 )
467 };
468 let first = cache.sync_once(&mut server).unwrap().unwrap();
469 assert_eq!(first.0, id);
470 let mut reviewed = edits.clone();
471 if expected_conflict {
472 assert_eq!(
473 first.1,
474 EditStatus::Conflict(ConflictKind::StructureChanged),
475 "{mode}:{phase}"
476 );
477 assert_eq!(server.publications, 0);
478 assert_eq!(cache.snapshot().unwrap(), local);
479 if mode == "indent" && phase == "08-collapsed-group-move" {
480 reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap();
481 }
482 cache
483 .rebase_pages_conflict(id, &local, &native, &reviewed)
484 .unwrap();
485 assert!(matches!(cache.sync_once(&mut server).unwrap(),
486 Some((published, EditStatus::Published { .. })) if published == id));
487 } else {
488 assert!(
489 matches!(first.1, EditStatus::Published { .. }),
490 "{mode}:{phase}"
491 );
492 }
493 counts[usize::from(expected_conflict)] += 1;
494 drop(cache);
495 let cache = Replica::open(&path).unwrap();
496 assert!(matches!(cache.sync_once(&mut server).unwrap(),
497 Some((published, EditStatus::Published { .. })) if published == text_id));
498 assert!(cache.pending().unwrap().is_empty());
499 let mut expected = order(&native);
500 for edit in &reviewed {
501 let at = expected.iter().position(|p| p.0 == edit.space()).unwrap();
502 expected[at].1 = edit.level();
503 if let PagePosition::Before(before) = edit.position() {
504 let page = expected.remove(at);
505 let at = before.map_or(expected.len(), |sid| {
506 expected.iter().position(|p| p.0 == sid).unwrap()
507 });
508 expected.insert(at, page);
509 }
510 }
511 assert_eq!(order(&server.durable), expected, "{mode}:{phase}");
512 assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}");
513 assert_eq!(
514 server.publications,
515 1 + usize::from(expected != order(&native))
516 );
517 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") {
518 let output = std::path::Path::new(&output);
519 assert!(output.is_absolute());
520 let path = output.join(format!("{mode}-{phase}"));
521 std::fs::create_dir_all(&path).unwrap();
522 std::fs::write(path.join("Lifecycle.one"), &server.durable).unwrap();
523 std::fs::write(
524 path.join("manifest.json"),
525 serde_json::to_vec_pretty(
526 &serde_json::json!({"mode": mode, "phase": phase, "original": edits,
527 "reviewed": reviewed, "conflict": expected_conflict,
528 "publications": server.publications, "confirmations": server.confirmations,
529 "page_receipt": format!("{:?}", cache.status(id).unwrap()),
530 "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}),
531 )
532 .unwrap(),
533 )
534 .unwrap();
535 }
536 }
537 }
538 assert_eq!(counts, [9, 9]);
539}
540
541#[test]
542fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
543 let mut source = onestore::create_section("pages.one", "Sentinel", "Author").unwrap();
544 let mut created = Vec::new();
545 for _ in 0..36 {
546 let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap();
547 source = PreparedEdit::create_page(&source, &page)
548 .unwrap()
549 .as_bytes()
550 .to_vec();
551 created.push(page);
552 }
553 let mut expected = vec![order(&source)[0]];
554 let mut expected_text = texts(&source);
555 let directory = tempfile::tempdir().unwrap();
556 let mut queues = Vec::new();
557 for (actor, group) in created.chunks_exact(3).enumerate() {
558 let path = directory.path().join(format!("{actor}.sqlite"));
559 let cache = Replica::create(&path, &source).unwrap();
560 let edits = [
561 PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(),
562 PageEdit::set_level(group[2].space(), 2).unwrap(),
563 ];
564 cache.pages(&source, &edits).unwrap().unwrap();
565 let body = format!("Actor {actor} 🦀 é");
566 let insertion =
567 onestore::Insertion::outline(group[1].object(), 36.0, 36.0, &body, "Author").unwrap();
568 cache
569 .insert(&cache.snapshot().unwrap(), group[1].space(), &insertion)
570 .unwrap()
571 .unwrap();
572 expected.extend([
573 (group[1].space(), 1),
574 (group[0].space(), 1),
575 (group[2].space(), 2),
576 ]);
577 expected_text.insert((group[1].space(), insertion.text_object()), body);
578 queues.push((path, cache.pending().unwrap()));
579 }
580 let mut server = Server::new(&source);
581 for (path, queue) in &queues {
582 let cache = Replica::open(path).unwrap();
583 assert_eq!(cache.pending().unwrap(), *queue);
584 for edit in queue {
585 assert!(matches!(cache.sync_once(&mut server).unwrap(),
586 Some((published, EditStatus::Published { .. })) if published == edit.id));
587 }
588 assert!(cache.pending().unwrap().is_empty());
589 }
590 assert_eq!(server.publications, 24);
591 assert_eq!(order(&server.durable), expected);
592 assert_eq!(texts(&server.durable), expected_text);
593 for (path, queue) in &queues {
594 let cache = Replica::open(path).unwrap();
595 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
596 assert_eq!(order(&cache.snapshot().unwrap()), expected);
597 for edit in queue {
598 assert!(matches!(
599 cache.status(edit.id).unwrap(),
600 Some(EditStatus::Published { .. })
601 ));
602 }
603 }
604 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_CLIENT_OUTPUT") {
605 let output = std::path::Path::new(&output);
606 assert!(output.is_absolute());
607 std::fs::create_dir_all(output).unwrap();
608 std::fs::write(output.join("pages.one"), &server.durable).unwrap();
609 let operations: Vec<_> = queues
610 .iter()
611 .map(|(_, queue)| {
612 queue
613 .iter()
614 .map(|edit| {
615 serde_json::json!({"id": edit.id, "space": edit.space,
616 "operation": edit.operation})
617 })
618 .collect::<Vec<_>>()
619 })
620 .collect();
621 std::fs::write(
622 output.join("manifest.json"),
623 serde_json::to_vec_pretty(
624 &serde_json::json!({"operations": operations, "publications": server.publications,
625 "expected_order": expected, "pages": 37}),
626 )
627 .unwrap(),
628 )
629 .unwrap();
630 }
631}
crates/notebook/tests/sync/tree.rs+454-654
......@@ -1,11 +1,25 @@
1//! Paragraph tree edits expressed as page-model saves: moves, deletions, their
2//! reconciliation against native remote images, and emptied table cells.
3
14use super::*;
2use onestore::{Insertion, TreeEdit};
5use model_schedule::move_subtree;
6use onestore::page::{Paragraph, ParagraphContent, TableCell};
7
8/// Saves an edited model of `space`, reaching content that outline helpers cannot.
9fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> {
10 let source = cache.snapshot().unwrap();
11 let mut page = page_of(&source, space);
12 edit(&mut page);
13 cache
14 .save(&source, space, &page, model_ops::AUTHOR)
15 .unwrap()
16}
317
418#[test]
519fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() {
620 let mut random = 1940_u64;
7 for _ in 0..60 {
8 let input: Vec<_> = (0..384)
21 for _ in 0..24 {
22 let input: Vec<_> = (0..256)
923 .map(|_| {
1024 random ^= random << 13;
1125 random ^= random >> 7;
......@@ -13,246 +27,202 @@ fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() {
1327 random as u8
1428 })
1529 .collect();
16 tree_schedule::run(&input);
30 model_schedule::run(&input);
1731 }
1832}
1933
34/// The one-outline fixture with three further sibling paragraphs.
2035fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) {
21 let (mut source, sid, outline, first, text) = super::outline::fixture();
22 let mut paragraphs = [first; 4];
23 let mut texts = [text; 4];
36 let (source, space, outline, text) = super::fixture();
37 let mut page = page_of(&source, space);
38 let mut anchor = text;
2439 for at in 1..4 {
25 let insertion =
26 Insertion::paragraph(outline, None, &format!("Sibling {at}"), "Author").unwrap();
27 source = PreparedEdit::insert(&source, sid, &insertion)
28 .unwrap()
29 .as_bytes()
30 .to_vec();
31 paragraphs[at] = insertion.object();
32 texts[at] = insertion.text_object();
40 anchor = model_ops::insert_after(&mut page, anchor, &format!("Sibling {at}")).1;
3341 }
34 (source, sid, outline, paragraphs, texts)
42 let source = PreparedEdit::page(&source, space, &page, "Author")
43 .unwrap()
44 .as_bytes()
45 .to_vec();
46 let stored = page_of(&source, space);
47 let siblings = &outline_of(&stored, outline).paragraphs;
48 assert_eq!(siblings.len(), 4);
49 let ids = std::array::from_fn(|at| siblings[at].id);
50 let texts = std::array::from_fn(|at| siblings[at].text().unwrap().id);
51 (source, space, outline, ids, texts)
3552}
3653
37fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec<ExGuid> {
38 let store = Store::parse(source).unwrap();
39 let index = RevisionIndex::parse(&store).unwrap();
40 index.validate_current().unwrap();
41 let document = Document::parse(&index).unwrap();
42 let space = &document.spaces[&sid];
43 space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object]
44 .children
45 .clone()
54/// Paragraph identities of an outline in model order.
55fn order(bytes: &[u8], space: ExGuid, outline: ExGuid) -> Vec<ExGuid> {
56 outline_of(&page_of(bytes, space), outline)
57 .paragraphs
58 .iter()
59 .map(|paragraph| paragraph.id)
60 .collect()
61}
62
63/// Adds a plain paragraph as the last child of `parent`.
64fn insert_child(page: &mut Page, parent: ExGuid, value: &str) {
65 for outline in outlines_mut(page) {
66 let Some(at) = outline.paragraphs.iter().position(|p| p.id == parent) else {
67 continue;
68 };
69 let mut child = model_ops::fresh_paragraph(&outline.paragraphs[at], value);
70 child.parent = Some(parent);
71 child.level = outline.paragraphs[at].level + 1;
72 let level = outline.paragraphs[at].level;
73 let mut end = at + 1;
74 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
75 end += 1;
76 }
77 outline.paragraphs.insert(end, child);
78 return;
79 }
80 panic!("the parent paragraph is on the page");
4681}
4782
4883#[test]
4984fn move_preserves_remote_content_and_dependent_edits_through_reopen() {
50 let (source, sid, outline, paragraphs, texts) = fixture();
85 let (source, space, outline, paragraphs, texts) = fixture();
5186 let directory = tempfile::tempdir().unwrap();
5287 let path = directory.path().join("cache.sqlite");
5388 let cache = Replica::create(&path, &source).unwrap();
54 let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap();
55 let id = cache.tree(&source, sid, &intent).unwrap().unwrap();
56 let dependent = cache
57 .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ")
58 .unwrap()
59 .unwrap();
89 let id = save(&cache, texts[0], |page| {
90 move_subtree(page, paragraphs[0], None, None);
91 replace_text(page, texts[0], 0..0, "Local ");
92 })
93 .unwrap()
94 .unwrap();
6095 let local = cache.snapshot().unwrap();
6196 let queue = cache.pending().unwrap();
6297 drop(cache);
6398 let cache = Replica::open(&path).unwrap();
6499 assert_eq!(cache.pending().unwrap(), queue);
65100 assert_eq!(cache.snapshot().unwrap(), local);
66 let edited = PreparedEdit::format(
67 &source,
68 sid,
69 texts[0],
70 0..8,
71 &[onestore::TextAttribute::Bold(true)],
72 )
73 .unwrap();
74 let descendant =
75 Insertion::paragraph(paragraphs[0], None, "New remote child", "Remote").unwrap();
76 let remote = PreparedEdit::insert(edited.as_bytes(), sid, &descendant).unwrap();
77 let mut server = Server::new(remote.as_bytes());
78 for expected in [id, dependent] {
79 assert!(
80 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected)
81 );
82 }
101 let mut server = Server::new(&remote_with(&source, space, |page| {
102 insert_child(page, paragraphs[0], "New remote child");
103 restyle(page, texts[1], 0..7, |format| format.bold = Some(true));
104 }));
105 published(&cache, &mut server, id);
106 let durable = page_of(&server.durable, space);
107 let published = outline_of(&durable, outline);
83108 assert_eq!(
84 children(&server.durable, sid, outline),
109 published
110 .paragraphs
111 .iter()
112 .map(|p| p.id)
113 .collect::<Vec<_>>()[..4],
85114 [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]]
86115 );
87 assert_eq!(
88 children(&server.durable, sid, paragraphs[0]),
89 [descendant.object()]
90 );
91 let node = super::outline::node(&server.durable, sid, texts[0]);
92 assert_eq!(node["kind"]["text"], "Local Original 🦀 é");
93 let store = Store::parse(&server.durable).unwrap();
94 let index = RevisionIndex::parse(&store).unwrap();
95 let document = Document::parse(&index).unwrap();
96 let space = &document.spaces[&sid];
97 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
98 assert_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true));
116 let child = &published.paragraphs[4];
117 assert_eq!(child.parent, Some(paragraphs[0]));
118 assert_eq!(child.text().unwrap().text.text(), "New remote child");
119 assert_eq!(text_of(&durable, texts[0]), "Local Original 🦀 é");
120 let sibling = &paragraph_with(&durable, texts[1])
121 .unwrap()
122 .text()
123 .unwrap()
124 .text;
125 assert_eq!(sibling.spans()[0].format.bold, Some(true));
99126 assert!(cache.pending().unwrap().is_empty());
100 assert_eq!(server.publications, 2);
127 assert_eq!(server.publications, 1);
101128}
102129
103130#[test]
104fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() {
105 let (source, sid, _, _, texts) = fixture();
106 let directory = tempfile::tempdir().unwrap();
107 let path = directory.path().join("cache.sqlite");
108 let cache = Replica::create(&path, &source).unwrap();
109 cache
110 .format(
111 &source,
112 sid,
113 texts[0],
114 0..8,
115 &[onestore::TextAttribute::Bold(true)],
116 )
117 .unwrap();
118 let split = onestore::ParagraphSplit::new(texts[0], 4, "Author").unwrap();
119 cache
120 .split(&cache.snapshot().unwrap(), sid, &split)
121 .unwrap();
122 cache
123 .tree(
124 &cache.snapshot().unwrap(),
125 sid,
126 &TreeEdit::delete(split.object(), "Author").unwrap(),
127 )
128 .unwrap();
129 cache
130 .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ")
131 .unwrap();
132 let queue = cache.pending().unwrap();
133 drop(cache);
134 let mut server = Server::new(&source);
135 for intent in queue {
136 let cache = Replica::open(&path).unwrap();
137 assert!(
138 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id)
139 );
140 }
141 assert_eq!(
142 super::outline::node(&server.durable, sid, texts[0])["kind"]["text"],
143 "Local Orig"
144 );
145}
146
147#[test]
148fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() {
131fn native_empty_child_list_normalization_merges_with_a_local_deletion() {
149132 let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one");
150 let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one");
151 let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!(
133 let native = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one");
134 let intent: serde_json::Value = serde_json::from_str(include_str!(
152135 "../../../../corpus/outline-edit/empty-children/intent.json"
153136 ))
154137 .unwrap();
155 let store = Store::parse(source).unwrap();
156 let index = RevisionIndex::parse(&store).unwrap();
157 let document = Document::parse(&index).unwrap();
158 let space = &document.spaces[&sid];
159 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
160 let tail = view.nodes[&intent.object()].content[0];
161 let (outline_id, outline) = view
162 .nodes
163 .iter()
164 .find(|(_, node)| node.children.contains(&intent.object()))
138 let space: ExGuid = intent[0].as_str().unwrap().parse().unwrap();
139 let object: ExGuid = intent[1]["object"].as_str().unwrap().parse().unwrap();
140 let page = page_of(source, space);
141 let outline = body_outlines(&page)
142 .into_iter()
143 .find(|outline| outline.paragraphs.iter().any(|p| p.id == object))
165144 .unwrap();
166 let left = view.nodes[&outline.children[0]].content[0];
167 for changed in [false, true] {
168 let directory = tempfile::tempdir().unwrap();
169 let path = directory.path().join("cache.sqlite");
170 let cache = Replica::create(&path, source).unwrap();
171 let deletion = cache.tree(source, sid, &intent).unwrap().unwrap();
172 let dependent = cache
173 .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ")
174 .unwrap()
175 .unwrap();
176 let local = cache.snapshot().unwrap();
177 drop(cache);
178 let cache = Replica::open(&path).unwrap();
179 let mut server = if changed {
180 let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap();
181 Server::new(edited.as_bytes())
182 } else {
183 Server::new(remote)
184 };
185 if changed {
186 assert_eq!(
187 cache.sync_once(&mut server).unwrap(),
188 Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged)))
189 );
190 assert_eq!(cache.snapshot().unwrap(), local);
191 assert_eq!(cache.pending().unwrap().len(), 2);
192 assert_eq!(server.publications, 0);
193 } else {
194 for expected in [deletion, dependent] {
195 assert!(
196 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected)
197 );
198 }
199 assert!(cache.pending().unwrap().is_empty());
200 assert_eq!(server.publications, 2);
201 assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object()));
202 let node = super::outline::node(&server.durable, sid, left);
203 assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local "));
204 }
145 let deleted = outline
146 .paragraphs
147 .iter()
148 .find(|p| p.id == object)
149 .unwrap()
150 .text()
151 .unwrap()
152 .id;
153 let left = outline.paragraphs[0].text().unwrap().id;
154 let outline = outline.id;
155 let concurrent: Vec<String> = body_outlines(&page_of(native, space))
156 .into_iter()
157 .flat_map(|outline| outline.paragraphs.iter())
158 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
159 .collect();
160 let directory = tempfile::tempdir().unwrap();
161 let path = directory.path().join("cache.sqlite");
162 let cache = Replica::create(&path, source).unwrap();
163 let change = |page: &mut Page| {
164 delete_paragraph(page, deleted);
165 replace_text(page, left, 0..0, "Local ");
166 };
167 let id = save(&cache, left, change).unwrap().unwrap();
168 drop(cache);
169 let cache = Replica::open(&path).unwrap();
170 let mut server = Server::new(native);
171 assert!(matches!(
172 cache.sync_once(&mut server).unwrap(),
173 Some((published, EditStatus::Published { .. })) if published == id
174 ));
175 assert_eq!(server.publications, 1);
176 assert!(cache.pending().unwrap().is_empty());
177 let durable = page_of(&server.durable, space);
178 assert!(!order(&server.durable, space, outline).contains(&object));
179 assert!(text_of(&durable, left).starts_with("Local "));
180 let published: Vec<String> = body_outlines(&durable)
181 .into_iter()
182 .flat_map(|outline| outline.paragraphs.iter())
183 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
184 .collect();
185 for text in concurrent {
186 assert!(
187 text == "🐈" || published.iter().any(|value| value.ends_with(&text)),
188 "the merge keeps concurrent remote content: {text}"
189 );
205190 }
206191}
207192
208193#[test]
209194fn deletion_requires_review_of_remote_content_and_preserves_later_work() {
210 let (source, sid, outline, paragraphs, texts) = fixture();
211 let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap();
212 let source = PreparedEdit::insert(&source, sid, &child)
195 let (source, space, outline, paragraphs, texts) = fixture();
196 let mut page = page_of(&source, space);
197 insert_child(&mut page, paragraphs[0], "Descendant");
198 let source = PreparedEdit::page(&source, space, &page, "Author")
213199 .unwrap()
214200 .as_bytes()
215201 .to_vec();
216 for remote in [
217 PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ")
218 .unwrap()
219 .as_bytes()
220 .to_vec(),
221 PreparedEdit::text(&source, sid, child.text_object(), 0..0, "Remote ")
222 .unwrap()
223 .as_bytes()
224 .to_vec(),
225 PreparedEdit::format(
226 &source,
227 sid,
228 child.text_object(),
229 0..10,
230 &[onestore::TextAttribute::Italic(true)],
231 )
232 .unwrap()
233 .as_bytes()
234 .to_vec(),
235 PreparedEdit::insert(
236 &source,
237 sid,
238 &Insertion::paragraph(paragraphs[0], None, "New child", "Remote").unwrap(),
239 )
202 let child = outline_of(&page_of(&source, space), outline).paragraphs[1]
203 .text()
240204 .unwrap()
241 .as_bytes()
242 .to_vec(),
243 ] {
205 .id;
206 let remotes: [Change<Page>; 4] = [
207 Box::new(move |page| replace_text(page, texts[0], 0..0, "Remote ")),
208 Box::new(move |page| replace_text(page, child, 0..0, "Remote ")),
209 Box::new(move |page| {
210 restyle(page, child, 0..10, |format| format.italic = Some(true));
211 }),
212 Box::new(move |page| insert_child(page, paragraphs[0], "New child")),
213 ];
214 for remote in remotes {
244215 let directory = tempfile::tempdir().unwrap();
245216 let path = directory.path().join("cache.sqlite");
246217 let cache = Replica::create(&path, &source).unwrap();
247 let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap();
248 let id = cache.tree(&source, sid, &intent).unwrap().unwrap();
249 let dependent = cache
250 .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ")
251 .unwrap()
252 .unwrap();
218 let change = |page: &mut Page| {
219 delete_paragraph(page, texts[0]);
220 replace_text(page, texts[1], 0..0, "Local ");
221 };
222 let id = save(&cache, texts[1], change).unwrap().unwrap();
253223 let queue = cache.pending().unwrap();
254224 let local = cache.snapshot().unwrap();
255 let mut server = Server::new(&remote);
225 let mut server = Server::new(&remote_with(&source, space, remote));
256226 assert_eq!(
257227 cache.sync_once(&mut server).unwrap(),
258228 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
......@@ -260,37 +230,26 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() {
260230 assert_eq!(server.publications, 0);
261231 assert_eq!(cache.pending().unwrap(), queue);
262232 assert_eq!(cache.snapshot().unwrap(), local);
263 assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err());
264 assert!(cache.rebase_tree_conflict(id, &local, &source).is_err());
233 let observed = cache.remote_snapshot().unwrap();
265234 assert!(
266235 cache
267 .rebase_tree_conflict(dependent, &local, &remote)
236 .review_page(id, &source, &observed, &page_of(&observed, space))
268237 .is_err()
269238 );
270 assert!(cache.rebase_conflict(id, &local, &remote, 0..0).is_err());
271 let archive = directory.path().join("recovery.sqlite");
272 cache.export_recovery(&archive).unwrap();
273 let recovery = notebook::Recovery::open(&archive).unwrap();
274 assert_eq!(recovery.pending().unwrap(), queue);
275 assert_eq!(recovery.status(id).unwrap(), cache.status(id).unwrap());
276 assert!(recovery.snapshot().unwrap() == local);
277239 drop(cache);
278240 let cache = Replica::open(&path).unwrap();
279241 assert_eq!(
280242 cache.status(id).unwrap(),
281243 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
282244 );
283 cache.rebase_tree_conflict(id, &local, &remote).unwrap();
284 assert_eq!(cache.pending().unwrap()[1], queue[1]);
285 assert_eq!(cache.snapshot().unwrap(), local);
286 for expected in [id, dependent] {
287 assert!(
288 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected)
289 );
290 }
291 assert_eq!(children(&server.durable, sid, outline), paragraphs[1..]);
245 review(&cache, id, texts[1], change).unwrap();
246 published(&cache, &mut server, id);
292247 assert_eq!(
293 super::outline::node(&server.durable, sid, texts[1])["kind"]["text"],
248 order(&server.durable, space, outline),
249 paragraphs[1..].to_vec()
250 );
251 assert_eq!(
252 text_of(&page_of(&server.durable, space), texts[1]),
294253 "Local Sibling 1"
295254 );
296255 }
......@@ -298,247 +257,121 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() {
298257
299258#[test]
300259fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {
301 let (source, sid, outline, p, texts) = fixture();
302 let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap();
303 let cases = [
304 (
305 PreparedEdit::insert(&source, sid, &insertion)
306 .unwrap()
307 .as_bytes()
308 .to_vec(),
309 None,
310 ),
311 (
312 PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[1], "Remote").unwrap())
313 .unwrap()
314 .as_bytes()
315 .to_vec(),
316 None,
317 ),
260 let (source, space, outline, paragraphs, texts) = fixture();
261 // The published order of the fixture's paragraphs, or a conflict.
262 let cases: [(Change<Page>, Option<&[usize]>); 5] = [
318263 (
319 PreparedEdit::tree(
320 &source,
321 sid,
322 &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(),
323 )
324 .unwrap()
325 .as_bytes()
326 .to_vec(),
327 None,
264 Box::new(move |page| {
265 model_ops::insert_after(page, texts[0], "New sibling");
266 }),
267 Some(&[1, 2, 3, 0]),
328268 ),
329269 (
330 PreparedEdit::tree(
331 &source,
332 sid,
333 &TreeEdit::move_to(p[0], outline, Some(p[2]), "Remote").unwrap(),
334 )
335 .unwrap()
336 .as_bytes()
337 .to_vec(),
338 Some(ConflictKind::StructureChanged),
270 Box::new(move |page| delete_paragraph(page, texts[1])),
271 Some(&[2, 3, 0]),
339272 ),
340273 (
341 PreparedEdit::tree(
342 &source,
343 sid,
344 &TreeEdit::move_to(p[0], p[1], None, "Remote").unwrap(),
345 )
346 .unwrap()
347 .as_bytes()
348 .to_vec(),
349 Some(ConflictKind::StructureChanged),
274 Box::new(move |page| move_subtree(page, paragraphs[1], None, None)),
275 Some(&[2, 3, 0, 1]),
350276 ),
351277 (
352 PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap())
353 .unwrap()
354 .as_bytes()
355 .to_vec(),
356 Some(ConflictKind::TargetUnavailable),
278 Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)),
279 None,
357280 ),
281 (Box::new(move |page| delete_paragraph(page, texts[0])), None),
358282 ];
359283 for (remote, expected) in cases {
360284 let directory = tempfile::tempdir().unwrap();
361285 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
362 let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap();
363 let id = cache.tree(&source, sid, &intent).unwrap().unwrap();
364 let mut server = Server::new(&remote);
286 let id = save(&cache, texts[0], |page| {
287 move_subtree(page, paragraphs[0], None, None)
288 })
289 .unwrap()
290 .unwrap();
291 let mut server = Server::new(&remote_with(&source, space, remote));
365292 let result = cache.sync_once(&mut server).unwrap().unwrap();
366293 assert_eq!(result.0, id);
367 if let Some(kind) = expected {
368 assert_eq!(result.1, EditStatus::Conflict(kind));
294 let Some(expected) = expected else {
295 assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged));
369296 assert_eq!(server.publications, 0);
370 } else {
371 assert!(matches!(result.1, EditStatus::Published { .. }));
372 assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0]));
373 assert_eq!(
374 super::outline::node(&server.durable, sid, texts[0])["kind"]["text"],
375 "Original 🦀 é"
376 );
377 }
378 }
379}
380
381#[test]
382fn moved_destination_and_missing_anchor_retain_conflicts_but_unrelated_text_does_not() {
383 let (source, sid, outline, p, texts) = fixture();
384 let child = Insertion::paragraph(p[1], None, "Anchor", "Author").unwrap();
385 let source = PreparedEdit::insert(&source, sid, &child)
386 .unwrap()
387 .as_bytes()
388 .to_vec();
389 let intent = TreeEdit::move_to(p[0], p[1], Some(child.object()), "Offline").unwrap();
390 let cases = [
391 (
392 PreparedEdit::tree(
393 &source,
394 sid,
395 &TreeEdit::move_to(p[1], p[2], None, "Remote").unwrap(),
396 )
397 .unwrap()
398 .as_bytes()
399 .to_vec(),
400 ConflictKind::StructureChanged,
401 ),
402 (
403 PreparedEdit::tree(
404 &source,
405 sid,
406 &TreeEdit::move_to(child.object(), outline, None, "Remote").unwrap(),
407 )
408 .unwrap()
409 .as_bytes()
410 .to_vec(),
411 ConflictKind::UnsupportedEdit,
412 ),
413 ];
414 for (remote, kind) in cases {
415 let directory = tempfile::tempdir().unwrap();
416 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
417 let id = cache.tree(&source, sid, &intent).unwrap().unwrap();
418 let mut server = Server::new(&remote);
297 continue;
298 };
299 assert!(matches!(result.1, EditStatus::Published { .. }));
300 let published: Vec<ExGuid> = order(&server.durable, space, outline)
301 .into_iter()
302 .filter(|id| paragraphs.contains(id))
303 .collect();
304 let expected: Vec<ExGuid> = expected.iter().map(|at| paragraphs[*at]).collect();
305 assert_eq!(published, expected);
419306 assert_eq!(
420 cache.sync_once(&mut server).unwrap(),
421 Some((id, EditStatus::Conflict(kind)))
307 text_of(&page_of(&server.durable, space), texts[2]),
308 "Sibling 2"
422309 );
423 assert_eq!(server.publications, 0);
424 }
425 let directory = tempfile::tempdir().unwrap();
426 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
427 let id = cache
428 .tree(&source, sid, &TreeEdit::delete(p[0], "Offline").unwrap())
429 .unwrap()
430 .unwrap();
431 let remote = PreparedEdit::text(&source, sid, texts[1], 0..0, "Remote ").unwrap();
432 let mut server = Server::new(remote.as_bytes());
433 assert!(
434 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
435 );
436 assert_eq!(
437 super::outline::node(&server.durable, sid, texts[1])["kind"]["text"],
438 "Remote Sibling 1"
439 );
440}
441
442#[test]
443fn unknown_tree_attempts_require_revision_evidence_even_when_effect_is_visible() {
444 let (source, sid, outline, p, _) = fixture();
445 for deletion in [false, true] {
446 for fault in [
447 Fault::UnknownBefore,
448 Fault::UnknownAfter,
449 Fault::Before,
450 Fault::Committed,
451 ] {
452 let directory = tempfile::tempdir().unwrap();
453 let path = directory.path().join("cache.sqlite");
454 let cache = Replica::create(&path, &source).unwrap();
455 let intent = if deletion {
456 TreeEdit::delete(p[0], "Offline")
457 } else {
458 TreeEdit::move_to(p[0], outline, None, "Offline")
459 }
460 .unwrap();
461 let id = cache.tree(&source, sid, &intent).unwrap().unwrap();
462 let mut server = Server::new(&source);
463 server.fault = fault;
464 assert!(cache.sync_once(&mut server).is_err());
465 let local = cache.snapshot().unwrap();
466 let before = cache.status(id).unwrap();
467 drop(cache);
468 let cache = Replica::open(&path).unwrap();
469 assert_eq!(cache.status(id).unwrap(), before);
470 if matches!(fault, Fault::UnknownBefore) {
471 let independent = if deletion {
472 TreeEdit::delete(p[0], "Other")
473 } else {
474 TreeEdit::move_to(p[0], outline, None, "Other")
475 }
476 .unwrap();
477 server = Server::new(
478 PreparedEdit::tree(&source, sid, &independent)
479 .unwrap()
480 .as_bytes(),
481 );
482 assert!(
483 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::AwaitingConfirmation { .. })) if n == id)
484 );
485 assert!(
486 cache
487 .rebase_tree_conflict(id, &local, &server.visible)
488 .is_err()
489 );
490 assert_eq!(server.publications, 0);
491 assert_eq!(cache.snapshot().unwrap(), local);
492 } else if matches!(fault, Fault::Committed) {
493 assert!(matches!(
494 cache.status(id).unwrap(),
495 Some(EditStatus::Published { .. })
496 ));
497 } else {
498 assert!(
499 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
500 );
501 assert_eq!(
502 server.publications,
503 if matches!(fault, Fault::Before) { 2 } else { 1 }
504 );
505 let saved = cache.snapshot().unwrap();
506 assert!(saved[..212] == server.durable[..212]);
507 assert!(saved[252..] == server.durable[252..]);
508 assert_eq!(
509 children(&saved, sid, outline),
510 children(&server.durable, sid, outline)
511 );
512 }
513 }
514310 }
515311}
516312
517313#[test]
518314fn independently_satisfied_move_confirms_without_republication() {
519 let (source, sid, outline, p, _) = fixture();
315 let (source, space, _, paragraphs, texts) = fixture();
520316 let directory = tempfile::tempdir().unwrap();
521317 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
522 let id = cache
523 .tree(
524 &source,
525 sid,
526 &TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(),
527 )
528 .unwrap()
529 .unwrap();
530 let remote = PreparedEdit::tree(
531 &source,
532 sid,
533 &TreeEdit::move_to(p[0], outline, None, "Remote").unwrap(),
534 )
318 let id = save(&cache, texts[0], |page| {
319 move_subtree(page, paragraphs[0], None, None)
320 })
321 .unwrap()
535322 .unwrap();
536 let mut server = Server::new(remote.as_bytes());
537 assert!(
538 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
539 );
540 assert_eq!(server.publications, 0);
541 assert_eq!(server.confirmations, 1);
323 let mut server = Server::new(&remote_with(&source, space, |page| {
324 move_subtree(page, paragraphs[0], None, None);
325 }));
326 published(&cache, &mut server, id);
327 assert_eq!((server.publications, server.confirmations), (0, 1));
328}
329
330fn cells(page: &Page) -> Vec<&TableCell> {
331 body_outlines(page)
332 .into_iter()
333 .flat_map(|outline| outline.paragraphs.iter())
334 .filter_map(|paragraph| match &paragraph.content {
335 ParagraphContent::Table(table) => Some(table),
336 _ => None,
337 })
338 .flat_map(|table| table.rows.iter())
339 .flat_map(|row| row.cells.iter())
340 .collect()
341}
342
343fn cell_of(page: &Page, id: ExGuid) -> &TableCell {
344 cells(page)
345 .into_iter()
346 .find(|cell| cell.id == id)
347 .expect("the cell is on the page")
348}
349
350fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell {
351 outlines_mut(page)
352 .into_iter()
353 .flat_map(|outline| outline.paragraphs.iter_mut())
354 .filter_map(|paragraph| match &mut paragraph.content {
355 ParagraphContent::Table(table) => Some(table),
356 _ => None,
357 })
358 .flat_map(|table| table.rows.iter_mut())
359 .flat_map(|row| row.cells.iter_mut())
360 .find(|cell| cell.id == id)
361 .expect("the cell is on the page")
362}
363
364/// Moves a cell's sole paragraph to the end of another cell, or deletes it.
365fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) {
366 let moved = cell_mut(page, cell).paragraphs.remove(0);
367 let Some(destination) = destination else {
368 return;
369 };
370 let destination = cell_mut(page, destination);
371 let mut moved = moved;
372 moved.level = destination.paragraphs[0].level;
373 moved.parent = None;
374 destination.paragraphs.push(moved);
542375}
543376
544377#[test]
......@@ -548,266 +381,233 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(
548381 let store = Store::parse(source).unwrap();
549382 let index = RevisionIndex::parse(&store).unwrap();
550383 let document = Document::parse(&index).unwrap();
551 let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| {
552 let space = &document.spaces[sid];
553 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
554 matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Delete sole cell paragraph")
555 }).unwrap();
556 let space = &document.spaces[&sid];
557 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
558 let mut pending = vec![page];
559 let mut selected = None;
560 let mut other = None;
561 while let Some(id) = pending.pop() {
562 let node = &view.nodes[&id];
563 pending.extend(&node.children);
564 pending.extend(&node.content);
565 if matches!(node.kind, Kind::Cell { .. }) {
566 let paragraph = node.children[0];
567 let text = view.nodes[&paragraph].content[0];
568 if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target "))
569 {
570 selected = Some((id, paragraph));
571 } else {
572 other = Some((id, text));
573 }
574 }
575 }
576 let (cell, target) = selected.unwrap();
577 let (other_cell, other_text) = other.unwrap();
384 let (space, page) = document
385 .pages()
386 .unwrap()
387 .into_iter()
388 .find_map(|(space, _)| {
389 let page = Page::from_space(&document, space).unwrap();
390 (page.title == "Delete sole cell paragraph").then_some((space, page))
391 })
392 .unwrap();
393 let other = document
394 .pages()
395 .unwrap()
396 .into_iter()
397 .find(|(other, _)| *other != space)
398 .unwrap()
399 .0;
400 let text = |cell: &TableCell| cell.paragraphs[0].text().unwrap().id;
401 let cell = cells(&page)
402 .into_iter()
403 .find(|cell| {
404 cell.paragraphs[0]
405 .text()
406 .unwrap()
407 .text
408 .text()
409 .starts_with("Target ")
410 })
411 .unwrap();
412 let (cell, target) = (cell.id, text(cell));
413 let neighbour = cells(&page)
414 .into_iter()
415 .find(|other| other.id != cell)
416 .unwrap();
417 let (neighbour, neighbour_text) = (neighbour.id, text(neighbour));
578418 for move_out in [false, true] {
579 for competing_child in [false, true] {
419 for competing in [false, true] {
580420 let directory = tempfile::tempdir().unwrap();
581421 let path = directory.path().join("cell.sqlite");
582422 let cache = Replica::create(&path, source).unwrap();
583 let intent = if move_out {
584 TreeEdit::move_to(target, other_cell, None, "Offline")
585 } else {
586 TreeEdit::delete(target, "Offline")
587 }
423 let id = save_page(&cache, space, |page| {
424 empty_cell(page, cell, move_out.then_some(neighbour));
425 })
588426 .unwrap();
589 let id = cache.tree(source, sid, &intent).unwrap().unwrap();
590 let local = cache.snapshot().unwrap();
591 let replacement = children(&local, sid, cell);
592 assert_eq!(replacement.len(), 1);
593 assert_ne!(replacement[0], target);
594 let replacement_text: ExGuid =
595 super::outline::node(&local, sid, replacement[0])["content"][0]
596 .as_str()
597 .unwrap()
598 .parse()
599 .unwrap();
600 let dependent = cache
601 .edit_text(&local, sid, replacement_text, 0..0, "Local replacement")
602 .unwrap()
603 .unwrap();
604427 let local = cache.snapshot().unwrap();
605428 let queue = cache.pending().unwrap();
429 {
430 let page = page_of(&local, space);
431 let paragraphs = &cell_of(&page, cell).paragraphs;
432 assert_eq!(paragraphs.len(), 1);
433 assert_ne!(paragraphs[0].text().unwrap().id, target);
434 assert!(paragraphs[0].text().unwrap().text.text().is_empty());
435 }
606436 drop(cache);
607437 let cache = Replica::open(&path).unwrap();
608438 assert_eq!(cache.pending().unwrap(), queue);
609439 assert_eq!(cache.snapshot().unwrap(), local);
610 let remote = if competing_child {
611 PreparedEdit::insert(
612 source,
613 sid,
614 &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(),
615 )
616 .unwrap()
617 .as_bytes()
618 .to_vec()
440 let mut server = Server::new(&if competing {
441 remote_with(source, space, |page| {
442 let target = cell_mut(page, cell);
443 let mut sibling =
444 model_ops::fresh_paragraph(&target.paragraphs[0], "Remote sibling");
445 sibling.level = target.paragraphs[0].level;
446 target.paragraphs.push(sibling);
447 })
619448 } else {
620 PreparedEdit::text(source, sid, other_text, 0..0, "Remote ")
621 .unwrap()
622 .as_bytes()
623 .to_vec()
624 };
625 let mut server = Server::new(&remote);
626 if competing_child {
449 remote_with(source, other, |page| {
450 let text = body_outlines(page)[0].paragraphs[0].text().unwrap().id;
451 replace_text(page, text, 0..0, "Remote ");
452 })
453 });
454 if competing {
627455 assert_eq!(
628456 cache.sync_once(&mut server).unwrap(),
629 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
457 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
630458 );
631459 assert_eq!(server.publications, 0);
632 assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err());
633460 assert_eq!(cache.pending().unwrap(), queue);
634461 assert_eq!(cache.snapshot().unwrap(), local);
635 } else {
636 for expected in [id, dependent] {
637 assert!(
638 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected)
639 );
640 }
641 assert_eq!(children(&server.durable, sid, cell), replacement);
642 assert_eq!(
643 super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"],
644 "Local replacement"
645 );
646 assert_eq!(
647 super::outline::node(&server.durable, sid, other_text)["kind"]["text"],
648 "Remote Other cell"
462 continue;
463 }
464 published(&cache, &mut server, id);
465 let replacement = {
466 let durable = page_of(&server.durable, space);
467 let paragraphs = &cell_of(&durable, cell).paragraphs;
468 assert_eq!(paragraphs.len(), 1);
469 assert_ne!(paragraphs[0].text().unwrap().id, target);
470 paragraphs[0].text().unwrap().id
471 };
472 let edit = save_page(&cache, space, |page| {
473 let cell = cell_mut(page, cell);
474 let text = cell.paragraphs[0].text_mut().unwrap();
475 text.text = Paragraph::new(
476 "Local replacement".into(),
477 text.text.format_at(0).unwrap().clone(),
649478 );
650 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") {
651 let output = std::path::PathBuf::from(output)
652 .join(if move_out { "cell-move" } else { "cell-delete" })
653 .join("candidate");
654 std::fs::create_dir_all(&output).unwrap();
655 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
656 }
479 })
480 .unwrap();
481 drop(cache);
482 let cache = Replica::open(&path).unwrap();
483 published(&cache, &mut server, edit);
484 let durable = page_of(&server.durable, space);
485 let paragraphs = &cell_of(&durable, cell).paragraphs;
486 assert_eq!(paragraphs.len(), 1);
487 assert_eq!(paragraphs[0].text().unwrap().id, replacement);
488 assert_eq!(
489 paragraphs[0].text().unwrap().text.text(),
490 "Local replacement"
491 );
492 let published = cell_of(&durable, neighbour);
493 assert_eq!(
494 published.paragraphs.len(),
495 1 + usize::from(move_out),
496 "the moved paragraph joins its destination"
497 );
498 assert_eq!(published.paragraphs[0].text().unwrap().id, neighbour_text);
499 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") {
500 let output = std::path::PathBuf::from(output)
501 .join(if move_out { "cell-move" } else { "cell-delete" })
502 .join("candidate");
503 std::fs::create_dir_all(&output).unwrap();
504 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
657505 }
658506 }
659507 }
660508}
661509
510/// The move or deletion each native fixture page is reconciled against.
511fn native_change(
512 name: &str,
513 outline: ExGuid,
514 target: (ExGuid, ExGuid),
515 anchor: Option<ExGuid>,
516) -> Change<Page> {
517 let name = name.to_owned();
518 Box::new(move |page| {
519 let present = paragraph_with(page, target.1).is_some();
520 match name.as_str() {
521 "Move leaf down" | "Move subtree down" if present => {
522 move_subtree(page, target.0, None, None);
523 }
524 "Move subtree up" if present => {
525 move_subtree(page, target.0, None, anchor);
526 }
527 "Delete outline" | "Move outline" | "Resize outline" | "Automatic outline size" => {
528 page.objects
529 .retain(|object| !matches!(object, PageObject::Outline(o) if o.id == outline));
530 }
531 _ if present => {
532 delete_paragraph(page, target.1);
533 // An outline cannot survive without a paragraph.
534 page.objects.retain(
535 |object| !matches!(object, PageObject::Outline(o) if o.paragraphs.is_empty()),
536 );
537 }
538 _ => {}
539 }
540 })
541}
542
662543#[test]
663544fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() {
664 let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one");
665 let native = include_bytes!("../../../../corpus/outline-edit/after/notebook/synthetic.one");
666 let store = Store::parse(source).unwrap();
667 let index = RevisionIndex::parse(&store).unwrap();
668 let document = Document::parse(&index).unwrap();
669 let mut server = Server::new(native);
670 let mut counts = [0; 3];
545 let mut server = Server::new(NATIVE);
671546 let mut records = Vec::new();
672 for (sid, page) in document.pages().unwrap() {
673 let space = &document.spaces[&sid];
674 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
675 let Kind::Metadata {
676 title: Some(name), ..
677 } = &view.nodes[&view.roots[&2]].kind
678 else {
679 continue;
680 };
681 if name == "Unicode rich text" {
682 continue;
683 }
684 let outlines: Vec<_> = view.nodes[&page]
685 .children
686 .iter()
687 .copied()
688 .filter(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
689 .collect();
690 if outlines.len() != 2 {
691 continue;
692 }
693 let outline = outlines[0];
694 let other = view.nodes[&outlines[1]].children[0];
695 let dependent_text = view.nodes[&other].content[0];
696 let mut pending = vec![outline];
697 let mut paragraphs = std::collections::BTreeMap::new();
698 while let Some(id) = pending.pop() {
699 let node = &view.nodes[&id];
700 pending.extend(&node.children);
701 if let Some(text) = node.content.first()
702 && let Kind::RichText { text, .. } = &view.nodes[text].kind
703 {
704 paragraphs.insert(text.as_str(), id);
705 }
706 }
707 let target = *paragraphs
547 for (name, space, outline, target, dependent) in native_pages(BEFORE) {
548 let page = page_of(BEFORE, space);
549 let paragraphs = &outline_of(&page, outline).paragraphs;
550 let target = (
551 paragraphs
552 .iter()
553 .find(|p| p.text().is_some_and(|text| text.id == target))
554 .unwrap()
555 .id,
556 target,
557 );
558 let anchor = paragraphs
708559 .iter()
709 .find(|(text, _)| text.starts_with("Target "))
710 .unwrap()
711 .1;
712 let (intent, conflict) = match name.as_str() {
713 "Move leaf down" | "Move subtree down" => {
714 (TreeEdit::move_to(target, outline, None, "Offline"), None)
715 }
716 "Move subtree up" => (
717 TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"),
718 None,
719 ),
720 "Indent subtree" | "Outdent subtree" => (
721 TreeEdit::delete(target, "Offline"),
722 Some(ConflictKind::StructureChanged),
723 ),
724 "Collapse subtree" | "Expand subtree" => (
725 TreeEdit::delete(target, "Offline"),
726 Some(ConflictKind::ContentChanged),
727 ),
728 "Delete leaf" | "Delete subtree" | "Delete only paragraph" => (
729 TreeEdit::delete(target, "Offline"),
730 Some(ConflictKind::TargetUnavailable),
731 ),
732 "Delete outline" => (
733 TreeEdit::delete(outline, "Offline"),
734 Some(ConflictKind::TargetUnavailable),
735 ),
736 "Move outline" | "Resize outline" | "Automatic outline size" => (
737 TreeEdit::delete(outline, "Offline"),
738 Some(ConflictKind::ContentChanged),
739 ),
740 _ => panic!("{name}"),
560 .find(|p| {
561 p.text()
562 .is_some_and(|text| text.text.text().starts_with("Anchor"))
563 })
564 .map(|paragraph| paragraph.id);
565 let change = native_change(&name, outline, target, anchor);
566 let save_both = |page: &mut Page| {
567 change(page);
568 replace_text(page, dependent, 0..0, "Offline ");
741569 };
742 let intent = intent.unwrap();
743570 let directory = tempfile::tempdir().unwrap();
744571 let path = directory.path().join("native.sqlite");
745 let cache = Replica::create(&path, source).unwrap();
746 let id = cache.tree(source, sid, &intent).unwrap().unwrap();
747 let dependent = cache
748 .edit_text(
749 &cache.snapshot().unwrap(),
750 sid,
751 dependent_text,
752 0..0,
753 "Offline ",
754 )
755 .unwrap()
756 .unwrap();
572 let cache = Replica::create(&path, BEFORE).unwrap();
573 let id = save(&cache, dependent, save_both).unwrap().unwrap();
757574 let local = cache.snapshot().unwrap();
758575 let queue = cache.pending().unwrap();
759 let old_publications = server.publications;
760 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
761 assert_eq!(actual, id);
762 if let Some(kind) = conflict {
763 assert_eq!(status, EditStatus::Conflict(kind), "{name}");
764 assert_eq!(server.publications, old_publications);
765 assert_eq!(cache.pending().unwrap(), queue);
766 assert!(cache.snapshot().unwrap() == local);
767 drop(cache);
768 let cache = Replica::open(&path).unwrap();
769 assert_eq!(cache.status(id).unwrap(), Some(status));
770 if kind == ConflictKind::TargetUnavailable {
771 counts[2] += 1;
772 assert!(
773 cache
774 .rebase_tree_conflict(id, &local, &server.visible)
775 .is_err()
776 );
777 records.push(serde_json::json!({"page": name, "space": sid, "result": "retained"}));
778 continue;
576 let merged = match cache.sync_once(&mut server).unwrap().unwrap() {
577 (actual, EditStatus::Published { .. }) => {
578 assert_eq!(actual, id, "{name}");
579 true
779580 }
780 counts[1] += 1;
781 cache
782 .rebase_tree_conflict(id, &local, &server.visible)
783 .unwrap();
784 assert_eq!(cache.pending().unwrap()[1], queue[1]);
785 for expected in [id, dependent] {
786 assert!(
787 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected)
581 result => {
582 assert_eq!(
583 result,
584 (id, EditStatus::Conflict(ConflictKind::ContentChanged)),
585 "{name}"
788586 );
587 assert_eq!(cache.pending().unwrap(), queue);
588 assert_eq!(cache.snapshot().unwrap(), local);
589 drop(cache);
590 let cache = Replica::open(&path).unwrap();
591 review(&cache, id, dependent, save_both).unwrap();
592 published(&cache, &mut server, id);
593 false
789594 }
790 } else {
791 counts[0] += 1;
792 assert!(matches!(status, EditStatus::Published { .. }), "{name}");
793 assert_eq!(server.publications, old_publications);
794 assert!(
795 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent)
796 );
797 }
798 assert_eq!(
799 super::outline::node(&server.durable, sid, dependent_text)["kind"]["text"],
800 format!(
801 "Offline {}",
802 match &view.nodes[&dependent_text].kind {
803 Kind::RichText { text, .. } => text,
804 _ => panic!(),
805 }
806 )
595 };
596 let durable = page_of(&server.durable, space);
597 assert!(
598 text_of(&durable, dependent).starts_with("Offline "),
599 "{name} keeps its dependent text"
807600 );
808 records.push(serde_json::json!({"page": name, "space": sid, "result": if conflict.is_some() { "reviewed" } else { "converged" }}));
601 let mut reapplied = durable.clone();
602 change(&mut reapplied);
603 assert_eq!(reapplied, durable, "{name} keeps its reconciled change");
604 records.push((name, if merged { "merged" } else { "reviewed" }));
809605 }
810 assert_eq!(counts, [3, 7, 4]);
606 let reviewed = records
607 .iter()
608 .filter(|(_, result)| *result == "reviewed")
609 .count();
610 assert_eq!((records.len(), reviewed), (14, 7), "{records:?}");
811611 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") {
812612 let output = std::path::PathBuf::from(output);
813613 std::fs::create_dir_all(output.join("candidate")).unwrap();
crates/notebook/tests/sync_formatting.rs created+499
......@@ -0,0 +1,499 @@
1//! Formatting and insertion as page-model differences: what reconciles with an independent
2//! remote change, what conflicts, and what a reviewed conflict publishes.
3
4use notebook::{ConflictKind, EditStatus, Replica};
5use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,
7 document::{Document, Format},
8 page::{Page, PageObject},
9};
10
11#[path = "support/server.rs"]
12mod server;
13use server::{Fault, Server};
14#[path = "support/model_ops.rs"]
15mod model_ops;
16use model_ops::{
17 AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, review,
18 save,
19};
20
21const OUTLINES: &[u8] =
22 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
23const PAGE: &str = "Move subtree down";
24
25fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
26 let store = Store::parse(bytes).unwrap();
27 let index = RevisionIndex::parse(&store).unwrap();
28 let document = Document::parse(&index).unwrap();
29 document
30 .pages()
31 .unwrap()
32 .into_iter()
33 .find_map(|(space, _)| {
34 let page = Page::from_space(&document, space).unwrap();
35 (page.title == title).then_some((space, page))
36 })
37 .unwrap()
38}
39
40/// The text objects of a page that carry no hidden field, and so accept range formatting.
41fn plain(page: &Page) -> Vec<ExGuid> {
42 page.objects
43 .iter()
44 .flat_map(|object| match object {
45 PageObject::Outline(outline) => outline.paragraphs.iter().collect(),
46 _ => Vec::new(),
47 })
48 .filter_map(|paragraph| {
49 let text = paragraph.text()?;
50 (!text.text.text().contains('\u{fddf}')).then_some(text.id)
51 })
52 .collect()
53}
54
55fn text_of(page: &Page, text: ExGuid) -> String {
56 paragraph_with(page, text)
57 .unwrap()
58 .text()
59 .unwrap()
60 .text
61 .text()
62 .to_owned()
63}
64
65/// The format of every UTF-16 unit of a text object.
66fn formats(page: &Page, text: ExGuid) -> Vec<Format> {
67 let content = &paragraph_with(page, text).unwrap().text().unwrap().text;
68 let mut out = Vec::new();
69 let mut from = 0;
70 for span in content.spans() {
71 let units = content.text()[from..span.end].encode_utf16().count();
72 out.extend(std::iter::repeat_n(span.format.clone(), units));
73 from = span.end;
74 }
75 out
76}
77
78/// A remote holding the fixture page with `change` applied by a native author.
79fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
80 let mut page = page_of(OUTLINES, space);
81 change(&mut page);
82 Server::new(
83 PreparedEdit::page(OUTLINES, space, &page, "Native author")
84 .unwrap()
85 .as_bytes(),
86 )
87}
88
89fn cache(directory: &tempfile::TempDir) -> Replica {
90 Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap()
91}
92
93#[test]
94fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
95 let (space, page) = page_titled(OUTLINES, PAGE);
96 let ids = plain(&page);
97 let directory = tempfile::tempdir().unwrap();
98 let cache = cache(&directory);
99 let id = save(&cache, ids[0], |page| {
100 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
101 })
102 .unwrap()
103 .unwrap();
104 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
105 assert!(
106 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
107 );
108 let published = page_of(&server.durable, space);
109 assert!(
110 formats(&published, ids[0])
111 .iter()
112 .all(|format| format.bold == Some(true))
113 );
114 assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling");
115 assert_eq!(cache.snapshot().unwrap(), server.durable);
116}
117
118#[test]
119fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {
120 let (space, page) = page_titled(OUTLINES, PAGE);
121 let ids = plain(&page);
122 for typed in [false, true] {
123 let directory = tempfile::tempdir().unwrap();
124 let cache = cache(&directory);
125 let id = save(&cache, ids[0], |page| {
126 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
127 })
128 .unwrap()
129 .unwrap();
130 let local = cache.snapshot().unwrap();
131 let mut server = remote_with(space, |page| {
132 if typed {
133 replace_text(page, ids[0], 0..0, "Remote ");
134 } else {
135 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
136 }
137 });
138 assert_eq!(
139 cache.sync_once(&mut server).unwrap(),
140 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
141 "typed {typed}"
142 );
143 assert_eq!(server.publications, 0);
144 assert_eq!(cache.snapshot().unwrap(), local);
145 }
146}
147
148#[test]
149fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {
150 let (space, page) = page_titled(OUTLINES, PAGE);
151 let ids = plain(&page);
152 let directory = tempfile::tempdir().unwrap();
153 let path = directory.path().join("competing.sqlite");
154 let cache = Replica::create(&path, OUTLINES).unwrap();
155 let id = save(&cache, ids[0], |page| {
156 restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0));
157 })
158 .unwrap()
159 .unwrap();
160 let pending = cache.pending().unwrap();
161 let mut server = remote_with(space, |page| {
162 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
163 });
164 for _ in 0..2 {
165 assert_eq!(
166 cache.sync_once(&mut server).unwrap(),
167 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
168 );
169 }
170 assert_eq!(cache.pending().unwrap(), pending);
171 drop(cache);
172 let cache = Replica::open(&path).unwrap();
173 assert_eq!(
174 cache.status(id).unwrap(),
175 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
176 );
177 review(&cache, id, ids[0], |page| {
178 restyle(page, ids[0], 0..6, |format| format.font_size = Some(14.0));
179 })
180 .unwrap();
181 assert!(matches!(
182 cache.sync_once(&mut server).unwrap(),
183 Some((_, EditStatus::Published { .. }))
184 ));
185 let published = formats(&page_of(&server.durable, space), ids[0]);
186 assert!(
187 published
188 .iter()
189 .all(|format| format.font_size == Some(14.0))
190 );
191 assert_eq!(
192 published
193 .iter()
194 .filter(|format| format.italic == Some(true))
195 .count(),
196 3
197 );
198}
199
200#[test]
201fn every_visual_attribute_publishes_through_a_page_model_save() {
202 let (space, page) = page_titled(OUTLINES, PAGE);
203 let ids = plain(&page);
204 for (name, apply) in [
205 (
206 "bold",
207 (|f: &mut Format| f.bold = Some(true)) as fn(&mut Format),
208 ),
209 ("italic", |f| f.italic = Some(true)),
210 ("underline", |f| f.underline = Some(true)),
211 ("strike", |f| f.strike = Some(true)),
212 ("superscript", |f| f.superscript = Some(true)),
213 ("subscript", |f| f.subscript = Some(true)),
214 ("font", |f| f.font = Some("Georgia".into())),
215 ("font size", |f| f.font_size = Some(21.0)),
216 ("color", |f| f.color = Some(0x0056_3412)),
217 ("highlight", |f| f.highlight = Some(0x0006_0504)),
218 ] {
219 let directory = tempfile::tempdir().unwrap();
220 let cache = cache(&directory);
221 let id = save(&cache, ids[0], |page| restyle(page, ids[0], 2..6, apply))
222 .unwrap()
223 .unwrap();
224 let mut server = Server::new(OUTLINES);
225 assert!(
226 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id),
227 "{name}"
228 );
229 let mut expected = formats(&page_of(OUTLINES, space), ids[0]);
230 assert_eq!(expected.len(), 6);
231 for format in &mut expected[2..] {
232 apply(format);
233 }
234 assert_eq!(
235 formats(&page_of(&server.durable, space), ids[0]),
236 expected,
237 "{name}"
238 );
239 }
240}
241
242#[test]
243fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone() {
244 let (space, page) = page_titled(OUTLINES, PAGE);
245 let ids = plain(&page);
246 let lengths: Vec<u32> = ids
247 .iter()
248 .map(|id| u32::try_from(text_of(&page, *id).encode_utf16().count()).unwrap())
249 .collect();
250 let (mut published, mut conflicts) = (0, 0);
251 for seed in 1_u64..=64 {
252 let mut state = seed;
253 let mut next = || {
254 state = state
255 .wrapping_mul(6364136223846793005)
256 .wrapping_add(1442695040888963407);
257 usize::try_from(state >> 33).unwrap()
258 };
259 let target = next() % ids.len();
260 let start = u32::try_from(next()).unwrap() % lengths[target];
261 let end = start + 1 + u32::try_from(next()).unwrap() % (lengths[target] - start);
262 let attribute: fn(&mut Format) = match seed % 3 {
263 0 => |format| format.bold = Some(true),
264 1 => |format| format.font_size = Some(21.0),
265 _ => |format| format.color = Some(0x0012_3456),
266 };
267 let remote: Vec<usize> = (0..1 + next() % 3).map(|_| next() % ids.len()).collect();
268 let directory = tempfile::tempdir().unwrap();
269 let cache = cache(&directory);
270 let id = save(&cache, ids[target], |page| {
271 restyle(page, ids[target], start..end, attribute);
272 })
273 .unwrap()
274 .unwrap();
275 let local = cache.snapshot().unwrap();
276 let mut server = remote_with(space, |page| {
277 for (step, &at) in remote.iter().enumerate() {
278 if step % 2 == 0 {
279 replace_text(page, ids[at], 0..0, "\u{2022}");
280 } else {
281 restyle(page, ids[at], 0..1, |format| format.underline = Some(true));
282 }
283 }
284 });
285 let outcome = cache.sync_once(&mut server).unwrap().unwrap();
286 assert_eq!(outcome.0, id, "seed {seed}");
287 if remote.contains(&target) {
288 conflicts += 1;
289 assert_eq!(
290 outcome.1,
291 EditStatus::Conflict(ConflictKind::ContentChanged),
292 "seed {seed}"
293 );
294 assert_eq!(server.publications, 0, "seed {seed}");
295 assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}");
296 continue;
297 }
298 published += 1;
299 assert!(
300 matches!(outcome.1, EditStatus::Published { .. }),
301 "seed {seed}: {outcome:?}"
302 );
303 let page = page_of(&server.durable, space);
304 let mut expected: Vec<Format> = formats(&page_of(OUTLINES, space), ids[target]);
305 for format in &mut expected[start as usize..end as usize] {
306 attribute(format);
307 }
308 assert_eq!(formats(&page, ids[target]), expected, "seed {seed}");
309 for &at in &remote {
310 assert!(
311 text_of(&page, ids[at]).starts_with('\u{2022}')
312 || formats(&page, ids[at])[0].underline == Some(true),
313 "seed {seed}"
314 );
315 }
316 assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}");
317 }
318 assert!(
319 published >= 16 && conflicts >= 16,
320 "{published} published, {conflicts} conflicts"
321 );
322}
323
324#[test]
325fn offline_insertions_survive_reopen_and_carry_their_formatting() {
326 let (space, page) = page_titled(OUTLINES, PAGE);
327 let ids = plain(&page);
328 let directory = tempfile::tempdir().unwrap();
329 let path = directory.path().join("insert.sqlite");
330 let mut cache = Replica::create(&path, OUTLINES).unwrap();
331 let id = save(&cache, ids[0], |page| {
332 insert_outline(page, 72.0, 144.0, "Offline outline");
333 insert_after(page, ids[0], "Offline paragraph 🦀");
334 })
335 .unwrap()
336 .unwrap();
337 let local = cache.snapshot().unwrap();
338 let pending = cache.pending().unwrap();
339 drop(cache);
340 cache = Replica::open(&path).unwrap();
341 assert_eq!(cache.snapshot().unwrap(), local);
342 assert_eq!(cache.pending().unwrap(), pending);
343 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
344 assert!(
345 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
346 );
347 let published = page_of(&server.durable, space);
348 let inserted = plain(&published)
349 .into_iter()
350 .find(|id| text_of(&published, *id) == "Offline paragraph 🦀")
351 .unwrap();
352 assert!(
353 plain(&published)
354 .iter()
355 .any(|id| text_of(&published, *id) == "Offline outline")
356 );
357 assert_eq!(text_of(&published, ids[3]), "Remote Trailing sibling");
358 let styled = save(&cache, inserted, |page| {
359 restyle(page, inserted, 8..17, |format| format.bold = Some(true));
360 })
361 .unwrap()
362 .unwrap();
363 assert!(
364 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == styled)
365 );
366 let styles = formats(&page_of(&server.durable, space), inserted);
367 for (offset, format) in styles.iter().enumerate() {
368 assert_eq!(format.bold == Some(true), (8..17).contains(&offset));
369 }
370 assert_eq!(cache.snapshot().unwrap(), server.durable);
371}
372
373#[test]
374fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
375 let (space, page) = page_titled(OUTLINES, PAGE);
376 let ids = plain(&page);
377 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
378 let directory = tempfile::tempdir().unwrap();
379 let path = directory.path().join("uncertain.sqlite");
380 let cache = Replica::create(&path, OUTLINES).unwrap();
381 let id = save(&cache, ids[0], |page| {
382 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
383 })
384 .unwrap()
385 .unwrap();
386 let local = cache.snapshot().unwrap();
387 let mut server = Server::new(OUTLINES);
388 server.fault = fault;
389 assert!(cache.sync_once(&mut server).is_err());
390 let state = cache.status(id).unwrap().unwrap();
391 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
392 drop(cache);
393 let cache = Replica::open(&path).unwrap();
394 assert_eq!(cache.status(id).unwrap(), Some(state));
395 if matches!(fault, Fault::UnknownBefore) {
396 for _ in 0..3 {
397 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state)));
398 }
399 assert_eq!(server.publications, 1);
400 assert_eq!(server.durable, OUTLINES);
401 assert_eq!(cache.snapshot().unwrap(), local);
402 continue;
403 }
404 assert!(matches!(
405 cache.sync_once(&mut server).unwrap(),
406 Some((_, EditStatus::Published { .. }))
407 ));
408 assert_eq!(server.publications, 1);
409 assert!(cache.pending().unwrap().is_empty());
410 assert!(
411 formats(&page_of(&server.durable, space), ids[0])
412 .iter()
413 .all(|format| format.bold == Some(true))
414 );
415 }
416}
417
418#[test]
419fn a_format_the_remote_already_carries_is_confirmed_without_republishing() {
420 let (space, page) = page_titled(OUTLINES, PAGE);
421 let ids = plain(&page);
422 let directory = tempfile::tempdir().unwrap();
423 let cache = cache(&directory);
424 let id = save(&cache, ids[0], |page| {
425 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
426 })
427 .unwrap()
428 .unwrap();
429 let mut server = remote_with(space, |page| {
430 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
431 });
432 server.durable = OUTLINES.to_vec();
433 server.fault = Fault::Confirm;
434 assert!(cache.sync_once(&mut server).is_err());
435 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
436 assert_eq!(server.durable, OUTLINES);
437 assert!(
438 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
439 );
440 assert_eq!((server.publications, server.confirmations), (0, 2));
441 assert!(cache.pending().unwrap().is_empty());
442 let published = page_of(&cache.snapshot().unwrap(), space);
443 assert_eq!(published, page_of(&server.durable, space));
444 assert!(
445 formats(&published, ids[0])
446 .iter()
447 .all(|format| format.bold == Some(true))
448 );
449}
450
451#[test]
452fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
453 let (space, page) = page_titled(OUTLINES, PAGE);
454 let ids = plain(&page);
455 let mut after = page.clone();
456 restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true));
457 let directory = tempfile::tempdir().unwrap();
458 let cache = cache(&directory);
459 let id = cache
460 .save(OUTLINES, space, &after, AUTHOR)
461 .unwrap()
462 .unwrap();
463 let mut server = Server::new(OUTLINES);
464 server.fault = Fault::UnknownAfter;
465 assert!(cache.sync_once(&mut server).is_err());
466 let attempt = cache.status(id).unwrap();
467 assert!(matches!(
468 attempt,
469 Some(EditStatus::AwaitingConfirmation { .. })
470 ));
471 let partial = remote_with(space, |page| {
472 restyle(page, ids[0], 0..3, |format| format.bold = Some(true));
473 });
474 server.visible.clone_from(&partial.visible);
475 assert_eq!(
476 cache.sync_once(&mut server).unwrap(),
477 attempt.map(|state| (id, state))
478 );
479 assert_eq!(server.confirmations, 0);
480 let complete = remote_with(space, |page| {
481 restyle(page, ids[0], 0..6, |format| format.bold = Some(true));
482 });
483 server.visible.clone_from(&complete.visible);
484 assert_eq!(page_of(&server.visible, space), after);
485 assert!(
486 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
487 );
488 assert_eq!((server.publications, server.confirmations), (1, 1));
489 assert!(cache.pending().unwrap().is_empty());
490}
491
492#[test]
493fn a_formatted_save_of_an_unchanged_model_queues_nothing() {
494 let (space, page) = page_titled(OUTLINES, PAGE);
495 let directory = tempfile::tempdir().unwrap();
496 let cache = cache(&directory);
497 assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None);
498 assert!(cache.pending().unwrap().is_empty());
499}
crates/notebook/tests/sync_model.rs created+534
......@@ -0,0 +1,534 @@
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
4use notebook::{ConflictKind, EditStatus, Replica};
5use onestore::{ExGuid, PreparedEdit, RevisionIndex, Store, document::Document};
6
7#[path = "support/server.rs"]
8mod server;
9use server::*;
10#[path = "support/model_ops.rs"]
11mod model_ops;
12
13use notebook::{Operation, PageIntent};
14use onestore::page::{Page, PageObject};
15
16const OUTLINES: &[u8] =
17 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
18
19fn page_of(bytes: &[u8], space: ExGuid) -> Page {
20 let store = Store::parse(bytes).unwrap();
21 let index = RevisionIndex::parse(&store).unwrap();
22 let document = Document::parse(&index).unwrap();
23 Page::from_space(&document, space).unwrap()
24}
25
26fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) {
27 let store = Store::parse(bytes).unwrap();
28 let index = RevisionIndex::parse(&store).unwrap();
29 let document = Document::parse(&index).unwrap();
30 document
31 .pages()
32 .unwrap()
33 .into_iter()
34 .find_map(|(sid, _)| {
35 let page = Page::from_space(&document, sid).unwrap();
36 (page.title == title).then_some((sid, page))
37 })
38 .unwrap()
39}
40
41/// Appends `suffix` to the first body paragraph of the first body outline.
42fn append(page: &mut Page, suffix: &str) {
43 let outline = page
44 .objects
45 .iter_mut()
46 .find_map(|object| match object {
47 PageObject::Outline(outline) => Some(outline),
48 _ => None,
49 })
50 .unwrap();
51 let text = outline.paragraphs[0].text_mut().unwrap();
52 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
53 let format = text.text.format_at(end).unwrap().clone();
54 text.text
55 .apply(onestore::page::text::Edit {
56 range: end..end,
57 replacement: onestore::page::Paragraph::new(suffix.into(), format),
58 })
59 .unwrap();
60}
61
62fn first_text(page: &Page) -> String {
63 page.objects
64 .iter()
65 .find_map(|object| match object {
66 PageObject::Outline(outline) => {
67 Some(outline.paragraphs[0].text().unwrap().text.text().to_owned())
68 }
69 _ => None,
70 })
71 .unwrap()
72}
73
74#[test]
75fn a_saved_page_model_publishes_and_reads_back() {
76 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
77 let directory = tempfile::tempdir().unwrap();
78 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
79 append(&mut after, " saved 🦀");
80 let id = cache
81 .save(OUTLINES, space, &after, "Model author")
82 .unwrap()
83 .unwrap();
84 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
85 assert_eq!(page_of(&cache.snapshot().unwrap(), space), after);
86 let pending = cache.pending().unwrap();
87 assert_eq!(pending.len(), 1);
88 let Operation::Page(PageIntent {
89 before,
90 after: stored,
91 author,
92 }) = &pending[0].operation
93 else {
94 panic!()
95 };
96 assert_eq!(
97 (before, stored, author.as_str()),
98 (&page_of(OUTLINES, space), &after, "Model author")
99 );
100 let mut server = Server::new(OUTLINES);
101 assert!(
102 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
103 );
104 assert_eq!(page_of(&server.durable, space), after);
105 assert_eq!(server.publications, 1);
106 assert!(cache.pending().unwrap().is_empty());
107 assert_eq!(
108 cache
109 .save(&cache.snapshot().unwrap(), space, &after, "Model author")
110 .unwrap(),
111 None
112 );
113}
114
115#[test]
116fn saves_before_an_attempt_coalesce_into_one_publication() {
117 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
118 let directory = tempfile::tempdir().unwrap();
119 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
120 append(&mut after, " one");
121 let id = cache
122 .save(OUTLINES, space, &after, "Model author")
123 .unwrap()
124 .unwrap();
125 let mut later = page_of(&cache.snapshot().unwrap(), space);
126 append(&mut later, " two");
127 assert_eq!(
128 cache
129 .save(&cache.snapshot().unwrap(), space, &later, "Model author")
130 .unwrap(),
131 Some(id)
132 );
133 let pending = cache.pending().unwrap();
134 assert_eq!(pending.len(), 1);
135 let Operation::Page(intent) = &pending[0].operation else {
136 panic!()
137 };
138 assert_eq!(intent.before, page_of(OUTLINES, space));
139 assert_eq!(intent.after, later);
140 let mut server = Server::new(OUTLINES);
141 assert!(
142 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
143 );
144 assert_eq!(server.publications, 1);
145 assert_eq!(
146 first_text(&page_of(&server.durable, space)),
147 first_text(&later)
148 );
149 let mut third = page_of(&cache.snapshot().unwrap(), space);
150 append(&mut third, " three");
151 let next = cache
152 .save(&cache.snapshot().unwrap(), space, &third, "Model author")
153 .unwrap()
154 .unwrap();
155 assert!(next > id);
156}
157
158#[test]
159fn an_attempted_save_is_never_rewritten() {
160 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
161 let directory = tempfile::tempdir().unwrap();
162 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
163 append(&mut after, " uncertain");
164 let id = cache
165 .save(OUTLINES, space, &after, "Model author")
166 .unwrap()
167 .unwrap();
168 let mut server = Server::new(OUTLINES);
169 server.fault = Fault::UnknownAfter;
170 assert!(cache.sync_once(&mut server).is_err());
171 assert!(matches!(
172 cache.status(id).unwrap(),
173 Some(EditStatus::AwaitingConfirmation { .. })
174 ));
175 let mut more = page_of(&cache.snapshot().unwrap(), space);
176 append(&mut more, " more");
177 let next = cache
178 .save(&cache.snapshot().unwrap(), space, &more, "Model author")
179 .unwrap()
180 .unwrap();
181 assert!(next > id);
182 let pending = cache.pending().unwrap();
183 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);
184 let Operation::Page(first) = &pending[0].operation else {
185 panic!()
186 };
187 assert_eq!(first.after, after);
188 let Operation::Page(second) = &pending[1].operation else {
189 panic!()
190 };
191 assert_eq!((&second.before, &second.after), (&after, &more));
192}
193
194#[test]
195fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
196 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
197 let directory = tempfile::tempdir().unwrap();
198 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
199 append(&mut after, " local");
200 let id = cache
201 .save(OUTLINES, space, &after, "Model author")
202 .unwrap()
203 .unwrap();
204 let mut remote_page = page_of(OUTLINES, space);
205 append(&mut remote_page, " remote");
206 let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap();
207 let mut server = Server::new(remote.as_bytes());
208 assert_eq!(
209 cache.sync_once(&mut server).unwrap(),
210 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
211 );
212 assert_eq!(server.publications, 0);
213 assert!(cache.pending().unwrap().len() == 1);
214 let local = cache.snapshot().unwrap();
215 let observed = cache.remote_snapshot().unwrap();
216 assert_eq!(observed, remote.as_bytes());
217 let mut reviewed = page_of(&observed, space);
218 append(&mut reviewed, " local");
219 assert!(
220 cache
221 .review_page(id + 1, &local, &observed, &reviewed)
222 .is_err()
223 );
224 assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err());
225 cache.review_page(id, &local, &observed, &reviewed).unwrap();
226 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
227 assert!(
228 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
229 );
230 assert_eq!(
231 first_text(&page_of(&server.durable, space)),
232 first_text(&reviewed)
233 );
234 assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local"));
235}
236
237#[test]
238fn remote_changes_to_other_pages_do_not_conflict() {
239 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
240 let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf");
241 let directory = tempfile::tempdir().unwrap();
242 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
243 append(&mut after, " local");
244 let id = cache
245 .save(OUTLINES, space, &after, "Model author")
246 .unwrap()
247 .unwrap();
248 append(&mut other_page, " elsewhere");
249 let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap();
250 let mut server = Server::new(remote.as_bytes());
251 assert!(
252 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
253 );
254 assert_eq!(page_of(&server.durable, space), after);
255 assert_eq!(page_of(&server.durable, other), other_page);
256 assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page);
257}
258
259#[test]
260fn pending_saves_survive_reopening_the_cache() {
261 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
262 let directory = tempfile::tempdir().unwrap();
263 let path = directory.path().join("cache.sqlite");
264 let cache = Replica::create(&path, OUTLINES).unwrap();
265 append(&mut after, " durable");
266 let id = cache
267 .save(OUTLINES, space, &after, "Model author")
268 .unwrap()
269 .unwrap();
270 let pending = cache.pending().unwrap();
271 drop(cache);
272 let reopened = Replica::open(&path).unwrap();
273 assert_eq!(reopened.pending().unwrap(), pending);
274 assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending));
275 assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after);
276}
277
278/// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`.
279fn edit_paragraph(page: &mut Page, index: usize, range: std::ops::Range<u32>, replacement: &str) {
280 let outline = page
281 .objects
282 .iter_mut()
283 .find_map(|object| match object {
284 PageObject::Outline(outline) => Some(outline),
285 _ => None,
286 })
287 .unwrap();
288 let text = outline.paragraphs[index].text_mut().unwrap();
289 let format = text.text.format_at(range.start).unwrap().clone();
290 text.text
291 .apply(onestore::page::text::Edit {
292 range,
293 replacement: onestore::page::Paragraph::new(replacement.into(), format),
294 })
295 .unwrap();
296}
297
298fn texts(page: &Page) -> Vec<String> {
299 page.objects
300 .iter()
301 .find_map(|object| match object {
302 PageObject::Outline(outline) => Some(
303 outline
304 .paragraphs
305 .iter()
306 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
307 .collect(),
308 ),
309 _ => None,
310 })
311 .unwrap()
312}
313
314fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
315 let mut page = page_of(OUTLINES, space);
316 change(&mut page);
317 Server::new(
318 PreparedEdit::page(OUTLINES, space, &page, "Native author")
319 .unwrap()
320 .as_bytes(),
321 )
322}
323
324#[test]
325fn concurrent_edits_to_different_paragraphs_merge() {
326 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
327 let directory = tempfile::tempdir().unwrap();
328 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
329 edit_paragraph(&mut after, 0, 0..0, "Local ");
330 let id = cache
331 .save(OUTLINES, space, &after, "Model author")
332 .unwrap()
333 .unwrap();
334 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
335 assert!(
336 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
337 );
338 let published = texts(&page_of(&server.durable, space));
339 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
340 assert!(published[2].starts_with("Remote Trailing"), "{published:?}");
341 assert_eq!(
342 cache
343 .status(id)
344 .unwrap()
345 .map(|s| matches!(s, EditStatus::Published { .. })),
346 Some(true)
347 );
348}
349
350#[test]
351fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
352 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
353 let directory = tempfile::tempdir().unwrap();
354 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
355 append(&mut after, " local");
356 let id = cache
357 .save(OUTLINES, space, &after, "Model author")
358 .unwrap()
359 .unwrap();
360 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
361 assert!(
362 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
363 );
364 assert_eq!(
365 texts(&page_of(&server.durable, space))[0],
366 "Remote Anchor local"
367 );
368
369 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
370 let mut after = page_of(OUTLINES, space);
371 edit_paragraph(&mut after, 0, 0..6, "Local");
372 let id = cache
373 .save(OUTLINES, space, &after, "Model author")
374 .unwrap()
375 .unwrap();
376 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
377 assert_eq!(
378 cache.sync_once(&mut server).unwrap(),
379 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
380 );
381 assert_eq!(server.publications, 0);
382}
383
384#[test]
385fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
386 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
387 let directory = tempfile::tempdir().unwrap();
388 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
389 let template = texts(&after);
390 assert_eq!(template.len(), 3);
391 {
392 let outline = after
393 .objects
394 .iter_mut()
395 .find_map(|object| match object {
396 PageObject::Outline(outline) => Some(outline),
397 _ => None,
398 })
399 .unwrap();
400 let mut fresh = outline.paragraphs[0].clone();
401 fresh.id = onestore::page::text::new_id().unwrap();
402 fresh.style = None;
403 let text = fresh.text_mut().unwrap();
404 text.id = onestore::page::text::new_id().unwrap();
405 text.text = onestore::page::Paragraph::new(
406 "Inserted locally".into(),
407 text.text.format_at(0).unwrap().clone(),
408 );
409 outline.paragraphs.insert(1, fresh);
410 }
411 let id = cache
412 .save(OUTLINES, space, &after, "Model author")
413 .unwrap()
414 .unwrap();
415 let mut server = remote_with(space, |page| {
416 let outline = page
417 .objects
418 .iter_mut()
419 .find_map(|object| match object {
420 PageObject::Outline(outline) => Some(outline),
421 _ => None,
422 })
423 .unwrap();
424 outline.paragraphs.pop();
425 });
426 assert!(
427 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
428 );
429 assert_eq!(
430 texts(&page_of(&server.durable, space)),
431 vec![
432 template[0].clone(),
433 "Inserted locally".to_owned(),
434 template[1].clone()
435 ]
436 );
437}
438
439#[test]
440fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
441 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
442 let directory = tempfile::tempdir().unwrap();
443 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
444 edit_paragraph(&mut after, 2, 0..0, "Local ");
445 let id = cache
446 .save(OUTLINES, space, &after, "Model author")
447 .unwrap()
448 .unwrap();
449 let mut server = remote_with(space, |page| {
450 let outline = page
451 .objects
452 .iter_mut()
453 .find_map(|object| match object {
454 PageObject::Outline(outline) => Some(outline),
455 _ => None,
456 })
457 .unwrap();
458 outline.paragraphs.pop();
459 });
460 assert_eq!(
461 cache.sync_once(&mut server).unwrap(),
462 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
463 );
464}
465
466#[test]
467fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
468 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
469 let directory = tempfile::tempdir().unwrap();
470 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
471 let outline_id = {
472 let PageObject::Outline(outline) = after
473 .objects
474 .iter_mut()
475 .find(|o| matches!(o, PageObject::Outline(_)))
476 .unwrap()
477 else {
478 unreachable!()
479 };
480 outline.layout.x = Some(200.0);
481 outline.layout.y = Some(300.0);
482 outline.id
483 };
484 let id = cache
485 .save(OUTLINES, space, &after, "Model author")
486 .unwrap()
487 .unwrap();
488 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
489 assert!(
490 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)
491 );
492 let published = page_of(&server.durable, space);
493 let PageObject::Outline(outline) = published
494 .objects
495 .iter()
496 .find(|o| o.id() == outline_id)
497 .unwrap()
498 else {
499 unreachable!()
500 };
501 assert_eq!(
502 (outline.layout.x, outline.layout.y),
503 (Some(200.0), Some(300.0))
504 );
505 assert!(
506 outline.paragraphs[0]
507 .text()
508 .unwrap()
509 .text
510 .text()
511 .starts_with("Remote ")
512 );
513
514 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
515 let id = cache
516 .save(OUTLINES, space, &after, "Model author")
517 .unwrap()
518 .unwrap();
519 let mut server = remote_with(space, |page| {
520 let PageObject::Outline(outline) = page
521 .objects
522 .iter_mut()
523 .find(|o| matches!(o, PageObject::Outline(_)))
524 .unwrap()
525 else {
526 unreachable!()
527 };
528 outline.layout.x = Some(50.0);
529 });
530 assert_eq!(
531 cache.sync_once(&mut server).unwrap(),
532 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
533 );
534}
crates/notebook/tests/sync_outline.rs created+580
......@@ -0,0 +1,580 @@
1//! Outline layout through page-model saves: reconciliation against the remote page,
2//! review of competing layout, twelve offline writers, and the native outline fixtures.
3
4use notebook::{ConflictKind, EditStatus, Replica};
5use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,
7 document::Document,
8 page::{Outline, Page, PageObject, PageParagraph},
9};
10
11#[path = "support/server.rs"]
12mod server;
13use server::{Fault, Server};
14#[path = "support/model_ops.rs"]
15mod model_ops;
16use model_ops::{
17 delete_paragraph, insert_outline, outlines_mut, page_of, paragraph_with, replace_text, restyle,
18 review, save,
19};
20#[path = "support/model_schedule.rs"]
21mod model_schedule;
22#[path = "sync/tree.rs"]
23mod tree;
24
25const BEFORE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
26const NATIVE: &[u8] = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one");
27
28/// One model change, boxed so alternative cases can be listed together.
29pub(crate) type Change<T> = Box<dyn Fn(&mut T)>;
30
31/// A one-outline page with its space, outline and first paragraph's text identities.
32pub(crate) fn fixture() -> (Vec<u8>, ExGuid, ExGuid, ExGuid) {
33 let source = onestore::create_section("layout.one", "Original 🦀 é", "Author").unwrap();
34 let store = Store::parse(&source).unwrap();
35 let index = RevisionIndex::parse(&store).unwrap();
36 let document = Document::parse(&index).unwrap();
37 let space = document.pages().unwrap()[0].0;
38 let page = Page::from_space(&document, space).unwrap();
39 let outline = body_outlines(&page)[0];
40 let ids = (outline.id, outline.paragraphs[0].text().unwrap().id);
41 (source, space, ids.0, ids.1)
42}
43
44pub(crate) fn body_outlines(page: &Page) -> Vec<&Outline> {
45 page.objects
46 .iter()
47 .filter_map(|object| match object {
48 PageObject::Outline(outline) => Some(outline),
49 _ => None,
50 })
51 .collect()
52}
53
54pub(crate) fn outline_of(page: &Page, id: ExGuid) -> &Outline {
55 body_outlines(page)
56 .into_iter()
57 .find(|outline| outline.id == id)
58 .expect("the outline is on the page")
59}
60
61pub(crate) fn outline_mut(page: &mut Page, id: ExGuid) -> &mut Outline {
62 outlines_mut(page)
63 .into_iter()
64 .find(|outline| outline.id == id)
65 .expect("the outline is on the page")
66}
67
68pub(crate) fn paragraph_mut(page: &mut Page, text: ExGuid) -> &mut PageParagraph {
69 outlines_mut(page)
70 .into_iter()
71 .flat_map(|outline| outline.paragraphs.iter_mut())
72 .find(|paragraph| paragraph.text().is_some_and(|value| value.id == text))
73 .expect("the paragraph is on the page")
74}
75
76/// The text identity of the stored paragraph carrying exactly `value`.
77pub(crate) fn text_with(page: &Page, value: &str) -> ExGuid {
78 body_outlines(page)
79 .into_iter()
80 .flat_map(|outline| outline.paragraphs.iter())
81 .find_map(|paragraph| {
82 let text = paragraph.text()?;
83 (text.text.text() == value).then_some(text.id)
84 })
85 .expect("a paragraph carries the text")
86}
87
88pub(crate) fn text_of(page: &Page, text: ExGuid) -> String {
89 paragraph_with(page, text)
90 .and_then(PageParagraph::text)
91 .expect("the paragraph is on the page")
92 .text
93 .text()
94 .to_owned()
95}
96
97/// A remote image holding `change`, published as another writer would.
98pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut Page)) -> Vec<u8> {
99 let mut page = page_of(source, space);
100 change(&mut page);
101 PreparedEdit::page(source, space, &page, "Native author")
102 .unwrap()
103 .as_bytes()
104 .to_vec()
105}
106
107pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) {
108 assert!(
109 matches!(cache.sync_once(server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
110 );
111}
112
113fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) {
114 move |outline| {
115 outline.layout.x = Some(x);
116 outline.layout.y = Some(y);
117 }
118}
119
120fn resized(points: f32, user_set: bool) -> impl Fn(&mut Outline) {
121 move |outline| {
122 outline.layout.max_width = Some(points);
123 outline.layout.width_set_by_user = Some(user_set);
124 }
125}
126
127#[test]
128fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page() {
129 let (source, space, outline, text_id) = fixture();
130 let changes: [Change<Page>; 3] = [
131 Box::new(move |page| moved(180.0, 216.0)(outline_mut(page, outline))),
132 Box::new(move |page| resized(144.0, true)(outline_mut(page, outline))),
133 Box::new(move |page| paragraph_mut(page, text_id).collapsed = true),
134 ];
135 for change in changes {
136 let directory = tempfile::tempdir().unwrap();
137 let path = directory.path().join("cache.sqlite");
138 let cache = Replica::create(&path, &source).unwrap();
139 let id = save(&cache, text_id, &change).unwrap().unwrap();
140 let expected = page_of(&cache.snapshot().unwrap(), space);
141 let pending = cache.pending().unwrap();
142 let local = cache.snapshot().unwrap();
143 drop(cache);
144 let cache = Replica::open(&path).unwrap();
145 assert_eq!(cache.pending().unwrap(), pending);
146 assert_eq!(cache.snapshot().unwrap(), local);
147 let mut server = Server::new(&remote_with(&source, space, |page| {
148 restyle(page, text_id, 0..8, |format| format.bold = Some(true));
149 }));
150 published(&cache, &mut server, id);
151 let durable = page_of(&server.durable, space);
152 assert_eq!(
153 outline_of(&durable, outline).layout,
154 outline_of(&expected, outline).layout
155 );
156 assert_eq!(
157 paragraph_with(&durable, text_id).unwrap().collapsed,
158 paragraph_with(&expected, text_id).unwrap().collapsed
159 );
160 assert!(cache.pending().unwrap().is_empty());
161
162 let dependent = save(&cache, text_id, |page| {
163 replace_text(page, text_id, 0..0, "Local ")
164 })
165 .unwrap()
166 .unwrap();
167 drop(cache);
168 let cache = Replica::open(&path).unwrap();
169 published(&cache, &mut server, dependent);
170 let durable = page_of(&server.durable, space);
171 assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é");
172 let text = &paragraph_with(&durable, text_id)
173 .unwrap()
174 .text()
175 .unwrap()
176 .text;
177 assert_eq!(text.spans()[0].format.bold, Some(true));
178 assert_eq!(
179 outline_of(&durable, outline).layout,
180 outline_of(&expected, outline).layout
181 );
182 assert_eq!(server.publications, 2);
183 drop(cache);
184 let cache = Replica::open(&path).unwrap();
185 for id in [id, dependent] {
186 assert!(matches!(
187 cache.status(id).unwrap(),
188 Some(EditStatus::Published { .. })
189 ));
190 }
191 }
192}
193
194#[test]
195fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_title() {
196 let (source, space, outline, _) = fixture();
197 let mut page = page_of(&source, space);
198 insert_outline(&mut page, 144.0, 36.0, "Second");
199 let source = PreparedEdit::page(&source, space, &page, "Author")
200 .unwrap()
201 .as_bytes()
202 .to_vec();
203 let second = text_with(&page_of(&source, space), "Second");
204 let directory = tempfile::tempdir().unwrap();
205 let path = directory.path().join("titles.sqlite");
206 let cache = Replica::create(&path, &source).unwrap();
207 let id = save(&cache, second, |page| {
208 moved(216.0, 72.0)(outline_mut(page, outline));
209 })
210 .unwrap()
211 .unwrap();
212 drop(cache);
213 let mut server = Server::new(&remote_with(&source, space, |page| {
214 replace_text(page, second, 0..6, "Remote second 🐈");
215 }));
216 server.fault = Fault::UnknownAfter;
217 let cache = Replica::open(&path).unwrap();
218 assert!(cache.sync_once(&mut server).is_err());
219 assert!(matches!(
220 cache.status(id).unwrap(),
221 Some(EditStatus::AwaitingConfirmation { .. })
222 ));
223 assert_eq!(page_of(&server.durable, space).title, "Original 🦀 é");
224 assert_eq!(page_of(&server.visible, space).title, "Remote second 🐈");
225 drop(cache);
226 let cache = Replica::open(&path).unwrap();
227 published(&cache, &mut server, id);
228 assert_eq!(page_of(&server.durable, space).title, "Remote second 🐈");
229 assert!(cache.pending().unwrap().is_empty());
230 assert_eq!((server.publications, server.confirmations), (1, 1));
231}
232
233#[test]
234fn competing_layout_requires_review_against_the_current_remote_page() {
235 let (source, space, outline, text_id) = fixture();
236 let cases: [(Change<Outline>, Change<Outline>); 2] = [
237 (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))),
238 (
239 Box::new(resized(144.0, true)),
240 Box::new(resized(216.0, false)),
241 ),
242 ];
243 for (local_change, remote_change) in cases {
244 let directory = tempfile::tempdir().unwrap();
245 let path = directory.path().join("cache.sqlite");
246 let cache = Replica::create(&path, &source).unwrap();
247 let id = save(&cache, text_id, |page| {
248 local_change(outline_mut(page, outline))
249 })
250 .unwrap()
251 .unwrap();
252 let local = cache.snapshot().unwrap();
253 let mut server = Server::new(&remote_with(&source, space, |page| {
254 remote_change(outline_mut(page, outline));
255 }));
256 assert_eq!(
257 cache.sync_once(&mut server).unwrap(),
258 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
259 );
260 assert_eq!(server.publications, 0);
261 assert_eq!(cache.snapshot().unwrap(), local);
262 let dependent = save(&cache, text_id, |page| {
263 replace_text(page, text_id, 0..0, "Local ")
264 })
265 .unwrap()
266 .unwrap();
267 let queue = cache.pending().unwrap();
268 let local = cache.snapshot().unwrap();
269 let observed = cache.remote_snapshot().unwrap();
270 assert!(
271 cache
272 .review_page(id, &source, &observed, &page_of(&observed, space))
273 .is_err()
274 );
275 assert!(
276 cache
277 .review_page(id, &local, &source, &page_of(&source, space))
278 .is_err()
279 );
280 drop(cache);
281 let cache = Replica::open(&path).unwrap();
282 assert_eq!(
283 cache.status(id).unwrap(),
284 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
285 );
286 review(&cache, id, text_id, |page| {
287 local_change(outline_mut(page, outline))
288 })
289 .unwrap();
290 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
291 assert_eq!(cache.pending().unwrap()[1], queue[1]);
292 drop(cache);
293 let cache = Replica::open(&path).unwrap();
294 for id in [id, dependent] {
295 published(&cache, &mut server, id);
296 }
297 let durable = page_of(&server.durable, space);
298 assert_eq!(
299 outline_of(&durable, outline).layout,
300 outline_of(&page_of(&local, space), outline).layout
301 );
302 assert_eq!(text_of(&durable, text_id), "Local Original 🦀 é");
303 }
304}
305
306#[test]
307fn a_local_move_and_a_remote_resize_of_one_outline_converge() {
308 let (source, space, outline, text_id) = fixture();
309 let directory = tempfile::tempdir().unwrap();
310 let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
311 let id = save(&cache, text_id, |page| {
312 moved(180.0, 216.0)(outline_mut(page, outline))
313 })
314 .unwrap()
315 .unwrap();
316 let mut server = Server::new(&remote_with(&source, space, |page| {
317 resized(252.0, true)(outline_mut(page, outline));
318 }));
319 published(&cache, &mut server, id);
320 let layout = outline_of(&page_of(&server.durable, space), outline)
321 .layout
322 .clone();
323 assert_eq!((layout.x, layout.y), (Some(180.0), Some(216.0)));
324 assert_eq!(
325 (layout.max_width, layout.width_set_by_user),
326 (Some(252.0), Some(true))
327 );
328}
329
330#[test]
331fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restarts() {
332 let (source, space, outline, text_id) = fixture();
333 let directory = tempfile::tempdir().unwrap();
334 let mut server = Server::new(&source);
335 let mut reviewed = 0;
336 for round in 0..4 {
337 for actor in 0..12 {
338 let path = directory.path().join(format!("{actor}.sqlite"));
339 let cache = if round == 0 {
340 Replica::create(&path, &source).unwrap()
341 } else {
342 Replica::open(&path).unwrap()
343 };
344 let change = |page: &mut Page| {
345 let layout = &mut outline_mut(page, outline).layout;
346 layout.x = Some((actor + 3) as f32 * 36.0);
347 layout.y = Some((round + 3) as f32 * 36.0);
348 layout.max_width = Some((actor + round + 3) as f32 * 36.0);
349 layout.width_set_by_user = Some(true);
350 paragraph_mut(page, text_id).collapsed = (actor + round) % 2 != 0;
351 };
352 let id = save(&cache, text_id, change).unwrap().unwrap();
353 let expected = page_of(&cache.snapshot().unwrap(), space);
354 drop(cache);
355 let cache = Replica::open(&path).unwrap();
356 if cache.sync_once(&mut server).unwrap()
357 == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
358 {
359 review(&cache, id, text_id, change).unwrap();
360 reviewed += 1;
361 published(&cache, &mut server, id);
362 }
363 assert!(matches!(
364 cache.status(id).unwrap(),
365 Some(EditStatus::Published { .. })
366 ));
367 assert!(cache.pending().unwrap().is_empty());
368 let durable = page_of(&server.durable, space);
369 assert_eq!(
370 outline_of(&durable, outline).layout,
371 outline_of(&expected, outline).layout
372 );
373 assert_eq!(
374 paragraph_with(&durable, text_id).unwrap().collapsed,
375 (actor + round) % 2 != 0
376 );
377 assert_eq!(text_of(&durable, text_id), "Original 🦀 é");
378 }
379 }
380 assert_eq!(reviewed, 47);
381}
382
383#[test]
384fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page() {
385 let (source, space, outline, text_id) = fixture();
386 for fault in [Fault::UnknownBefore, Fault::UnknownAfter] {
387 let directory = tempfile::tempdir().unwrap();
388 let path = directory.path().join("cache.sqlite");
389 let cache = Replica::create(&path, &source).unwrap();
390 let id = save(&cache, text_id, |page| {
391 resized(144.0, true)(outline_mut(page, outline))
392 })
393 .unwrap()
394 .unwrap();
395 let local = cache.snapshot().unwrap();
396 let mut server = Server::new(&source);
397 server.fault = fault;
398 assert!(cache.sync_once(&mut server).is_err());
399 let status = cache.status(id).unwrap().unwrap();
400 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
401 drop(cache);
402 let cache = Replica::open(&path).unwrap();
403 if matches!(fault, Fault::UnknownAfter) {
404 published(&cache, &mut server, id);
405 let layout = outline_of(&page_of(&server.durable, space), outline)
406 .layout
407 .clone();
408 assert_eq!(layout.max_width, Some(144.0));
409 assert_eq!((server.publications, server.confirmations), (1, 1));
410 continue;
411 }
412 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));
413 server.visible = remote_with(&source, space, |page| {
414 resized(216.0, true)(outline_mut(page, outline));
415 });
416 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, status)));
417 assert_eq!(cache.snapshot().unwrap(), local);
418 server.visible = remote_with(&source, space, |page| {
419 resized(144.0, true)(outline_mut(page, outline));
420 });
421 published(&cache, &mut server, id);
422 assert_eq!((server.publications, server.confirmations), (1, 1));
423 }
424}
425
426/// The layout or collapse change the native fixture page is reconciled against.
427fn native_case(name: &str, outline: ExGuid, target: ExGuid) -> Change<Page> {
428 let layout: Option<Change<Outline>> = match name {
429 "Move outline" => Some(Box::new(moved(252.0, 288.0))),
430 "Resize outline" | "Automatic outline size" | "Delete outline" => {
431 Some(Box::new(resized(252.0, true)))
432 }
433 _ => None,
434 };
435 let collapsed = name != "Expand subtree";
436 Box::new(move |page| match &layout {
437 Some(change) => {
438 if body_outlines(page).iter().any(|o| o.id == outline) {
439 change(outline_mut(page, outline));
440 }
441 }
442 None => {
443 if paragraph_with(page, target).is_some() {
444 paragraph_mut(page, target).collapsed = collapsed;
445 }
446 }
447 })
448}
449
450/// Native fixture pages with two body outlines: the page, its first outline, the
451/// "Target " paragraph's text and an independent paragraph's text in the second outline.
452pub(crate) fn native_pages(source: &[u8]) -> Vec<(String, ExGuid, ExGuid, ExGuid, ExGuid)> {
453 let store = Store::parse(source).unwrap();
454 let index = RevisionIndex::parse(&store).unwrap();
455 let document = Document::parse(&index).unwrap();
456 let mut cases = Vec::new();
457 for (space, _) in document.pages().unwrap() {
458 let page = Page::from_space(&document, space).unwrap();
459 let outlines = body_outlines(&page);
460 if outlines.len() != 2 || page.title == "Unicode rich text" {
461 continue;
462 }
463 let target = outlines[0]
464 .paragraphs
465 .iter()
466 .find(|paragraph| {
467 paragraph
468 .text()
469 .is_some_and(|text| text.text.text().starts_with("Target "))
470 })
471 .expect("each fixture page marks its target paragraph");
472 cases.push((
473 page.title.clone(),
474 space,
475 outlines[0].id,
476 target.text().unwrap().id,
477 outlines[1].paragraphs[0].text().unwrap().id,
478 ));
479 }
480 cases
481}
482
483#[test]
484fn native_moves_deletions_and_layout_changes_merge_or_require_review() {
485 let mut server = Server::new(NATIVE);
486 let mut records = Vec::new();
487 for (name, space, outline, target, dependent) in native_pages(BEFORE) {
488 let change = native_case(&name, outline, target);
489 let save_both = |page: &mut Page| {
490 change(page);
491 replace_text(page, dependent, 0..0, "Local ");
492 };
493 let directory = tempfile::tempdir().unwrap();
494 let path = directory.path().join("cache.sqlite");
495 let cache = Replica::create(&path, BEFORE).unwrap();
496 let id = save(&cache, dependent, save_both).unwrap().unwrap();
497 let local = cache.snapshot().unwrap();
498 let queue = cache.pending().unwrap();
499 let merged = match cache.sync_once(&mut server).unwrap().unwrap() {
500 (actual, EditStatus::Published { .. }) => {
501 assert_eq!(actual, id, "{name}");
502 true
503 }
504 result => {
505 assert_eq!(
506 result,
507 (id, EditStatus::Conflict(ConflictKind::ContentChanged)),
508 "{name}"
509 );
510 assert_eq!(cache.pending().unwrap(), queue);
511 assert_eq!(cache.snapshot().unwrap(), local);
512 drop(cache);
513 let cache = Replica::open(&path).unwrap();
514 review(&cache, id, dependent, save_both).unwrap();
515 published(&cache, &mut server, id);
516 false
517 }
518 };
519 let durable = page_of(&server.durable, space);
520 assert!(
521 text_of(&durable, dependent).starts_with("Local "),
522 "{name} keeps its dependent text"
523 );
524 let mut reapplied = durable.clone();
525 change(&mut reapplied);
526 assert_eq!(reapplied, durable, "{name} keeps its reconciled change");
527 records.push((name, if merged { "merged" } else { "reviewed" }));
528 }
529 let reviewed = records.iter().filter(|(_, r)| *r == "reviewed").count();
530 assert_eq!((records.len(), reviewed), (14, 7), "{records:?}");
531 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_OUTLINE_OUTPUT") {
532 let output = std::path::PathBuf::from(output);
533 assert!(output.is_absolute());
534 std::fs::create_dir(&output).unwrap();
535 std::fs::write(output.join("synthetic.one"), &server.durable).unwrap();
536 std::fs::write(
537 output.with_extension("json"),
538 serde_json::to_vec_pretty(&records).unwrap(),
539 )
540 .unwrap();
541 }
542}
543
544#[test]
545fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
546 let (_, space, outline, _, text_id) = native_pages(BEFORE)
547 .into_iter()
548 .find(|(name, ..)| name == "Move outline")
549 .unwrap();
550 let stored = outline_of(&page_of(BEFORE, space), outline).layout.clone();
551 let remote = outline_of(&page_of(NATIVE, space), outline).layout.clone();
552 assert_eq!(
553 (stored.max_width, stored.width_set_by_user),
554 (remote.max_width, remote.width_set_by_user)
555 );
556 assert!(remote.reserved_width.is_some());
557 let directory = tempfile::tempdir().unwrap();
558 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
559 let id = save(&cache, text_id, |page| {
560 resized(144.0, true)(outline_mut(page, outline))
561 })
562 .unwrap()
563 .unwrap();
564 let mut server = Server::new(NATIVE);
565 assert_eq!(
566 cache.sync_once(&mut server).unwrap(),
567 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
568 );
569 review(&cache, id, text_id, |page| {
570 resized(144.0, true)(outline_mut(page, outline))
571 })
572 .unwrap();
573 published(&cache, &mut server, id);
574 let layout = outline_of(&page_of(&server.durable, space), outline)
575 .layout
576 .clone();
577 assert_eq!((layout.x, layout.y), (remote.x, remote.y));
578 assert_eq!(layout.max_width, Some(144.0));
579 assert!(layout.reserved_width.is_none());
580}
crates/notebook/tests/sync_page.rs created+293
......@@ -0,0 +1,293 @@
1//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review,
2//! and deterministic multi-actor schedules.
3
4use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica};
5use onestore::{
6 ExGuid, PageCreation, RevisionIndex, Store,
7 document::{Document, Kind},
8};
9use std::collections::BTreeMap;
10
11#[path = "support/server.rs"]
12mod server;
13use server::*;
14#[path = "../../onestore/tests/support/disk.rs"]
15mod disk;
16#[path = "support/model_ops.rs"]
17mod model_ops;
18#[path = "support/page_schedule.rs"]
19mod page_schedule;
20use page_schedule::body_outline;
21
22#[test]
23fn twelve_replica_page_schedules_retain_acknowledged_pages_through_interruptions() {
24 for seed in 0..24_u64 {
25 let mut random = seed + 1956;
26 let mut input = Vec::new();
27 for step in 0..48 {
28 random ^= random << 13;
29 random ^= random >> 7;
30 random ^= random << 17;
31 let mut action = random.to_le_bytes();
32 if step < 12 {
33 action[0] = step;
34 action[1] = 1;
35 }
36 input.extend_from_slice(&action);
37 }
38 page_schedule::run(&input);
39 }
40}
41
42#[test]
43fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
44 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
45 let directory = tempfile::tempdir().unwrap();
46 let mut server = Server::new(&source);
47 let mut expected = Vec::new();
48 for actor in 0..12 {
49 let path = directory.path().join(format!("{actor}.sqlite"));
50 let cache = Replica::create(&path, &source).unwrap();
51 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
52 let id = cache.create_page(&source, &page).unwrap().unwrap();
53 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());
54 let body = body_outline(&mut created, "Body");
55 let save = cache
56 .save(
57 &cache.snapshot().unwrap(),
58 page.space(),
59 &created,
60 model_ops::AUTHOR,
61 )
62 .unwrap()
63 .unwrap();
64 assert_eq!(
65 model_ops::save(&cache, body, |page| model_ops::replace_text(
66 page,
67 body,
68 4..4,
69 &format!(" {actor}")
70 ))
71 .unwrap(),
72 Some(save),
73 "a dependent text edit coalesces into the unattempted body save"
74 );
75 let original = cache.pending().unwrap();
76 assert_eq!(original.len(), 2);
77 assert!(
78 matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page)
79 );
80 let local = cache.snapshot().unwrap();
81 drop(cache);
82 let cache = Replica::open(&path).unwrap();
83 assert_eq!(cache.pending().unwrap(), original);
84 assert_eq!(cache.snapshot().unwrap(), local);
85 for edit in original {
86 assert!(matches!(cache.sync_once(&mut server).unwrap(),
87 Some((published, EditStatus::Published { .. })) if published == edit.id));
88 }
89 assert!(matches!(
90 cache.status(id).unwrap(),
91 Some(EditStatus::Published { .. })
92 ));
93 expected.push((page.space(), page.object(), body, format!("Body {actor}")));
94 }
95 let store = Store::parse(&server.durable).unwrap();
96 let index = RevisionIndex::parse(&store).unwrap();
97 let document = Document::parse(&index).unwrap();
98 let pages = document.pages().unwrap();
99 assert_eq!(pages.len(), 13);
100 for (actual, (sid, page, text, expected)) in pages[1..].iter().zip(&expected) {
101 assert_eq!(*actual, (*sid, *page));
102 let space = &document.spaces[sid];
103 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
104 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
105 }
106 assert_eq!(server.publications, 24);
107 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
108 std::fs::create_dir(&output).unwrap();
109 std::fs::write(
110 std::path::Path::new(&output).join("pages.one"),
111 &server.durable,
112 )
113 .unwrap();
114 }
115}
116
117#[test]
118fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
119 for fault in [
120 Fault::UnknownBefore,
121 Fault::UnknownAfter,
122 Fault::PanicBefore,
123 Fault::PanicAfter,
124 ] {
125 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
126 let directory = tempfile::tempdir().unwrap();
127 let path = directory.path().join("pages.sqlite");
128 let cache = Replica::create(&path, &source).unwrap();
129 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();
130 let id = cache.create_page(&source, &page).unwrap().unwrap();
131 let local = cache.snapshot().unwrap();
132 let mut server = Server::new(&source);
133 server.fault = fault;
134 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
135 cache.sync_once(&mut server)
136 }));
137 let attempted = cache.status(id).unwrap().unwrap();
138 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
139 let archive = directory.path().join("recovery.sqlite");
140 cache.export_recovery(&archive).unwrap();
141 assert_eq!(
142 Recovery::open(&archive).unwrap().status(id).unwrap(),
143 Some(attempted)
144 );
145 drop(cache);
146 let db = rusqlite::Connection::open(&path).unwrap();
147 let encoded: String = db
148 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
149 .unwrap();
150 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
151 assert_eq!(revisions.len(), 2);
152 assert!(revisions.contains_key(&page.space()));
153 drop(db);
154 let cache = Replica::open(&path).unwrap();
155 let observed = server.visible.clone();
156 let result = cache.sync_once(&mut server).unwrap().unwrap();
157 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
158 assert_eq!(server.publications, 1);
159 assert_eq!(server.confirmations, usize::from(visible));
160 if visible {
161 assert!(matches!(result.1, EditStatus::Published { .. }));
162 assert!(cache.snapshot().unwrap() == observed);
163 assert!(observed[..212] == server.durable[..212]);
164 assert!(observed[252..] == server.durable[252..]);
165 } else {
166 assert_eq!(result, (id, attempted));
167 assert_eq!(cache.snapshot().unwrap(), local);
168 }
169 }
170}
171
172#[test]
173fn surviving_page_revision_alone_does_not_confirm_section_publication() {
174 let source = onestore::create_section("pages.one", "Original", "Author").unwrap();
175 let directory = tempfile::tempdir().unwrap();
176 let path = directory.path().join("pages.sqlite");
177 let cache = Replica::create(&path, &source).unwrap();
178 let page = PageCreation::new(None, None, "Author").unwrap();
179 let id = cache.create_page(&source, &page).unwrap().unwrap();
180 let mut server = Server::new(&source);
181 server.fault = Fault::UnknownAfter;
182 assert!(cache.sync_once(&mut server).is_err());
183 let attempted = cache.status(id).unwrap().unwrap();
184 drop(cache);
185 let db = rusqlite::Connection::open(&path).unwrap();
186 let encoded: String = db
187 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))
188 .unwrap();
189 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
190 drop(db);
191 let complete = server.visible.clone();
192 let (&section, &retired) = revisions
193 .iter()
194 .find(|(sid, _)| **sid != page.space())
195 .unwrap();
196 let store = Store::parse(&complete).unwrap();
197 let mut positions = Vec::new();
198 for list in store.lists.values() {
199 for node in &list.nodes {
200 if node.id == 0x1e
201 && node.payload[..16] == retired.guid
202 && node.payload[16..20] == retired.n.to_le_bytes()
203 {
204 positions.push(node.offset);
205 }
206 }
207 }
208 assert_eq!(positions.len(), 1);
209 // Replacing only the revision identity models maintenance retiring the section proof.
210 let at = positions[0] + 4;
211 server.visible[at] ^= 0x40;
212 let store = Store::parse(&server.visible).unwrap();
213 let index = RevisionIndex::parse(&store).unwrap();
214 index.validate_current().unwrap();
215 assert!(!index.spaces[&section].revisions.contains_key(&retired));
216 assert!(
217 index.spaces[&page.space()]
218 .revisions
219 .contains_key(&revisions[&page.space()])
220 );
221 let cache = Replica::open(&path).unwrap();
222 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));
223 assert_eq!(server.confirmations, 0);
224 assert_eq!(server.publications, 1);
225 server.visible = complete;
226 assert!(matches!(
227 cache.sync_once(&mut server).unwrap(),
228 Some((_, EditStatus::Published { .. }))
229 ));
230 assert_eq!(server.publications, 1);
231}
232
233#[test]
234fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() {
235 let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
236 let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
237 let store = Store::parse(source).unwrap();
238 let index = RevisionIndex::parse(&store).unwrap();
239 let pages = Document::parse(&index).unwrap().pages().unwrap();
240 let directory = tempfile::tempdir().unwrap();
241 let path = directory.path().join("pages.sqlite");
242 let cache = Replica::create(&path, source).unwrap();
243 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
244 let id = cache.create_page(source, &page).unwrap().unwrap();
245 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());
246 let body = body_outline(&mut created, "Retained body");
247 cache
248 .save(&cache.snapshot().unwrap(), page.space(), &created, "Author")
249 .unwrap()
250 .unwrap();
251 let local = cache.snapshot().unwrap();
252 let pending = cache.pending().unwrap();
253 let mut server = Server::new(remote);
254 assert_eq!(
255 cache.sync_once(&mut server).unwrap(),
256 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
257 );
258 assert!(
259 cache
260 .rebase_page_creation_conflict(id, source, remote, None)
261 .is_err()
262 );
263 assert!(
264 cache
265 .rebase_page_creation_conflict(id, &local, source, None)
266 .is_err()
267 );
268 cache
269 .rebase_page_creation_conflict(id, &local, remote, None)
270 .unwrap();
271 assert_eq!(cache.snapshot().unwrap(), local);
272 assert_eq!(cache.pending().unwrap()[1], pending[1]);
273 let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else {
274 panic!()
275 };
276 assert_eq!(*reviewed, page.reposition(None).unwrap());
277 drop(cache);
278 let cache = Replica::open(&path).unwrap();
279 for _ in 0..2 {
280 assert!(matches!(
281 cache.sync_once(&mut server).unwrap(),
282 Some((_, EditStatus::Published { .. }))
283 ));
284 }
285 assert_eq!(server.publications, 2);
286 let store = Store::parse(&server.durable).unwrap();
287 let index = RevisionIndex::parse(&store).unwrap();
288 let document = Document::parse(&index).unwrap();
289 let space = &document.spaces[&page.space()];
290 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
291 assert!(matches!(&view.nodes[&body].kind,
292 Kind::RichText { text, .. } if text == "Retained body"));
293}
crates/notebook/tests/sync_pages.rs created+647
......@@ -0,0 +1,647 @@
1//! Page batch reconciliation: atomic batches with dependent body saves, competing moves and
2//! indentation, uncertain batches, and native page movement fixtures.
3
4use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica};
5use onestore::{
6 ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
7 document::{Document, Format, Kind, Layout},
8 page::{
9 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
10 text::new_id,
11 },
12};
13use std::collections::BTreeMap;
14
15#[path = "support/server.rs"]
16mod server;
17use server::*;
18#[path = "support/model_ops.rs"]
19mod model_ops;
20
21const SOURCE: &[u8] =
22 include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
23
24fn order(source: &[u8]) -> Vec<(ExGuid, u32)> {
25 let store = Store::parse(source).unwrap();
26 let index = RevisionIndex::parse(&store).unwrap();
27 index.validate_current().unwrap();
28 let document = Document::parse(&index).unwrap();
29 document
30 .pages()
31 .unwrap()
32 .iter()
33 .map(|(sid, _)| {
34 let space = &document.spaces[sid];
35 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
36 let Kind::Metadata { level, .. } = view.nodes[&view.roots[&2]].kind else {
37 panic!()
38 };
39 (*sid, level.unwrap_or(1))
40 })
41 .collect()
42}
43
44fn texts(source: &[u8]) -> BTreeMap<(ExGuid, ExGuid), String> {
45 let store = Store::parse(source).unwrap();
46 let index = RevisionIndex::parse(&store).unwrap();
47 let document = Document::parse(&index).unwrap();
48 let mut texts = BTreeMap::new();
49 for (sid, space) in &document.spaces {
50 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
51 for (oid, node) in &view.nodes {
52 if let Kind::RichText { text, .. } = &node.kind {
53 texts.insert((*sid, *oid), text.clone());
54 }
55 }
56 }
57 texts
58}
59
60/// Adds a body outline holding one plain paragraph, returning its text identity. A page the
61/// section just created has no body text for `model_ops::insert_outline` to copy formatting from.
62fn body_outline(page: &mut Page, text: &str) -> ExGuid {
63 // The writer creates outline text with the store's default style; the model must state it.
64 let format = Format {
65 font: Some("Calibri".to_owned()),
66 font_size: Some(11.0),
67 language: Some(0x409),
68 ..Default::default()
69 };
70 let content = TextObject {
71 id: new_id().unwrap(),
72 date_field: None,
73 text: Paragraph::new(text.into(), format),
74 tags: Vec::new(),
75 };
76 let id = content.id;
77 let outline = Outline {
78 id: new_id().unwrap(),
79 title: false,
80 min_width: None,
81 layout: Layout {
82 x: Some(36.0),
83 y: Some(36.0),
84 ..Default::default()
85 },
86 indents: Vec::new(),
87 paragraphs: vec![PageParagraph {
88 id: new_id().unwrap(),
89 parent: None,
90 level: 1,
91 style: None,
92 format: Default::default(),
93 content: ParagraphContent::Text(content),
94 lists: Vec::new(),
95 tags: Vec::new(),
96 collapsed: false,
97 }],
98 unsupported: Vec::new(),
99 };
100 let at = page
101 .objects
102 .iter()
103 .position(|object| matches!(object, PageObject::Title(_)))
104 .unwrap_or(page.objects.len());
105 page.objects.insert(at, PageObject::Outline(outline));
106 id
107}
108
109#[test]
110fn atomic_page_batches_survive_reopen_with_dependent_text() {
111 let directory = tempfile::tempdir().unwrap();
112 let path = directory.path().join("pages.sqlite");
113 let cache = Replica::create(&path, SOURCE).unwrap();
114 let before = order(SOURCE);
115 let edits: Vec<_> = before[3..6]
116 .iter()
117 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
118 .collect();
119 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
120 assert!(cache.pages(SOURCE, &edits).is_err());
121 let mut expected_text = texts(SOURCE);
122 let (&(sid, oid), original) = expected_text
123 .iter()
124 .find(|(_, text)| text.starts_with("Body parent"))
125 .unwrap();
126 let changed = format!("Offline {original}");
127 expected_text.insert((sid, oid), changed);
128 model_ops::save(&cache, oid, |page| {
129 model_ops::replace_text(page, oid, 0..0, "Offline ")
130 })
131 .unwrap()
132 .unwrap();
133 let queue = cache.pending().unwrap();
134 assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits));
135 let local = cache.snapshot().unwrap();
136 drop(cache);
137 let cache = Replica::open(&path).unwrap();
138 assert_eq!(cache.pending().unwrap(), queue);
139 assert_eq!(cache.snapshot().unwrap(), local);
140 let mut server = Server::new(SOURCE);
141 for edit in queue {
142 assert!(matches!(cache.sync_once(&mut server).unwrap(),
143 Some((published, EditStatus::Published { .. })) if published == edit.id));
144 }
145 let expected = [
146 before[..3].to_vec(),
147 before[6..].to_vec(),
148 before[3..6].to_vec(),
149 ]
150 .concat();
151 assert_eq!(order(&server.durable), expected);
152 assert_eq!(texts(&server.durable), expected_text);
153 assert!(matches!(
154 cache.status(id).unwrap(),
155 Some(EditStatus::Published { .. })
156 ));
157 assert_eq!(server.publications, 2);
158}
159
160#[test]
161fn indentation_only_edits_preserve_remote_page_movement() {
162 let directory = tempfile::tempdir().unwrap();
163 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
164 let before = order(SOURCE);
165 let sid = before[4].0;
166 let id = cache
167 .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()])
168 .unwrap()
169 .unwrap();
170 let mut server = Server::new(SOURCE);
171 PreparedEdit::pages(
172 SOURCE,
173 &[PageEdit::move_to(sid, Some(before[7].0), 2).unwrap()],
174 )
175 .unwrap()
176 .commit(&mut server)
177 .unwrap();
178 let mut expected = order(&server.durable);
179 expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3;
180 assert!(matches!(cache.sync_once(&mut server).unwrap(),
181 Some((published, EditStatus::Published { .. })) if published == id));
182 assert_eq!(order(&server.durable), expected);
183}
184
185#[test]
186fn competing_page_moves_require_current_review_and_retain_intent_identities() {
187 let directory = tempfile::tempdir().unwrap();
188 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
189 let pages = order(SOURCE);
190 let sid = pages[6].0;
191 let original = PageEdit::move_to(sid, None, 1).unwrap();
192 let id = cache
193 .pages(SOURCE, std::slice::from_ref(&original))
194 .unwrap()
195 .unwrap();
196 let mut server = Server::new(SOURCE);
197 PreparedEdit::pages(
198 SOURCE,
199 &[PageEdit::move_to(sid, Some(pages[0].0), 1).unwrap()],
200 )
201 .unwrap()
202 .commit(&mut server)
203 .unwrap();
204 let remote = server.visible.clone();
205 let local = cache.snapshot().unwrap();
206 assert_eq!(
207 cache.sync_once(&mut server).unwrap(),
208 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
209 );
210 assert_eq!(server.publications, 0);
211 assert_eq!(cache.snapshot().unwrap(), local);
212 let revised = original
213 .reposition(PagePosition::Before(Some(pages[2].0)), 1)
214 .unwrap();
215 let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap();
216 assert!(
217 cache
218 .rebase_pages_conflict(id, &local, &remote, &[fresh])
219 .is_err()
220 );
221 assert!(
222 cache
223 .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised))
224 .is_err()
225 );
226 assert!(
227 cache
228 .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised))
229 .is_err()
230 );
231 cache
232 .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised))
233 .unwrap();
234 assert!(matches!(&cache.pending().unwrap()[0].operation,
235 Operation::Pages(batch) if batch.edits == [revised.clone()]));
236 let expected = PreparedEdit::pages(&remote, &[revised]).unwrap();
237 assert!(matches!(cache.sync_once(&mut server).unwrap(),
238 Some((published, EditStatus::Published { .. })) if published == id));
239 assert_eq!(order(&server.durable), order(expected.as_bytes()));
240}
241
242#[test]
243fn one_competing_level_prevents_publication_of_the_whole_batch() {
244 let directory = tempfile::tempdir().unwrap();
245 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
246 let pages = order(SOURCE);
247 let id = cache
248 .pages(
249 SOURCE,
250 &[
251 PageEdit::set_level(pages[4].0, 3).unwrap(),
252 PageEdit::set_level(pages[5].0, 2).unwrap(),
253 ],
254 )
255 .unwrap()
256 .unwrap();
257 let local = cache.snapshot().unwrap();
258 let mut server = Server::new(SOURCE);
259 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()])
260 .unwrap()
261 .commit(&mut server)
262 .unwrap();
263 let remote = server.durable.clone();
264 assert_eq!(
265 cache.sync_once(&mut server).unwrap(),
266 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
267 );
268 assert_eq!(server.publications, 0);
269 assert_eq!(server.durable, remote);
270 assert_eq!(cache.snapshot().unwrap(), local);
271}
272
273#[test]
274fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
275 for fault in [
276 Fault::UnknownBefore,
277 Fault::UnknownAfter,
278 Fault::PanicBefore,
279 Fault::PanicAfter,
280 ] {
281 let directory = tempfile::tempdir().unwrap();
282 let path = directory.path().join("pages.sqlite");
283 let mut cache = Replica::create(&path, SOURCE).unwrap();
284 let pages = order(SOURCE);
285 let edits = [
286 PageEdit::set_level(pages[4].0, 3).unwrap(),
287 PageEdit::set_level(pages[5].0, 2).unwrap(),
288 ];
289 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
290 let local = cache.snapshot().unwrap();
291 let queue = cache.pending().unwrap();
292 let mut server = Server::new(SOURCE);
293 server.fault = fault;
294 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
295 cache.sync_once(&mut server)
296 }));
297 drop(cache);
298 cache = Replica::open(&path).unwrap();
299 let attempted = cache.status(id).unwrap().unwrap();
300 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
301 let archive = directory.path().join("recovery.sqlite");
302 cache.export_recovery(&archive).unwrap();
303 let recovery = Recovery::open(&archive).unwrap();
304 assert_eq!(recovery.pending().unwrap(), queue);
305 assert_eq!(recovery.status(id).unwrap(), Some(attempted));
306 assert_eq!(recovery.snapshot().unwrap(), local);
307 assert!(
308 cache
309 .rebase_pages_conflict(id, &local, SOURCE, &edits)
310 .is_err()
311 );
312 let result = cache.sync_once(&mut server).unwrap().unwrap();
313 assert_eq!(server.publications, 1);
314 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
315 assert!(matches!(result.1, EditStatus::Published { .. }));
316 assert_eq!(server.confirmations, 1);
317 } else {
318 assert_eq!(result, (id, attempted));
319 assert_eq!(server.confirmations, 0);
320 }
321 }
322}
323
324#[test]
325fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
326 let before = order(SOURCE);
327 let sid = before[4].0;
328 let store = Store::parse(SOURCE).unwrap();
329 let index = RevisionIndex::parse(&store).unwrap();
330 let root = index.root;
331 let doc = Document::parse(&index).unwrap();
332 let space = &doc.spaces[&root];
333 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
334 let copy = *view.nodes.iter().find(|(_, node)|
335 matches!(&node.kind, Kind::Metadata { title: Some(title), .. } if title == "child"))
336 .unwrap().0;
337 let source =
338 onestore::replace_property_bytes(SOURCE, root, copy, 0x14001dff, &3_u32.to_le_bytes())
339 .unwrap();
340 let directory = tempfile::tempdir().unwrap();
341 let path = directory.path().join("pages.sqlite");
342 let cache = Replica::create(&path, &source).unwrap();
343 let id = cache
344 .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()])
345 .unwrap()
346 .unwrap();
347 let mut server = Server::new(&source);
348 server.fault = Fault::UnknownAfter;
349 assert!(cache.sync_once(&mut server).is_err());
350 let attempted = cache.status(id).unwrap().unwrap();
351 drop(cache);
352 let db = rusqlite::Connection::open(&path).unwrap();
353 let encoded: String = db
354 .query_row("SELECT revisions FROM attempt", [], |r| r.get(0))
355 .unwrap();
356 let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
357 assert_eq!(proofs.len(), 2);
358 let store = Store::parse(&source).unwrap();
359 let index = RevisionIndex::parse(&store).unwrap();
360 assert_eq!(
361 proofs[&root],
362 index.spaces[&root].labels[&(ExGuid::default(), 1)]
363 );
364 assert!(!index.spaces[&sid].revisions.contains_key(&proofs[&sid]));
365 drop(db);
366 let cache = Replica::open(&path).unwrap();
367 let complete = server.visible.clone();
368 server.visible = source;
369 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, attempted)));
370 assert_eq!(server.confirmations, 0);
371 server.visible = complete;
372 assert!(matches!(
373 cache.sync_once(&mut server).unwrap(),
374 Some((_, EditStatus::Published { .. }))
375 ));
376 assert_eq!(server.publications, 1);
377 assert_eq!(server.confirmations, 1);
378}
379
380#[test]
381fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
382 let directory = tempfile::tempdir().unwrap();
383 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
384 let pages = order(SOURCE);
385 let id = cache
386 .pages(
387 SOURCE,
388 &[
389 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),
390 PageEdit::set_level(pages[4].0, 3).unwrap(),
391 ],
392 )
393 .unwrap()
394 .unwrap();
395 let mut server = Server::new(SOURCE);
396 PreparedEdit::pages(
397 SOURCE,
398 &[PageEdit::move_to(pages[6].0, Some(pages[0].0), 1).unwrap()],
399 )
400 .unwrap()
401 .commit(&mut server)
402 .unwrap();
403 assert_eq!(
404 cache.sync_once(&mut server).unwrap(),
405 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
406 );
407 assert_eq!(server.publications, 0);
408}
409
410#[test]
411fn convergent_page_indentation_requires_confirmation_without_republishing() {
412 let directory = tempfile::tempdir().unwrap();
413 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
414 let sid = order(SOURCE)[4].0;
415 let id = cache
416 .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
417 .unwrap()
418 .unwrap();
419 let mut server = Server::new(SOURCE);
420 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
421 .unwrap()
422 .commit(&mut server)
423 .unwrap();
424 server.fault = Fault::Confirm;
425 assert!(cache.sync_once(&mut server).is_err());
426 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
427 assert_eq!(server.publications, 0);
428 assert!(matches!(cache.sync_once(&mut server).unwrap(),
429 Some((published, EditStatus::Published { .. })) if published == id));
430 assert_eq!(server.publications, 0);
431 assert_eq!(server.confirmations, 2);
432}
433#[test]
434fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
435 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
436 .join("../../corpus/page-lifecycle/movement");
437 let provenance: serde_json::Value =
438 serde_json::from_slice(&std::fs::read(root.join("provenance.json")).unwrap()).unwrap();
439 let phases = provenance["cold"].as_object().unwrap();
440 assert_eq!(phases.len(), 9);
441 let original = order(SOURCE);
442 let original_text = texts(SOURCE);
443 let (&(text_space, text_object), body) = original_text
444 .iter()
445 .find(|(_, text)| text.starts_with("Body parent"))
446 .unwrap();
447 let mut expected_text = original_text.clone();
448 expected_text.insert((text_space, text_object), format!("Offline {body}"));
449 let mut counts = [0; 2];
450 for mode in ["indent", "group"] {
451 for phase in phases.keys() {
452 let directory = tempfile::tempdir().unwrap();
453 let path = directory.path().join("pages.sqlite");
454 let cache = Replica::create(&path, SOURCE).unwrap();
455 let edits = if mode == "indent" {
456 vec![PageEdit::set_level(original[4].0, 3).unwrap()]
457 } else {
458 original[3..6]
459 .iter()
460 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
461 .collect()
462 };
463 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
464 let text_id = model_ops::save(&cache, text_object, |page| {
465 model_ops::replace_text(page, text_object, 0..0, "Offline ")
466 })
467 .unwrap()
468 .unwrap();
469 let native = std::fs::read(root.join(phase).join("notebook/Lifecycle.one")).unwrap();
470 let mut server = Server::new(&native);
471 let local = cache.snapshot().unwrap();
472 let expected_conflict = if mode == "indent" {
473 matches!(
474 phase.as_str(),
475 "04-subpage-move" | "07-promoted-root" | "08-collapsed-group-move"
476 )
477 } else {
478 !matches!(
479 phase.as_str(),
480 "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two"
481 )
482 };
483 let first = cache.sync_once(&mut server).unwrap().unwrap();
484 assert_eq!(first.0, id);
485 let mut reviewed = edits.clone();
486 if expected_conflict {
487 assert_eq!(
488 first.1,
489 EditStatus::Conflict(ConflictKind::StructureChanged),
490 "{mode}:{phase}"
491 );
492 assert_eq!(server.publications, 0);
493 assert_eq!(cache.snapshot().unwrap(), local);
494 if mode == "indent" && phase == "08-collapsed-group-move" {
495 reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap();
496 }
497 cache
498 .rebase_pages_conflict(id, &local, &native, &reviewed)
499 .unwrap();
500 assert!(matches!(cache.sync_once(&mut server).unwrap(),
501 Some((published, EditStatus::Published { .. })) if published == id));
502 } else {
503 assert!(
504 matches!(first.1, EditStatus::Published { .. }),
505 "{mode}:{phase}"
506 );
507 }
508 counts[usize::from(expected_conflict)] += 1;
509 drop(cache);
510 let cache = Replica::open(&path).unwrap();
511 assert!(matches!(cache.sync_once(&mut server).unwrap(),
512 Some((published, EditStatus::Published { .. })) if published == text_id));
513 assert!(cache.pending().unwrap().is_empty());
514 let mut expected = order(&native);
515 for edit in &reviewed {
516 let at = expected.iter().position(|p| p.0 == edit.space()).unwrap();
517 expected[at].1 = edit.level();
518 if let PagePosition::Before(before) = edit.position() {
519 let page = expected.remove(at);
520 let at = before.map_or(expected.len(), |sid| {
521 expected.iter().position(|p| p.0 == sid).unwrap()
522 });
523 expected.insert(at, page);
524 }
525 }
526 assert_eq!(order(&server.durable), expected, "{mode}:{phase}");
527 assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}");
528 assert_eq!(
529 server.publications,
530 1 + usize::from(expected != order(&native))
531 );
532 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") {
533 let output = std::path::Path::new(&output);
534 assert!(output.is_absolute());
535 let path = output.join(format!("{mode}-{phase}"));
536 std::fs::create_dir_all(&path).unwrap();
537 std::fs::write(path.join("Lifecycle.one"), &server.durable).unwrap();
538 std::fs::write(
539 path.join("manifest.json"),
540 serde_json::to_vec_pretty(
541 &serde_json::json!({"mode": mode, "phase": phase, "original": edits,
542 "reviewed": reviewed, "conflict": expected_conflict,
543 "publications": server.publications, "confirmations": server.confirmations,
544 "page_receipt": format!("{:?}", cache.status(id).unwrap()),
545 "text_receipt": format!("{:?}", cache.status(text_id).unwrap())}),
546 )
547 .unwrap(),
548 )
549 .unwrap();
550 }
551 }
552 }
553 assert_eq!(counts, [9, 9]);
554}
555
556#[test]
557fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
558 let mut source = onestore::create_section("pages.one", "Sentinel", "Author").unwrap();
559 let mut created = Vec::new();
560 for _ in 0..36 {
561 let page = onestore::PageCreation::new(None, Some("Same title"), "Author").unwrap();
562 source = PreparedEdit::create_page(&source, &page)
563 .unwrap()
564 .as_bytes()
565 .to_vec();
566 created.push(page);
567 }
568 let mut expected = vec![order(&source)[0]];
569 let mut expected_text = texts(&source);
570 let directory = tempfile::tempdir().unwrap();
571 let mut queues = Vec::new();
572 for (actor, group) in created.chunks_exact(3).enumerate() {
573 let path = directory.path().join(format!("{actor}.sqlite"));
574 let cache = Replica::create(&path, &source).unwrap();
575 let edits = [
576 PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(),
577 PageEdit::set_level(group[2].space(), 2).unwrap(),
578 ];
579 cache.pages(&source, &edits).unwrap().unwrap();
580 let body = format!("Actor {actor} 🦀 é");
581 let local = cache.snapshot().unwrap();
582 let mut model = model_ops::page_of(&local, group[1].space());
583 let text = body_outline(&mut model, &body);
584 cache
585 .save(&local, group[1].space(), &model, "Author")
586 .unwrap()
587 .unwrap();
588 expected.extend([
589 (group[1].space(), 1),
590 (group[0].space(), 1),
591 (group[2].space(), 2),
592 ]);
593 expected_text.insert((group[1].space(), text), body);
594 queues.push((path, cache.pending().unwrap()));
595 }
596 let mut server = Server::new(&source);
597 for (path, queue) in &queues {
598 let cache = Replica::open(path).unwrap();
599 assert_eq!(cache.pending().unwrap(), *queue);
600 for edit in queue {
601 assert!(matches!(cache.sync_once(&mut server).unwrap(),
602 Some((published, EditStatus::Published { .. })) if published == edit.id));
603 }
604 assert!(cache.pending().unwrap().is_empty());
605 }
606 assert_eq!(server.publications, 24);
607 assert_eq!(order(&server.durable), expected);
608 assert_eq!(texts(&server.durable), expected_text);
609 for (path, queue) in &queues {
610 let cache = Replica::open(path).unwrap();
611 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
612 assert_eq!(order(&cache.snapshot().unwrap()), expected);
613 for edit in queue {
614 assert!(matches!(
615 cache.status(edit.id).unwrap(),
616 Some(EditStatus::Published { .. })
617 ));
618 }
619 }
620 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_CLIENT_OUTPUT") {
621 let output = std::path::Path::new(&output);
622 assert!(output.is_absolute());
623 std::fs::create_dir_all(output).unwrap();
624 std::fs::write(output.join("pages.one"), &server.durable).unwrap();
625 let operations: Vec<_> = queues
626 .iter()
627 .map(|(_, queue)| {
628 queue
629 .iter()
630 .map(|edit| {
631 serde_json::json!({"id": edit.id, "space": edit.space,
632 "operation": edit.operation})
633 })
634 .collect::<Vec<_>>()
635 })
636 .collect();
637 std::fs::write(
638 output.join("manifest.json"),
639 serde_json::to_vec_pretty(
640 &serde_json::json!({"operations": operations, "publications": server.publications,
641 "expected_order": expected, "pages": 37}),
642 )
643 .unwrap(),
644 )
645 .unwrap();
646 }
647}
crates/notebook/tests/sync_paragraph.rs created+860
......@@ -0,0 +1,860 @@
1//! Paragraph splits and joins expressed as page-model differences: reconciliation against
2//! native OneNote captures, dependent edits, uncertain attempts and competing clients.
3
4use notebook::{ConflictKind, EditStatus, Replica};
5use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,
7 document::Document,
8 page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id},
9};
10use std::path::Path;
11
12#[path = "support/server.rs"]
13mod server;
14use server::{Fault, Server};
15#[path = "support/model_ops.rs"]
16mod model_ops;
17use model_ops::{AUTHOR, outlines_mut, page_of, replace_text};
18
19const BEFORE: &[u8] =
20 include_bytes!("../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one");
21const KEYBOARD: &[u8] =
22 include_bytes!("../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one");
23const REPLACED: &[u8] =
24 include_bytes!("../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one");
25const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/paragraph-edit");
26
27fn at(list: &[PageParagraph], text: ExGuid) -> Option<usize> {
28 list.iter()
29 .position(|p| p.text().is_some_and(|t| t.id == text))
30}
31
32/// Applies `edit` to the paragraph list holding `text`, descending into table cells.
33fn in_list<T>(
34 list: &mut Vec<PageParagraph>,
35 text: ExGuid,
36 edit: &mut impl FnMut(&mut Vec<PageParagraph>, usize) -> T,
37) -> Option<T> {
38 if let Some(index) = at(list, text) {
39 return Some(edit(list, index));
40 }
41 for paragraph in list {
42 if let ParagraphContent::Table(table) = &mut paragraph.content {
43 for row in &mut table.rows {
44 for cell in &mut row.cells {
45 if let Some(value) = in_list(&mut cell.paragraphs, text, edit) {
46 return Some(value);
47 }
48 }
49 }
50 }
51 }
52 None
53}
54
55fn locate_in<T>(
56 page: &mut Page,
57 text: ExGuid,
58 mut edit: impl FnMut(&mut Vec<PageParagraph>, usize) -> T,
59) -> T {
60 for outline in outlines_mut(page) {
61 if let Some(value) = in_list(&mut outline.paragraphs, text, &mut edit) {
62 return value;
63 }
64 }
65 panic!("the edited paragraph is on the page");
66}
67
68/// Splits the paragraph carrying `text` at a UTF-16 offset: the text tail and the children
69/// move to a new following paragraph, as pressing Enter mid-paragraph does.
70fn split_paragraph(page: &mut Page, text: ExGuid, offset: u32) {
71 locate_in(page, text, |list, index| {
72 // One identity per split, numbered as OneNote numbers the paragraph it creates.
73 let guid = new_id().unwrap().guid;
74 let mut right = list[index].clone();
75 right.id = ExGuid { guid, n: 1 };
76 right.tags.clear();
77 let source = list[index].text().unwrap().text.clone();
78 let end = source.utf16_offset(source.text().len()).unwrap();
79 list[index].text_mut().unwrap().text = source.slice(0..offset).unwrap();
80 right.content = ParagraphContent::Text(TextObject {
81 id: ExGuid { guid, n: 2 },
82 date_field: None,
83 text: source.slice(offset..end).unwrap(),
84 tags: Vec::new(),
85 });
86 let (left, tail) = (list[index].id, right.id);
87 for paragraph in &mut list[index + 1..] {
88 if paragraph.parent == Some(left) {
89 paragraph.parent = Some(tail);
90 }
91 }
92 list.insert(index + 1, right);
93 })
94}
95
96/// Joins the paragraph carrying `right` into the one carrying `left`; an empty left text
97/// object is replaced by a non-empty right one, which is the identity OneNote keeps.
98fn join_paragraphs(page: &mut Page, left: ExGuid, right: ExGuid) {
99 locate_in(page, left, |list, first| {
100 let second = at(list, right).expect("the joined paragraphs share a container");
101 let (parent, orphaned, removed) = (
102 list[first].id,
103 list[second].id,
104 list[second].text().unwrap().clone(),
105 );
106 let adopts = !removed.text.text().is_empty();
107 let target = list[first].text_mut().unwrap();
108 let mut joined = target.text.clone();
109 joined.append(removed.text).unwrap();
110 if adopts && target.text.text().is_empty() {
111 target.id = removed.id;
112 }
113 target.text = joined;
114 list.remove(second);
115 for paragraph in list {
116 if paragraph.parent == Some(orphaned) {
117 paragraph.parent = Some(parent);
118 }
119 }
120 });
121}
122
123/// The paragraphs of a page's single body outline.
124fn body(page: &Page) -> &[PageParagraph] {
125 page.objects
126 .iter()
127 .find_map(|object| match object {
128 PageObject::Outline(outline) => Some(&outline.paragraphs[..]),
129 _ => None,
130 })
131 .unwrap()
132}
133
134fn texts(page: &Page) -> Vec<String> {
135 body(page)
136 .iter()
137 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
138 .collect()
139}
140
141/// The page space and model holding a text object anywhere on a page, table cells included.
142fn holder(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) {
143 let store = Store::parse(bytes).unwrap();
144 let index = RevisionIndex::parse(&store).unwrap();
145 let document = Document::parse(&index).unwrap();
146 document
147 .pages()
148 .unwrap()
149 .into_iter()
150 .find_map(|(space, _)| {
151 let mut page = Page::from_space(&document, space).ok()?;
152 let found = outlines_mut(&mut page)
153 .into_iter()
154 .any(|outline| in_list(&mut outline.paragraphs, text, &mut |_, _| ()).is_some());
155 found.then_some((space, page))
156 })
157 .expect("text object belongs to an active page")
158}
159
160fn save_page(
161 cache: &Replica,
162 text: ExGuid,
163 edit: impl FnOnce(&mut Page),
164) -> Result<Option<u64>, notebook::Error> {
165 let source = cache.snapshot()?;
166 let (space, mut page) = holder(&source, text);
167 edit(&mut page);
168 cache.save(&source, space, &page, AUTHOR)
169}
170
171/// Reviews a conflicting save by applying `edit` to the page the remote now holds.
172fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) {
173 let local = cache.snapshot().unwrap();
174 let remote = cache.remote_snapshot().unwrap();
175 let (_, mut page) = holder(&remote, text);
176 edit(&mut page);
177 cache.review_page(id, &local, &remote, &page).unwrap();
178}
179
180/// The styled text of an object anywhere on a page.
181fn content(page: &Page, text: ExGuid) -> onestore::page::Paragraph {
182 locate_in(&mut page.clone(), text, |list, index| {
183 list[index].text().unwrap().text.clone()
184 })
185}
186
187/// Rewrites one character of a text object, the dependent edit the native lane checks.
188fn dependent_edit(page: &mut Page, text: ExGuid) {
189 let content = content(page, text);
190 match content.text().find('c') {
191 Some(byte) => {
192 let start = content.utf16_offset(byte).unwrap();
193 replace_text(page, text, start..start + 1, "C");
194 }
195 None => replace_text(page, text, 1..2, "I"),
196 }
197}
198
199/// The reconciliation fixture's split and join controls, in page order.
200fn controls() -> Vec<(ExGuid, String, bool)> {
201 let store = Store::parse(BEFORE).unwrap();
202 let index = RevisionIndex::parse(&store).unwrap();
203 let document = Document::parse(&index).unwrap();
204 document
205 .pages()
206 .unwrap()
207 .into_iter()
208 .filter_map(|(space, _)| {
209 let title = Page::from_space(&document, space).unwrap().title;
210 let split = title.starts_with("Split ");
211 (split || title.starts_with("Join ")).then_some((space, title, split))
212 })
213 .collect()
214}
215
216/// Publishes every control's split or join against the native keyboard capture, reviewing
217/// whatever conflicts, then a dependent edit of the resulting paragraph. Returns the
218/// reconciled remote image and one manifest entry per control.
219fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
220 let mut server = Server::new(KEYBOARD);
221 let mut recorded = Vec::new();
222 let mut automatic = 0;
223 for (space, name, split) in controls() {
224 let original = page_of(BEFORE, space);
225 let left = body(&original)[0].text().unwrap().id;
226 let right = (!split).then(|| body(&original)[1].text().unwrap().id);
227 let apply = |page: &mut Page| match right {
228 Some(right) => join_paragraphs(page, left, right),
229 None => split_paragraph(page, left, 2),
230 };
231 let directory = tempfile::tempdir().unwrap();
232 let path = directory.path().join("reconciliation.sqlite");
233 let mut cache = Replica::create(&path, BEFORE).unwrap();
234 let mut after = original.clone();
235 apply(&mut after);
236 let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap();
237 let pending = cache.pending().unwrap();
238 drop(cache);
239 cache = Replica::open(&path).unwrap();
240 assert_eq!(cache.pending().unwrap(), pending);
241 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
242 assert_eq!(actual, id);
243 let reviewed = matches!(status, EditStatus::Conflict(_));
244 let receipt = if reviewed {
245 assert_eq!(cache.pending().unwrap(), pending);
246 review_page(&cache, id, left, apply);
247 cache.sync_once(&mut server).unwrap().unwrap().1
248 } else {
249 automatic += 1;
250 status
251 };
252 let EditStatus::Published { revision } = receipt else {
253 panic!("{name}: {receipt:?}")
254 };
255 let published = page_of(&server.visible, space);
256 let index = body(&published)
257 .iter()
258 .position(|p| {
259 p.text()
260 .is_some_and(|t| t.id == left || Some(t.id) == right)
261 })
262 .unwrap();
263 let intent = match right {
264 Some(right) => serde_json::json!({"author": AUTHOR, "left": left, "right": right}),
265 None => {
266 // The native lane derives both new identities from the guid recorded here.
267 let tail = &body(&published)[index + 1];
268 let guid = tail.id.guid;
269 assert_eq!(tail.id, ExGuid { guid, n: 1 }, "{name}");
270 assert_eq!(tail.text().unwrap().id, ExGuid { guid, n: 2 }, "{name}");
271 serde_json::json!({"author": AUTHOR, "offset": 2, "text": left, "guid": guid})
272 }
273 };
274 let target = body(&published)[if split { index + 1 } else { index }]
275 .text()
276 .unwrap()
277 .id;
278 drop(cache);
279 cache = Replica::open(&path).unwrap();
280 let dependent = save_page(&cache, target, |page| dependent_edit(page, target))
281 .unwrap()
282 .unwrap();
283 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
284 assert_eq!(actual, dependent);
285 let EditStatus::Published {
286 revision: dependent_revision,
287 } = status
288 else {
289 panic!("{name} dependent: {status:?}")
290 };
291 assert!(cache.pending().unwrap().is_empty());
292 assert_eq!(cache.snapshot().unwrap(), server.durable);
293 recorded.push(
294 serde_json::json!({"case": name, "space": space, "intent": intent,
295 "outcome": if reviewed { "reviewed" } else { "automatic" },
296 "receipt": revision, "dependent_receipt": dependent_revision}),
297 );
298 }
299 assert_eq!((recorded.len(), automatic), (16, 6));
300 assert_eq!(server.publications, 32);
301 (server.durable, recorded)
302}
303
304#[test]
305fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review() {
306 let (durable, cases) = reconcile_controls();
307 let expected = [
308 ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]),
309 ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]),
310 ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]),
311 ("Split remote list", "automatic", vec!["ab", "🦀Cd"]),
312 ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]),
313 ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]),
314 ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]),
315 ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]),
316 ("Join left boundary", "reviewed", vec!["ab🦀CdXRight"]),
317 ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]),
318 ("Join remote child", "reviewed", vec!["ab🦀CdRight"]),
319 ("Join remote list", "reviewed", vec!["ab🦀CdRight"]),
320 ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]),
321 ("Join remote format", "reviewed", vec!["ab🦀CdRight"]),
322 ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]),
323 ("Join empty adoption", "reviewed", vec!["XIight"]),
324 ];
325 for (case, (name, outcome, prefix)) in cases.iter().zip(expected) {
326 assert_eq!(case["case"], name);
327 assert_eq!(case["outcome"], outcome, "{name}");
328 let space: ExGuid = serde_json::from_value(case["space"].clone()).unwrap();
329 let published = texts(&page_of(&durable, space));
330 assert_eq!(&published[..prefix.len()], prefix, "{name}");
331 assert!(
332 published.iter().any(|text| text.contains("sibling")),
333 "{name}: {published:?}"
334 );
335 }
336}
337
338#[test]
339fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join() {
340 let mut server = Server::new(REPLACED);
341 for (space, name, split) in controls() {
342 let original = page_of(BEFORE, space);
343 let left = body(&original)[0].text().unwrap().id;
344 let directory = tempfile::tempdir().unwrap();
345 let path = directory.path().join("replaced.sqlite");
346 let cache = Replica::create(&path, BEFORE).unwrap();
347 let mut after = original.clone();
348 if split {
349 split_paragraph(&mut after, left, 2);
350 } else {
351 join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id);
352 }
353 let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap();
354 let local = cache.snapshot().unwrap();
355 let pending = cache.pending().unwrap();
356 assert_eq!(
357 cache.sync_once(&mut server).unwrap(),
358 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
359 "{name}"
360 );
361 drop(cache);
362 let cache = Replica::open(&path).unwrap();
363 assert_eq!(cache.snapshot().unwrap(), local);
364 assert_eq!(cache.pending().unwrap(), pending);
365 assert_eq!(
366 cache.status(id).unwrap(),
367 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
368 );
369 }
370 assert_eq!(server.publications, 0);
371 assert_eq!(server.visible, REPLACED);
372}
373
374/// Replays a recorded native split or join intent as a page-model difference.
375fn replay(page: &mut Page, intent: &serde_json::Value, split: bool) {
376 if split {
377 split_paragraph(
378 page,
379 serde_json::from_value(intent["text"].clone()).unwrap(),
380 u32::try_from(intent["offset"].as_u64().unwrap()).unwrap(),
381 );
382 } else {
383 join_paragraphs(
384 page,
385 serde_json::from_value(intent["left"].clone()).unwrap(),
386 serde_json::from_value(intent["right"].clone()).unwrap(),
387 );
388 }
389}
390
391/// The text object a recorded intent starts from.
392fn origin(intent: &serde_json::Value, split: bool) -> ExGuid {
393 serde_json::from_value(intent[if split { "text" } else { "left" }].clone()).unwrap()
394}
395
396/// Replays every recorded native split and join intent as a page-model save, against a
397/// remote that prefixed the edited text wherever a prefix is distinguishable.
398fn offline_paragraphs(output: Option<&Path>) {
399 let root = Path::new(CORPUS);
400 for (name, source, recorded, split) in [
401 (
402 "splits",
403 "before/notebook/synthetic.one",
404 "rust-split/manifest.json",
405 true,
406 ),
407 (
408 "joins",
409 "split/notebook/synthetic.one",
410 "rust-join/split/manifest.json",
411 false,
412 ),
413 (
414 "inheritance",
415 "join-edges/before/notebook/synthetic.one",
416 "rust-join/inheritance/manifest.json",
417 false,
418 ),
419 (
420 "tags",
421 "join-tags/before/notebook/synthetic.one",
422 "rust-join/tags/manifest.json",
423 false,
424 ),
425 ] {
426 let source = std::fs::read(root.join(source)).unwrap();
427 let directory = tempfile::tempdir().unwrap();
428 let folder = output.map_or_else(|| directory.path().to_owned(), |at| at.join(name));
429 std::fs::create_dir_all(&folder).unwrap();
430 let path = folder.join("cache.sqlite");
431 let mut cache = Replica::create(&path, &source).unwrap();
432 let mut server = Server::new(&source);
433 let manifest: serde_json::Value =
434 serde_json::from_slice(&std::fs::read(root.join(recorded)).unwrap()).unwrap();
435 let cases = if split { &manifest["cases"] } else { &manifest };
436 let mut entries = Vec::new();
437 let mut refused: Vec<String> = Vec::new();
438 for case in cases
439 .as_array()
440 .unwrap()
441 .iter()
442 .filter(|case| case.get("intent").is_some())
443 {
444 let intent = &case["intent"];
445 let left = origin(intent, split);
446 let (space, page) = holder(&cache.snapshot().unwrap(), left);
447 let offset = if split {
448 intent["offset"].as_u64().unwrap()
449 } else {
450 1
451 };
452 let prefixed = offset > 0 && !content(&page, left).text().is_empty();
453 if prefixed {
454 server.visible =
455 onestore::replace_text(&server.visible, space, left, 0..0, "\u{2602}").unwrap();
456 server.durable.clone_from(&server.visible);
457 }
458 let Ok(queued) = save_page(&cache, left, |page| replay(page, intent, split)) else {
459 // Joining runs that differ in language or spacing is not a page-model edit.
460 refused.push(case["case"].as_str().unwrap().to_owned());
461 continue;
462 };
463 let id = queued.unwrap();
464 entries.push(
465 serde_json::json!({"case": case["case"], "id": id, "space": space,
466 "intent": intent, "remote_prefix": prefixed}),
467 );
468 drop(cache);
469 cache = Replica::open(&path).unwrap();
470 }
471 let mut reviewed: Vec<String> = Vec::new();
472 for entry in &mut entries {
473 let id = entry["id"].as_u64().unwrap();
474 let case = entry["case"].as_str().unwrap().to_owned();
475 let intent = entry["intent"].clone();
476 let mut uncertain = id % 2 == 0;
477 let revision = loop {
478 if uncertain {
479 server.fault = Fault::UnknownAfter;
480 }
481 match cache.sync_once(&mut server) {
482 Err(_) => {
483 assert!(matches!(
484 cache.status(id).unwrap(),
485 Some(EditStatus::AwaitingConfirmation { .. })
486 ));
487 uncertain = false;
488 drop(cache);
489 cache = Replica::open(&path).unwrap();
490 }
491 Ok(Some((actual, EditStatus::Published { revision }))) => {
492 assert_eq!(actual, id);
493 break revision;
494 }
495 Ok(Some((actual, EditStatus::Conflict(_)))) => {
496 assert_eq!(actual, id);
497 server.fault = Fault::None;
498 reviewed.push(case.clone());
499 review_page(&cache, id, origin(&intent, split), |page| {
500 replay(page, &intent, split)
501 });
502 }
503 other => panic!("{name} {}: {other:?}", entry["case"]),
504 }
505 };
506 entry["revision"] = serde_json::to_value(revision).unwrap();
507 }
508 // A split inside a table cell the remote also changed overlaps at the cell.
509 let structural = ["Split cell".to_owned()];
510 let unrepresentable = ["Join inherited styles".to_owned()];
511 let expected: (usize, &[String], &[String]) = match name {
512 "splits" | "joins" => (12, &structural, &[]),
513 "inheritance" => (4, &[], &unrepresentable),
514 _ => (3, &[], &[]),
515 };
516 assert_eq!(
517 (entries.len(), reviewed.as_slice(), refused.as_slice()),
518 expected,
519 "{name}"
520 );
521 assert_eq!(server.publications, entries.len());
522 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
523 assert_eq!(cache.snapshot().unwrap(), server.durable);
524 if output.is_some() {
525 cache
526 .export_recovery(folder.join("recovery.sqlite"))
527 .unwrap();
528 std::fs::create_dir(folder.join("candidate")).unwrap();
529 std::fs::write(folder.join("candidate/synthetic.one"), &server.durable).unwrap();
530 std::fs::write(
531 folder.join("manifest.json"),
532 serde_json::to_vec_pretty(&entries).unwrap(),
533 )
534 .unwrap();
535 }
536 }
537}
538
539/// A control page of the reconciliation fixture, by title.
540fn control(title: &str) -> (ExGuid, Page) {
541 let (space, ..) = controls()
542 .into_iter()
543 .find(|(_, name, _)| name == title)
544 .unwrap();
545 (space, page_of(BEFORE, space))
546}
547
548/// A remote holding the control page with `change` applied by a native author.
549fn remote_with(space: ExGuid, change: impl FnOnce(&mut Page)) -> Server {
550 let mut page = page_of(BEFORE, space);
551 change(&mut page);
552 Server::new(
553 PreparedEdit::page(BEFORE, space, &page, "Native author")
554 .unwrap()
555 .as_bytes(),
556 )
557}
558
559#[test]
560fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
561 let (space, page) = control("Join remote prefix");
562 let left = body(&page)[0].text().unwrap().id;
563 let directory = tempfile::tempdir().unwrap();
564 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
565 let mut server = Server::new(BEFORE);
566 save_page(&cache, left, |page| split_paragraph(page, left, 2))
567 .unwrap()
568 .unwrap();
569 assert!(matches!(
570 cache.sync_once(&mut server).unwrap(),
571 Some((_, EditStatus::Published { .. }))
572 ));
573 let published = page_of(&server.durable, space);
574 assert_eq!(
575 texts(&published),
576 ["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
577 );
578 let right = body(&published)[1].text().unwrap().id;
579 save_page(&cache, left, |page| join_paragraphs(page, left, right))
580 .unwrap()
581 .unwrap();
582 assert!(matches!(
583 cache.sync_once(&mut server).unwrap(),
584 Some((_, EditStatus::Published { .. }))
585 ));
586 assert_eq!(
587 texts(&page_of(&server.durable, space)),
588 ["ab\u{1f980}cd", "Right", "Preserved sibling"]
589 );
590 assert_eq!(server.publications, 2);
591 assert_eq!(cache.snapshot().unwrap(), server.durable);
592}
593
594#[test]
595fn a_split_merges_with_a_remote_edit_to_another_paragraph() {
596 let (space, page) = control("Join remote prefix");
597 let (left, other) = (
598 body(&page)[0].text().unwrap().id,
599 body(&page)[1].text().unwrap().id,
600 );
601 let directory = tempfile::tempdir().unwrap();
602 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
603 let id = save_page(&cache, left, |page| split_paragraph(page, left, 2))
604 .unwrap()
605 .unwrap();
606 let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote "));
607 assert!(
608 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
609 );
610 assert_eq!(
611 texts(&page_of(&server.durable, space)),
612 ["ab", "\u{1f980}cd", "Remote Right", "Preserved sibling"]
613 );
614}
615
616#[test]
617fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() {
618 let (space, page) = control("Join remote prefix");
619 let (left, right) = (
620 body(&page)[0].text().unwrap().id,
621 body(&page)[1].text().unwrap().id,
622 );
623 let directory = tempfile::tempdir().unwrap();
624 let cache = Replica::create(directory.path().join("cache.sqlite"), BEFORE).unwrap();
625 let id = save_page(&cache, left, |page| join_paragraphs(page, left, right))
626 .unwrap()
627 .unwrap();
628 let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote "));
629 assert_eq!(
630 cache.sync_once(&mut server).unwrap(),
631 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
632 );
633 assert_eq!(server.publications, 0);
634 review_page(&cache, id, left, |page| join_paragraphs(page, left, right));
635 assert!(matches!(
636 cache.sync_once(&mut server).unwrap(),
637 Some((_, EditStatus::Published { .. }))
638 ));
639 assert_eq!(
640 texts(&page_of(&server.durable, space))[0],
641 "ab\u{1f980}cdRemote Right"
642 );
643}
644
645#[test]
646fn edits_dependent_on_a_published_split_survive_reopen() {
647 let (space, page) = control("Join remote prefix");
648 let (left, other) = (
649 body(&page)[0].text().unwrap().id,
650 body(&page)[1].text().unwrap().id,
651 );
652 let directory = tempfile::tempdir().unwrap();
653 let path = directory.path().join("dependent.sqlite");
654 let mut cache = Replica::create(&path, BEFORE).unwrap();
655 let mut server = Server::new(BEFORE);
656 save_page(&cache, left, |page| split_paragraph(page, left, 2))
657 .unwrap()
658 .unwrap();
659 cache.sync_once(&mut server).unwrap().unwrap();
660 let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1]
661 .text()
662 .unwrap()
663 .id;
664 let dependent = save_page(&cache, tail, |page| {
665 replace_text(page, tail, 0..0, "Local ")
666 })
667 .unwrap()
668 .unwrap();
669 let local = cache.snapshot().unwrap();
670 let pending = cache.pending().unwrap();
671 drop(cache);
672 cache = Replica::open(&path).unwrap();
673 assert_eq!(cache.snapshot().unwrap(), local);
674 assert_eq!(cache.pending().unwrap(), pending);
675 let mut remote = page_of(&server.durable, space);
676 replace_text(&mut remote, other, 0..0, "Remote ");
677 server.visible = PreparedEdit::page(&server.durable, space, &remote, "Native author")
678 .unwrap()
679 .as_bytes()
680 .to_vec();
681 server.durable.clone_from(&server.visible);
682 assert!(
683 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent)
684 );
685 assert_eq!(
686 texts(&page_of(&server.durable, space)),
687 [
688 "ab",
689 "Local \u{1f980}cd",
690 "Remote Right",
691 "Preserved sibling"
692 ]
693 );
694}
695
696#[test]
697fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
698 for join in [false, true] {
699 for fault in [
700 Fault::Before,
701 Fault::UnknownBefore,
702 Fault::UnknownAfter,
703 Fault::Committed,
704 ] {
705 let (space, page) = control("Join remote prefix");
706 let (left, right) = (
707 body(&page)[0].text().unwrap().id,
708 body(&page)[1].text().unwrap().id,
709 );
710 let directory = tempfile::tempdir().unwrap();
711 let path = directory.path().join("uncertain.sqlite");
712 let cache = Replica::create(&path, BEFORE).unwrap();
713 let id = save_page(&cache, left, |page| {
714 if join {
715 join_paragraphs(page, left, right);
716 } else {
717 split_paragraph(page, left, 2);
718 }
719 })
720 .unwrap()
721 .unwrap();
722 let local = cache.snapshot().unwrap();
723 let pending = cache.pending().unwrap();
724 let mut server = Server::new(BEFORE);
725 server.fault = fault;
726 assert!(cache.sync_once(&mut server).is_err());
727 let state = cache.status(id).unwrap().unwrap();
728 drop(cache);
729 let cache = Replica::open(&path).unwrap();
730 assert_eq!(cache.status(id).unwrap(), Some(state));
731 match fault {
732 Fault::Before => {
733 assert_eq!(state, EditStatus::Pending);
734 assert_eq!(cache.pending().unwrap(), pending);
735 assert!(matches!(
736 cache.sync_once(&mut server).unwrap().unwrap().1,
737 EditStatus::Published { .. }
738 ));
739 assert_eq!(server.publications, 2);
740 }
741 Fault::UnknownBefore => {
742 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
743 assert_eq!(cache.pending().unwrap(), pending);
744 assert_eq!(cache.snapshot().unwrap(), local);
745 for _ in 0..3 {
746 assert_eq!(cache.sync_once(&mut server).unwrap(), Some((id, state)));
747 }
748 assert_eq!(server.publications, 1);
749 assert_eq!(server.durable, BEFORE);
750 continue;
751 }
752 Fault::UnknownAfter => {
753 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
754 assert_eq!(cache.snapshot().unwrap(), local);
755 assert!(matches!(
756 cache.sync_once(&mut server).unwrap().unwrap().1,
757 EditStatus::Published { .. }
758 ));
759 assert_eq!(server.publications, 1);
760 }
761 _ => {
762 assert!(matches!(state, EditStatus::Published { .. }));
763 assert_eq!(server.publications, 1);
764 }
765 }
766 assert!(cache.pending().unwrap().is_empty());
767 let expected: &[&str] = if join {
768 &["ab\u{1f980}cdRight", "Preserved sibling"]
769 } else {
770 &["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
771 };
772 assert_eq!(texts(&page_of(&server.durable, space)), expected);
773 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
774 assert_eq!(cache.snapshot().unwrap(), server.durable);
775 }
776 }
777}
778
779#[test]
780fn a_competing_join_leaves_the_other_client_a_reviewable_split() {
781 let (space, page) = control("Join remote prefix");
782 let (left, right) = (
783 body(&page)[0].text().unwrap().id,
784 body(&page)[1].text().unwrap().id,
785 );
786 let directory = tempfile::tempdir().unwrap();
787 let first = Replica::create(directory.path().join("first.sqlite"), BEFORE).unwrap();
788 let path = directory.path().join("second.sqlite");
789 let second = Replica::create(&path, BEFORE).unwrap();
790 save_page(&first, left, |page| join_paragraphs(page, left, right))
791 .unwrap()
792 .unwrap();
793 let id = save_page(&second, right, |page| split_paragraph(page, right, 2))
794 .unwrap()
795 .unwrap();
796 let local = second.snapshot().unwrap();
797 let pending = second.pending().unwrap();
798 let mut server = Server::new(BEFORE);
799 assert!(matches!(
800 first.sync_once(&mut server).unwrap().unwrap().1,
801 EditStatus::Published { .. }
802 ));
803 assert_eq!(
804 second.sync_once(&mut server).unwrap(),
805 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
806 );
807 assert_eq!(server.publications, 1);
808 drop(second);
809 let second = Replica::open(&path).unwrap();
810 assert_eq!(second.snapshot().unwrap(), local);
811 assert_eq!(second.pending().unwrap(), pending);
812 assert_eq!(
813 second.status(id).unwrap(),
814 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
815 );
816 review_page(&second, id, left, |page| {
817 split_paragraph(page, left, 2);
818 });
819 assert!(matches!(
820 second.sync_once(&mut server).unwrap().unwrap().1,
821 EditStatus::Published { .. }
822 ));
823 assert_eq!(
824 texts(&page_of(&server.durable, space)),
825 ["ab", "\u{1f980}cdRight", "Preserved sibling"]
826 );
827}
828
829#[test]
830fn recorded_native_splits_and_joins_replay_over_a_remote_prefix() {
831 offline_paragraphs(None);
832}
833
834#[test]
835#[ignore = "exports native-controlled reconciliation for cold OneNote validation"]
836fn export_native_reconciliation() {
837 let output = std::path::PathBuf::from(
838 std::env::var_os("ONESTORE_NATIVE_RECONCILIATION_OUTPUT").unwrap(),
839 );
840 assert!(output.is_absolute());
841 std::fs::create_dir(&output).unwrap();
842 let (bytes, cases) = reconcile_controls();
843 std::fs::create_dir(output.join("candidate")).unwrap();
844 std::fs::write(output.join("candidate/synthetic.one"), bytes).unwrap();
845 std::fs::write(
846 output.join("manifest.json"),
847 serde_json::to_vec_pretty(&cases).unwrap(),
848 )
849 .unwrap();
850}
851
852#[test]
853#[ignore = "exports reconciled paragraph edits for independent native validation"]
854fn export_native_offline_paragraphs() {
855 let output =
856 std::path::PathBuf::from(std::env::var_os("ONESTORE_OFFLINE_PARAGRAPH_OUTPUT").unwrap());
857 assert!(output.is_absolute());
858 std::fs::create_dir(&output).unwrap();
859 offline_paragraphs(Some(&output));
860}
crates/notebook/tests/sync_worker.rs created+692
......@@ -0,0 +1,692 @@
1//! The background worker: waking, backoff, ownership and error propagation.
2
3use notebook::{ConflictKind, EditStatus, Error, Remote, Replica};
4use onestore::{
5 CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document,
6};
7use std::io;
8
9#[path = "support/server.rs"]
10mod server;
11use server::*;
12#[path = "support/model_ops.rs"]
13mod model_ops;
14
15use std::{
16 ops::Range,
17 sync::{Arc, Mutex, mpsc},
18 time::{Duration, Instant},
19};
20
21#[derive(Clone)]
22struct Shared(Arc<Mutex<Server>>);
23
24impl Remote for Shared {
25 fn read(&mut self) -> io::Result<Vec<u8>> {
26 self.0.lock().unwrap().read()
27 }
28 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
29 self.0.lock().unwrap().publish(edit)
30 }
31 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
32 self.0.lock().unwrap().confirm(snapshot)
33 }
34}
35
36/// Saves a replacement of `range` in the page holding `text`.
37fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {
38 model_ops::save(cache, text, |page| {
39 model_ops::replace_text(page, text, range, replacement)
40 })
41 .unwrap()
42}
43
44/// The text of the paragraph holding `object`, wherever the section's pages hold it.
45fn content(bytes: &[u8], object: ExGuid) -> String {
46 let (_, page) = model_ops::locate(bytes, object);
47 model_ops::paragraph_with(&page, object)
48 .unwrap()
49 .text()
50 .unwrap()
51 .text
52 .text()
53 .to_owned()
54}
55
56fn pages(bytes: &[u8]) -> usize {
57 let store = Store::parse(bytes).unwrap();
58 let index = RevisionIndex::parse(&store).unwrap();
59 Document::parse(&index).unwrap().pages().unwrap().len()
60}
61
62#[test]
63fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
64 struct Session {
65 shared: Shared,
66 fail_read: bool,
67 }
68 impl Remote for Session {
69 fn read(&mut self) -> io::Result<Vec<u8>> {
70 if self.fail_read {
71 return Err(io::ErrorKind::ConnectionReset.into());
72 }
73 self.shared.read()
74 }
75 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
76 self.shared.publish(edit)
77 }
78 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
79 self.shared.confirm(snapshot)
80 }
81 }
82 let dir = tempfile::tempdir().unwrap();
83 let path = dir.path().join("cache.sqlite");
84 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
85 let (_, oid, _) = text(&source);
86 let cache = Arc::new(Replica::create(&path, &source).unwrap());
87 let id = save(&cache, oid, 1..2, "🦀").unwrap();
88 let mut server = Server::new(&source);
89 server.fault = Fault::UnknownAfter;
90 let server = Arc::new(Mutex::new(server));
91 let shared = Shared(Arc::clone(&server));
92 let (connected_tx, connected_rx) = mpsc::channel();
93 let (observed_tx, observed_rx) = mpsc::channel();
94 let mut connections = 0;
95 let worker = cache
96 .start_sync(
97 Duration::from_millis(10),
98 move || {
99 connections += 1;
100 connected_tx.send(connections).unwrap();
101 if connections <= 2 {
102 return Err(io::ErrorKind::ConnectionRefused.into());
103 }
104 Ok(Session {
105 shared: shared.clone(),
106 fail_read: connections == 3,
107 })
108 },
109 move |result| {
110 observed_tx
111 .send(result.as_ref().copied().map_err(|error| error.to_string()))
112 .unwrap();
113 },
114 )
115 .unwrap();
116 let mut errors = 0;
117 let published = loop {
118 match observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
119 Err(_) => errors += 1,
120 Ok(Some((actual, status @ EditStatus::Published { .. }))) => {
121 assert_eq!(actual, id);
122 break status;
123 }
124 other => panic!("Unexpected result: {other:?}"),
125 }
126 };
127 worker.stop().unwrap();
128 assert_eq!(errors, 4);
129 assert_eq!(connected_rx.try_iter().collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
130 let server = server.lock().unwrap();
131 assert_eq!(server.publications, 1);
132 assert_eq!(server.confirmations, 1);
133 assert_eq!(text(&server.durable).2, "a🦀c");
134 drop(cache);
135 let cache = Replica::open(&path).unwrap();
136 assert_eq!(cache.status(id).unwrap(), Some(published));
137 let cached = cache.snapshot().unwrap();
138 // Confirmation changes reader-notification fields without changing the committed graph.
139 assert_eq!(cached[..212], server.durable[..212]);
140 assert_eq!(cached[252..], server.durable[252..]);
141 let cached_generation = Store::parse(&cached).unwrap().header.generation;
142 let remote_generation = Store::parse(&server.durable).unwrap().header.generation;
143 if cached_generation == remote_generation {
144 assert_eq!(cached, server.durable);
145 } else {
146 assert_eq!(cached_generation.checked_add(1), Some(remote_generation));
147 }
148}
149
150#[test]
151fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
152 let dir = tempfile::tempdir().unwrap();
153 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
154 let (_, oid, _) = text(&source);
155 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
156 let server = Arc::new(Mutex::new(Server::new(&source)));
157 let shared = Shared(Arc::clone(&server));
158 let (observed_tx, observed_rx) = mpsc::channel();
159 let (resume_tx, resume_rx) = mpsc::channel();
160 let mut first = true;
161 let worker = cache
162 .start_sync(
163 Duration::from_secs(3600),
164 move || Ok(shared.clone()),
165 move |result| {
166 observed_tx.send(*result.as_ref().unwrap()).unwrap();
167 if first {
168 first = false;
169 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
170 }
171 },
172 )
173 .unwrap();
174 assert_eq!(
175 observed_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
176 None
177 );
178 let started = Instant::now();
179 let queued = std::thread::scope(|scope| {
180 (0..12)
181 .map(|writer| {
182 let cache = &cache;
183 scope.spawn(move || {
184 let marker = format!("[{writer}] ");
185 loop {
186 match model_ops::save(cache, oid, |page| {
187 model_ops::replace_text(page, oid, 0..0, &marker)
188 }) {
189 Ok(Some(id)) => break id,
190 Err(Error::Io(error))
191 if error.kind() == io::ErrorKind::ResourceBusy => {}
192 other => panic!("Unexpected local outcome: {other:?}"),
193 }
194 }
195 })
196 })
197 .collect::<Vec<_>>()
198 .into_iter()
199 .map(|join| join.join().unwrap())
200 .collect::<std::collections::BTreeSet<_>>()
201 });
202 resume_tx.send(()).unwrap();
203 let mut published = std::collections::BTreeSet::new();
204 while !cache.pending().unwrap().is_empty() {
205 if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
206 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
207 published.insert(id);
208 }
209 }
210 assert!(
211 started.elapsed() < Duration::from_secs(5),
212 "Edits waited for the hourly poll"
213 );
214 worker.stop().unwrap();
215 assert_eq!(published, queued);
216 let content = text(&cache.snapshot().unwrap()).2;
217 for writer in 0..12 {
218 assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1);
219 }
220 assert!(content.ends_with("abc"));
221 let server = server.lock().unwrap();
222 assert_eq!(text(&server.durable).2, content);
223 assert_eq!(server.publications, queued.len());
224 assert_eq!(cache.snapshot().unwrap(), server.durable);
225}
226
227#[test]
228fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recovery_is_recorded() {
229 struct Paused {
230 shared: Shared,
231 entered: mpsc::Sender<()>,
232 resume: mpsc::Receiver<()>,
233 }
234 impl Remote for Paused {
235 fn read(&mut self) -> io::Result<Vec<u8>> {
236 self.shared.read()
237 }
238 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
239 self.entered.send(()).unwrap();
240 self.resume.recv_timeout(Duration::from_secs(5)).unwrap();
241 self.shared.publish(edit)
242 }
243 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
244 self.shared.confirm(snapshot)
245 }
246 }
247 let dir = tempfile::tempdir().unwrap();
248 let path = dir.path().join("cache.sqlite");
249 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
250 let (_, oid, _) = text(&source);
251 let cache = Arc::new(Replica::create(&path, &source).unwrap());
252 let id = save(&cache, oid, 0..0, "L ").unwrap();
253 let mut server = Server::new(&source);
254 server.fault = Fault::UnknownAfter;
255 let server = Arc::new(Mutex::new(server));
256 let shared = Shared(Arc::clone(&server));
257 let (entered_tx, entered_rx) = mpsc::channel();
258 let (resume_tx, resume_rx) = mpsc::channel();
259 let mut session = Some(Paused {
260 shared,
261 entered: entered_tx,
262 resume: resume_rx,
263 });
264 let worker = cache
265 .start_sync(
266 Duration::from_secs(3600),
267 move || Ok(session.take().unwrap()),
268 |_| {},
269 )
270 .unwrap();
271 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
272 let stopped = Instant::now();
273 drop(worker);
274 assert!(stopped.elapsed() < Duration::from_millis(500));
275 let shared = Shared(Arc::clone(&server));
276 let start = cache.start_sync(
277 Duration::from_secs(3600),
278 move || Ok(shared.clone()),
279 |_| {},
280 );
281 assert!(matches!(start, Err(error) if error.kind() == io::ErrorKind::WouldBlock));
282 let weak = Arc::downgrade(&cache);
283 drop(cache);
284 resume_tx.send(()).unwrap();
285 while weak.upgrade().is_some() {
286 assert!(stopped.elapsed() < Duration::from_secs(5));
287 std::thread::sleep(Duration::from_millis(1));
288 }
289 let cache = Arc::new(Replica::open(&path).unwrap());
290 assert!(matches!(
291 cache.status(id).unwrap(),
292 Some(EditStatus::AwaitingConfirmation { .. })
293 ));
294 assert_eq!(server.lock().unwrap().publications, 1);
295 let shared = Shared(Arc::clone(&server));
296 let (tx, rx) = mpsc::channel();
297 let worker = cache
298 .start_sync(
299 Duration::from_secs(3600),
300 move || Ok(shared.clone()),
301 move |result| {
302 tx.send(*result.as_ref().unwrap()).unwrap();
303 },
304 )
305 .unwrap();
306 assert!(
307 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
308 );
309 worker.stop().unwrap();
310 let server = server.lock().unwrap();
311 assert_eq!(server.publications, 1);
312 assert_eq!(server.confirmations, 1);
313 assert_eq!(text(&server.durable).2, "L abc");
314}
315
316#[test]
317fn cache_failures_stop_retries_and_return_the_error_without_remote_publication() {
318 let dir = tempfile::tempdir().unwrap();
319 let path = dir.path().join("cache.sqlite");
320 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
321 let (_, oid, _) = text(&source);
322 let cache = Replica::create(&path, &source).unwrap();
323 let id = save(&cache, oid, 0..0, "L ").unwrap();
324 drop(cache);
325 let connection = rusqlite::Connection::open(&path).unwrap();
326 connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap();
327 drop(connection);
328 let cache = Arc::new(Replica::open(&path).unwrap());
329 let server = Arc::new(Mutex::new(Server::new(&source)));
330 let shared = Shared(Arc::clone(&server));
331 let (tx, rx) = mpsc::channel();
332 let worker = cache
333 .start_sync(
334 Duration::from_millis(1),
335 move || Ok(shared.clone()),
336 move |result| {
337 tx.send(matches!(result, Err(Error::Database(_)))).unwrap();
338 },
339 )
340 .unwrap();
341 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
342 assert!(matches!(worker.stop(), Err(Error::Database(_))));
343 assert!(rx.try_iter().next().is_none());
344 assert_eq!(server.lock().unwrap().publications, 0);
345 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
346 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
347}
348
349#[test]
350fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
351 for uncertain in [false, true] {
352 let dir = tempfile::tempdir().unwrap();
353 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
354 let (sid, oid, _) = text(&source);
355 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
356 let id = save(&cache, oid, 1..2, "L").unwrap();
357 let local = cache.snapshot().unwrap();
358 let mut server = if uncertain {
359 Server::new(&source)
360 } else {
361 Server::new(&onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap())
362 };
363 if uncertain {
364 server.fault = Fault::UnknownBefore;
365 }
366 let server = Arc::new(Mutex::new(server));
367 let shared = Shared(Arc::clone(&server));
368 let (tx, rx) = mpsc::channel();
369 let worker = cache
370 .start_sync(
371 Duration::from_millis(10),
372 move || Ok(shared.clone()),
373 move |result| {
374 tx.send(result.as_ref().copied().map_err(|_| ())).unwrap();
375 },
376 )
377 .unwrap();
378 if uncertain {
379 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
380 }
381 let mut previous = None;
382 let started = Instant::now();
383 for _ in 0..5 {
384 let (actual, status) = rx
385 .recv_timeout(Duration::from_secs(5))
386 .unwrap()
387 .unwrap()
388 .unwrap();
389 assert_eq!(actual, id);
390 if uncertain {
391 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
392 } else {
393 assert_eq!(status, EditStatus::Conflict(ConflictKind::ContentChanged));
394 }
395 if let Some(previous) = previous {
396 assert_eq!(previous, status);
397 }
398 previous = Some(status);
399 }
400 assert!(
401 started.elapsed() >= Duration::from_millis(30),
402 "Worker spun instead of waiting between retries"
403 );
404 worker.stop().unwrap();
405 assert_eq!(cache.snapshot().unwrap(), local);
406 assert_eq!(cache.pending().unwrap().len(), 1);
407 let server = server.lock().unwrap();
408 assert_eq!(server.publications, usize::from(uncertain));
409 assert_eq!(server.confirmations, 0);
410 }
411}
412
413#[test]
414fn reachability_notification_retries_without_waiting_for_the_poll() {
415 let dir = tempfile::tempdir().unwrap();
416 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
417 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
418 let (tx, rx) = mpsc::channel();
419 let worker = cache
420 .start_sync(
421 Duration::from_secs(3600),
422 || -> io::Result<Shared> { Err(io::ErrorKind::NotConnected.into()) },
423 move |result| {
424 tx.send(matches!(result, Err(Error::RemoteIo(_)))).unwrap();
425 },
426 )
427 .unwrap();
428 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
429 worker.wake();
430 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap());
431 worker.stop().unwrap();
432 assert!(rx.try_iter().next().is_none());
433}
434
435#[test]
436fn cancellation_during_connect_does_not_read_or_report_a_false_refresh() {
437 let dir = tempfile::tempdir().unwrap();
438 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
439 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
440 let (entered_tx, entered_rx) = mpsc::channel();
441 let (resume_tx, resume_rx) = mpsc::channel();
442 let (tx, rx) = mpsc::channel();
443 // An invalid image makes any unexpected read observable as an error callback.
444 let shared = Shared(Arc::new(Mutex::new(Server::new(&[]))));
445 let worker = cache
446 .start_sync(
447 Duration::from_secs(3600),
448 move || {
449 entered_tx.send(()).unwrap();
450 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
451 Ok(shared.clone())
452 },
453 move |_| {
454 tx.send(()).unwrap();
455 },
456 )
457 .unwrap();
458 entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
459 drop(worker);
460 let weak = Arc::downgrade(&cache);
461 drop(cache);
462 resume_tx.send(()).unwrap();
463 let started = Instant::now();
464 while weak.upgrade().is_some() {
465 assert!(started.elapsed() < Duration::from_secs(5));
466 std::thread::sleep(Duration::from_millis(1));
467 }
468 assert_eq!(
469 rx.recv_timeout(Duration::from_secs(5)),
470 Err(mpsc::RecvTimeoutError::Disconnected)
471 );
472}
473
474#[test]
475fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
476 let dir = tempfile::tempdir().unwrap();
477 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
478 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
479 for interval in [Duration::ZERO, Duration::MAX] {
480 assert!(
481 matches!(cache.start_sync(interval, || -> io::Result<Shared> { panic!("Unexpected connection") }, |_| {}), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
482 );
483 }
484 let shared = Shared(Arc::new(Mutex::new(Server::new(&source))));
485 let first = shared.clone();
486 let (tx, rx) = mpsc::channel();
487 let worker = cache
488 .start_sync(
489 Duration::from_secs(3600),
490 move || Ok(first.clone()),
491 move |_| {
492 tx.send(()).unwrap();
493 panic!("Test observer panic");
494 },
495 )
496 .unwrap();
497 rx.recv_timeout(Duration::from_secs(5)).unwrap();
498 assert!(matches!(worker.stop(), Err(Error::Io(_))));
499 let (tx, rx) = mpsc::channel();
500 let worker = cache
501 .start_sync(
502 Duration::from_secs(3600),
503 move || Ok(shared.clone()),
504 move |result| {
505 tx.send(*result.as_ref().unwrap()).unwrap();
506 },
507 )
508 .unwrap();
509 assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), None);
510 worker.stop().unwrap();
511 assert_eq!(cache.snapshot().unwrap(), source);
512}
513
514#[test]
515fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
516 let dir = tempfile::tempdir().unwrap();
517 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
518 let (sid, oid, _) = text(&source);
519 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
520 let id = save(&cache, oid, 1..2, "L").unwrap();
521 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
522 let server = Arc::new(Mutex::new(Server::new(&remote)));
523 let shared = Shared(Arc::clone(&server));
524 let (tx, rx) = mpsc::channel();
525 let (resume_tx, resume_rx) = mpsc::channel();
526 let mut first = true;
527 let worker = cache
528 .start_sync(
529 Duration::from_secs(3600),
530 move || Ok(shared.clone()),
531 move |result| {
532 tx.send(*result.as_ref().unwrap()).unwrap();
533 if first {
534 first = false;
535 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
536 }
537 },
538 )
539 .unwrap();
540 assert_eq!(
541 rx.recv_timeout(Duration::from_secs(5)).unwrap(),
542 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
543 );
544 model_ops::review(&cache, id, oid, |page| {
545 model_ops::replace_text(page, oid, 1..2, "L")
546 })
547 .unwrap();
548 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
549 resume_tx.send(()).unwrap();
550 assert!(
551 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
552 );
553 worker.stop().unwrap();
554 assert!(cache.pending().unwrap().is_empty());
555 let server = server.lock().unwrap();
556 assert_eq!(server.publications, 1);
557 assert_eq!(text(&server.durable).2, "aLc");
558 assert_eq!(cache.snapshot().unwrap(), server.durable);
559}
560
561#[test]
562fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
563 struct Busy {
564 server: Server,
565 reads: usize,
566 writes: usize,
567 }
568 impl Remote for Busy {
569 fn read(&mut self) -> io::Result<Vec<u8>> {
570 self.reads += 1;
571 match self.reads {
572 1 => Err(io::ErrorKind::WouldBlock.into()),
573 2 => Err(io::ErrorKind::ResourceBusy.into()),
574 _ => self.server.read(),
575 }
576 }
577 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
578 self.writes += 1;
579 match self.writes {
580 1 | 2 => Err(CommitError {
581 state: CommitState::NotCommitted,
582 error: if self.writes == 1 {
583 io::ErrorKind::WouldBlock
584 } else {
585 io::ErrorKind::ResourceBusy
586 }
587 .into(),
588 }),
589 _ => self.server.publish(edit),
590 }
591 }
592 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
593 self.server.confirm(source)
594 }
595 }
596 let dir = tempfile::tempdir().unwrap();
597 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
598 let (_, oid, _) = text(&source);
599 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
600 let id = save(&cache, oid, 0..0, "L ").unwrap();
601 let mut remote = Some(Busy {
602 server: Server::new(&source),
603 reads: 0,
604 writes: 0,
605 });
606 let (tx, rx) = mpsc::channel();
607 let worker = cache
608 .start_sync(
609 Duration::from_millis(1),
610 move || Ok(remote.take().expect("Contention caused a reconnect")),
611 move |result| {
612 tx.send(result.as_ref().copied().map_err(|error| error.to_string()))
613 .unwrap();
614 },
615 )
616 .unwrap();
617 for _ in 0..4 {
618 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
619 }
620 assert!(
621 matches!(rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
622 );
623 worker.stop().unwrap();
624 assert_eq!(text(&cache.snapshot().unwrap()).2, "L abc");
625 assert!(cache.pending().unwrap().is_empty());
626}
627
628#[test]
629fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
630 struct BusyOnce(Server);
631 impl Remote for BusyOnce {
632 fn read(&mut self) -> io::Result<Vec<u8>> {
633 self.0.read()
634 }
635 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
636 let server = &mut self.0;
637 if server.publications == 0 {
638 server.publications += 1;
639 return Err(CommitError {
640 state: CommitState::NotCommitted,
641 error: io::ErrorKind::ResourceBusy.into(),
642 });
643 }
644 server.publish(edit)
645 }
646 fn confirm(&mut self, source: &[u8]) -> Result<(), CommitError> {
647 self.0.confirm(source)
648 }
649 }
650 let dir = tempfile::tempdir().unwrap();
651 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
652 let (_, oid, _) = text(&source);
653 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
654 let first = save(&cache, oid, 0..0, "L ").unwrap();
655 let mut remote = Some(BusyOnce(Server::new(&source)));
656 let observed = Arc::clone(&cache);
657 let (tx, rx) = mpsc::channel();
658 let mut failed = false;
659 let worker = cache
660 .start_sync(
661 Duration::from_secs(3600),
662 move || Ok(remote.take().expect("Contention must retain the session")),
663 move |result| match result {
664 Err(Error::Remote(error)) if error.state == CommitState::NotCommitted => {
665 assert!(!failed);
666 failed = true;
667 let page =
668 onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap();
669 let second = observed
670 .create_page(&observed.snapshot().unwrap(), &page)
671 .unwrap()
672 .unwrap();
673 tx.send((second, None)).unwrap();
674 }
675 Ok(Some((id, status))) => tx.send((*id, Some(*status))).unwrap(),
676 Ok(None) => {}
677 other => panic!("Unexpected worker result: {other:?}"),
678 },
679 )
680 .unwrap();
681 let (second, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
682 assert_eq!(status, None);
683 for expected in [first, second] {
684 let (id, status) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
685 assert_eq!(id, expected);
686 assert!(matches!(status, Some(EditStatus::Published { .. })));
687 }
688 worker.stop().unwrap();
689 assert!(cache.pending().unwrap().is_empty());
690 assert_eq!(content(&cache.snapshot().unwrap(), oid), "L abc");
691 assert_eq!(pages(&cache.snapshot().unwrap()), 2);
692}
fuzz/Cargo.toml+6-13
......@@ -95,13 +95,6 @@ test = false
9595doc = false
9696bench = false
9797
98[[bin]]
99name = "offline_paragraph"
100path = "fuzz_targets/offline_paragraph.rs"
101test = false
102doc = false
103bench = false
104
10598[[bin]]
10699name = "tree"
107100path = "fuzz_targets/tree.rs"
......@@ -110,22 +103,22 @@ doc = false
110103bench = false
111104
112105[[bin]]
113name = "offline_tree"
114path = "fuzz_targets/offline_tree.rs"
106name = "page"
107path = "fuzz_targets/page.rs"
115108test = false
116109doc = false
117110bench = false
118111
119112[[bin]]
120name = "page"
121path = "fuzz_targets/page.rs"
113name = "offline_page"
114path = "fuzz_targets/offline_page.rs"
122115test = false
123116doc = false
124117bench = false
125118
126119[[bin]]
127name = "offline_page"
128path = "fuzz_targets/offline_page.rs"
120name = "offline_model"
121path = "fuzz_targets/offline_model.rs"
129122test = false
130123doc = false
131124bench = false
fuzz/fuzz_targets/offline_model.rs created+11
......@@ -0,0 +1,11 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3
4#[path = "../../crates/notebook/tests/support/model_ops.rs"]
5mod model_ops;
6#[path = "../../crates/notebook/tests/support/server.rs"]
7mod server;
8#[path = "../../crates/notebook/tests/support/model_schedule.rs"]
9mod model_schedule;
10
11fuzz_target!(|input: &[u8]| model_schedule::run(input));
fuzz/fuzz_targets/offline_paragraph.rs deleted-235
......@@ -1,235 +0,0 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3use onestore::{
4 CommitError, ExGuid, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex,
5 Store,
6 document::{Document, Kind},
7};
8use notebook::{EditStatus, Operation, Remote, Replica};
9use std::{io, sync::LazyLock};
10
11#[path = "../../crates/onestore/tests/support/disk.rs"]
12mod disk;
13
14const TEXT: &str = "a🦀 e\u{301} 東京\rEnd";
15
16static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
17 let source = onestore::create_section("offline-paragraph.one", TEXT, "Fixture").unwrap();
18 let store = Store::parse(&source).unwrap();
19 let index = RevisionIndex::parse(&store).unwrap();
20 let doc = Document::parse(&index).unwrap();
21 let (sid, page) = doc.pages().unwrap()[0];
22 let space = &doc.spaces[&sid];
23 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
24 let outline = *view.nodes[&page]
25 .children
26 .iter()
27 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
28 .unwrap();
29 (source, sid, outline)
30});
31
32struct Server(disk::Disk);
33
34impl Remote for Server {
35 fn read(&mut self) -> io::Result<Vec<u8>> {
36 Ok(self.0.visible.clone())
37 }
38 fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> {
39 edit.commit(&mut self.0)
40 }
41 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
42 onestore::confirm_snapshot(&mut self.0, snapshot)
43 }
44}
45
46fn paragraphs(source: &[u8], sid: ExGuid, outline: ExGuid) -> Vec<(ExGuid, String)> {
47 let store = Store::parse(source).unwrap();
48 assert!(store.checksum_mismatches.is_empty());
49 let index = RevisionIndex::parse(&store).unwrap();
50 index.validate_current().unwrap();
51 let doc = Document::parse(&index).unwrap();
52 let space = &doc.spaces[&sid];
53 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
54 let mut output = Vec::new();
55 for paragraph in &view.nodes[&outline].children {
56 let node = &view.nodes[paragraph];
57 assert!(node.children.is_empty());
58 let [text] = node.content.as_slice() else {
59 panic!()
60 };
61 let Kind::RichText { text: content, .. } = &view.nodes[text].kind else {
62 panic!()
63 };
64 output.push((*text, content.clone()));
65 }
66 assert_eq!(
67 output
68 .iter()
69 .map(|(_, text)| text.as_str())
70 .collect::<String>(),
71 TEXT
72 );
73 output
74}
75
76fuzz_target!(|input: &[u8]| {
77 if input.len() < 8 {
78 return;
79 }
80 let (source, sid, outline) = &*SOURCE;
81 let directory = tempfile::tempdir().unwrap();
82 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
83 let mut server = Server(disk::Disk {
84 visible: source.clone(),
85 durable: source.clone(),
86 operation: 0,
87 fail_at: None,
88 write_limit: 4096,
89 random: 1,
90 });
91 for step in input.chunks_exact(8).take(24) {
92 let actor = usize::from(step[0]) % replicas.len();
93 let path = directory.path().join(format!("{actor}.sqlite"));
94 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());
95 let snapshot = cache.snapshot().unwrap();
96 let pending = cache.pending().unwrap();
97 let rows = paragraphs(&snapshot, *sid, *outline);
98 match step[1] % 8 {
99 0 => {
100 let (text, content) = &rows[usize::from(step[2]) % rows.len()];
101 let offsets: Vec<u32> = std::iter::once(0)
102 .chain(content.chars().scan(0, |at, c| {
103 *at += c.len_utf16() as u32;
104 Some(*at)
105 }))
106 .collect();
107 let offset = offsets[usize::from(step[3]) % offsets.len()];
108 let intent = ParagraphSplit::new(*text, offset, "Fuzz").unwrap();
109 let id = cache.split(&snapshot, *sid, &intent).unwrap().unwrap();
110 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
111 let next = cache.pending().unwrap();
112 assert_eq!(next[..pending.len()], pending);
113 assert_eq!(next.len(), pending.len() + 1);
114 }
115 1 if rows.len() > 1 => {
116 let pair = usize::from(step[2]) % (rows.len() - 1);
117 let intent = ParagraphJoin::new(rows[pair].0, rows[pair + 1].0, "Fuzz").unwrap();
118 let id = cache.join(&snapshot, *sid, &intent).unwrap().unwrap();
119 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
120 let next = cache.pending().unwrap();
121 assert_eq!(next[..pending.len()], pending);
122 assert_eq!(next.len(), pending.len() + 1);
123 }
124 2 | 3 => {
125 server.0.operation = 0;
126 server.0.fail_at =
127 (step[4] != 0).then_some(usize::from(u16::from_le_bytes([step[4], step[5]])));
128 server.0.write_limit = if step[6] & 1 == 0 { 17 } else { 4096 };
129 server.0.random = u64::from(step[7]) + 1;
130 let _ = cache.sync_once(&mut server);
131 let after = cache.pending().unwrap();
132 assert!(after.len() == pending.len() || after.len() + 1 == pending.len());
133 if after.len() == pending.len() {
134 assert_eq!(after, pending);
135 } else {
136 assert_eq!(after, pending[1..]);
137 let Some(EditStatus::Published { revision }) =
138 cache.status(pending[0].id).unwrap()
139 else {
140 panic!()
141 };
142 let store = Store::parse(&server.0.durable).unwrap();
143 let index = RevisionIndex::parse(&store).unwrap();
144 assert!(index.spaces[sid].revisions.contains_key(&revision));
145 }
146 if !after.is_empty() {
147 assert!(cache.snapshot().unwrap() == snapshot);
148 }
149 server.0.visible.clone_from(&server.0.durable);
150 }
151 5..=7 => {
152 let store = Store::parse(&snapshot).unwrap();
153 let index = RevisionIndex::parse(&store).unwrap();
154 let document = Document::parse(&index).unwrap();
155 let space = &document.spaces[sid];
156 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
157 let (object, change) = match step[1] % 8 {
158 5 => (
159 *outline,
160 OutlineEdit::Position {
161 x: f32::from(step[2]) * 18.0,
162 y: f32::from(step[3]) * 18.0,
163 },
164 ),
165 6 => (
166 *outline,
167 OutlineEdit::Width {
168 points: (2.0 + f32::from(step[2])) * 18.0,
169 user_set: step[3] & 1 != 0,
170 },
171 ),
172 _ => (
173 view.nodes[outline].children[usize::from(step[2]) % rows.len()],
174 OutlineEdit::Collapsed(step[3] & 1 != 0),
175 ),
176 };
177 let mut expected = serde_json::to_value(&view.nodes[&object]).unwrap();
178 match change {
179 OutlineEdit::Position { x, y } => {
180 expected["layout"]["x"] = x.into();
181 expected["layout"]["y"] = y.into();
182 }
183 OutlineEdit::Width { points, user_set } => {
184 expected["layout"]["max_width"] = points.into();
185 expected["layout"]["width_set_by_user"] = user_set.into();
186 }
187 OutlineEdit::Collapsed(value) => {
188 expected["kind"]["collapse_state"] = u8::from(value).into()
189 }
190 }
191 let id = cache.outline(&snapshot, *sid, object, change).unwrap();
192 let next = cache.pending().unwrap();
193 assert_eq!(next[..pending.len()], pending);
194 assert_eq!(next.len(), pending.len() + usize::from(id.is_some()));
195 if let Some(id) = id {
196 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
197 }
198 let bytes = cache.snapshot().unwrap();
199 let store = Store::parse(&bytes).unwrap();
200 let index = RevisionIndex::parse(&store).unwrap();
201 let document = Document::parse(&index).unwrap();
202 let space = &document.spaces[sid];
203 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
204 let actual = serde_json::to_value(&view.nodes[&object]).unwrap();
205 for key in ["kind", "layout", "children", "content"] {
206 assert_eq!(actual[key], expected[key]);
207 }
208 }
209 _ => {
210 if let Some(edit) = pending.first()
211 && matches!(edit.operation, Operation::Outline(_))
212 && matches!(
213 cache.status(edit.id).unwrap(),
214 Some(EditStatus::Conflict(_))
215 )
216 {
217 let remote = cache.remote_snapshot().unwrap();
218 let _ = cache.rebase_layout_conflict(edit.id, &snapshot, &remote);
219 }
220 let pending = cache.pending().unwrap();
221 drop(replicas[actor].take());
222 let reopened = Replica::open(&path).unwrap();
223 assert!(reopened.snapshot().unwrap() == snapshot);
224 assert_eq!(reopened.pending().unwrap(), pending);
225 replicas[actor] = Some(reopened);
226 }
227 }
228 paragraphs(&server.0.durable, *sid, *outline);
229 paragraphs(
230 &replicas[actor].as_ref().unwrap().snapshot().unwrap(),
231 *sid,
232 *outline,
233 );
234 }
235});
fuzz/fuzz_targets/offline_tree.rs deleted-9
......@@ -1,9 +0,0 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3
4#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;
6#[path = "../../crates/notebook/tests/support/tree_schedule.rs"]
7mod tree_schedule;
8
9fuzz_target!(|input: &[u8]| tree_schedule::run(input));
tools/test_offline_document_history.py-54
......@@ -1,7 +1,5 @@
11import copy
22import json
3from pathlib import Path
4import subprocess
53import unittest
64import uuid
75from unittest.mock import patch
......@@ -140,58 +138,6 @@ class DocumentHistoryTests(unittest.TestCase):
140138 with self.subTest(mutation=mutation), self.assertRaises(AssertionError):
141139 document_history(logs, 2)
142140
143 def test_production_cache_workload_matches_the_independent_history_model(self):
144 result = subprocess.run(['cargo', 'test', '--locked', '-p', 'notebook', '--features', 'smb',
145 '--example', 'smb_offline_client',
146 'tests::document_workload_retains_dependencies_and_receipts_across_reopen',
147 '--', '--exact', '--nocapture'],
148 cwd=Path(__file__).resolve().parent.parent, capture_output=True, text=True, timeout=120)
149 self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
150 events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')]
151 documents = document_history({'w0': events}, 2)
152 self.assertEqual(sum(len(document['states']) for document in documents.values()), 20)
153 self.assertEqual(sum(row['event'] == 'remote_attempt' and row['state'] == 'Unknown' for row in events), 12)
154 self.assertEqual(sum(row['event'] == 'remote_confirm' and row['state'] == 'Committed' for row in events), 12)
155 document = next(iter(documents.values()))
156 states = document['states']
157 for phase, next_phase, stale in [('nest', 'unnest', 'right_text'), ('delete', None, 'tail_split')]:
158 read = copy.deepcopy(states[stale]['attempt'])
159 started = states[phase]['attempt']['acknowledged_us'] + 1
160 if next_phase:
161 self.assertLess(started + 1, states[next_phase]['attempt']['started_us'])
162 read.update(event='read', started_us=started, finished_us=started + 1)
163 logs = {'w0': events, 'r0': [{'event': 'ready', 'document_graph': True}, read, {'event': 'done'}]}
164 with self.subTest(phase=phase), self.assertRaisesRegex(AssertionError, 'missed acknowledged'):
165 document_history(logs, 2)
166 for mutation in ('nest-intent', 'unnest-intent', 'delete-intent', 'tail-boundary',
167 'nest-children', 'delete-resurrection', 'graph-delta', 'extra-attempt'):
168 changed = copy.deepcopy(events)
169 intents = {row['kind']: row for row in changed if row['event'] == 'local_document_commit' and row['operation'] == 0}
170 attempts = {kind: next(row for row in changed if row['event'] == 'remote_attempt' and row['revision'] == state['attempt']['revision'])
171 for kind, state in states.items()}
172 if mutation == 'nest-intent':
173 intents['nest']['tree']['placement']['Move']['parent'] = intents['unnest']['tree']['placement']['Move']['parent']
174 elif mutation == 'unnest-intent':
175 intents['unnest']['tree']['placement']['Move']['before'] = intents['unnest']['tree']['object']
176 elif mutation == 'delete-intent':
177 intents['delete']['tree']['object'] = intents['nest']['tree']['object']
178 elif mutation == 'tail-boundary':
179 intents['tail_split']['split']['offset'] += 1
180 elif mutation == 'nest-children':
181 parent = intents['nest']['tree']['placement']['Move']['parent']
182 attempts['nest']['document_graph'][parent]['children'] = []
183 elif mutation == 'delete-resurrection':
184 tail = intents['delete']['object']
185 attempts['delete']['documents'][tail] = attempts['tail_split']['documents'][tail]
186 elif mutation == 'graph-delta':
187 attempts['nest']['document_graph_changes'] = {}
188 else:
189 extra = copy.deepcopy(attempts['nest'])
190 extra.update(revision='unrecorded-tree-revision', state='Committed')
191 changed.append(extra)
192 with self.subTest(mutation=mutation), self.assertRaises(AssertionError):
193 document_history({'w0': changed}, 2)
194
195141 def test_boundary_histories_retain_every_intermediate_graph_and_retired_identity(self):
196142 baseline = history(boundaries=True)
197143 documents = document_history(baseline, 2)