| author | |
| committer | |
| log | cc5b6a1bca8a59e2c06208c9086e7b002f0e95df |
| tree | 29e15310e4918861a3a9608f78a186567619185b |
| parent | 2e2f9466aec89c2ab7fc03378e3cbf975ffd7f9d |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.144 files changed, 6527 insertions(+), 9663 deletions(-)
corpus/offline-v4/README.md deleted-15| ... | @@ -1,15 +0,0 @@ | ||
| 1 | # Original schema-4 offline state | ||
| 2 | |||
| 3 | These databases were produced by `onestore-offline` at revision `2244711b`, | ||
| 4 | before the external-media table and schema-5 migration existed. Tests copy the | ||
| 5 | live database before opening it; the archive remains read-only. | ||
| 6 | |||
| 7 | [`producer.rs`](producer.rs) records the original producer. Run it against that | ||
| 8 | revision's core/offline crates, from a checkout whose `corpus/offline-v4` does | ||
| 9 | not exist. It uses the public native external-asset fixture, queues a text edit, | ||
| 10 | simulates a publication with a lost response, queues a dependent text edit, and | ||
| 11 | exports the complete recovery archive. Generated revision IDs vary between runs. | ||
| 12 | |||
| 13 | [`provenance.json`](provenance.json) records the original source/database hashes | ||
| 14 | and the retained uncertain and pending edit IDs. No credentials or personal | ||
| 15 | notebook 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 @@ | ||
| 1 | use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::{Document, Kind}}; | ||
| 2 | use onestore_offline::{Remote, Replica, Recovery}; | ||
| 3 | use std::{fs, io, path::Path}; | ||
| 4 | struct LostReply(Vec<u8>); | ||
| 5 | impl 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 | } | ||
| 13 | fn 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 @@ | ... | @@ -2,52 +2,65 @@ |
| 2 | 2 | ||
| 3 | The application-facing crate: notebook discovery, a durable local replica with | 3 | The application-facing crate: notebook discovery, a durable local replica with |
| 4 | reconnect reconciliation, external-asset caching, recovery export and optional | 4 | reconnect reconciliation, external-asset caching, recovery export and optional |
| 5 | embedded SMB access. Durable local editing for a OneNote file: the current | 5 | embedded SMB access. The replica stores a complete working image of one section |
| 6 | foundation stores a complete working image and typed editing intents in a local | 6 | and the editor's intents in a local SQLite database. `sync_once` provides a |
| 7 | SQLite database. `sync_once` provides a reconciliation step and `start_sync` owns | 7 | reconciliation step and `start_sync` owns automatic polling and reconnects. Local |
| 8 | automatic polling and reconnects. Local success does not | 8 | success does not acknowledge publication to a shared notebook. |
| 9 | acknowledge publication to a shared notebook. | 9 | |
| 10 | ## Saving pages | ||
| 11 | |||
| 12 | The editor works on `onestore::page::Page` values and saves whole pages. `save` | ||
| 13 | diffs the supplied model against the page stored in the supplied local snapshot, | ||
| 14 | writes the difference into the working image and queues one | ||
| 15 | `Operation::Page(PageIntent { before, after, author })`; `before` is the page the | ||
| 16 | edit started from and is the precondition reconciliation checks. Text, styles, | ||
| 17 | paragraph structure, outline layout, insertions and deletions are all differences | ||
| 18 | between `before` and `after`; the library never sees editor operations. | ||
| 10 | 19 | ||
| 11 | ```no_run | 20 | ```no_run |
| 12 | use onestore::ExGuid; | 21 | use onestore::{ExGuid, page::Page}; |
| 13 | use notebook::Replica; | 22 | use 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) |
| 15 | # -> Result<(), Box<dyn std::error::Error>> { | 24 | # -> 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. |
| 17 | let cache = Replica::create(path, source)?; | 26 | let cache = Replica::create(path, source)?; |
| 18 | let snapshot = cache.snapshot()?; | 27 | let snapshot = cache.snapshot()?; |
| 19 | let local_id = cache.edit_text(&snapshot, space, text, 0..0, "Offline edit ")?; | 28 | let local_id = cache.save(&snapshot, space, edited, "Author")?; |
| 20 | drop(cache); | 29 | drop(cache); |
| 21 | 30 | ||
| 22 | let reopened = Replica::open(path)?; | 31 | let reopened = Replica::open(path)?; |
| 23 | let current = reopened.snapshot()?; | ||
| 24 | let pending = reopened.pending()?; | 32 | let pending = reopened.pending()?; |
| 25 | assert_eq!(pending.last().map(|edit| edit.id), local_id); | 33 | assert_eq!(pending.last().map(|edit| edit.id), local_id); |
| 26 | # Ok(()) | 34 | # Ok(()) |
| 27 | # } | 35 | # } |
| 28 | ``` | 36 | ``` |
| 29 | 37 | ||
| 30 | `insert` accepts the core library's `Insertion` value and durably retains its | 38 | Saves coalesce the way OneNote's own autosave does: while the newest queued intent |
| 31 | intent identities. `Insertion::with_formatting` queues text and | 39 | for the same page has not been attempted, a later save replaces its `after` model |
| 32 | its styles as one intent and one publication. Keep that value across retries; its `text_object()` identifies | 40 | under the same ID. Once an intent has been attempted or holds a conflict it is |
| 33 | the new text for subsequent offline edits. Pending entries expose | 41 | never rewritten, nor is the intent a synchronization step has selected for |
| 34 | `Operation::Text(TextEdit)`, `Operation::Insert(Insertion)`, | 42 | publication; the next save then queues a new intent. A save whose model equals |
| 35 | `Operation::Format(FormatEdit)`, `Operation::Split(SplitEdit)`, | 43 | the stored page returns `None`. `PageIntent::text_change` describes an intent that |
| 36 | `Operation::Join(JoinEdit)`, `Operation::Outline(OutlineEdit)`, | 44 | changes exactly one paragraph's text as the text object, its previous text, the |
| 37 | `Operation::Tree(TreeEdit)`, `Operation::CreatePage(PageCreation)` and | 45 | replaced UTF-16 range and the replacement. |
| 38 | `Operation::Pages(PageEdits)` through their | 46 | |
| 39 | `operation` field. Synchronization applies these in queue order, so an inserted | 47 | Share one `Replica` between application threads. Each save compares its supplied |
| 40 | outline can precede its paragraphs and their later edits. Missing anchors or | 48 | snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. |
| 41 | existing insertion identities preserve a conflict and the complete local image. | 49 | The intent and its resulting image commit together. Keep the cache on a local |
| 50 | filesystem: the connection holds exclusive ownership between transactions, and a | ||
| 51 | second open fails busy. No network wait occurs in a local save. After a database | ||
| 52 | error, reopen and inspect the durable state before retrying. | ||
| 53 | |||
| 54 | ## Pages of a section | ||
| 42 | 55 | ||
| 43 | `create_page` accepts the core `PageCreation` intent and queues its page space | 56 | `create_page` accepts the core `PageCreation` intent and queues its page space |
| 44 | and section entry as one publication. Subsequent outlines and title edits use | 57 | and section entry as one publication. Subsequent saves of the new page use the |
| 45 | the intent's stable identities immediately after local acknowledgement. | 58 | intent's stable identities immediately after local acknowledgement. Independent |
| 46 | Independent page additions rebase against the current section order; duplicate | 59 | page additions rebase against the current section order; duplicate titles remain |
| 47 | titles remain distinct. An unavailable or no-longer-leading insertion anchor | 60 | distinct. An unavailable or no-longer-leading insertion anchor produces |
| 48 | produces `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, | 61 | `StructureChanged`. `rebase_page_creation_conflict(id, local, remote, before)` |
| 49 | before)` reviews a replacement anchor against both cache images while retaining | 62 | reviews a replacement anchor against both cache images while retaining the new |
| 50 | the new page and dependent object identities. Existing page identities require | 63 | page and dependent object identities. Existing page identities require |
| 51 | reconciliation; a matching page alone does not establish a receipt. | 64 | reconciliation; a matching page alone does not establish a receipt. |
| 52 | 65 | ||
| 53 | `pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic | 66 | `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 | ... | @@ -57,149 +70,59 @@ edits. Every requested level remains explicit: a competing remote level produces |
| 57 | the selected page's position relative to surviving observed pages; an unchanged | 70 | the selected page's position relative to surviving observed pages; an unchanged |
| 58 | or already-satisfied position can proceed, and new remote pages remain present. | 71 | or already-satisfied position can proceed, and new remote pages remain present. |
| 59 | Indistinguishable competing moves retain a conflict and the complete local image. | 72 | Indistinguishable competing moves retain a conflict and the complete local image. |
| 60 | |||
| 61 | `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both | 73 | `rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both |
| 62 | cache images. Use `PageEdit::reposition(position, level)` on the retained intents | 74 | cache images. Use `PageEdit::reposition(position, level)` on the retained intents |
| 63 | to revise anchors or indentation; replacing page or allocated series identities | 75 | to revise anchors or indentation; replacing page or allocated series identities |
| 64 | is rejected. Dependent edits remain queued. Schema 11 adds the durable batch; | 76 | is rejected. Dependent edits remain queued. Attempt evidence includes every |
| 65 | schema 10 migration preserves existing multi-space publication evidence unchanged. | 77 | changed space plus the receipt space when that space is unchanged. An existing |
| 66 | Attempt evidence includes every changed space plus the receipt space when that | 78 | section revision alone cannot confirm a page edit. The |
| 67 | space is unchanged. An existing section revision alone cannot confirm a page edit. | 79 | [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) |
| 68 | The [offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md) | ||
| 69 | contains native comparisons, reproduction commands and stateful sanitizer seeds. | 80 | contains native comparisons, reproduction commands and stateful sanitizer seeds. |
| 70 | 81 | ||
| 71 | `split` and `join` accept the core `ParagraphSplit` and `ParagraphJoin` intents. | 82 | `delete_pages(snapshot, pages)` queues the permanent removal of page spaces; |
| 72 | Splits retain allocated identities when the original UTF-16 boundary rebases; | 83 | a page already absent remotely produces `TargetUnavailable`. |
| 73 | dependent edits can address `ParagraphSplit::text_object()` immediately after | 84 | |
| 74 | local acknowledgement. Both operations retain observed parent paths, children, | 85 | ## Reconciliation |
| 75 | indentation, paragraph/list state and tags. Changed structure produces | 86 | |
| 76 | `StructureChanged`. Remote text changes must leave unambiguous boundary mappings; | 87 | `sync_once(&mut remote)` processes the oldest pending intent through a `Remote` |
| 77 | current remote character styles are preserved. Reconciliation does not silently | 88 | implementation, returning its ID and `EditStatus`. When the remote page still |
| 78 | discard a newly added right tag or move an unobserved child. | 89 | equals `before`, the intent publishes as prepared. When the remote page changed, |
| 79 | 90 | a three-way merge keeps everything the remote changed and re-applies the local | |
| 80 | `rebase_conflict` accepts a zero-length reviewed range for a split, preserving its | 91 | changes wherever the two sides touched different objects, fields or text ranges: |
| 81 | allocated identities. `rebase_join_conflict(id, local, remote)` reviews the original | 92 | a local text edit merges with a remote edit elsewhere in the same paragraph, an |
| 82 | join against current images. It rejects a change in which text identity survives, | 93 | outline move merges with a remote text change, a new paragraph survives a remote |
| 83 | because dependent edits still address that identity. As with other conflict | 94 | deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with |
| 84 | reviews, stale images, pending operations and uncertain attempts cannot be rebased. | 95 | the complete local image and the last observed remote image returned by |
| 85 | An uncertain structural operation requires its original attempted revision for | 96 | `remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a |
| 86 | confirmation; matching text or structure does not establish a receipt. | 97 | page the writer can no longer express retains `UnsupportedEdit`. |
| 87 | 98 | ||
| 88 | `rebase_paragraph_conflict(id, local, remote, parent, before)` and | 99 | Publication attempts are recorded before network I/O. A retained attempt |
| 89 | `rebase_outline_conflict(id, local, remote, page, x, y)` accept reviewed replacement | 100 | requires its recorded revision to remain present in the remote, or the remote |
| 90 | placements for the oldest insertion conflict. They preserve creation time, text, | 101 | page to equal the intent's `after` model exactly, followed by comparison, |
| 91 | author and object identities, keeping later edits attached to their original targets. | 102 | flushing and refreshed header version metadata before acknowledgement. Otherwise |
| 92 | The images must still match the cache; the new placement must be valid in the remote | 103 | the attempt remains `AwaitingConfirmation`; an uncertain publication is never |
| 93 | image. Paragraph intents cannot become outlines or vice versa. Pending edits and | 104 | replayed. A durable `Published` receipt survives reopening. Transport errors |
| 94 | uncertain publication attempts cannot be repositioned through conflict review. | 105 | return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error |
| 95 | 106 | to distinguish a retained attempt from a pending edit or receipt. The error return | |
| 96 | ```no_run | 107 | does not roll back a locally acknowledged intent. |
| 97 | use onestore::{ExGuid, Insertion, TextAttribute}; | ||
| 98 | use notebook::{EditStatus, Replica}; | ||
| 99 | # fn add_outline(cache: &Replica, space: ExGuid, page: ExGuid) | ||
| 100 | # -> Result<(), Box<dyn std::error::Error>> { | ||
| 101 | let outline = Insertion::outline(page, 144.0, 216.0, "Offline outline", "Author")? | ||
| 102 | .with_formatting(0..7, &[TextAttribute::Bold(true)])?; | ||
| 103 | let id = cache.insert(&cache.snapshot()?, space, &outline)?; | ||
| 104 | if 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 | ``` | ||
| 114 | 108 | ||
| 115 | `format` accepts the core `TextAttribute` slice and a UTF-16 range. Its durable intent | ||
| 116 | retains the observed text and selected attribute values. Remote text changes must | ||
| 117 | leave an unambiguous range; independent remote attributes merge, while competing | ||
| 118 | values preserve `FormattingChanged`. A remote value that already matches the | ||
| 119 | requested value is accepted. Enabling superscript or subscript also checks the | ||
| 120 | opposite attribute that the operation clears. If the remote image already satisfies | ||
| 121 | the whole operation, guarded confirmation still precedes a durable receipt. | ||
| 122 | |||
| 123 | `outline` accepts a target object and the core `onestore::OutlineEdit`: outline | ||
| 124 | position/width or a paragraph's saved collapse default. It retains the target's | ||
| 125 | ancestry and the properties the operation changes. Independent content, formatting | ||
| 126 | and layout properties merge; competing selected values produce `LayoutChanged`, | ||
| 127 | and changed ancestry produces `StructureChanged`. Width changes also check the | ||
| 128 | reserved wrapping width that they clear. Missing targets preserve the local branch. | ||
| 129 | `rebase_layout_conflict(id, local, remote)` explicitly reviews the original change | ||
| 130 | against both current images, preserving dependent intents. An uncertain layout | ||
| 131 | attempt requires its original revision; converged values cannot establish its receipt. | ||
| 132 | |||
| 133 | `tree` accepts the core `onestore::TreeEdit`. Moves preserve independent remote | ||
| 134 | text, formatting and descendants. A competing ancestor, indentation or sibling | ||
| 135 | crossing produces `StructureChanged`; unrelated sibling insertions/deletions can | ||
| 136 | merge. An anchor must remain a direct child of the requested destination. An | ||
| 137 | already satisfied move still requires guarded confirmation before acknowledgement. | ||
| 138 | Deletion compares the selected raw property graph, including referenced styles, | ||
| 139 | tags, unknown fields and internal attachments. Changed content produces | ||
| 140 | `ContentChanged`; property order, CompactID numbering and modification timestamps | ||
| 141 | do not affect that comparison. Immutable records compare by content, and empty | ||
| 142 | child lists compare equally to absent child lists. Mutable object identities | ||
| 143 | remain significant. Missing targets retain `TargetUnavailable`. | ||
| 144 | `rebase_tree_conflict(id, local, remote)` reviews the original move/deletion against | ||
| 145 | both current images. An emptied cell's replacement paragraph/text identities must | ||
| 146 | remain the same before replay or review; later queued edits keep their targets. | ||
| 147 | Uncertain tree attempts require their original revision for confirmation, even | ||
| 148 | when an independent move or deletion has the same visible effect. | ||
| 149 | |||
| 150 | Recognized earlier caches migrate transactionally to version ten. Publication | ||
| 151 | attempts retain every changed space's revision; legacy attempts retain their | ||
| 152 | single-space evidence. Earlier recovery archives remain readable without migration. | ||
| 153 | Version-eight | ||
| 154 | deletion observations retain their original identity-sensitive preconditions; | ||
| 155 | explicit conflict review upgrades them to the immutable-content comparison. | ||
| 156 | The migration retains images, local IDs, publication attempts, conflicts, | ||
| 157 | receipts and the autoincrement sequence; it does not reuse acknowledged IDs when | ||
| 158 | the pending queue is empty. | ||
| 159 | |||
| 160 | Share one `Replica` between application threads. Each edit compares its supplied | ||
| 161 | snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`. | ||
| 162 | The intent and its resulting image commit together. No-op edits return `None`. | ||
| 163 | Keep the cache on a local filesystem: the connection holds exclusive ownership | ||
| 164 | between transactions, and a second open fails busy. No network wait occurs in a | ||
| 165 | local edit. After a database error, reopen and inspect the durable state before | ||
| 166 | retrying. | ||
| 167 | |||
| 168 | `sync_once(&mut remote)` processes the oldest pending edit through a `Remote` | ||
| 169 | implementation, returning its ID and `EditStatus`. A durable `Published` receipt | ||
| 170 | survives reopening. Publication attempts are recorded before network I/O; a retained | ||
| 171 | attempt requires every recorded revision to remain present, followed by comparison, | ||
| 172 | flushing and refreshed header version | ||
| 173 | metadata before acknowledgement. If a formatting attempt's revision is missing, | ||
| 174 | the complete requested effect can instead be confirmed on a uniquely aligned | ||
| 175 | range; its receipt identifies that confirmed current revision. Otherwise the | ||
| 176 | missing attempt remains `AwaitingConfirmation`. Neither path replays an uncertain | ||
| 177 | publication. Overlapping or ambiguous edits retain `Conflict` status, their complete | ||
| 178 | local image and the last observed remote image returned by `remote_snapshot`. | ||
| 179 | Transport errors return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` | ||
| 180 | after the error to distinguish a retained attempt from a pending edit or receipt. | ||
| 181 | The error return does not roll back a locally acknowledged intent. | ||
| 182 | |||
| 183 | Rebasing accepts only character mappings shared by every minimum insertion/deletion | ||
| 184 | alignment. UTF-16 ranges must preserve Unicode scalar boundaries. A bounded | ||
| 185 | alignment search also leaves a conflict when it cannot establish a unique mapping. | ||
| 186 | The current operation processes one queue head; while edits remain, `snapshot` | 109 | The current operation processes one queue head; while edits remain, `snapshot` |
| 187 | preserves the complete local working image. An empty queue can refresh from the | 110 | preserves the complete local working image. An empty queue can refresh from the |
| 188 | remote image. Synchronization holds a separate owner lock, so local edits can | 111 | remote image. Synchronization holds a separate owner lock, so local saves can |
| 189 | continue during network waits; competing synchronization calls return `WouldBlock`. | 112 | continue during network waits; competing synchronization calls return `WouldBlock`. |
| 190 | 113 | ||
| 191 | `rebase_conflict(id, local, remote, range)` lets a caller explicitly place the | 114 | `review_page(id, local, remote, after)` resolves the oldest page conflict with a |
| 192 | oldest conflicting text or formatting intent at a reviewed UTF-16 range in the remote image. | 115 | model the user reviewed against the current remote page: the intent's `before` |
| 193 | The supplied images must still match `snapshot()` and `remote_snapshot()`. | 116 | becomes that remote page and its `after` the reviewed model, under the same ID, |
| 194 | It preserves the original replacement or requested attributes, intent ID, complete local working image | 117 | in one local transaction. The supplied images must still match `snapshot()` and |
| 195 | and every later intent; the selected range and remote paragraph become the | 118 | `remote_snapshot()`. Review performs no network I/O, clears the conflict to |
| 196 | intent's new comparison base in one local transaction. Formatting also captures | 119 | `Pending` and wakes the worker; publication still reads the latest remote image, |
| 197 | the reviewed attribute values as its new precondition. This operation performs | 120 | so another overlapping remote edit can produce a new conflict. Pending intents |
| 198 | no network I/O, clears the conflict to `Pending`, and wakes the worker. | 121 | and uncertain attempts cannot be reviewed. |
| 199 | Publication still reads the latest remote image and uses exact guarded comparison; | 122 | |
| 200 | another overlapping remote edit can produce a new conflict. An uncertain attempt | 123 | Opening recognizes the application identity and the current schema version only; |
| 201 | cannot be rebased, and selecting text that already equals the replacement does | 124 | caches and archives written by other versions are refused unchanged. Until the |
| 202 | not create a publication acknowledgement. | 125 | application is usable end to end there are no migrations. |
| 203 | 126 | ||
| 204 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an | 127 | With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an |
| 205 | `notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root | 128 | `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. | ... | @@ -273,7 +196,7 @@ the library continues to preserve conflicts requiring an explicit decision. |
| 273 | 196 | ||
| 274 | ## Recovery archives | 197 | ## Recovery archives |
| 275 | 198 | ||
| 276 | `export_recovery(new_path)` captures both complete notebook images, the typed | 199 | `export_recovery(new_path)` captures both complete notebook images, the intent |
| 277 | queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one | 200 | queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one |
| 278 | SQLite snapshot. It refuses existing destinations and leaves the live queue | 201 | SQLite snapshot. It refuses existing destinations and leaves the live queue |
| 279 | unchanged. Export to a local directory from a background thread: copying holds | 202 | unchanged. Export to a local directory from a background thread: copying holds |
| ... | @@ -322,7 +245,6 @@ identity return `AssetChanged` and preserve the previous download. | ... | @@ -322,7 +245,6 @@ identity return `AssetChanged` and preserve the previous download. |
| 322 | 245 | ||
| 323 | Downloads do not change pending edits, publication attempts or receipts. Recovery | 246 | Downloads do not change pending edits, publication attempts or receipts. Recovery |
| 324 | archives include cached media and expose the same bounded `cached_asset` lookup. | 247 | archives include cached media and expose the same bounded `cached_asset` lookup. |
| 325 | Schema-4 archives remain readable without migration and contain no media cache. | ||
| 326 | 248 | ||
| 327 | ## Discovery | 249 | ## Discovery |
| 328 | 250 | ||
| ... | @@ -378,7 +300,7 @@ Paths are relative to the share. The read limit bounds the complete physical | ... | @@ -378,7 +300,7 @@ Paths are relative to the share. The read limit bounds the complete physical |
| 378 | snapshot. Call from a background thread outside a Tokio runtime. Use identities | 300 | snapshot. Call from a background thread outside a Tokio runtime. Use identities |
| 379 | from the document and the same snapshot with `Client::commit_text` or | 301 | from the document and the same snapshot with `Client::commit_text` or |
| 380 | `Client::commit_property_bytes`; their errors retain `onestore::CommitState`. | 302 | `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 |
| 382 | and persist the intended revision identity before `Client::commit_prepared`. | 304 | and persist the intended revision identity before `Client::commit_prepared`. |
| 383 | `Client::confirm_snapshot` compares and flushes an observed image, then refreshes | 305 | `Client::confirm_snapshot` compares and flushes an observed image, then refreshes |
| 384 | its header version metadata without adding a revision. The caller must first | 306 | its header version metadata without adding a revision. The caller must first |
crates/notebook/examples/cache_probe.rs+131-84| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | use notebook::Replica; | 1 | use notebook::{Operation, Replica}; |
| 2 | use onestore::{ | 2 | use onestore::{ |
| 3 | ExGuid, Insertion, RevisionIndex, Store, TextAttribute, | 3 | ExGuid, RevisionIndex, Store, |
| 4 | document::{Document, Kind}, | 4 | document::{Document, Kind}, |
| 5 | page::{Page, PageObject, Paragraph, text::Edit}, | ||
| 5 | }; | 6 | }; |
| 6 | use std::{ | 7 | use std::{ |
| 7 | io::{self, BufRead, Write}, | 8 | io::{self, BufRead, Write}, |
| ... | @@ -85,68 +86,70 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -85,68 +86,70 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 85 | let mut ids = Vec::new(); | 86 | let mut ids = Vec::new(); |
| 86 | let mut font_size = None; | 87 | let mut font_size = None; |
| 87 | for pending in cache.pending()? { | 88 | for pending in cache.pending()? { |
| 88 | let operation = match pending.operation { | 89 | let Operation::Page(intent) = &pending.operation else { |
| 89 | notebook::Operation::Text(edit) => { | 90 | panic!("Unexpected operation for this fixture") |
| 90 | assert_eq!(operation_kind, "text"); | 91 | }; |
| 91 | assert_eq!(edit.object, target); | 92 | let operation = match operation_kind.as_str() { |
| 92 | assert_eq!(edit.before, expected); | 93 | "text" => { |
| 93 | assert_eq!( | 94 | let (object, before, range, replacement) = |
| 94 | edit.range, | 95 | intent.text_change().expect("one replaced paragraph"); |
| 95 | 0..u32::try_from(expected.encode_utf16().count())? | 96 | assert_eq!(object, target); |
| 96 | ); | 97 | assert_eq!(before, expected); |
| 97 | let operation: u64 = edit.replacement.split_once(':').unwrap().0.parse()?; | 98 | assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?); |
| 99 | let operation: u64 = replacement.split_once(':').unwrap().0.parse()?; | ||
| 98 | expected = payload(operation, size); | 100 | expected = payload(operation, size); |
| 99 | assert_eq!(edit.replacement, expected); | 101 | assert_eq!(replacement, expected); |
| 100 | operation | 102 | operation |
| 101 | } | 103 | } |
| 102 | notebook::Operation::Insert(insertion) => { | 104 | "insert" => { |
| 103 | assert_eq!(operation_kind, "insert"); | 105 | let added: Vec<_> = intent |
| 104 | let Kind::RichText { text, .. } = | 106 | .after |
| 105 | &revision.nodes[&insertion.text_object()].kind | 107 | .objects |
| 106 | else { | 108 | .iter() |
| 107 | panic!("Missing inserted text") | 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") | ||
| 108 | }; | 115 | }; |
| 116 | let text = outline.paragraphs[0].text().unwrap().text.text(); | ||
| 109 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; | 117 | let operation: u64 = text.split_once(':').unwrap().0.parse()?; |
| 110 | assert_eq!(*text, payload(operation, size)); | 118 | 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 { .. })); | ||
| 114 | assert_eq!( | 119 | assert_eq!( |
| 115 | (outline.layout.x, outline.layout.y), | 120 | (outline.layout.x, outline.layout.y), |
| 116 | (Some(144.0), Some(operation as f32 * 72.0)) | 121 | (Some(144.0), Some(operation as f32 * 72.0)) |
| 117 | ); | 122 | ); |
| 118 | let (_, page) = document | 123 | assert!(revision.nodes.contains_key(&outline.id)); |
| 119 | .pages()? | ||
| 120 | .into_iter() | ||
| 121 | .find(|(space, _)| *space == sid) | ||
| 122 | .unwrap(); | ||
| 123 | assert!(revision.nodes[&page].children.contains(&insertion.object())); | ||
| 124 | operation | 124 | operation |
| 125 | } | 125 | } |
| 126 | notebook::Operation::Format(edit) => { | 126 | "format" => { |
| 127 | assert_eq!(operation_kind, "format"); | 127 | let paragraph = intent |
| 128 | assert_eq!(edit.object, target); | 128 | .after |
| 129 | assert_eq!(edit.before, expected); | 129 | .objects |
| 130 | assert_eq!( | 130 | .iter() |
| 131 | edit.range, | 131 | .find_map(|object| match object { |
| 132 | 0..u32::try_from(expected.encode_utf16().count())? | 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)) | ||
| 133 | ); | 147 | ); |
| 134 | let [TextAttribute::FontSize(value)] = edit.attributes.as_slice() else { | 148 | assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0); |
| 135 | panic!("Unexpected formatting intent") | 149 | font_size = Some(value); |
| 136 | }; | 150 | value as u64 - 6 |
| 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") | ||
| 149 | } | 151 | } |
| 152 | _ => unreachable!(), | ||
| 150 | }; | 153 | }; |
| 151 | assert!(operations.last().is_none_or(|last| *last < operation)); | 154 | assert!(operations.last().is_none_or(|last| *last < operation)); |
| 152 | assert!(ids.last().is_none_or(|last| *last < pending.id)); | 155 | assert!(ids.last().is_none_or(|last| *last < pending.id)); |
| ... | @@ -193,41 +196,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -193,41 +196,85 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 193 | let replacement = payload(operation, size); | 196 | let replacement = payload(operation, size); |
| 194 | println!("editing {operation}"); | 197 | println!("editing {operation}"); |
| 195 | io::stdout().flush()?; | 198 | io::stdout().flush()?; |
| 196 | let id = match operation_kind.as_str() { | 199 | let (space, page) = { |
| 197 | "text" => cache.edit_text( | 200 | let store = Store::parse(&source)?; |
| 198 | &source, | 201 | let index = RevisionIndex::parse(&store)?; |
| 199 | sid, | 202 | let document = Document::parse(&index)?; |
| 200 | oid, | 203 | let mut page = Page::from_space(&document, sid)?; |
| 201 | 0..text.encode_utf16().count().try_into()?, | 204 | fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph { |
| 202 | &replacement, | 205 | page.objects |
| 203 | )?, | 206 | .iter_mut() |
| 204 | "insert" => { | 207 | .find_map(|object| match object { |
| 205 | let store = Store::parse(&source)?; | 208 | PageObject::Outline(outline) => outline |
| 206 | let index = RevisionIndex::parse(&store)?; | 209 | .paragraphs |
| 207 | let document = Document::parse(&index)?; | 210 | .iter_mut() |
| 208 | let (sid, page) = document.pages()?[0]; | 211 | .find(|p| p.text().is_some_and(|t| t.id == oid)), |
| 209 | let insertion = Insertion::outline( | 212 | _ => None, |
| 210 | page, | 213 | }) |
| 211 | 144.0, | 214 | .expect("target paragraph") |
| 212 | operation as f32 * 72.0, | ||
| 213 | &replacement, | ||
| 214 | "Fixture", | ||
| 215 | )?; | ||
| 216 | cache.insert(&source, sid, &insertion)? | ||
| 217 | } | 215 | } |
| 218 | "format" => { | 216 | let end = u32::try_from(text.encode_utf16().count())?; |
| 219 | assert!((1..=124).contains(&operation)); | 217 | match operation_kind.as_str() { |
| 220 | cache.format( | 218 | "text" => { |
| 221 | &source, | 219 | let target = target_paragraph(&mut page, oid).text_mut().unwrap(); |
| 222 | sid, | 220 | let format = target.text.format_at(0)?.clone(); |
| 223 | oid, | 221 | target.text.apply(Edit { |
| 224 | 0..text.encode_utf16().count().try_into()?, | 222 | range: 0..end, |
| 225 | &[TextAttribute::FontSize(6.0 + operation as f32)], | 223 | replacement: Paragraph::new(replacement.clone(), format), |
| 226 | )? | 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!(), | ||
| 227 | } | 274 | } |
| 228 | _ => unreachable!(), | 275 | (sid, page) |
| 229 | } | 276 | }; |
| 230 | .unwrap(); | 277 | let id = cache.save(&source, space, &page, "Fixture")?.unwrap(); |
| 231 | if acknowledgement == "unack" { | 278 | if acknowledgement == "unack" { |
| 232 | println!("durable {operation} {id}"); | 279 | println!("durable {operation} {id}"); |
| 233 | } else { | 280 | } 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> | ... | @@ -98,7 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error> |
| 98 | json!({"event":"state", "status":status, "revision":revision, | 98 | json!({"event":"state", "status":status, "revision":revision, |
| 99 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), | 99 | "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(), |
| 100 | "pending":cache.pending()?.iter().map(|pending|match &pending.operation { | 100 | "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 | }, | ||
| 102 | operation => json!({"id":pending.id,"operation":operation}), | 105 | operation => json!({"id":pending.id,"operation":operation}), |
| 103 | }).collect::<Vec<_>>() }) | 106 | }).collect::<Vec<_>>() }) |
| 104 | ); | 107 | ); |
| ... | @@ -124,14 +127,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -124,14 +127,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 124 | drop(remote); | 127 | drop(remote); |
| 125 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; | 128 | let cache = Replica::create(root.join("cache.sqlite"), &source)?; |
| 126 | let at = u32::try_from(target.text.encode_utf16().count())?; | 129 | 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 | })?; | ||
| 127 | assert_eq!( | 154 | assert_eq!( |
| 128 | cache.edit_text( | 155 | cache.save(&source, target.space, &page, "Fixture")?, |
| 129 | &source, | ||
| 130 | target.space, | ||
| 131 | target.object, | ||
| 132 | at..at, | ||
| 133 | " [offline-recovery]" | ||
| 134 | )?, | ||
| 135 | Some(1) | 156 | Some(1) |
| 136 | ); | 157 | ); |
| 137 | phase("local-after"); | 158 | phase("local-after"); |
crates/notebook/examples/smb_offline_client.rs+67-319| ... | @@ -4,8 +4,7 @@ mod concurrent; | ... | @@ -4,8 +4,7 @@ mod concurrent; |
| 4 | use notebook::smb::{Client, Credentials}; | 4 | use notebook::smb::{Client, Credentials}; |
| 5 | use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; | 5 | use notebook::{EditStatus, Error, Remote, Replica, SmbRemote}; |
| 6 | use onestore::{ | 6 | use onestore::{ |
| 7 | CommitError, CommitState, ExGuid, Insertion, ParagraphJoin, ParagraphSplit, PreparedEdit, | 7 | CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, page::Paragraph, |
| 8 | RevisionIndex, Store, TextAttribute, TreeEdit, document::Document, | ||
| 9 | }; | 8 | }; |
| 10 | use serde_json::json; | 9 | use serde_json::json; |
| 11 | use std::{ | 10 | use std::{ |
| ... | @@ -41,19 +40,6 @@ impl DocumentView { | ... | @@ -41,19 +40,6 @@ impl DocumentView { |
| 41 | } | 40 | } |
| 42 | } | 41 | } |
| 43 | 42 | ||
| 44 | const 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 | |||
| 57 | fn now() -> u128 { | 43 | fn now() -> u128 { |
| 58 | SystemTime::now() | 44 | SystemTime::now() |
| 59 | .duration_since(UNIX_EPOCH) | 45 | .duration_since(UNIX_EPOCH) |
| ... | @@ -282,6 +268,56 @@ fn tokens(text: &str) -> Option<Vec<&str>> { | ... | @@ -282,6 +268,56 @@ fn tokens(text: &str) -> Option<Vec<&str>> { |
| 282 | } | 268 | } |
| 283 | 269 | ||
| 284 | // This owned workload explicitly resolves append conflicts after all retained tokens. | 270 | // This owned workload explicitly resolves append conflicts after all retained tokens. |
| 271 | fn 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 | |||
| 286 | fn 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 | |||
| 309 | fn 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 | |||
| 285 | fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { | 321 | fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { |
| 286 | let original = tokens(before)?; | 322 | let original = tokens(before)?; |
| 287 | let present = tokens(current)?; | 323 | let present = tokens(current)?; |
| ... | @@ -295,162 +331,6 @@ fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { | ... | @@ -295,162 +331,6 @@ fn append_position(before: &str, current: &str, token: &str) -> Option<u32> { |
| 295 | u32::try_from(current.encode_utf16().count()).ok() | 331 | u32::try_from(current.encode_utf16().count()).ok() |
| 296 | } | 332 | } |
| 297 | 333 | ||
| 298 | fn 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 | |||
| 454 | fn main() -> Result<(), Box<dyn std::error::Error>> { | 334 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 455 | let args: Vec<_> = env::args().skip(1).collect(); | 335 | let args: Vec<_> = env::args().skip(1).collect(); |
| 456 | if args.len() != 7 | 336 | if args.len() != 7 |
| ... | @@ -495,7 +375,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -495,7 +375,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 495 | let cache = Arc::new(Replica::create(&cache_path, &source)?); | 375 | let cache = Arc::new(Replica::create(&cache_path, &source)?); |
| 496 | println!( | 376 | println!( |
| 497 | "{}", | 377 | "{}", |
| 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":[]}) |
| 499 | ); | 379 | ); |
| 500 | while !Path::new(&args[4]).exists() { | 380 | while !Path::new(&args[4]).exists() { |
| 501 | if Instant::now() >= deadline { | 381 | if Instant::now() >= deadline { |
| ... | @@ -529,8 +409,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -529,8 +409,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 529 | } | 409 | } |
| 530 | })?; | 410 | })?; |
| 531 | let mut ids = Vec::new(); | 411 | let mut ids = Vec::new(); |
| 532 | let mut document_ids = std::collections::BTreeSet::new(); | ||
| 533 | let mut document_parent = None; | ||
| 534 | let result = (|| -> Result<(), Box<dyn std::error::Error>> { | 412 | let result = (|| -> Result<(), Box<dyn std::error::Error>> { |
| 535 | let mut generated = 0; | 413 | let mut generated = 0; |
| 536 | let mut received = 0; | 414 | let mut received = 0; |
| ... | @@ -547,7 +425,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -547,7 +425,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 547 | Some(EditStatus::Published { revision }) => { | 425 | Some(EditStatus::Published { revision }) => { |
| 548 | println!( | 426 | println!( |
| 549 | "{}", | 427 | "{}", |
| 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()}) |
| 551 | ); | 429 | ); |
| 552 | received += 1; | 430 | received += 1; |
| 553 | } | 431 | } |
| ... | @@ -564,8 +442,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -564,8 +442,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 564 | .is_some_and(|directory| !directory.join("offline-outage-down").exists()) | 442 | .is_some_and(|directory| !directory.join("offline-outage-down").exists()) |
| 565 | { | 443 | { |
| 566 | 1 | 444 | 1 |
| 567 | } else if documents && outage.is_some() { | ||
| 568 | 8 * (1 + DOCUMENT_OPERATIONS.len()) | ||
| 569 | } else { | 445 | } else { |
| 570 | 8 | 446 | 8 |
| 571 | }; | 447 | }; |
| ... | @@ -575,25 +451,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -575,25 +451,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 575 | let at = u32::try_from(target.text.encode_utf16().count())?; | 451 | let at = u32::try_from(target.text.encode_utf16().count())?; |
| 576 | let token = format!(" [{}:{}]", args[2], generated); | 452 | let token = format!(" [{}:{}]", args[2], generated); |
| 577 | let started = now(); | 453 | 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") { | ||
| 579 | Ok(Some(id)) => { | 457 | Ok(Some(id)) => { |
| 580 | println!( | 458 | println!( |
| 581 | "{}", | 459 | "{}", |
| 582 | 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()}) | 460 | 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()}) |
| 583 | ); | 461 | ); |
| 584 | ids.push(id); | 462 | 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 | } | ||
| 597 | generated += 1; | 463 | generated += 1; |
| 598 | } | 464 | } |
| 599 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} | 465 | Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {} |
| ... | @@ -606,20 +472,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -606,20 +472,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 606 | let local = cache.snapshot()?; | 472 | let local = cache.snapshot()?; |
| 607 | let remote = cache.remote_snapshot()?; | 473 | let remote = cache.remote_snapshot()?; |
| 608 | let current = view(&remote)?; | 474 | let current = view(&remote)?; |
| 609 | let notebook::Operation::Text(edit) = &intent.operation else { | 475 | let notebook::Operation::Page(edit) = &intent.operation else { |
| 610 | return Err(format!( | 476 | return Err(format!( |
| 611 | "Document intent {} requires review: {:?}", | 477 | "Intent {} requires review: {:?}", |
| 612 | intent.id, | 478 | intent.id, |
| 613 | cache.status(intent.id)? | 479 | cache.status(intent.id)? |
| 614 | ) | 480 | ) |
| 615 | .into()); | 481 | .into()); |
| 616 | }; | 482 | }; |
| 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) | ||
| 618 | .ok_or("Append model disagrees with retained history")?; | 487 | .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) { | ||
| 620 | Ok(()) => println!( | 491 | Ok(()) => println!( |
| 621 | "{}", | 492 | "{}", |
| 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()}) |
| 623 | ), | 494 | ), |
| 624 | Err(Error::Io(error)) | 495 | Err(Error::Io(error)) |
| 625 | if [ | 496 | if [ |
| ... | @@ -658,7 +529,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { | ... | @@ -658,7 +529,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 658 | }; | 529 | }; |
| 659 | println!( | 530 | println!( |
| 660 | "{}", | 531 | "{}", |
| 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()}) |
| 662 | ); | 533 | ); |
| 663 | } | 534 | } |
| 664 | println!( | 535 | println!( |
| ... | @@ -699,133 +570,10 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { | ... | @@ -699,133 +570,10 @@ fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() { |
| 699 | ); | 570 | ); |
| 700 | } | 571 | } |
| 701 | 572 | ||
| 702 | #[cfg(test)] | ||
| 703 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 704 | mod disk; | ||
| 705 | |||
| 706 | #[cfg(test)] | 573 | #[cfg(test)] |
| 707 | mod tests { | 574 | mod tests { |
| 708 | use super::*; | 575 | use super::*; |
| 709 | 576 | ||
| 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 | |||
| 829 | #[test] | 577 | #[test] |
| 830 | fn publication_differences_retain_removed_text_and_graph_identities() { | 578 | fn publication_differences_retain_removed_text_and_graph_identities() { |
| 831 | let before = DocumentView { | 579 | let before = DocumentView { |
crates/notebook/src/assets.rs-4| ... | @@ -99,10 +99,6 @@ fn referenced(connection: &Connection, key: &str) -> Result<bool> { | ... | @@ -99,10 +99,6 @@ fn referenced(connection: &Connection, key: &str) -> Result<bool> { |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> { | 101 | pub(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 | } | ||
| 106 | let length: Option<i64> = connection | 102 | let length: Option<i64> = connection |
| 107 | .query_row( | 103 | .query_row( |
| 108 | "SELECT length(data) FROM assets WHERE name=?1", | 104 | "SELECT length(data) FROM assets WHERE name=?1", |
crates/notebook/src/formatting.rs deleted-203| ... | @@ -1,203 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::TextAttribute; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | use std::collections::BTreeMap; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(deny_unknown_fields)] | ||
| 9 | struct 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)] | ||
| 17 | pub 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 | |||
| 25 | fn 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 | |||
| 61 | fn 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 | |||
| 104 | impl 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 | |||
| 120 | impl 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; | ... | @@ -6,29 +6,27 @@ pub mod discover; |
| 6 | pub mod smb; | 6 | pub mod smb; |
| 7 | 7 | ||
| 8 | use onestore::{ | 8 | use onestore::{ |
| 9 | ExGuid, Insertion, PageCreation, PreparedEdit, RevisionIndex, Store, | 9 | ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, |
| 10 | document::{Document, Kind}, | 10 | document::{Document, Kind}, |
| 11 | page::Page, | 11 | page::Page, |
| 12 | }; | 12 | }; |
| 13 | use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; | 13 | use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; |
| 14 | use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; | 14 | use std::{ |
| 15 | fs::OpenOptions, | ||
| 16 | io, | ||
| 17 | path::Path, | ||
| 18 | sync::{Mutex, atomic::AtomicI64}, | ||
| 19 | time::Duration, | ||
| 20 | }; | ||
| 15 | 21 | ||
| 16 | mod assets; | 22 | mod assets; |
| 17 | mod formatting; | ||
| 18 | mod merge; | 23 | mod merge; |
| 19 | mod outline; | ||
| 20 | mod pages; | 24 | mod pages; |
| 21 | mod paragraph; | ||
| 22 | mod rebase; | 25 | mod rebase; |
| 23 | mod recovery; | 26 | mod recovery; |
| 24 | mod schema; | 27 | mod schema; |
| 25 | mod tree; | ||
| 26 | pub use formatting::FormatEdit; | ||
| 27 | pub use outline::OutlineEdit; | ||
| 28 | pub use pages::PageEdits; | 28 | pub use pages::PageEdits; |
| 29 | pub use paragraph::{JoinEdit, SplitEdit}; | ||
| 30 | pub use recovery::{Recovery, RecoverySummary}; | 29 | pub use recovery::{Recovery, RecoverySummary}; |
| 31 | pub use tree::TreeEdit; | ||
| 32 | mod sync; | 30 | mod sync; |
| 33 | pub use sync::{ConflictKind, EditStatus, Remote}; | 31 | pub use sync::{ConflictKind, EditStatus, Remote}; |
| 34 | mod worker; | 32 | mod worker; |
| ... | @@ -57,17 +55,7 @@ pub enum Error { | ... | @@ -57,17 +55,7 @@ pub enum Error { |
| 57 | type Result<T> = std::result::Result<T, Error>; | 55 | type Result<T> = std::result::Result<T, Error>; |
| 58 | 56 | ||
| 59 | const APPLICATION_ID: u32 = 0x4f4e454f; | 57 | const APPLICATION_ID: u32 = 0x4f4e454f; |
| 60 | const SCHEMA_VERSION: u32 = 11; | 58 | const SCHEMA_VERSION: u32 = 12; |
| 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)] | ||
| 65 | pub struct TextEdit { | ||
| 66 | pub object: ExGuid, | ||
| 67 | pub before: String, | ||
| 68 | pub range: Range<u32>, | ||
| 69 | pub replacement: String, | ||
| 70 | } | ||
| 71 | 59 | ||
| 72 | /// An edited page model together with the stored model it was edited from. | 60 | /// An edited page model together with the stored model it was edited from. |
| 73 | /// `before` is the precondition reconciliation checks against the remote page. | 61 | /// `before` is the precondition reconciliation checks against the remote page. |
| ... | @@ -79,19 +67,80 @@ pub struct PageIntent { | ... | @@ -79,19 +67,80 @@ pub struct PageIntent { |
| 79 | pub author: String, | 67 | pub author: String, |
| 80 | } | 68 | } |
| 81 | 69 | ||
| 70 | impl 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 | |||
| 82 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] | 135 | #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] |
| 83 | #[serde(deny_unknown_fields)] | 136 | #[serde(deny_unknown_fields)] |
| 84 | pub enum Operation { | 137 | pub enum Operation { |
| 138 | /// An edited page model; body content is edited only through this intent. | ||
| 85 | Page(PageIntent), | 139 | Page(PageIntent), |
| 86 | CreatePage(PageCreation), | 140 | CreatePage(PageCreation), |
| 87 | Pages(PageEdits), | 141 | Pages(PageEdits), |
| 88 | Text(TextEdit), | 142 | /// Permanent removal of explicitly selected page spaces from the section. |
| 89 | Insert(Insertion), | 143 | DeletePages(Vec<ExGuid>), |
| 90 | Format(FormatEdit), | ||
| 91 | Split(SplitEdit), | ||
| 92 | Join(JoinEdit), | ||
| 93 | Outline(OutlineEdit), | ||
| 94 | Tree(TreeEdit), | ||
| 95 | } | 144 | } |
| 96 | 145 | ||
| 97 | /// A locally acknowledged intent; its ID remains stable across cache reopen. | 146 | /// A locally acknowledged intent; its ID remains stable across cache reopen. |
| ... | @@ -107,6 +156,8 @@ pub struct PendingEdit { | ... | @@ -107,6 +156,8 @@ pub struct PendingEdit { |
| 107 | pub struct Replica { | 156 | pub struct Replica { |
| 108 | connection: Mutex<Connection>, | 157 | connection: Mutex<Connection>, |
| 109 | synchronization: Mutex<()>, | 158 | synchronization: Mutex<()>, |
| 159 | /// The intent a synchronization step selected for publication, or zero. | ||
| 160 | in_flight: AtomicI64, | ||
| 110 | worker: Mutex<std::sync::Weak<worker::Signal>>, | 161 | worker: Mutex<std::sync::Weak<worker::Signal>>, |
| 111 | } | 162 | } |
| 112 | 163 | ||
| ... | @@ -164,24 +215,28 @@ impl Replica { | ... | @@ -164,24 +215,28 @@ impl Replica { |
| 164 | schema::create(&transaction)?; | 215 | schema::create(&transaction)?; |
| 165 | transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?; | 216 | transaction.execute("INSERT INTO replica VALUES (1, ?1, ?1)", [source])?; |
| 166 | } else { | 217 | } 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. | ||
| 168 | return Err(io::Error::new( | 226 | return Err(io::Error::new( |
| 169 | io::ErrorKind::InvalidData, | 227 | io::ErrorKind::InvalidData, |
| 170 | "Unrecognized cache or unsupported schema version", | 228 | format!("Cache schema version {version} is not the supported version {SCHEMA_VERSION}"), |
| 171 | ) | 229 | ) |
| 172 | .into()); | 230 | .into()); |
| 173 | } | 231 | } |
| 174 | validate_images(&transaction)?; | 232 | validate_images(&transaction)?; |
| 175 | if version < SCHEMA_VERSION { | ||
| 176 | schema::migrate(&transaction, version)?; | ||
| 177 | transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?; | ||
| 178 | } | ||
| 179 | pending(&transaction)?; | 233 | pending(&transaction)?; |
| 180 | } | 234 | } |
| 181 | transaction.commit()?; | 235 | transaction.commit()?; |
| 182 | Ok(Self { | 236 | Ok(Self { |
| 183 | connection: Mutex::new(connection), | 237 | connection: Mutex::new(connection), |
| 184 | synchronization: Mutex::new(()), | 238 | synchronization: Mutex::new(()), |
| 239 | in_flight: AtomicI64::new(0), | ||
| 185 | worker: Mutex::new(std::sync::Weak::new()), | 240 | worker: Mutex::new(std::sync::Weak::new()), |
| 186 | }) | 241 | }) |
| 187 | } | 242 | } |
| ... | @@ -207,50 +262,6 @@ impl Replica { | ... | @@ -207,50 +262,6 @@ impl Replica { |
| 207 | pending(&connection) | 262 | pending(&connection) |
| 208 | } | 263 | } |
| 209 | 264 | ||
| 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 | |||
| 254 | /// Durably queues an edited page model using the supplied local snapshot. | 265 | /// Durably queues an edited page model using the supplied local snapshot. |
| 255 | /// While the newest pending edit is an unattempted save of the same page, a new save | 266 | /// While the newest pending edit is an unattempted save of the same page, a new save |
| 256 | /// replaces its result instead of queueing another publication, as OneNote does | 267 | /// replaces its result instead of queueing another publication, as OneNote does |
| ... | @@ -297,7 +308,10 @@ impl Replica { | ... | @@ -297,7 +308,10 @@ impl Replica { |
| 297 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), | 308 | |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), |
| 298 | ) | 309 | ) |
| 299 | .optional()?; | 310 | .optional()?; |
| 311 | // The intent a step is publishing has no attempt row yet; rewriting it would lose | ||
| 312 | // this save under the published bytes. | ||
| 300 | if let Some((id, sid, operation)) = newest | 313 | if let Some((id, sid, operation)) = newest |
| 314 | && id != self.in_flight.load(std::sync::atomic::Ordering::Acquire) | ||
| 301 | && sid == space.to_string() | 315 | && sid == space.to_string() |
| 302 | && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation) | 316 | && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation) |
| 303 | { | 317 | { |
| ... | @@ -350,6 +364,14 @@ impl Replica { | ... | @@ -350,6 +364,14 @@ impl Replica { |
| 350 | ) | 364 | ) |
| 351 | } | 365 | } |
| 352 | 366 | ||
| 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 | |||
| 353 | fn record( | 375 | fn record( |
| 354 | &self, | 376 | &self, |
| 355 | source: &[u8], | 377 | source: &[u8], |
| ... | @@ -403,35 +425,6 @@ impl Replica { | ... | @@ -403,35 +425,6 @@ impl Replica { |
| 403 | } | 425 | } |
| 404 | } | 426 | } |
| 405 | 427 | ||
| 406 | fn 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 | |||
| 435 | fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { | 428 | fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { |
| 436 | let store = Store::parse(source)?; | 429 | let store = Store::parse(source)?; |
| 437 | let index = RevisionIndex::parse(&store)?; | 430 | let index = RevisionIndex::parse(&store)?; |
| ... | @@ -439,20 +432,6 @@ fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { | ... | @@ -439,20 +432,6 @@ fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> { |
| 439 | Ok(Page::from_space(&document, space).ok()) | 432 | Ok(Page::from_space(&document, space).ok()) |
| 440 | } | 433 | } |
| 441 | 434 | ||
| 442 | fn 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 | |||
| 456 | fn validate(source: &[u8]) -> Result<ExGuid> { | 435 | fn validate(source: &[u8]) -> Result<ExGuid> { |
| 457 | let store = Store::parse(source)?; | 436 | let store = Store::parse(source)?; |
| 458 | if !store.checksum_mismatches.is_empty() { | 437 | 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> { | ... | @@ -33,12 +33,7 @@ pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> { |
| 33 | (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => { | 33 | (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => { |
| 34 | result.push(PageObject::Outline(merge_outline(x, y, z)?)); | 34 | result.push(PageObject::Outline(merge_outline(x, y, z)?)); |
| 35 | } | 35 | } |
| 36 | _ => { | 36 | _ => result.push(pick(before, after, &object)?.clone()), |
| 37 | if after != before { | ||
| 38 | return None; | ||
| 39 | } | ||
| 40 | result.push(object.clone()); | ||
| 41 | } | ||
| 42 | }, | 37 | }, |
| 43 | (Some(before), None) => { | 38 | (Some(before), None) => { |
| 44 | // Removed locally; keep only if the remote left it untouched. | 39 | // 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> { | ... | @@ -148,36 +143,42 @@ fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> { |
| 148 | } | 143 | } |
| 149 | 144 | ||
| 150 | fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> { | 145 | fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> { |
| 151 | if ours.title != base.title | 146 | let title = pick(&base.title, &ours.title, &theirs.title)?; |
| 152 | || ours.min_width != base.min_width | 147 | let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?; |
| 153 | || ours.indents != base.indents | 148 | let indents = pick(&base.indents, &ours.indents, &theirs.indents)?; |
| 154 | || ours.unsupported != base.unsupported | 149 | let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?; |
| 155 | { | ||
| 156 | return None; | ||
| 157 | } | ||
| 158 | let mut layout = theirs.layout.clone(); | 150 | let mut layout = theirs.layout.clone(); |
| 159 | let (x, y) = pick( | 151 | let (x, y) = pick( |
| 160 | &(base.layout.x, base.layout.y), | 152 | &(base.layout.x, base.layout.y), |
| 161 | &(ours.layout.x, ours.layout.y), | 153 | &(ours.layout.x, ours.layout.y), |
| 162 | &(theirs.layout.x, theirs.layout.y), | 154 | &(theirs.layout.x, theirs.layout.y), |
| 163 | )?; | 155 | )?; |
| 164 | let (max_width, user_set) = pick( | 156 | // A user-set width clears the wrap reservation, so the three travel together. |
| 165 | &(base.layout.max_width, base.layout.width_set_by_user), | 157 | let width = |layout: &onestore::document::Layout| { |
| 166 | &(ours.layout.max_width, ours.layout.width_set_by_user), | 158 | ( |
| 167 | &(theirs.layout.max_width, theirs.layout.width_set_by_user), | 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), | ||
| 168 | )?; | 168 | )?; |
| 169 | layout.x = x; | 169 | layout.x = x; |
| 170 | layout.y = y; | 170 | layout.y = y; |
| 171 | layout.max_width = max_width; | 171 | layout.max_width = max_width; |
| 172 | layout.width_set_by_user = user_set; | 172 | layout.width_set_by_user = user_set; |
| 173 | layout.reserved_width = reserved; | ||
| 173 | Some(Outline { | 174 | Some(Outline { |
| 174 | id: theirs.id, | 175 | id: theirs.id, |
| 175 | title: theirs.title, | 176 | title, |
| 176 | min_width: theirs.min_width, | 177 | min_width, |
| 177 | layout, | 178 | layout, |
| 178 | indents: theirs.indents.clone(), | 179 | indents, |
| 179 | paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?, | 180 | paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?, |
| 180 | unsupported: theirs.unsupported.clone(), | 181 | unsupported, |
| 181 | }) | 182 | }) |
| 182 | } | 183 | } |
| 183 | 184 | ||
| ... | @@ -236,7 +237,7 @@ fn merge_paragraphs( | ... | @@ -236,7 +237,7 @@ fn merge_paragraphs( |
| 236 | }; | 237 | }; |
| 237 | merged.insert(*id, paragraph); | 238 | merged.insert(*id, paragraph); |
| 238 | } | 239 | } |
| 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. |
| 240 | let (bc, oc, tc) = (children(base), children(ours), children(theirs)); | 241 | let (bc, oc, tc) = (children(base), children(ours), children(theirs)); |
| 241 | let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new(); | 242 | let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new(); |
| 242 | let containers: BTreeSet<Option<ExGuid>> = bc | 243 | let containers: BTreeSet<Option<ExGuid>> = bc |
| ... | @@ -255,55 +256,47 @@ fn merge_paragraphs( | ... | @@ -255,55 +256,47 @@ fn merge_paragraphs( |
| 255 | }) | 256 | }) |
| 256 | .unwrap_or_default() | 257 | .unwrap_or_default() |
| 257 | }; | 258 | }; |
| 258 | let (base_list, our_list, their_list) = ( | 259 | let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container))); |
| 259 | live(bc.get(&container)), | 260 | let of_base = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> { |
| 260 | live(oc.get(&container)), | 261 | ids.map(|ids| { |
| 261 | live(tc.get(&container)), | 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)), | ||
| 262 | ); | 273 | ); |
| 263 | let common = |list: &[ExGuid]| -> Vec<ExGuid> { | 274 | let ours_moved = if our_ids == base_ids || our_ids == their_ids { |
| 264 | list.iter() | 275 | BTreeSet::new() |
| 265 | .copied() | 276 | } else { |
| 266 | .filter(|id| { | 277 | moved(&base_ids, &our_ids) |
| 267 | base_list.contains(id) && our_list.contains(id) && their_list.contains(id) | ||
| 268 | }) | ||
| 269 | .collect() | ||
| 270 | }; | 278 | }; |
| 271 | let (base_common, our_common, their_common) = | 279 | // A paragraph we repositioned that the remote also moved, reparented or removed |
| 272 | (common(&base_list), common(&our_list), common(&their_list)); | 280 | // has two destinations; neither side's placement can be assumed. |
| 273 | let mut list: Vec<ExGuid> = their_list.clone(); | 281 | let mut theirs_touched = moved(&base_ids, &their_ids); |
| 274 | if our_common != base_common { | 282 | theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id))); |
| 275 | if their_common != base_common && their_common != our_common { | 283 | if ours_moved.iter().any(|id| theirs_touched.contains(id)) { |
| 276 | return None; | 284 | 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 | } | ||
| 287 | } | 285 | } |
| 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. | ||
| 289 | for (at, id) in our_list.iter().enumerate() { | 289 | 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)) { |
| 291 | continue; | 291 | continue; |
| 292 | } | 292 | } |
| 293 | if !b.contains_key(id) && merged[id].parent == container { | 293 | list.retain(|other| other != id); |
| 294 | let predecessor = our_list[..at] | 294 | let predecessor = our_list[..at] |
| 295 | .iter() | 295 | .iter() |
| 296 | .rev() | 296 | .rev() |
| 297 | .find(|other| list.contains(other)); | 297 | .find(|other| list.contains(other)); |
| 298 | let position = | 298 | let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1); |
| 299 | predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1); | 299 | list.insert(position, *id); |
| 300 | list.insert(position, *id); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | for id in &list { | ||
| 304 | if merged[id].parent != container { | ||
| 305 | return None; | ||
| 306 | } | ||
| 307 | } | 300 | } |
| 308 | order.insert(container, list); | 301 | order.insert(container, list); |
| 309 | } | 302 | } |
| ... | @@ -323,49 +316,84 @@ fn merge_paragraphs( | ... | @@ -323,49 +316,84 @@ fn merge_paragraphs( |
| 323 | Some(out) | 316 | Some(out) |
| 324 | } | 317 | } |
| 325 | 318 | ||
| 319 | /// The members of `list` outside a longest common subsequence with `base`: the paragraphs | ||
| 320 | /// a reorder of the shared members had to move. | ||
| 321 | fn 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 | |||
| 326 | fn merge_paragraph( | 358 | fn merge_paragraph( |
| 327 | base: &PageParagraph, | 359 | base: &PageParagraph, |
| 328 | ours: &PageParagraph, | 360 | ours: &PageParagraph, |
| 329 | theirs: &PageParagraph, | 361 | theirs: &PageParagraph, |
| 330 | ) -> Option<PageParagraph> { | 362 | ) -> Option<PageParagraph> { |
| 331 | if ours.lists != base.lists | 363 | let lists = pick(&base.lists, &ours.lists, &theirs.lists)?; |
| 332 | || ours.tags != base.tags | 364 | let tags = pick(&base.tags, &ours.tags, &theirs.tags)?; |
| 333 | || ours.style != base.style | 365 | let style = pick(&base.style, &ours.style, &theirs.style)?; |
| 334 | || ours.format != base.format | 366 | let format = pick(&base.format, &ours.format, &theirs.format)?; |
| 335 | { | ||
| 336 | return None; | ||
| 337 | } | ||
| 338 | let (parent, level) = pick( | 367 | let (parent, level) = pick( |
| 339 | &(base.parent, base.level), | 368 | &(base.parent, base.level), |
| 340 | &(ours.parent, ours.level), | 369 | &(ours.parent, ours.level), |
| 341 | &(theirs.parent, theirs.level), | 370 | &(theirs.parent, theirs.level), |
| 342 | )?; | 371 | )?; |
| 343 | let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?; | 372 | let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?; |
| 344 | let content = match (&base.content, &ours.content, &theirs.content) { | 373 | let content = match pick(&base.content, &ours.content, &theirs.content) { |
| 345 | (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) => { | 374 | Some(picked) => picked, |
| 346 | if x.id != y.id || x.id != z.id || y.date_field != x.date_field || y.tags != x.tags { | 375 | None => match (&base.content, &ours.content, &theirs.content) { |
| 347 | return None; | 376 | (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z)) |
| 348 | } | 377 | if x.id == y.id && x.id == z.id => |
| 349 | let mut text = z.clone(); | 378 | { |
| 350 | text.text = merge_text(&x.text, &y.text, &z.text)?; | 379 | let mut text = z.clone(); |
| 351 | ParagraphContent::Text(text) | 380 | text.date_field = pick(&x.date_field, &y.date_field, &z.date_field)?; |
| 352 | } | 381 | text.tags = pick(&x.tags, &y.tags, &z.tags)?; |
| 353 | (x, y, z) => { | 382 | text.text = merge_text(&x.text, &y.text, &z.text)?; |
| 354 | if y != x { | 383 | ParagraphContent::Text(text) |
| 355 | return None; | ||
| 356 | } | 384 | } |
| 357 | z.clone() | 385 | _ => return None, |
| 358 | } | 386 | }, |
| 359 | }; | 387 | }; |
| 360 | Some(PageParagraph { | 388 | Some(PageParagraph { |
| 361 | id: theirs.id, | 389 | id: theirs.id, |
| 362 | parent, | 390 | parent, |
| 363 | level, | 391 | level, |
| 364 | style: theirs.style, | 392 | style, |
| 365 | format: theirs.format.clone(), | 393 | format, |
| 366 | content, | 394 | content, |
| 367 | lists: theirs.lists.clone(), | 395 | lists, |
| 368 | tags: theirs.tags.clone(), | 396 | tags, |
| 369 | collapsed, | 397 | collapsed, |
| 370 | }) | 398 | }) |
| 371 | } | 399 | } |
| ... | @@ -379,8 +407,8 @@ fn merge_text( | ... | @@ -379,8 +407,8 @@ fn merge_text( |
| 379 | if let Some(picked) = pick(base, ours, theirs) { | 407 | if let Some(picked) = pick(base, ours, theirs) { |
| 380 | return Some(picked); | 408 | return Some(picked); |
| 381 | } | 409 | } |
| 382 | if base.text() == ours.text() || base.text() == theirs.text() { | 410 | if base.text() == ours.text() { |
| 383 | // Formatting-only changes on both sides cannot be attributed to ranges. | 411 | // A local formatting-only change cannot be attributed to a range of the remote text. |
| 384 | return None; | 412 | return None; |
| 385 | } | 413 | } |
| 386 | let (b, o) = (base.text(), ours.text()); | 414 | let (b, o) = (base.text(), ours.text()); |
| ... | @@ -402,6 +430,15 @@ fn merge_text( | ... | @@ -402,6 +430,15 @@ fn merge_text( |
| 402 | let end = base.utf16_offset(b.len() - suffix).ok()?; | 430 | let end = base.utf16_offset(b.len() - suffix).ok()?; |
| 403 | let inserted = ours.utf16_offset(o.len() - suffix).ok()?; | 431 | let inserted = ours.utf16_offset(o.len() - suffix).ok()?; |
| 404 | let replacement = ours.slice(start..inserted).ok()?; | 432 | 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 | } | ||
| 405 | let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?; | 442 | let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?; |
| 406 | let mut merged = theirs.clone(); | 443 | let mut merged = theirs.clone(); |
| 407 | merged | 444 | merged |
| ... | @@ -454,5 +491,29 @@ mod tests { | ... | @@ -454,5 +491,29 @@ mod tests { |
| 454 | .text(), | 491 | .text(), |
| 455 | "y" | 492 | "y" |
| 456 | ); | 493 | ); |
| 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 | ); | ||
| 457 | } | 518 | } |
| 458 | } | 519 | } |
crates/notebook/src/outline.rs deleted-138| ... | @@ -1,138 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use serde::{Deserialize, Serialize}; | ||
| 3 | use serde_json::{Value, json}; | ||
| 4 | |||
| 5 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 6 | #[serde(deny_unknown_fields)] | ||
| 7 | struct 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)] | ||
| 15 | pub struct OutlineEdit { | ||
| 16 | pub object: ExGuid, | ||
| 17 | pub change: onestore::OutlineEdit, | ||
| 18 | observed: Observed, | ||
| 19 | } | ||
| 20 | |||
| 21 | fn 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 | |||
| 61 | impl 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 | |||
| 75 | impl 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{ParagraphJoin, ParagraphSplit}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use serde_json::{Value, json}; | ||
| 5 | |||
| 6 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 7 | #[serde(deny_unknown_fields)] | ||
| 8 | struct 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)] | ||
| 16 | pub 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)] | ||
| 24 | pub struct JoinEdit { | ||
| 25 | pub intent: ParagraphJoin, | ||
| 26 | observed: [Observed; 2], | ||
| 27 | } | ||
| 28 | |||
| 29 | fn 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 | |||
| 71 | impl 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 | |||
| 95 | impl 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 | |||
| 141 | impl 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 { | ... | @@ -31,10 +31,17 @@ impl Recovery { |
| 31 | let application: u32 = | 31 | let application: u32 = |
| 32 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; | 32 | connection.pragma_query_value(None, "application_id", |row| row.get(0))?; |
| 33 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | 33 | 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 { | ||
| 35 | return Err(io::Error::new( | 40 | return Err(io::Error::new( |
| 36 | io::ErrorKind::InvalidData, | 41 | 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 | ), | ||
| 38 | ) | 45 | ) |
| 39 | .into()); | 46 | .into()); |
| 40 | } | 47 | } |
| ... | @@ -138,16 +145,11 @@ impl Replica { | ... | @@ -138,16 +145,11 @@ impl Replica { |
| 138 | } | 145 | } |
| 139 | 146 | ||
| 140 | fn summary(connection: &Connection) -> Result<RecoverySummary> { | 147 | fn summary(connection: &Connection) -> Result<RecoverySummary> { |
| 141 | let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; | 148 | let (cached_assets, cached_asset_bytes) = connection.query_row( |
| 142 | let (cached_assets, cached_asset_bytes) = if version < 5 { | 149 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", |
| 143 | (0, 0) | 150 | [], |
| 144 | } else { | 151 | |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), |
| 145 | connection.query_row( | 152 | )?; |
| 146 | "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", | ||
| 147 | [], | ||
| 148 | |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), | ||
| 149 | )? | ||
| 150 | }; | ||
| 151 | Ok(connection.query_row( | 153 | Ok(connection.query_row( |
| 152 | "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), | 154 | "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), |
| 153 | (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), | 155 | (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), |
crates/notebook/src/schema.rs+2-110| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use rusqlite::{OptionalExtension, Transaction}; | 2 | use rusqlite::Transaction; |
| 3 | 3 | ||
| 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( | 4 | const CONFLICTS: &str = "CREATE TABLE conflicts ( |
| 5 | edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, | 5 | 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) |
| 7 | ) STRICT;"; | 7 | ) STRICT;"; |
| 8 | 8 | ||
| 9 | const ASSETS: &str = "CREATE TABLE assets ( | 9 | const ASSETS: &str = "CREATE TABLE assets ( |
| ... | @@ -33,111 +33,3 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { | ... | @@ -33,111 +33,3 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { |
| 33 | transaction.execute_batch(ASSETS)?; | 33 | transaction.execute_batch(ASSETS)?; |
| 34 | Ok(()) | 34 | Ok(()) |
| 35 | } | 35 | } |
| 36 | |||
| 37 | pub(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 | |||
| 137 | fn 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}; | ... | @@ -3,7 +3,7 @@ use onestore::{CommitError, CommitState}; |
| 3 | use rusqlite::OptionalExtension; | 3 | use rusqlite::OptionalExtension; |
| 4 | use std::{ | 4 | use std::{ |
| 5 | collections::BTreeMap, | 5 | collections::BTreeMap, |
| 6 | sync::{MutexGuard, TryLockError}, | 6 | sync::{MutexGuard, TryLockError, atomic::Ordering}, |
| 7 | }; | 7 | }; |
| 8 | 8 | ||
| 9 | /// A single remote file with fresh reads and native-compatible guarded publication. | 9 | /// A single remote file with fresh reads and native-compatible guarded publication. |
| ... | @@ -17,13 +17,14 @@ pub trait Remote { | ... | @@ -17,13 +17,14 @@ pub trait Remote { |
| 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 17 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 18 | #[repr(i64)] | 18 | #[repr(i64)] |
| 19 | pub enum ConflictKind { | 19 | pub enum ConflictKind { |
| 20 | TextChanged = 0, | 20 | /// The edited page or a selected page no longer exists remotely. |
| 21 | TargetUnavailable = 1, | 21 | TargetUnavailable = 0, |
| 22 | UnsupportedEdit = 2, | 22 | /// The remote image no longer accepts the intent's publication. |
| 23 | FormattingChanged = 3, | 23 | UnsupportedEdit = 1, |
| 24 | StructureChanged = 4, | 24 | /// Page order or indentation changed remotely in a way the batch cannot absorb. |
| 25 | LayoutChanged = 5, | 25 | StructureChanged = 2, |
| 26 | ContentChanged = 6, | 26 | /// The remote page changed where the local model changed too; review is required. |
| 27 | ContentChanged = 3, | ||
| 27 | } | 28 | } |
| 28 | 29 | ||
| 29 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 30 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| ... | @@ -65,6 +66,13 @@ impl Replica { | ... | @@ -65,6 +66,13 @@ impl Replica { |
| 65 | /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. | 66 | /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect. |
| 66 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> { | 67 | pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> { |
| 67 | let _owner = self.sync_owner()?; | 68 | 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); | ||
| 68 | let snapshot = remote.read().map_err(Error::RemoteIo)?; | 76 | let snapshot = remote.read().map_err(Error::RemoteIo)?; |
| 69 | let identity = validate(&snapshot)?; | 77 | let identity = validate(&snapshot)?; |
| 70 | let (intent, attempted) = { | 78 | let (intent, attempted) = { |
| ... | @@ -93,6 +101,10 @@ impl Replica { | ... | @@ -93,6 +101,10 @@ impl Replica { |
| 93 | transaction.commit()?; | 101 | transaction.commit()?; |
| 94 | return Ok(None); | 102 | return Ok(None); |
| 95 | }; | 103 | }; |
| 104 | self.in_flight.store( | ||
| 105 | i64::try_from(intent.id).map_err(io::Error::other)?, | ||
| 106 | Ordering::Release, | ||
| 107 | ); | ||
| 96 | let attempted = transaction | 108 | let attempted = transaction |
| 97 | .query_row( | 109 | .query_row( |
| 98 | "SELECT revisions FROM attempt WHERE edit_id=?1", | 110 | "SELECT revisions FROM attempt WHERE edit_id=?1", |
| ... | @@ -115,10 +127,11 @@ impl Replica { | ... | @@ -115,10 +127,11 @@ impl Replica { |
| 115 | .get(space) | 127 | .get(space) |
| 116 | .is_some_and(|space| space.revisions.contains_key(revision)) | 128 | .is_some_and(|space| space.revisions.contains_key(revision)) |
| 117 | }) { | 129 | }) { |
| 130 | // A retired attempt confirms only when its complete effect is observed. | ||
| 118 | let satisfied = match &intent.operation { | 131 | let satisfied = match &intent.operation { |
| 119 | Operation::Format(edit) if revisions.len() == 1 => edit | 132 | Operation::Page(edit) if revisions.len() == 1 => { |
| 120 | .prepare(&snapshot, intent.space)? | 133 | page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after) |
| 121 | .is_ok_and(|prepared| prepared.as_bytes() == snapshot), | 134 | } |
| 122 | _ => false, | 135 | _ => false, |
| 123 | }; | 136 | }; |
| 124 | if !satisfied { | 137 | if !satisfied { |
| ... | @@ -154,31 +167,27 @@ impl Replica { | ... | @@ -154,31 +167,27 @@ impl Replica { |
| 154 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) | 167 | Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page) |
| 155 | .map_err(|_| ConflictKind::StructureChanged), | 168 | .map_err(|_| ConflictKind::StructureChanged), |
| 156 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, | 169 | Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?, |
| 157 | Operation::Format(edit) => edit.prepare(&snapshot, intent.space)?, | 170 | Operation::DeletePages(pages) => { |
| 158 | Operation::Split(edit) => edit.prepare(&snapshot, intent.space)?, | 171 | let store = Store::parse(&snapshot)?; |
| 159 | Operation::Join(edit) => edit.prepare(&snapshot, intent.space)?, | 172 | let index = RevisionIndex::parse(&store)?; |
| 160 | Operation::Outline(edit) => edit.prepare(&snapshot, intent.space)?, | 173 | if pages.iter().any(|page| { |
| 161 | Operation::Tree(edit) => edit.prepare(&snapshot, intent.space)?, | 174 | index |
| 162 | Operation::Insert(insertion) => { | 175 | .spaces |
| 163 | PreparedEdit::insert(&snapshot, intent.space, insertion) | 176 | .get(page) |
| 164 | .map_err(|_| ConflictKind::UnsupportedEdit) | 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 | } | ||
| 165 | } | 190 | } |
| 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 | }), | ||
| 182 | }; | 191 | }; |
| 183 | let prepared = match candidate { | 192 | let prepared = match candidate { |
| 184 | Ok(prepared) => prepared, | 193 | Ok(prepared) => prepared, |
| ... | @@ -267,124 +276,6 @@ impl Replica { | ... | @@ -267,124 +276,6 @@ impl Replica { |
| 267 | Ok(Some((intent.id, EditStatus::Published { revision }))) | 276 | Ok(Some((intent.id, EditStatus::Published { revision }))) |
| 268 | } | 277 | } |
| 269 | 278 | ||
| 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 | |||
| 388 | /// Repositions an unattempted page-creation conflict, retaining dependent object identities. | 279 | /// Repositions an unattempted page-creation conflict, retaining dependent object identities. |
| 389 | pub fn rebase_page_creation_conflict( | 280 | pub fn rebase_page_creation_conflict( |
| 390 | &self, | 281 | &self, |
| ... | @@ -431,102 +322,6 @@ impl Replica { | ... | @@ -431,102 +322,6 @@ impl Replica { |
| 431 | }) | 322 | }) |
| 432 | } | 323 | } |
| 433 | 324 | ||
| 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 | |||
| 530 | /// Replaces a conflicting page save with a model reviewed against the remote page. | 325 | /// Replaces a conflicting page save with a model reviewed against the remote page. |
| 531 | /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed | 326 | /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed |
| 532 | /// model must publish against the remote image, and its author is retained. | 327 | /// 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 | ... | @@ -661,32 +456,21 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat |
| 661 | revision: revision.parse()?, | 456 | revision: revision.parse()?, |
| 662 | })); | 457 | })); |
| 663 | } | 458 | } |
| 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 | }; | ||
| 670 | let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row( | 459 | 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()?; |
| 672 | Ok(match record { | 461 | Ok(match record { |
| 673 | None => None, | 462 | None => None, |
| 674 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { | 463 | Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation { |
| 675 | revision: if version < 10 { | 464 | revision: { |
| 676 | revision.parse()? | ||
| 677 | } else { | ||
| 678 | let space = space.parse()?; | 465 | let space = space.parse()?; |
| 679 | attempted_revisions(&revision, space)?[&space] | 466 | attempted_revisions(&revision, space)?[&space] |
| 680 | }, | 467 | }, |
| 681 | }), | 468 | }), |
| 682 | Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { | 469 | Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind { |
| 683 | 0 => ConflictKind::TextChanged, | 470 | 0 => ConflictKind::TargetUnavailable, |
| 684 | 1 => ConflictKind::TargetUnavailable, | 471 | 1 => ConflictKind::UnsupportedEdit, |
| 685 | 2 => ConflictKind::UnsupportedEdit, | 472 | 2 => ConflictKind::StructureChanged, |
| 686 | 3 => ConflictKind::FormattingChanged, | 473 | 3 => ConflictKind::ContentChanged, |
| 687 | 4 => ConflictKind::StructureChanged, | ||
| 688 | 5 => ConflictKind::LayoutChanged, | ||
| 689 | 6 => ConflictKind::ContentChanged, | ||
| 690 | _ => { | 474 | _ => { |
| 691 | return Err(io::Error::new( | 475 | return Err(io::Error::new( |
| 692 | io::ErrorKind::InvalidData, | 476 | io::ErrorKind::InvalidData, |
crates/notebook/src/tree.rs deleted-541| ... | @@ -1,541 +0,0 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::{FileDataReference, IdStream, ObjectData, PropertySets, ResolvedRevision, Value}; | ||
| 3 | use serde::{Deserialize, Serialize}; | ||
| 4 | use sha2::{Digest, Sha256}; | ||
| 5 | use std::collections::{BTreeMap, BTreeSet}; | ||
| 6 | |||
| 7 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 8 | #[serde(untagged, deny_unknown_fields)] | ||
| 9 | enum Content { | ||
| 10 | Legacy([u8; 32]), | ||
| 11 | Semantic { sha256: [u8; 32] }, | ||
| 12 | } | ||
| 13 | |||
| 14 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | ||
| 15 | #[serde(deny_unknown_fields)] | ||
| 16 | struct 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)] | ||
| 26 | pub struct TreeEdit { | ||
| 27 | pub intent: onestore::TreeEdit, | ||
| 28 | observed: Observed, | ||
| 29 | created: BTreeSet<ExGuid>, | ||
| 30 | } | ||
| 31 | |||
| 32 | fn 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 | |||
| 162 | fn 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 | |||
| 224 | fn 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 | |||
| 235 | impl 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 | |||
| 248 | impl 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)] | ||
| 331 | mod 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 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | use notebook::{EditStatus, Error, Operation, Recovery, Replica}; | 1 | use notebook::{EditStatus, Error, Operation, Recovery, Replica}; |
| 2 | use onestore::{ | ||
| 3 | ExGuid, RevisionIndex, Store, | ||
| 4 | document::Document, | ||
| 5 | page::{Page, PageObject}, | ||
| 6 | }; | ||
| 2 | use std::{ | 7 | use std::{ |
| 3 | fs, io, | 8 | fs, io, |
| 4 | path::Path, | 9 | path::Path, |
| ... | @@ -6,14 +11,54 @@ use std::{ | ... | @@ -6,14 +11,54 @@ use std::{ |
| 6 | time::Duration, | 11 | time::Duration, |
| 7 | }; | 12 | }; |
| 8 | 13 | ||
| 14 | #[path = "support/model_ops.rs"] | ||
| 15 | mod model_ops; | ||
| 16 | |||
| 9 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; | 17 | const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; |
| 10 | const LEGACY: &str = "../../corpus/offline-v4/live.sqlite"; | ||
| 11 | 18 | ||
| 12 | fn copied_cache(root: &Path) -> Replica { | 19 | /// The first body text object of the section referencing the fixture's media. |
| 20 | fn 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. | ||
| 45 | fn queued_cache(root: &Path) -> Replica { | ||
| 13 | let path = root.join("cache.sqlite"); | 46 | let path = root.join("cache.sqlite"); |
| 14 | assert!(!path.exists()); | 47 | assert!(!path.exists()); |
| 15 | fs::copy(LEGACY, &path).unwrap(); | 48 | let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap(); |
| 16 | Replica::open(path).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 | ||
| 17 | } | 62 | } |
| 18 | 63 | ||
| 19 | fn payload(size: usize) -> (String, Vec<u8>) { | 64 | fn payload(size: usize) -> (String, Vec<u8>) { |
| ... | @@ -39,10 +84,9 @@ impl notebook::discover::Source for Payload { | ... | @@ -39,10 +84,9 @@ impl notebook::discover::Source for Payload { |
| 39 | } | 84 | } |
| 40 | 85 | ||
| 41 | #[test] | 86 | #[test] |
| 42 | fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() { | 87 | fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() { |
| 43 | let directory = tempfile::tempdir().unwrap(); | 88 | let directory = tempfile::tempdir().unwrap(); |
| 44 | let original = fs::read(LEGACY).unwrap(); | 89 | let cache = queued_cache(directory.path()); |
| 45 | let cache = copied_cache(directory.path()); | ||
| 46 | let working = cache.snapshot().unwrap(); | 90 | let working = cache.snapshot().unwrap(); |
| 47 | let remote = cache.remote_snapshot().unwrap(); | 91 | let remote = cache.remote_snapshot().unwrap(); |
| 48 | let pending = cache.pending().unwrap(); | 92 | let pending = cache.pending().unwrap(); |
| ... | @@ -51,10 +95,7 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact | ... | @@ -51,10 +95,7 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact |
| 51 | [1, 2] | 95 | [1, 2] |
| 52 | ); | 96 | ); |
| 53 | let uncertain = cache.status(1).unwrap(); | 97 | let uncertain = cache.status(1).unwrap(); |
| 54 | assert!(matches!( | 98 | assert_eq!(uncertain, Some(EditStatus::Pending)); |
| 55 | uncertain, | ||
| 56 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 57 | )); | ||
| 58 | let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); | 99 | let mut source = notebook::discover::Local::open(FIXTURE).unwrap(); |
| 59 | for size in [0, 1024] { | 100 | for size in [0, 1024] { |
| 60 | let (name, bytes) = payload(size); | 101 | let (name, bytes) = payload(size); |
| ... | @@ -99,30 +140,12 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact | ... | @@ -99,30 +140,12 @@ fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact |
| 99 | ); | 140 | ); |
| 100 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); | 141 | assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); |
| 101 | } | 142 | } |
| 102 | assert_eq!(fs::read(LEGACY).unwrap(), original); | ||
| 103 | } | ||
| 104 | |||
| 105 | #[test] | ||
| 106 | fn 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); | ||
| 120 | } | 143 | } |
| 121 | 144 | ||
| 122 | #[test] | 145 | #[test] |
| 123 | fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { | 146 | fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { |
| 124 | let directory = tempfile::tempdir().unwrap(); | 147 | let directory = tempfile::tempdir().unwrap(); |
| 125 | let cache = copied_cache(directory.path()); | 148 | let cache = queued_cache(directory.path()); |
| 126 | let (name, bytes) = payload(1024); | 149 | let (name, bytes) = payload(1024); |
| 127 | let mut source = Payload(Some(Ok(bytes.clone()))); | 150 | let mut source = Payload(Some(Ok(bytes.clone()))); |
| 128 | cache | 151 | cache |
| ... | @@ -154,7 +177,7 @@ fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payl | ... | @@ -154,7 +177,7 @@ fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payl |
| 154 | #[test] | 177 | #[test] |
| 155 | fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { | 178 | fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { |
| 156 | let directory = tempfile::tempdir().unwrap(); | 179 | let directory = tempfile::tempdir().unwrap(); |
| 157 | let cache = copied_cache(directory.path()); | 180 | let cache = queued_cache(directory.path()); |
| 158 | let mut unused = Payload(None); | 181 | let mut unused = Payload(None); |
| 159 | assert!( | 182 | assert!( |
| 160 | matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100), | 183 | 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() { | ... | @@ -186,7 +209,7 @@ fn download_network_wait_does_not_block_local_edits() { |
| 186 | } | 209 | } |
| 187 | } | 210 | } |
| 188 | let directory = tempfile::tempdir().unwrap(); | 211 | let directory = tempfile::tempdir().unwrap(); |
| 189 | let cache = copied_cache(directory.path()); | 212 | let cache = queued_cache(directory.path()); |
| 190 | let (name, bytes) = payload(1024); | 213 | let (name, bytes) = payload(1024); |
| 191 | let (entered, waiting) = mpsc::channel(); | 214 | let (entered, waiting) = mpsc::channel(); |
| 192 | let (release, released) = mpsc::channel(); | 215 | let (release, released) = mpsc::channel(); |
| ... | @@ -202,30 +225,26 @@ fn download_network_wait_does_not_block_local_edits() { | ... | @@ -202,30 +225,26 @@ fn download_network_wait_does_not_block_local_edits() { |
| 202 | .unwrap() | 225 | .unwrap() |
| 203 | }); | 226 | }); |
| 204 | waiting.recv_timeout(Duration::from_secs(5)).unwrap(); | 227 | waiting.recv_timeout(Duration::from_secs(5)).unwrap(); |
| 205 | let pending = cache.pending().unwrap(); | 228 | let text = first_text(&cache.snapshot().unwrap()); |
| 206 | let Operation::Text(edit) = &pending[0].operation else { | 229 | let id = model_ops::save(&cache, text, |page| { |
| 207 | panic!() | 230 | model_ops::replace_text(page, text, 0..0, "during download ") |
| 208 | }; | 231 | }) |
| 209 | let id = cache | 232 | .unwrap() |
| 210 | .edit_text( | 233 | .unwrap(); |
| 211 | &cache.snapshot().unwrap(), | ||
| 212 | pending[0].space, | ||
| 213 | edit.object, | ||
| 214 | 0..0, | ||
| 215 | "during download ", | ||
| 216 | ) | ||
| 217 | .unwrap() | ||
| 218 | .unwrap(); | ||
| 219 | release.send(()).unwrap(); | 234 | release.send(()).unwrap(); |
| 220 | assert_eq!(download.join().unwrap(), bytes); | 235 | assert_eq!(download.join().unwrap(), bytes); |
| 221 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 236 | 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 "); | ||
| 222 | }); | 241 | }); |
| 223 | } | 242 | } |
| 224 | 243 | ||
| 225 | #[test] | 244 | #[test] |
| 226 | fn concurrent_downloads_publish_one_immutable_cache_entry() { | 245 | fn concurrent_downloads_publish_one_immutable_cache_entry() { |
| 227 | let directory = tempfile::tempdir().unwrap(); | 246 | let directory = tempfile::tempdir().unwrap(); |
| 228 | let cache = copied_cache(directory.path()); | 247 | let cache = queued_cache(directory.path()); |
| 229 | let (name, bytes) = payload(1024); | 248 | let (name, bytes) = payload(1024); |
| 230 | let ready = Barrier::new(8); | 249 | let ready = Barrier::new(8); |
| 231 | std::thread::scope(|scope| { | 250 | std::thread::scope(|scope| { |
| ... | @@ -287,7 +306,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { | ... | @@ -287,7 +306,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { |
| 287 | #[test] | 306 | #[test] |
| 288 | fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { | 307 | fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { |
| 289 | let directory = tempfile::tempdir().unwrap(); | 308 | let directory = tempfile::tempdir().unwrap(); |
| 290 | drop(copied_cache(directory.path())); | 309 | drop(queued_cache(directory.path())); |
| 291 | let path = directory.path().join("cache.sqlite"); | 310 | let path = directory.path().join("cache.sqlite"); |
| 292 | let connection = rusqlite::Connection::open(&path).unwrap(); | 311 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 293 | connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap(); | 312 | 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() { | ... | @@ -346,7 +365,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { |
| 346 | } | 365 | } |
| 347 | } | 366 | } |
| 348 | let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap(); | 367 | 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)); |
| 350 | let (name, bytes) = payload(1024); | 369 | let (name, bytes) = payload(1024); |
| 351 | if phase == "download" { | 370 | if phase == "download" { |
| 352 | cache | 371 | cache |
| ... | @@ -391,10 +410,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { | ... | @@ -391,10 +410,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { |
| 391 | let expected = (phase != "download").then_some(bytes); | 410 | let expected = (phase != "download").then_some(bytes); |
| 392 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); | 411 | assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); |
| 393 | assert_eq!(cache.pending().unwrap().len(), 2); | 412 | assert_eq!(cache.pending().unwrap().len(), 2); |
| 394 | assert!(matches!( | 413 | assert_eq!(cache.status(1).unwrap(), Some(EditStatus::Pending)); |
| 395 | cache.status(1).unwrap(), | ||
| 396 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 397 | )); | ||
| 398 | if phase == "archive" { | 414 | if phase == "archive" { |
| 399 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); | 415 | let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); |
| 400 | assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); | 416 | assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); |
| ... | @@ -409,7 +425,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { | ... | @@ -409,7 +425,7 @@ fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { |
| 409 | #[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] | 425 | #[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] |
| 410 | fn live_smb_downloads_survive_disconnect_and_cache_reopen() { | 426 | fn live_smb_downloads_survive_disconnect_and_cache_reopen() { |
| 411 | let root = tempfile::tempdir().unwrap(); | 427 | let root = tempfile::tempdir().unwrap(); |
| 412 | let cache = copied_cache(root.path()); | 428 | let cache = queued_cache(root.path()); |
| 413 | let client = notebook::smb::Client::connect( | 429 | let client = notebook::smb::Client::connect( |
| 414 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), | 430 | &std::env::var("ONESTORE_SMB_LAB").unwrap(), |
| 415 | "agent", | 431 | "agent", |
crates/notebook/tests/cache.rs+261-543| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | use notebook::{Error, Replica}; | 1 | use notebook::{Error, Operation, Replica}; |
| 2 | use onestore::{ | 2 | use onestore::{ |
| 3 | ExGuid, RevisionIndex, Store, | 3 | ExGuid, RevisionIndex, Store, |
| 4 | document::{Document, Kind}, | 4 | document::{Document, Kind}, |
| 5 | page::{Outline, PageObject}, | ||
| 5 | }; | 6 | }; |
| 6 | use std::{ | 7 | use std::{ |
| 7 | collections::BTreeSet, | 8 | collections::BTreeSet, |
| ... | @@ -11,6 +12,9 @@ use std::{ | ... | @@ -11,6 +12,9 @@ use std::{ |
| 11 | time::{Duration, Instant}, | 12 | time::{Duration, Instant}, |
| 12 | }; | 13 | }; |
| 13 | 14 | ||
| 15 | #[path = "support/model_ops.rs"] | ||
| 16 | mod model_ops; | ||
| 17 | |||
| 14 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | 18 | fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { |
| 15 | let store = Store::parse(source).unwrap(); | 19 | let store = Store::parse(source).unwrap(); |
| 16 | assert!(store.checksum_mismatches.is_empty()); | 20 | assert!(store.checksum_mismatches.is_empty()); |
| ... | @@ -32,6 +36,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { | ... | @@ -32,6 +36,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) { |
| 32 | .unwrap() | 36 | .unwrap() |
| 33 | } | 37 | } |
| 34 | 38 | ||
| 39 | fn 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 | |||
| 49 | fn 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 | |||
| 35 | #[test] | 56 | #[test] |
| 36 | fn cache_reopen_preserves_exact_images_and_intents() { | 57 | fn cache_reopen_preserves_exact_images_and_intents() { |
| 37 | let dir = tempfile::tempdir().unwrap(); | 58 | let dir = tempfile::tempdir().unwrap(); |
| ... | @@ -41,68 +62,85 @@ fn cache_reopen_preserves_exact_images_and_intents() { | ... | @@ -41,68 +62,85 @@ fn cache_reopen_preserves_exact_images_and_intents() { |
| 41 | assert_eq!(replica.snapshot().unwrap(), source); | 62 | assert_eq!(replica.snapshot().unwrap(), source); |
| 42 | assert!(replica.pending().unwrap().is_empty()); | 63 | assert!(replica.pending().unwrap().is_empty()); |
| 43 | let (sid, oid, _) = target(&source); | 64 | let (sid, oid, _) = target(&source); |
| 44 | assert_eq!( | 65 | let unchanged = |page: &mut onestore::page::Page| { |
| 45 | replica.edit_text(&source, sid, oid, 0..0, "").unwrap(), | 66 | model_ops::replace_text(page, oid, 0..0, ""); |
| 46 | None | 67 | model_ops::replace_text(page, oid, 0..4, "café"); |
| 47 | ); | 68 | }; |
| 48 | assert_eq!( | 69 | assert_eq!(model_ops::save(&replica, oid, unchanged).unwrap(), None); |
| 49 | replica.edit_text(&source, sid, oid, 0..4, "café").unwrap(), | 70 | let first = model_ops::save(&replica, oid, |page| { |
| 50 | None | 71 | model_ops::replace_text(page, oid, 5..7, "🐈 日本語") |
| 51 | ); | 72 | }) |
| 52 | let first = replica | 73 | .unwrap() |
| 53 | .edit_text(&source, sid, oid, 5..7, "🐈 日本語") | 74 | .unwrap(); |
| 54 | .unwrap() | ||
| 55 | .unwrap(); | ||
| 56 | let edited = replica.snapshot().unwrap(); | 75 | let edited = replica.snapshot().unwrap(); |
| 57 | assert_eq!(target(&edited).2, "café 🐈 日本語"); | 76 | assert_eq!(target(&edited).2, "café 🐈 日本語"); |
| 58 | let intents = replica.pending().unwrap(); | 77 | let intents = replica.pending().unwrap(); |
| 59 | assert_eq!(intents.len(), 1); | 78 | assert_eq!(intents.len(), 1); |
| 60 | assert_eq!(intents[0].id, first); | 79 | assert_eq!((intents[0].id, intents[0].space), (first, sid)); |
| 61 | let notebook::Operation::Text(first_edit) = &intents[0].operation else { | 80 | let first_intent = intent(&replica, 0); |
| 62 | panic!() | 81 | assert_eq!( |
| 63 | }; | 82 | first_intent.text_change(), |
| 64 | assert_eq!(first_edit.before, "café 🦀"); | 83 | Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into())) |
| 65 | assert_eq!(first_edit.range, 5..7); | 84 | ); |
| 66 | assert_eq!(first_edit.replacement, "🐈 日本語"); | 85 | assert_eq!(first_intent.author, model_ops::AUTHOR); |
| 67 | assert_eq!((intents[0].space, first_edit.object), (sid, oid)); | ||
| 68 | drop(replica); | 86 | drop(replica); |
| 69 | let replica = Replica::open(&path).unwrap(); | 87 | let replica = Replica::open(&path).unwrap(); |
| 70 | assert_eq!(replica.snapshot().unwrap(), edited); | 88 | assert_eq!(replica.snapshot().unwrap(), edited); |
| 71 | assert_eq!(replica.pending().unwrap(), intents); | 89 | assert_eq!(replica.pending().unwrap(), intents); |
| 72 | let second = replica | 90 | let second = model_ops::save(&replica, oid, |page| { |
| 73 | .edit_text(&edited, sid, oid, 0..0, "Recovered ") | 91 | model_ops::replace_text(page, oid, 0..0, "Recovered ") |
| 74 | .unwrap() | 92 | }) |
| 75 | .unwrap(); | 93 | .unwrap() |
| 76 | assert!(second > first); | 94 | .unwrap(); |
| 77 | let notebook::Operation::Text(second_edit) = &replica.pending().unwrap()[1].operation else { | 95 | assert_eq!(second, first, "an unattempted save is replaced in place"); |
| 78 | panic!() | 96 | let coalesced = intent(&replica, 0); |
| 79 | }; | 97 | assert_eq!(coalesced.before, first_intent.before); |
| 80 | assert_eq!(second_edit.before, "café 🐈 日本語"); | 98 | assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語"); |
| 99 | assert_eq!( | ||
| 100 | target(&replica.snapshot().unwrap()).2, | ||
| 101 | "Recovered café 🐈 日本語" | ||
| 102 | ); | ||
| 81 | } | 103 | } |
| 82 | 104 | ||
| 83 | #[test] | 105 | #[test] |
| 84 | fn failed_edits_preserve_both_intent_queue_and_working_image() { | 106 | fn failed_saves_preserve_both_intent_queue_and_working_image() { |
| 85 | let dir = tempfile::tempdir().unwrap(); | 107 | let dir = tempfile::tempdir().unwrap(); |
| 86 | let path = dir.path().join("section.sqlite"); | 108 | let path = dir.path().join("section.sqlite"); |
| 87 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); | 109 | let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap(); |
| 88 | let replica = Replica::create(&path, &source).unwrap(); | 110 | let replica = Replica::create(&path, &source).unwrap(); |
| 89 | let (sid, oid, _) = target(&source); | 111 | let (sid, oid, _) = target(&source); |
| 90 | for (range, replacement) in [(6..7, "X"), (0..u32::MAX, "X"), (0..1, "\n")] { | 112 | let (_, mut page) = model_ops::locate(&source, oid); |
| 91 | assert!( | 113 | let mut empty = Outline { |
| 92 | replica | 114 | id: onestore::page::text::new_id().unwrap(), |
| 93 | .edit_text(&source, sid, oid, range, replacement) | 115 | title: false, |
| 94 | .is_err() | 116 | min_width: None, |
| 95 | ); | 117 | layout: Default::default(), |
| 96 | assert_eq!(replica.snapshot().unwrap(), source); | 118 | indents: Vec::new(), |
| 97 | assert!(replica.pending().unwrap().is_empty()); | 119 | paragraphs: Vec::new(), |
| 98 | } | 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()); | ||
| 99 | drop(replica); | 132 | drop(replica); |
| 100 | let connection = rusqlite::Connection::open(&path).unwrap(); | 133 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 101 | connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); | 134 | connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap(); |
| 102 | drop(connection); | 135 | drop(connection); |
| 103 | let replica = Replica::open(&path).unwrap(); | 136 | let replica = Replica::open(&path).unwrap(); |
| 104 | assert!(matches!( | 137 | 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 | )), | ||
| 106 | Err(Error::Database(_)) | 144 | Err(Error::Database(_)) |
| 107 | )); | 145 | )); |
| 108 | drop(replica); | 146 | drop(replica); |
| ... | @@ -113,11 +151,11 @@ fn failed_edits_preserve_both_intent_queue_and_working_image() { | ... | @@ -113,11 +151,11 @@ fn failed_edits_preserve_both_intent_queue_and_working_image() { |
| 113 | 151 | ||
| 114 | #[test] | 152 | #[test] |
| 115 | fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | 153 | fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 116 | use notebook::{Operation, Recovery}; | 154 | use notebook::Recovery; |
| 117 | 155 | ||
| 118 | let directory = tempfile::tempdir().unwrap(); | 156 | let directory = tempfile::tempdir().unwrap(); |
| 119 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); | 157 | let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap(); |
| 120 | let (space, object, _) = target(&source); | 158 | let (_, object, _) = target(&source); |
| 121 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | 159 | let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); |
| 122 | let start = Barrier::new(4); | 160 | let start = Barrier::new(4); |
| 123 | std::thread::scope(|scope| { | 161 | std::thread::scope(|scope| { |
| ... | @@ -127,14 +165,10 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -127,14 +165,10 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 127 | start.wait(); | 165 | start.wait(); |
| 128 | for edit in 0..20 { | 166 | for edit in 0..20 { |
| 129 | loop { | 167 | loop { |
| 130 | let source = replica.snapshot().unwrap(); | 168 | let marker = format!("[{writer}:{edit}] "); |
| 131 | match replica.edit_text( | 169 | match model_ops::save(replica, object, |page| { |
| 132 | &source, | 170 | model_ops::replace_text(page, object, 0..0, &marker) |
| 133 | space, | 171 | }) { |
| 134 | object, | ||
| 135 | 0..0, | ||
| 136 | &format!("[{writer}:{edit}] "), | ||
| 137 | ) { | ||
| 138 | Ok(Some(_)) => break, | 172 | Ok(Some(_)) => break, |
| 139 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { | 173 | Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => { |
| 140 | continue; | 174 | continue; |
| ... | @@ -151,16 +185,20 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -151,16 +185,20 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 151 | replica.export_recovery(&path).unwrap(); | 185 | replica.export_recovery(&path).unwrap(); |
| 152 | let archive = Recovery::open(path).unwrap(); | 186 | let archive = Recovery::open(path).unwrap(); |
| 153 | let pending = archive.pending().unwrap(); | 187 | let pending = archive.pending().unwrap(); |
| 154 | let mut expected = String::new(); | 188 | let snapshot = target(&archive.snapshot().unwrap()).2; |
| 155 | for edit in pending.iter().rev() { | 189 | match pending.as_slice() { |
| 156 | let Operation::Text(edit) = &edit.operation else { | 190 | [] => assert_eq!(snapshot, "base"), |
| 157 | panic!() | 191 | [single] => { |
| 158 | }; | 192 | let Operation::Page(intent) = &single.operation else { |
| 159 | assert_eq!(edit.range, 0..0); | 193 | panic!() |
| 160 | expected.push_str(&edit.replacement); | 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:?}"), | ||
| 161 | } | 201 | } |
| 162 | expected.push_str("base"); | ||
| 163 | assert_eq!(target(&archive.snapshot().unwrap()).2, expected); | ||
| 164 | assert_eq!(archive.remote_snapshot().unwrap(), source); | 202 | assert_eq!(archive.remote_snapshot().unwrap(), source); |
| 165 | assert_eq!( | 203 | assert_eq!( |
| 166 | archive.summary().unwrap().queued_edits, | 204 | archive.summary().unwrap().queued_edits, |
| ... | @@ -169,7 +207,13 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { | ... | @@ -169,7 +207,13 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() { |
| 169 | assert!(archive.receipts().unwrap().is_empty()); | 207 | assert!(archive.receipts().unwrap().is_empty()); |
| 170 | } | 208 | } |
| 171 | }); | 209 | }); |
| 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 | } | ||
| 173 | } | 217 | } |
| 174 | 218 | ||
| 175 | #[test] | 219 | #[test] |
| ... | @@ -192,15 +236,39 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ... | @@ -192,15 +236,39 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { |
| 192 | assert_eq!(replica.snapshot().unwrap(), source); | 236 | assert_eq!(replica.snapshot().unwrap(), source); |
| 193 | } | 237 | } |
| 194 | drop(replica); | 238 | 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(); | ||
| 195 | for sql in [ | 243 | for sql in [ |
| 196 | "PRAGMA application_id=0", | 244 | "PRAGMA application_id=0".to_owned(), |
| 197 | "PRAGMA application_id=1330529615; PRAGMA user_version=99", | 245 | format!( |
| 246 | "PRAGMA application_id=1330529615; PRAGMA user_version={}", | ||
| 247 | current - 1 | ||
| 248 | ), | ||
| 249 | format!("PRAGMA user_version={}", current + 1), | ||
| 198 | ] { | 250 | ] { |
| 199 | let connection = rusqlite::Connection::open(&path).unwrap(); | 251 | let connection = rusqlite::Connection::open(&path).unwrap(); |
| 200 | connection.execute_batch(sql).unwrap(); | 252 | connection.execute_batch(&sql).unwrap(); |
| 201 | drop(connection); | 253 | drop(connection); |
| 202 | let before = fs::read(&path).unwrap(); | 254 | 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 | ); | ||
| 204 | assert_eq!(fs::read(&path).unwrap(), before); | 272 | assert_eq!(fs::read(&path).unwrap(), before); |
| 205 | } | 273 | } |
| 206 | let foreign = dir.path().join("foreign.sqlite"); | 274 | let foreign = dir.path().join("foreign.sqlite"); |
| ... | @@ -221,7 +289,7 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { | ... | @@ -221,7 +289,7 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() { |
| 221 | } | 289 | } |
| 222 | 290 | ||
| 223 | #[test] | 291 | #[test] |
| 224 | fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() { | 292 | fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() { |
| 225 | let dir = tempfile::tempdir().unwrap(); | 293 | let dir = tempfile::tempdir().unwrap(); |
| 226 | let path = dir.path().join("section.sqlite"); | 294 | let path = dir.path().join("section.sqlite"); |
| 227 | let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap(); | 295 | 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() | ... | @@ -234,11 +302,11 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() |
| 234 | .map(|writer| { | 302 | .map(|writer| { |
| 235 | let (replica, source, barrier) = (&replica, &source, &barrier); | 303 | let (replica, source, barrier) = (&replica, &source, &barrier); |
| 236 | scope.spawn(move || { | 304 | 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}] ")); | ||
| 237 | barrier.wait(); | 307 | barrier.wait(); |
| 238 | let first = | ||
| 239 | replica.edit_text(source, sid, oid, 0..0, &format!("[initial-{writer}] ")); | ||
| 240 | let mut ids = Vec::new(); | 308 | let mut ids = Vec::new(); |
| 241 | let first_won = match first { | 309 | let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) { |
| 242 | Ok(Some(id)) => { | 310 | Ok(Some(id)) => { |
| 243 | ids.push(id); | 311 | ids.push(id); |
| 244 | true | 312 | true |
| ... | @@ -252,14 +320,10 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() | ... | @@ -252,14 +320,10 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() |
| 252 | Instant::now() < deadline, | 320 | Instant::now() < deadline, |
| 253 | "Writer {writer} stopped progressing at {edit}" | 321 | "Writer {writer} stopped progressing at {edit}" |
| 254 | ); | 322 | ); |
| 255 | let source = replica.snapshot().unwrap(); | 323 | let marker = format!("[{writer}-{edit}] "); |
| 256 | match replica.edit_text( | 324 | match model_ops::save(replica, oid, |page| { |
| 257 | &source, | 325 | model_ops::replace_text(page, oid, 0..0, &marker) |
| 258 | sid, | 326 | }) { |
| 259 | oid, | ||
| 260 | 0..0, | ||
| 261 | &format!("[{writer}-{edit}] "), | ||
| 262 | ) { | ||
| 263 | Ok(Some(id)) => { | 327 | Ok(Some(id)) => { |
| 264 | ids.push(id); | 328 | ids.push(id); |
| 265 | break; | 329 | break; |
| ... | @@ -281,48 +345,39 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() | ... | @@ -281,48 +345,39 @@ fn twelve_local_editors_reject_stale_ranges_and_preserve_every_acknowledgement() |
| 281 | }); | 345 | }); |
| 282 | assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); | 346 | assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1); |
| 283 | let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect(); | 347 | 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"); |
| 285 | let final_bytes = replica.snapshot().unwrap(); | 349 | let final_bytes = replica.snapshot().unwrap(); |
| 286 | let content = target(&final_bytes).2; | 350 | let content = target(&final_bytes).2; |
| 287 | let mut expected = "Shared café 🦀".to_owned(); | 351 | let pending = replica.pending().unwrap(); |
| 288 | for pending in replica.pending().unwrap() { | 352 | assert_eq!(pending.len(), 1); |
| 289 | let notebook::Operation::Text(edit) = pending.operation else { | 353 | assert_eq!(pending[0].id, *ids.first().unwrap()); |
| 290 | panic!() | 354 | let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap(); |
| 291 | }; | 355 | assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0)); |
| 292 | assert_eq!(edit.before, expected); | 356 | assert_eq!(content, format!("{replacement}Shared café 🦀")); |
| 293 | assert_eq!(edit.range, 0..0); | 357 | assert_eq!(content.matches("[initial-").count(), 1); |
| 294 | expected.insert_str(0, &edit.replacement); | ||
| 295 | } | ||
| 296 | assert_eq!(content, expected); | ||
| 297 | for writer in 0..12 { | 358 | for writer in 0..12 { |
| 298 | for edit in 0..20 { | 359 | for edit in 0..20 { |
| 299 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); | 360 | assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1); |
| 300 | } | 361 | } |
| 301 | } | 362 | } |
| 302 | assert_eq!( | 363 | let (_, mut stale) = model_ops::locate(&source, oid); |
| 303 | replica | 364 | model_ops::replace_text(&mut stale, oid, 0..0, "stale "); |
| 304 | .pending() | ||
| 305 | .unwrap() | ||
| 306 | .iter() | ||
| 307 | .map(|edit| edit.id) | ||
| 308 | .collect::<BTreeSet<_>>(), | ||
| 309 | ids | ||
| 310 | ); | ||
| 311 | assert!( | 365 | 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) |
| 313 | ); | 367 | ); |
| 314 | drop(replica); | 368 | drop(replica); |
| 315 | let reopened = Replica::open(&path).unwrap(); | 369 | let reopened = Replica::open(&path).unwrap(); |
| 316 | assert_eq!(reopened.snapshot().unwrap(), final_bytes); | 370 | assert_eq!(reopened.snapshot().unwrap(), final_bytes); |
| 317 | assert_eq!(reopened.pending().unwrap().len(), 241); | 371 | assert_eq!(reopened.pending().unwrap(), pending); |
| 318 | } | 372 | } |
| 319 | 373 | ||
| 320 | #[test] | 374 | #[test] |
| 321 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | 375 | fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 322 | let dir = tempfile::tempdir().unwrap(); | 376 | let dir = tempfile::tempdir().unwrap(); |
| 323 | let path = dir.path().join("model.sqlite"); | 377 | let path = dir.path().join("model.sqlite"); |
| 324 | let mut text = "ab🚀ab🦀 é repeated repeated".to_owned(); | 378 | let original = "ab🚀ab🦀 é repeated repeated".to_owned(); |
| 325 | let mut source = onestore::create_section("model.one", &text, "Fixture").unwrap(); | 379 | let mut text = original.clone(); |
| 380 | let source = onestore::create_section("model.one", &text, "Fixture").unwrap(); | ||
| 326 | let mut replica = Replica::create(&path, &source).unwrap(); | 381 | let mut replica = Replica::create(&path, &source).unwrap(); |
| 327 | let (space, object, _) = target(&source); | 382 | let (space, object, _) = target(&source); |
| 328 | let mut random = 911_u64; | 383 | let mut random = 911_u64; |
| ... | @@ -332,8 +387,9 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ... | @@ -332,8 +387,9 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 332 | random ^= random << 17; | 387 | random ^= random << 17; |
| 333 | random | 388 | random |
| 334 | }; | 389 | }; |
| 335 | let mut intents = Vec::new(); | 390 | let mut acknowledged = 0; |
| 336 | for step in 0..1024 { | 391 | let mut pending = Vec::new(); |
| 392 | for step in 0..512 { | ||
| 337 | let boundaries: Vec<_> = text | 393 | let boundaries: Vec<_> = text |
| 338 | .char_indices() | 394 | .char_indices() |
| 339 | .map(|(at, _)| at) | 395 | .map(|(at, _)| at) |
| ... | @@ -344,283 +400,110 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { | ... | @@ -344,283 +400,110 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() { |
| 344 | let bytes = first.min(last)..first.max(last); | 400 | let bytes = first.min(last)..first.max(last); |
| 345 | let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap() | 401 | let range = u32::try_from(text[..bytes.start].encode_utf16().count()).unwrap() |
| 346 | ..u32::try_from(text[..bytes.end].encode_utf16().count()).unwrap(); | 402 | ..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]; |
| 348 | let mut expected = text.clone(); | 404 | let mut expected = text.clone(); |
| 349 | expected.replace_range(bytes, replacement); | 405 | expected.replace_range(bytes, replacement); |
| 350 | let acknowledgement = replica | 406 | let before = replica.snapshot().unwrap(); |
| 351 | .edit_text(&source, space, object, range.clone(), replacement) | 407 | let acknowledgement = model_ops::save(&replica, object, |page| { |
| 352 | .unwrap(); | 408 | model_ops::replace_text(page, object, range.clone(), replacement) |
| 409 | }) | ||
| 410 | .unwrap(); | ||
| 353 | if let Some(id) = acknowledgement { | 411 | if let Some(id) = acknowledgement { |
| 354 | assert_ne!(text, expected); | 412 | assert_ne!(text, expected); |
| 355 | assert!( | 413 | acknowledged += 1; |
| 356 | intents | 414 | pending = replica.pending().unwrap(); |
| 357 | .last() | 415 | assert_eq!(pending.len(), 1); |
| 358 | .is_none_or(|edit: &notebook::PendingEdit| edit.id < id) | 416 | assert_eq!((pending[0].id, pending[0].space), (1, space)); |
| 359 | ); | 417 | let saved = intent(&replica, 0); |
| 360 | intents.push(notebook::PendingEdit { | 418 | assert_eq!(id, 1); |
| 361 | id, | 419 | assert_eq!(text_of(&saved.before, object), original); |
| 362 | space, | 420 | assert_eq!(text_of(&saved.after, object), expected); |
| 363 | operation: notebook::Operation::Text(notebook::TextEdit { | 421 | if step % 37 == 0 { |
| 364 | object, | 422 | let (_, mut stale) = model_ops::locate(&before, object); |
| 365 | before: text, | 423 | model_ops::replace_text(&mut stale, object, 0..0, "stale"); |
| 366 | range, | 424 | assert!( |
| 367 | replacement: replacement.into(), | 425 | matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) |
| 368 | }), | 426 | ); |
| 369 | }); | 427 | } |
| 370 | assert!( | ||
| 371 | matches!(replica.edit_text(&source, space, object, 0..0, "stale"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy) | ||
| 372 | ); | ||
| 373 | } else { | 428 | } else { |
| 374 | assert_eq!(text, expected); | 429 | assert_eq!(text, expected); |
| 375 | } | 430 | } |
| 376 | text = expected; | 431 | text = expected; |
| 377 | source = replica.snapshot().unwrap(); | 432 | let source = replica.snapshot().unwrap(); |
| 378 | assert_eq!(target(&source).2, text, "seed 911, step {step}"); | 433 | assert_eq!(target(&source).2, text, "seed 911, step {step}"); |
| 379 | if step % 37 == 0 { | 434 | if step % 37 == 0 { |
| 380 | drop(replica); | 435 | drop(replica); |
| 381 | replica = Replica::open(&path).unwrap(); | 436 | replica = Replica::open(&path).unwrap(); |
| 382 | assert_eq!(replica.snapshot().unwrap(), source); | 437 | assert_eq!(replica.snapshot().unwrap(), source); |
| 383 | assert_eq!(replica.pending().unwrap(), intents); | 438 | assert_eq!(replica.pending().unwrap(), pending); |
| 384 | } | 439 | } |
| 385 | } | 440 | } |
| 386 | assert!( | 441 | assert!(acknowledged > 256, "Most random edits change the text"); |
| 387 | intents.len() > 512, | 442 | let source = replica.snapshot().unwrap(); |
| 388 | "The history checkpoint boundary was not exercised" | ||
| 389 | ); | ||
| 390 | drop(replica); | 443 | drop(replica); |
| 391 | let replica = Replica::open(&path).unwrap(); | 444 | let replica = Replica::open(&path).unwrap(); |
| 392 | assert_eq!(replica.pending().unwrap(), intents); | 445 | assert_eq!(replica.pending().unwrap(), pending); |
| 393 | assert_eq!(replica.snapshot().unwrap(), source); | 446 | assert_eq!(replica.snapshot().unwrap(), source); |
| 394 | } | 447 | } |
| 395 | 448 | ||
| 396 | #[test] | ||
| 397 | #[ignore = "migrates a fresh copy of a retained version-two or version-three cache"] | ||
| 398 | fn 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 | |||
| 571 | #[test] | 449 | #[test] |
| 572 | fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | 450 | fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 573 | use onestore::Insertion; | ||
| 574 | let directory = tempfile::tempdir().unwrap(); | 451 | let directory = tempfile::tempdir().unwrap(); |
| 575 | let path = directory.path().join("parallel.sqlite"); | 452 | let path = directory.path().join("parallel.sqlite"); |
| 576 | let source = onestore::create_section("parallel.one", "Original", "Author").unwrap(); | 453 | let source = onestore::create_section("parallel.one", "Original", "Author").unwrap(); |
| 577 | let store = Store::parse(&source).unwrap(); | 454 | let (sid, anchor, _) = target(&source); |
| 578 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 579 | let doc = Document::parse(&index).unwrap(); | ||
| 580 | let (sid, page) = doc.pages().unwrap()[0]; | ||
| 581 | let cache = Replica::create(&path, &source).unwrap(); | 455 | let cache = Replica::create(&path, &source).unwrap(); |
| 582 | let barrier = Barrier::new(12); | 456 | let barrier = Barrier::new(12); |
| 583 | let deadline = Instant::now() + Duration::from_secs(90); | 457 | let deadline = Instant::now() + Duration::from_secs(90); |
| 458 | let italic = |format: &mut onestore::document::Format| format.italic = Some(true); | ||
| 584 | let all = std::thread::scope(|scope| { | 459 | let all = std::thread::scope(|scope| { |
| 585 | let handles: Vec<_> = (0..12) | 460 | let handles: Vec<_> = (0..12) |
| 586 | .map(|client| { | 461 | .map(|client| { |
| 587 | let (cache, barrier) = (&cache, &barrier); | 462 | let (cache, barrier) = (&cache, &barrier); |
| 588 | scope.spawn(move || { | 463 | 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(); | ||
| 597 | let mut ids = Vec::new(); | 464 | let mut ids = Vec::new(); |
| 598 | let mut objects = Vec::new(); | 465 | let mut objects = Vec::new(); |
| 599 | barrier.wait(); | 466 | barrier.wait(); |
| 600 | for sequence in 0..4 { | 467 | for sequence in 0..4 { |
| 601 | let insertion = if sequence == 0 { | 468 | let content = if sequence == 0 { |
| 602 | outline.clone() | 469 | format!("Client {client}") |
| 603 | } else { | 470 | } else { |
| 604 | Insertion::paragraph( | 471 | format!("Paragraph {client}:{sequence}") |
| 605 | outline.object(), | 472 | }; |
| 606 | None, | 473 | let mut inserted = None; |
| 607 | &format!("Paragraph {client}:{sequence}"), | ||
| 608 | "Author", | ||
| 609 | ) | ||
| 610 | .unwrap() | ||
| 611 | } | ||
| 612 | .with_formatting(0..6, &[onestore::TextAttribute::Italic(true)]) | ||
| 613 | .unwrap(); | ||
| 614 | loop { | 474 | loop { |
| 615 | assert!( | 475 | assert!( |
| 616 | Instant::now() < deadline, | 476 | Instant::now() < deadline, |
| 617 | "Client {client} stopped at insertion {sequence}" | 477 | "Client {client} stopped at insertion {sequence}" |
| 618 | ); | 478 | ); |
| 619 | let snapshot = cache.snapshot().unwrap(); | 479 | let result = model_ops::save(cache, anchor, |page| { |
| 620 | match cache.insert(&snapshot, sid, &insertion) { | 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 { | ||
| 621 | Ok(Some(id)) => { | 504 | Ok(Some(id)) => { |
| 622 | ids.push(id); | 505 | ids.push(id); |
| 623 | objects.push(insertion.text_object()); | 506 | objects.push(inserted.unwrap()); |
| 624 | break; | 507 | break; |
| 625 | } | 508 | } |
| 626 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} | 509 | Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {} |
| ... | @@ -630,19 +513,15 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -630,19 +513,15 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 630 | if sequence == 0 { | 513 | if sequence == 0 { |
| 631 | continue; | 514 | continue; |
| 632 | } | 515 | } |
| 516 | let text = *objects.last().unwrap(); | ||
| 633 | loop { | 517 | loop { |
| 634 | assert!( | 518 | assert!( |
| 635 | Instant::now() < deadline, | 519 | Instant::now() < deadline, |
| 636 | "Client {client} stopped at text {sequence}" | 520 | "Client {client} stopped at text {sequence}" |
| 637 | ); | 521 | ); |
| 638 | let snapshot = cache.snapshot().unwrap(); | 522 | match model_ops::save(cache, text, |page| { |
| 639 | match cache.edit_text( | 523 | model_ops::replace_text(page, text, 0..0, "Edited ") |
| 640 | &snapshot, | 524 | }) { |
| 641 | sid, | ||
| 642 | insertion.text_object(), | ||
| 643 | 0..0, | ||
| 644 | "Edited ", | ||
| 645 | ) { | ||
| 646 | Ok(Some(id)) => { | 525 | Ok(Some(id)) => { |
| 647 | ids.push(id); | 526 | ids.push(id); |
| 648 | break; | 527 | break; |
| ... | @@ -665,7 +544,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -665,7 +544,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 665 | .iter() | 544 | .iter() |
| 666 | .flat_map(|(ids, _)| ids.iter().copied()) | 545 | .flat_map(|(ids, _)| ids.iter().copied()) |
| 667 | .collect(); | 546 | .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); | ||
| 669 | let snapshot = cache.snapshot().unwrap(); | 549 | let snapshot = cache.snapshot().unwrap(); |
| 670 | drop(cache); | 550 | drop(cache); |
| 671 | let cache = Replica::open(&path).unwrap(); | 551 | let cache = Replica::open(&path).unwrap(); |
| ... | @@ -709,69 +589,41 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { | ... | @@ -709,69 +589,41 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() { |
| 709 | 589 | ||
| 710 | #[test] | 590 | #[test] |
| 711 | fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | 591 | fn 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"] { |
| 713 | let directory = tempfile::tempdir().unwrap(); | 593 | let directory = tempfile::tempdir().unwrap(); |
| 714 | let path = directory.path().join("unknown.sqlite"); | 594 | let path = directory.path().join("unknown.sqlite"); |
| 715 | let mut source = onestore::create_section("unknown.one", "Original", "Author").unwrap(); | 595 | let first = onestore::create_section("unknown.one", "Original", "Author").unwrap(); |
| 716 | let (sid, oid, _) = target(&source); | 596 | let second = onestore::PageCreation::new(None, Some("Second"), "Author").unwrap(); |
| 717 | let split = onestore::ParagraphSplit::new(oid, 3, "Author").unwrap(); | 597 | let source = onestore::PreparedEdit::create_page(&first, &second) |
| 718 | if operation == "Join" { | 598 | .unwrap() |
| 719 | source = onestore::PreparedEdit::split(&source, sid, &split) | 599 | .as_bytes() |
| 720 | .unwrap() | 600 | .to_vec(); |
| 721 | .as_bytes() | 601 | let (_, oid, _) = target(&source); |
| 722 | .to_vec(); | 602 | let store = Store::parse(&source).unwrap(); |
| 723 | } | 603 | let index = RevisionIndex::parse(&store).unwrap(); |
| 604 | let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0; | ||
| 724 | let cache = Replica::create(&path, &source).unwrap(); | 605 | let cache = Replica::create(&path, &source).unwrap(); |
| 725 | if operation == "Insert" { | 606 | match operation { |
| 726 | let store = Store::parse(&source).unwrap(); | 607 | "Page" => { |
| 727 | let index = RevisionIndex::parse(&store).unwrap(); | 608 | model_ops::save(&cache, oid, |page| { |
| 728 | let doc = Document::parse(&index).unwrap(); | 609 | model_ops::replace_text(page, oid, 0..0, "New ") |
| 729 | let (_, page) = doc.pages().unwrap()[0]; | 610 | }) |
| 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]) | ||
| 743 | .unwrap() | 611 | .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 | ) | ||
| 774 | .unwrap(); | 612 | .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 | } | ||
| 775 | } | 627 | } |
| 776 | drop(cache); | 628 | drop(cache); |
| 777 | let db = rusqlite::Connection::open(&path).unwrap(); | 629 | let db = rusqlite::Connection::open(&path).unwrap(); |
| ... | @@ -779,153 +631,19 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { | ... | @@ -779,153 +631,19 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { |
| 779 | .query_row("SELECT operation FROM edits", [], |r| r.get(0)) | 631 | .query_row("SELECT operation FROM edits", [], |r| r.get(0)) |
| 780 | .unwrap(); | 632 | .unwrap(); |
| 781 | let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap(); | 633 | 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 | } | ||
| 783 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) | 639 | db.execute("UPDATE edits SET operation=?1", [value.to_string()]) |
| 784 | .unwrap(); | 640 | .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 | } | ||
| 788 | drop(db); | 641 | drop(db); |
| 789 | let before = fs::read(&path).unwrap(); | 642 | let before = fs::read(&path).unwrap(); |
| 790 | assert!( | 643 | 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}" | ||
| 792 | ); | 646 | ); |
| 793 | assert_eq!(fs::read(&path).unwrap(), before); | 647 | assert_eq!(fs::read(&path).unwrap(), before); |
| 794 | } | 648 | } |
| 795 | } | 649 | } |
| 796 | |||
| 797 | #[test] | ||
| 798 | fn 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 | |||
| 5 | use notebook::Replica; | ||
| 6 | use 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 | }; | ||
| 14 | use std::ops::Range; | ||
| 15 | |||
| 16 | pub const AUTHOR: &str = "Offline author"; | ||
| 17 | |||
| 18 | pub 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. | ||
| 26 | pub 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 | |||
| 43 | pub 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 | |||
| 55 | pub 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 | |||
| 69 | fn 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. | ||
| 79 | pub 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. | ||
| 92 | pub 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. | ||
| 113 | pub 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. | ||
| 134 | pub 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. | ||
| 160 | pub 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. | ||
| 202 | pub 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. | ||
| 222 | pub 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. | ||
| 234 | pub 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 | |||
| 4 | use crate::model_ops::{self, AUTHOR}; | ||
| 5 | use crate::server::{Fault, Server}; | ||
| 6 | use notebook::{EditStatus, Operation, Remote, Replica}; | ||
| 7 | use onestore::{ | ||
| 8 | CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 9 | document::Document, | ||
| 10 | page::{Outline, Page, PageObject}, | ||
| 11 | }; | ||
| 12 | use std::{io, sync::LazyLock}; | ||
| 13 | |||
| 14 | static 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 | |||
| 33 | fn 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. | ||
| 44 | fn 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. | ||
| 60 | fn 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. | ||
| 74 | pub(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. | ||
| 130 | struct 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 | |||
| 138 | impl 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 | |||
| 164 | fn 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. | ||
| 174 | fn 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 | |||
| 180 | pub 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 @@ | ... | @@ -1,11 +1,17 @@ |
| 1 | use crate::disk; | 1 | use crate::disk; |
| 2 | use notebook::{EditStatus, Operation, Remote, Replica}; | 2 | use notebook::{EditStatus, Operation, Remote, Replica}; |
| 3 | use onestore::{ | 3 | use onestore::{ |
| 4 | CommitError, ExGuid, Insertion, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, | 4 | CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, |
| 5 | document::{Document, Kind}, | 5 | document::{Document, Format, Kind, Layout}, |
| 6 | page::{ | ||
| 7 | Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, | ||
| 8 | text::new_id, | ||
| 9 | }, | ||
| 6 | }; | 10 | }; |
| 7 | use std::{io, sync::LazyLock}; | 11 | use std::{io, sync::LazyLock}; |
| 8 | 12 | ||
| 13 | const BODY: &str = "Body 🦋 é"; | ||
| 14 | |||
| 9 | #[path = "../../../onestore/tests/support/current.rs"] | 15 | #[path = "../../../onestore/tests/support/current.rs"] |
| 10 | mod current; | 16 | mod current; |
| 11 | 17 | ||
| ... | @@ -41,6 +47,73 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { | ... | @@ -41,6 +47,73 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { |
| 41 | .collect() | 47 | .collect() |
| 42 | } | 48 | } |
| 43 | 49 | ||
| 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. | ||
| 52 | pub 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. | ||
| 100 | fn 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 | |||
| 110 | fn 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 | |||
| 44 | struct Session<'a> { | 117 | struct Session<'a> { |
| 45 | disk: &'a mut disk::Disk, | 118 | disk: &'a mut disk::Disk, |
| 46 | operation: Option<&'a Operation>, | 119 | operation: Option<&'a Operation>, |
| ... | @@ -68,7 +141,8 @@ impl Remote for Session<'_> { | ... | @@ -68,7 +141,8 @@ impl Remote for Session<'_> { |
| 68 | } | 141 | } |
| 69 | } | 142 | } |
| 70 | } | 143 | } |
| 71 | Operation::Insert(insertion) => { | 144 | Operation::Page(intent) => { |
| 145 | let body = body_text(&intent.after); | ||
| 72 | let store = Store::parse(edit.as_bytes()).unwrap(); | 146 | let store = Store::parse(edit.as_bytes()).unwrap(); |
| 73 | let index = RevisionIndex::parse(&store).unwrap(); | 147 | let index = RevisionIndex::parse(&store).unwrap(); |
| 74 | let document = Document::parse(&index).unwrap(); | 148 | let document = Document::parse(&index).unwrap(); |
| ... | @@ -77,13 +151,11 @@ impl Remote for Session<'_> { | ... | @@ -77,13 +151,11 @@ impl Remote for Session<'_> { |
| 77 | .values() | 151 | .values() |
| 78 | .filter_map(|space| { | 152 | .filter_map(|space| { |
| 79 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 153 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 80 | view.nodes.get(&insertion.text_object()) | 154 | view.nodes.get(&body) |
| 81 | }) | 155 | }) |
| 82 | .collect(); | 156 | .collect(); |
| 83 | assert_eq!(matching.len(), 1); | 157 | assert_eq!(matching.len(), 1); |
| 84 | assert!( | 158 | assert!(matches!(&matching[0].kind, Kind::RichText { text, .. } if text == BODY)); |
| 85 | matches!(&matching[0].kind, Kind::RichText { text, .. } if text == "Body 🦋 é") | ||
| 86 | ); | ||
| 87 | } | 159 | } |
| 88 | _ => panic!(), | 160 | _ => panic!(), |
| 89 | } | 161 | } |
| ... | @@ -129,11 +201,11 @@ pub fn run(input: &[u8]) { | ... | @@ -129,11 +201,11 @@ pub fn run(input: &[u8]) { |
| 129 | cache.create_page(&snapshot, &page).unwrap().unwrap(); | 201 | cache.create_page(&snapshot, &page).unwrap().unwrap(); |
| 130 | owned[actor].push((page.space(), page.object())); | 202 | owned[actor].push((page.space(), page.object())); |
| 131 | if step[1] % 8 == 1 { | 203 | if step[1] % 8 == 1 { |
| 132 | let insertion = | 204 | let source = cache.snapshot().unwrap(); |
| 133 | Insertion::outline(page.object(), 36.0, 36.0, "Body 🦋 é", "Author") | 205 | let mut model = page_of(&source, page.space()); |
| 134 | .unwrap(); | 206 | body_outline(&mut model, BODY); |
| 135 | cache | 207 | cache |
| 136 | .insert(&cache.snapshot().unwrap(), page.space(), &insertion) | 208 | .save(&source, page.space(), &model, "Author") |
| 137 | .unwrap() | 209 | .unwrap() |
| 138 | .unwrap(); | 210 | .unwrap(); |
| 139 | } | 211 | } |
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 | |||
| 4 | use notebook::Remote; | ||
| 5 | use onestore::{ | ||
| 6 | CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 7 | document::{Document, Kind}, | ||
| 8 | }; | ||
| 9 | use std::io; | ||
| 10 | |||
| 11 | #[derive(Clone, Copy, Default)] | ||
| 12 | pub enum Fault { | ||
| 13 | #[default] | ||
| 14 | None, | ||
| 15 | Before, | ||
| 16 | UnknownBefore, | ||
| 17 | UnknownAfter, | ||
| 18 | Committed, | ||
| 19 | PanicBefore, | ||
| 20 | PanicAfter, | ||
| 21 | Confirm, | ||
| 22 | ConfirmCommitted, | ||
| 23 | } | ||
| 24 | |||
| 25 | pub 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 | |||
| 33 | impl 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 | |||
| 45 | pub fn failure(state: CommitState) -> CommitError { | ||
| 46 | CommitError { | ||
| 47 | state, | ||
| 48 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | impl 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 | |||
| 74 | impl 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 | |||
| 114 | pub 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 @@ | ||
| 1 | use crate::disk; | ||
| 2 | use notebook::{EditStatus, Operation, Remote, Replica}; | ||
| 3 | use onestore::{ | ||
| 4 | CommitError, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TreeEdit, | ||
| 5 | document::{Document, Kind}, | ||
| 6 | }; | ||
| 7 | use std::{io, sync::LazyLock}; | ||
| 8 | |||
| 9 | static 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 | |||
| 34 | fn 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 | |||
| 68 | struct Session<'a> { | ||
| 69 | disk: &'a mut disk::Disk, | ||
| 70 | operation: Option<&'a Operation>, | ||
| 71 | } | ||
| 72 | |||
| 73 | impl 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 | |||
| 138 | pub 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 @@ | ... | @@ -1,1142 +1,44 @@ |
| 1 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; | 1 | //! Replica protocol: durable receipts, retired attempts, conflict review and contention. |
| 2 | use onestore::{ | ||
| 3 | CommitError, CommitIo, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 4 | document::{Document, Kind}, | ||
| 5 | }; | ||
| 6 | use std::io; | ||
| 7 | |||
| 8 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 9 | mod disk; | ||
| 10 | #[path = "sync/model.rs"] | ||
| 11 | mod model; | ||
| 12 | #[path = "sync/outline.rs"] | ||
| 13 | mod outline; | ||
| 14 | #[path = "sync/page.rs"] | ||
| 15 | mod page; | ||
| 16 | #[path = "support/page_schedule.rs"] | ||
| 17 | mod page_schedule; | ||
| 18 | #[path = "sync/pages.rs"] | ||
| 19 | mod pages; | ||
| 20 | #[path = "sync/tree.rs"] | ||
| 21 | mod tree; | ||
| 22 | #[path = "support/tree_schedule.rs"] | ||
| 23 | mod tree_schedule; | ||
| 24 | |||
| 25 | mod 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)] | ||
| 969 | enum Fault { | ||
| 970 | #[default] | ||
| 971 | None, | ||
| 972 | Before, | ||
| 973 | UnknownBefore, | ||
| 974 | UnknownAfter, | ||
| 975 | Committed, | ||
| 976 | PanicBefore, | ||
| 977 | PanicAfter, | ||
| 978 | Confirm, | ||
| 979 | ConfirmCommitted, | ||
| 980 | } | ||
| 981 | |||
| 982 | struct Server { | ||
| 983 | visible: Vec<u8>, | ||
| 984 | durable: Vec<u8>, | ||
| 985 | fault: Fault, | ||
| 986 | publications: usize, | ||
| 987 | confirmations: usize, | ||
| 988 | } | ||
| 989 | |||
| 990 | impl 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 | |||
| 1002 | fn failure(state: CommitState) -> CommitError { | ||
| 1003 | CommitError { | ||
| 1004 | state, | ||
| 1005 | error: io::Error::from(io::ErrorKind::ConnectionAborted), | ||
| 1006 | } | ||
| 1007 | } | ||
| 1008 | |||
| 1009 | impl 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 | |||
| 1031 | impl 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 | } | ||
| 1070 | 2 | ||
| 1071 | fn text(source: &[u8]) -> (ExGuid, ExGuid, String) { | 3 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; |
| 1072 | let store = Store::parse(source).unwrap(); | 4 | use onestore::{CommitError, CommitState, ExGuid, PreparedEdit, page::Page}; |
| 1073 | assert!(store.checksum_mismatches.is_empty()); | 5 | use std::{io, ops::Range}; |
| 1074 | let index = RevisionIndex::parse(&store).unwrap(); | 6 | |
| 1075 | index.validate_current().unwrap(); | 7 | #[path = "support/server.rs"] |
| 1076 | let doc = Document::parse(&index).unwrap(); | 8 | mod server; |
| 1077 | doc.spaces | 9 | use server::*; |
| 1078 | .iter() | 10 | #[path = "support/model_ops.rs"] |
| 1079 | .find_map(|(sid, space)| { | 11 | mod model_ops; |
| 1080 | space.revisions[&space.contexts[&ExGuid::default()]] | 12 | |
| 1081 | .nodes | 13 | /// The page holding `text`, with a UTF-16 range of that text replaced. |
| 1082 | .iter() | 14 | fn edited(bytes: &[u8], text: ExGuid, range: Range<u32>, replacement: &str) -> Page { |
| 1083 | .find_map(|(oid, node)| match &node.kind { | 15 | let (_, mut page) = model_ops::locate(bytes, text); |
| 1084 | Kind::RichText { text, .. } => Some((*sid, *oid, text.clone())), | 16 | model_ops::replace_text(&mut page, text, range, replacement); |
| 1085 | _ => None, | 17 | page |
| 1086 | }) | 18 | } |
| 1087 | }) | 19 | |
| 1088 | .unwrap() | 20 | /// Saves a replacement of `range` in the page holding `text`. |
| 21 | fn 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() | ||
| 1089 | } | 26 | } |
| 1090 | 27 | ||
| 1091 | #[test] | 28 | #[test] |
| 1092 | fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becoming_a_writer() { | 29 | fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becoming_a_writer() { |
| 1093 | use notebook::{Recovery, RecoverySummary}; | 30 | use notebook::{Recovery, RecoverySummary}; |
| 1094 | use onestore::{Insertion, TextAttribute}; | ||
| 1095 | 31 | ||
| 1096 | let directory = tempfile::tempdir().unwrap(); | 32 | let directory = tempfile::tempdir().unwrap(); |
| 1097 | let path = directory.path().join("live.sqlite"); | 33 | let path = directory.path().join("live.sqlite"); |
| 1098 | let archive_path = directory.path().join("recovery.sqlite"); | 34 | let archive_path = directory.path().join("recovery.sqlite"); |
| 1099 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | 35 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); |
| 1100 | let (space, object, _) = text(&source); | 36 | let (_, object, _) = text(&source); |
| 1101 | let cache = Replica::create(&path, &source).unwrap(); | 37 | let cache = Replica::create(&path, &source).unwrap(); |
| 1102 | let published = cache | 38 | let published = save(&cache, object, 0..0, "Published ").unwrap(); |
| 1103 | .edit_text(&source, space, object, 0..0, "Published ") | ||
| 1104 | .unwrap() | ||
| 1105 | .unwrap(); | ||
| 1106 | let mut server = Server::new(&source); | 39 | let mut server = Server::new(&source); |
| 1107 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); | 40 | let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap(); |
| 1108 | let source = cache.snapshot().unwrap(); | 41 | let attempted = save(&cache, object, 0..0, "Uncertain ").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(); | ||
| 1140 | server.fault = Fault::UnknownBefore; | 42 | server.fault = Fault::UnknownBefore; |
| 1141 | assert!(matches!( | 43 | assert!(matches!( |
| 1142 | cache.sync_once(&mut server), | 44 | cache.sync_once(&mut server), |
| ... | @@ -1145,10 +47,17 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom | ... | @@ -1145,10 +47,17 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom |
| 1145 | .. | 47 | .. |
| 1146 | })) | 48 | })) |
| 1147 | )); | 49 | )); |
| 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(); | ||
| 1148 | let working = cache.snapshot().unwrap(); | 57 | let working = cache.snapshot().unwrap(); |
| 1149 | let remote = cache.remote_snapshot().unwrap(); | 58 | let remote = cache.remote_snapshot().unwrap(); |
| 1150 | let pending = cache.pending().unwrap(); | 59 | let pending = cache.pending().unwrap(); |
| 1151 | let uncertain = cache.status(inserted).unwrap(); | 60 | let uncertain = cache.status(attempted).unwrap(); |
| 1152 | let source_file = std::fs::read(&path).unwrap(); | 61 | let source_file = std::fs::read(&path).unwrap(); |
| 1153 | let summary = RecoverySummary { | 62 | let summary = RecoverySummary { |
| 1154 | queued_edits: 4, | 63 | queued_edits: 4, |
| ... | @@ -1173,7 +82,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom | ... | @@ -1173,7 +82,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom |
| 1173 | assert_eq!(archive.remote_snapshot().unwrap(), remote); | 82 | assert_eq!(archive.remote_snapshot().unwrap(), remote); |
| 1174 | assert_eq!(archive.pending().unwrap(), pending); | 83 | assert_eq!(archive.pending().unwrap(), pending); |
| 1175 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); | 84 | assert_eq!(archive.status(published).unwrap(), Some(receipt)); |
| 1176 | assert_eq!(archive.status(inserted).unwrap(), uncertain); | 85 | assert_eq!(archive.status(attempted).unwrap(), uncertain); |
| 1177 | for edit in &pending { | 86 | for edit in &pending { |
| 1178 | assert_eq!( | 87 | assert_eq!( |
| 1179 | archive.status(edit.id).unwrap(), | 88 | archive.status(edit.id).unwrap(), |
| ... | @@ -1195,9 +104,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom | ... | @@ -1195,9 +104,7 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom |
| 1195 | } | 104 | } |
| 1196 | assert_eq!(std::fs::read(&path).unwrap(), source_file); | 105 | assert_eq!(std::fs::read(&path).unwrap(), source_file); |
| 1197 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); | 106 | assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes); |
| 1198 | cache | 107 | save(&cache, object, 0..0, "Later ").unwrap(); |
| 1199 | .edit_text(&working, space, object, 0..0, "Later ") | ||
| 1200 | .unwrap(); | ||
| 1201 | assert_eq!(archive.snapshot().unwrap(), working); | 108 | assert_eq!(archive.snapshot().unwrap(), working); |
| 1202 | assert_eq!(archive.pending().unwrap(), pending); | 109 | assert_eq!(archive.pending().unwrap(), pending); |
| 1203 | drop(archive); | 110 | drop(archive); |
| ... | @@ -1238,16 +145,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv | ... | @@ -1238,16 +145,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv |
| 1238 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); | 145 | let source = onestore::create_section("recovery.one", "Original", "Fixture").unwrap(); |
| 1239 | let (space, object, _) = text(&source); | 146 | let (space, object, _) = text(&source); |
| 1240 | let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); | 147 | let cache = Replica::create(directory.path().join("live.sqlite"), &source).unwrap(); |
| 1241 | let id = cache | 148 | let id = save(&cache, object, 0..8, "Local").unwrap(); |
| 1242 | .edit_text(&source, space, object, 0..8, "Local") | ||
| 1243 | .unwrap() | ||
| 1244 | .unwrap(); | ||
| 1245 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); | 149 | let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap(); |
| 1246 | let mut server = Server::new(&changed); | 150 | let mut server = Server::new(&changed); |
| 1247 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | 151 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); |
| 1248 | assert_eq!( | 152 | assert_eq!( |
| 1249 | outcome, | 153 | outcome, |
| 1250 | (id, EditStatus::Conflict(ConflictKind::TextChanged)) | 154 | (id, EditStatus::Conflict(ConflictKind::ContentChanged)) |
| 1251 | ); | 155 | ); |
| 1252 | let path = directory.path().join("conflict.sqlite"); | 156 | let path = directory.path().join("conflict.sqlite"); |
| 1253 | cache.export_recovery(&path).unwrap(); | 157 | cache.export_recovery(&path).unwrap(); |
| ... | @@ -1274,16 +178,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv | ... | @@ -1274,16 +178,13 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv |
| 1274 | } | 178 | } |
| 1275 | 179 | ||
| 1276 | #[test] | 180 | #[test] |
| 1277 | fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { | 181 | fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() { |
| 1278 | let dir = tempfile::tempdir().unwrap(); | 182 | let dir = tempfile::tempdir().unwrap(); |
| 1279 | let path = dir.path().join("cache.sqlite"); | 183 | let path = dir.path().join("cache.sqlite"); |
| 1280 | let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap(); | 184 | let source = onestore::create_section("sync.one", "ab🦀cd", "Fixture").unwrap(); |
| 1281 | let (sid, oid, _) = text(&source); | 185 | let (sid, oid, _) = text(&source); |
| 1282 | let cache = Replica::create(&path, &source).unwrap(); | 186 | let cache = Replica::create(&path, &source).unwrap(); |
| 1283 | let id = cache | 187 | let id = save(&cache, oid, 2..4, "🐈").unwrap(); |
| 1284 | .edit_text(&source, sid, oid, 2..4, "🐈") | ||
| 1285 | .unwrap() | ||
| 1286 | .unwrap(); | ||
| 1287 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); | 188 | let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap(); |
| 1288 | let mut server = Server::new(&remote); | 189 | let mut server = Server::new(&remote); |
| 1289 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); | 190 | let outcome = cache.sync_once(&mut server).unwrap().unwrap(); |
| ... | @@ -1298,11 +199,7 @@ fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { | ... | @@ -1298,11 +199,7 @@ fn rebases_multiple_disjoint_remote_changes_and_persists_the_remote_receipt() { |
| 1298 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); | 199 | assert_eq!(cache.sync_once(&mut server).unwrap(), None); |
| 1299 | assert_eq!(server.publications, 1); | 200 | assert_eq!(server.publications, 1); |
| 1300 | assert_eq!(cache.status(id + 1).unwrap(), None); | 201 | assert_eq!(cache.status(id + 1).unwrap(), None); |
| 1301 | let source = cache.snapshot().unwrap(); | 202 | let next = save(&cache, oid, 0..0, "Later ").unwrap(); |
| 1302 | let next = cache | ||
| 1303 | .edit_text(&source, sid, oid, 0..0, "Later ") | ||
| 1304 | .unwrap() | ||
| 1305 | .unwrap(); | ||
| 1306 | assert!(next > id); | 203 | assert!(next > id); |
| 1307 | } | 204 | } |
| 1308 | 205 | ||
| ... | @@ -1313,16 +210,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { | ... | @@ -1313,16 +210,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { |
| 1313 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 210 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1314 | let (sid, oid, _) = text(&source); | 211 | let (sid, oid, _) = text(&source); |
| 1315 | let cache = Replica::create(&path, &source).unwrap(); | 212 | let cache = Replica::create(&path, &source).unwrap(); |
| 1316 | let id = cache | 213 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 1317 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1318 | .unwrap() | ||
| 1319 | .unwrap(); | ||
| 1320 | let local = cache.snapshot().unwrap(); | 214 | let local = cache.snapshot().unwrap(); |
| 1321 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | 215 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); |
| 1322 | let mut server = Server::new(&remote); | 216 | let mut server = Server::new(&remote); |
| 1323 | assert_eq!( | 217 | assert_eq!( |
| 1324 | cache.sync_once(&mut server).unwrap(), | 218 | cache.sync_once(&mut server).unwrap(), |
| 1325 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | 219 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 1326 | ); | 220 | ); |
| 1327 | assert_eq!(server.publications, 0); | 221 | assert_eq!(server.publications, 0); |
| 1328 | drop(cache); | 222 | drop(cache); |
| ... | @@ -1332,7 +226,7 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { | ... | @@ -1332,7 +226,7 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() { |
| 1332 | assert_eq!(cache.pending().unwrap().len(), 1); | 226 | assert_eq!(cache.pending().unwrap().len(), 1); |
| 1333 | assert_eq!( | 227 | assert_eq!( |
| 1334 | cache.status(id).unwrap(), | 228 | cache.status(id).unwrap(), |
| 1335 | Some(EditStatus::Conflict(ConflictKind::TextChanged)) | 229 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) |
| 1336 | ); | 230 | ); |
| 1337 | } | 231 | } |
| 1338 | 232 | ||
| ... | @@ -1347,12 +241,9 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { | ... | @@ -1347,12 +241,9 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() { |
| 1347 | let dir = tempfile::tempdir().unwrap(); | 241 | let dir = tempfile::tempdir().unwrap(); |
| 1348 | let path = dir.path().join("cache.sqlite"); | 242 | let path = dir.path().join("cache.sqlite"); |
| 1349 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 243 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1350 | let (sid, oid, _) = text(&source); | 244 | let (_, oid, _) = text(&source); |
| 1351 | let cache = Replica::create(&path, &source).unwrap(); | 245 | let cache = Replica::create(&path, &source).unwrap(); |
| 1352 | let id = cache | 246 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 1353 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1354 | .unwrap() | ||
| 1355 | .unwrap(); | ||
| 1356 | let mut server = Server::new(&source); | 247 | let mut server = Server::new(&source); |
| 1357 | server.fault = fault; | 248 | server.fault = fault; |
| 1358 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { | 249 | let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| ... | @@ -1383,12 +274,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { | ... | @@ -1383,12 +274,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { |
| 1383 | let dir = tempfile::tempdir().unwrap(); | 274 | let dir = tempfile::tempdir().unwrap(); |
| 1384 | let path = dir.path().join("cache.sqlite"); | 275 | let path = dir.path().join("cache.sqlite"); |
| 1385 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 276 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1386 | let (sid, oid, _) = text(&source); | 277 | let (_, oid, _) = text(&source); |
| 1387 | let cache = Replica::create(&path, &source).unwrap(); | 278 | let cache = Replica::create(&path, &source).unwrap(); |
| 1388 | let id = cache | 279 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 1389 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1390 | .unwrap() | ||
| 1391 | .unwrap(); | ||
| 1392 | let mut server = Server::new(&source); | 280 | let mut server = Server::new(&source); |
| 1393 | server.fault = Fault::UnknownAfter; | 281 | server.fault = Fault::UnknownAfter; |
| 1394 | assert!(cache.sync_once(&mut server).is_err()); | 282 | assert!(cache.sync_once(&mut server).is_err()); |
| ... | @@ -1412,12 +300,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { | ... | @@ -1412,12 +300,9 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() { |
| 1412 | fn confirmation_cleanup_failure_still_records_a_durable_receipt() { | 300 | fn confirmation_cleanup_failure_still_records_a_durable_receipt() { |
| 1413 | let dir = tempfile::tempdir().unwrap(); | 301 | let dir = tempfile::tempdir().unwrap(); |
| 1414 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 302 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1415 | let (sid, oid, _) = text(&source); | 303 | let (_, oid, _) = text(&source); |
| 1416 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 304 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 1417 | let id = cache | 305 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 1418 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1419 | .unwrap() | ||
| 1420 | .unwrap(); | ||
| 1421 | let mut server = Server::new(&source); | 306 | let mut server = Server::new(&source); |
| 1422 | server.fault = Fault::UnknownAfter; | 307 | server.fault = Fault::UnknownAfter; |
| 1423 | assert!(cache.sync_once(&mut server).is_err()); | 308 | assert!(cache.sync_once(&mut server).is_err()); |
| ... | @@ -1441,12 +326,9 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( | ... | @@ -1441,12 +326,9 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts( |
| 1441 | let dir = tempfile::tempdir().unwrap(); | 326 | let dir = tempfile::tempdir().unwrap(); |
| 1442 | let path = dir.path().join("cache.sqlite"); | 327 | let path = dir.path().join("cache.sqlite"); |
| 1443 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 328 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1444 | let (sid, oid, _) = text(&source); | 329 | let (_, oid, _) = text(&source); |
| 1445 | let cache = Replica::create(&path, &source).unwrap(); | 330 | let cache = Replica::create(&path, &source).unwrap(); |
| 1446 | let id = cache | 331 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 1447 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1448 | .unwrap() | ||
| 1449 | .unwrap(); | ||
| 1450 | let mut server = Server::new(&source); | 332 | let mut server = Server::new(&source); |
| 1451 | server.fault = fault; | 333 | server.fault = fault; |
| 1452 | assert!(matches!( | 334 | assert!(matches!( |
| ... | @@ -1478,12 +360,9 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { | ... | @@ -1478,12 +360,9 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() { |
| 1478 | let dir = tempfile::tempdir().unwrap(); | 360 | let dir = tempfile::tempdir().unwrap(); |
| 1479 | let path = dir.path().join("cache.sqlite"); | 361 | let path = dir.path().join("cache.sqlite"); |
| 1480 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 362 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1481 | let (sid, oid, _) = text(&source); | 363 | let (_, oid, _) = text(&source); |
| 1482 | let cache = Replica::create(&path, &source).unwrap(); | 364 | let cache = Replica::create(&path, &source).unwrap(); |
| 1483 | let id = cache | 365 | let id = save(&cache, oid, 0..0, "Once ").unwrap(); |
| 1484 | .edit_text(&source, sid, oid, 0..0, "Once ") | ||
| 1485 | .unwrap() | ||
| 1486 | .unwrap(); | ||
| 1487 | drop(cache); | 366 | drop(cache); |
| 1488 | let db = rusqlite::Connection::open(&path).unwrap(); | 367 | let db = rusqlite::Connection::open(&path).unwrap(); |
| 1489 | db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap(); | 368 | 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 | ... | @@ -1550,12 +429,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1550 | for phase in ["read", "publish", "confirm"] { | 429 | for phase in ["read", "publish", "confirm"] { |
| 1551 | let dir = tempfile::tempdir().unwrap(); | 430 | let dir = tempfile::tempdir().unwrap(); |
| 1552 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 431 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1553 | let (sid, oid, _) = text(&source); | 432 | let (_, oid, _) = text(&source); |
| 1554 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 433 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 1555 | let first = cache | 434 | let first = save(&cache, oid, 0..0, "First ").unwrap(); |
| 1556 | .edit_text(&source, sid, oid, 0..0, "First ") | ||
| 1557 | .unwrap() | ||
| 1558 | .unwrap(); | ||
| 1559 | let mut server = Server::new(&source); | 435 | let mut server = Server::new(&source); |
| 1560 | if phase == "confirm" { | 436 | if phase == "confirm" { |
| 1561 | server.fault = Fault::UnknownAfter; | 437 | server.fault = Fault::UnknownAfter; |
| ... | @@ -1583,8 +459,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -1583,8 +459,9 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1583 | assert!( | 459 | assert!( |
| 1584 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) | 460 | matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock) |
| 1585 | ); | 461 | ); |
| 462 | let reviewed = edited(&source, oid, 0..0, "Reviewed "); | ||
| 1586 | assert!( | 463 | 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) |
| 1588 | ); | 465 | ); |
| 1589 | let started = std::time::Instant::now(); | 466 | let started = std::time::Instant::now(); |
| 1590 | let handles: Vec<_> = (0..12) | 467 | let handles: Vec<_> = (0..12) |
| ... | @@ -1592,14 +469,10 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -1592,14 +469,10 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1592 | let cache = &cache; | 469 | let cache = &cache; |
| 1593 | scope.spawn(move || { | 470 | scope.spawn(move || { |
| 1594 | loop { | 471 | loop { |
| 1595 | let snapshot = cache.snapshot().unwrap(); | 472 | let marker = format!("[{writer}] "); |
| 1596 | match cache.edit_text( | 473 | match model_ops::save(cache, oid, |page| { |
| 1597 | &snapshot, | 474 | model_ops::replace_text(page, oid, 0..0, &marker) |
| 1598 | sid, | 475 | }) { |
| 1599 | oid, | ||
| 1600 | 0..0, | ||
| 1601 | &format!("[{writer}] "), | ||
| 1602 | ) { | ||
| 1603 | Ok(Some(id)) => break id, | 476 | Ok(Some(id)) => break id, |
| 1604 | Err(Error::Io(error)) | 477 | Err(Error::Io(error)) |
| 1605 | if error.kind() == io::ErrorKind::ResourceBusy => {} | 478 | if error.kind() == io::ErrorKind::ResourceBusy => {} |
| ... | @@ -1613,7 +486,12 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -1613,7 +486,12 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1613 | .into_iter() | 486 | .into_iter() |
| 1614 | .map(|handle| handle.join().unwrap()) | 487 | .map(|handle| handle.join().unwrap()) |
| 1615 | .collect(); | 488 | .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 | ); | ||
| 1617 | assert!( | 495 | assert!( |
| 1618 | started.elapsed() < std::time::Duration::from_secs(2), | 496 | started.elapsed() < std::time::Duration::from_secs(2), |
| 1619 | "Local edits waited for remote {phase}" | 497 | "Local edits waited for remote {phase}" |
| ... | @@ -1621,16 +499,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -1621,16 +499,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1621 | resume_tx.send(()).unwrap(); | 499 | resume_tx.send(()).unwrap(); |
| 1622 | running.join().unwrap() | 500 | running.join().unwrap() |
| 1623 | }); | 501 | }); |
| 1624 | assert_eq!(cache.pending().unwrap().len(), 12); | 502 | assert_eq!(cache.pending().unwrap().len(), usize::from(phase != "read")); |
| 1625 | let expected = text(&cache.snapshot().unwrap()).2; | 503 | 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() { | ||
| 1627 | assert!(matches!( | 508 | assert!(matches!( |
| 1628 | cache.sync_once(&mut server).unwrap(), | 509 | cache.sync_once(&mut server).unwrap(), |
| 1629 | Some((_, EditStatus::Published { .. })) | 510 | Some((_, EditStatus::Published { .. })) |
| 1630 | )); | 511 | )); |
| 1631 | } | 512 | } |
| 1632 | assert!(cache.pending().unwrap().is_empty()); | 513 | assert_eq!(server.publications, if phase == "read" { 1 } else { 2 }); |
| 1633 | assert_eq!(server.publications, 13); | ||
| 1634 | assert_eq!(text(&server.durable).2, expected); | 514 | assert_eq!(text(&server.durable).2, expected); |
| 1635 | assert_eq!(cache.snapshot().unwrap(), server.durable); | 515 | assert_eq!(cache.snapshot().unwrap(), server.durable); |
| 1636 | } | 516 | } |
| ... | @@ -1640,13 +520,13 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio | ... | @@ -1640,13 +520,13 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio |
| 1640 | fn unrelated_remote_files_never_replace_a_local_cache() { | 520 | fn unrelated_remote_files_never_replace_a_local_cache() { |
| 1641 | let dir = tempfile::tempdir().unwrap(); | 521 | let dir = tempfile::tempdir().unwrap(); |
| 1642 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); | 522 | let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap(); |
| 1643 | let (sid, oid, _) = text(&source); | 523 | let (_, oid, _) = text(&source); |
| 1644 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 524 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 1645 | let other = onestore::create_section("other.one", "abc", "Fixture").unwrap(); | 525 | let other = onestore::create_section("other.one", "abc", "Fixture").unwrap(); |
| 1646 | let mut server = Server::new(&other); | 526 | let mut server = Server::new(&other); |
| 1647 | for pending in [false, true] { | 527 | for pending in [false, true] { |
| 1648 | if pending { | 528 | if pending { |
| 1649 | cache.edit_text(&source, sid, oid, 0..0, "Local ").unwrap(); | 529 | save(&cache, oid, 0..0, "Local ").unwrap(); |
| 1650 | } | 530 | } |
| 1651 | let before = cache.snapshot().unwrap(); | 531 | let before = cache.snapshot().unwrap(); |
| 1652 | assert!( | 532 | assert!( |
| ... | @@ -1659,126 +539,72 @@ fn unrelated_remote_files_never_replace_a_local_cache() { | ... | @@ -1659,126 +539,72 @@ fn unrelated_remote_files_never_replace_a_local_cache() { |
| 1659 | } | 539 | } |
| 1660 | 540 | ||
| 1661 | #[test] | 541 | #[test] |
| 1662 | fn version_one_cache_migration_preserves_images_intents_and_local_ids() { | 542 | fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() { |
| 1663 | let dir = tempfile::tempdir().unwrap(); | 543 | let dir = tempfile::tempdir().unwrap(); |
| 1664 | let path = dir.path().join("cache.sqlite"); | 544 | 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(); |
| 1666 | let (sid, oid, _) = text(&source); | 546 | let (sid, oid, _) = text(&source); |
| 1667 | let cache = Replica::create(&path, &source).unwrap(); | 547 | let cache = Replica::create(&path, &source).unwrap(); |
| 1668 | let id = cache | 548 | let first = save(&cache, oid, 1..2, "L").unwrap(); |
| 1669 | .edit_text(&source, sid, oid, 0..0, "Local ") | 549 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap(); |
| 1670 | .unwrap() | 550 | let mut server = Server::new(&remote); |
| 1671 | .unwrap(); | 551 | assert_eq!( |
| 1672 | let snapshot = cache.snapshot().unwrap(); | 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(); | ||
| 1673 | let pending = cache.pending().unwrap(); | 562 | let pending = cache.pending().unwrap(); |
| 1674 | drop(cache); | 563 | assert_eq!(pending.len(), 2); |
| 1675 | let db = rusqlite::Connection::open(&path).unwrap(); | 564 | model_ops::review(&cache, first, oid, |page| { |
| 1676 | let current_version: u32 = db | 565 | model_ops::replace_text(page, oid, 1..2, "L") |
| 1677 | .pragma_query_value(None, "user_version", |row| row.get(0)) | 566 | }) |
| 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 | ) | ||
| 1688 | .unwrap(); | 567 | .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(); | 568 | assert_eq!(cache.snapshot().unwrap(), local); |
| 1690 | drop(db); | 569 | assert_eq!(cache.remote_snapshot().unwrap(), remote); |
| 1691 | let cache = Replica::open(&path).unwrap(); | 570 | let reviewed = cache.pending().unwrap(); |
| 1692 | assert_eq!(cache.snapshot().unwrap(), snapshot); | 571 | assert_eq!(reviewed[1..], pending[1..]); |
| 1693 | assert_eq!(cache.pending().unwrap(), pending); | 572 | assert_eq!(reviewed[0].id, first); |
| 1694 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | 573 | let notebook::Operation::Page(intent) = &reviewed[0].operation else { |
| 1695 | let mut server = Server::new(&source); | 574 | panic!() |
| 1696 | assert!(matches!( | 575 | }; |
| 1697 | cache.sync_once(&mut server).unwrap(), | ||
| 1698 | Some((_, EditStatus::Published { .. })) | ||
| 1699 | )); | ||
| 1700 | drop(cache); | ||
| 1701 | let db = rusqlite::Connection::open(&path).unwrap(); | ||
| 1702 | assert_eq!( | 576 | assert_eq!( |
| 1703 | db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) | 577 | intent.text_change(), |
| 1704 | .unwrap(), | 578 | Some((oid, "aRcZ".into(), 1..2, "L".into())) |
| 1705 | current_version | ||
| 1706 | ); | 579 | ); |
| 1707 | } | 580 | assert_eq!(intent.author, model_ops::AUTHOR); |
| 1708 | |||
| 1709 | #[test] | ||
| 1710 | fn 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"); | ||
| 1753 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); | 581 | assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending)); |
| 1754 | drop(cache); | 582 | drop(cache); |
| 1755 | let cache = Replica::open(&path).unwrap(); | 583 | let cache = Replica::open(&path).unwrap(); |
| 1756 | assert_eq!(cache.pending().unwrap(), rebased); | 584 | assert_eq!(cache.pending().unwrap(), reviewed); |
| 1757 | assert_eq!(cache.snapshot().unwrap(), local); | 585 | assert_eq!(cache.snapshot().unwrap(), local); |
| 1758 | for intent in &pending { | 586 | for intent in &pending { |
| 1759 | assert!( | 587 | assert!( |
| 1760 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) | 588 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id) |
| 1761 | ); | 589 | ); |
| 1762 | } | 590 | } |
| 1763 | assert_eq!(server.publications, 13); | 591 | assert_eq!(server.publications, 2); |
| 1764 | assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); | 592 | assert_eq!(text(&server.durable).2, text(&local).2 + "Z"); |
| 1765 | assert_eq!(cache.snapshot().unwrap(), server.durable); | 593 | assert_eq!(cache.snapshot().unwrap(), server.durable); |
| 1766 | assert!(cache.pending().unwrap().is_empty()); | 594 | assert!(cache.pending().unwrap().is_empty()); |
| 1767 | } | 595 | } |
| 1768 | 596 | ||
| 1769 | #[test] | 597 | #[test] |
| 1770 | fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states() { | 598 | fn conflict_review_rejects_stale_images_and_nonconflicting_states() { |
| 1771 | let dir = tempfile::tempdir().unwrap(); | 599 | let dir = tempfile::tempdir().unwrap(); |
| 1772 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 600 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 1773 | let (sid, oid, _) = text(&source); | 601 | let (sid, oid, _) = text(&source); |
| 1774 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 602 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 1775 | let id = cache | 603 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 1776 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1777 | .unwrap() | ||
| 1778 | .unwrap(); | ||
| 1779 | let local = cache.snapshot().unwrap(); | 604 | let local = cache.snapshot().unwrap(); |
| 605 | let pending_review = edited(&source, oid, 1..2, "L"); | ||
| 1780 | assert!( | 606 | 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) |
| 1782 | ); | 608 | ); |
| 1783 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); | 609 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap(); |
| 1784 | let mut server = Server::new(&remote); | 610 | let mut server = Server::new(&remote); |
| ... | @@ -1787,20 +613,15 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states | ... | @@ -1787,20 +613,15 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states |
| 1787 | Some((_, EditStatus::Conflict(_))) | 613 | Some((_, EditStatus::Conflict(_))) |
| 1788 | )); | 614 | )); |
| 1789 | let pending = cache.pending().unwrap(); | 615 | let pending = cache.pending().unwrap(); |
| 1790 | for range in [1..2, 100..101] { | 616 | let reviewed = edited(&remote, oid, 2..3, "L"); |
| 1791 | assert!(matches!( | ||
| 1792 | cache.rebase_conflict(id, &local, &remote, range), | ||
| 1793 | Err(Error::Document(_)) | ||
| 1794 | )); | ||
| 1795 | assert_eq!(cache.pending().unwrap(), pending); | ||
| 1796 | } | ||
| 1797 | assert!( | 617 | 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) |
| 1799 | ); | 619 | ); |
| 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(); | ||
| 1801 | let changed = cache.snapshot().unwrap(); | 622 | let changed = cache.snapshot().unwrap(); |
| 1802 | assert!( | 623 | 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) |
| 1804 | ); | 625 | ); |
| 1805 | let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); | 626 | let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap(); |
| 1806 | server.visible = new_remote.clone(); | 627 | server.visible = new_remote.clone(); |
| ... | @@ -1810,14 +631,17 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states | ... | @@ -1810,14 +631,17 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states |
| 1810 | Some((_, EditStatus::Conflict(_))) | 631 | Some((_, EditStatus::Conflict(_))) |
| 1811 | )); | 632 | )); |
| 1812 | assert!( | 633 | 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) |
| 1814 | ); | 635 | ); |
| 1815 | assert_eq!(cache.snapshot().unwrap(), changed); | 636 | assert_eq!(cache.snapshot().unwrap(), changed); |
| 1816 | assert_eq!(cache.pending().unwrap()[0], pending[0]); | 637 | assert_eq!(cache.pending().unwrap()[0], pending[0]); |
| 1817 | assert_eq!(server.publications, 0); | 638 | assert_eq!(server.publications, 0); |
| 1818 | 639 | ||
| 1819 | let current = cache.remote_snapshot().unwrap(); | 640 | 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(); | ||
| 1821 | server.fault = Fault::UnknownBefore; | 645 | server.fault = Fault::UnknownBefore; |
| 1822 | assert!( | 646 | assert!( |
| 1823 | matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown) | 647 | 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 | ... | @@ -1825,7 +649,7 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states |
| 1825 | let attempted = cache.status(id).unwrap(); | 649 | let attempted = cache.status(id).unwrap(); |
| 1826 | let pending = cache.pending().unwrap(); | 650 | let pending = cache.pending().unwrap(); |
| 1827 | assert!( | 651 | 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) |
| 1829 | ); | 653 | ); |
| 1830 | assert_eq!(cache.status(id).unwrap(), attempted); | 654 | assert_eq!(cache.status(id).unwrap(), attempted); |
| 1831 | assert_eq!(cache.pending().unwrap(), pending); | 655 | assert_eq!(cache.pending().unwrap(), pending); |
| ... | @@ -1833,16 +657,13 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states | ... | @@ -1833,16 +657,13 @@ fn conflict_review_rejects_stale_images_invalid_ranges_and_nonconflicting_states |
| 1833 | } | 657 | } |
| 1834 | 658 | ||
| 1835 | #[test] | 659 | #[test] |
| 1836 | fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() { | 660 | fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() { |
| 1837 | let dir = tempfile::tempdir().unwrap(); | 661 | let dir = tempfile::tempdir().unwrap(); |
| 1838 | let path = dir.path().join("cache.sqlite"); | 662 | let path = dir.path().join("cache.sqlite"); |
| 1839 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 663 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 1840 | let (sid, oid, _) = text(&source); | 664 | let (sid, oid, _) = text(&source); |
| 1841 | let cache = Replica::create(&path, &source).unwrap(); | 665 | let cache = Replica::create(&path, &source).unwrap(); |
| 1842 | let id = cache | 666 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 1843 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1844 | .unwrap() | ||
| 1845 | .unwrap(); | ||
| 1846 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); | 667 | let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap(); |
| 1847 | let mut server = Server::new(&remote); | 668 | let mut server = Server::new(&remote); |
| 1848 | cache.sync_once(&mut server).unwrap(); | 669 | cache.sync_once(&mut server).unwrap(); |
| ... | @@ -1854,8 +675,9 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() | ... | @@ -1854,8 +675,9 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() |
| 1854 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); | 675 | connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap(); |
| 1855 | drop(connection); | 676 | drop(connection); |
| 1856 | let cache = Replica::open(&path).unwrap(); | 677 | let cache = Replica::open(&path).unwrap(); |
| 678 | let reviewed = edited(&remote, oid, 2..3, "L"); | ||
| 1857 | assert!(matches!( | 679 | assert!(matches!( |
| 1858 | cache.rebase_conflict(id, &local, &remote, 2..3), | 680 | cache.review_page(id, &local, &remote, &reviewed), |
| 1859 | Err(Error::Database(_)) | 681 | Err(Error::Database(_)) |
| 1860 | )); | 682 | )); |
| 1861 | drop(cache); | 683 | drop(cache); |
| ... | @@ -1868,7 +690,7 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() | ... | @@ -1868,7 +690,7 @@ fn failure_between_rebase_and_conflict_clear_rolls_back_the_entire_resolution() |
| 1868 | } | 690 | } |
| 1869 | 691 | ||
| 1870 | #[test] | 692 | #[test] |
| 1871 | fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replacement() { | 693 | fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() { |
| 1872 | let dir = tempfile::tempdir().unwrap(); | 694 | let dir = tempfile::tempdir().unwrap(); |
| 1873 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); | 695 | let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect(); |
| 1874 | let mut seed = 911_u64; | 696 | let mut seed = 911_u64; |
| ... | @@ -1889,20 +711,9 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace | ... | @@ -1889,20 +711,9 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace |
| 1889 | .unwrap(); | 711 | .unwrap(); |
| 1890 | let (sid, oid, _) = text(&source); | 712 | let (sid, oid, _) = text(&source); |
| 1891 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); | 713 | let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap(); |
| 1892 | let id = cache | 714 | let id = save(&cache, oid, start..end, "λ🦊μ").unwrap(); |
| 1893 | .edit_text(&source, sid, oid, start..end, "λ🦊μ") | 715 | let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap(); |
| 1894 | .unwrap() | 716 | assert_eq!(dependent, id, "case {case}, seed {seed}"); |
| 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(); | ||
| 1906 | let mut remote_text = original.clone(); | 717 | let mut remote_text = original.clone(); |
| 1907 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); | 718 | remote_text.splice(at..at + 1, "Ω🐈π".chars()); |
| 1908 | let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix; | 719 | 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 | ... | @@ -1915,28 +726,20 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace |
| 1915 | ) | 726 | ) |
| 1916 | .unwrap(); | 727 | .unwrap(); |
| 1917 | let mut server = Server::new(&remote); | 728 | let mut server = Server::new(&remote); |
| 1918 | assert!( | 729 | assert_eq!( |
| 1919 | matches!( | 730 | cache.sync_once(&mut server).unwrap(), |
| 1920 | cache.sync_once(&mut server).unwrap(), | 731 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))), |
| 1921 | Some((_, EditStatus::Conflict(_))) | ||
| 1922 | ), | ||
| 1923 | "case {case}, seed {seed}" | 732 | "case {case}, seed {seed}" |
| 1924 | ); | 733 | ); |
| 1925 | let at_remote = start + prefix.len() as u32; | 734 | let at_remote = start + prefix.len() as u32; |
| 1926 | cache | 735 | model_ops::review(&cache, id, oid, |page| { |
| 1927 | .rebase_conflict( | 736 | model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ") |
| 1928 | id, | 737 | }) |
| 1929 | &cache.snapshot().unwrap(), | 738 | .unwrap(); |
| 1930 | &remote, | 739 | assert!( |
| 1931 | at_remote..at_remote + 4, | 740 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id), |
| 1932 | ) | 741 | "case {case}, seed {seed}" |
| 1933 | .unwrap(); | 742 | ); |
| 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 | } | ||
| 1940 | let mut expected = original.clone(); | 743 | let mut expected = original.clone(); |
| 1941 | expected.splice(at..at + 1, "λ🐕μ".chars()); | 744 | expected.splice(at..at + 1, "λ🐕μ".chars()); |
| 1942 | let expected = prefix + &expected.iter().collect::<String>() + &suffix; | 745 | let expected = prefix + &expected.iter().collect::<String>() + &suffix; |
| ... | @@ -1945,13 +748,13 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace | ... | @@ -1945,13 +748,13 @@ fn seeded_reviewed_ranges_preserve_unicode_and_edits_inside_the_original_replace |
| 1945 | expected, | 748 | expected, |
| 1946 | "case {case}, seed {seed}" | 749 | "case {case}, seed {seed}" |
| 1947 | ); | 750 | ); |
| 1948 | assert_eq!(server.publications, 2); | 751 | assert_eq!(server.publications, 1); |
| 1949 | assert!(cache.pending().unwrap().is_empty()); | 752 | assert!(cache.pending().unwrap().is_empty()); |
| 1950 | } | 753 | } |
| 1951 | } | 754 | } |
| 1952 | 755 | ||
| 1953 | #[test] | 756 | #[test] |
| 1954 | fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() { | 757 | fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() { |
| 1955 | struct ChangedAfterRead { | 758 | struct ChangedAfterRead { |
| 1956 | server: Server, | 759 | server: Server, |
| 1957 | change: Option<Vec<u8>>, | 760 | change: Option<Vec<u8>>, |
| ... | @@ -1977,10 +780,7 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() | ... | @@ -1977,10 +780,7 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() |
| 1977 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); | 780 | let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap(); |
| 1978 | let (sid, oid, _) = text(&source); | 781 | let (sid, oid, _) = text(&source); |
| 1979 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); | 782 | let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap(); |
| 1980 | let id = cache | 783 | let id = save(&cache, oid, 1..2, "L").unwrap(); |
| 1981 | .edit_text(&source, sid, oid, 1..2, "L") | ||
| 1982 | .unwrap() | ||
| 1983 | .unwrap(); | ||
| 1984 | let local = cache.snapshot().unwrap(); | 784 | let local = cache.snapshot().unwrap(); |
| 1985 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); | 785 | let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap(); |
| 1986 | let mut remote = ChangedAfterRead { | 786 | let mut remote = ChangedAfterRead { |
| ... | @@ -1991,9 +791,10 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() | ... | @@ -1991,9 +791,10 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() |
| 1991 | cache.sync_once(&mut remote).unwrap(), | 791 | cache.sync_once(&mut remote).unwrap(), |
| 1992 | Some((_, EditStatus::Conflict(_))) | 792 | Some((_, EditStatus::Conflict(_))) |
| 1993 | )); | 793 | )); |
| 1994 | cache | 794 | model_ops::review(&cache, id, oid, |page| { |
| 1995 | .rebase_conflict(id, &local, &cache.remote_snapshot().unwrap(), 1..2) | 795 | model_ops::replace_text(page, oid, 1..2, "L") |
| 1996 | .unwrap(); | 796 | }) |
| 797 | .unwrap(); | ||
| 1997 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); | 798 | let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap(); |
| 1998 | if after_read { | 799 | if after_read { |
| 1999 | remote.change = Some(changed.clone()); | 800 | remote.change = Some(changed.clone()); |
| ... | @@ -2009,1925 +810,18 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() | ... | @@ -2009,1925 +810,18 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_placement() |
| 2009 | } | 810 | } |
| 2010 | assert_eq!( | 811 | assert_eq!( |
| 2011 | cache.sync_once(&mut remote).unwrap(), | 812 | cache.sync_once(&mut remote).unwrap(), |
| 2012 | Some((id, EditStatus::Conflict(ConflictKind::TextChanged))) | 813 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 2013 | ); | 814 | ); |
| 2014 | assert_eq!(remote.server.durable, changed); | 815 | assert_eq!(remote.server.durable, changed); |
| 2015 | assert_eq!(remote.server.visible, changed); | 816 | assert_eq!(remote.server.visible, changed); |
| 2016 | assert_eq!(remote.server.publications, usize::from(after_read)); | 817 | assert_eq!(remote.server.publications, usize::from(after_read)); |
| 2017 | assert_eq!(cache.snapshot().unwrap(), local); | 818 | 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 { |
| 2019 | panic!() | 820 | panic!() |
| 2020 | }; | 821 | }; |
| 2021 | assert_eq!(edit.replacement, "L"); | ||
| 2022 | } | ||
| 2023 | } | ||
| 2024 | |||
| 2025 | mod 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 | } | ||
| 2463 | assert_eq!( | 822 | assert_eq!( |
| 2464 | rx.recv_timeout(Duration::from_secs(5)), | 823 | intent.text_change(), |
| 2465 | Err(mpsc::RecvTimeoutError::Disconnected) | 824 | Some((oid, "aRc".into(), 1..2, "L".into())) |
| 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] | ||
| 2701 | fn 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) | ||
| 2749 | ); | 825 | ); |
| 2750 | } | 826 | } |
| 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] | ||
| 2779 | fn 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] | ||
| 2865 | fn 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] | ||
| 2941 | fn 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] | ||
| 2979 | fn 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] | ||
| 3017 | fn 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] | ||
| 3050 | fn 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] | ||
| 3153 | fn 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] | ||
| 3204 | fn 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] | ||
| 3244 | fn 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] | ||
| 3301 | fn 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] | ||
| 3321 | fn 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] | ||
| 3535 | fn 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] | ||
| 3594 | fn 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] | ||
| 3715 | fn 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] | ||
| 3879 | fn 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 | } | ||
| 3933 | } | 827 | } |
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 | |||
| 4 | use super::*; | ||
| 5 | use notebook::{Operation, PageIntent}; | ||
| 6 | use onestore::page::{Page, PageObject}; | ||
| 7 | |||
| 8 | const OUTLINES: &[u8] = | ||
| 9 | include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 10 | |||
| 11 | fn page_of(bytes: &[u8], space: ExGuid) -> Page { | ||
| 12 | let store = Store::parse(bytes).unwrap(); | ||
| 13 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 14 | let document = Document::parse(&index).unwrap(); | ||
| 15 | Page::from_space(&document, space).unwrap() | ||
| 16 | } | ||
| 17 | |||
| 18 | fn page_titled(bytes: &[u8], title: &str) -> (ExGuid, Page) { | ||
| 19 | let store = Store::parse(bytes).unwrap(); | ||
| 20 | let index = RevisionIndex::parse(&store).unwrap(); | ||
| 21 | let document = Document::parse(&index).unwrap(); | ||
| 22 | document | ||
| 23 | .pages() | ||
| 24 | .unwrap() | ||
| 25 | .into_iter() | ||
| 26 | .find_map(|(sid, _)| { | ||
| 27 | let page = Page::from_space(&document, sid).unwrap(); | ||
| 28 | (page.title == title).then_some((sid, page)) | ||
| 29 | }) | ||
| 30 | .unwrap() | ||
| 31 | } | ||
| 32 | |||
| 33 | /// Appends `suffix` to the first body paragraph of the first body outline. | ||
| 34 | fn append(page: &mut Page, suffix: &str) { | ||
| 35 | let outline = page | ||
| 36 | .objects | ||
| 37 | .iter_mut() | ||
| 38 | .find_map(|object| match object { | ||
| 39 | PageObject::Outline(outline) => Some(outline), | ||
| 40 | _ => None, | ||
| 41 | }) | ||
| 42 | .unwrap(); | ||
| 43 | let text = outline.paragraphs[0].text_mut().unwrap(); | ||
| 44 | let end = text.text.utf16_offset(text.text.text().len()).unwrap(); | ||
| 45 | let format = text.text.format_at(end).unwrap().clone(); | ||
| 46 | text.text | ||
| 47 | .apply(onestore::page::text::Edit { | ||
| 48 | range: end..end, | ||
| 49 | replacement: onestore::page::Paragraph::new(suffix.into(), format), | ||
| 50 | }) | ||
| 51 | .unwrap(); | ||
| 52 | } | ||
| 53 | |||
| 54 | fn first_text(page: &Page) -> String { | ||
| 55 | page.objects | ||
| 56 | .iter() | ||
| 57 | .find_map(|object| match object { | ||
| 58 | PageObject::Outline(outline) => { | ||
| 59 | Some(outline.paragraphs[0].text().unwrap().text.text().to_owned()) | ||
| 60 | } | ||
| 61 | _ => None, | ||
| 62 | }) | ||
| 63 | .unwrap() | ||
| 64 | } | ||
| 65 | |||
| 66 | #[test] | ||
| 67 | fn a_saved_page_model_publishes_and_reads_back() { | ||
| 68 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 69 | let directory = tempfile::tempdir().unwrap(); | ||
| 70 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | ||
| 71 | append(&mut after, " saved 🦀"); | ||
| 72 | let id = cache | ||
| 73 | .save(OUTLINES, space, &after, "Model author") | ||
| 74 | .unwrap() | ||
| 75 | .unwrap(); | ||
| 76 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 77 | assert_eq!(page_of(&cache.snapshot().unwrap(), space), after); | ||
| 78 | let pending = cache.pending().unwrap(); | ||
| 79 | assert_eq!(pending.len(), 1); | ||
| 80 | let Operation::Page(PageIntent { | ||
| 81 | before, | ||
| 82 | after: stored, | ||
| 83 | author, | ||
| 84 | }) = &pending[0].operation | ||
| 85 | else { | ||
| 86 | panic!() | ||
| 87 | }; | ||
| 88 | assert_eq!( | ||
| 89 | (before, stored, author.as_str()), | ||
| 90 | (&page_of(OUTLINES, space), &after, "Model author") | ||
| 91 | ); | ||
| 92 | let mut server = Server::new(OUTLINES); | ||
| 93 | assert!( | ||
| 94 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 95 | ); | ||
| 96 | assert_eq!(page_of(&server.durable, space), after); | ||
| 97 | assert_eq!(server.publications, 1); | ||
| 98 | assert!(cache.pending().unwrap().is_empty()); | ||
| 99 | assert_eq!( | ||
| 100 | cache | ||
| 101 | .save(&cache.snapshot().unwrap(), space, &after, "Model author") | ||
| 102 | .unwrap(), | ||
| 103 | None | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn saves_before_an_attempt_coalesce_into_one_publication() { | ||
| 109 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 110 | let directory = tempfile::tempdir().unwrap(); | ||
| 111 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | ||
| 112 | append(&mut after, " one"); | ||
| 113 | let id = cache | ||
| 114 | .save(OUTLINES, space, &after, "Model author") | ||
| 115 | .unwrap() | ||
| 116 | .unwrap(); | ||
| 117 | let mut later = page_of(&cache.snapshot().unwrap(), space); | ||
| 118 | append(&mut later, " two"); | ||
| 119 | assert_eq!( | ||
| 120 | cache | ||
| 121 | .save(&cache.snapshot().unwrap(), space, &later, "Model author") | ||
| 122 | .unwrap(), | ||
| 123 | Some(id) | ||
| 124 | ); | ||
| 125 | let pending = cache.pending().unwrap(); | ||
| 126 | assert_eq!(pending.len(), 1); | ||
| 127 | let Operation::Page(intent) = &pending[0].operation else { | ||
| 128 | panic!() | ||
| 129 | }; | ||
| 130 | assert_eq!(intent.before, page_of(OUTLINES, space)); | ||
| 131 | assert_eq!(intent.after, later); | ||
| 132 | let mut server = Server::new(OUTLINES); | ||
| 133 | assert!( | ||
| 134 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 135 | ); | ||
| 136 | assert_eq!(server.publications, 1); | ||
| 137 | assert_eq!( | ||
| 138 | first_text(&page_of(&server.durable, space)), | ||
| 139 | first_text(&later) | ||
| 140 | ); | ||
| 141 | let mut third = page_of(&cache.snapshot().unwrap(), space); | ||
| 142 | append(&mut third, " three"); | ||
| 143 | let next = cache | ||
| 144 | .save(&cache.snapshot().unwrap(), space, &third, "Model author") | ||
| 145 | .unwrap() | ||
| 146 | .unwrap(); | ||
| 147 | assert!(next > id); | ||
| 148 | } | ||
| 149 | |||
| 150 | #[test] | ||
| 151 | fn an_attempted_save_is_never_rewritten() { | ||
| 152 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 153 | let directory = tempfile::tempdir().unwrap(); | ||
| 154 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | ||
| 155 | append(&mut after, " uncertain"); | ||
| 156 | let id = cache | ||
| 157 | .save(OUTLINES, space, &after, "Model author") | ||
| 158 | .unwrap() | ||
| 159 | .unwrap(); | ||
| 160 | let mut server = Server::new(OUTLINES); | ||
| 161 | server.fault = Fault::UnknownAfter; | ||
| 162 | assert!(cache.sync_once(&mut server).is_err()); | ||
| 163 | assert!(matches!( | ||
| 164 | cache.status(id).unwrap(), | ||
| 165 | Some(EditStatus::AwaitingConfirmation { .. }) | ||
| 166 | )); | ||
| 167 | let mut more = page_of(&cache.snapshot().unwrap(), space); | ||
| 168 | append(&mut more, " more"); | ||
| 169 | let next = cache | ||
| 170 | .save(&cache.snapshot().unwrap(), space, &more, "Model author") | ||
| 171 | .unwrap() | ||
| 172 | .unwrap(); | ||
| 173 | assert!(next > id); | ||
| 174 | let pending = cache.pending().unwrap(); | ||
| 175 | assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]); | ||
| 176 | let Operation::Page(first) = &pending[0].operation else { | ||
| 177 | panic!() | ||
| 178 | }; | ||
| 179 | assert_eq!(first.after, after); | ||
| 180 | let Operation::Page(second) = &pending[1].operation else { | ||
| 181 | panic!() | ||
| 182 | }; | ||
| 183 | assert_eq!((&second.before, &second.after), (&after, &more)); | ||
| 184 | } | ||
| 185 | |||
| 186 | #[test] | ||
| 187 | fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() { | ||
| 188 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 189 | let directory = tempfile::tempdir().unwrap(); | ||
| 190 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | ||
| 191 | append(&mut after, " local"); | ||
| 192 | let id = cache | ||
| 193 | .save(OUTLINES, space, &after, "Model author") | ||
| 194 | .unwrap() | ||
| 195 | .unwrap(); | ||
| 196 | let mut remote_page = page_of(OUTLINES, space); | ||
| 197 | append(&mut remote_page, " remote"); | ||
| 198 | let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap(); | ||
| 199 | let mut server = Server::new(remote.as_bytes()); | ||
| 200 | assert_eq!( | ||
| 201 | cache.sync_once(&mut server).unwrap(), | ||
| 202 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | ||
| 203 | ); | ||
| 204 | assert_eq!(server.publications, 0); | ||
| 205 | assert!(cache.pending().unwrap().len() == 1); | ||
| 206 | let local = cache.snapshot().unwrap(); | ||
| 207 | let observed = cache.remote_snapshot().unwrap(); | ||
| 208 | assert_eq!(observed, remote.as_bytes()); | ||
| 209 | let mut reviewed = page_of(&observed, space); | ||
| 210 | append(&mut reviewed, " local"); | ||
| 211 | assert!( | ||
| 212 | cache | ||
| 213 | .review_page(id + 1, &local, &observed, &reviewed) | ||
| 214 | .is_err() | ||
| 215 | ); | ||
| 216 | assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err()); | ||
| 217 | cache.review_page(id, &local, &observed, &reviewed).unwrap(); | ||
| 218 | assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 219 | assert!( | ||
| 220 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 221 | ); | ||
| 222 | assert_eq!( | ||
| 223 | first_text(&page_of(&server.durable, space)), | ||
| 224 | first_text(&reviewed) | ||
| 225 | ); | ||
| 226 | assert!(first_text(&page_of(&server.durable, space)).ends_with(" remote local")); | ||
| 227 | } | ||
| 228 | |||
| 229 | #[test] | ||
| 230 | fn remote_changes_to_other_pages_do_not_conflict() { | ||
| 231 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 232 | let (other, mut other_page) = page_titled(OUTLINES, "Delete leaf"); | ||
| 233 | let directory = tempfile::tempdir().unwrap(); | ||
| 234 | let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap(); | ||
| 235 | append(&mut after, " local"); | ||
| 236 | let id = cache | ||
| 237 | .save(OUTLINES, space, &after, "Model author") | ||
| 238 | .unwrap() | ||
| 239 | .unwrap(); | ||
| 240 | append(&mut other_page, " elsewhere"); | ||
| 241 | let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap(); | ||
| 242 | let mut server = Server::new(remote.as_bytes()); | ||
| 243 | assert!( | ||
| 244 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | ||
| 245 | ); | ||
| 246 | assert_eq!(page_of(&server.durable, space), after); | ||
| 247 | assert_eq!(page_of(&server.durable, other), other_page); | ||
| 248 | assert_eq!(page_of(&cache.snapshot().unwrap(), other), other_page); | ||
| 249 | } | ||
| 250 | |||
| 251 | #[test] | ||
| 252 | fn pending_saves_survive_reopening_the_cache() { | ||
| 253 | let (space, mut after) = page_titled(OUTLINES, "Move leaf down"); | ||
| 254 | let directory = tempfile::tempdir().unwrap(); | ||
| 255 | let path = directory.path().join("cache.sqlite"); | ||
| 256 | let cache = Replica::create(&path, OUTLINES).unwrap(); | ||
| 257 | append(&mut after, " durable"); | ||
| 258 | let id = cache | ||
| 259 | .save(OUTLINES, space, &after, "Model author") | ||
| 260 | .unwrap() | ||
| 261 | .unwrap(); | ||
| 262 | let pending = cache.pending().unwrap(); | ||
| 263 | drop(cache); | ||
| 264 | let reopened = Replica::open(&path).unwrap(); | ||
| 265 | assert_eq!(reopened.pending().unwrap(), pending); | ||
| 266 | assert_eq!(reopened.status(id).unwrap(), Some(EditStatus::Pending)); | ||
| 267 | assert_eq!(page_of(&reopened.snapshot().unwrap(), space), after); | ||
| 268 | } | ||
| 269 | |||
| 270 | /// Edits paragraph `index` of the first body outline: replaces its text range with `replacement`. | ||
| 271 | fn 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 | |||
| 290 | fn 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 | |||
| 306 | fn 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] | ||
| 317 | fn 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] | ||
| 343 | fn 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] | ||
| 377 | fn 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] | ||
| 432 | fn 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] | ||
| 459 | fn 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use onestore::OutlineEdit as Change; | ||
| 3 | |||
| 4 | pub(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 | |||
| 27 | pub(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] | ||
| 38 | fn 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] | ||
| 112 | fn 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] | ||
| 187 | fn 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] | ||
| 267 | fn 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] | ||
| 297 | fn 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] | ||
| 410 | fn 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] | ||
| 468 | fn 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] | ||
| 663 | fn 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use notebook::{Operation, Recovery}; | ||
| 3 | use onestore::{Insertion, PageCreation}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | #[test] | ||
| 7 | fn 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] | ||
| 27 | fn 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] | ||
| 101 | fn 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] | ||
| 173 | fn 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] | ||
| 228 | fn 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] | ||
| 289 | fn 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 @@ | ||
| 1 | use super::*; | ||
| 2 | use notebook::{Operation, Recovery}; | ||
| 3 | use onestore::{PageEdit, PagePosition}; | ||
| 4 | use std::collections::BTreeMap; | ||
| 5 | |||
| 6 | const SOURCE: &[u8] = | ||
| 7 | include_bytes!("../../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 8 | |||
| 9 | fn 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 | |||
| 29 | fn 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] | ||
| 46 | fn 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] | ||
| 96 | fn 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] | ||
| 121 | fn 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] | ||
| 178 | fn 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] | ||
| 209 | fn 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] | ||
| 260 | fn 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] | ||
| 316 | fn 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] | ||
| 346 | fn 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] | ||
| 370 | fn 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] | ||
| 414 | fn 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] | ||
| 542 | fn 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,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 | |||
| 1 | use super::*; | 4 | use super::*; |
| 2 | use onestore::{Insertion, TreeEdit}; | 5 | use model_schedule::move_subtree; |
| 6 | use onestore::page::{Paragraph, ParagraphContent, TableCell}; | ||
| 7 | |||
| 8 | /// Saves an edited model of `space`, reaching content that outline helpers cannot. | ||
| 9 | fn 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 | } | ||
| 3 | 17 | ||
| 4 | #[test] | 18 | #[test] |
| 5 | fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { | 19 | fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { |
| 6 | let mut random = 1940_u64; | 20 | let mut random = 1940_u64; |
| 7 | for _ in 0..60 { | 21 | for _ in 0..24 { |
| 8 | let input: Vec<_> = (0..384) | 22 | let input: Vec<_> = (0..256) |
| 9 | .map(|_| { | 23 | .map(|_| { |
| 10 | random ^= random << 13; | 24 | random ^= random << 13; |
| 11 | random ^= random >> 7; | 25 | random ^= random >> 7; |
| ... | @@ -13,246 +27,202 @@ fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { | ... | @@ -13,246 +27,202 @@ fn twelve_offline_clients_reconcile_tree_text_and_interrupted_publication() { |
| 13 | random as u8 | 27 | random as u8 |
| 14 | }) | 28 | }) |
| 15 | .collect(); | 29 | .collect(); |
| 16 | tree_schedule::run(&input); | 30 | model_schedule::run(&input); |
| 17 | } | 31 | } |
| 18 | } | 32 | } |
| 19 | 33 | ||
| 34 | /// The one-outline fixture with three further sibling paragraphs. | ||
| 20 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { | 35 | fn fixture() -> (Vec<u8>, ExGuid, ExGuid, [ExGuid; 4], [ExGuid; 4]) { |
| 21 | let (mut source, sid, outline, first, text) = super::outline::fixture(); | 36 | let (source, space, outline, text) = super::fixture(); |
| 22 | let mut paragraphs = [first; 4]; | 37 | let mut page = page_of(&source, space); |
| 23 | let mut texts = [text; 4]; | 38 | let mut anchor = text; |
| 24 | for at in 1..4 { | 39 | for at in 1..4 { |
| 25 | let insertion = | 40 | anchor = model_ops::insert_after(&mut page, anchor, &format!("Sibling {at}")).1; |
| 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(); | ||
| 33 | } | 41 | } |
| 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) | ||
| 35 | } | 52 | } |
| 36 | 53 | ||
| 37 | fn children(source: &[u8], sid: ExGuid, object: ExGuid) -> Vec<ExGuid> { | 54 | /// Paragraph identities of an outline in model order. |
| 38 | let store = Store::parse(source).unwrap(); | 55 | fn order(bytes: &[u8], space: ExGuid, outline: ExGuid) -> Vec<ExGuid> { |
| 39 | let index = RevisionIndex::parse(&store).unwrap(); | 56 | outline_of(&page_of(bytes, space), outline) |
| 40 | index.validate_current().unwrap(); | 57 | .paragraphs |
| 41 | let document = Document::parse(&index).unwrap(); | 58 | .iter() |
| 42 | let space = &document.spaces[&sid]; | 59 | .map(|paragraph| paragraph.id) |
| 43 | space.revisions[&space.contexts[&ExGuid::default()]].nodes[&object] | 60 | .collect() |
| 44 | .children | 61 | } |
| 45 | .clone() | 62 | |
| 63 | /// Adds a plain paragraph as the last child of `parent`. | ||
| 64 | fn 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"); | ||
| 46 | } | 81 | } |
| 47 | 82 | ||
| 48 | #[test] | 83 | #[test] |
| 49 | fn move_preserves_remote_content_and_dependent_edits_through_reopen() { | 84 | fn 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(); |
| 51 | let directory = tempfile::tempdir().unwrap(); | 86 | let directory = tempfile::tempdir().unwrap(); |
| 52 | let path = directory.path().join("cache.sqlite"); | 87 | let path = directory.path().join("cache.sqlite"); |
| 53 | let cache = Replica::create(&path, &source).unwrap(); | 88 | let cache = Replica::create(&path, &source).unwrap(); |
| 54 | let intent = TreeEdit::move_to(paragraphs[0], outline, None, "Offline").unwrap(); | 89 | let id = save(&cache, texts[0], |page| { |
| 55 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | 90 | move_subtree(page, paragraphs[0], None, None); |
| 56 | let dependent = cache | 91 | replace_text(page, texts[0], 0..0, "Local "); |
| 57 | .edit_text(&cache.snapshot().unwrap(), sid, texts[0], 0..0, "Local ") | 92 | }) |
| 58 | .unwrap() | 93 | .unwrap() |
| 59 | .unwrap(); | 94 | .unwrap(); |
| 60 | let local = cache.snapshot().unwrap(); | 95 | let local = cache.snapshot().unwrap(); |
| 61 | let queue = cache.pending().unwrap(); | 96 | let queue = cache.pending().unwrap(); |
| 62 | drop(cache); | 97 | drop(cache); |
| 63 | let cache = Replica::open(&path).unwrap(); | 98 | let cache = Replica::open(&path).unwrap(); |
| 64 | assert_eq!(cache.pending().unwrap(), queue); | 99 | assert_eq!(cache.pending().unwrap(), queue); |
| 65 | assert_eq!(cache.snapshot().unwrap(), local); | 100 | assert_eq!(cache.snapshot().unwrap(), local); |
| 66 | let edited = PreparedEdit::format( | 101 | let mut server = Server::new(&remote_with(&source, space, |page| { |
| 67 | &source, | 102 | insert_child(page, paragraphs[0], "New remote child"); |
| 68 | sid, | 103 | restyle(page, texts[1], 0..7, |format| format.bold = Some(true)); |
| 69 | texts[0], | 104 | })); |
| 70 | 0..8, | 105 | published(&cache, &mut server, id); |
| 71 | &[onestore::TextAttribute::Bold(true)], | 106 | let durable = page_of(&server.durable, space); |
| 72 | ) | 107 | let published = outline_of(&durable, outline); |
| 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 | } | ||
| 83 | assert_eq!( | 108 | assert_eq!( |
| 84 | children(&server.durable, sid, outline), | 109 | published |
| 110 | .paragraphs | ||
| 111 | .iter() | ||
| 112 | .map(|p| p.id) | ||
| 113 | .collect::<Vec<_>>()[..4], | ||
| 85 | [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] | 114 | [paragraphs[1], paragraphs[2], paragraphs[3], paragraphs[0]] |
| 86 | ); | 115 | ); |
| 87 | assert_eq!( | 116 | let child = &published.paragraphs[4]; |
| 88 | children(&server.durable, sid, paragraphs[0]), | 117 | assert_eq!(child.parent, Some(paragraphs[0])); |
| 89 | [descendant.object()] | 118 | assert_eq!(child.text().unwrap().text.text(), "New remote child"); |
| 90 | ); | 119 | assert_eq!(text_of(&durable, texts[0]), "Local Original 🦀 é"); |
| 91 | let node = super::outline::node(&server.durable, sid, texts[0]); | 120 | let sibling = &paragraph_with(&durable, texts[1]) |
| 92 | assert_eq!(node["kind"]["text"], "Local Original 🦀 é"); | 121 | .unwrap() |
| 93 | let store = Store::parse(&server.durable).unwrap(); | 122 | .text() |
| 94 | let index = RevisionIndex::parse(&store).unwrap(); | 123 | .unwrap() |
| 95 | let document = Document::parse(&index).unwrap(); | 124 | .text; |
| 96 | let space = &document.spaces[&sid]; | 125 | assert_eq!(sibling.spans()[0].format.bold, Some(true)); |
| 97 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | ||
| 98 | assert_eq!(view.text_runs(texts[0]).unwrap()[0].format.bold, Some(true)); | ||
| 99 | assert!(cache.pending().unwrap().is_empty()); | 126 | assert!(cache.pending().unwrap().is_empty()); |
| 100 | assert_eq!(server.publications, 2); | 127 | assert_eq!(server.publications, 1); |
| 101 | } | 128 | } |
| 102 | 129 | ||
| 103 | #[test] | 130 | #[test] |
| 104 | fn queued_format_split_and_delete_reconcile_equivalent_immutable_style_identities() { | 131 | fn native_empty_child_list_normalization_merges_with_a_local_deletion() { |
| 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] | ||
| 148 | fn native_empty_child_list_normalization_preserves_deletion_and_its_dependents() { | ||
| 149 | let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); | 132 | let source = include_bytes!("../../../../corpus/outline-edit/empty-children/before.one"); |
| 150 | let remote = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); | 133 | let native = include_bytes!("../../../../corpus/outline-edit/empty-children/remote.one"); |
| 151 | let (sid, intent): (ExGuid, TreeEdit) = serde_json::from_str(include_str!( | 134 | let intent: serde_json::Value = serde_json::from_str(include_str!( |
| 152 | "../../../../corpus/outline-edit/empty-children/intent.json" | 135 | "../../../../corpus/outline-edit/empty-children/intent.json" |
| 153 | )) | 136 | )) |
| 154 | .unwrap(); | 137 | .unwrap(); |
| 155 | let store = Store::parse(source).unwrap(); | 138 | let space: ExGuid = intent[0].as_str().unwrap().parse().unwrap(); |
| 156 | let index = RevisionIndex::parse(&store).unwrap(); | 139 | let object: ExGuid = intent[1]["object"].as_str().unwrap().parse().unwrap(); |
| 157 | let document = Document::parse(&index).unwrap(); | 140 | let page = page_of(source, space); |
| 158 | let space = &document.spaces[&sid]; | 141 | let outline = body_outlines(&page) |
| 159 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 142 | .into_iter() |
| 160 | let tail = view.nodes[&intent.object()].content[0]; | 143 | .find(|outline| outline.paragraphs.iter().any(|p| p.id == object)) |
| 161 | let (outline_id, outline) = view | ||
| 162 | .nodes | ||
| 163 | .iter() | ||
| 164 | .find(|(_, node)| node.children.contains(&intent.object())) | ||
| 165 | .unwrap(); | 144 | .unwrap(); |
| 166 | let left = view.nodes[&outline.children[0]].content[0]; | 145 | let deleted = outline |
| 167 | for changed in [false, true] { | 146 | .paragraphs |
| 168 | let directory = tempfile::tempdir().unwrap(); | 147 | .iter() |
| 169 | let path = directory.path().join("cache.sqlite"); | 148 | .find(|p| p.id == object) |
| 170 | let cache = Replica::create(&path, source).unwrap(); | 149 | .unwrap() |
| 171 | let deletion = cache.tree(source, sid, &intent).unwrap().unwrap(); | 150 | .text() |
| 172 | let dependent = cache | 151 | .unwrap() |
| 173 | .edit_text(&cache.snapshot().unwrap(), sid, left, 0..0, "Local ") | 152 | .id; |
| 174 | .unwrap() | 153 | let left = outline.paragraphs[0].text().unwrap().id; |
| 175 | .unwrap(); | 154 | let outline = outline.id; |
| 176 | let local = cache.snapshot().unwrap(); | 155 | let concurrent: Vec<String> = body_outlines(&page_of(native, space)) |
| 177 | drop(cache); | 156 | .into_iter() |
| 178 | let cache = Replica::open(&path).unwrap(); | 157 | .flat_map(|outline| outline.paragraphs.iter()) |
| 179 | let mut server = if changed { | 158 | .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned())) |
| 180 | let edited = PreparedEdit::text(remote, sid, tail, 0..2, "Changed").unwrap(); | 159 | .collect(); |
| 181 | Server::new(edited.as_bytes()) | 160 | let directory = tempfile::tempdir().unwrap(); |
| 182 | } else { | 161 | let path = directory.path().join("cache.sqlite"); |
| 183 | Server::new(remote) | 162 | let cache = Replica::create(&path, source).unwrap(); |
| 184 | }; | 163 | let change = |page: &mut Page| { |
| 185 | if changed { | 164 | delete_paragraph(page, deleted); |
| 186 | assert_eq!( | 165 | replace_text(page, left, 0..0, "Local "); |
| 187 | cache.sync_once(&mut server).unwrap(), | 166 | }; |
| 188 | Some((deletion, EditStatus::Conflict(ConflictKind::ContentChanged))) | 167 | let id = save(&cache, left, change).unwrap().unwrap(); |
| 189 | ); | 168 | drop(cache); |
| 190 | assert_eq!(cache.snapshot().unwrap(), local); | 169 | let cache = Replica::open(&path).unwrap(); |
| 191 | assert_eq!(cache.pending().unwrap().len(), 2); | 170 | let mut server = Server::new(native); |
| 192 | assert_eq!(server.publications, 0); | 171 | assert!(matches!( |
| 193 | } else { | 172 | cache.sync_once(&mut server).unwrap(), |
| 194 | for expected in [deletion, dependent] { | 173 | Some((published, EditStatus::Published { .. })) if published == id |
| 195 | assert!( | 174 | )); |
| 196 | matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == expected) | 175 | assert_eq!(server.publications, 1); |
| 197 | ); | 176 | assert!(cache.pending().unwrap().is_empty()); |
| 198 | } | 177 | let durable = page_of(&server.durable, space); |
| 199 | assert!(cache.pending().unwrap().is_empty()); | 178 | assert!(!order(&server.durable, space, outline).contains(&object)); |
| 200 | assert_eq!(server.publications, 2); | 179 | assert!(text_of(&durable, left).starts_with("Local ")); |
| 201 | assert!(!children(&server.durable, sid, *outline_id).contains(&intent.object())); | 180 | let published: Vec<String> = body_outlines(&durable) |
| 202 | let node = super::outline::node(&server.durable, sid, left); | 181 | .into_iter() |
| 203 | assert!(node["kind"]["text"].as_str().unwrap().starts_with("Local ")); | 182 | .flat_map(|outline| outline.paragraphs.iter()) |
| 204 | } | 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 | ); | ||
| 205 | } | 190 | } |
| 206 | } | 191 | } |
| 207 | 192 | ||
| 208 | #[test] | 193 | #[test] |
| 209 | fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | 194 | fn deletion_requires_review_of_remote_content_and_preserves_later_work() { |
| 210 | let (source, sid, outline, paragraphs, texts) = fixture(); | 195 | let (source, space, outline, paragraphs, texts) = fixture(); |
| 211 | let child = Insertion::paragraph(paragraphs[0], None, "Descendant", "Author").unwrap(); | 196 | let mut page = page_of(&source, space); |
| 212 | let source = PreparedEdit::insert(&source, sid, &child) | 197 | insert_child(&mut page, paragraphs[0], "Descendant"); |
| 198 | let source = PreparedEdit::page(&source, space, &page, "Author") | ||
| 213 | .unwrap() | 199 | .unwrap() |
| 214 | .as_bytes() | 200 | .as_bytes() |
| 215 | .to_vec(); | 201 | .to_vec(); |
| 216 | for remote in [ | 202 | let child = outline_of(&page_of(&source, space), outline).paragraphs[1] |
| 217 | PreparedEdit::text(&source, sid, texts[0], 0..0, "Remote ") | 203 | .text() |
| 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 | ) | ||
| 240 | .unwrap() | 204 | .unwrap() |
| 241 | .as_bytes() | 205 | .id; |
| 242 | .to_vec(), | 206 | let remotes: [Change<Page>; 4] = [ |
| 243 | ] { | 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 { | ||
| 244 | let directory = tempfile::tempdir().unwrap(); | 215 | let directory = tempfile::tempdir().unwrap(); |
| 245 | let path = directory.path().join("cache.sqlite"); | 216 | let path = directory.path().join("cache.sqlite"); |
| 246 | let cache = Replica::create(&path, &source).unwrap(); | 217 | let cache = Replica::create(&path, &source).unwrap(); |
| 247 | let intent = TreeEdit::delete(paragraphs[0], "Offline").unwrap(); | 218 | let change = |page: &mut Page| { |
| 248 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | 219 | delete_paragraph(page, texts[0]); |
| 249 | let dependent = cache | 220 | replace_text(page, texts[1], 0..0, "Local "); |
| 250 | .edit_text(&cache.snapshot().unwrap(), sid, texts[1], 0..0, "Local ") | 221 | }; |
| 251 | .unwrap() | 222 | let id = save(&cache, texts[1], change).unwrap().unwrap(); |
| 252 | .unwrap(); | ||
| 253 | let queue = cache.pending().unwrap(); | 223 | let queue = cache.pending().unwrap(); |
| 254 | let local = cache.snapshot().unwrap(); | 224 | let local = cache.snapshot().unwrap(); |
| 255 | let mut server = Server::new(&remote); | 225 | let mut server = Server::new(&remote_with(&source, space, remote)); |
| 256 | assert_eq!( | 226 | assert_eq!( |
| 257 | cache.sync_once(&mut server).unwrap(), | 227 | cache.sync_once(&mut server).unwrap(), |
| 258 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) | 228 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| ... | @@ -260,37 +230,26 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | ... | @@ -260,37 +230,26 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { |
| 260 | assert_eq!(server.publications, 0); | 230 | assert_eq!(server.publications, 0); |
| 261 | assert_eq!(cache.pending().unwrap(), queue); | 231 | assert_eq!(cache.pending().unwrap(), queue); |
| 262 | assert_eq!(cache.snapshot().unwrap(), local); | 232 | assert_eq!(cache.snapshot().unwrap(), local); |
| 263 | assert!(cache.rebase_tree_conflict(id, &source, &remote).is_err()); | 233 | let observed = cache.remote_snapshot().unwrap(); |
| 264 | assert!(cache.rebase_tree_conflict(id, &local, &source).is_err()); | ||
| 265 | assert!( | 234 | assert!( |
| 266 | cache | 235 | cache |
| 267 | .rebase_tree_conflict(dependent, &local, &remote) | 236 | .review_page(id, &source, &observed, &page_of(&observed, space)) |
| 268 | .is_err() | 237 | .is_err() |
| 269 | ); | 238 | ); |
| 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); | ||
| 277 | drop(cache); | 239 | drop(cache); |
| 278 | let cache = Replica::open(&path).unwrap(); | 240 | let cache = Replica::open(&path).unwrap(); |
| 279 | assert_eq!( | 241 | assert_eq!( |
| 280 | cache.status(id).unwrap(), | 242 | cache.status(id).unwrap(), |
| 281 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) | 243 | Some(EditStatus::Conflict(ConflictKind::ContentChanged)) |
| 282 | ); | 244 | ); |
| 283 | cache.rebase_tree_conflict(id, &local, &remote).unwrap(); | 245 | review(&cache, id, texts[1], change).unwrap(); |
| 284 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | 246 | published(&cache, &mut server, id); |
| 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..]); | ||
| 292 | assert_eq!( | 247 | 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]), | ||
| 294 | "Local Sibling 1" | 253 | "Local Sibling 1" |
| 295 | ); | 254 | ); |
| 296 | } | 255 | } |
| ... | @@ -298,247 +257,121 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { | ... | @@ -298,247 +257,121 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() { |
| 298 | 257 | ||
| 299 | #[test] | 258 | #[test] |
| 300 | fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { | 259 | fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() { |
| 301 | let (source, sid, outline, p, texts) = fixture(); | 260 | let (source, space, outline, paragraphs, texts) = fixture(); |
| 302 | let insertion = Insertion::paragraph(outline, Some(p[0]), "New sibling", "Remote").unwrap(); | 261 | // The published order of the fixture's paragraphs, or a conflict. |
| 303 | let cases = [ | 262 | let cases: [(Change<Page>, Option<&[usize]>); 5] = [ |
| 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 | ), | ||
| 318 | ( | 263 | ( |
| 319 | PreparedEdit::tree( | 264 | Box::new(move |page| { |
| 320 | &source, | 265 | model_ops::insert_after(page, texts[0], "New sibling"); |
| 321 | sid, | 266 | }), |
| 322 | &TreeEdit::move_to(p[1], outline, None, "Remote").unwrap(), | 267 | Some(&[1, 2, 3, 0]), |
| 323 | ) | ||
| 324 | .unwrap() | ||
| 325 | .as_bytes() | ||
| 326 | .to_vec(), | ||
| 327 | None, | ||
| 328 | ), | 268 | ), |
| 329 | ( | 269 | ( |
| 330 | PreparedEdit::tree( | 270 | Box::new(move |page| delete_paragraph(page, texts[1])), |
| 331 | &source, | 271 | Some(&[2, 3, 0]), |
| 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), | ||
| 339 | ), | 272 | ), |
| 340 | ( | 273 | ( |
| 341 | PreparedEdit::tree( | 274 | Box::new(move |page| move_subtree(page, paragraphs[1], None, None)), |
| 342 | &source, | 275 | Some(&[2, 3, 0, 1]), |
| 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), | ||
| 350 | ), | 276 | ), |
| 351 | ( | 277 | ( |
| 352 | PreparedEdit::tree(&source, sid, &TreeEdit::delete(p[0], "Remote").unwrap()) | 278 | Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)), |
| 353 | .unwrap() | 279 | None, |
| 354 | .as_bytes() | ||
| 355 | .to_vec(), | ||
| 356 | Some(ConflictKind::TargetUnavailable), | ||
| 357 | ), | 280 | ), |
| 281 | (Box::new(move |page| delete_paragraph(page, texts[0])), None), | ||
| 358 | ]; | 282 | ]; |
| 359 | for (remote, expected) in cases { | 283 | for (remote, expected) in cases { |
| 360 | let directory = tempfile::tempdir().unwrap(); | 284 | let directory = tempfile::tempdir().unwrap(); |
| 361 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | 285 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); |
| 362 | let intent = TreeEdit::move_to(p[0], outline, None, "Offline").unwrap(); | 286 | let id = save(&cache, texts[0], |page| { |
| 363 | let id = cache.tree(&source, sid, &intent).unwrap().unwrap(); | 287 | move_subtree(page, paragraphs[0], None, None) |
| 364 | let mut server = Server::new(&remote); | 288 | }) |
| 289 | .unwrap() | ||
| 290 | .unwrap(); | ||
| 291 | let mut server = Server::new(&remote_with(&source, space, remote)); | ||
| 365 | let result = cache.sync_once(&mut server).unwrap().unwrap(); | 292 | let result = cache.sync_once(&mut server).unwrap().unwrap(); |
| 366 | assert_eq!(result.0, id); | 293 | assert_eq!(result.0, id); |
| 367 | if let Some(kind) = expected { | 294 | let Some(expected) = expected else { |
| 368 | assert_eq!(result.1, EditStatus::Conflict(kind)); | 295 | assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged)); |
| 369 | assert_eq!(server.publications, 0); | 296 | assert_eq!(server.publications, 0); |
| 370 | } else { | 297 | continue; |
| 371 | assert!(matches!(result.1, EditStatus::Published { .. })); | 298 | }; |
| 372 | assert_eq!(children(&server.durable, sid, outline).last(), Some(&p[0])); | 299 | assert!(matches!(result.1, EditStatus::Published { .. })); |
| 373 | assert_eq!( | 300 | let published: Vec<ExGuid> = order(&server.durable, space, outline) |
| 374 | super::outline::node(&server.durable, sid, texts[0])["kind"]["text"], | 301 | .into_iter() |
| 375 | "Original 🦀 é" | 302 | .filter(|id| paragraphs.contains(id)) |
| 376 | ); | 303 | .collect(); |
| 377 | } | 304 | let expected: Vec<ExGuid> = expected.iter().map(|at| paragraphs[*at]).collect(); |
| 378 | } | 305 | assert_eq!(published, expected); |
| 379 | } | ||
| 380 | |||
| 381 | #[test] | ||
| 382 | fn 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); | ||
| 419 | assert_eq!( | 306 | assert_eq!( |
| 420 | cache.sync_once(&mut server).unwrap(), | 307 | text_of(&page_of(&server.durable, space), texts[2]), |
| 421 | Some((id, EditStatus::Conflict(kind))) | 308 | "Sibling 2" |
| 422 | ); | 309 | ); |
| 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] | ||
| 443 | fn 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 | } | ||
| 514 | } | 310 | } |
| 515 | } | 311 | } |
| 516 | 312 | ||
| 517 | #[test] | 313 | #[test] |
| 518 | fn independently_satisfied_move_confirms_without_republication() { | 314 | fn independently_satisfied_move_confirms_without_republication() { |
| 519 | let (source, sid, outline, p, _) = fixture(); | 315 | let (source, space, _, paragraphs, texts) = fixture(); |
| 520 | let directory = tempfile::tempdir().unwrap(); | 316 | let directory = tempfile::tempdir().unwrap(); |
| 521 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); | 317 | let cache = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap(); |
| 522 | let id = cache | 318 | let id = save(&cache, texts[0], |page| { |
| 523 | .tree( | 319 | move_subtree(page, paragraphs[0], None, None) |
| 524 | &source, | 320 | }) |
| 525 | sid, | 321 | .unwrap() |
| 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 | ) | ||
| 535 | .unwrap(); | 322 | .unwrap(); |
| 536 | let mut server = Server::new(remote.as_bytes()); | 323 | let mut server = Server::new(&remote_with(&source, space, |page| { |
| 537 | assert!( | 324 | move_subtree(page, paragraphs[0], None, None); |
| 538 | matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id) | 325 | })); |
| 539 | ); | 326 | published(&cache, &mut server, id); |
| 540 | assert_eq!(server.publications, 0); | 327 | assert_eq!((server.publications, server.confirmations), (0, 1)); |
| 541 | assert_eq!(server.confirmations, 1); | 328 | } |
| 329 | |||
| 330 | fn 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 | |||
| 343 | fn 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 | |||
| 350 | fn 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. | ||
| 365 | fn 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); | ||
| 542 | } | 375 | } |
| 543 | 376 | ||
| 544 | #[test] | 377 | #[test] |
| ... | @@ -548,266 +381,233 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( | ... | @@ -548,266 +381,233 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay( |
| 548 | let store = Store::parse(source).unwrap(); | 381 | let store = Store::parse(source).unwrap(); |
| 549 | let index = RevisionIndex::parse(&store).unwrap(); | 382 | let index = RevisionIndex::parse(&store).unwrap(); |
| 550 | let document = Document::parse(&index).unwrap(); | 383 | let document = Document::parse(&index).unwrap(); |
| 551 | let (sid, page) = document.pages().unwrap().into_iter().find(|(sid, _)| { | 384 | let (space, page) = document |
| 552 | let space = &document.spaces[sid]; | 385 | .pages() |
| 553 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 386 | .unwrap() |
| 554 | matches!(&view.nodes[&view.roots[&2]].kind, Kind::Metadata { title: Some(title), .. } if title == "Delete sole cell paragraph") | 387 | .into_iter() |
| 555 | }).unwrap(); | 388 | .find_map(|(space, _)| { |
| 556 | let space = &document.spaces[&sid]; | 389 | let page = Page::from_space(&document, space).unwrap(); |
| 557 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 390 | (page.title == "Delete sole cell paragraph").then_some((space, page)) |
| 558 | let mut pending = vec![page]; | 391 | }) |
| 559 | let mut selected = None; | 392 | .unwrap(); |
| 560 | let mut other = None; | 393 | let other = document |
| 561 | while let Some(id) = pending.pop() { | 394 | .pages() |
| 562 | let node = &view.nodes[&id]; | 395 | .unwrap() |
| 563 | pending.extend(&node.children); | 396 | .into_iter() |
| 564 | pending.extend(&node.content); | 397 | .find(|(other, _)| *other != space) |
| 565 | if matches!(node.kind, Kind::Cell { .. }) { | 398 | .unwrap() |
| 566 | let paragraph = node.children[0]; | 399 | .0; |
| 567 | let text = view.nodes[&paragraph].content[0]; | 400 | let text = |cell: &TableCell| cell.paragraphs[0].text().unwrap().id; |
| 568 | if matches!(&view.nodes[&text].kind, Kind::RichText { text, .. } if text.starts_with("Target ")) | 401 | let cell = cells(&page) |
| 569 | { | 402 | .into_iter() |
| 570 | selected = Some((id, paragraph)); | 403 | .find(|cell| { |
| 571 | } else { | 404 | cell.paragraphs[0] |
| 572 | other = Some((id, text)); | 405 | .text() |
| 573 | } | 406 | .unwrap() |
| 574 | } | 407 | .text |
| 575 | } | 408 | .text() |
| 576 | let (cell, target) = selected.unwrap(); | 409 | .starts_with("Target ") |
| 577 | let (other_cell, other_text) = other.unwrap(); | 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)); | ||
| 578 | for move_out in [false, true] { | 418 | for move_out in [false, true] { |
| 579 | for competing_child in [false, true] { | 419 | for competing in [false, true] { |
| 580 | let directory = tempfile::tempdir().unwrap(); | 420 | let directory = tempfile::tempdir().unwrap(); |
| 581 | let path = directory.path().join("cell.sqlite"); | 421 | let path = directory.path().join("cell.sqlite"); |
| 582 | let cache = Replica::create(&path, source).unwrap(); | 422 | let cache = Replica::create(&path, source).unwrap(); |
| 583 | let intent = if move_out { | 423 | let id = save_page(&cache, space, |page| { |
| 584 | TreeEdit::move_to(target, other_cell, None, "Offline") | 424 | empty_cell(page, cell, move_out.then_some(neighbour)); |
| 585 | } else { | 425 | }) |
| 586 | TreeEdit::delete(target, "Offline") | ||
| 587 | } | ||
| 588 | .unwrap(); | 426 | .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(); | ||
| 604 | let local = cache.snapshot().unwrap(); | 427 | let local = cache.snapshot().unwrap(); |
| 605 | let queue = cache.pending().unwrap(); | 428 | 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 | } | ||
| 606 | drop(cache); | 436 | drop(cache); |
| 607 | let cache = Replica::open(&path).unwrap(); | 437 | let cache = Replica::open(&path).unwrap(); |
| 608 | assert_eq!(cache.pending().unwrap(), queue); | 438 | assert_eq!(cache.pending().unwrap(), queue); |
| 609 | assert_eq!(cache.snapshot().unwrap(), local); | 439 | assert_eq!(cache.snapshot().unwrap(), local); |
| 610 | let remote = if competing_child { | 440 | let mut server = Server::new(&if competing { |
| 611 | PreparedEdit::insert( | 441 | remote_with(source, space, |page| { |
| 612 | source, | 442 | let target = cell_mut(page, cell); |
| 613 | sid, | 443 | let mut sibling = |
| 614 | &Insertion::paragraph(cell, None, "Remote sibling", "Remote").unwrap(), | 444 | model_ops::fresh_paragraph(&target.paragraphs[0], "Remote sibling"); |
| 615 | ) | 445 | sibling.level = target.paragraphs[0].level; |
| 616 | .unwrap() | 446 | target.paragraphs.push(sibling); |
| 617 | .as_bytes() | 447 | }) |
| 618 | .to_vec() | ||
| 619 | } else { | 448 | } else { |
| 620 | PreparedEdit::text(source, sid, other_text, 0..0, "Remote ") | 449 | remote_with(source, other, |page| { |
| 621 | .unwrap() | 450 | let text = body_outlines(page)[0].paragraphs[0].text().unwrap().id; |
| 622 | .as_bytes() | 451 | replace_text(page, text, 0..0, "Remote "); |
| 623 | .to_vec() | 452 | }) |
| 624 | }; | 453 | }); |
| 625 | let mut server = Server::new(&remote); | 454 | if competing { |
| 626 | if competing_child { | ||
| 627 | assert_eq!( | 455 | assert_eq!( |
| 628 | cache.sync_once(&mut server).unwrap(), | 456 | cache.sync_once(&mut server).unwrap(), |
| 629 | Some((id, EditStatus::Conflict(ConflictKind::StructureChanged))) | 457 | Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))) |
| 630 | ); | 458 | ); |
| 631 | assert_eq!(server.publications, 0); | 459 | assert_eq!(server.publications, 0); |
| 632 | assert!(cache.rebase_tree_conflict(id, &local, &remote).is_err()); | ||
| 633 | assert_eq!(cache.pending().unwrap(), queue); | 460 | assert_eq!(cache.pending().unwrap(), queue); |
| 634 | assert_eq!(cache.snapshot().unwrap(), local); | 461 | assert_eq!(cache.snapshot().unwrap(), local); |
| 635 | } else { | 462 | continue; |
| 636 | for expected in [id, dependent] { | 463 | } |
| 637 | assert!( | 464 | published(&cache, &mut server, id); |
| 638 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | 465 | let replacement = { |
| 639 | ); | 466 | let durable = page_of(&server.durable, space); |
| 640 | } | 467 | let paragraphs = &cell_of(&durable, cell).paragraphs; |
| 641 | assert_eq!(children(&server.durable, sid, cell), replacement); | 468 | assert_eq!(paragraphs.len(), 1); |
| 642 | assert_eq!( | 469 | assert_ne!(paragraphs[0].text().unwrap().id, target); |
| 643 | super::outline::node(&server.durable, sid, replacement_text)["kind"]["text"], | 470 | paragraphs[0].text().unwrap().id |
| 644 | "Local replacement" | 471 | }; |
| 645 | ); | 472 | let edit = save_page(&cache, space, |page| { |
| 646 | assert_eq!( | 473 | let cell = cell_mut(page, cell); |
| 647 | super::outline::node(&server.durable, sid, other_text)["kind"]["text"], | 474 | let text = cell.paragraphs[0].text_mut().unwrap(); |
| 648 | "Remote Other cell" | 475 | text.text = Paragraph::new( |
| 476 | "Local replacement".into(), | ||
| 477 | text.text.format_at(0).unwrap().clone(), | ||
| 649 | ); | 478 | ); |
| 650 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | 479 | }) |
| 651 | let output = std::path::PathBuf::from(output) | 480 | .unwrap(); |
| 652 | .join(if move_out { "cell-move" } else { "cell-delete" }) | 481 | drop(cache); |
| 653 | .join("candidate"); | 482 | let cache = Replica::open(&path).unwrap(); |
| 654 | std::fs::create_dir_all(&output).unwrap(); | 483 | published(&cache, &mut server, edit); |
| 655 | std::fs::write(output.join("synthetic.one"), &server.durable).unwrap(); | 484 | let durable = page_of(&server.durable, space); |
| 656 | } | 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(); | ||
| 657 | } | 505 | } |
| 658 | } | 506 | } |
| 659 | } | 507 | } |
| 660 | } | 508 | } |
| 661 | 509 | ||
| 510 | /// The move or deletion each native fixture page is reconciled against. | ||
| 511 | fn 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 | |||
| 662 | #[test] | 543 | #[test] |
| 663 | fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { | 544 | fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_content() { |
| 664 | let source = include_bytes!("../../../../corpus/outline-edit/before/notebook/synthetic.one"); | 545 | let mut server = Server::new(NATIVE); |
| 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]; | ||
| 671 | let mut records = Vec::new(); | 546 | let mut records = Vec::new(); |
| 672 | for (sid, page) in document.pages().unwrap() { | 547 | for (name, space, outline, target, dependent) in native_pages(BEFORE) { |
| 673 | let space = &document.spaces[&sid]; | 548 | let page = page_of(BEFORE, space); |
| 674 | let view = &space.revisions[&space.contexts[&ExGuid::default()]]; | 549 | let paragraphs = &outline_of(&page, outline).paragraphs; |
| 675 | let Kind::Metadata { | 550 | let target = ( |
| 676 | title: Some(name), .. | 551 | paragraphs |
| 677 | } = &view.nodes[&view.roots[&2]].kind | 552 | .iter() |
| 678 | else { | 553 | .find(|p| p.text().is_some_and(|text| text.id == target)) |
| 679 | continue; | 554 | .unwrap() |
| 680 | }; | 555 | .id, |
| 681 | if name == "Unicode rich text" { | 556 | target, |
| 682 | continue; | 557 | ); |
| 683 | } | 558 | let anchor = paragraphs |
| 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 | ||
| 708 | .iter() | 559 | .iter() |
| 709 | .find(|(text, _)| text.starts_with("Target ")) | 560 | .find(|p| { |
| 710 | .unwrap() | 561 | p.text() |
| 711 | .1; | 562 | .is_some_and(|text| text.text.text().starts_with("Anchor")) |
| 712 | let (intent, conflict) = match name.as_str() { | 563 | }) |
| 713 | "Move leaf down" | "Move subtree down" => { | 564 | .map(|paragraph| paragraph.id); |
| 714 | (TreeEdit::move_to(target, outline, None, "Offline"), None) | 565 | let change = native_change(&name, outline, target, anchor); |
| 715 | } | 566 | let save_both = |page: &mut Page| { |
| 716 | "Move subtree up" => ( | 567 | change(page); |
| 717 | TreeEdit::move_to(target, outline, Some(paragraphs["Anchor"]), "Offline"), | 568 | replace_text(page, dependent, 0..0, "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}"), | ||
| 741 | }; | 569 | }; |
| 742 | let intent = intent.unwrap(); | ||
| 743 | let directory = tempfile::tempdir().unwrap(); | 570 | let directory = tempfile::tempdir().unwrap(); |
| 744 | let path = directory.path().join("native.sqlite"); | 571 | let path = directory.path().join("native.sqlite"); |
| 745 | let cache = Replica::create(&path, source).unwrap(); | 572 | let cache = Replica::create(&path, BEFORE).unwrap(); |
| 746 | let id = cache.tree(source, sid, &intent).unwrap().unwrap(); | 573 | let id = save(&cache, dependent, save_both).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(); | ||
| 757 | let local = cache.snapshot().unwrap(); | 574 | let local = cache.snapshot().unwrap(); |
| 758 | let queue = cache.pending().unwrap(); | 575 | let queue = cache.pending().unwrap(); |
| 759 | let old_publications = server.publications; | 576 | let merged = match cache.sync_once(&mut server).unwrap().unwrap() { |
| 760 | let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap(); | 577 | (actual, EditStatus::Published { .. }) => { |
| 761 | assert_eq!(actual, id); | 578 | assert_eq!(actual, id, "{name}"); |
| 762 | if let Some(kind) = conflict { | 579 | true |
| 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; | ||
| 779 | } | 580 | } |
| 780 | counts[1] += 1; | 581 | result => { |
| 781 | cache | 582 | assert_eq!( |
| 782 | .rebase_tree_conflict(id, &local, &server.visible) | 583 | result, |
| 783 | .unwrap(); | 584 | (id, EditStatus::Conflict(ConflictKind::ContentChanged)), |
| 784 | assert_eq!(cache.pending().unwrap()[1], queue[1]); | 585 | "{name}" |
| 785 | for expected in [id, dependent] { | ||
| 786 | assert!( | ||
| 787 | matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == expected) | ||
| 788 | ); | 586 | ); |
| 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 | ||
| 789 | } | 594 | } |
| 790 | } else { | 595 | }; |
| 791 | counts[0] += 1; | 596 | let durable = page_of(&server.durable, space); |
| 792 | assert!(matches!(status, EditStatus::Published { .. }), "{name}"); | 597 | assert!( |
| 793 | assert_eq!(server.publications, old_publications); | 598 | text_of(&durable, dependent).starts_with("Offline "), |
| 794 | assert!( | 599 | "{name} keeps its dependent text" |
| 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 | ) | ||
| 807 | ); | 600 | ); |
| 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" })); | ||
| 809 | } | 605 | } |
| 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:?}"); | ||
| 811 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { | 611 | if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_TREE_OUTPUT") { |
| 812 | let output = std::path::PathBuf::from(output); | 612 | let output = std::path::PathBuf::from(output); |
| 813 | std::fs::create_dir_all(output.join("candidate")).unwrap(); | 613 | 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Replica}; | ||
| 5 | use onestore::{ | ||
| 6 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 7 | document::{Document, Format}, | ||
| 8 | page::{Page, PageObject}, | ||
| 9 | }; | ||
| 10 | |||
| 11 | #[path = "support/server.rs"] | ||
| 12 | mod server; | ||
| 13 | use server::{Fault, Server}; | ||
| 14 | #[path = "support/model_ops.rs"] | ||
| 15 | mod model_ops; | ||
| 16 | use model_ops::{ | ||
| 17 | AUTHOR, insert_after, insert_outline, page_of, paragraph_with, replace_text, restyle, review, | ||
| 18 | save, | ||
| 19 | }; | ||
| 20 | |||
| 21 | const OUTLINES: &[u8] = | ||
| 22 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 23 | const PAGE: &str = "Move subtree down"; | ||
| 24 | |||
| 25 | fn 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. | ||
| 41 | fn 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 | |||
| 55 | fn 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. | ||
| 66 | fn 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. | ||
| 79 | fn 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 | |||
| 89 | fn cache(directory: &tempfile::TempDir) -> Replica { | ||
| 90 | Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap() | ||
| 91 | } | ||
| 92 | |||
| 93 | #[test] | ||
| 94 | fn 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] | ||
| 119 | fn 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] | ||
| 149 | fn 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] | ||
| 201 | fn 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] | ||
| 243 | fn 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] | ||
| 325 | fn 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] | ||
| 374 | fn 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] | ||
| 419 | fn 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] | ||
| 452 | fn 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] | ||
| 493 | fn 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Replica}; | ||
| 5 | use onestore::{ExGuid, PreparedEdit, RevisionIndex, Store, document::Document}; | ||
| 6 | |||
| 7 | #[path = "support/server.rs"] | ||
| 8 | mod server; | ||
| 9 | use server::*; | ||
| 10 | #[path = "support/model_ops.rs"] | ||
| 11 | mod model_ops; | ||
| 12 | |||
| 13 | use notebook::{Operation, PageIntent}; | ||
| 14 | use onestore::page::{Page, PageObject}; | ||
| 15 | |||
| 16 | const OUTLINES: &[u8] = | ||
| 17 | include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 18 | |||
| 19 | fn 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 | |||
| 26 | fn 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. | ||
| 42 | fn 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 | |||
| 62 | fn 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] | ||
| 75 | fn 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] | ||
| 116 | fn 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] | ||
| 159 | fn 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] | ||
| 195 | fn 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] | ||
| 238 | fn 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] | ||
| 260 | fn 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`. | ||
| 279 | fn 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 | |||
| 298 | fn 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 | |||
| 314 | fn 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] | ||
| 325 | fn 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] | ||
| 351 | fn 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] | ||
| 385 | fn 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] | ||
| 440 | fn 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] | ||
| 467 | fn 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Replica}; | ||
| 5 | use onestore::{ | ||
| 6 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 7 | document::Document, | ||
| 8 | page::{Outline, Page, PageObject, PageParagraph}, | ||
| 9 | }; | ||
| 10 | |||
| 11 | #[path = "support/server.rs"] | ||
| 12 | mod server; | ||
| 13 | use server::{Fault, Server}; | ||
| 14 | #[path = "support/model_ops.rs"] | ||
| 15 | mod model_ops; | ||
| 16 | use 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"] | ||
| 21 | mod model_schedule; | ||
| 22 | #[path = "sync/tree.rs"] | ||
| 23 | mod tree; | ||
| 24 | |||
| 25 | const BEFORE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"); | ||
| 26 | const NATIVE: &[u8] = include_bytes!("../../../corpus/outline-edit/after/notebook/synthetic.one"); | ||
| 27 | |||
| 28 | /// One model change, boxed so alternative cases can be listed together. | ||
| 29 | pub(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. | ||
| 32 | pub(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 | |||
| 44 | pub(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 | |||
| 54 | pub(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 | |||
| 61 | pub(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 | |||
| 68 | pub(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`. | ||
| 77 | pub(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 | |||
| 88 | pub(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. | ||
| 98 | pub(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 | |||
| 107 | pub(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 | |||
| 113 | fn 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 | |||
| 120 | fn 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] | ||
| 128 | fn 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] | ||
| 195 | fn 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] | ||
| 234 | fn 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] | ||
| 307 | fn 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] | ||
| 331 | fn 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] | ||
| 384 | fn 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. | ||
| 427 | fn 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. | ||
| 452 | pub(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] | ||
| 484 | fn 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] | ||
| 545 | fn 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; | ||
| 5 | use onestore::{ | ||
| 6 | ExGuid, PageCreation, RevisionIndex, Store, | ||
| 7 | document::{Document, Kind}, | ||
| 8 | }; | ||
| 9 | use std::collections::BTreeMap; | ||
| 10 | |||
| 11 | #[path = "support/server.rs"] | ||
| 12 | mod server; | ||
| 13 | use server::*; | ||
| 14 | #[path = "../../onestore/tests/support/disk.rs"] | ||
| 15 | mod disk; | ||
| 16 | #[path = "support/model_ops.rs"] | ||
| 17 | mod model_ops; | ||
| 18 | #[path = "support/page_schedule.rs"] | ||
| 19 | mod page_schedule; | ||
| 20 | use page_schedule::body_outline; | ||
| 21 | |||
| 22 | #[test] | ||
| 23 | fn 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] | ||
| 43 | fn 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] | ||
| 118 | fn 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] | ||
| 173 | fn 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] | ||
| 234 | fn 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica}; | ||
| 5 | use 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 | }; | ||
| 13 | use std::collections::BTreeMap; | ||
| 14 | |||
| 15 | #[path = "support/server.rs"] | ||
| 16 | mod server; | ||
| 17 | use server::*; | ||
| 18 | #[path = "support/model_ops.rs"] | ||
| 19 | mod model_ops; | ||
| 20 | |||
| 21 | const SOURCE: &[u8] = | ||
| 22 | include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one"); | ||
| 23 | |||
| 24 | fn 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 | |||
| 44 | fn 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. | ||
| 62 | fn 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] | ||
| 110 | fn 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] | ||
| 161 | fn 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] | ||
| 186 | fn 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] | ||
| 243 | fn 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] | ||
| 274 | fn 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] | ||
| 325 | fn 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] | ||
| 381 | fn 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] | ||
| 411 | fn 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] | ||
| 434 | fn 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] | ||
| 557 | fn 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 | |||
| 4 | use notebook::{ConflictKind, EditStatus, Replica}; | ||
| 5 | use onestore::{ | ||
| 6 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 7 | document::Document, | ||
| 8 | page::{Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, | ||
| 9 | }; | ||
| 10 | use std::path::Path; | ||
| 11 | |||
| 12 | #[path = "support/server.rs"] | ||
| 13 | mod server; | ||
| 14 | use server::{Fault, Server}; | ||
| 15 | #[path = "support/model_ops.rs"] | ||
| 16 | mod model_ops; | ||
| 17 | use model_ops::{AUTHOR, outlines_mut, page_of, replace_text}; | ||
| 18 | |||
| 19 | const BEFORE: &[u8] = | ||
| 20 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/before/notebook/synthetic.one"); | ||
| 21 | const KEYBOARD: &[u8] = | ||
| 22 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/keyboard/notebook/synthetic.one"); | ||
| 23 | const REPLACED: &[u8] = | ||
| 24 | include_bytes!("../../../corpus/paragraph-edit/reconciliation/remote/notebook/synthetic.one"); | ||
| 25 | const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/paragraph-edit"); | ||
| 26 | |||
| 27 | fn 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. | ||
| 33 | fn 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 | |||
| 55 | fn 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. | ||
| 70 | fn 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. | ||
| 98 | fn 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. | ||
| 124 | fn 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 | |||
| 134 | fn 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. | ||
| 142 | fn 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 | |||
| 160 | fn 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. | ||
| 172 | fn 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. | ||
| 181 | fn 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. | ||
| 188 | fn 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. | ||
| 200 | fn 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. | ||
| 219 | fn 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] | ||
| 305 | fn 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] | ||
| 339 | fn 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. | ||
| 375 | fn 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. | ||
| 392 | fn 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. | ||
| 398 | fn 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. | ||
| 540 | fn 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. | ||
| 549 | fn 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] | ||
| 560 | fn 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] | ||
| 595 | fn 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] | ||
| 617 | fn 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] | ||
| 646 | fn 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] | ||
| 697 | fn 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] | ||
| 780 | fn 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] | ||
| 830 | fn 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"] | ||
| 836 | fn 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"] | ||
| 854 | fn 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 | |||
| 3 | use notebook::{ConflictKind, EditStatus, Error, Remote, Replica}; | ||
| 4 | use onestore::{ | ||
| 5 | CommitError, CommitState, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, | ||
| 6 | }; | ||
| 7 | use std::io; | ||
| 8 | |||
| 9 | #[path = "support/server.rs"] | ||
| 10 | mod server; | ||
| 11 | use server::*; | ||
| 12 | #[path = "support/model_ops.rs"] | ||
| 13 | mod model_ops; | ||
| 14 | |||
| 15 | use std::{ | ||
| 16 | ops::Range, | ||
| 17 | sync::{Arc, Mutex, mpsc}, | ||
| 18 | time::{Duration, Instant}, | ||
| 19 | }; | ||
| 20 | |||
| 21 | #[derive(Clone)] | ||
| 22 | struct Shared(Arc<Mutex<Server>>); | ||
| 23 | |||
| 24 | impl 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`. | ||
| 37 | fn 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. | ||
| 45 | fn 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 | |||
| 56 | fn 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] | ||
| 63 | fn 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] | ||
| 151 | fn 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] | ||
| 228 | fn 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] | ||
| 317 | fn 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] | ||
| 350 | fn 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] | ||
| 414 | fn 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] | ||
| 436 | fn 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] | ||
| 475 | fn 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] | ||
| 515 | fn 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] | ||
| 562 | fn 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] | ||
| 629 | fn 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 | ... | @@ -95,13 +95,6 @@ test = false |
| 95 | doc = false | 95 | doc = false |
| 96 | bench = false | 96 | bench = false |
| 97 | 97 | ||
| 98 | [[bin]] | ||
| 99 | name = "offline_paragraph" | ||
| 100 | path = "fuzz_targets/offline_paragraph.rs" | ||
| 101 | test = false | ||
| 102 | doc = false | ||
| 103 | bench = false | ||
| 104 | |||
| 105 | [[bin]] | 98 | [[bin]] |
| 106 | name = "tree" | 99 | name = "tree" |
| 107 | path = "fuzz_targets/tree.rs" | 100 | path = "fuzz_targets/tree.rs" |
| ... | @@ -110,22 +103,22 @@ doc = false | ... | @@ -110,22 +103,22 @@ doc = false |
| 110 | bench = false | 103 | bench = false |
| 111 | 104 | ||
| 112 | [[bin]] | 105 | [[bin]] |
| 113 | name = "offline_tree" | 106 | name = "page" |
| 114 | path = "fuzz_targets/offline_tree.rs" | 107 | path = "fuzz_targets/page.rs" |
| 115 | test = false | 108 | test = false |
| 116 | doc = false | 109 | doc = false |
| 117 | bench = false | 110 | bench = false |
| 118 | 111 | ||
| 119 | [[bin]] | 112 | [[bin]] |
| 120 | name = "page" | 113 | name = "offline_page" |
| 121 | path = "fuzz_targets/page.rs" | 114 | path = "fuzz_targets/offline_page.rs" |
| 122 | test = false | 115 | test = false |
| 123 | doc = false | 116 | doc = false |
| 124 | bench = false | 117 | bench = false |
| 125 | 118 | ||
| 126 | [[bin]] | 119 | [[bin]] |
| 127 | name = "offline_page" | 120 | name = "offline_model" |
| 128 | path = "fuzz_targets/offline_page.rs" | 121 | path = "fuzz_targets/offline_model.rs" |
| 129 | test = false | 122 | test = false |
| 130 | doc = false | 123 | doc = false |
| 131 | bench = false | 124 | bench = false |
fuzz/fuzz_targets/offline_model.rs created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | #![no_main] | ||
| 2 | use libfuzzer_sys::fuzz_target; | ||
| 3 | |||
| 4 | #[path = "../../crates/notebook/tests/support/model_ops.rs"] | ||
| 5 | mod model_ops; | ||
| 6 | #[path = "../../crates/notebook/tests/support/server.rs"] | ||
| 7 | mod server; | ||
| 8 | #[path = "../../crates/notebook/tests/support/model_schedule.rs"] | ||
| 9 | mod model_schedule; | ||
| 10 | |||
| 11 | fuzz_target!(|input: &[u8]| model_schedule::run(input)); | ||
fuzz/fuzz_targets/offline_paragraph.rs deleted-235| ... | @@ -1,235 +0,0 @@ | ||
| 1 | #![no_main] | ||
| 2 | use libfuzzer_sys::fuzz_target; | ||
| 3 | use onestore::{ | ||
| 4 | CommitError, ExGuid, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit, RevisionIndex, | ||
| 5 | Store, | ||
| 6 | document::{Document, Kind}, | ||
| 7 | }; | ||
| 8 | use notebook::{EditStatus, Operation, Remote, Replica}; | ||
| 9 | use std::{io, sync::LazyLock}; | ||
| 10 | |||
| 11 | #[path = "../../crates/onestore/tests/support/disk.rs"] | ||
| 12 | mod disk; | ||
| 13 | |||
| 14 | const TEXT: &str = "a🦀 e\u{301} 東京\rEnd"; | ||
| 15 | |||
| 16 | static 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 | |||
| 32 | struct Server(disk::Disk); | ||
| 33 | |||
| 34 | impl 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 | |||
| 46 | fn 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 | |||
| 76 | fuzz_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] | ||
| 2 | use libfuzzer_sys::fuzz_target; | ||
| 3 | |||
| 4 | #[path = "../../crates/onestore/tests/support/disk.rs"] | ||
| 5 | mod disk; | ||
| 6 | #[path = "../../crates/notebook/tests/support/tree_schedule.rs"] | ||
| 7 | mod tree_schedule; | ||
| 8 | |||
| 9 | fuzz_target!(|input: &[u8]| tree_schedule::run(input)); | ||
tools/test_offline_document_history.py-54| ... | @@ -1,7 +1,5 @@ | ... | @@ -1,7 +1,5 @@ |
| 1 | import copy | 1 | import copy |
| 2 | import json | 2 | import json |
| 3 | from pathlib import Path | ||
| 4 | import subprocess | ||
| 5 | import unittest | 3 | import unittest |
| 6 | import uuid | 4 | import uuid |
| 7 | from unittest.mock import patch | 5 | from unittest.mock import patch |
| ... | @@ -140,58 +138,6 @@ class DocumentHistoryTests(unittest.TestCase): | ... | @@ -140,58 +138,6 @@ class DocumentHistoryTests(unittest.TestCase): |
| 140 | with self.subTest(mutation=mutation), self.assertRaises(AssertionError): | 138 | with self.subTest(mutation=mutation), self.assertRaises(AssertionError): |
| 141 | document_history(logs, 2) | 139 | document_history(logs, 2) |
| 142 | 140 | ||
| 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 | |||
| 195 | def test_boundary_histories_retain_every_intermediate_graph_and_retired_identity(self): | 141 | def test_boundary_histories_retain_every_intermediate_graph_and_retired_identity(self): |
| 196 | baseline = history(boundaries=True) | 142 | baseline = history(boundaries=True) |
| 197 | documents = document_history(baseline, 2) | 143 | documents = document_history(baseline, 2) |