authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-26 10:05:26-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log4292e4deb721a9526b18e9d9385d225c033b245c
tree9f5b4fa9e49dc1994ada3a0db13e3e550ef05955
parent216e64b3c750a6df75ac13a7f3779327f01cbe9e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(data): the notebook queues ops and publishes sealed transactions

Schema 15 queues ops with a sealed transaction, keeps the section image in 16 KiB chunks and payloads by hash, and runs the cache in WAL with full sync. A keystroke on a 3000-paragraph page writes ~19 KB to the cache instead of ~13 MB; publishing reads 2 KiB of the section instead of three whole copies, and idle polls read the header only. A moved remote is read once and the queue replays over it, merging per object; keep-mine restores a remotely deleted page. Schema-14 caches convert once behind a recovery archive and roll back on any mismatch. The cache lab tools read the new schema. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

54 files changed, 6610 insertions(+), 4329 deletions(-)

crates/notebook/Cargo.toml+1
...@@ -21,6 +21,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true...@@ -21,6 +21,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true
21zeroize = { version = "1.9.0", optional = true }21zeroize = { version = "1.9.0", optional = true }
2222
23[dev-dependencies]23[dev-dependencies]
24libc = "0.2"
24serde_json = "1"25serde_json = "1"
25tempfile = "3"26tempfile = "3"
2627
crates/notebook/README.md+91-194
...@@ -2,184 +2,97 @@...@@ -2,184 +2,97 @@
22
3The application-facing crate: notebook discovery, a durable local replica with3The application-facing crate: notebook discovery, a durable local replica with
4reconnect reconciliation, external-asset caching, recovery export and optional4reconnect reconciliation, external-asset caching, recovery export and optional
5embedded SMB access. The replica stores the last observed remote image of one section,5embedded SMB access. A replica keeps one section's queue in a local SQLite database:
6the working image as its difference from that, and the editor's intents in a6the image the queued edits apply to (the base, in 16 KiB chunks), the edits as
7local SQLite database; a save writes the revision it appended, not the section. `sync_once` provides a7`onestore::op` ops in publication batches, and picture and attachment bytes once
8reconciliation step and `start_sync` owns automatic polling and reconnects. Local8each by hash. Local success does not acknowledge publication to a shared notebook.
9success does not acknowledge publication to a shared notebook.9
1010```text
11## Saving pages11editor ── Edit ──► section thread ─ Section::apply, INSERT edit ─┐
1212 (the parsed section: base + sealed + open) │ one fsync per burst
13The editor works on `onestore::page::Page` values and saves whole pages. `save`13sync thread: remote.stamp() == base ? publish(sealed batch) ──────┤ base chunks += transaction
14diffs the supplied model against the page stored in the supplied local snapshot,14 otherwise read once, replay the queue on it ─────────┘ conflict, or base := remote
15writes the difference into the working image and queues one15```
16`Operation::Page(PageIntent { before, after, author })`; `before` is the page the16
17edit started from and is the precondition reconciliation checks. Text, styles,17## Edits
18paragraph formatting, hyperlinks (external and internal), bullets, numbering, note tags, table rows, columns, cell shading, nested tables, pictures (insertion in paragraphs or on the page, position, size, description), attachments, ink strokes, equations, paragraph structure,18
19outline layout, insertions and deletions are all differences between `before` and19`Replica::apply(author, edit)` applies an `onestore::op::Edit` to the section the
20`after`; the library never sees editor operations.20queue leaves and returns its id once written; a refused edit returns `Rejected`
21and changes nothing. `page(space)` and `pages()` read what the queue leaves, for
22opening and reloading. Edits collect in an open batch until the sync thread seals
23it, so edits arriving while a publication is in flight publish together.
2124
22```no_run25```no_run
23use onestore::{ExGuid, page::Page};26use onestore::{ExGuid, op::{Edit, Op, PageOp}};
24use notebook::Replica;27use notebook::Replica;
25# fn example(path: &std::path::Path, source: &[u8], space: ExGuid, edited: &Page)28# fn example(path: &std::path::Path, source: &[u8], space: ExGuid, text: ExGuid)
26# -> Result<(), Box<dyn std::error::Error>> {29# -> Result<(), Box<dyn std::error::Error>> {
27// `space` identifies the page; `edited` is the editor's current model of it.
28let cache = Replica::create(path, source)?;30let cache = Replica::create(path, source)?;
29let snapshot = cache.snapshot()?;31let typed = PageOp::Text { text, range: 0..0, with: "Hello ".into() };
30let local_id = cache.save(&snapshot, space, edited, "Author")?;32let id = cache.apply("Author", Edit { at: 133_000_000_000_000_000, ops: vec![Op::Page { space, op: typed }] })?;
31drop(cache);33drop(cache);
3234
33let reopened = Replica::open(path)?;35let reopened = Replica::open(path)?;
34let pending = reopened.pending()?;36assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id));
35assert_eq!(pending.last().map(|edit| edit.id), local_id);
36# Ok(())37# Ok(())
37# }38# }
38```39```
3940
40Saves coalesce the way OneNote's own autosave does: while the newest queued intent41Share one `Replica` between threads. Keep the cache on a local filesystem: the
41for the same page has not been attempted, a later save replaces its `after` model42connection holds exclusive ownership between transactions, and a second open fails
42under the same ID. Once an intent has been attempted or holds a conflict it is43busy. No network wait occurs in a local edit. After a database error the section
43never rewritten, nor is the intent a synchronization step has selected for44thread rereads the queue.
44publication; the next save then queues a new intent. A save whose model equals
45the stored page returns `None`. `PageIntent::text_change` describes an intent that
46changes exactly one paragraph's text as the text object, its previous text, the
47replaced UTF-16 range and the replacement.
48
49Share one `Replica` between application threads. Each save compares its supplied
50snapshot under the cache transaction; stale snapshots return `Io(ResourceBusy)`.
51The intent and its resulting image commit together. Keep the cache on a local
52filesystem: the connection holds exclusive ownership between transactions, and a
53second open fails busy. No network wait occurs in a local save. After a database
54error, reopen and inspect the durable state before retrying.
5545
56## Sessions46## Sessions
5747
58`session::Notebook::open(root, cache_dir)` discovers a notebook directory and48`session::Notebook::open(root, cache_dir)` discovers a notebook directory and
59`session::Section::open(file, cache_dir, notify)` opens one section file through49`session::Section::open(file, cache_dir, notify)` opens one section file through
60a replica stored under the cache directory, named by the section's document50a replica named by the section's document identity, so the same file reopens the
61identity so the same file reopens the same queue after a relaunch. A section51same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`
62publishes in the background to the file itself under OneNote-compatible52returns at once and reports a refusal as `Event::Rejected`; `events()` drains
63exclusion. `pages()` lists page spaces and titles from the local image,53remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
64`page(space)` returns the model to edit (its `identity` and the section's54failures; `notify` runs on a background thread whenever an event waits.
65`identity()` feed `onestore::page::link::internal_link`), and `save(space, before, after,55`conflict()`, `remote_page(space)` and `resolve(id, Resolution)` present and end a
66author)` queues the edited model: `Save::Queued(id)` is durable locally,56conflict; `release(id, archive, Resolution)` ends an uncertain attempt.
67`Save::Unchanged` means the model equals the stored page, and `Save::Stale`57`import_page` creates a page holding a copy of another; `delete_pages` removes pages.
68means the stored page no longer matches `before` because the section changed58
69underneath the editor, so the page must be reloaded before saving again.59Until the application emits ops, `save`, `queue_save`, `saved`, `saving`,
70`queue_save(space, before, after, author)` does the same on the section's save60`conflicts`, `queue`, `review` and `Event::Refreshed` keep the whole-page API: a
71thread and returns at once, so an editor's frame never waits on the write;61save lowers the page it is given against the stored page (`onestore::op::lower_page`)
72`saved()` drains the outcomes. A queued save whose `before` is the previous62and applies the ops.
73one's `after` continues it, needing no reload in between, and a run of such63
74saves waiting together is written once.64`Section::resume(file, replica, notify)` starts from an owned `Replica` without
75`events()` drains what the synchronization thread reported since the last65consulting the remote file; with the `smb` feature, `Section::resume_smb(path,
76poll: refreshes, attempt outcomes and unreachable files; `notify` runs66replica, limit, connect, notify)` binds a share-relative path, `connect` running on
77whenever an event or save outcome is available so the application can wake its67the worker again after transport failure.
78event loop. `close()` finishes queued saves and stops publication.
79
80`Event::Unreachable` retains an `io::Error`: callers can distinguish permission
81denial, missing targets, timeouts, and connection failures through `kind()` without
82parsing display text. Document/cache failures are reported as `Event::Failed` and
83stop the worker. Durable publication outcomes remain available through `status`.
84
85`Section::resume(file, replica, notify)` starts a session from an owned
86`Replica::open(cache_file)` without consulting the remote file. The caller retains
87the cache location and publication path for offline relaunch. Local pages and
88saves remain available while the target is absent; synchronization verifies the
89document identity before adopting or publishing remote content. A different
90document stops the worker and retains the pending local edits. `Section::open`
91remains the online convenience constructor that discovers the identity from the
92file and creates or reopens its cache.
93
94With the `smb` feature, `Section::resume_smb(path, replica, limit, connect,
95notify)` binds the same session operations to a share-relative path. `connect`
96returns a new SMB client on the synchronization worker and is called again after
97transport failure. The caller supplies credentials there, outside the replica
98schema; construction and local saving do not wait for a network connection.
99
100## Pages of a section
101
102`create_page` accepts the core `PageCreation` intent and queues its page space
103and section entry as one publication. Subsequent saves of the new page use the
104intent's stable identities immediately after local acknowledgement. Independent
105page additions rebase against the current section order; duplicate titles remain
106distinct. An unavailable or no-longer-leading insertion anchor produces
107`StructureChanged`. `rebase_page_creation_conflict(id, local, remote, before)`
108reviews a replacement anchor against both cache images while retaining the new
109page and dependent object identities. Existing page identities require
110reconciliation; a matching page alone does not establish a receipt.
111
112`pages(snapshot, edits)` queues a slice of core `PageEdit` intents as one atomic
113publication. `PagePosition::Keep` preserves remote movement during indentation-only
114edits. Every requested level remains explicit: a competing remote level produces
115`StructureChanged` unless it already matches the requested level. Moves compare
116the selected page's position relative to surviving observed pages; an unchanged
117or already-satisfied position can proceed, and new remote pages remain present.
118Indistinguishable competing moves retain a conflict and the complete local image.
119`rebase_pages_conflict(id, local, remote, edits)` reviews the batch against both
120cache images. Use `PageEdit::reposition(position, level)` on the retained intents
121to revise anchors or indentation; replacing page or allocated series identities
122is rejected. Dependent edits remain queued. Attempt evidence includes every
123changed space plus the receipt space when that space is unchanged. An existing
124section revision alone cannot confirm a page edit. The
125[offline page corpus](../../corpus/page-lifecycle/offline-edits/README.md)
126contains native comparisons, reproduction commands and stateful sanitizer seeds.
127
128`delete_pages(snapshot, pages)` queues the permanent removal of page spaces;
129a page already absent remotely produces `TargetUnavailable`.
13068
131## Reconciliation69## Reconciliation
13270
133`sync_once(&mut remote)` processes the oldest pending intent through a `Remote`71`sync_once(&mut remote)` returns what one step did as `Synced { edit, changed }`:
134implementation, returning its ID and `EditStatus`. When the remote page still72the state the step's batch reached, named by its newest edit, and the pages a remote
135equals `before`, the intent publishes as prepared. When the remote page changed,73change replaced. While the remote's stamp (header and length) equals the base's, the
136a three-way merge keeps everything the remote changed and re-applies the local74oldest sealed batch publishes as its `Transaction`, and the base's chunks take its
137changes wherever the two sides touched different objects, fields or text ranges:75writes; nothing is read. When the stamp moved, the remote image is read once and the
138a local text edit merges with a remote edit elsewhere in the same paragraph, an76queue replays on it (`merge.rs`): an op whose objects the remote left alone applies
139outline move merges with a remote text change, a new paragraph survives a remote77as it is; a text op shifts past the remote's changes to the same text when its
140deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with78range stays clear of them; a move, deletion or property the remote already made is
141the complete local image and the last observed remote image returned by79done; a page the remote already holds as the local edits leave it drops their ops.
142`remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a80Anything else is a `Conflict` on the batch: the queue stays on its base and the
143page the writer can no longer express retains `UnsupportedEdit`.81remote image is kept for review. `resolve(id, Mine)` rewrites the remote page to the
14482local one (`lower_page`, once), `Theirs` drops the local ops on that page; either
145Publication attempts are recorded before network I/O. A retained attempt83applies from the conflicted batch on.
146requires its recorded revision to remain present in the remote, or the remote84
147page to equal the intent's `after` model exactly, followed by comparison,85Publication attempts are recorded before network I/O. An attempt confirms only
148flushing and refreshed header version metadata before acknowledgement. Otherwise86when the remote holds its revisions, or every page it changed as it changed them,
149the attempt remains `AwaitingConfirmation`; an uncertain publication is never87followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never
150replayed. A durable `Published` receipt survives reopening. Transport errors88replayed. `release(id, archive, Resolution)` exports the queue first, then
151return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error89publishes the batch again (`Mine`) or abandons every unpublished edit (`Theirs`).
152to distinguish a retained attempt from a pending edit or receipt. The error return90A durable `Published` receipt survives reopening.
153does not roll back a locally acknowledged intent.91
15492A schema-14 cache is converted when opened: it is exported to
155The current operation processes one queue head; while edits remain, `snapshot`93`<cache>.v14-recovery`, each queued page is lowered against the page the conversion
156preserves the complete local working image. An empty queue can refresh from the94has so far, and every converted page must equal the page in the old working image,
157remote image. Synchronization holds a separate owner lock, so local saves can95or the conversion rolls back and the open fails naming the archive.
158continue during network waits; competing synchronization calls return `WouldBlock`.
159
160`review_page(id, local, remote, after)` resolves the oldest page conflict with a
161model the user reviewed against the current remote page: the intent's `before`
162becomes that remote page and its `after` the reviewed model, under the same ID,
163in one local transaction. The supplied images must still match `snapshot()` and
164`remote_snapshot()`. Review performs no network I/O, clears the conflict to
165`Pending` and wakes the worker; publication still reads the latest remote image,
166so another overlapping remote edit can produce a new conflict. Pending intents
167and uncertain attempts cannot be reviewed.
168
169An attempt that stays `AwaitingConfirmation` is released by review, never by
170replay: `release_attempt(id, local, remote, archive, after)` on the replica
171(`release` on a session) exports the branch to a new archive, then retires
172the oldest edit's attempt. `Some(page)` continues from a page reviewed
173against the current remote image as a fresh intent under the same id,
174keeping later edits; `None` abandons the local branch, whose ids report
175`EditStatus::Archived { archive }` from then on, and the working image
176returns to the remote image. Neither writes a receipt: the uncertain
177publication may or may not have landed, and the archive is the record of
178what was attempted.
179
180Opening recognizes the application identity and the current schema version only;
181caches and archives written by other versions are refused unchanged. Until the
182application is usable end to end there are no migrations.
18396
184With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an97With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an
185`notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root98`notebook::smb::Client` to one share-relative file and snapshot limit. Remote identity uses the logical root
...@@ -188,18 +101,15 @@ object space, which survives the tested native compaction that replaces the file...@@ -188,18 +101,15 @@ object space, which survives the tested native compaction that replaces the file
188An `Arc<Replica>` can own one background worker. Supply a connection factory, poll101An `Arc<Replica>` can own one background worker. Supply a connection factory, poll
189interval and observer; successful publications drain immediately, durable local102interval and observer; successful publications drain immediately, durable local
190edits wake the worker, and `wake()` requests an immediate reachability retry.103edits wake the worker, and `wake()` requests an immediate reachability retry.
191While nothing is queued, a remote whose `Remote::stamp` (its header and length, read104While nothing is queued, or the queue waits on a remote that has not changed since,
192without coordination) equals the last observed image's is not read again; publishing105a remote whose `Remote::stamp` holds is not read again.
193against an unchanged stamp needs no read either.
194Transport failures discard the old connection and retry through the factory;106Transport failures discard the old connection and retry through the factory;
195Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`107Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
196reuse the connection. Contended `NotCommitted` operations use randomized backoff,108reuse the connection. Contended `NotCommitted` operations use randomized backoff,
197capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications109capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications
198remain coalesced until its end.110remain coalesced until its end.
199`RemoteIo` distinguishes connection/read failures from111`RemoteIo` distinguishes connection/read failures from
200local `Io` errors. Cache/document errors stop the worker. Observers receive every112local `Io` errors. Cache/document errors stop the worker.
201attempt's result on the worker thread, including unchanged conflict/uncertain
202statuses; durable edit state remains available through `status`.
203113
204```no_run114```no_run
205# #[cfg(feature = "smb")]115# #[cfg(feature = "smb")]
...@@ -229,35 +139,25 @@ worker.stop()?;...@@ -229,35 +139,25 @@ worker.stop()?;
229```139```
230140
231`stop()` cancels future steps and joins the worker, returning a fatal cache error141`stop()` cancels future steps and joins the worker, returning a fatal cache error
232or worker panic. Dropping it requests cancellation without waiting. An in-flight142or worker panic. Dropping it requests cancellation without waiting. Remote
233step completes before releasing ownership; a replacement worker cannot start143operations and callbacks must have bounded execution times if shutdown latency
234while the old one still owns the replica. Remote operations and callbacks must144matters. Credentials belong to the factory, not the cache database.
235have bounded execution times if shutdown latency matters. A dropped worker can145
236briefly retain the cache; use `stop()` before requiring an immediate reopen.146Creation refuses existing paths. Opening validates database integrity and replays
237Cancellation and application restart retain pending edits and publication attempts.147the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL
238Credentials belong to the factory, not the cache database.148synchronization and fullfsync, each queried back: every commit is durable, and the only
239149file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint
240Creation refuses existing paths. Opening recognizes the application identity and150and must travel with the cache when it is copied. Recovery archives are single files.
241schema version, validates database integrity and both notebook images, and rejects151The cache contains notebook content.
242unsupported journal modes without converting them. Failed initialization preserves
243the file for inspection. SQLite uses DELETE journaling, EXTRA synchronization and
244fullfsync; each required setting is queried back.
245
246The cache and its pending intents contain notebook content. The core `onestore`
247crate stays independent of SQLite and network runtimes. Device and simulator
248examples compile and link for iOS; recorded process-interruption tests do not
249establish physical power-loss durability. Evidence is tracked in [Milestone 9](../../evidence/MILESTONE9.md).
250152
251The SMB-enabled `smb_offline_client` example is an owned-lab workload for153The SMB-enabled `smb_offline_client` example is an owned-lab workload for
252`tools/native_collaboration.py --offline --embedded-smb`. It separates local154`tools/native_collaboration.py --offline --embedded-smb`; its append-specific
253acknowledgements, remote publication attempts, persisted receipts and cache reopen155conflict policy lives in the client.
254checks. Its append-specific conflict review policy lives in the test client;
255the library continues to preserve conflicts requiring an explicit decision.
256156
257## Recovery archives157## Recovery archives
258158
259`export_recovery(new_path)` captures both complete notebook images, the intent159`export_recovery(new_path)` captures the base and remote images, the edit queue,
260queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one160uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
261SQLite snapshot. It refuses existing destinations and leaves the live queue161SQLite snapshot. It refuses existing destinations and leaves the live queue
262unchanged. Export to a local directory from a background thread: copying holds162unchanged. Export to a local directory from a background thread: copying holds
263the cache mutex while capturing the database. Failure after the final rename can163the cache mutex while capturing the database. Failure after the final rename can
...@@ -448,9 +348,6 @@ physical power-loss durability....@@ -448,9 +348,6 @@ physical power-loss durability.
448## Queue measurement348## Queue measurement
449349
450`cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000`350`cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000`
451measures alternating-page saves that retain separate queue IDs, cache reopen,351measures alternating-page edits, cache reopen, recovery export and one local-file
452recovery export and local-file publication. JSON lines record each acknowledgement352publication of the queue. `keystroke_probe PARAGRAPHS KEYSTROKES` types into a large
453and publication latency plus image/cache sizes. The workload verifies every ID353page and reports the bytes the cache and the section file write per keystroke.
454and both final page titles, then independently reopens the recovery archive.
455Use an external resource monitor for peak memory; these local-file timings do not
456measure SMB or phone performance.
crates/notebook/examples/cache_probe.rs+75-108
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1use notebook::{Operation, Replica};1use notebook::Replica;
2use onestore::op::{Op, PageOp};
2use onestore::{3use onestore::{
3 ExGuid, RevisionIndex, Store,4 ExGuid, RevisionIndex, Store,
4 document::{Document, Kind},5 document::{Document, Kind},
5 page::{Page, PageObject, Paragraph, text::Edit},6 page::{PageObject, Paragraph},
6};7};
7use std::{8use std::{
8 io::{self, BufRead, Write},9 io::{self, BufRead, Write},
...@@ -86,34 +87,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -86,34 +87,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
86 let mut ids = Vec::new();87 let mut ids = Vec::new();
87 let mut font_size = None;88 let mut font_size = None;
88 for pending in cache.pending()? {89 for pending in cache.pending()? {
89 let Operation::Page(intent) = &pending.operation else {90 let [Op::Page { op, .. }] = &pending.edit.ops[..] else {
90 panic!("Unexpected operation for this fixture")91 panic!("Unexpected edit for this fixture")
91 };92 };
92 let operation = match operation_kind.as_str() {93 let operation = match (operation_kind.as_str(), op) {
93 "text" => {94 ("text", PageOp::Text { text, range, with }) => {
94 let (object, before, range, replacement) =95 assert_eq!(*text, target);
95 intent.text_change().expect("one replaced paragraph");96 assert_eq!(*range, 0..u32::try_from(expected.encode_utf16().count())?);
96 assert_eq!(object, target);97 let operation: u64 = with.split_once(':').unwrap().0.parse()?;
97 assert_eq!(before, expected);
98 assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?);
99 let operation: u64 = replacement.split_once(':').unwrap().0.parse()?;
100 expected = payload(operation, size);98 expected = payload(operation, size);
101 assert_eq!(replacement, expected);99 assert_eq!(*with, expected);
102 operation100 operation
103 }101 }
104 "insert" => {102 (
105 let added: Vec<_> = intent103 "insert",
106 .after104 PageOp::Add {
107 .objects105 object: PageObject::Outline(outline),
108 .iter()106 ..
109 .filter(|object| {107 },
110 !intent.before.objects.iter().any(|o| o.id() == object.id())108 ) => {
111 })
112 .collect();
113 // A save that replaced a pending one carries its outlines too.
114 let Some(PageObject::Outline(outline)) = added.last() else {
115 panic!("Expected an added outline")
116 };
117 let text = outline.paragraphs[0].text().unwrap().text.text();109 let text = outline.paragraphs[0].text().unwrap().text.text();
118 let operation: u64 = text.split_once(':').unwrap().0.parse()?;110 let operation: u64 = text.split_once(':').unwrap().0.parse()?;
119 assert_eq!(text, payload(operation, size));111 assert_eq!(text, payload(operation, size));
...@@ -124,33 +116,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -124,33 +116,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124 assert!(revision.nodes.contains_key(&outline.id));116 assert!(revision.nodes.contains_key(&outline.id));
125 operation117 operation
126 }118 }
127 "format" => {119 ("format", PageOp::Format { text, set, .. }) => {
128 let paragraph = intent120 assert_eq!(*text, target);
129 .after121 let [onestore::TextAttribute::FontSize(value)] = set[..] else {
130 .objects122 panic!("Expected a font size")
131 .iter()123 };
132 .find_map(|object| match object {
133 PageObject::Outline(outline) => outline
134 .paragraphs
135 .iter()
136 .find(|p| p.text().is_some_and(|t| t.id == target)),
137 _ => None,
138 })
139 .expect("formatted paragraph");
140 let text = paragraph.text().unwrap();
141 assert_eq!(text.text.text(), expected);
142 let value = text.text.spans()[0].format.font_size.expect("font size");
143 assert!(
144 text.text
145 .spans()
146 .iter()
147 .all(|s| s.format.font_size == Some(value))
148 );
149 assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0);124 assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0);
150 font_size = Some(value);125 font_size = Some(value);
151 value as u64 - 6126 value as u64 - 6
152 }127 }
153 _ => unreachable!(),128 other => panic!("Unexpected op {other:?}"),
154 };129 };
155 assert!(operations.last().is_none_or(|last| *last < operation));130 assert!(operations.last().is_none_or(|last| *last < operation));
156 assert!(ids.last().is_none_or(|last| *last < pending.id));131 assert!(ids.last().is_none_or(|last| *last < pending.id));
...@@ -167,10 +142,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -167,10 +142,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
167 );142 );
168 }143 }
169 if operations.is_empty() {144 if operations.is_empty() {
170 assert!(145 assert!(snapshot == base, "An empty local queue changed its image");
171 snapshot == base,
172 "An empty local queue changed its working image"
173 );
174 }146 }
175 if let Some(output) = args.get(3) {147 if let Some(output) = args.get(3) {
176 std::fs::write(output, &snapshot)?;148 std::fs::write(output, &snapshot)?;
...@@ -197,51 +169,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -197,51 +169,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
197 let replacement = payload(operation, size);169 let replacement = payload(operation, size);
198 println!("editing {operation}");170 println!("editing {operation}");
199 io::stdout().flush()?;171 io::stdout().flush()?;
200 let (space, page) = {172 let end = u32::try_from(text.encode_utf16().count())?;
201 let store = Store::parse(&source)?;173 let op = match operation_kind.as_str() {
202 let index = RevisionIndex::parse(&store)?;174 "text" => PageOp::Text {
203 let document = Document::parse(&index)?;175 text: oid,
204 let mut page = Page::from_space(&document, sid)?;176 range: 0..end,
205 fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph {177 with: replacement.clone(),
206 page.objects178 },
207 .iter_mut()179 "insert" => {
180 let page = cache.page(sid)?;
181 let template = page
182 .objects
183 .iter()
208 .find_map(|object| match object {184 .find_map(|object| match object {
209 PageObject::Outline(outline) => outline185 PageObject::Outline(outline) => outline
210 .paragraphs186 .paragraphs
211 .iter_mut()187 .iter()
212 .find(|p| p.text().is_some_and(|t| t.id == oid)),188 .find(|p| p.text().is_some_and(|t| t.id == oid)),
213 _ => None,189 _ => None,
214 })190 })
215 .expect("target paragraph")191 .expect("target paragraph")
216 }192 .clone();
217 let end = u32::try_from(text.encode_utf16().count())?;193 let mut fresh = template.clone();
218 match operation_kind.as_str() {194 fresh.id = onestore::page::text::new_id()?;
219 "text" => {195 fresh.parent = None;
220 let target = target_paragraph(&mut page, oid).text_mut().unwrap();196 fresh.level = 1;
221 let format = target.text.format_at(0)?.clone();197 fresh.lists.clear();
222 target.text.apply(Edit {198 fresh.tags.clear();
223 range: 0..end,199 fresh.style = None;
224 replacement: Paragraph::new(replacement.clone(), format),200 let format = template.text().unwrap().text.format_at(0)?.clone();
225 })?;201 fresh.content = onestore::page::ParagraphContent::Text(onestore::page::TextObject {
226 }202 id: onestore::page::text::new_id()?,
227 "insert" => {203 date_field: None,
228 let template = target_paragraph(&mut page, oid).clone();204 text: Paragraph::new(replacement.clone(), format),
229 let mut fresh = template.clone();205 tags: Vec::new(),
230 fresh.id = onestore::page::text::new_id()?;206 });
231 fresh.parent = None;207 PageOp::Add {
232 fresh.level = 1;208 object: PageObject::Outline(onestore::page::Outline {
233 fresh.lists.clear();
234 fresh.tags.clear();
235 fresh.style = None;
236 let format = template.text().unwrap().text.format_at(0)?.clone();
237 fresh.content =
238 onestore::page::ParagraphContent::Text(onestore::page::TextObject {
239 id: onestore::page::text::new_id()?,
240 date_field: None,
241 text: Paragraph::new(replacement.clone(), format),
242 tags: Vec::new(),
243 });
244 let outline = onestore::page::Outline {
245 id: onestore::page::text::new_id()?,209 id: onestore::page::text::new_id()?,
246 title: false,210 title: false,
247 min_width: None,211 min_width: None,
...@@ -253,29 +217,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -253,29 +217,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
253 indents: Vec::new(),217 indents: Vec::new(),
254 paragraphs: vec![fresh],218 paragraphs: vec![fresh],
255 unsupported: Vec::new(),219 unsupported: Vec::new(),
256 };220 }),
257 let at = page221 before: page
258 .objects222 .objects
259 .iter()223 .iter()
260 .position(|o| matches!(o, PageObject::Title(_)))224 .find(|o| matches!(o, PageObject::Title(_)))
261 .unwrap_or(page.objects.len());225 .map(PageObject::id),
262 page.objects.insert(at, PageObject::Outline(outline));
263 }226 }
264 "format" => {227 }
265 assert!((1..=124).contains(&operation));228 "format" => {
266 let target = target_paragraph(&mut page, oid).text_mut().unwrap();229 assert!((1..=124).contains(&operation));
267 let mut format = target.text.format_at(0)?.clone();230 PageOp::Format {
268 format.font_size = Some(6.0 + operation as f32);231 text: oid,
269 target.text.apply(Edit {232 range: 0..end,
270 range: 0..end,233 set: vec![onestore::TextAttribute::FontSize(6.0 + operation as f32)],
271 replacement: Paragraph::new(text.clone(), format),234 clear: Vec::new(),
272 })?;
273 }235 }
274 _ => unreachable!(),
275 }236 }
276 (sid, page)237 _ => unreachable!(),
277 };238 };
278 let id = cache.save(&source, space, &page, "Fixture")?.unwrap();239 let id = cache.apply(
240 "Fixture",
241 onestore::op::Edit {
242 at: 133_000_000_000_000_000,
243 ops: vec![Op::Page { space: sid, op }],
244 },
245 )?;
279 if acknowledgement == "unack" {246 if acknowledgement == "unack" {
280 println!("durable {operation} {id}");247 println!("durable {operation} {id}");
281 } else {248 } else {
crates/notebook/examples/keystroke_probe.rs created+260
...@@ -0,0 +1,260 @@
1//! `keystroke_probe PARAGRAPHS KEYSTROKES`: types into the middle paragraph of a page
2//! of PARAGRAPHS paragraphs and reports, per keystroke, the time, the bytes the cache
3//! writes to disk and the bytes the section file sees: queued alone, then each one
4//! published at once, then idle polls. The section file stays in memory so the
5//! process's disk writes (macOS `proc_pid_rusage`) are the cache's.
6
7use notebook::{Remote, Replica};
8use onestore::{
9 CommitError, CommitIo, ExGuid, Stamp, Transaction,
10 document::{Format, Layout},
11 op::{Edit, Op, PageOp, SectionOp},
12 page::{
13 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
14 text::new_id,
15 },
16};
17use std::{
18 io,
19 time::{Duration, Instant},
20};
21
22fn disk_written() -> u64 {
23 // SAFETY: proc_pid_rusage fills the zeroed structure for this process.
24 unsafe {
25 let mut info: libc::rusage_info_v2 = std::mem::zeroed();
26 libc::proc_pid_rusage(
27 libc::getpid(),
28 libc::RUSAGE_INFO_V2,
29 &mut info as *mut _ as *mut _,
30 );
31 info.ri_diskio_byteswritten
32 }
33}
34
35/// The section file in memory, counting what synchronization reads and writes, so the
36/// process's disk writes are the cache's alone.
37struct Memory {
38 bytes: Vec<u8>,
39 read: u64,
40 written: u64,
41}
42
43impl CommitIo for Memory {
44 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
45 let offset = offset as usize;
46 let size = output.len().min(self.bytes.len().saturating_sub(offset));
47 output[..size].copy_from_slice(&self.bytes[offset..offset + size]);
48 self.read += size as u64;
49 Ok(size)
50 }
51 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
52 let offset = offset as usize;
53 self.bytes
54 .resize(self.bytes.len().max(offset + bytes.len()), 0);
55 self.bytes[offset..offset + bytes.len()].copy_from_slice(bytes);
56 self.written += bytes.len() as u64;
57 Ok(bytes.len())
58 }
59 fn flush(&mut self) -> io::Result<()> {
60 Ok(())
61 }
62}
63
64impl Remote for Memory {
65 fn read(&mut self) -> io::Result<Vec<u8>> {
66 self.read += self.bytes.len() as u64;
67 Ok(self.bytes.clone())
68 }
69 fn stamp(&mut self) -> io::Result<Option<Stamp>> {
70 self.read += 1024;
71 Ok(Stamp::of(&self.bytes).ok())
72 }
73 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
74 transaction.commit(self)
75 }
76 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
77 onestore::confirm_snapshot(self, snapshot)
78 }
79}
80
81fn paragraph(i: usize) -> PageParagraph {
82 let words = [
83 "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog",
84 ];
85 let text: Vec<&str> = (0..1 + i % 17).map(|j| words[(i * 7 + j) % 8]).collect();
86 PageParagraph {
87 id: new_id().unwrap(),
88 parent: None,
89 level: 1,
90 style: None,
91 format: Format::default(),
92 content: ParagraphContent::Text(TextObject {
93 id: new_id().unwrap(),
94 date_field: None,
95 text: Paragraph::new(text.join(" "), Format::default()),
96 tags: Vec::new(),
97 }),
98 lists: Vec::new(),
99 tags: Vec::new(),
100 media: Default::default(),
101 collapsed: false,
102 }
103}
104
105fn percentiles(label: &str, mut times: Vec<Duration>, bytes: &[u64]) {
106 times.sort();
107 let at = |q: f64| times[((times.len() - 1) as f64 * q) as usize];
108 let mean = bytes.iter().sum::<u64>() as f64 / bytes.len() as f64;
109 let mut sorted = bytes.to_vec();
110 sorted.sort();
111 println!(
112 "{label}: time p50 {:.2?} p90 {:.2?} max {:.2?}; bytes mean {:.0} p50 {} max {}",
113 at(0.5),
114 at(0.9),
115 at(1.0),
116 mean,
117 sorted[sorted.len() / 2],
118 sorted[sorted.len() - 1]
119 );
120}
121
122fn main() -> Result<(), Box<dyn std::error::Error>> {
123 let mut args = std::env::args().skip(1);
124 let count: usize = args.next().ok_or("PARAGRAPHS")?.parse()?;
125 let keystrokes: usize = args.next().map_or(Ok(200), |n| n.parse())?;
126 let directory = tempfile::tempdir()?;
127 // The probe page, stored in the section file before the cache opens.
128 let source = {
129 let arena = onestore::Arena::default();
130 let mut section =
131 onestore::Section::open(&arena, onestore::create_section("probe.one", "", "Probe")?)?;
132 let creation = onestore::PageCreation::new(None, Some("Probe"), "Probe")?;
133 let page = Page {
134 title: "Probe".into(),
135 identity: None,
136 created: None,
137 margin_origin: [0.0; 2],
138 objects: vec![PageObject::Outline(Outline {
139 id: new_id()?,
140 title: false,
141 min_width: None,
142 layout: Layout {
143 x: Some(36.0),
144 y: Some(86.4),
145 ..Layout::default()
146 },
147 indents: Vec::new(),
148 paragraphs: (0..count).map(paragraph).collect(),
149 unsupported: Vec::new(),
150 })],
151 definitions: Default::default(),
152 };
153 section.apply(
154 "Probe",
155 &Edit {
156 at: 133_000_000_000_000_000,
157 ops: vec![Op::Section(SectionOp::Import { creation, page })],
158 },
159 )?;
160 section.seal()?;
161 section.image()
162 };
163 println!("section {} bytes, {count} paragraphs", source.len());
164 let cache = Replica::create(directory.path().join("cache.sqlite"), &source)?;
165 let (space, _, _) = cache
166 .pages()?
167 .into_iter()
168 .find(|(_, title, _)| title == "Probe")
169 .ok_or("the probe page")?;
170 let target: ExGuid = cache
171 .page(space)?
172 .objects
173 .iter()
174 .find_map(|object| match object {
175 PageObject::Outline(outline) if outline.paragraphs.len() == count => {
176 outline.paragraphs[count / 2].text().map(|text| text.id)
177 }
178 _ => None,
179 })
180 .ok_or("the probe paragraph")?;
181 let mut remote = Memory {
182 bytes: source.clone(),
183 read: 0,
184 written: 0,
185 };
186 let mut at = 0_u32;
187 let mut keystroke = |cache: &Replica| -> Result<(), notebook::Error> {
188 cache.apply(
189 "Probe",
190 Edit {
191 at: 133_000_000_000_000_000,
192 ops: vec![Op::Page {
193 space,
194 op: PageOp::Text {
195 text: target,
196 range: at..at,
197 with: "x".into(),
198 },
199 }],
200 },
201 )?;
202 at += 1;
203 Ok(())
204 };
205
206 let (mut times, mut bytes) = (Vec::new(), Vec::new());
207 for _ in 0..keystrokes {
208 let (disk, start) = (disk_written(), Instant::now());
209 keystroke(&cache)?;
210 times.push(start.elapsed());
211 bytes.push(disk_written() - disk);
212 }
213 percentiles("queued keystroke (apply + SQLite commit)", times, &bytes);
214
215 let (disk, read, written, start) =
216 (disk_written(), remote.read, remote.written, Instant::now());
217 cache.sync_once(&mut remote)?;
218 println!(
219 "publish the {keystrokes} queued keystrokes as one batch: {:.2?}; cache {} B, file writes {} B, file reads {} B",
220 start.elapsed(),
221 disk_written() - disk,
222 remote.written - written,
223 remote.read - read
224 );
225
226 let (mut times, mut cache_bytes, mut file_bytes, mut reads) =
227 (Vec::new(), Vec::new(), Vec::new(), Vec::new());
228 for _ in 0..keystrokes {
229 let (disk, read, written, start) =
230 (disk_written(), remote.read, remote.written, Instant::now());
231 keystroke(&cache)?;
232 cache.sync_once(&mut remote)?;
233 times.push(start.elapsed());
234 file_bytes.push(remote.written - written);
235 cache_bytes.push(disk_written() - disk);
236 reads.push(remote.read - read);
237 }
238 percentiles(
239 "keystroke published at once: cache",
240 times.clone(),
241 &cache_bytes,
242 );
243 percentiles(
244 "keystroke published at once: file writes",
245 times.clone(),
246 &file_bytes,
247 );
248 percentiles("keystroke published at once: file reads", times, &reads);
249
250 let (disk, read) = (disk_written(), remote.read);
251 for _ in 0..100 {
252 assert!(cache.sync_once(&mut remote)?.edit.is_none());
253 }
254 println!(
255 "100 idle polls: cache {} B, file reads {} B",
256 disk_written() - disk,
257 remote.read - read
258 );
259 Ok(())
260}
crates/notebook/examples/migrate_probe.rs created+118
...@@ -0,0 +1,118 @@
1//! migrate_probe CACHE...: converts copies of schema-14 caches, reports what they hold
2//! and, where nothing blocks the queue, publishes it to an in-memory copy of the remote.
3use notebook::{Remote, Replica};
4use onestore::{CommitError, CommitIo, Stamp, Transaction};
5use std::io;
6
7struct Memory(Vec<u8>);
8
9impl CommitIo for Memory {
10 fn read_at(&mut self, offset: u64, output: &mut [u8]) -> io::Result<usize> {
11 let offset = offset as usize;
12 let size = output.len().min(self.0.len().saturating_sub(offset));
13 output[..size].copy_from_slice(&self.0[offset..offset + size]);
14 Ok(size)
15 }
16 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> io::Result<usize> {
17 let offset = offset as usize;
18 self.0.resize(self.0.len().max(offset + bytes.len()), 0);
19 self.0[offset..offset + bytes.len()].copy_from_slice(bytes);
20 Ok(bytes.len())
21 }
22 fn flush(&mut self) -> io::Result<()> {
23 Ok(())
24 }
25}
26
27impl Remote for Memory {
28 fn read(&mut self) -> io::Result<Vec<u8>> {
29 Ok(self.0.clone())
30 }
31 fn stamp(&mut self) -> io::Result<Option<Stamp>> {
32 Ok(Stamp::of(&self.0).ok())
33 }
34 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
35 transaction.commit(self)
36 }
37 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
38 onestore::confirm_snapshot(self, snapshot)
39 }
40}
41
42fn pages(replica: &Replica) -> Result<Vec<onestore::page::Page>, notebook::Error> {
43 replica
44 .pages()?
45 .into_iter()
46 .map(|(space, ..)| replica.page(space))
47 .collect()
48}
49
50fn main() -> Result<(), Box<dyn std::error::Error>> {
51 for path in std::env::args().skip(1) {
52 let copy = std::env::temp_dir().join(format!(
53 "migrate-probe-{}-{}",
54 std::process::id(),
55 std::path::Path::new(&path)
56 .file_name()
57 .unwrap()
58 .to_string_lossy()
59 ));
60 std::fs::copy(&path, &copy)?;
61 // A cache open or crashed in WAL mode keeps committed pages in its `-wal` file.
62 let wal = format!("{path}-wal");
63 if std::path::Path::new(&wal).exists() {
64 let mut target = copy.as_os_str().to_owned();
65 target.push("-wal");
66 std::fs::copy(&wal, target)?;
67 }
68 let started = std::time::Instant::now();
69 match Replica::open(&copy) {
70 Ok(replica) => {
71 let elapsed = started.elapsed();
72 let pending = replica.pending()?;
73 let summary = replica.recovery_summary()?;
74 println!(
75 "{path}: converted in {:.1} ms; {} edits; {:?}; conflict {:?}",
76 elapsed.as_secs_f64() * 1e3,
77 pending.len(),
78 summary,
79 replica.conflict()?
80 );
81 for edit in &pending {
82 println!(
83 " {} {:?} {} ops",
84 edit.id,
85 replica.status(edit.id)?,
86 edit.edit.ops.len()
87 );
88 }
89 for (space, title, level) in replica.pages()? {
90 println!(" page {space} {level} {title:?}");
91 }
92 let blocked = replica.conflict()?.is_some()
93 || replica.recovery_summary()?.uncertain_edits > 0;
94 if !pending.is_empty() && !blocked {
95 let local = pages(&replica)?;
96 let mut remote = Memory(replica.remote_snapshot()?);
97 let synced = replica.sync_once(&mut remote)?;
98 let published = {
99 let arena = onestore::Arena::default();
100 let mut section = onestore::Section::open(&arena, remote.0.clone())?;
101 section
102 .pages()?
103 .into_iter()
104 .map(|(space, ..)| section.page(space))
105 .collect::<Result<Vec<_>, _>>()?
106 };
107 println!(
108 " published {:?}; remote pages equal the converted pages: {}",
109 synced.edit.map(|(id, _)| id),
110 published == local
111 );
112 }
113 }
114 Err(error) => println!("{path}: {error}"),
115 }
116 }
117 Ok(())
118}
crates/notebook/examples/queue_scale.rs+25-18
...@@ -47,13 +47,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -47,13 +47,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
47 let start = Instant::now();47 let start = Instant::now();
48 for n in 0..count {48 for n in 0..count {
49 let edit_start = Instant::now();49 let edit_start = Instant::now();
50 let source = cache.snapshot()?;
51 let store = Store::parse(&source)?;
52 let index = RevisionIndex::parse(&store)?;
53 let document = Document::parse(&index)?;
54 let slot = n % 2;50 let slot = n % 2;
55 let space = pages[slot].0;51 let space = pages[slot].0;
56 let mut page = Page::from_space(&document, space)?;52 let mut page = cache.page(space)?;
57 let text = page53 let text = page
58 .objects54 .objects
59 .iter_mut()55 .iter_mut()
...@@ -76,13 +72,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -76,13 +72,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
76 range: 0..end,72 range: 0..end,
77 replacement: Paragraph::new(expected[slot].clone(), format),73 replacement: Paragraph::new(expected[slot].clone(), format),
78 })?;74 })?;
79 let id = cache.save(&source, space, &page, "Fixture")?.unwrap();75 let before = cache.page(space)?;
76 let ops = onestore::op::lower_page(&before, &page)?;
77 let id = cache.apply(
78 "Fixture",
79 onestore::op::Edit {
80 at: 133_000_000_000_000_000,
81 ops: ops
82 .into_iter()
83 .map(|op| onestore::op::Op::Page { space, op })
84 .collect(),
85 },
86 )?;
80 assert!(ids.last().is_none_or(|previous| *previous < id));87 assert!(ids.last().is_none_or(|previous| *previous < id));
81 ids.push(id);88 ids.push(id);
82 println!(89 println!(
83 "{}",90 "{}",
84 serde_json::json!({"phase":"ack","n":n,"id":id,91 serde_json::json!({"phase":"ack","n":n,"id":id,
85 "ms":edit_start.elapsed().as_secs_f64()*1000.0,"working_bytes":source.len()})92 "ms":edit_start.elapsed().as_secs_f64()*1000.0})
86 );93 );
87 }94 }
88 println!(95 println!(
...@@ -114,16 +121,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -114,16 +121,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
114 serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()})121 serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()})
115 );122 );
116 let start = Instant::now();123 let start = Instant::now();
117 for (n, id) in ids.iter().enumerate() {124 // The queue publishes as one batch.
118 let step = Instant::now();125 let step = Instant::now();
119 assert!(126 assert!(matches!(
120 matches!(cache.sync_once(&mut remote)?, Some((actual, EditStatus::Published { .. })) if actual == *id)127 cache.sync_once(&mut remote)?.edit,
121 );128 Some((actual, EditStatus::Published { .. })) if Some(&actual) == ids.last()
122 println!(129 ));
123 "{}",130 println!(
124 serde_json::json!({"phase":"publish","n":n,"id":id,"ms":step.elapsed().as_secs_f64()*1000.0})131 "{}",
125 );132 serde_json::json!({"phase":"publish","count":ids.len(),"ms":step.elapsed().as_secs_f64()*1000.0})
126 }133 );
127 assert!(cache.pending()?.is_empty());134 assert!(cache.pending()?.is_empty());
128 let published = cache.snapshot()?;135 let published = cache.snapshot()?;
129 assert_eq!(onestore::read_file(&remote.0)?, published);136 assert_eq!(onestore::read_file(&remote.0)?, published);
...@@ -145,7 +152,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -145,7 +152,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
145 .collect::<Vec<_>>(),152 .collect::<Vec<_>>(),
146 ids153 ids
147 );154 );
148 assert_eq!(archive.snapshot()?, source);155 verify(&archive.snapshot()?, &pages, &expected)?;
149 println!(156 println!(
150 "{}",157 "{}",
151 serde_json::json!({"phase":"complete","count":count,158 serde_json::json!({"phase":"complete","count":count,
crates/notebook/examples/recovery_probe.rs+15-33
...@@ -98,12 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>...@@ -98,12 +98,10 @@ fn report(cache: &Replica, root: &Path) -> Result<(), Box<dyn std::error::Error>
98 "{}",98 "{}",
99 json!({"event":"state", "status":status, "revision":revision,99 json!({"event":"state", "status":status, "revision":revision,
100 "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(),100 "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(),
101 "pending":cache.pending()?.iter().map(|pending|match &pending.operation {101 "pending":cache.pending()?.iter().map(|pending| match &pending.edit.ops[..] {
102 notebook::Operation::Page(intent) => match intent.text_change() {102 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] =>
103 Some((_, before, range, replacement)) => json!({"id":pending.id,"before":before,"replacement":replacement,"range":[range.start,range.end]}),103 json!({"id":pending.id,"replacement":with,"range":[range.start,range.end]}),
104 None => json!({"id":pending.id,"operation":"page"}),104 _ => json!({"id":pending.id,"edit":pending.edit}),
105 },
106 operation => json!({"id":pending.id,"operation":operation}),
107 }).collect::<Vec<_>>() })105 }).collect::<Vec<_>>() })
108 );106 );
109 Ok(())107 Ok(())
...@@ -128,34 +126,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -128,34 +126,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
128 drop(remote);126 drop(remote);
129 let cache = Replica::create(root.join("cache.sqlite"), &source)?;127 let cache = Replica::create(root.join("cache.sqlite"), &source)?;
130 let at = u32::try_from(target.text.encode_utf16().count())?;128 let at = u32::try_from(target.text.encode_utf16().count())?;
131 let mut page = {129 let edit = onestore::op::Edit {
132 let store = onestore::Store::parse(&source)?;130 at: 133_000_000_000_000_000,
133 let index = onestore::RevisionIndex::parse(&store)?;131 ops: vec![onestore::op::Op::Page {
134 let document = onestore::document::Document::parse(&index)?;132 space: target.space,
135 onestore::page::Page::from_space(&document, target.space)?133 op: onestore::op::PageOp::Text {
134 text: target.object,
135 range: at..at,
136 with: " [offline-recovery]".into(),
137 },
138 }],
136 };139 };
137 let paragraph = page140 assert_eq!(cache.apply("Fixture", edit)?, 1);
138 .objects
139 .iter_mut()
140 .find_map(|object| match object {
141 onestore::page::PageObject::Outline(outline) => outline
142 .paragraphs
143 .iter_mut()
144 .find(|p| p.text().is_some_and(|t| t.id == target.object)),
145 _ => None,
146 })
147 .ok_or("Target paragraph is not on its page")?
148 .text_mut()
149 .unwrap();
150 let format = paragraph.text.format_at(at)?.clone();
151 paragraph.text.apply(onestore::page::text::Edit {
152 range: at..at,
153 replacement: onestore::page::Paragraph::new(" [offline-recovery]".into(), format),
154 })?;
155 assert_eq!(
156 cache.save(&source, target.space, &page, "Fixture")?,
157 Some(1)
158 );
159 phase("local-after");141 phase("local-after");
160 report(&cache, root)?;142 report(&cache, root)?;
161 } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) {143 } else if args.len() == 2 && ["inspect", "sync"].contains(&mode.as_str()) {
crates/notebook/examples/session_client.rs+13-4
...@@ -159,9 +159,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -159,9 +159,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
159 for event in section.events() {159 for event in section.events() {
160 match event {160 match event {
161 Event::Attempt {161 Event::Attempt {
162 id,162 status: EditStatus::Published { .. },
163 status: EditStatus::Published { revision },163 ..
164 } => receipts.push((id, revision)),164 } => {}
165 Event::Attempt { id, status } => {165 Event::Attempt { id, status } => {
166 println!(166 println!(
167 "{}",167 "{}",
...@@ -175,7 +175,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -175,7 +175,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
175 );175 );
176 }176 }
177 Event::Failed(error) => return Err(error.into()),177 Event::Failed(error) => return Err(error.into()),
178 Event::Refreshed => {}178 Event::Rejected { error, .. } => return Err(error.into()),
179 Event::Refreshed | Event::Changed(_) => {}
180 }
181 }
182 // A batch reports its newest edit; each edit's receipt is its own.
183 for (id, ..) in &queued {
184 if !receipts.iter().any(|(n, _)| n == id)
185 && let Some(EditStatus::Published { revision }) = section.status(*id)?
186 {
187 receipts.push((*id, revision));
179 }188 }
180 }189 }
181 }190 }
crates/notebook/examples/smb_offline_client.rs+52-195
...@@ -3,9 +3,7 @@ mod concurrent;...@@ -3,9 +3,7 @@ mod concurrent;
33
4use notebook::smb::{Client, Credentials};4use notebook::smb::{Client, Credentials};
5use notebook::{EditStatus, Error, Remote, Replica, SmbRemote};5use notebook::{EditStatus, Error, Remote, Replica, SmbRemote};
6use onestore::{6use onestore::{CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction};
7 CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, page::Paragraph,
8};
9use serde_json::json;7use serde_json::json;
10use std::{8use std::{
11 env,9 env,
...@@ -252,91 +250,19 @@ impl<R: Remote> Remote for Traced<R> {...@@ -252,91 +250,19 @@ impl<R: Remote> Remote for Traced<R> {
252 }250 }
253}251}
254252
255fn tokens(text: &str) -> Option<Vec<&str>> {253/// The edit appending `token` at `at` of a text.
256 let mut remaining = text.strip_prefix("Concurrent edits:")?;254fn append(space: ExGuid, text: ExGuid, at: u32, token: &str) -> onestore::op::Edit {
257 let mut tokens = Vec::new();255 onestore::op::Edit {
258 while !remaining.is_empty() {256 at: u64::try_from(now()).unwrap_or_default() * 10 + 116_444_736_000_000_000,
259 let body = remaining.strip_prefix(" [w")?;257 ops: vec![onestore::op::Op::Page {
260 let (token, tail) = body.split_once(']')?;258 space,
261 let (actor, operation) = token.split_once(':')?;259 op: onestore::op::PageOp::Text {
262 if actor.is_empty()260 text,
263 || operation.is_empty()261 range: at..at,
264 || !actor262 with: token.to_owned(),
265 .bytes()263 },
266 .chain(operation.bytes())264 }],
267 .all(|b| b.is_ascii_digit())
268 || tokens.contains(&token)
269 {
270 return None;
271 }
272 tokens.push(token);
273 remaining = tail;
274 }
275 Some(tokens)
276}
277
278// This owned workload explicitly resolves append conflicts after all retained tokens.
279fn page_with(
280 bytes: &[u8],
281 space: ExGuid,
282 text: ExGuid,
283) -> Result<onestore::page::Page, Box<dyn std::error::Error>> {
284 let store = Store::parse(bytes)?;
285 let index = RevisionIndex::parse(&store)?;
286 let document = onestore::document::Document::parse(&index)?;
287 let page = onestore::page::Page::from_space(&document, space)?;
288 if paragraph_mut(&mut page.clone(), text).is_none() {
289 return Err("The target text is not on its page".into());
290 }
291 Ok(page)
292}
293
294fn paragraph_mut(
295 page: &mut onestore::page::Page,
296 text: ExGuid,
297) -> Option<&mut onestore::page::TextObject> {
298 for object in &mut page.objects {
299 let outlines: Vec<&mut onestore::page::Outline> = match object {
300 onestore::page::PageObject::Outline(outline) => vec![outline],
301 onestore::page::PageObject::Title(title) => title.outlines.iter_mut().collect(),
302 _ => Vec::new(),
303 };
304 for outline in outlines {
305 if let Some(paragraph) = outline
306 .paragraphs
307 .iter_mut()
308 .find(|p| p.text().is_some_and(|t| t.id == text))
309 {
310 return paragraph.text_mut();
311 }
312 }
313 }
314 None
315}
316
317fn append_to(page: &mut onestore::page::Page, text: ExGuid, at: u32, token: &str) {
318 let target = paragraph_mut(page, text).expect("target text is on its page");
319 let format = target.text.format_at(at).unwrap().clone();
320 target
321 .text
322 .apply(onestore::page::text::Edit {
323 range: at..at,
324 replacement: Paragraph::new(token.into(), format),
325 })
326 .unwrap();
327}
328
329fn append_position(before: &str, current: &str, token: &str) -> Option<u32> {
330 let original = tokens(before)?;
331 let present = tokens(current)?;
332 let mut retained = present.iter();
333 for token in original {
334 retained.find(|&&candidate| candidate == token)?;
335 }265 }
336 if current.contains(token) {
337 return None;
338 }
339 u32::try_from(current.encode_utf16().count()).ok()
340}266}
341267
342fn main() -> Result<(), Box<dyn std::error::Error>> {268fn main() -> Result<(), Box<dyn std::error::Error>> {
...@@ -459,10 +385,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -459,10 +385,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
459 let at = u32::try_from(target.text.encode_utf16().count())?;385 let at = u32::try_from(target.text.encode_utf16().count())?;
460 let token = format!(" [{}:{}]", args[2], generated);386 let token = format!(" [{}:{}]", args[2], generated);
461 let started = now();387 let started = now();
462 let mut page = page_with(&source, target.space, target.object)?;388 match cache.apply(
463 append_to(&mut page, target.object, at, &token);389 "Offline document writer",
464 match cache.save(&source, target.space, &page, "Offline document writer") {390 append(target.space, target.object, at, &token),
465 Ok(Some(id)) => {391 ) {
392 Ok(id) => {
466 println!(393 println!(
467 "{}",394 "{}",
468 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()})395 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()})
...@@ -470,43 +397,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -470,43 +397,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
470 ids.push(id);397 ids.push(id);
471 generated += 1;398 generated += 1;
472 }399 }
473 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
474 other => return Err(format!("Unexpected local result: {other:?}").into()),400 other => return Err(format!("Unexpected local result: {other:?}").into()),
475 }401 }
476 }402 }
477 if let Some(intent) = pending.first()403 if let Some(conflict) = cache.conflict()? {
478 && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_)))404 // Concurrent appends meet at the end of the text: take the remote text and
479 {405 // append the queued tokens it lacks after it again.
480 let local = cache.snapshot()?;406 let remote = view(&cache.remote_snapshot()?)?;
481 let remote = cache.remote_snapshot()?;407 let tokens: Vec<String> = cache
482 let current = view(&remote)?;408 .pending()?
483 let notebook::Operation::Page(edit) = &intent.operation else {409 .iter()
484 return Err(format!(410 .flat_map(|queued| queued.edit.ops.iter())
485 "Intent {} requires review: {:?}",411 .filter_map(|op| match op {
486 intent.id,412 onestore::op::Op::Page {
487 cache.status(intent.id)?413 op: onestore::op::PageOp::Text { with, .. },
488 )414 ..
489 .into());415 } => Some(with.clone()),
490 };416 _ => None,
491 let (object, before, _, token) = edit417 })
492 .text_change()418 .filter(|token| !remote.text.contains(token.as_str()))
493 .ok_or("A review expects one appended token")?;419 .collect();
494 let at = append_position(&before, &current.text, &token)420 match cache.resolve(conflict.id, notebook::Resolution::Theirs) {
495 .ok_or("Append model disagrees with retained history")?;421 Ok(()) => {
496 let mut reviewed = page_with(&remote, intent.space, object)?;422 let mut at = u32::try_from(remote.text.encode_utf16().count())?;
497 append_to(&mut reviewed, object, at, &token);423 for token in &tokens {
498 match cache.review_page(intent.id, &local, &remote, &reviewed) {424 cache.apply(
499 Ok(()) => println!(425 "Offline document writer",
500 "{}",426 append(remote.space, remote.object, at, token),
501 json!({"event":"reviewed_append", "id":intent.id, "before":before, "remote":current.text, "token":token, "at_us":now()})427 )?;
502 ),428 at += u32::try_from(token.encode_utf16().count())?;
429 }
430 println!(
431 "{}",
432 json!({"event":"reviewed_append", "id":conflict.id, "remote":remote.text, "tokens":tokens, "at_us":now()})
433 );
434 }
503 Err(Error::Io(error))435 Err(Error::Io(error))
504 if [436 if [io::ErrorKind::WouldBlock, io::ErrorKind::InvalidInput]
505 io::ErrorKind::ResourceBusy,437 .contains(&error.kind()) => {}
506 io::ErrorKind::WouldBlock,
507 io::ErrorKind::InvalidInput,
508 ]
509 .contains(&error.kind()) => {}
510 Err(error) => return Err(error.into()),438 Err(error) => return Err(error.into()),
511 }439 }
512 }440 }
...@@ -546,74 +474,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {...@@ -546,74 +474,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
546 );474 );
547 Ok(())475 Ok(())
548}476}
549
550#[test]
551fn append_review_requires_a_unique_ordered_history_and_an_absent_new_token() {
552 assert_eq!(
553 append_position(
554 "Concurrent edits: [w0:0]",
555 "Concurrent edits: [w1:0] [w0:0]",
556 " [w0:1]"
557 ),
558 Some(31)
559 );
560 for current in [
561 "Concurrent edits:",
562 "Concurrent edits: [w0:0] [w0:0]",
563 "Concurrent edits: [w0:1] [w0:0]",
564 "Concurrent edits: changed [w0:0]",
565 ] {
566 assert_eq!(
567 append_position("Concurrent edits: [w0:0]", current, " [w0:1]"),
568 None
569 );
570 }
571 assert_eq!(
572 append_position(
573 "Concurrent edits: [w0:0] [w1:0]",
574 "Concurrent edits: [w1:0] [w0:0]",
575 " [w0:1]"
576 ),
577 None
578 );
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn publication_differences_retain_removed_text_and_graph_identities() {
587 let before = DocumentView {
588 texts: serde_json::from_value(json!({"left":{"text":"ab"}, "right":{"text":"cd"}, "untouched":{"text":"ef"}})).unwrap(),
589 graph: serde_json::from_value(json!({"outline":{"children":["a","b"]}, "a":{"content":["left"]}, "b":{"content":["right"]}})).unwrap(),
590 };
591 let after = DocumentView {
592 texts: serde_json::from_value(
593 json!({"left":{"text":"abcd"}, "untouched":{"text":"ef"}}),
594 )
595 .unwrap(),
596 graph: serde_json::from_value(
597 json!({"outline":{"children":["a"]}, "a":{"content":["left"]}}),
598 )
599 .unwrap(),
600 };
601 let changes = after.changes(&before);
602 assert_eq!(
603 changes.texts,
604 serde_json::from_value::<serde_json::Map<_, _>>(
605 json!({"left":{"text":"abcd"}, "right":null})
606 )
607 .unwrap()
608 );
609 assert_eq!(
610 changes.graph,
611 serde_json::from_value::<serde_json::Map<_, _>>(
612 json!({"outline":{"children":["a"]}, "b":null})
613 )
614 .unwrap()
615 );
616 assert_eq!(before.changes(&after).texts["right"], before.texts["right"]);
617 assert_eq!(before.changes(&before), DocumentView::default());
618 }
619}
crates/notebook/src/assets.rs+28-37
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1use super::*;1use super::*;
2use onestore::FileDataReference;2use onestore::FileDataReference;
3use onestore::{
4 RevisionIndex, Store,
5 document::{Document, Kind},
6};
3use rusqlite::OptionalExtension;7use rusqlite::OptionalExtension;
4use sha2::{Digest, Sha256};8use sha2::{Digest, Sha256};
59
...@@ -7,12 +11,7 @@ impl Replica {...@@ -7,12 +11,7 @@ impl Replica {
7 /// Reads a previously downloaded external payload without network access.11 /// Reads a previously downloaded external payload without network access.
8 /// Absence is distinct from an empty payload; every returned buffer passes its stored checksum.12 /// Absence is distinct from an empty payload; every returned buffer passes its stored checksum.
9 pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> {13 pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> {
10 let key = key(filename)?;14 cached(&*self.lock()?, &key(filename)?, limit)
11 let connection = self
12 .connection
13 .lock()
14 .map_err(|_| io::Error::other("Cache owner panicked"))?;
15 cached(&connection, &key, limit)
16 }15 }
1716
18 /// Fetches a declared external payload and durably retains it without changing the edit queue.17 /// Fetches a declared external payload and durably retains it without changing the edit queue.
...@@ -26,32 +25,23 @@ impl Replica {...@@ -26,32 +25,23 @@ impl Replica {
26 limit: usize,25 limit: usize,
27 ) -> Result<Vec<u8>> {26 ) -> Result<Vec<u8>> {
28 let key = key(filename)?;27 let key = key(filename)?;
29 {28 if !self.references(&key)? {
30 let connection = self29 return Err(io::Error::new(
31 .connection30 io::ErrorKind::InvalidInput,
32 .lock()31 "The retained document images do not reference this external payload",
33 .map_err(|_| io::Error::other("Cache owner panicked"))?;32 )
34 if !referenced(&connection, &key)? {33 .into());
35 return Err(io::Error::new(
36 io::ErrorKind::InvalidInput,
37 "The retained document images do not reference this external payload",
38 )
39 .into());
40 }
41 }34 }
42 let bytes = crate::discover::read_external_asset(source, section, filename, limit)?;35 let bytes = crate::discover::read_external_asset(source, section, filename, limit)?;
43 let mut connection = self36 if !self.references(&key)? {
44 .connection
45 .lock()
46 .map_err(|_| io::Error::other("Cache owner panicked"))?;
47 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
48 if !referenced(&transaction, &key)? {
49 return Err(io::Error::new(37 return Err(io::Error::new(
50 io::ErrorKind::ResourceBusy,38 io::ErrorKind::ResourceBusy,
51 "The external payload reference changed during download",39 "The external payload reference changed during download",
52 )40 )
53 .into());41 .into());
54 }42 }
43 let mut connection = self.lock()?;
44 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
55 let previous = match cached(&transaction, &key, bytes.len()) {45 let previous = match cached(&transaction, &key, bytes.len()) {
56 Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => {46 Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => {
57 return Err(Error::AssetChanged);47 return Err(Error::AssetChanged);
...@@ -78,21 +68,22 @@ pub(crate) fn key(filename: &str) -> Result<String> {...@@ -78,21 +68,22 @@ pub(crate) fn key(filename: &str) -> Result<String> {
78 Ok(filename.to_ascii_lowercase())68 Ok(filename.to_ascii_lowercase())
79}69}
8070
81fn referenced(connection: &Connection, key: &str) -> Result<bool> {71impl Replica {
82 // One image at a time: the working image usually answers.72 /// Whether the local pages or the remote image declare the external payload `key`.
83 for read in [crate::images::working, crate::images::base] {73 fn references(&self, key: &str) -> Result<bool> {
84 let image = read(connection)?;74 for image in [self.snapshot()?, self.remote_snapshot()?] {
85 let store = Store::parse(&image)?;75 let store = Store::parse(&image)?;
86 let index = RevisionIndex::parse(&store)?;76 let index = RevisionIndex::parse(&store)?;
87 let document = Document::parse(&index)?;77 let document = Document::parse(&index)?;
88 if document.spaces.values().flat_map(|space| space.revisions.values())78 if document.spaces.values().flat_map(|space| space.revisions.values())
89 .flat_map(|revision| revision.nodes.values()).any(|node| {79 .flat_map(|revision| revision.nodes.values()).any(|node| {
90 matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key))80 matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key))
91 }) {81 }) {
92 return Ok(true);82 return Ok(true);
83 }
93 }84 }
85 Ok(false)
94 }86 }
95 Ok(false)
96}87}
9788
98pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> {89pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result<Option<Vec<u8>>> {
crates/notebook/src/base.rs created+231
...@@ -0,0 +1,231 @@
1//! Section images kept as 16 KiB chunk rows, so a publication rewrites the chunks its
2//! transaction touches instead of the image: `base`, the image the queued edits apply to,
3//! and `remote`, the last observed remote image while it is not the base.
4
5use crate::Result;
6use onestore::{Stamp, Transaction};
7use rusqlite::{Connection, OptionalExtension, params};
8use std::io;
9
10const CHUNK: usize = 16 * 1024;
11
12#[derive(Clone, Copy)]
13pub(crate) enum Image {
14 Base,
15 Remote,
16}
17
18impl Image {
19 fn table(self) -> &'static str {
20 match self {
21 Self::Base => "base",
22 Self::Remote => "remote",
23 }
24 }
25}
26
27fn damaged() -> crate::Error {
28 io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image").into()
29}
30
31/// The whole image, or `None` when none is stored.
32pub(crate) fn read(connection: &Connection, image: Image) -> Result<Option<Vec<u8>>> {
33 let mut query = connection.prepare_cached(&format!(
34 "SELECT chunk, bytes FROM {} ORDER BY chunk",
35 image.table()
36 ))?;
37 let mut rows = query.query([])?;
38 let mut bytes = Vec::new();
39 let mut count = 0;
40 while let Some(row) = rows.next()? {
41 let chunk: i64 = row.get(0)?;
42 let part = row.get_ref(1)?.as_blob().map_err(|_| damaged())?;
43 if chunk != count || bytes.len() % CHUNK != 0 || part.is_empty() || part.len() > CHUNK {
44 return Err(damaged());
45 }
46 bytes.extend_from_slice(part);
47 count += 1;
48 }
49 Ok((count > 0).then_some(bytes))
50}
51
52/// The stored image's header and length, reading its first and last chunks.
53pub(crate) fn stamp(connection: &Connection, image: Image) -> Result<Option<Stamp>> {
54 let table = image.table();
55 let Some((header, last)): Option<(Vec<u8>, i64)> = connection
56 .query_row(
57 &format!("SELECT substr(bytes, 1, 1024), (SELECT max(chunk) FROM {table}) FROM {table} WHERE chunk=0"),
58 [],
59 |row| Ok((row.get(0)?, row.get(1)?)),
60 )
61 .optional()?
62 else {
63 return Ok(None);
64 };
65 let tail: i64 = connection.query_row(
66 &format!("SELECT length(bytes) FROM {table} WHERE chunk=?1"),
67 [last],
68 |row| row.get(0),
69 )?;
70 Ok(Some(Stamp {
71 header: header.try_into().map_err(|_| damaged())?,
72 length: u64::try_from(last).map_err(|_| damaged())? * CHUNK as u64
73 + u64::try_from(tail).map_err(|_| damaged())?,
74 }))
75}
76
77/// Stores `bytes` as the image, rewriting only the chunks that differ.
78pub(crate) fn write(connection: &Connection, image: Image, bytes: &[u8]) -> Result<()> {
79 let table = image.table();
80 let count = bytes.len().div_ceil(CHUNK);
81 connection.execute(
82 &format!("DELETE FROM {table} WHERE chunk>=?1"),
83 [count as i64],
84 )?;
85 let mut stored =
86 connection.prepare_cached(&format!("SELECT bytes FROM {table} WHERE chunk=?1"))?;
87 let mut replace = connection.prepare_cached(&format!(
88 "INSERT INTO {table}(chunk, bytes) VALUES (?1, ?2) ON CONFLICT(chunk) DO UPDATE SET bytes=excluded.bytes"
89 ))?;
90 for (index, part) in bytes.chunks(CHUNK).enumerate() {
91 let current: Option<Vec<u8>> = stored
92 .query_row([index as i64], |row| row.get(0))
93 .optional()?;
94 if current.as_deref() != Some(part) {
95 replace.execute(params![index as i64, part])?;
96 }
97 }
98 Ok(())
99}
100
101pub(crate) fn clear(connection: &Connection, image: Image) -> Result<()> {
102 connection.execute(&format!("DELETE FROM {}", image.table()), [])?;
103 Ok(())
104}
105
106/// Commits `transaction` to the base image the way it commits to the file, rewriting only
107/// the chunks it writes.
108pub(crate) fn publish(connection: &Connection, transaction: &Transaction) -> Result<()> {
109 if stamp(connection, Image::Base)?.as_ref() != Some(transaction.base()) {
110 return Err(io::Error::new(
111 io::ErrorKind::InvalidData,
112 "The published transaction is not on the cached base image",
113 )
114 .into());
115 }
116 let mut stored = connection.prepare_cached("SELECT bytes FROM base WHERE chunk=?1")?;
117 let mut replace = connection.prepare_cached(
118 "INSERT INTO base(chunk, bytes) VALUES (?1, ?2) ON CONFLICT(chunk) DO UPDATE SET bytes=excluded.bytes",
119 )?;
120 let mut chunks: std::collections::BTreeMap<usize, Vec<u8>> = Default::default();
121 for (offset, bytes) in transaction.writes() {
122 let mut offset = usize::try_from(offset).map_err(|_| damaged())?;
123 let mut bytes = bytes;
124 while !bytes.is_empty() {
125 let index = offset / CHUNK;
126 let chunk = match chunks.entry(index) {
127 std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
128 std::collections::btree_map::Entry::Vacant(entry) => entry.insert(
129 stored
130 .query_row([index as i64], |row| row.get(0))
131 .optional()?
132 .unwrap_or_default(),
133 ),
134 };
135 let at = offset % CHUNK;
136 let take = bytes.len().min(CHUNK - at);
137 if chunk.len() < at {
138 return Err(damaged());
139 }
140 if chunk.len() < at + take {
141 chunk.resize(at + take, 0);
142 }
143 chunk[at..at + take].copy_from_slice(&bytes[..take]);
144 offset += take;
145 bytes = &bytes[take..];
146 }
147 }
148 for (index, bytes) in chunks {
149 replace.execute(params![index as i64, bytes])?;
150 }
151 Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn connection() -> Connection {
159 let connection = Connection::open_in_memory().unwrap();
160 connection.execute_batch(crate::schema::QUEUE).unwrap();
161 connection
162 }
163
164 /// Types `text` at the start of the page's first body paragraph.
165 pub(crate) fn typed(section: &mut onestore::Section<'_>, space: onestore::ExGuid, text: &str) {
166 use onestore::op::{Edit, Op, PageOp};
167 let page = section.page(space).unwrap();
168 let target = page
169 .objects
170 .iter()
171 .find_map(|object| match object {
172 onestore::page::PageObject::Outline(outline) => outline
173 .paragraphs
174 .iter()
175 .find_map(|p| p.text().map(|t| t.id)),
176 _ => None,
177 })
178 .unwrap();
179 section
180 .apply(
181 "Author",
182 &Edit {
183 at: 133_000_000_000_000_000,
184 ops: vec![Op::Page {
185 space,
186 op: PageOp::Text {
187 text: target,
188 range: 0..0,
189 with: text.into(),
190 },
191 }],
192 },
193 )
194 .unwrap();
195 }
196
197 #[test]
198 fn a_published_transaction_rewrites_only_its_chunks_and_matches_the_file() {
199 let source =
200 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one").to_vec();
201 assert!(source.len() > 4 * CHUNK);
202 let arena = onestore::Arena::default();
203 let mut section = onestore::Section::open(&arena, source.clone()).unwrap();
204 let space = section.pages().unwrap()[0].0;
205 let connection = connection();
206 write(&connection, Image::Base, &source).unwrap();
207 assert_eq!(read(&connection, Image::Base).unwrap().unwrap(), source);
208 assert_eq!(
209 stamp(&connection, Image::Base).unwrap().unwrap(),
210 Stamp::of(&source).unwrap()
211 );
212 let mut image = source.clone();
213 for round in 0..3 {
214 typed(&mut section, space, &format!("{round}"));
215 let transaction = section.seal().unwrap().unwrap();
216 publish(&connection, &transaction).unwrap();
217 transaction.apply(&mut image).unwrap();
218 assert_eq!(read(&connection, Image::Base).unwrap().unwrap(), image);
219 assert_eq!(
220 stamp(&connection, Image::Base).unwrap().unwrap(),
221 Stamp::of(&image).unwrap()
222 );
223 assert!(publish(&connection, &transaction).is_err());
224 }
225 let shorter = &image[..CHUNK + 5];
226 write(&connection, Image::Base, shorter).unwrap();
227 assert_eq!(read(&connection, Image::Base).unwrap().unwrap(), shorter);
228 clear(&connection, Image::Remote).unwrap();
229 assert!(read(&connection, Image::Remote).unwrap().is_none());
230 }
231}
crates/notebook/src/images.rs deleted-198
...@@ -1,198 +0,0 @@
1//! The cache's two section images. `base` is stored whole; `working` is stored as the byte
2//! ranges where it differs from `base`, so saving an edit writes the revision it appended
3//! instead of the section.
4
5use crate::Result;
6use onestore::Stamp;
7use rusqlite::{Connection, params};
8use std::io;
9
10const BLOCK: usize = 4096;
11
12/// `image` as its length and the `(offset, bytes)` runs of blocks that differ from `base`;
13/// empty when the images are equal.
14fn difference(base: &[u8], image: &[u8]) -> Vec<u8> {
15 if base == image {
16 return Vec::new();
17 }
18 let mut patch = (image.len() as u64).to_le_bytes().to_vec();
19 let mut run: Option<usize> = None;
20 let close = |patch: &mut Vec<u8>, start: usize, end: usize| {
21 patch.extend_from_slice(&(start as u64).to_le_bytes());
22 patch.extend_from_slice(&((end - start) as u64).to_le_bytes());
23 patch.extend_from_slice(&image[start..end]);
24 };
25 for start in (0..image.len()).step_by(BLOCK) {
26 let end = (start + BLOCK).min(image.len());
27 if base.get(start..end) == Some(&image[start..end]) {
28 if let Some(from) = run.take() {
29 close(&mut patch, from, start);
30 }
31 } else {
32 run.get_or_insert(start);
33 }
34 }
35 if let Some(from) = run {
36 close(&mut patch, from, image.len());
37 }
38 patch
39}
40
41fn restore(mut base: Vec<u8>, patch: &[u8]) -> Result<Vec<u8>> {
42 let damaged = || io::Error::new(io::ErrorKind::InvalidData, "Damaged working image");
43 let Some((length, mut runs)) = patch.split_first_chunk::<8>() else {
44 return if patch.is_empty() {
45 Ok(base)
46 } else {
47 Err(damaged().into())
48 };
49 };
50 // Every block past the base is a run, so a whole patch bounds the image.
51 let length = usize::try_from(u64::from_le_bytes(*length))
52 .ok()
53 .filter(|length| *length <= base.len() + patch.len())
54 .ok_or_else(damaged)?;
55 base.resize(length, 0);
56 while let Some((header, rest)) = runs.split_first_chunk::<16>() {
57 let offset = usize::try_from(u64::from_le_bytes(header[..8].try_into().unwrap()))
58 .map_err(|_| damaged())?;
59 let size = usize::try_from(u64::from_le_bytes(header[8..].try_into().unwrap()))
60 .map_err(|_| damaged())?;
61 let (bytes, rest) = rest.split_at_checked(size).ok_or_else(damaged)?;
62 base.get_mut(offset..)
63 .and_then(|target| target.get_mut(..size))
64 .ok_or_else(damaged)?
65 .copy_from_slice(bytes);
66 runs = rest;
67 }
68 if runs.is_empty() {
69 Ok(base)
70 } else {
71 Err(damaged().into())
72 }
73}
74
75pub(crate) fn base(connection: &Connection) -> Result<Vec<u8>> {
76 Ok(connection.query_row("SELECT base FROM replica WHERE id=1", [], |row| row.get(0))?)
77}
78
79/// `(base, working)`.
80pub(crate) fn both(connection: &Connection) -> Result<(Vec<u8>, Vec<u8>)> {
81 let (base, patch): (Vec<u8>, Vec<u8>) =
82 connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
83 Ok((row.get(0)?, row.get(1)?))
84 })?;
85 let working = restore(base.clone(), &patch)?;
86 Ok((base, working))
87}
88
89/// The working image's length, without restoring it.
90pub(crate) fn working_length(connection: &Connection) -> Result<u64> {
91 Ok(connection.query_row(
92 "SELECT length(base), substr(working, 1, 8) FROM replica WHERE id=1",
93 [],
94 |row| {
95 // SQLite's substr of an empty blob is NULL.
96 let header: Option<Vec<u8>> = row.get(1)?;
97 Ok(match header.as_deref().and_then(<[u8]>::first_chunk::<8>) {
98 Some(length) => u64::from_le_bytes(*length),
99 None => row.get::<_, i64>(0)? as u64,
100 })
101 },
102 )?)
103}
104
105/// Whether no edit is queued and the working image is the base image, without reading
106/// either image.
107pub(crate) fn settled(connection: &Connection) -> Result<bool> {
108 Ok(connection.query_row(
109 "SELECT length(working) = 0 AND NOT EXISTS (SELECT 1 FROM edits) FROM replica WHERE id=1",
110 [],
111 |row| row.get(0),
112 )?)
113}
114
115/// The base image's stamp, without reading the image.
116pub(crate) fn stamp(connection: &Connection) -> Result<Stamp> {
117 let (header, length): (Vec<u8>, i64) = connection.query_row(
118 "SELECT substr(base, 1, 1024), length(base) FROM replica WHERE id=1",
119 [],
120 |row| Ok((row.get(0)?, row.get(1)?)),
121 )?;
122 Ok(Stamp {
123 header: header
124 .try_into()
125 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Damaged base image"))?,
126 length: u64::try_from(length).map_err(io::Error::other)?,
127 })
128}
129
130pub(crate) fn working(connection: &Connection) -> Result<Vec<u8>> {
131 let (base, patch): (Vec<u8>, Vec<u8>) =
132 connection.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
133 Ok((row.get(0)?, row.get(1)?))
134 })?;
135 restore(base, &patch)
136}
137
138/// Replaces the working image; `base` is the stored base image.
139pub(crate) fn set_working(connection: &Connection, base: &[u8], working: &[u8]) -> Result<()> {
140 connection.execute(
141 "UPDATE replica SET working=?1 WHERE id=1",
142 [difference(base, working)],
143 )?;
144 Ok(())
145}
146
147/// Replaces the base image, keeping `working` as the working image. An unchanged base
148/// leaves its stored bytes alone.
149pub(crate) fn set_base(
150 connection: &Connection,
151 old: &[u8],
152 base: &[u8],
153 working: &[u8],
154) -> Result<()> {
155 if old == base {
156 return set_working(connection, base, working);
157 }
158 connection.execute(
159 "UPDATE replica SET base=?1, working=?2 WHERE id=1",
160 params![base, difference(base, working)],
161 )?;
162 Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn a_working_image_survives_as_its_difference_from_any_base() {
171 let base: Vec<u8> = (0..3 * BLOCK + 17).map(|i| i as u8).collect();
172 let mut edited = base.clone();
173 edited[5] ^= 1;
174 edited[2 * BLOCK + 1] ^= 1;
175 edited.extend_from_slice(b"appended revision");
176 for image in [
177 base.clone(),
178 edited.clone(),
179 base[..BLOCK + 3].to_vec(),
180 Vec::new(),
181 vec![7; 5 * BLOCK],
182 ] {
183 let patch = difference(&base, &image);
184 assert_eq!(restore(base.clone(), &patch).unwrap(), image);
185 }
186 assert!(difference(&base, &base).is_empty());
187 assert!(difference(&base, &edited).len() < 3 * BLOCK);
188 let mut huge = difference(&base, &edited);
189 huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
190 assert!(restore(base.clone(), &huge).is_err());
191 let patch = difference(&base, &edited);
192 for cut in 1..patch.len() {
193 if let Ok(image) = restore(base.clone(), &patch[..cut]) {
194 assert_ne!(image, edited);
195 }
196 }
197 }
198}
crates/notebook/src/lib.rs+221-415
...@@ -6,32 +6,33 @@ pub mod discover;...@@ -6,32 +6,33 @@ pub mod discover;
6pub mod smb;6pub mod smb;
77
8use onestore::{8use onestore::{
9 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,9 ExGuid,
10 document::{Document, Kind},10 op::{Edit, OpError},
11 page::Page,11 page::Page,
12};12};
13use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params};13use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
14use std::{14use std::{
15 fs::OpenOptions,15 fs::OpenOptions,
16 io,16 io,
17 path::Path,17 path::Path,
18 sync::{Mutex, atomic::AtomicI64},18 sync::{Arc, Mutex, MutexGuard, mpsc},
19 thread::JoinHandle,
19 time::Duration,20 time::Duration,
20};21};
2122
22mod assets;23mod assets;
23mod images;24mod base;
24mod merge;25mod merge;
25mod pages;26mod migrate;
26mod rebase;27mod queue;
27mod recovery;28mod recovery;
28mod schema;29mod schema;
29pub mod session;30pub mod session;
30pub use pages::PageEdits;
31pub use recovery::{Recovery, RecoverySummary};31pub use recovery::{Recovery, RecoverySummary};
32mod sync;32mod sync;
33pub use sync::{ConflictKind, EditStatus, Remote};33pub use sync::{ConflictKind, EditStatus, Remote, Synced};
34mod worker;34mod worker;
35mod working;
35#[cfg(feature = "smb")]36#[cfg(feature = "smb")]
36pub use smb::SmbRemote;37pub use smb::SmbRemote;
37pub use worker::SyncWorker;38pub use worker::SyncWorker;
...@@ -50,6 +51,9 @@ pub enum Error {...@@ -50,6 +51,9 @@ pub enum Error {
50 RemoteIo(io::Error),51 RemoteIo(io::Error),
51 #[error(transparent)]52 #[error(transparent)]
52 Discovery(#[from] discover::Error),53 Discovery(#[from] discover::Error),
54 /// The section refused an edit; the pages it names are as they were.
55 #[error(transparent)]
56 Rejected(#[from] OpError),
53 #[error("External payload identity now refers to different bytes")]57 #[error("External payload identity now refers to different bytes")]
54 AssetChanged,58 AssetChanged,
55 #[cfg(feature = "protected")]59 #[cfg(feature = "protected")]
...@@ -60,114 +64,49 @@ pub enum Error {...@@ -60,114 +64,49 @@ pub enum Error {
60type Result<T> = std::result::Result<T, Error>;64type Result<T> = std::result::Result<T, Error>;
6165
62const APPLICATION_ID: u32 = 0x4f4e454f;66const APPLICATION_ID: u32 = 0x4f4e454f;
63const SCHEMA_VERSION: u32 = 14;
64
65/// An edited page model together with the stored model it was edited from.
66/// `before` is the precondition reconciliation checks against the remote page.
67#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct PageIntent {
70 pub before: Page,
71 pub after: Page,
72 pub author: String,
73}
74
75impl PageIntent {
76 /// Describes the intent as one paragraph's text replacement, when that is all it changes:
77 /// the text object, its text before, the replaced UTF-16 range and the replacement.
78 pub fn text_change(&self) -> Option<(ExGuid, String, std::ops::Range<u32>, String)> {
79 fn texts(page: &Page) -> Vec<(ExGuid, &onestore::page::Paragraph)> {
80 let mut out = Vec::new();
81 for object in &page.objects {
82 let outlines: Vec<&onestore::page::Outline> = match object {
83 onestore::page::PageObject::Outline(outline) => vec![outline],
84 onestore::page::PageObject::Title(title) => title.outlines.iter().collect(),
85 _ => Vec::new(),
86 };
87 for outline in outlines {
88 for paragraph in &outline.paragraphs {
89 if let Some(text) = paragraph.text() {
90 out.push((text.id, &text.text));
91 }
92 }
93 }
94 }
95 out
96 }
97 let (before, after) = (texts(&self.before), texts(&self.after));
98 if before.len() != after.len() {
99 return None;
100 }
101 let mut changed = None;
102 for ((id, x), (other, y)) in before.iter().zip(&after) {
103 if id != other {
104 return None;
105 }
106 if x.text() != y.text() {
107 if changed.is_some() {
108 return None;
109 }
110 changed = Some((*id, *x, *y));
111 }
112 }
113 let (id, x, y) = changed?;
114 let (b, o) = (x.text(), y.text());
115 let prefix = b
116 .char_indices()
117 .zip(o.chars())
118 .take_while(|((_, c), d)| c == d)
119 .map(|((i, c), _)| i + c.len_utf8())
120 .last()
121 .unwrap_or(0);
122 let suffix = b[prefix..]
123 .chars()
124 .rev()
125 .zip(o[prefix..].chars().rev())
126 .take_while(|(c, d)| c == d)
127 .map(|(c, _)| c.len_utf8())
128 .sum::<usize>();
129 let start = x.utf16_offset(prefix).ok()?;
130 let end = x.utf16_offset(b.len() - suffix).ok()?;
131 Some((
132 id,
133 b.to_owned(),
134 start..end,
135 o[prefix..o.len() - suffix].to_owned(),
136 ))
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
141#[serde(deny_unknown_fields)]
142pub enum Operation {
143 /// An edited page model; body content is edited only through this intent.
144 Page(PageIntent),
145 CreatePage(PageCreation),
146 Pages(PageEdits),
147 /// Permanent removal of explicitly selected page spaces from the section.
148 DeletePages(Vec<ExGuid>),
149}
15067
151/// A locally acknowledged intent; its ID remains stable across cache reopen.68/// A locally durable edit; its ID remains stable across cache reopen.
152#[derive(Debug, Clone, PartialEq)]69#[derive(Debug, Clone, PartialEq)]
153pub struct PendingEdit {70pub struct PendingEdit {
71 pub id: u64,
72 pub author: String,
73 pub edit: Edit,
74}
75
76/// Unpublished work the remote section no longer accepts: the batch holding edit `id`
77/// changed page `space` where the remote changed it too.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Conflict {
154 pub id: u64,80 pub id: u64,
155 pub space: ExGuid,81 pub space: ExGuid,
156 pub operation: Operation,82 pub kind: ConflictKind,
83}
84
85/// How a conflict or an uncertain attempt ends. `Mine` keeps the local pages: a conflicted
86/// page is rewritten from the remote page to the local one; a released attempt publishes
87/// again. `Theirs` drops the local edits to the conflicted page, or the whole unpublished
88/// branch of a released attempt.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Resolution {
91 Mine,
92 Theirs,
157}93}
15894
159/// Owns one local cache. Share this handle between threads; a second open fails busy.95/// Owns one local cache. Share this handle between threads; a second open fails busy.
160/// SQLite's exclusive connection retains ownership between local transactions.96/// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's
97/// exclusive connection retains ownership between local transactions.
161pub struct Replica {98pub struct Replica {
162 connection: Mutex<Connection>,99 connection: Arc<Mutex<Connection>>,
100 working: Option<mpsc::Sender<working::Request>>,
101 thread: Option<JoinHandle<()>>,
163 synchronization: Mutex<()>,102 synchronization: Mutex<()>,
164 /// The intent a synchronization step selected for publication, or zero.103 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
165 in_flight: AtomicI64,104 /// The section's root object space, which names the document.
166 worker: Mutex<std::sync::Weak<worker::Signal>>,105 root: ExGuid,
167}106}
168107
169impl Replica {108impl Replica {
170 /// Seeds a new cache from a validated notebook image, refusing any existing path.109 /// Seeds a new cache from a validated section image, refusing any existing path.
171 /// An initialization error preserves the created file for inspection.110 /// An initialization error preserves the created file for inspection.
172 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {111 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
173 validate(source)?;112 validate(source)?;
...@@ -179,364 +118,224 @@ impl Replica {...@@ -179,364 +118,224 @@ impl Replica {
179 options.mode(0o600);118 options.mode(0o600);
180 }119 }
181 drop(options.open(path.as_ref())?);120 drop(options.open(path.as_ref())?);
182 Self::connect(path.as_ref(), Some(source))121 let mut connection = cache_connection(path.as_ref())?;
122 write_ahead(&connection)?;
123 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
124 let tables: i64 =
125 transaction.query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?;
126 let application: u32 =
127 transaction.pragma_query_value(None, "application_id", |row| row.get(0))?;
128 if application != 0 || tables != 0 {
129 return Err(io::Error::new(
130 io::ErrorKind::AlreadyExists,
131 "Cache initialization found an existing database",
132 )
133 .into());
134 }
135 transaction.pragma_update(None, "application_id", APPLICATION_ID)?;
136 transaction.pragma_update(None, "user_version", schema::VERSION)?;
137 schema::create(&transaction)?;
138 base::write(&transaction, base::Image::Base, source)?;
139 transaction.commit()?;
140 Self::start(connection)
183 }141 }
184142
185 /// Reopens an existing cache and its durable pending edits without network access.143 /// Reopens an existing cache and its durable pending edits without network access.
186 /// Unrecognized databases and unsupported journal modes are rejected without conversion.144 /// A schema-14 cache is converted once, after exporting it to `<path>.v14-recovery`;
145 /// a conversion that cannot reproduce every queued page leaves it untouched.
146 /// Unrecognized databases and unsupported journal modes are rejected.
187 pub fn open(path: impl AsRef<Path>) -> Result<Self> {147 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
188 Self::connect(path.as_ref(), None)148 let path = path.as_ref();
189 }
190
191 fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> {
192 let mut connection = cache_connection(path)?;149 let mut connection = cache_connection(path)?;
193 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
194 let application: u32 =150 let application: u32 =
195 transaction.pragma_query_value(None, "application_id", |row| row.get(0))?;151 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
196 let version: u32 =152 if application != APPLICATION_ID {
197 transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;153 return Err(io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into());
198 if let Some(source) = source {154 }
199 let tables: i64 =155 let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
200 transaction156 if integrity != "ok" {
201 .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?;157 return Err(
202 if application != 0 || version != 0 || tables != 0 {158 io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(),
203 return Err(io::Error::new(159 );
204 io::ErrorKind::AlreadyExists,160 }
205 "Cache initialization found an existing database",161 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
206 )162 // A schema-14 cache converts in its rollback journal, so a failed conversion leaves
207 .into());163 // the file as it was.
208 }164 match version {
209 transaction.pragma_update(None, "application_id", APPLICATION_ID)?;165 migrate::VERSION => migrate::migrate(&mut connection, path)?,
210 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;166 schema::VERSION => {}
211 transaction.execute_batch(167 _ => {
212 "
213 CREATE TABLE replica (
214 id INTEGER PRIMARY KEY CHECK(id=1),
215 base BLOB NOT NULL,
216 working BLOB NOT NULL
217 ) STRICT;
218 ",
219 )?;
220 schema::create(&transaction)?;
221 transaction.execute("INSERT INTO replica VALUES (1, ?1, x'')", [source])?;
222 } else {
223 if application != APPLICATION_ID {
224 return Err(
225 io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into(),
226 );
227 }
228 if version != SCHEMA_VERSION {
229 // Older caches are refused rather than migrated until the application is
230 // usable end to end; the queue must be drained by the build that wrote it.
231 return Err(io::Error::new(168 return Err(io::Error::new(
232 io::ErrorKind::InvalidData,169 io::ErrorKind::InvalidData,
233 format!("Cache schema version {version} is not the supported version {SCHEMA_VERSION}"),170 format!(
171 "Cache schema version {version} is not the supported version {}",
172 schema::VERSION
173 ),
234 )174 )
235 .into());175 .into());
236 }176 }
237 validate_images(&transaction)?;
238 pending(&transaction)?;
239 }177 }
240 transaction.commit()?;178 write_ahead(&connection)?;
179 Self::start(connection)
180 }
181
182 fn start(connection: Connection) -> Result<Self> {
183 let connection = Arc::new(Mutex::new(connection));
184 let worker = Arc::new(Mutex::new(std::sync::Weak::new()));
185 let (sender, thread, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?;
241 Ok(Self {186 Ok(Self {
242 connection: Mutex::new(connection),187 connection,
188 working: Some(sender),
189 thread: Some(thread),
243 synchronization: Mutex::new(()),190 synchronization: Mutex::new(()),
244 in_flight: AtomicI64::new(0),191 worker,
245 worker: Mutex::new(std::sync::Weak::new()),192 root,
246 })193 })
247 }194 }
248195
249 /// Returns the latest complete locally committed image, including pending edits.196 fn lock(&self) -> Result<MutexGuard<'_, Connection>> {
250 pub fn snapshot(&self) -> Result<Vec<u8>> {197 lock(&self.connection)
251 let connection = self
252 .connection
253 .lock()
254 .map_err(|_| io::Error::other("Cache owner panicked"))?;
255 images::working(&connection)
256 }198 }
257199
258 /// The page in `space` as the working image stores it.200 /// Hands `request` to the section thread.
259 pub fn page(&self, space: ExGuid) -> Result<Page> {201 fn send(&self, request: working::Request) -> Result<()> {
260 let snapshot = self.snapshot()?;202 self.working
261 let store = Store::parse(&snapshot)?;203 .as_ref()
262 let index = RevisionIndex::parse(&store)?;204 .and_then(|working| working.send(request).ok())
263 Ok(Page::from_space(&Document::parse(&index)?, space)?)205 .ok_or_else(|| io::Error::other("The section thread stopped").into())
264 }206 }
265207
266 /// Whether no edit is queued and the remote still has the last observed image's stamp,208 /// Asks the section thread and waits for its answer.
267 /// reading neither image; the cache is not locked during remote I/O.209 fn ask<T: Send + 'static>(
268 pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> {210 &self,
269 let lock = || {211 request: impl FnOnce(working::Reply<T>) -> working::Request,
270 self.connection212 ) -> Result<T> {
271 .lock()213 let (sender, receiver) = mpsc::sync_channel(1);
272 .map_err(|_| io::Error::other("Cache owner panicked"))214 self.send(request(Box::new(move |result| {
273 };215 let _ = sender.send(result);
274 if !images::settled(&*lock()?)? {216 })))?;
275 return Ok(false);217 receiver
276 }218 .recv()
277 let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else {219 .map_err(|_| io::Error::other("The section thread stopped"))?
278 return Ok(false);
279 };
280 Ok(images::stamp(&*lock()?)? == stamp)
281 }
282
283 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
284 let connection = self
285 .connection
286 .lock()
287 .map_err(|_| io::Error::other("Cache owner panicked"))?;
288 pending(&connection)
289 }220 }
290221
291 /// Identities and page spaces of the pending edits, oldest first, without reading their222 /// Applies an edit and queues it durably for publication, returning its id once
292 /// operations.223 /// written. A refused edit returns `Rejected` and leaves every page as it was.
293 pub(crate) fn queued(&self) -> Result<Vec<(u64, ExGuid)>> {224 pub fn apply(&self, author: &str, edit: Edit) -> Result<u64> {
294 let connection = self225 self.ask(|reply| working::Request::Apply {
295 .connection226 author: author.to_owned(),
296 .lock()227 edit,
297 .map_err(|_| io::Error::other("Cache owner panicked"))?;228 reply,
298 let mut query = connection.prepare("SELECT id, space FROM edits ORDER BY id")?;229 })
299 let mut rows = query.query([])?;
300 let mut edits = Vec::new();
301 while let Some(row) = rows.next()? {
302 edits.push((
303 u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
304 row.get::<_, String>(1)?.parse()?,
305 ));
306 }
307 Ok(edits)
308 }230 }
309231
310 /// Durably queues an edited page model using the supplied local snapshot.232 /// `apply` without waiting: `reply` runs on the section thread once the edit is
311 /// While the newest pending edit is an unattempted save of the same page, a new save233 /// durable or refused.
312 /// replaces its result instead of queueing another publication, as OneNote does234 pub(crate) fn submit(
313 /// within its own save interval. An unchanged model returns `None`.
314 pub fn save(
315 &self,235 &self,
316 source: &[u8],
317 space: ExGuid,
318 after: &Page,
319 author: &str,236 author: &str,
320 ) -> Result<Option<u64>> {237 edit: Edit,
321 Ok(self.save_image(source, space, after, author)?.0)238 reply: working::Reply<u64>,
239 ) -> Result<()> {
240 self.send(working::Request::Apply {
241 author: author.to_owned(),
242 edit,
243 reply,
244 })
322 }245 }
323246
324 /// `save`, with the working image it leaves.247 /// The page in `space` as the queued edits leave it; O(page), for opening and reloading.
325 pub(crate) fn save_image(248 pub fn page(&self, space: ExGuid) -> Result<Page> {
326 &self,249 self.ask(|reply| working::Request::Page { space, reply })
327 source: &[u8],
328 space: ExGuid,
329 after: &Page,
330 author: &str,
331 ) -> Result<(Option<u64>, Vec<u8>)> {
332 let prepared = PreparedEdit::page(source, space, after, author)?;
333 if prepared.as_bytes() == source {
334 return Ok((None, source.to_vec()));
335 }
336 let before = page_of(source, space)?.ok_or_else(|| {
337 io::Error::new(
338 io::ErrorKind::InvalidData,
339 "The saved page is not in the local image",
340 )
341 })?;
342 {
343 let mut connection = self
344 .connection
345 .lock()
346 .map_err(|_| io::Error::other("Cache owner panicked"))?;
347 let transaction =
348 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
349 let (base, current) = images::both(&transaction)?;
350 if current != source {
351 return Err(io::Error::new(
352 io::ErrorKind::ResourceBusy,
353 "The local snapshot changed before this edit",
354 )
355 .into());
356 }
357 let newest: Option<(i64, String, String)> = transaction
358 .query_row(
359 "SELECT id, space, operation FROM edits WHERE id=(SELECT max(id) FROM edits)",
360 [],
361 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
362 )
363 .optional()?;
364 // The intent a step is publishing has no attempt row yet; rewriting it would lose
365 // this save under the published bytes.
366 if let Some((id, sid, operation)) = newest
367 && id != self.in_flight.load(std::sync::atomic::Ordering::Acquire)
368 && sid == space.to_string()
369 && let Ok(Operation::Page(mut head)) = serde_json::from_str::<Operation>(&operation)
370 {
371 let attempted: bool = transaction.query_row(
372 "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1) OR EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1)",
373 [id],
374 |row| row.get(0),
375 )?;
376 if !attempted {
377 head.after = after.clone();
378 transaction.execute(
379 "UPDATE edits SET operation=?1 WHERE id=?2",
380 params![
381 serde_json::to_string(&Operation::Page(head))
382 .map_err(io::Error::other)?,
383 id
384 ],
385 )?;
386 images::set_working(&transaction, &base, prepared.as_bytes())?;
387 transaction.commit()?;
388 drop(connection);
389 self.wake_sync();
390 let id = u64::try_from(id).map_err(io::Error::other)?;
391 return Ok((Some(id), prepared.as_bytes().to_vec()));
392 }
393 }
394 }
395 let id = self.record(
396 source,
397 space,
398 Operation::Page(PageIntent {
399 before,
400 after: after.clone(),
401 author: author.to_owned(),
402 }),
403 &prepared,
404 )?;
405 Ok((id, prepared.as_bytes().to_vec()))
406 }250 }
407251
408 /// Queues a new page and its section entry with stable identities for dependent edits.252 /// Page spaces, titles and outline levels (1 at the top) in section order.
409 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {253 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
410 let edit = PreparedEdit::create_page(source, page)?;254 self.ask(|reply| working::Request::Pages { reply })
411 self.record(
412 source,
413 page.space(),
414 Operation::CreatePage(page.clone()),
415 &edit,
416 )
417 }255 }
418256
419 /// Queues the permanent removal of explicitly selected pages; the batch is republished257 /// The section image the queued edits leave, the unsealed ones sealed as one more
420 /// only while every selected page still exists remotely.258 /// revision whose identities differ per call: O(section), for tests and diagnostics.
421 pub fn delete_pages(&self, source: &[u8], pages: &[ExGuid]) -> Result<Option<u64>> {259 pub fn snapshot(&self) -> Result<Vec<u8>> {
422 let edit = PreparedEdit::delete_pages_permanently(source, pages)?;260 self.ask(|reply| working::Request::Image { reply })
423 let space = validate(source)?;
424 self.record(source, space, Operation::DeletePages(pages.to_vec()), &edit)
425 }261 }
426262
427 fn record(263 /// The section file's identity, which internal links name as `section-id`.
428 &self,264 pub fn identity(&self) -> Result<[u8; 16]> {
429 source: &[u8],265 let stamp = base::stamp(&*self.lock()?, base::Image::Base)?
430 space: ExGuid,266 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?;
431 operation: Operation,267 Ok(onestore::Header::parse(&stamp.header)?.file_id)
432 edit: &PreparedEdit<'_>,268 }
433 ) -> Result<Option<u64>> {269
434 let mut connection = self270 /// Queued edits, oldest first.
435 .connection271 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
436 .lock()272 pending(&*self.lock()?)
437 .map_err(|_| io::Error::other("Cache owner panicked"))?;
438 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
439 let (base, current) = images::both(&transaction)?;
440 if current != source {
441 return Err(io::Error::new(
442 io::ErrorKind::ResourceBusy,
443 "The local snapshot changed before this edit",
444 )
445 .into());
446 }
447 if edit.as_bytes() == source {
448 return Ok(None);
449 }
450 transaction.execute(
451 "INSERT INTO edits(space, operation) VALUES (?1, ?2)",
452 params![
453 space.to_string(),
454 serde_json::to_string(&operation).map_err(io::Error::other)?
455 ],
456 )?;
457 let id = u64::try_from(transaction.last_insert_rowid()).map_err(io::Error::other)?;
458 images::set_working(&transaction, &base, edit.as_bytes())?;
459 transaction.commit()?;
460 drop(connection);
461 self.wake_sync();
462 Ok(Some(id))
463 }273 }
464274
465 fn wake_sync(&self) {275 fn wake_sync(&self) {
466 if let Ok(worker) = self.worker.lock()276 wake(&self.worker);
467 && let Some(worker) = worker.upgrade()277 }
468 {278}
469 worker.wake();279
280impl Drop for Replica {
281 fn drop(&mut self) {
282 drop(self.working.take());
283 if let Some(thread) = self.thread.take() {
284 let _ = thread.join();
470 }285 }
471 }286 }
472}287}
473288
474fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> {289fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> {
475 let store = Store::parse(source)?;290 connection
476 let index = RevisionIndex::parse(&store)?;291 .lock()
477 let document = Document::parse(&index)?;292 .map_err(|_| io::Error::other("Cache owner panicked").into())
478 Ok(Page::from_space(&document, space).ok())
479}293}
480294
481fn validate(source: &[u8]) -> Result<ExGuid> {295fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) {
482 let store = Store::parse(source)?;296 if let Ok(worker) = worker.lock()
483 if !store.checksum_mismatches.is_empty() {297 && let Some(worker) = worker.upgrade()
484 return Err(io::Error::new(298 {
485 io::ErrorKind::InvalidData,299 worker.wake();
486 "Notebook transaction checksum damage",
487 )
488 .into());
489 }300 }
490 let index = RevisionIndex::parse(&store)?;301}
491 index.validate_current()?;302
492 Document::parse(&index)?;303/// Fully validates a section image, returning its root object space.
493 Ok(index.root)304fn validate(source: &[u8]) -> Result<ExGuid> {
305 let arena = onestore::Arena::default();
306 Ok(onestore::Section::open(&arena, source.to_vec())?.root())
494}307}
495308
496fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {309fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
497 let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?;310 queue::load(connection, None)
498 let mut rows = query.query([])?;
499 let mut edits = Vec::new();
500 while let Some(row) = rows.next()? {
501 edits.push(pending_edit(row)?);
502 }
503 Ok(edits)
504}311}
505312
506fn pending_edit(row: &rusqlite::Row<'_>) -> Result<PendingEdit> {313fn unsigned(value: i64) -> Result<u64> {
507 let edit = PendingEdit {314 Ok(u64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?)
508 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,315}
509 space: row.get::<_, String>(1)?.parse()?,316
510 operation: serde_json::from_str(&row.get::<_, String>(2)?)317fn signed(value: u64) -> Result<i64> {
511 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,318 Ok(i64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?)
512 };
513 if matches!(&edit.operation, Operation::CreatePage(page) if page.space() != edit.space) {
514 return Err(io::Error::new(
515 io::ErrorKind::InvalidData,
516 "Cached page identity differs from its creation intent",
517 )
518 .into());
519 }
520 Ok(edit)
521}319}
522320
321/// Opens a cache without writing to it: exclusive locking, a full sync of every commit
322/// (`F_FULLFSYNC` on macOS, the WAL's syncs included) and foreign keys, each queried back.
523fn cache_connection(path: &Path) -> Result<Connection> {323fn cache_connection(path: &Path) -> Result<Connection> {
524 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;324 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
525 connection.busy_timeout(Duration::ZERO)?;325 connection.busy_timeout(Duration::ZERO)?;
526 connection.execute_batch(326 connection.execute_batch(
527 "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=EXTRA; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;",327 "PRAGMA locking_mode=EXCLUSIVE; PRAGMA synchronous=FULL; PRAGMA fullfsync=ON; PRAGMA foreign_keys=ON;",
528 )?;328 )?;
529 for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] {329 let locking: String = connection.pragma_query_value(None, "locking_mode", |row| row.get(0))?;
530 let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?;330 let journal: String = connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
531 if actual != expected {331 if locking != "exclusive" || !["wal", "delete"].contains(&journal.as_str()) {
532 return Err(io::Error::new(332 return Err(io::Error::new(
533 io::ErrorKind::InvalidData,333 io::ErrorKind::InvalidData,
534 "Unsupported cache locking or journal mode",334 "Unsupported cache locking or journal mode",
535 )335 )
536 .into());336 .into());
537 }
538 }337 }
539 for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] {338 for (name, expected) in [("synchronous", 2), ("fullfsync", 1), ("foreign_keys", 1)] {
540 let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?;339 let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?;
541 if actual != expected {340 if actual != expected {
542 return Err(io::Error::new(341 return Err(io::Error::new(
...@@ -549,20 +348,27 @@ fn cache_connection(path: &Path) -> Result<Connection> {...@@ -549,20 +348,27 @@ fn cache_connection(path: &Path) -> Result<Connection> {
549 Ok(connection)348 Ok(connection)
550}349}
551350
552fn validate_images(connection: &Connection) -> Result<()> {351/// Switches a cache to write-ahead logging: a commit appends its pages to `<cache>-wal`
553 let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?;352/// instead of copying the originals to a rollback journal. Under exclusive locking the WAL
554 if integrity != "ok" {353/// index lives in memory, so the WAL is the only file beside the cache; it is checkpointed
555 return Err(354/// and removed on close, and replayed on the next open after a crash.
556 io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(),355fn write_ahead(connection: &Connection) -> Result<()> {
557 );356 let mode: String =
558 }357 connection.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?;
559 let (base, working) = images::both(connection)?;358 if mode != "wal" {
560 if validate(&base)? != validate(&working)? {
561 return Err(io::Error::new(359 return Err(io::Error::new(
562 io::ErrorKind::InvalidData,360 io::ErrorKind::Unsupported,
563 "Cache images belong to different documents",361 "The cache cannot use write-ahead logging",
564 )362 )
565 .into());363 .into());
566 }364 }
567 Ok(())365 Ok(())
568}366}
367
368/// FILETIME now, as an edit's `at`.
369pub(crate) fn now() -> u64 {
370 let unix = std::time::SystemTime::now()
371 .duration_since(std::time::UNIX_EPOCH)
372 .unwrap_or_default();
373 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
374}
crates/notebook/src/merge.rs+1102-476
...@@ -1,554 +1,1180 @@...@@ -1,554 +1,1180 @@
1//! Three-way merge of page models: the remote page keeps everything it changed, and the1//! Rebasing queued edits onto a changed remote section. Each op replays on the remote
2//! local changes (`base` → `ours`) are re-applied wherever the two sides touched2//! section when the objects it reads are as the base image had them; text ops on a text
3//! different objects, fields or text ranges. Any overlap is a conflict for review.3//! the remote also changed shift past the remote's changes when they stay clear of them;
4//! a move, deletion or outline setting the remote already made is dropped as done.
5//! Anything else is a conflict: the queue stays on its base until it is resolved.
6//! Objects the queue itself created are not in the base, so nothing remote can have
7//! changed them.
48
9use crate::{ConflictKind, PendingEdit, Result};
5use onestore::{10use onestore::{
6 ExGuid,11 ExGuid, OutlineEdit, PageCreation, PageEdit, PagePosition, Section,
7 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, text::Edit},12 document::{Format, Layout, Tag},
13 op::{Edit, Op, OpError, PageOp, SectionOp, TableEdit},
14 page::{
15 Attachment, Image, Ink, MediaIndex, Outline, Page, PageObject, PageParagraph, Paragraph,
16 ParagraphContent, TableColumn, TextObject, Unsupported,
17 },
18};
19use std::{
20 collections::{BTreeMap, BTreeSet},
21 ops::Range,
8};22};
9use std::collections::{BTreeMap, BTreeSet};
1023
11pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {24/// A conflict being resolved: from edit `first` on, ops on `space` are dropped. Keeping
12 if ours == base {25/// mine replaces the first of them by the ops rewriting the remote page to `target` (or
13 return Some(theirs.clone());26/// putting `target` back as a page where the remote removed it), or, for the section's
14 }27/// page list, keeps what of each section op still applies.
15 if theirs == base || ours == theirs {28pub(crate) struct Resolving<'p> {
16 return Some(ours.clone());29 pub first: u64,
17 }30 pub space: ExGuid,
18 if ours.created != base.created || ours.margin_origin != base.margin_origin {31 pub mine: bool,
19 return None;32 pub target: Option<&'p Page>,
20 }33}
21 fn by_id(page: &Page) -> BTreeMap<ExGuid, &PageObject> {34
22 page.objects35pub(crate) enum Outcome {
23 .iter()36 /// Every edit applied; these edits' ops changed. An edit whose ops were all dropped
24 .map(|object| (object.id(), object))37 /// stays, empty, to be published and acknowledged with its batch.
25 .collect()38 Applied(Vec<(u64, Edit)>),
26 }39 Conflict {
27 let (b, o, t) = (by_id(base), by_id(ours), by_id(theirs));40 id: u64,
28 let mut result: Vec<PageObject> = Vec::new();41 space: ExGuid,
29 for object in &theirs.objects {42 kind: ConflictKind,
30 let id = object.id();43 },
31 match (b.get(&id), o.get(&id)) {44}
32 (Some(before), Some(after)) => match (before, after, object) {45
33 (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => {46/// Replays `edits` on `new`, the remote section; `old` is the base they were made on.
34 result.push(PageObject::Outline(merge_outline(x, y, z)?));47pub(crate) fn rebase(
48 old: &mut Section<'_>,
49 new: &mut Section<'_>,
50 edits: &[PendingEdit],
51 converged: &BTreeSet<ExGuid>,
52 resolving: Option<Resolving<'_>>,
53) -> Result<Outcome> {
54 let root = new.root();
55 let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect();
56 let after: BTreeMap<ExGuid, ExGuid> = new.revisions().collect();
57 let orders = (order(old)?, order(new)?);
58 let mut state = Replay {
59 before,
60 after,
61 orders,
62 created: BTreeSet::new(),
63 diffs: BTreeMap::new(),
64 };
65 let mut rewritten = Vec::new();
66 let mut resolved = false;
67 for queued in edits {
68 let branch = resolving
69 .as_ref()
70 .filter(|resolving| queued.id >= resolving.first);
71 let mut ops = Vec::new();
72 let mut changed = false;
73 for op in &queued.edit.ops {
74 let conflict = |kind, space| Outcome::Conflict {
75 id: queued.id,
76 space,
77 kind,
78 };
79 if let Some(resolving) = branch {
80 let dropped = match op {
81 Op::Page { space, .. } => *space == resolving.space,
82 Op::Section(_) => resolving.space == root,
83 };
84 if dropped {
85 changed = true;
86 let rewrite = match op {
87 Op::Page { space, .. } if !resolved && resolving.mine => {
88 resolving.target.map(|target| (*space, target))
89 }
90 _ => None,
91 };
92 resolved |= matches!(op, Op::Page { .. });
93 if let Some((space, target)) = rewrite {
94 if !state.listed(space) {
95 // Kept while the remote removed it: the page comes back.
96 match state.restore(
97 new,
98 space,
99 target,
100 &queued.author,
101 queued.edit.at,
102 )? {
103 Ok(restored) => ops.extend(restored),
104 Err(kind) => return Ok(conflict(kind, space)),
105 }
106 continue;
107 }
108 let current = new.page(space)?;
109 for op in onestore::op::lower_page(&current, target)
110 .map_err(|error| OpError::Unsupported(error.message))?
111 {
112 let op = Op::Page { space, op };
113 if let Some(kind) =
114 state.apply(new, &queued.author, queued.edit.at, &op)?
115 {
116 return Ok(conflict(kind, space));
117 }
118 ops.push(op);
119 }
120 }
121 if let (Op::Section(op), true) = (op, resolving.mine) {
122 // Keeping mine for the section list: the first form that applies.
123 for op in state.salvage(new, op) {
124 let op = Op::Section(op);
125 if state
126 .apply(new, &queued.author, queued.edit.at, &op)?
127 .is_none()
128 {
129 ops.push(op);
130 break;
131 }
132 }
133 }
134 continue;
35 }135 }
36 _ => result.push(pick(before, after, &object)?.clone()),136 }
37 },137 let space = match op {
38 (Some(before), None) => {138 Op::Page { space, .. } => *space,
39 // Removed locally; keep only if the remote left it untouched.139 Op::Section(_) => root,
40 if object != *before {140 };
41 return None;141 if matches!(op, Op::Page { .. }) && converged.contains(&space) {
142 changed = true;
143 continue;
144 }
145 let mapped = match state.check(old, new, op)? {
146 Ok(Some(mapped)) => mapped,
147 Ok(None) => {
148 changed = true;
149 continue;
42 }150 }
151 Err(kind) => return Ok(conflict(kind, space)),
152 };
153 changed |= mapped != *op;
154 if let Some(kind) = state.apply(new, &queued.author, queued.edit.at, &mapped)? {
155 return Ok(conflict(kind, space));
43 }156 }
44 (None, _) => result.push(object.clone()),157 ops.push(mapped);
158 }
159 if changed {
160 rewritten.push((
161 queued.id,
162 Edit {
163 at: queued.edit.at,
164 ops,
165 },
166 ));
45 }167 }
46 }168 }
47 for (index, object) in ours.objects.iter().enumerate() {169 Ok(Outcome::Applied(rewritten))
48 let id = object.id();170}
49 if b.contains_key(&id) {171
50 if !t.contains_key(&id) {172fn order(section: &mut Section<'_>) -> Result<Order> {
51 // Removed remotely; a local edit to it cannot be placed.173 Ok(section
52 if b[&id] != object {174 .pages()?
53 return None;175 .into_iter()
176 .map(|(space, _, level)| (space, level))
177 .collect())
178}
179
180/// Page spaces in section order with their levels.
181type Order = Vec<(ExGuid, u32)>;
182
183struct Replay {
184 /// Active revisions of the base and the remote.
185 before: BTreeMap<ExGuid, ExGuid>,
186 after: BTreeMap<ExGuid, ExGuid>,
187 /// Page order of the base and the remote.
188 orders: (Order, Order),
189 /// Page spaces the replayed edits created.
190 created: BTreeSet<ExGuid>,
191 /// Per page the remote changed, what it changed; `None` when it left the page alone.
192 diffs: BTreeMap<ExGuid, Option<Diff>>,
193}
194
195impl Replay {
196 /// Applies one op to the remote section; `Some` names why it did not apply.
197 fn apply(
198 &mut self,
199 new: &mut Section<'_>,
200 author: &str,
201 at: u64,
202 op: &Op,
203 ) -> Result<Option<ConflictKind>> {
204 match new.apply(
205 author,
206 &Edit {
207 at,
208 ops: vec![op.clone()],
209 },
210 ) {
211 Ok(()) => {
212 if let Op::Section(
213 SectionOp::Create(creation) | SectionOp::Import { creation, .. },
214 ) = op
215 {
216 self.created.insert(creation.space());
54 }217 }
218 Ok(None)
55 }219 }
56 continue;220 Err(OpError::Failed(error)) => Err(error.into()),
221 Err(error) => Ok(Some(match op {
222 // A page list the remote reordered refuses the op, however it says so.
223 Op::Section(_) => ConflictKind::StructureChanged,
224 Op::Page { .. } if matches!(error, OpError::Unsupported(_)) => {
225 ConflictKind::UnsupportedEdit
226 }
227 Op::Page { space, .. } if !self.listed(*space) => ConflictKind::TargetUnavailable,
228 Op::Page { .. } => ConflictKind::ContentChanged,
229 })),
57 }230 }
58 if t.contains_key(&id) {231 }
59 return None;232
233 /// The op as it replays on the remote section, or why it cannot.
234 fn check(
235 &mut self,
236 old: &mut Section<'_>,
237 new: &mut Section<'_>,
238 op: &Op,
239 ) -> Result<std::result::Result<Option<Op>, ConflictKind>> {
240 match op {
241 Op::Page { space, op } => {
242 if !self.listed(*space) {
243 return Ok(Err(ConflictKind::TargetUnavailable));
244 }
245 let diff = match self.diffs.get_mut(space) {
246 Some(diff) => diff,
247 None => {
248 let diff = match (self.before.get(space), self.after.get(space)) {
249 (Some(before), Some(after)) if before != after => Some(Diff {
250 old: Index::of(&old.page(*space)?),
251 new: Index::of(&new.page(*space)?),
252 regions: BTreeMap::new(),
253 }),
254 _ => None,
255 };
256 self.diffs.entry(*space).or_insert(diff)
257 }
258 };
259 Ok(match diff {
260 None => Ok(Some(Op::Page {
261 space: *space,
262 op: op.clone(),
263 })),
264 Some(diff) => diff
265 .check(op)
266 .map(|op| op.map(|op| Op::Page { space: *space, op })),
267 })
268 }
269 Op::Section(section) => Ok(self.section(section).map(|()| Some(op.clone()))),
60 }270 }
61 let PageObject::Outline(_) = object else {
62 return None;
63 };
64 let predecessor = ours.objects[..index]
65 .iter()
66 .rev()
67 .map(PageObject::id)
68 .find(|other| result.iter().any(|r| r.id() == *other));
69 let at = match predecessor {
70 Some(other) => result.iter().position(|r| r.id() == other).unwrap() + 1,
71 None => 0,
72 };
73 let at = at.min(
74 result
75 .iter()
76 .position(|r| matches!(r, PageObject::Title(_)))
77 .unwrap_or(result.len()),
78 );
79 result.insert(at, object.clone());
80 }271 }
81 let order = |objects: &[PageObject], keep: &dyn Fn(ExGuid) -> bool| -> Vec<ExGuid> {272
82 objects273 /// Whether the remote section lists the page, or the replayed edits created it; a page
274 /// removed from the list keeps its stored revisions.
275 fn listed(&self, space: ExGuid) -> bool {
276 self.created.contains(&space) || self.orders.1.iter().any(|(page, _)| *page == space)
277 }
278
279 /// Puts `page`, which the remote removed, back into the section and returns the ops
280 /// that did: a copy under fresh identities at its level, before the first page after it
281 /// that the remote still has and that can take a page before it, or last.
282 fn restore(
283 &mut self,
284 new: &mut Section<'_>,
285 space: ExGuid,
286 page: &Page,
287 author: &str,
288 at: u64,
289 ) -> Result<std::result::Result<Vec<Op>, ConflictKind>> {
290 let old = self.orders.0.clone();
291 let position = old.iter().position(|(listed, _)| *listed == space);
292 let anchors = position
293 .map_or(&[][..], |position| &old[position + 1..])
83 .iter()294 .iter()
84 .filter(|object| matches!(object, PageObject::Outline(_)) && keep(object.id()))295 .map(|(next, _)| Some(*next))
85 .map(PageObject::id)296 .filter(|next| next.is_some_and(|next| self.listed(next)))
86 .collect()297 .chain([None])
87 };298 .collect::<Vec<_>>();
88 let common = |id: ExGuid| b.contains_key(&id) && o.contains_key(&id) && t.contains_key(&id);299 let copy = page.copy()?;
89 let (base_order, our_order, their_order) = (300 let mut refusal = ConflictKind::StructureChanged;
90 order(&base.objects, &common),301 for before in anchors {
91 order(&ours.objects, &common),302 let creation = PageCreation::new(before, Some(&page.title), author)?;
92 order(&theirs.objects, &common),303 let import = Op::Section(SectionOp::Import {
93 );304 creation: creation.clone(),
94 if our_order != base_order {305 page: copy.clone(),
95 if their_order != base_order && their_order != our_order {306 });
96 return None;307 match self.apply(new, author, at, &import)? {
308 Some(kind) => refusal = kind,
309 None => {
310 let mut ops = vec![import];
311 if let Some(level) = position.map(|at| old[at].1).filter(|level| *level > 1) {
312 let indent = Op::Section(SectionOp::Pages(vec![PageEdit::set_level(
313 creation.space(),
314 level,
315 )?]));
316 if let Some(kind) = self.apply(new, author, at, &indent)? {
317 return Ok(Err(kind));
318 }
319 ops.push(indent);
320 }
321 return Ok(Ok(ops));
322 }
323 }
97 }324 }
98 result = reorder(result, &our_order);325 Ok(Err(refusal))
99 }326 }
100 // Definitions we added or changed travel with our paragraphs; theirs win elsewhere.327
101 let mut definitions = theirs.definitions.clone();328 fn section(&self, op: &SectionOp) -> std::result::Result<(), ConflictKind> {
102 for (id, definition) in &ours.definitions {329 let (old, new) = &self.orders;
103 match (base.definitions.get(id), theirs.definitions.get(id)) {330 let find =
104 (Some(before), Some(after)) if before != definition => {331 |list: &[(ExGuid, u32)], space: ExGuid| list.iter().position(|(s, _)| *s == space);
105 if after != before && after != definition {332 match op {
106 return None;333 SectionOp::Create(_) | SectionOp::Import { .. } => Ok(()),
334 SectionOp::Delete(pages) => {
335 for page in pages {
336 if find(new, *page).is_none() && !self.created.contains(page) {
337 return Err(ConflictKind::TargetUnavailable);
338 }
339 if self.before.get(page) != self.after.get(page) {
340 return Err(ConflictKind::ContentChanged);
341 }
107 }342 }
108 definitions.insert(*id, definition.clone());343 Ok(())
109 }344 }
110 (None, None) => {345 SectionOp::Pages(edits) => {
111 definitions.insert(*id, definition.clone());346 // Where the batch puts each page, from the base order.
347 let mut wanted: Vec<ExGuid> = old.iter().map(|(space, _)| *space).collect();
348 for edit in edits {
349 if let PagePosition::Before(before) = edit.position()
350 && let Some(at) = wanted.iter().position(|space| *space == edit.space())
351 {
352 let space = wanted.remove(at);
353 let at = before
354 .and_then(|before| wanted.iter().position(|space| *space == before))
355 .unwrap_or(wanted.len());
356 wanted.insert(at, space);
357 }
358 }
359 let wanted: Vec<(ExGuid, u32)> =
360 wanted.into_iter().map(|space| (space, 0)).collect();
361 for edit in edits {
362 let space = edit.space();
363 let Some(at) = find(old, space) else {
364 continue;
365 };
366 let Some(now) = find(new, space) else {
367 return Err(ConflictKind::TargetUnavailable);
368 };
369 if old[at].1 != new[now].1 && new[now].1 != edit.level() {
370 return Err(ConflictKind::StructureChanged);
371 }
372 if edit.position() != PagePosition::Keep {
373 // Each page the remote also has must keep its side of the moved page,
374 // or already be on the side the batch wants.
375 let side = |list: &[(ExGuid, u32)], peer: ExGuid| {
376 Some(find(list, peer)? < find(list, space)?)
377 };
378 let conflicting = old.iter().any(|(peer, _)| {
379 *peer != space
380 && find(new, *peer).is_some()
381 && side(new, *peer) != side(old, *peer)
382 && side(new, *peer) != side(&wanted, *peer)
383 });
384 if conflicting {
385 return Err(ConflictKind::StructureChanged);
386 }
387 }
388 }
389 Ok(())
390 }
391 }
392 }
393
394 /// Forms of a section op to try on the remote section list, in order: as it is, a
395 /// creation appended instead, moves and removals of the pages still there.
396 fn salvage(&self, new: &mut Section<'_>, op: &SectionOp) -> Vec<SectionOp> {
397 let pages: BTreeSet<ExGuid> = new
398 .pages()
399 .map(|pages| pages.into_iter().map(|(space, ..)| space).collect())
400 .unwrap_or_default();
401 let present = |space: &ExGuid| pages.contains(space) || self.created.contains(space);
402 match op {
403 SectionOp::Create(creation) => std::iter::once(creation.clone())
404 .chain(creation.reposition(None).ok())
405 .map(SectionOp::Create)
406 .collect(),
407 SectionOp::Import { .. } => vec![op.clone()],
408 SectionOp::Pages(edits) => {
409 let edits: Vec<PageEdit> = edits
410 .iter()
411 .filter(|edit| present(&edit.space()))
412 .cloned()
413 .collect();
414 if edits.is_empty() {
415 Vec::new()
416 } else {
417 vec![SectionOp::Pages(edits)]
418 }
419 }
420 SectionOp::Delete(deleted) => {
421 let deleted: Vec<ExGuid> = deleted
422 .iter()
423 .filter(|space| present(space))
424 .copied()
425 .collect();
426 if deleted.is_empty() {
427 Vec::new()
428 } else {
429 vec![SectionOp::Delete(deleted)]
430 }
112 }431 }
113 (None, Some(after)) if after != definition => return None,
114 _ => {}
115 }432 }
116 }433 }
117 Some(Page {
118 title: theirs.title.clone(),
119 identity: theirs.identity,
120 created: theirs.created,
121 margin_origin: theirs.margin_origin,
122 objects: result,
123 definitions,
124 })
125}434}
126435
127/// Places the objects named in `order` into the slots those objects occupy in `objects`,436/// One page as the base stored it and as the remote does, with the remote's text changes
128/// leaving every other object where it is.437/// in the coordinates of the replayed edits.
129fn reorder(objects: Vec<PageObject>, order: &[ExGuid]) -> Vec<PageObject> {438struct Diff {
130 let mut slots: Vec<Option<PageObject>> = objects.into_iter().map(Some).collect();439 old: Index,
131 let positions: Vec<usize> = slots440 new: Index,
132 .iter()441 regions: BTreeMap<ExGuid, Changes>,
133 .enumerate()
134 .filter(|(_, slot)| {
135 slot.as_ref()
136 .is_some_and(|object| order.contains(&object.id()))
137 })
138 .map(|(i, _)| i)
139 .collect();
140 let mut taken: BTreeMap<ExGuid, PageObject> = positions
141 .iter()
142 .map(|&i| {
143 let object = slots[i].take().unwrap();
144 (object.id(), object)
145 })
146 .collect();
147 for (slot, id) in positions.into_iter().zip(order) {
148 slots[slot] = taken.remove(id);
149 }
150 slots.into_iter().flatten().collect()
151}442}
152443
153fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> {444impl Diff {
154 if ours == base {445 fn ours(&self, id: ExGuid) -> bool {
155 Some(theirs.clone())446 !self.old.nodes.contains_key(&id)
156 } else if theirs == base || theirs == ours {
157 Some(ours.clone())
158 } else {
159 None
160 }447 }
161}
162448
163fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> {449 /// Whether the remote left the object's own data as the base had it.
164 let title = pick(&base.title, &ours.title, &theirs.title)?;450 fn kept(&self, id: ExGuid) -> bool {
165 let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?;451 self.ours(id)
166 let indents = pick(&base.indents, &ours.indents, &theirs.indents)?;452 || matches!((self.old.nodes.get(&id), self.new.nodes.get(&id)), (Some(a), Some(b)) if a.own == b.own)
167 let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?;453 }
168 let mut layout = theirs.layout.clone();
169 let (x, y) = pick(
170 &(base.layout.x, base.layout.y),
171 &(ours.layout.x, ours.layout.y),
172 &(theirs.layout.x, theirs.layout.y),
173 )?;
174 // A user-set width clears the wrap reservation, so the three travel together.
175 let width = |layout: &onestore::document::Layout| {
176 (
177 layout.max_width,
178 layout.width_set_by_user,
179 layout.reserved_width,
180 )
181 };
182 let (max_width, user_set, reserved) = pick(
183 &width(&base.layout),
184 &width(&ours.layout),
185 &width(&theirs.layout),
186 )?;
187 layout.x = x;
188 layout.y = y;
189 layout.max_width = max_width;
190 layout.width_set_by_user = user_set;
191 layout.reserved_width = reserved;
192 Some(Outline {
193 id: theirs.id,
194 title,
195 min_width,
196 layout,
197 indents,
198 paragraphs: merge_paragraphs(&base.paragraphs, &ours.paragraphs, &theirs.paragraphs)?,
199 unsupported,
200 })
201}
202454
203type Children = BTreeMap<Option<ExGuid>, Vec<ExGuid>>;455 /// Whether the remote left the object and everything under it as the base had it.
456 fn untouched(&self, id: ExGuid) -> bool {
457 if self.ours(id) {
458 return true;
459 }
460 let (Some(a), Some(b)) = (self.old.nodes.get(&id), self.new.nodes.get(&id)) else {
461 return false;
462 };
463 a.own == b.own
464 && a.parent == b.parent
465 && a.children == b.children
466 && a.below().all(|child| self.untouched(child))
467 }
204468
205fn children(list: &[PageParagraph]) -> Children {469 /// Whether the remote kept the object under the same parent, in the same order among
206 let mut children: Children = BTreeMap::new();470 /// the siblings both sides have.
207 for paragraph in list {471 fn placed(&self, id: ExGuid) -> bool {
208 children472 if self.ours(id) {
209 .entry(paragraph.parent)473 return true;
210 .or_default()474 }
211 .push(paragraph.id);475 let (Some(a), Some(b)) = (self.old.nodes.get(&id), self.new.nodes.get(&id)) else {
476 return false;
477 };
478 if a.parent != b.parent {
479 return false;
480 }
481 let siblings = |index: &Index| index.children(a.parent).to_vec();
482 let (old, new) = (siblings(&self.old), siblings(&self.new));
483 let at = |list: &[ExGuid], id: ExGuid| list.iter().position(|x| *x == id);
484 let (Some(i), Some(j)) = (at(&old, id), at(&new, id)) else {
485 return false;
486 };
487 old.iter()
488 .enumerate()
489 .all(|(k, peer)| *peer == id || at(&new, *peer).is_none_or(|l| (k < i) == (l < j)))
212 }490 }
213 children
214}
215491
216fn merge_paragraphs(492 /// Where the remote changed a text's characters and formats; its tags and date field
217 base: &[PageParagraph],493 /// are not positions an op names.
218 ours: &[PageParagraph],494 fn region(&mut self, text: ExGuid) -> std::result::Result<Option<&mut Changes>, ConflictKind> {
219 theirs: &[PageParagraph],495 if !self.regions.contains_key(&text) {
220) -> Option<Vec<PageParagraph>> {496 if self.ours(text) {
221 fn index(list: &[PageParagraph]) -> BTreeMap<ExGuid, &PageParagraph> {497 return Ok(None);
222 list.iter().map(|p| (p.id, p)).collect()498 }
223 }499 let (Some(Own::Text(a)), Some(Own::Text(b))) = (
224 let (b, o, t) = (index(base), index(ours), index(theirs));500 self.old.nodes.get(&text).map(|node| &node.own),
225 let mut merged: BTreeMap<ExGuid, PageParagraph> = BTreeMap::new();501 self.new.nodes.get(&text).map(|node| &node.own),
226 let mut removed = BTreeSet::new();502 ) else {
227 for id in b.keys().chain(o.keys()).chain(t.keys()) {503 return Err(ConflictKind::ContentChanged);
228 if merged.contains_key(id) || removed.contains(id) {504 };
229 continue;505 if a.text == b.text {
506 return Ok(None);
507 }
508 let region = Changes::between(&a.text, &b.text);
509 self.regions.insert(text, region);
230 }510 }
231 let paragraph = match (b.get(id), o.get(id), t.get(id)) {511 Ok(self.regions.get_mut(&text))
232 (Some(x), Some(y), Some(z)) => merge_paragraph(x, y, z)?,512 }
233 (Some(x), None, Some(z)) => {513
234 if z != x {514 /// The op as it replays on the remote page; `None` when the remote already made it.
235 return None;515 fn check(&mut self, op: &PageOp) -> std::result::Result<Option<PageOp>, ConflictKind> {
516 use ConflictKind::ContentChanged as Changed;
517 let require = |ok: bool| if ok { Ok(()) } else { Err(Changed) };
518 let mut op = op.clone();
519 match &mut op {
520 PageOp::Text { text, range, with } => {
521 if let Some(region) = self.region(*text)? {
522 let mapped = region.map(range).ok_or(Changed)?;
523 region.replaced(range, with.encode_utf16().count() as u32);
524 *range = mapped;
236 }525 }
237 removed.insert(*id);
238 continue;
239 }526 }
240 (Some(x), Some(y), None) => {527 PageOp::Format { text, range, .. } => {
241 if y != x {528 if let Some(region) = self.region(*text)? {
242 return None;529 *range = region.map(range).ok_or(Changed)?;
243 }530 }
244 removed.insert(*id);
245 continue;
246 }531 }
247 (Some(_), None, None) => {532 PageOp::Link { text, .. } | PageOp::Equation { text, .. } => {
248 removed.insert(*id);533 require(self.region(*text)?.is_none())?;
249 continue;
250 }534 }
251 (None, Some(_), Some(_)) => return None,535 PageOp::Split {
252 (None, Some(y), None) => (*y).clone(),536 text, at, right, ..
253 (None, None, Some(z)) => (*z).clone(),537 } => {
254 (None, None, None) => unreachable!(),538 // The new paragraph copies the holder's placement and formats; lists and tags
255 };539 // stay with the holder unless the split names copies of them.
256 merged.insert(*id, paragraph);540 let holder = self.holder(*text);
257 }541 require(holder.is_none_or(|holder| self.splits_alike(holder)))?;
258 // Child order per container: the remote order with our repositioned paragraphs re-placed.542 if let Some(region) = self.region(*text)? {
259 let (bc, oc, tc) = (children(base), children(ours), children(theirs));543 // The cut moves with the character after it: text the remote typed at
260 // A paragraph we added after one they gave new children would sit after or among those544 // the cut stays left of it.
261 // children; Enter splitting a paragraph carries its children below, so neither is assumed.545 let cut = region.map(&(*at..*at + 1)).ok_or(Changed)?;
262 for pair in oc.values().flat_map(|list| list.windows(2)) {546 let right_changes = region.split(*at);
263 if b.contains_key(&pair[0])547 if !right_changes.0.is_empty() {
264 && !b.contains_key(&pair[1])548 self.regions.insert(*right, right_changes);
265 && tc549 }
266 .get(&Some(pair[0]))550 *at = cut.start;
267 .is_some_and(|children| children.iter().any(|id| !b.contains_key(id)))551 }
268 {552 }
269 return None;553 PageOp::Join { left, right } => {
554 require(self.untouched(*right))?;
555 require(
556 self.holder_text(*right)
557 .is_none_or(|text| !self.regions.contains_key(&text)),
558 )?;
559 if let Some(text) = self.holder_text(*left) {
560 self.region(text)?;
561 }
562 }
563 PageOp::Insert { .. } | PageOp::Add { .. } => {}
564 PageOp::Move {
565 object,
566 parent,
567 before,
568 } => require(self.placed(*object) || self.placed_at(*object, *parent, *before))?,
569 PageOp::Delete { object } => {
570 if !self.ours(*object) && !self.new.nodes.contains_key(object) {
571 return Ok(None);
572 }
573 require(self.untouched(*object))?
574 }
575 PageOp::Level { paragraph, .. } => require(
576 self.ours(*paragraph) || {
577 let level = |index: &Index| {
578 index.nodes.get(paragraph).map(|node| {
579 (
580 node.parent,
581 match &node.own {
582 Own::Paragraph { level, .. } => *level,
583 _ => 0,
584 },
585 )
586 })
587 };
588 level(&self.old).is_some() && level(&self.old) == level(&self.new)
589 },
590 )?,
591 PageOp::Outline { object, edit } => {
592 if self.ours(*object) {
593 return Ok(Some(op));
594 }
595 let edit: &OutlineEdit = edit;
596 // What the edit sets, as each side has it.
597 let part = |index: &Index| {
598 index.nodes.get(object).map(|node| match (&node.own, edit) {
599 (Own::Outline { layout, .. }, OutlineEdit::Position { .. }) => {
600 (layout.x, layout.y, None, None)
601 }
602 (Own::Outline { layout, .. }, OutlineEdit::Width { .. }) => (
603 layout.max_width,
604 layout.reserved_width,
605 layout.width_set_by_user,
606 None,
607 ),
608 (Own::Paragraph { collapsed, .. }, OutlineEdit::Collapsed(_)) => {
609 (None, None, None, Some(*collapsed))
610 }
611 _ => (None, None, None, None),
612 })
613 };
614 let (before, after) = (part(&self.old), part(&self.new));
615 if before.is_some() && before != after {
616 // The remote set the same value: the edit is done.
617 let near = |a: Option<f32>, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-3);
618 let done = match (after, edit) {
619 (Some((x, y, ..)), OutlineEdit::Position { x: px, y: py }) => {
620 near(x, *px) && near(y, *py)
621 }
622 (
623 Some((width, reserved, set, _)),
624 OutlineEdit::Width { points, user_set },
625 ) => near(width, *points) && reserved.is_none() && set == Some(*user_set),
626 (Some((.., collapsed)), OutlineEdit::Collapsed(value)) => {
627 collapsed == Some(*value)
628 }
629 _ => false,
630 };
631 return if done { Ok(None) } else { Err(Changed) };
632 }
633 require(before.is_some())?
634 }
635 PageOp::Paragraph { paragraph, .. }
636 | PageOp::Style { paragraph, .. }
637 | PageOp::List { paragraph, .. } => {
638 require(self.kept(*paragraph) && self.new_has(*paragraph))?
639 }
640 PageOp::Tags { target, .. } => require(self.same_tags(*target))?,
641 PageOp::Picture {
642 picture: object, ..
643 }
644 | PageOp::Attachment {
645 attachment: object, ..
646 }
647 | PageOp::Strokes { ink: object, .. } => {
648 require(self.kept(*object) && self.new_has(*object))?
649 }
650 PageOp::Table { table, edit } => match edit {
651 TableEdit::Cell { cell, .. } => require(self.kept(*cell) && self.new_has(*cell))?,
652 TableEdit::DeleteRow(row) => require(self.kept(*table) && self.untouched(*row))?,
653 TableEdit::DeleteColumn(_) => require(self.untouched(*table))?,
654 TableEdit::Rows { .. }
655 | TableEdit::Column { .. }
656 | TableEdit::Columns(_)
657 | TableEdit::Borders(_) => require(self.kept(*table) && self.new_has(*table))?,
658 },
270 }659 }
660 Ok(Some(op))
271 }661 }
272 let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new();662
273 let containers: BTreeSet<Option<ExGuid>> = bc663 /// Whether the remote put the object where a move places it: under `parent` (the page
274 .keys()664 /// when `None`), before `before` or last.
275 .chain(oc.keys())665 fn placed_at(&self, id: ExGuid, parent: Option<ExGuid>, before: Option<ExGuid>) -> bool {
276 .chain(tc.keys())666 let Some(node) = self.new.nodes.get(&id) else {
277 .copied()667 return false;
278 .collect();
279 for container in containers {
280 let live = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> {
281 ids.map(|ids| {
282 ids.iter()
283 .copied()
284 .filter(|id| merged.contains_key(id))
285 .collect()
286 })
287 .unwrap_or_default()
288 };668 };
289 let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container)));669 let container = parent.unwrap_or(PAGE);
290 let of_base = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> {670 let siblings = self.new.children(container);
291 ids.map(|ids| {671 node.parent == container
292 ids.iter()672 && siblings
293 .copied()673 .iter()
294 .filter(|id| b.contains_key(id))674 .position(|sibling| *sibling == id)
295 .collect()675 .is_some_and(|at| siblings.get(at + 1).copied() == before)
676 }
677
678 /// Whether the remote kept a paragraph's, text's or table's tags.
679 fn same_tags(&self, id: ExGuid) -> bool {
680 if self.ours(id) {
681 return true;
682 }
683 let tags = |index: &Index| {
684 index.nodes.get(&id).map(|node| match &node.own {
685 Own::Paragraph { tags, .. } | Own::Table { tags, .. } => Some(tags.clone()),
686 Own::Text(text) => Some(text.tags.clone()),
687 _ => None,
296 })688 })
297 .unwrap_or_default()
298 };689 };
299 let (base_ids, our_ids, their_ids) = (690 let before = tags(&self.old);
300 of_base(bc.get(&container)),691 before.is_some() && before == tags(&self.new)
301 of_base(oc.get(&container)),692 }
302 of_base(tc.get(&container)),693
303 );694 fn new_has(&self, id: ExGuid) -> bool {
304 let ours_moved = if our_ids == base_ids || our_ids == their_ids {695 self.ours(id) || self.new.nodes.contains_key(&id)
305 BTreeSet::new()696 }
306 } else {697
307 moved(&base_ids, &our_ids)698 /// The paragraph holding a text, as the base stored it.
699 fn holder(&self, text: ExGuid) -> Option<ExGuid> {
700 self.old.nodes.get(&text).map(|node| node.parent)
701 }
702
703 fn holder_text(&self, paragraph: ExGuid) -> Option<ExGuid> {
704 match self.old.nodes.get(&paragraph).map(|node| &node.own) {
705 Some(Own::Paragraph { content, .. }) => Some(*content),
706 _ => None,
707 }
708 }
709
710 /// Whether a split of the paragraph makes the same new paragraph on both sides: its
711 /// container, style, format and children are as the base had them.
712 fn splits_alike(&self, id: ExGuid) -> bool {
713 if self.ours(id) {
714 return true;
715 }
716 let shape = |index: &Index| {
717 index.nodes.get(&id).map(|node| match &node.own {
718 Own::Paragraph { style, format, .. } => {
719 Some((node.parent, *style, format.clone(), node.children.clone()))
720 }
721 _ => None,
722 })
308 };723 };
309 // A paragraph we repositioned that the remote also moved, reparented or removed724 let before = shape(&self.old);
310 // has two destinations; neither side's placement can be assumed.725 before.is_some() && before == shape(&self.new)
311 let mut theirs_touched = moved(&base_ids, &their_ids);726 }
312 theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id)));727}
313 if ours_moved.iter().any(|id| theirs_touched.contains(id)) {728
314 return None;729/// Where the remote replaced parts of a text, in order: each `start..end` of the text as
730/// the replayed edits see it holds what the remote replaced by `end - start + delta` UTF-16
731/// units. Outside them both texts hold the same characters in the same formats.
732#[derive(Debug, Clone, Default, PartialEq)]
733struct Changes(Vec<Region>);
734
735#[derive(Debug, Clone, Copy, PartialEq)]
736struct Region {
737 start: u32,
738 end: u32,
739 delta: i64,
740}
741
742/// The characters of a text with their formats and UTF-16 widths.
743fn units(paragraph: &Paragraph) -> Vec<(char, Format)> {
744 let mut out = Vec::new();
745 let mut spans = paragraph.spans().iter();
746 let mut span = spans.next();
747 for (byte, character) in paragraph.text().char_indices() {
748 while span.is_some_and(|span| span.end <= byte) {
749 span = spans.next();
315 }750 }
316 let mut list: Vec<ExGuid> = their_list.clone();751 out.push((
317 list.retain(|id| merged[id].parent == container);752 character,
318 // Paragraphs we repositioned, added or brought here follow their predecessor in our list.753 span.map(|span| span.format.clone()).unwrap_or_default(),
319 for (at, id) in our_list.iter().enumerate() {754 ));
320 if merged[id].parent != container || (list.contains(id) && !ours_moved.contains(id)) {755 }
321 continue;756 out
757}
758
759/// Beyond this many cells of the alignment table the changed middle counts as one change.
760const CELLS: usize = 4_000_000;
761
762impl Changes {
763 /// The runs where `new` differs from `old` in characters or formats, from a longest
764 /// common subsequence of the two: where several align equally, one is chosen, which
765 /// places an op among repeated characters one way of the equally valid ones.
766 fn between(old: &Paragraph, new: &Paragraph) -> Self {
767 let (a, b) = (units(old), units(new));
768 let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
769 let suffix = a[prefix..]
770 .iter()
771 .rev()
772 .zip(b[prefix..].iter().rev())
773 .take_while(|(x, y)| x == y)
774 .count();
775 let (x, y) = (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix]);
776 // Matched pairs of the middles, in order.
777 let mut matched = Vec::new();
778 if x.len().saturating_mul(y.len()) <= CELLS {
779 let width = y.len() + 1;
780 let mut table = vec![0_u32; (x.len() + 1) * width];
781 for i in (0..x.len()).rev() {
782 for j in (0..y.len()).rev() {
783 table[i * width + j] = if x[i] == y[j] {
784 table[(i + 1) * width + j + 1] + 1
785 } else {
786 table[(i + 1) * width + j].max(table[i * width + j + 1])
787 };
788 }
322 }789 }
323 list.retain(|other| other != id);790 let (mut i, mut j) = (0, 0);
324 let predecessor = our_list[..at]791 while i < x.len() && j < y.len() {
792 if x[i] == y[j] {
793 matched.push((i, j));
794 i += 1;
795 j += 1;
796 } else if table[(i + 1) * width + j] >= table[i * width + j + 1] {
797 i += 1;
798 } else {
799 j += 1;
800 }
801 }
802 }
803 matched.push((x.len(), y.len()));
804 let width = |units: &[(char, Format)]| -> u32 {
805 units
325 .iter()806 .iter()
326 .rev()807 .map(|(character, _)| character.len_utf16() as u32)
327 .find(|other| list.contains(other));808 .sum()
328 let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1);809 };
329 list.insert(position, *id);810 let mut regions = Vec::new();
811 let (mut at, mut i, mut j) = (width(&a[..prefix]), 0, 0);
812 for (k, l) in matched {
813 if (k, l) != (i, j) {
814 let (removed, inserted) = (width(&x[i..k]), width(&y[j..l]));
815 regions.push(Region {
816 start: at,
817 end: at + removed,
818 delta: i64::from(inserted) - i64::from(removed),
819 });
820 at += removed;
821 }
822 if k < x.len() {
823 at += width(&x[k..k + 1]);
824 }
825 (i, j) = (k + 1, l + 1);
330 }826 }
331 order.insert(container, list);827 Self(regions)
332 }828 }
333 let mut out = Vec::new();829
334 let mut pending: Vec<ExGuid> = order.get(&None).cloned().unwrap_or_default();830 /// The range on the remote text, when it lies clear of every change: a range may end
335 pending.reverse();831 /// where a change starts or start where one ends, an insertion point may not.
336 while let Some(id) = pending.pop() {832 fn map(&self, range: &Range<u32>) -> Option<Range<u32>> {
337 let paragraph = merged.remove(&id)?;833 let empty = range.start == range.end;
338 out.push(paragraph);834 let mut shift = 0;
339 if let Some(children) = order.get(&Some(id)) {835 for region in &self.0 {
340 pending.extend(children.iter().rev().copied());836 if range.end < region.start || (!empty && range.end == region.start) {
837 break;
838 }
839 if range.start > region.end || (!empty && range.start == region.end) {
840 shift += region.delta;
841 } else {
842 return None;
843 }
844 }
845 let shifted = |at: u32| u32::try_from(i64::from(at) + shift).ok();
846 Some(shifted(range.start)?..shifted(range.end)?)
847 }
848
849 /// Follows a replacement of `range`, clear of every change, by `length` units.
850 fn replaced(&mut self, range: &Range<u32>, length: u32) {
851 let moved = i64::from(length) - i64::from(range.end - range.start);
852 for region in &mut self.0 {
853 if region.start >= range.end {
854 region.start = (i64::from(region.start) + moved) as u32;
855 region.end = (i64::from(region.end) + moved) as u32;
856 }
341 }857 }
342 }858 }
343 if !merged.is_empty() {859
344 return None;860 /// Cuts the text at `at`, clear of every change, keeping the changes left of it (text
861 /// typed at the cut included) and returning those right of it, measured from the cut.
862 fn split(&mut self, at: u32) -> Self {
863 let right = self
864 .0
865 .iter()
866 .filter(|region| region.end > at)
867 .map(|region| Region {
868 start: region.start - at,
869 end: region.end - at,
870 delta: region.delta,
871 })
872 .collect();
873 self.0.retain(|region| region.end <= at);
874 Self(right)
345 }875 }
346 Some(out)
347}876}
348877
349/// The members of `list` outside a longest common subsequence with `base`: the paragraphs878/// What an object holds apart from the objects under it.
350/// a reorder of the shared members had to move.879#[derive(Debug, Clone, PartialEq)]
351fn moved(base: &[ExGuid], list: &[ExGuid]) -> BTreeSet<ExGuid> {880enum Own {
352 let a: Vec<ExGuid> = base881 Outline {
353 .iter()882 title: bool,
354 .copied()883 min_width: Option<f32>,
355 .filter(|id| list.contains(id))884 layout: Layout,
356 .collect();885 indents: Vec<f32>,
357 let b: Vec<ExGuid> = list886 unsupported: Vec<Unsupported>,
358 .iter()887 },
359 .copied()888 Title {
360 .filter(|id| base.contains(id))889 date: Option<ExGuid>,
361 .collect();890 layout: Layout,
362 let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1];891 },
363 for i in (0..a.len()).rev() {892 Paragraph {
364 for j in (0..b.len()).rev() {893 level: u32,
365 table[i][j] = if a[i] == b[j] {894 style: Option<ExGuid>,
366 table[i + 1][j + 1] + 1895 format: Format,
367 } else {896 lists: Vec<ExGuid>,
368 table[i + 1][j].max(table[i][j + 1])897 tags: Vec<Tag>,
369 };898 media: MediaIndex,
899 collapsed: bool,
900 content: ExGuid,
901 },
902 Text(TextObject),
903 Table {
904 columns: Vec<TableColumn>,
905 borders: Option<bool>,
906 layout: Layout,
907 tags: Vec<Tag>,
908 },
909 Row,
910 Cell {
911 layout: Layout,
912 indents: Vec<f32>,
913 shading: Option<u32>,
914 unsupported: Vec<Unsupported>,
915 },
916 Image(Image),
917 Attachment(Attachment),
918 Ink(Ink),
919 Unsupported(Unsupported),
920}
921
922#[derive(Debug)]
923struct Node {
924 own: Own,
925 /// The containing object; the page is the default identity.
926 parent: ExGuid,
927 children: Vec<ExGuid>,
928}
929
930impl Node {
931 /// Everything directly under the object, a paragraph's content included.
932 fn below(&self) -> impl Iterator<Item = ExGuid> + '_ {
933 let content = match &self.own {
934 Own::Paragraph { content, .. } => Some(*content),
935 _ => None,
936 };
937 content.into_iter().chain(self.children.iter().copied())
938 }
939}
940
941#[derive(Default)]
942struct Index {
943 nodes: BTreeMap<ExGuid, Node>,
944 page: Vec<ExGuid>,
945}
946
947const PAGE: ExGuid = ExGuid {
948 guid: [0; 16],
949 n: 0,
950};
951
952impl Index {
953 fn of(page: &Page) -> Self {
954 let mut index = Self::default();
955 for object in &page.objects {
956 index.page.push(object.id());
957 match object {
958 PageObject::Outline(outline) => index.outline(outline, PAGE),
959 PageObject::Title(title) => {
960 index.add(
961 title.id,
962 PAGE,
963 Own::Title {
964 date: title.date,
965 layout: title.layout.clone(),
966 },
967 title.outlines.iter().map(|outline| outline.id).collect(),
968 );
969 for outline in &title.outlines {
970 index.outline(outline, title.id);
971 }
972 }
973 PageObject::Image(image) => {
974 index.add(image.id, PAGE, Own::Image(image.clone()), Vec::new())
975 }
976 PageObject::Ink(ink) => index.add(ink.id, PAGE, Own::Ink(ink.clone()), Vec::new()),
977 PageObject::Unsupported(object) => index.add(
978 object.id,
979 PAGE,
980 Own::Unsupported(object.clone()),
981 Vec::new(),
982 ),
983 }
370 }984 }
985 index
371 }986 }
372 let mut kept = BTreeSet::new();987
373 let (mut i, mut j) = (0, 0);988 fn children(&self, id: ExGuid) -> &[ExGuid] {
374 while i < a.len() && j < b.len() {989 if id == PAGE {
375 if a[i] == b[j] {990 &self.page
376 kept.insert(a[i]);
377 i += 1;
378 j += 1;
379 } else if table[i + 1][j] >= table[i][j + 1] {
380 i += 1;
381 } else {991 } else {
382 j += 1;992 self.nodes.get(&id).map_or(&[], |node| &node.children)
383 }993 }
384 }994 }
385 b.into_iter().filter(|id| !kept.contains(id)).collect()
386}
387995
388fn merge_paragraph(996 fn add(&mut self, id: ExGuid, parent: ExGuid, own: Own, children: Vec<ExGuid>) {
389 base: &PageParagraph,997 self.nodes.insert(
390 ours: &PageParagraph,998 id,
391 theirs: &PageParagraph,999 Node {
392) -> Option<PageParagraph> {1000 own,
393 let lists = pick(&base.lists, &ours.lists, &theirs.lists)?;1001 parent,
394 let tags = pick(&base.tags, &ours.tags, &theirs.tags)?;1002 children,
395 let style = pick(&base.style, &ours.style, &theirs.style)?;1003 },
396 let format = pick(&base.format, &ours.format, &theirs.format)?;1004 );
397 let (parent, level) = pick(1005 }
398 &(base.parent, base.level),
399 &(ours.parent, ours.level),
400 &(theirs.parent, theirs.level),
401 )?;
402 let collapsed = pick(&base.collapsed, &ours.collapsed, &theirs.collapsed)?;
403 let content = match pick(&base.content, &ours.content, &theirs.content) {
404 Some(picked) => picked,
405 None => match (&base.content, &ours.content, &theirs.content) {
406 (ParagraphContent::Text(x), ParagraphContent::Text(y), ParagraphContent::Text(z))
407 if x.id == y.id && x.id == z.id =>
408 {
409 let mut text = z.clone();
410 text.date_field = pick(&x.date_field, &y.date_field, &z.date_field)?;
411 text.tags = pick(&x.tags, &y.tags, &z.tags)?;
412 text.text = merge_text(&x.text, &y.text, &z.text)?;
413 ParagraphContent::Text(text)
414 }
415 _ => return None,
416 },
417 };
418 Some(PageParagraph {
419 id: theirs.id,
420 parent,
421 level,
422 style,
423 format,
424 content,
425 lists,
426 tags,
427 media: pick(&base.media, &ours.media, &theirs.media)?,
428 collapsed,
429 })
430}
4311006
432/// Re-applies our replacement inside the remote text when its range maps unambiguously.1007 fn outline(&mut self, outline: &Outline, parent: ExGuid) {
433fn merge_text(1008 let children = self.paragraphs(&outline.paragraphs, outline.id);
434 base: &onestore::page::Paragraph,1009 self.add(
435 ours: &onestore::page::Paragraph,1010 outline.id,
436 theirs: &onestore::page::Paragraph,1011 parent,
437) -> Option<onestore::page::Paragraph> {1012 Own::Outline {
438 if let Some(picked) = pick(base, ours, theirs) {1013 title: outline.title,
439 return Some(picked);1014 min_width: outline.min_width,
440 }1015 layout: outline.layout.clone(),
441 if base.text() == ours.text() {1016 indents: outline.indents.clone(),
442 // A local formatting-only change cannot be attributed to a range of the remote text.1017 unsupported: outline.unsupported.clone(),
443 return None;1018 },
444 }1019 children,
445 let (b, o) = (base.text(), ours.text());1020 );
446 let prefix = b1021 }
447 .char_indices()1022
448 .zip(o.chars())1023 /// Indexes a flat paragraph list; returns the container's direct children.
449 .take_while(|((_, x), y)| x == y)1024 fn paragraphs(&mut self, list: &[PageParagraph], container: ExGuid) -> Vec<ExGuid> {
450 .map(|((i, x), _)| i + x.len_utf8())1025 let mut children: BTreeMap<ExGuid, Vec<ExGuid>> = BTreeMap::new();
451 .last()1026 for paragraph in list {
452 .unwrap_or(0);1027 children
453 let suffix = b[prefix..]1028 .entry(paragraph.parent.unwrap_or(container))
454 .chars()1029 .or_default()
455 .rev()1030 .push(paragraph.id);
456 .zip(o[prefix..].chars().rev())1031 }
457 .take_while(|(x, y)| x == y)1032 for paragraph in list {
458 .map(|(x, _)| x.len_utf8())1033 let content = match &paragraph.content {
459 .sum::<usize>();1034 ParagraphContent::Text(text) => {
460 let start = base.utf16_offset(prefix).ok()?;1035 self.add(text.id, paragraph.id, Own::Text(text.clone()), Vec::new());
461 let end = base.utf16_offset(b.len() - suffix).ok()?;1036 text.id
462 let inserted = ours.utf16_offset(o.len() - suffix).ok()?;1037 }
463 let replacement = ours.slice(start..inserted).ok()?;1038 ParagraphContent::Table(table) => {
464 let our_end = ours.utf16_offset(o.len()).ok()?;1039 let rows = table.rows.iter().map(|row| row.id).collect();
465 let base_end = base.utf16_offset(b.len()).ok()?;1040 for row in &table.rows {
466 if (start > 0 && ours.slice(0..start).ok()? != base.slice(0..start).ok()?)1041 let cells = row.cells.iter().map(|cell| cell.id).collect();
467 || (end < base_end1042 for cell in &row.cells {
468 && ours.slice(inserted..our_end).ok()? != base.slice(end..base_end).ok()?)1043 let children = self.paragraphs(&cell.paragraphs, cell.id);
469 {1044 self.add(
470 // Formatting changed outside the replaced range; the remote text would hide it.1045 cell.id,
471 return None;1046 row.id,
472 }1047 Own::Cell {
473 let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?;1048 layout: cell.layout.clone(),
474 // Text they reformatted is not ours to replace, nor to move as a split would.1049 indents: cell.indents.clone(),
475 if start < end && theirs.slice(mapped.clone()).ok()? != base.slice(start..end).ok()? {1050 shading: cell.shading,
476 return None;1051 unsupported: cell.unsupported.clone(),
477 }1052 },
478 let mut merged = theirs.clone();1053 children,
479 merged1054 );
480 .apply(Edit {1055 }
481 range: mapped,1056 self.add(row.id, table.id, Own::Row, cells);
482 replacement,1057 }
483 })1058 self.add(
484 .ok()?;1059 table.id,
485 Some(merged)1060 paragraph.id,
1061 Own::Table {
1062 columns: table.columns.clone(),
1063 borders: table.borders,
1064 layout: table.layout.clone(),
1065 tags: table.tags.clone(),
1066 },
1067 rows,
1068 );
1069 table.id
1070 }
1071 ParagraphContent::Image(image) => {
1072 self.add(
1073 image.id,
1074 paragraph.id,
1075 Own::Image(image.clone()),
1076 Vec::new(),
1077 );
1078 image.id
1079 }
1080 ParagraphContent::Attachment(attachment) => {
1081 self.add(
1082 attachment.id,
1083 paragraph.id,
1084 Own::Attachment(attachment.clone()),
1085 Vec::new(),
1086 );
1087 attachment.id
1088 }
1089 ParagraphContent::Ink(ink) => {
1090 self.add(ink.id, paragraph.id, Own::Ink(ink.clone()), Vec::new());
1091 ink.id
1092 }
1093 ParagraphContent::Unsupported(object) => {
1094 self.add(
1095 object.id,
1096 paragraph.id,
1097 Own::Unsupported(object.clone()),
1098 Vec::new(),
1099 );
1100 object.id
1101 }
1102 };
1103 self.add(
1104 paragraph.id,
1105 paragraph.parent.unwrap_or(container),
1106 Own::Paragraph {
1107 level: paragraph.level,
1108 style: paragraph.style,
1109 format: paragraph.format.clone(),
1110 lists: paragraph.lists.clone(),
1111 tags: paragraph.tags.clone(),
1112 media: paragraph.media.clone(),
1113 collapsed: paragraph.collapsed,
1114 content,
1115 },
1116 children.remove(&paragraph.id).unwrap_or_default(),
1117 );
1118 }
1119 children.remove(&container).unwrap_or_default()
1120 }
486}1121}
4871122
488#[cfg(test)]1123#[cfg(test)]
489mod tests {1124mod tests {
490 use super::*;1125 use super::*;
491 use onestore::page::Paragraph;
4921126
493 fn text(s: &str) -> Paragraph {1127 fn text(s: &str) -> Paragraph {
494 Paragraph::new(s.into(), Default::default())1128 Paragraph::new(s.into(), Format::default())
495 }1129 }
4961130
497 #[test]1131 #[test]
498 fn same_paragraph_edits_merge_when_their_ranges_do_not_overlap() {1132 fn ranges_clear_of_the_remote_changes_shift_past_them() {
499 let base = text("one two three");1133 let region = |start, end, delta| Region { start, end, delta };
500 let ours = text("one two three four");1134 let changes = Changes::between(&text("one two three"), &text("one 2 three"));
501 assert_eq!(1135 assert_eq!(changes.0, [region(4, 7, -2)]);
502 merge_text(&base, &ours, &text("zero one two three"))1136 assert_eq!(changes.map(&(0..3)), Some(0..3));
503 .unwrap()1137 assert_eq!(changes.map(&(0..4)), Some(0..4));
504 .text(),1138 assert_eq!(changes.map(&(8..13)), Some(6..11));
505 "zero one two three four"1139 assert_eq!(changes.map(&(7..8)), Some(5..6));
506 );1140 assert_eq!(changes.map(&(13..13)), Some(11..11));
507 assert_eq!(1141 assert_eq!(changes.map(&(4..4)), None);
508 merge_text(&base, &ours, &text("one 2 three"))1142 assert_eq!(changes.map(&(7..7)), None);
509 .unwrap()1143 assert_eq!(changes.map(&(3..5)), None);
510 .text(),1144 let mut moved = changes.clone();
511 "one 2 three four"1145 moved.replaced(&(0..0), 5);
512 );1146 assert_eq!(moved.0, [region(9, 12, -2)]);
513 assert_eq!(1147 let both = Changes::between(&text("ab🦀cd"), &text("Xab🦀cYd"));
514 merge_text(&base, &text("one two THREE"), &text("one two 3")),1148 assert_eq!(both.0, [region(0, 0, 1), region(5, 5, 1)]);
515 None1149 assert_eq!(both.map(&(2..4)), Some(3..5));
516 );1150 assert_eq!(both.map(&(1..1)), Some(2..2));
517 assert_eq!(1151 assert_eq!(both.map(&(0..0)), None);
518 merge_text(&base, &text("one two three"), &text("x"))1152 assert_eq!(both.map(&(3..6)), None);
519 .unwrap()1153 let bold = Format {
520 .text(),
521 "x"
522 );
523 assert_eq!(
524 merge_text(&base, &text("y"), &text("one two three"))
525 .unwrap()
526 .text(),
527 "y"
528 );
529 let bold = onestore::document::Format {
530 bold: Some(true),1154 bold: Some(true),
531 ..Default::default()1155 ..Default::default()
532 };1156 };
533 let remote = Paragraph::new("one two three".into(), bold);1157 let formatted = Changes::between(
534 let merged = merge_text(&base, &text("one two three four"), &remote).unwrap();1158 &text("abc"),
535 assert_eq!(merged.text(), "one two three four");1159 &Paragraph::from_runs([
536 assert_eq!(merged.format_at(0).unwrap(), remote.format_at(0).unwrap());1160 ("a".to_owned(), Format::default()),
537 assert_eq!(1161 ("b".to_owned(), bold),
538 merge_text(&base, &remote, &text("one two three four")),1162 ("c".to_owned(), Format::default()),
539 None1163 ]),
540 );
541 assert_eq!(
542 merge_text(&base, &text("one two"), &text("X one two three"))
543 .unwrap()
544 .text(),
545 "X one two"
546 );1164 );
1165 assert_eq!(formatted.0, [region(1, 2, 0)]);
1166 let emoji = Changes::between(&text("🦀a"), &text("🦀ab"));
1167 assert_eq!(emoji.0, [region(3, 3, 1)]);
1168 let mut cut = Changes::between(&text("abcdef"), &text("aXbcdeYf"));
1169 assert_eq!(cut.map(&(3..4)), Some(4..5));
1170 let right = cut.split(3);
547 assert_eq!(1171 assert_eq!(
548 merge_text(&base, &text("two three"), &text("one two three!"))1172 (cut.0, right.0),
549 .unwrap()1173 (vec![region(1, 1, 1)], vec![region(2, 2, 1)])
550 .text(),
551 "two three!"
552 );1174 );
1175 let mut typed = Changes::between(&text("ab🦀cd"), &text("abX🦀cd"));
1176 assert_eq!(typed.map(&(2..3)), Some(3..4));
1177 assert!(typed.split(2).0.is_empty());
1178 assert_eq!(typed.0, [region(2, 2, 1)]);
553 }1179 }
554}1180}
crates/notebook/src/migrate.rs created+303
...@@ -0,0 +1,303 @@
1//! Converts a schema-14 cache, whose queue holds whole-page edits over a working image,
2//! to this schema's op queue. The cache is exported first; each queued page is lowered to
3//! ops against the page the conversion has so far, and every converted page must equal
4//! the page in the old working image, or nothing changes.
5
6use crate::{Result, base, queue, schema};
7use onestore::{
8 Arena, ExGuid, PageCreation, PageEdit, RevisionIndex, Section, Store,
9 document::Document,
10 op::{Edit, Op, SectionOp},
11 page::Page,
12};
13use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
14use std::{collections::BTreeMap, io, path::Path};
15
16pub(crate) const VERSION: u32 = 14;
17
18/// Schema 14's queued intents, as it serialized them.
19#[derive(serde::Deserialize)]
20#[serde(deny_unknown_fields)]
21enum Operation {
22 Page(PageIntent),
23 CreatePage(PageCreation),
24 Pages(PageEdits),
25 DeletePages(Vec<ExGuid>),
26}
27
28#[derive(serde::Deserialize)]
29#[serde(deny_unknown_fields)]
30struct PageIntent {
31 #[allow(dead_code)]
32 before: Page,
33 after: Page,
34 author: String,
35}
36
37#[derive(serde::Deserialize)]
38#[serde(deny_unknown_fields)]
39struct PageEdits {
40 edits: Vec<PageEdit>,
41 #[allow(dead_code)]
42 observed: Vec<(ExGuid, u32)>,
43}
44
45fn invalid(message: String) -> crate::Error {
46 io::Error::new(io::ErrorKind::InvalidData, message).into()
47}
48
49/// Schema 14 stored the working image as its length and the runs where it differs from
50/// the base.
51fn working(mut base: Vec<u8>, patch: &[u8]) -> Result<Vec<u8>> {
52 let damaged = || invalid("Damaged working image".into());
53 let Some((length, mut runs)) = patch.split_first_chunk::<8>() else {
54 return if patch.is_empty() {
55 Ok(base)
56 } else {
57 Err(damaged())
58 };
59 };
60 let length = usize::try_from(u64::from_le_bytes(*length))
61 .ok()
62 .filter(|length| *length <= base.len() + patch.len())
63 .ok_or_else(damaged)?;
64 base.resize(length, 0);
65 while let Some((header, rest)) = runs.split_first_chunk::<16>() {
66 let offset = usize::try_from(u64::from_le_bytes(header[..8].try_into().unwrap()))
67 .map_err(|_| damaged())?;
68 let size = usize::try_from(u64::from_le_bytes(header[8..].try_into().unwrap()))
69 .map_err(|_| damaged())?;
70 let (bytes, rest) = rest.split_at_checked(size).ok_or_else(damaged)?;
71 base.get_mut(offset..)
72 .and_then(|target| target.get_mut(..size))
73 .ok_or_else(damaged)?
74 .copy_from_slice(bytes);
75 runs = rest;
76 }
77 if runs.is_empty() {
78 Ok(base)
79 } else {
80 Err(damaged())
81 }
82}
83
84struct Converted {
85 id: i64,
86 author: String,
87 edit: Edit,
88 /// Schema 14's publication evidence, when this edit was attempted.
89 attempted: Option<String>,
90 conflict: Option<(i64, ExGuid)>,
91}
92
93pub(crate) fn migrate(connection: &mut Connection, path: &Path) -> Result<()> {
94 let mut archive = path.as_os_str().to_owned();
95 archive.push(".v14-recovery");
96 let archive = std::path::PathBuf::from(archive);
97 crate::recovery::export(connection, &archive, true)?;
98 let failed = |error: crate::Error| {
99 invalid(format!(
100 "The schema-14 cache could not be converted ({error}); it is unchanged and archived at {}",
101 archive.display()
102 ))
103 };
104 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
105 convert(&transaction).map_err(failed)?;
106 transaction.commit()?;
107 Ok(())
108}
109
110fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> {
111 let (base, patch): (Vec<u8>, Vec<u8>) =
112 transaction.query_row("SELECT base, working FROM replica WHERE id=1", [], |row| {
113 Ok((row.get(0)?, row.get(1)?))
114 })?;
115 let expected = working(base.clone(), &patch)?;
116 let attempt: Option<(i64, String)> = transaction
117 .query_row("SELECT edit_id, revisions FROM attempt", [], |row| {
118 Ok((row.get(0)?, row.get(1)?))
119 })
120 .optional()?;
121 let conflicts: BTreeMap<i64, i64> = transaction
122 .prepare("SELECT edit_id, kind FROM conflicts")?
123 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
124 .collect::<rusqlite::Result<_>>()?;
125 let queued: Vec<(i64, String, String)> = transaction
126 .prepare("SELECT id, space, operation FROM edits ORDER BY id")?
127 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
128 .collect::<rusqlite::Result<_>>()?;
129 let sequence: i64 = transaction.query_row(
130 "SELECT max(coalesce((SELECT seq FROM sqlite_sequence WHERE name='edits'), 0),
131 coalesce((SELECT max(edit_id) FROM receipts), 0),
132 coalesce((SELECT max(edit_id) FROM archived), 0))",
133 [],
134 |row| row.get(0),
135 )?;
136
137 let arena = Arena::default();
138 let mut section = Section::open(&arena, base.clone())?;
139 let at = crate::now();
140 let mut converted = Vec::new();
141 let mut sealed = None;
142 let mut edited = BTreeMap::new();
143 let (mut created, mut deleted) = (Vec::new(), Vec::new());
144 for (id, space, operation) in queued {
145 let space: ExGuid = space.parse()?;
146 let operation: Operation = serde_json::from_str(&operation)
147 .map_err(|error| invalid(format!("Queued edit {id} is unreadable: {error}")))?;
148 let (author, ops) = match operation {
149 Operation::Page(intent) => {
150 let current = section.page(space)?;
151 let ops = onestore::op::lower_page(&current, &intent.after)?;
152 edited.insert(space, id);
153 (
154 intent.author,
155 ops.into_iter().map(|op| Op::Page { space, op }).collect(),
156 )
157 }
158 Operation::CreatePage(creation) => {
159 created.push(creation.space());
160 (
161 String::new(),
162 vec![Op::Section(SectionOp::Create(creation))],
163 )
164 }
165 Operation::Pages(batch) => (
166 String::new(),
167 vec![Op::Section(SectionOp::Pages(batch.edits))],
168 ),
169 Operation::DeletePages(pages) => {
170 deleted.extend(&pages);
171 (String::new(), vec![Op::Section(SectionOp::Delete(pages))])
172 }
173 };
174 let edit = Edit { at, ops };
175 section.apply(&author, &edit)?;
176 let attempted = attempt
177 .as_ref()
178 .filter(|(edit, _)| *edit == id)
179 .map(|(_, revisions)| revisions.clone());
180 if attempted.is_some() {
181 if !converted.is_empty() {
182 return Err(invalid(
183 "Only the oldest queued edit can hold an attempt".into(),
184 ));
185 }
186 sealed = Some(section.seal()?);
187 }
188 // A conflicting page-list edit is the section's conflict, whichever page it names.
189 let conflicted = match edit.ops.first() {
190 Some(Op::Section(_)) => section.root(),
191 _ => space,
192 };
193 converted.push(Converted {
194 id,
195 author,
196 edit,
197 attempted,
198 conflict: conflicts.get(&id).map(|kind| (*kind, conflicted)),
199 });
200 }
201 let listed = |image: &[u8]| -> Result<Vec<ExGuid>> {
202 let store = Store::parse(image)?;
203 let index = RevisionIndex::parse(&store)?;
204 Ok(Document::parse(&index)?
205 .pages()?
206 .into_iter()
207 .map(|(space, _)| space)
208 .collect())
209 };
210 let based = listed(&base)?;
211 let listing: Vec<ExGuid> = section
212 .pages()?
213 .into_iter()
214 .map(|(space, ..)| space)
215 .collect();
216 let cached = listed(&expected)?;
217 // A page the remote added or removed while edits waited is in one list and not the
218 // other; one the queue created or deleted must agree.
219 let agree = cached
220 .iter()
221 .filter(|space| !listing.contains(space))
222 .all(|space| !based.contains(space) && !created.contains(space))
223 && listing
224 .iter()
225 .filter(|space| !cached.contains(space))
226 .all(|space| based.contains(space) && !deleted.contains(space));
227 if !agree {
228 return Err(invalid(
229 "The converted page list differs from the cached one".into(),
230 ));
231 }
232 verify(&mut section, &expected, &edited)?;
233
234 transaction.execute_batch(
235 "DROP TABLE replica; DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE edits;",
236 )?;
237 transaction.execute_batch(schema::QUEUE)?;
238 base::write(transaction, base::Image::Base, &base)?;
239 let mut batch = None;
240 for edit in converted {
241 let current = match (batch, &edit.attempted) {
242 (Some(current), None) => current,
243 _ => {
244 transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?;
245 transaction.last_insert_rowid()
246 }
247 };
248 if let Some(evidence) = &edit.attempted {
249 transaction.execute(
250 "UPDATE batches SET sealed=?1, revisions=?2, attempted=1 WHERE id=?3",
251 params![
252 serde_json::to_string(sealed.as_ref().unwrap()).map_err(io::Error::other)?,
253 evidence,
254 current
255 ],
256 )?;
257 batch = None;
258 } else {
259 batch = Some(current);
260 }
261 if let Some((kind, space)) = edit.conflict {
262 transaction.execute(
263 "UPDATE batches SET conflict=?1, space=?2 WHERE id=?3",
264 params![kind, space.to_string(), current],
265 )?;
266 }
267 queue::insert(
268 transaction,
269 Some(crate::unsigned(edit.id)?),
270 current,
271 &edit.author,
272 &edit.edit,
273 )?;
274 }
275 transaction.execute("DELETE FROM sqlite_sequence WHERE name='edits'", [])?;
276 transaction.execute(
277 "INSERT INTO sqlite_sequence(name, seq) VALUES ('edits', ?1)",
278 [sequence],
279 )?;
280 transaction.pragma_update(None, "user_version", schema::VERSION)?;
281 Ok(())
282}
283
284/// Requires each page a queued page edit changed to read as the old working image has it.
285fn verify(
286 section: &mut Section<'_>,
287 expected: &[u8],
288 edited: &BTreeMap<ExGuid, i64>,
289) -> Result<()> {
290 let store = Store::parse(expected)?;
291 let index = RevisionIndex::parse(&store)?;
292 let document = Document::parse(&index)?;
293 for (space, id) in edited {
294 let converted = section.page(*space).ok();
295 let stored = Page::from_space(&document, *space).ok();
296 if converted != stored {
297 return Err(invalid(format!(
298 "Queued edit {id} converts to a page that differs from the cached page"
299 )));
300 }
301 }
302 Ok(())
303}
crates/notebook/src/pages.rs deleted-168
...@@ -1,168 +0,0 @@
1use super::*;
2use onestore::{PageEdit, PagePosition};
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6type PageOrder = Vec<(ExGuid, u32)>;
7
8/// An atomic page batch with its observed order and indentation.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct PageEdits {
12 pub edits: Vec<PageEdit>,
13 observed: PageOrder,
14}
15
16fn observe(source: &[u8]) -> Result<Option<(ExGuid, PageOrder)>> {
17 let store = Store::parse(source)?;
18 let index = RevisionIndex::parse(&store)?;
19 let document = Document::parse(&index)?;
20 let mut pages = Vec::new();
21 for (sid, _) in document.pages()? {
22 let view = document.active(sid)?;
23 let Some(metadata) = view.roots.get(&2).and_then(|id| view.nodes.get(id)) else {
24 return Ok(None);
25 };
26 let Kind::Metadata { level, .. } = metadata.kind else {
27 return Ok(None);
28 };
29 pages.push((sid, level.unwrap_or(1)));
30 }
31 Ok(Some((document.root, pages)))
32}
33
34fn positions(pages: &[(ExGuid, u32)]) -> Result<BTreeMap<ExGuid, (usize, u32)>> {
35 let mut positions = BTreeMap::new();
36 for (at, (sid, level)) in pages.iter().enumerate() {
37 if sid.guid == [0; 16]
38 || !(1..=3).contains(level)
39 || positions.insert(*sid, (at, *level)).is_some()
40 {
41 return Err(io::Error::new(
42 io::ErrorKind::InvalidData,
43 "Page observations need unique identities and valid indentation",
44 )
45 .into());
46 }
47 }
48 Ok(positions)
49}
50
51impl Replica {
52 /// Durably queues the complete page batch using the supplied local snapshot.
53 pub fn pages(&self, source: &[u8], edits: &[PageEdit]) -> Result<Option<u64>> {
54 let (space, batch, prepared) = PageEdits::capture(source, edits)?;
55 self.record(source, space, Operation::Pages(batch), &prepared)
56 }
57}
58
59impl PageEdits {
60 fn capture<'a>(
61 source: &'a [u8],
62 edits: &[PageEdit],
63 ) -> Result<(ExGuid, Self, PreparedEdit<'a>)> {
64 let prepared = PreparedEdit::pages(source, edits)?;
65 let (space, observed) = observe(source)?.ok_or_else(|| {
66 io::Error::new(
67 io::ErrorKind::InvalidData,
68 "Prepared page edits have no page metadata",
69 )
70 })?;
71 positions(&observed)?;
72 Ok((
73 space,
74 Self {
75 edits: edits.to_vec(),
76 observed,
77 },
78 prepared,
79 ))
80 }
81
82 pub(crate) fn prepare<'a>(
83 &self,
84 source: &'a [u8],
85 space: ExGuid,
86 ) -> Result<std::result::Result<PreparedEdit<'a>, ConflictKind>> {
87 let Some((root, current)) = observe(source)? else {
88 return Ok(Err(ConflictKind::TargetUnavailable));
89 };
90 if root != space {
91 return Ok(Err(ConflictKind::TargetUnavailable));
92 }
93 let before = positions(&self.observed)?;
94 let current = positions(&current)?;
95 for edit in &self.edits {
96 let Some((_, original_level)) = before.get(&edit.space()) else {
97 return Ok(Err(ConflictKind::TargetUnavailable));
98 };
99 let Some((_, current_level)) = current.get(&edit.space()) else {
100 return Ok(Err(ConflictKind::TargetUnavailable));
101 };
102 if current_level != original_level && *current_level != edit.level() {
103 return Ok(Err(ConflictKind::StructureChanged));
104 }
105 }
106 let prepared = match PreparedEdit::pages(source, &self.edits) {
107 Ok(prepared) => prepared,
108 Err(_) => return Ok(Err(ConflictKind::StructureChanged)),
109 };
110 let (_, wanted) = observe(prepared.as_bytes())?.ok_or_else(|| {
111 io::Error::new(
112 io::ErrorKind::InvalidData,
113 "Prepared page edits lost page metadata",
114 )
115 })?;
116 let wanted = positions(&wanted)?;
117 for edit in &self.edits {
118 if edit.position() == PagePosition::Keep {
119 continue;
120 }
121 let sid = edit.space();
122 let peers = before
123 .keys()
124 .filter(|id| **id != sid && current.contains_key(id));
125 let mut unchanged = true;
126 let mut satisfied = true;
127 for id in peers {
128 let observed = current[id].0 < current[&sid].0;
129 unchanged &= observed == (before[id].0 < before[&sid].0);
130 satisfied &= observed == (wanted[id].0 < wanted[&sid].0);
131 }
132 if !unchanged && !satisfied {
133 return Ok(Err(ConflictKind::StructureChanged));
134 }
135 }
136 Ok(Ok(prepared))
137 }
138
139 pub(crate) fn review(&self, source: &[u8], space: ExGuid, edits: &[PageEdit]) -> Result<Self> {
140 let identities_match = edits.len() == self.edits.len()
141 && edits.iter().all(|edit| {
142 self.edits
143 .iter()
144 .find(|original| original.space() == edit.space())
145 .is_some_and(|original| {
146 original
147 .reposition(edit.position(), edit.level())
148 .is_ok_and(|revised| revised == *edit)
149 })
150 });
151 if !identities_match {
152 return Err(io::Error::new(
153 io::ErrorKind::InvalidInput,
154 "Retain the original page and series identities when reviewing a page batch",
155 )
156 .into());
157 }
158 let (root, reviewed, _) = Self::capture(source, edits)?;
159 if root != space {
160 return Err(io::Error::new(
161 io::ErrorKind::InvalidInput,
162 "Review the original section for page edits",
163 )
164 .into());
165 }
166 Ok(reviewed)
167 }
168}
crates/notebook/src/queue.rs created+324
...@@ -0,0 +1,324 @@
1//! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes
2//! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them.
3
4use crate::{PendingEdit, Result, signed, unsigned};
5use onestore::{
6 op::{Edit, Op, PageOp, SectionOp, TableEdit},
7 page::{PageObject, PageParagraph, ParagraphContent, TableCell},
8};
9use rusqlite::{Connection, OptionalExtension, params};
10use sha2::{Digest, Sha256};
11use std::{io, sync::Arc};
12
13type Payload = Option<Arc<[u8]>>;
14
15fn paragraphs(list: &mut [PageParagraph], visit: &mut impl FnMut(&mut Payload)) {
16 for paragraph in list {
17 match &mut paragraph.content {
18 ParagraphContent::Image(image) => visit(&mut image.bytes),
19 ParagraphContent::Attachment(attachment) => {
20 visit(&mut attachment.bytes);
21 visit(&mut attachment.preview);
22 }
23 ParagraphContent::Table(table) => {
24 for row in &mut table.rows {
25 cells(&mut row.cells, visit);
26 }
27 }
28 ParagraphContent::Text(_)
29 | ParagraphContent::Ink(_)
30 | ParagraphContent::Unsupported(_) => {}
31 }
32 }
33}
34
35fn cells(list: &mut [TableCell], visit: &mut impl FnMut(&mut Payload)) {
36 for cell in list {
37 paragraphs(&mut cell.paragraphs, visit);
38 }
39}
40
41fn object(object: &mut PageObject, visit: &mut impl FnMut(&mut Payload)) {
42 match object {
43 PageObject::Outline(outline) => paragraphs(&mut outline.paragraphs, visit),
44 PageObject::Title(title) => {
45 for outline in &mut title.outlines {
46 paragraphs(&mut outline.paragraphs, visit);
47 }
48 }
49 PageObject::Image(image) => visit(&mut image.bytes),
50 PageObject::Ink(_) | PageObject::Unsupported(_) => {}
51 }
52}
53
54/// Visits every payload an edit carries, in a fixed order.
55fn slots(edit: &mut Edit, mut visit: impl FnMut(&mut Payload)) {
56 for op in &mut edit.ops {
57 match op {
58 Op::Page { op, .. } => match op {
59 PageOp::Insert {
60 paragraphs: list, ..
61 } => paragraphs(list, &mut visit),
62 PageOp::Add { object: added, .. } => object(added, &mut visit),
63 PageOp::Table {
64 edit: TableEdit::Rows { rows, .. },
65 ..
66 } => {
67 for row in rows {
68 cells(&mut row.cells, &mut visit);
69 }
70 }
71 PageOp::Table {
72 edit: TableEdit::Column { cells: list, .. },
73 ..
74 } => cells(list, &mut visit),
75 _ => {}
76 },
77 Op::Section(SectionOp::Import { page, .. }) => {
78 for added in &mut page.objects {
79 object(added, &mut visit);
80 }
81 }
82 Op::Section(_) => {}
83 }
84 }
85}
86
87fn damaged(message: &'static str) -> crate::Error {
88 io::Error::new(io::ErrorKind::InvalidData, message).into()
89}
90
91/// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by hash;
92/// returns the edit's id.
93pub(crate) fn insert(
94 connection: &Connection,
95 id: Option<u64>,
96 batch: i64,
97 author: &str,
98 edit: &Edit,
99) -> Result<u64> {
100 let (text, names) = encode(connection, edit)?;
101 connection.execute(
102 "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)",
103 params![id.map(signed).transpose()?, batch, author, text, names],
104 )?;
105 unsigned(connection.last_insert_rowid())
106}
107
108fn hex(digest: &[u8]) -> String {
109 digest.iter().map(|byte| format!("{byte:02x}")).collect()
110}
111
112/// Replaces a stored edit's ops.
113pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> {
114 let (text, names) = encode(connection, edit)?;
115 connection.execute(
116 "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3",
117 params![text, names, signed(id)?],
118 )?;
119 Ok(())
120}
121
122/// The serialized edit without payload bytes and the payload names, storing the payloads.
123fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option<String>)> {
124 let mut edit = edit.clone();
125 let mut names: Vec<Option<String>> = Vec::new();
126 let mut failure = None;
127 slots(&mut edit, |payload| {
128 let name = payload.take().map(|bytes| {
129 let digest = Sha256::digest(&bytes);
130 if let Err(error) = connection.execute(
131 "INSERT INTO payloads(sha256, bytes) VALUES (?1, ?2) ON CONFLICT DO NOTHING",
132 params![&digest[..], &bytes[..]],
133 ) {
134 failure.get_or_insert(error);
135 }
136 hex(&digest)
137 });
138 names.push(name);
139 });
140 if let Some(error) = failure {
141 return Err(error.into());
142 }
143 let names = names
144 .iter()
145 .any(Option::is_some)
146 .then(|| serde_json::to_string(&names))
147 .transpose()
148 .map_err(io::Error::other)?;
149 Ok((
150 serde_json::to_string(&edit).map_err(io::Error::other)?,
151 names,
152 ))
153}
154
155fn decode(connection: &Connection, text: &str, names: Option<String>) -> Result<Edit> {
156 let mut edit: Edit = serde_json::from_str(text)
157 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
158 let Some(names) = names else {
159 return Ok(edit);
160 };
161 let names: Vec<Option<String>> = serde_json::from_str(&names)
162 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
163 let mut names = names.into_iter();
164 let mut failure: Option<crate::Error> = None;
165 let mut query =
166 connection.prepare_cached("SELECT bytes FROM payloads WHERE sha256=unhex(?1)")?;
167 slots(&mut edit, |payload| {
168 let Some(name) = names.next() else {
169 failure.get_or_insert(damaged("A queued edit names fewer payloads than it holds"));
170 return;
171 };
172 let Some(name) = name else {
173 return;
174 };
175 match query
176 .query_row([&name], |row| row.get::<_, Vec<u8>>(0))
177 .optional()
178 {
179 Ok(Some(bytes)) if hex(&Sha256::digest(&bytes)) == name => {
180 *payload = Some(Arc::from(bytes));
181 }
182 Ok(_) => {
183 failure.get_or_insert(damaged("A queued payload is missing or damaged"));
184 }
185 Err(error) => {
186 failure.get_or_insert(error.into());
187 }
188 }
189 });
190 if names.next().is_some() {
191 failure.get_or_insert(damaged("A queued edit names more payloads than it holds"));
192 }
193 match failure {
194 Some(error) => Err(error),
195 None => Ok(edit),
196 }
197}
198
199/// Queued edits, oldest first; of one batch when `batch` is given.
200pub(crate) fn load(connection: &Connection, batch: Option<i64>) -> Result<Vec<PendingEdit>> {
201 let mut query = connection.prepare_cached(
202 "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id",
203 )?;
204 let mut rows = query.query([batch])?;
205 let mut edits = Vec::new();
206 while let Some(row) = rows.next()? {
207 edits.push(PendingEdit {
208 id: unsigned(row.get(0)?)?,
209 author: row.get(1)?,
210 edit: decode(connection, &row.get::<_, String>(2)?, row.get(3)?)?,
211 });
212 }
213 Ok(edits)
214}
215
216/// Drops payloads no queued edit names.
217pub(crate) fn collect(connection: &Connection) -> Result<()> {
218 connection.execute(
219 "DELETE FROM payloads WHERE NOT EXISTS (SELECT 1 FROM edits, json_each(edits.payloads) AS name
220 WHERE edits.payloads IS NOT NULL AND name.value=lower(hex(payloads.sha256)))",
221 [],
222 )?;
223 Ok(())
224}
225
226/// The object spaces an edit changes; section ops change the root space as well.
227pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec<onestore::ExGuid> {
228 let mut spaces = Vec::new();
229 for op in &edit.ops {
230 match op {
231 Op::Page { space, .. } => spaces.push(*space),
232 Op::Section(op) => {
233 spaces.push(root);
234 match op {
235 SectionOp::Create(creation) | SectionOp::Import { creation, .. } => {
236 spaces.push(creation.space());
237 }
238 SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())),
239 SectionOp::Delete(pages) => spaces.extend(pages),
240 }
241 }
242 }
243 }
244 spaces.sort();
245 spaces.dedup();
246 spaces
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use onestore::{ExGuid, page::Image};
253
254 #[test]
255 fn payloads_are_stored_once_by_hash_and_restored_in_place() {
256 let connection = Connection::open_in_memory().unwrap();
257 connection.execute_batch(crate::schema::QUEUE).unwrap();
258 connection
259 .execute("INSERT INTO batches DEFAULT VALUES", [])
260 .unwrap();
261 let bytes: Arc<[u8]> = Arc::from(vec![7_u8; 100_000]);
262 let image = |id: u32| {
263 PageObject::Image(Image {
264 id: ExGuid {
265 guid: [1; 16],
266 n: id,
267 },
268 layout: Default::default(),
269 size: None,
270 bytes: Some(Arc::clone(&bytes)),
271 alt: None,
272 background: false,
273 })
274 };
275 let space = ExGuid {
276 guid: [2; 16],
277 n: 1,
278 };
279 let edit = Edit {
280 at: 1,
281 ops: [image(1), image(2)]
282 .into_iter()
283 .map(|object| Op::Page {
284 space,
285 op: PageOp::Add {
286 object,
287 before: None,
288 },
289 })
290 .collect(),
291 };
292 let id = insert(&connection, None, 1, "Author", &edit).unwrap();
293 let stored: String = connection
294 .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| {
295 row.get(0)
296 })
297 .unwrap();
298 assert!(stored.len() < 1000, "{}", stored.len());
299 let payloads: i64 = connection
300 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
301 .unwrap();
302 assert_eq!(payloads, 1);
303 let loaded = load(&connection, None).unwrap();
304 assert_eq!(loaded[0].edit, edit);
305 let Op::Page {
306 op:
307 PageOp::Add {
308 object: PageObject::Image(image),
309 ..
310 },
311 ..
312 } = &loaded[0].edit.ops[1]
313 else {
314 panic!()
315 };
316 assert_eq!(image.bytes.as_deref(), Some(&bytes[..]));
317 connection.execute("DELETE FROM edits", []).unwrap();
318 collect(&connection).unwrap();
319 let payloads: i64 = connection
320 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
321 .unwrap();
322 assert_eq!(payloads, 0);
323 }
324}
crates/notebook/src/rebase.rs deleted-297
...@@ -1,297 +0,0 @@
1use std::ops::Range;
2
3const INFINITY: u32 = 1 << 30;
4const MAX_CELLS: usize = 4_000_000;
5
6struct Matrix {
7 band: usize,
8 values: Vec<u32>,
9}
10
11impl Matrix {
12 fn get(&self, row: usize, column: usize) -> u32 {
13 let Some(column) = column
14 .checked_add(self.band)
15 .and_then(|at| at.checked_sub(row))
16 else {
17 return INFINITY;
18 };
19 let width = self.band * 2 + 1;
20 if column >= width {
21 return INFINITY;
22 }
23 self.values
24 .get(row * width + column)
25 .copied()
26 .unwrap_or(INFINITY)
27 }
28
29 fn build(before: &[char], after: &[char], band: usize) -> Self {
30 let width = band * 2 + 1;
31 let mut matrix = Self {
32 band,
33 values: vec![INFINITY; (before.len() + 1) * width],
34 };
35 for row in 0..=before.len() {
36 for column in row.saturating_sub(band)..=after.len().min(row + band) {
37 let mut cost = if row == 0 && column == 0 { 0 } else { INFINITY };
38 if row > 0 {
39 cost = cost.min(matrix.get(row - 1, column) + 1);
40 }
41 if column > 0 {
42 cost = cost.min(matrix.get(row, column - 1) + 1);
43 }
44 if row > 0 && column > 0 && before[row - 1] == after[column - 1] {
45 cost = cost.min(matrix.get(row - 1, column - 1));
46 }
47 matrix.values[row * width + column + band - row] = cost;
48 }
49 }
50 matrix
51 }
52}
53
54/// Requires the same mapping in every minimum insertion/deletion alignment.
55pub(crate) fn rebase(before: &str, after: &str, range: Range<u32>) -> Option<Range<u32>> {
56 if range.start > range.end {
57 return None;
58 }
59 let mut a: Vec<_> = before.chars().collect();
60 let offsets: Vec<_> = std::iter::once(0)
61 .chain(a.iter().scan(0_u32, |at, character| {
62 *at = at.checked_add(character.len_utf16() as u32)?;
63 Some(*at)
64 }))
65 .collect();
66 let local =
67 offsets.binary_search(&range.start).ok()?..offsets.binary_search(&range.end).ok()?;
68 if before == after {
69 return Some(range);
70 }
71 let mut b: Vec<_> = after.chars().collect();
72 let (n, m) = (a.len(), b.len());
73 let mut band = n.abs_diff(m).max(1);
74 let forward = loop {
75 let width = band.checked_mul(2)?.checked_add(1)?;
76 if (n + 1).checked_mul(width)? > MAX_CELLS {
77 return None;
78 }
79 let matrix = Matrix::build(&a, &b, band);
80 if matrix.get(n, m) <= band as u32 {
81 break matrix;
82 }
83 band *= 2;
84 };
85 let distance = forward.get(n, m);
86 a.reverse();
87 b.reverse();
88 let backward = Matrix::build(&a, &b, band);
89 a.reverse();
90 b.reverse();
91 let position = |index: usize| {
92 let mut matched = None;
93 for column in index.saturating_sub(band)..=m.min(index + band) {
94 let cost = forward.get(index, column);
95 if cost + 1 + backward.get(n - index - 1, m - column) == distance {
96 return None;
97 }
98 if b.get(column) == Some(&a[index])
99 && cost + backward.get(n - index - 1, m - column - 1) == distance
100 {
101 if matched.is_some() {
102 return None;
103 }
104 matched = Some(column);
105 }
106 }
107 matched
108 };
109 let mapped = if local.is_empty() {
110 if local.start > 0 {
111 position(local.start - 1)?;
112 }
113 if local.start < n {
114 position(local.start)?;
115 }
116 let mut boundary = None;
117 for column in local.start.saturating_sub(band)..=m.min(local.start + band) {
118 if forward.get(local.start, column) + backward.get(n - local.start, m - column)
119 == distance
120 {
121 if boundary.is_some() {
122 return None;
123 }
124 boundary = Some(column);
125 }
126 }
127 let at = boundary?;
128 at..at
129 } else {
130 let start = position(local.start)?;
131 for index in local.start + 1..local.end {
132 if position(index)? != start + index - local.start {
133 return None;
134 }
135 }
136 start..start + local.len()
137 };
138 let start = b[..mapped.start]
139 .iter()
140 .map(|character| character.len_utf16())
141 .sum::<usize>();
142 let end = start
143 + b[mapped]
144 .iter()
145 .map(|character| character.len_utf16())
146 .sum::<usize>();
147 Some(u32::try_from(start).ok()?..u32::try_from(end).ok()?)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn optimal_paths(a: &[char], b: &[char]) -> Vec<Vec<(usize, usize)>> {
155 fn visit(
156 a: &[char],
157 b: &[char],
158 path: &mut Vec<(usize, usize)>,
159 cost: usize,
160 best: &mut usize,
161 found: &mut Vec<Vec<(usize, usize)>>,
162 ) {
163 if cost > *best {
164 return;
165 }
166 let (i, j) = *path.last().unwrap();
167 if (i, j) == (a.len(), b.len()) {
168 if cost < *best {
169 found.clear();
170 *best = cost;
171 }
172 found.push(path.clone());
173 return;
174 }
175 for (next_i, next_j, charge) in [(i + 1, j + 1, 0), (i + 1, j, 1), (i, j + 1, 1)] {
176 if next_i > a.len() || next_j > b.len() || (charge == 0 && a[i] != b[j]) {
177 continue;
178 }
179 path.push((next_i, next_j));
180 visit(a, b, path, cost + charge, best, found);
181 path.pop();
182 }
183 }
184 let mut found = Vec::new();
185 let mut best = usize::MAX;
186 visit(a, b, &mut vec![(0, 0)], 0, &mut best, &mut found);
187 found
188 }
189
190 #[test]
191 fn bounded_alignment_agrees_with_exhaustive_paths_for_every_small_unicode_edit() {
192 let mut words = vec![String::new()];
193 for length in 1..=4 {
194 for bits in 0..1 << length {
195 words.push(
196 (0..length)
197 .map(|bit| if bits & (1 << bit) == 0 { 'a' } else { '🦀' })
198 .collect(),
199 );
200 }
201 }
202 let mut cases = 0;
203 for before in &words {
204 let a: Vec<_> = before.chars().collect();
205 for after in &words {
206 let b: Vec<_> = after.chars().collect();
207 let paths = optimal_paths(&a, &b);
208 for start in 0..=a.len() {
209 for end in start..=a.len() {
210 let mut expected = None;
211 let mut valid = true;
212 for path in &paths {
213 let mapping: Vec<_> = (0..a.len())
214 .map(|i| {
215 path.windows(2).find_map(|edge| {
216 (edge[0].0 == i && edge[1] == (i + 1, edge[0].1 + 1))
217 .then_some(edge[0].1)
218 })
219 })
220 .collect();
221 let candidate = if start == end {
222 let vertices: Vec<_> = path
223 .iter()
224 .filter(|(i, _)| *i == start)
225 .map(|(_, j)| *j)
226 .collect();
227 if (start > 0 && mapping[start - 1].is_none())
228 || (start < a.len() && mapping[start].is_none())
229 || vertices.len() != 1
230 {
231 None
232 } else {
233 Some(vertices[0]..vertices[0])
234 }
235 } else if let Some(first) = mapping[start] {
236 (start..end)
237 .all(|i| mapping[i] == Some(first + i - start))
238 .then_some(first..first + end - start)
239 } else {
240 None
241 };
242 let Some(candidate) = candidate else {
243 valid = false;
244 break;
245 };
246 if expected.as_ref().is_some_and(|old| *old != candidate) {
247 valid = false;
248 break;
249 }
250 expected = Some(candidate);
251 }
252 let expected = if valid {
253 expected.map(|range| {
254 b[..range.start].iter().map(|c| c.len_utf16() as u32).sum()
255 ..b[..range.end].iter().map(|c| c.len_utf16() as u32).sum()
256 })
257 } else {
258 None
259 };
260 let range = a[..start].iter().map(|c| c.len_utf16() as u32).sum()
261 ..a[..end].iter().map(|c| c.len_utf16() as u32).sum();
262 assert_eq!(
263 rebase(before, after, range),
264 expected,
265 "{before:?} -> {after:?}, characters {start}..{end}"
266 );
267 cases += 1;
268 }
269 }
270 }
271 }
272 assert_eq!(cases, 10881);
273 }
274
275 #[test]
276 fn maps_disjoint_unicode_edits_and_rejects_ambiguous_or_overlapping_changes() {
277 assert_eq!(rebase("ab🦀cd", "Xab🦀cYd", 2..4), Some(3..5));
278 assert_eq!(rebase("abc", "XabcY", 1..2), Some(2..3));
279 assert_eq!(rebase("abc", "XabcY", 1..1), Some(2..2));
280 assert_eq!(rebase("abc", "abcX", 3..3), None);
281 assert_eq!(rebase("abc", "ac", 1..2), None);
282 assert_eq!(rebase("abc", "", 1..1), None);
283 assert_eq!(rebase("aaaa", "aaaaa", 1..2), None);
284 assert_eq!(rebase("ab🦀cd", "ab🦀cd", 3..4), None);
285 assert_eq!(rebase("abc", "abc", 4..4), None);
286 }
287
288 #[test]
289 fn long_paragraphs_with_small_remote_changes_use_a_narrow_band() {
290 let text = format!("{}🦀{}", "a".repeat(8192), "b".repeat(8192));
291 assert_eq!(
292 rebase(&text, &format!("X{text}Y"), 8192..8194),
293 Some(8193..8195)
294 );
295 assert_eq!(rebase(&"a".repeat(8192), &"b".repeat(8192), 1..2), None);
296 }
297}
crates/notebook/src/recovery.rs+90-78
...@@ -11,7 +11,9 @@ pub struct RecoverySummary {...@@ -11,7 +11,9 @@ pub struct RecoverySummary {
11 pub conflicts: u64,11 pub conflicts: u64,
12 pub uncertain_edits: u64,12 pub uncertain_edits: u64,
13 pub published_receipts: u64,13 pub published_receipts: u64,
14 pub working_bytes: u64,14 /// Bytes of the image the queued edits apply to.
15 pub base_bytes: u64,
16 /// Bytes of the observed remote image, while it is not the base.
15 pub remote_bytes: u64,17 pub remote_bytes: u64,
16 pub cached_assets: u64,18 pub cached_assets: u64,
17 pub cached_asset_bytes: u64,19 pub cached_asset_bytes: u64,
...@@ -36,33 +38,48 @@ impl Recovery {...@@ -36,33 +38,48 @@ impl Recovery {
36 io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(),38 io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(),
37 );39 );
38 }40 }
39 if version != SCHEMA_VERSION {41 if version != schema::VERSION {
40 return Err(io::Error::new(42 return Err(io::Error::new(
41 io::ErrorKind::InvalidData,43 io::ErrorKind::InvalidData,
42 format!(44 format!(
43 "Archive schema version {version} is not the supported version {SCHEMA_VERSION}"45 "Archive schema version {version} is not the supported version {}",
46 schema::VERSION
44 ),47 ),
45 )48 )
46 .into());49 .into());
47 }50 }
48 validate_images(&connection)?;51 let recovery = Self { connection };
49 for edit in pending(&connection)? {52 recovery.snapshot()?;
50 sync::status(&connection, edit.id)?;53 for edit in pending(&recovery.connection)? {
54 sync::status(&recovery.connection, edit.id)?;
51 }55 }
52 receipts(&connection)?;56 receipts(&recovery.connection)?;
53 Ok(Self { connection })57 Ok(recovery)
54 }58 }
5559
56 pub fn summary(&self) -> Result<RecoverySummary> {60 pub fn summary(&self) -> Result<RecoverySummary> {
57 summary(&self.connection)61 summary(&self.connection)
58 }62 }
5963
64 /// The image the archived queue leaves, as `Replica::snapshot` gives it.
60 pub fn snapshot(&self) -> Result<Vec<u8>> {65 pub fn snapshot(&self) -> Result<Vec<u8>> {
61 crate::images::working(&self.connection)66 working::image(&self.connection)
62 }67 }
6368
69 fn base(&self) -> Result<Vec<u8>> {
70 Ok(
71 base::read(&self.connection, base::Image::Base)?.ok_or_else(|| {
72 io::Error::new(io::ErrorKind::InvalidData, "The archive has no base image")
73 })?,
74 )
75 }
76
77 /// The last observed remote image.
64 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {78 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
65 crate::images::base(&self.connection)79 match base::read(&self.connection, base::Image::Remote)? {
80 Some(image) => Ok(image),
81 None => self.base(),
82 }
66 }83 }
6784
68 pub fn pending(&self) -> Result<Vec<PendingEdit>> {85 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
...@@ -86,83 +103,88 @@ impl Recovery {...@@ -86,83 +103,88 @@ impl Recovery {
86impl Replica {103impl Replica {
87 /// Summarizes durable state without exposing notebook content.104 /// Summarizes durable state without exposing notebook content.
88 pub fn recovery_summary(&self) -> Result<RecoverySummary> {105 pub fn recovery_summary(&self) -> Result<RecoverySummary> {
89 let connection = self106 summary(&*self.lock()?)
90 .connection
91 .lock()
92 .map_err(|_| io::Error::other("Cache owner panicked"))?;
93 summary(&connection)
94 }107 }
95108
96 /// Exports a consistent archive to a new local path without changing the live queue.109 /// Exports a consistent archive to a new local path without changing the live queue.
97 /// Archives contain notebook content and cannot be opened as writable replicas.110 /// Archives contain notebook content and cannot be opened as writable replicas.
98 /// An error after publication can leave a complete archive at the destination.111 /// An error after publication can leave a complete archive at the destination.
99 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {112 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
100 let path = path.as_ref();113 export(&*self.lock()?, path.as_ref(), false)
101 if path.file_name().is_none() {114 }
115}
116
117/// Copies the cache to `path` as a recovery archive; `replace` overwrites an existing file.
118pub(crate) fn export(source: &Connection, path: &Path, replace: bool) -> Result<()> {
119 if path.file_name().is_none() {
120 return Err(io::Error::new(
121 io::ErrorKind::InvalidInput,
122 "Provide a new recovery archive filename",
123 )
124 .into());
125 }
126 let parent = path
127 .parent()
128 .filter(|parent| !parent.as_os_str().is_empty())
129 .unwrap_or_else(|| Path::new("."));
130 let temporary = tempfile::Builder::new()
131 .prefix(".onestore-recovery-")
132 .tempfile_in(parent)?;
133 let mut destination = cache_connection(temporary.path())?;
134 {
135 let backup = Backup::new(source, &mut destination)?;
136 if backup.step(-1)? != StepResult::Done {
102 return Err(io::Error::new(137 return Err(io::Error::new(
103 io::ErrorKind::InvalidInput,138 io::ErrorKind::WouldBlock,
104 "Provide a new recovery archive filename",139 "Recovery export could not acquire the database snapshot",
105 )140 )
106 .into());141 .into());
107 }142 }
108 let parent = path143 }
109 .parent()144 // One self-contained file: the copied header says WAL, as the cache does.
110 .filter(|parent| !parent.as_os_str().is_empty())145 destination.pragma_update(None, "journal_mode", "DELETE")?;
111 .unwrap_or_else(|| Path::new("."));146 destination.pragma_update(None, "application_id", RECOVERY_ID)?;
112 let temporary = tempfile::Builder::new()147 destination.close().map_err(|(_, error)| error)?;
113 .prefix(".onestore-recovery-")148 temporary.as_file().sync_all()?;
114 .tempfile_in(parent)?;149 if replace {
115 let mut destination = cache_connection(temporary.path())?;150 temporary.persist(path).map_err(|error| error.error)?;
116 {151 } else {
117 let source = self
118 .connection
119 .lock()
120 .map_err(|_| io::Error::other("Cache owner panicked"))?;
121 let backup = Backup::new(&source, &mut destination)?;
122 if backup.step(-1)? != StepResult::Done {
123 return Err(io::Error::new(
124 io::ErrorKind::WouldBlock,
125 "Recovery export could not acquire the database snapshot",
126 )
127 .into());
128 }
129 }
130 destination.pragma_update(None, "application_id", RECOVERY_ID)?;
131 destination.close().map_err(|(_, error)| error)?;
132 temporary.as_file().sync_all()?;
133 temporary152 temporary
134 .persist_noclobber(path)153 .persist_noclobber(path)
135 .map_err(|error| error.error)?;154 .map_err(|error| error.error)?;
136 File::open(parent)?.sync_all()?;
137 Ok(())
138 }155 }
156 File::open(parent)?.sync_all()?;
157 Ok(())
139}158}
140159
141fn summary(connection: &Connection) -> Result<RecoverySummary> {160fn summary(connection: &Connection) -> Result<RecoverySummary> {
142 let (cached_assets, cached_asset_bytes) = connection.query_row(161 let (cached_assets, cached_asset_bytes): (i64, i64) = connection.query_row(
143 "SELECT count(*),coalesce(sum(length(data)),0) FROM assets",162 "SELECT count(*),coalesce(sum(length(data)),0) FROM assets",
144 [],163 [],
145 |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)),164 |row| Ok((row.get(0)?, row.get(1)?)),
146 )?;165 )?;
147 let working_bytes = crate::images::working_length(connection)?;166 let length = |image| -> Result<u64> {
148 Ok(connection.query_row(167 Ok(base::stamp(connection, image)?.map_or(0, |stamp| stamp.length))
149 "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts),168 };
150 (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts),169 let (queued_edits, conflicts, uncertain_edits, published_receipts): (i64, i64, i64, i64) =
151 length(base) FROM replica WHERE id=1",170 connection.query_row(
152 [],171 "SELECT (SELECT count(*) FROM edits),
153 |row| {172 (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE conflict IS NOT NULL),
154 Ok(RecoverySummary {173 (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE attempted=1),
155 queued_edits: unsigned(row, 0)?,174 (SELECT count(*) FROM receipts)",
156 conflicts: unsigned(row, 1)?,175 [],
157 uncertain_edits: unsigned(row, 2)?,176 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
158 published_receipts: unsigned(row, 3)?,177 )?;
159 working_bytes,178 Ok(RecoverySummary {
160 remote_bytes: unsigned(row, 4)?,179 queued_edits: unsigned(queued_edits)?,
161 cached_assets,180 conflicts: unsigned(conflicts)?,
162 cached_asset_bytes,181 uncertain_edits: unsigned(uncertain_edits)?,
163 })182 published_receipts: unsigned(published_receipts)?,
164 },183 base_bytes: length(base::Image::Base)?,
165 )?)184 remote_bytes: length(base::Image::Remote)?,
185 cached_assets: unsigned(cached_assets)?,
186 cached_asset_bytes: unsigned(cached_asset_bytes)?,
187 })
166}188}
167189
168fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {190fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {
...@@ -171,17 +193,7 @@ fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {...@@ -171,17 +193,7 @@ fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {
171 let mut rows = statement.query([])?;193 let mut rows = statement.query([])?;
172 let mut receipts = BTreeMap::new();194 let mut receipts = BTreeMap::new();
173 while let Some(row) = rows.next()? {195 while let Some(row) = rows.next()? {
174 receipts.insert(unsigned(row, 0)?, row.get::<_, String>(1)?.parse()?);196 receipts.insert(unsigned(row.get(0)?)?, row.get::<_, String>(1)?.parse()?);
175 }197 }
176 Ok(receipts)198 Ok(receipts)
177}199}
178
179fn unsigned(row: &rusqlite::Row<'_>, column: usize) -> rusqlite::Result<u64> {
180 u64::try_from(row.get::<_, i64>(column)?).map_err(|error| {
181 rusqlite::Error::FromSqlConversionFailure(
182 column,
183 rusqlite::types::Type::Integer,
184 Box::new(error),
185 )
186 })
187}
crates/notebook/src/schema.rs+45-31
...@@ -1,39 +1,53 @@...@@ -1,39 +1,53 @@
1use super::*;1use super::*;
2use rusqlite::Transaction;2use rusqlite::Transaction;
33
4const CONFLICTS: &str = "CREATE TABLE conflicts (4pub(crate) const VERSION: u32 = 15;
5 edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE,
6 kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)
7) STRICT;";
85
9const ASSETS: &str = "CREATE TABLE assets (6/// Tables a schema-14 cache shares with this one.
10 name TEXT PRIMARY KEY NOT NULL,7pub(crate) const KEPT: &str = "
11 data BLOB NOT NULL,8 CREATE TABLE receipts (
12 sha256 BLOB NOT NULL CHECK(length(sha256)=32)9 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
13) STRICT;";10 revision TEXT NOT NULL
11 ) STRICT;
12 CREATE TABLE archived (
13 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
14 archive TEXT NOT NULL
15 ) STRICT;
16 CREATE TABLE assets (
17 name TEXT PRIMARY KEY NOT NULL,
18 data BLOB NOT NULL,
19 sha256 BLOB NOT NULL CHECK(length(sha256)=32)
20 ) STRICT;";
21
22/// The queue: the image its edits apply to, in chunks, and the edits in publication batches.
23pub(crate) const QUEUE: &str = "
24 CREATE TABLE base (chunk INTEGER PRIMARY KEY CHECK(chunk>=0), bytes BLOB NOT NULL) STRICT;
25 CREATE TABLE remote (chunk INTEGER PRIMARY KEY CHECK(chunk>=0), bytes BLOB NOT NULL) STRICT;
26 CREATE TABLE batches (
27 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0),
28 sealed TEXT,
29 revisions TEXT,
30 attempted INTEGER NOT NULL DEFAULT 0 CHECK(attempted IN (0,1)),
31 conflict INTEGER CHECK(conflict BETWEEN 0 AND 3),
32 space TEXT,
33 CHECK((sealed IS NULL) = (revisions IS NULL)),
34 CHECK(attempted=0 OR sealed IS NOT NULL),
35 CHECK((conflict IS NULL) = (space IS NULL))
36 ) STRICT;
37 CREATE TABLE edits (
38 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0),
39 batch INTEGER NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
40 author TEXT NOT NULL,
41 edit TEXT NOT NULL,
42 payloads TEXT
43 ) STRICT;
44 CREATE TABLE payloads (
45 sha256 BLOB PRIMARY KEY CHECK(length(sha256)=32),
46 bytes BLOB NOT NULL
47 ) STRICT;";
1448
15pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {49pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
16 transaction.execute_batch(50 transaction.execute_batch(QUEUE)?;
17 "CREATE TABLE edits (51 transaction.execute_batch(KEPT)?;
18 id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0),
19 space TEXT NOT NULL,
20 operation TEXT NOT NULL
21 ) STRICT;
22 CREATE TABLE attempt (
23 id INTEGER PRIMARY KEY CHECK(id=1),
24 edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE,
25 revisions TEXT NOT NULL
26 ) STRICT;
27 CREATE TABLE receipts (
28 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
29 revision TEXT NOT NULL
30 ) STRICT;
31 CREATE TABLE archived (
32 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
33 archive TEXT NOT NULL
34 ) STRICT;",
35 )?;
36 transaction.execute_batch(CONFLICTS)?;
37 transaction.execute_batch(ASSETS)?;
38 Ok(())52 Ok(())
39}53}
crates/notebook/src/session.rs+246-299
...@@ -1,15 +1,18 @@...@@ -1,15 +1,18 @@
1//! The application's view of a notebook: sections opened through a local replica that1//! The application's view of a notebook: sections opened through a local replica that
2//! publishes page saves to the section file in the background.2//! publishes their edits to the section file in the background.
33
4use crate::{4use crate::{
5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,5 Conflict, ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result,
6 SyncWorker, discover,
6};7};
7use onestore::{8use onestore::{
8 CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction,9 CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction,
9 document::Document, page::Page,10 document::Document,
11 op::{Edit, Op, SectionOp},
12 page::Page,
10};13};
11use std::{14use std::{
12 collections::{BTreeMap, VecDeque},15 collections::BTreeMap,
13 io,16 io,
14 io::Write,17 io::Write,
15 path::{Path, PathBuf},18 path::{Path, PathBuf},
...@@ -18,7 +21,6 @@ use std::{...@@ -18,7 +21,6 @@ use std::{
18 atomic::{AtomicUsize, Ordering},21 atomic::{AtomicUsize, Ordering},
19 mpsc::{self, Receiver, Sender},22 mpsc::{self, Receiver, Sender},
20 },23 },
21 thread::{self, JoinHandle},
22 time::Duration,24 time::Duration,
23};25};
2426
...@@ -723,10 +725,15 @@ fn catalog_path(folder: &str, name: &str) -> String {...@@ -723,10 +725,15 @@ fn catalog_path(folder: &str, name: &str) -> String {
723 }725 }
724}726}
725727
726/// What happened on the synchronization thread since the last poll.728/// What happened to the section since the last poll.
727#[derive(Debug)]729#[derive(Debug)]
728pub enum Event {730pub enum Event {
729 /// The working image was refreshed from the section file with no local edit pending.731 /// A remote change reached these pages; reload them where they are open.
732 Changed(Vec<ExGuid>),
733 /// The section refused an edit `apply` handed it; the pages it names are as they were
734 /// before it, so an editor showing them should reload them.
735 Rejected { spaces: Vec<ExGuid>, error: String },
736 /// Old session API, until the application reloads on `Changed`: pages changed remotely.
730 Refreshed,737 Refreshed,
731 /// A publication attempt finished with this durable state.738 /// A publication attempt finished with this durable state.
732 Attempt { id: u64, status: EditStatus },739 Attempt { id: u64, status: EditStatus },
...@@ -736,7 +743,7 @@ pub enum Event {...@@ -736,7 +743,7 @@ pub enum Event {
736 Failed(String),743 Failed(String),
737}744}
738745
739/// The outcome of saving an edited page.746/// The outcome of saving an edited page (old session API).
740#[derive(Debug, PartialEq, Eq)]747#[derive(Debug, PartialEq, Eq)]
741pub enum Save {748pub enum Save {
742 /// The model equals the stored page.749 /// The model equals the stored page.
...@@ -755,31 +762,27 @@ pub struct QueuedEdit {...@@ -755,31 +762,27 @@ pub struct QueuedEdit {
755 pub status: EditStatus,762 pub status: EditStatus,
756}763}
757764
758/// A section file with its replica, background saves and background publication.765type Notify = Arc<dyn Fn() + Send + Sync>;
766
767/// A section file with its replica and background publication. `Send + Sync`: edits
768/// arrive from any thread without waiting, and `notify` wakes the host when events wait.
759pub struct Section {769pub struct Section {
760 file: PathBuf,770 file: PathBuf,
761 replica: Arc<Replica>,771 replica: Arc<Replica>,
762 worker: Option<SyncWorker>,772 worker: Option<SyncWorker>,
763 events: Receiver<Event>,773 events: Mutex<Receiver<Event>>,
764 saves: Option<Sender<PageSave>>,774 sender: Sender<Event>,
765 saved: Receiver<(ExGuid, std::result::Result<Save, String>)>,775 notify: Notify,
776 saved: Mutex<Receiver<(ExGuid, std::result::Result<Save, String>)>>,
777 saves: Sender<(ExGuid, std::result::Result<Save, String>)>,
766 /// Saves queued and not yet reported.778 /// Saves queued and not yet reported.
767 unsaved: Arc<AtomicUsize>,779 unsaved: Arc<AtomicUsize>,
768 saver: Option<JoinHandle<()>>,
769}
770
771/// An edited page `Section::queue_save` hands to the save thread.
772struct PageSave {
773 space: ExGuid,
774 before: Page,
775 after: Page,
776 author: String,
777}780}
778781
779impl Section {782impl Section {
780 /// Opens the section file through a replica in `cache`, creating the replica from the783 /// Opens the section file through a replica in `cache`, creating the replica from the
781 /// file on first use. `notify` runs on the synchronization thread whenever an event is784 /// file on first use and converting an older one. `notify` runs on a background thread
782 /// available.785 /// whenever an event is available.
783 pub fn open(786 pub fn open(
784 file: impl AsRef<Path>,787 file: impl AsRef<Path>,
785 cache: impl AsRef<Path>,788 cache: impl AsRef<Path>,
...@@ -847,58 +850,58 @@ impl Section {...@@ -847,58 +850,58 @@ impl Section {
847 ) -> Result<Self> {850 ) -> Result<Self> {
848 let replica = Arc::new(replica);851 let replica = Arc::new(replica);
849 let (sender, events) = mpsc::channel();852 let (sender, events) = mpsc::channel();
850 let (saved_sender, saved) = mpsc::channel();853 let (saves, saved) = mpsc::channel();
851 let notify = Arc::new(Mutex::new(notify));854 let notify = Mutex::new(notify);
852 let signal = move || {855 let notify: Notify = Arc::new(move || {
853 if let Ok(notify) = notify.lock() {856 if let Ok(notify) = notify.lock() {
854 notify();857 notify();
855 }858 }
856 };859 });
857 let (saves, requests) = mpsc::channel();860 let worker = {
858 let unsaved = Arc::new(AtomicUsize::new(0));861 let (sender, notify) = (sender.clone(), Arc::clone(&notify));
859 let saver = {862 replica.start_sync(Duration::from_secs(2), connect, move |result| {
860 let (replica, signal) = (Arc::clone(&replica), signal.clone());863 let mut events = Vec::new();
861 let unsaved = Arc::clone(&unsaved);864 match result {
862 thread::Builder::new()865 Ok(synced) => {
863 .name("onestore-save".into())866 if !synced.changed.is_empty() {
864 .spawn(move || {867 events.push(Event::Changed(synced.changed.clone()));
865 save_pages(&replica, &requests, |outcome, count| {868 events.push(Event::Refreshed);
866 unsaved.fetch_sub(count, Ordering::Release);
867 if saved_sender.send(outcome).is_ok() {
868 signal();
869 }869 }
870 });870 if let Some((id, status)) = &synced.edit {
871 })?871 events.push(Event::Attempt {
872 };872 id: *id,
873 let worker = replica.start_sync(Duration::from_secs(2), connect, move |result| {873 status: status.clone(),
874 let event = match result {874 });
875 Ok(None) => Event::Refreshed,875 }
876 Ok(Some((id, status))) => Event::Attempt {876 }
877 id: *id,877 Err(Error::RemoteIo(error)) => {
878 status: status.clone(),878 events.push(Event::Unreachable(match error.raw_os_error() {
879 },879 Some(code) => io::Error::from_raw_os_error(code),
880 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {880 None => io::Error::new(error.kind(), error.to_string()),
881 Some(code) => io::Error::from_raw_os_error(code),881 }))
882 None => io::Error::new(error.kind(), error.to_string()),882 }
883 }),883 Err(Error::Remote(error)) => events.push(Event::Unreachable(io::Error::new(
884 Err(Error::Remote(error)) => {884 error.error.kind(),
885 Event::Unreachable(io::Error::new(error.error.kind(), error.to_string()))885 error.to_string(),
886 ))),
887 Err(error) => events.push(Event::Failed(error.to_string())),
886 }888 }
887 Err(error) => Event::Failed(error.to_string()),889 if !events.is_empty() && events.into_iter().all(|event| sender.send(event).is_ok())
888 };890 {
889 if sender.send(event).is_ok() {891 notify();
890 signal();892 }
891 }893 })?
892 })?;894 };
893 Ok(Self {895 Ok(Self {
894 file,896 file,
895 replica,897 replica,
896 worker: Some(worker),898 worker: Some(worker),
897 events,899 events: Mutex::new(events),
898 saves: Some(saves),900 sender,
899 saved,901 notify,
900 unsaved,902 saved: Mutex::new(saved),
901 saver: Some(saver),903 saves,
904 unsaved: Arc::new(AtomicUsize::new(0)),
902 })905 })
903 }906 }
904907
...@@ -910,111 +913,74 @@ impl Section {...@@ -910,111 +913,74 @@ impl Section {
910 /// The section file identity, which internal links name as `section-id`913 /// The section file identity, which internal links name as `section-id`
911 /// (`onestore::page::link::internal_link`).914 /// (`onestore::page::link::internal_link`).
912 pub fn identity(&self) -> Result<[u8; 16]> {915 pub fn identity(&self) -> Result<[u8; 16]> {
913 Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id)916 self.replica.identity()
914 }917 }
915918
916 /// Page spaces, titles and outline levels (1 at the top) in section order, from the919 /// Applies an edit without waiting: it becomes durable on the section thread, which
917 /// local working image.920 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.
918 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {921 pub fn apply(&self, author: &str, edit: Edit) -> Result<()> {
919 let snapshot = self.replica.snapshot()?;922 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
920 let store = Store::parse(&snapshot)?;923 let root = self.replica.root;
921 let index = RevisionIndex::parse(&store)?;924 let spaces = crate::queue::spaces(&edit, root);
922 let document = Document::parse(&index)?;925 self.replica.submit(
923 let pages = document.pages()?;926 author,
924 let mut spaces = std::collections::BTreeSet::new();927 edit,
925 pages928 Box::new(move |result| {
926 .into_iter()929 let event = match result {
927 .map(|(space, page)| {930 Ok(_) => return,
928 if !spaces.insert(space) {931 Err(Error::Rejected(error)) => Event::Rejected {
929 return Err(onestore::Error {932 spaces,
930 offset: 0,933 error: error.to_string(),
931 message: "Choose an object space containing one active page",934 },
932 }935 Err(error) => Event::Failed(error.to_string()),
933 .into());936 };
937 if sender.send(event).is_ok() {
938 notify();
934 }939 }
935 let (title, level) = Page::heading(document.active(space)?, page);940 }),
936 Ok((space, title, level))941 )
937 })
938 .collect()
939 }942 }
940943
944 /// Page spaces, titles and outline levels (1 at the top) in section order, as the
945 /// local edits leave them.
946 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
947 self.replica.pages()
948 }
949
950 /// The page to show or edit; O(page), for opening and reloading.
941 pub fn page(&self, space: ExGuid) -> Result<Page> {951 pub fn page(&self, space: ExGuid) -> Result<Page> {
942 self.replica.page(space)952 self.replica.page(space)
943 }953 }
944954
945 /// Copies a page (usually from another section) to the end of this section as a955 /// Copies a page (usually from another section) to the end of this section under
946 /// creation and a save queued like the user's own edits, under fresh identities.956 /// fresh identities, queued like the user's own edits. Returns the new page's space.
947 /// Returns the new page's space. Content outside the model refuses to copy.957 /// Content outside the model refuses to copy.
948 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {958 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {
949 let copy = page.copy()?;
950 let creation = PageCreation::new(None, Some(&page.title), author)?;959 let creation = PageCreation::new(None, Some(&page.title), author)?;
951 self.replica
952 .create_page(&self.replica.snapshot()?, &creation)?;
953 let space = creation.space();960 let space = creation.space();
954 loop {961 self.replica.apply(
955 // The worker may publish the creation, and so replace the working image,962 author,
956 // between reading it and saving against it.963 Edit {
957 let source = self.replica.snapshot()?;964 at: crate::now(),
958 let mut after = Page::from_space(965 ops: vec![Op::Section(SectionOp::Import {
959 &Document::parse(&RevisionIndex::parse(&Store::parse(&source)?)?)?,966 creation,
960 space,967 page: page.copy()?,
961 )?;968 })],
962 after969 },
963 .objects970 )?;
964 .retain(|object| matches!(object, onestore::page::PageObject::Title(_)));
965 after.objects.extend(
966 copy.objects
967 .iter()
968 .filter(|object| !matches!(object, onestore::page::PageObject::Title(_)))
969 .cloned(),
970 );
971 after.definitions = copy.definitions.clone();
972 match self.replica.save(&source, space, &after, author) {
973 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => continue,
974 result => result?,
975 };
976 break;
977 }
978 self.wake();
979 Ok(space)971 Ok(space)
980 }972 }
981973
982 /// Removes pages permanently, queued like the user's own edits (a move across974 /// Removes pages permanently, queued like the user's own edits (a move across
983 /// sections is `import_page` there, then this here).975 /// sections is `import_page` there, then this here).
984 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<Option<u64>> {976 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> {
985 let id = self977 self.replica.apply(
986 .replica978 "",
987 .delete_pages(&self.replica.snapshot()?, pages)?;979 Edit {
988 self.wake();980 at: crate::now(),
989 Ok(id)981 ops: vec![Op::Section(SectionOp::Delete(pages.to_vec()))],
990 }982 },
991983 )
992 /// Saves an edited page. `before` is the model the edit started from; a stored page
993 /// that differs from it means the section changed underneath the editor.
994 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {
995 Ok(save(&self.replica, space, before, None, after, author)?.0)
996 }
997
998 /// `save` on the section's save thread, returning at once; `saved` reports the outcome.
999 /// Saves queue in order, and one whose `before` is the previous save's `after` continues
1000 /// it: pass each save's `after` as the next one's `before`.
1001 pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> {
1002 let save = PageSave {
1003 space,
1004 before,
1005 after,
1006 author: author.to_owned(),
1007 };
1008 self.unsaved.fetch_add(1, Ordering::Relaxed);
1009 self.saves
1010 .as_ref()
1011 .and_then(|saves| saves.send(save).ok())
1012 .ok_or_else(|| io::Error::other("The save thread stopped").into())
1013 }
1014
1015 /// Whether a queued save has yet to report, so the stored page may trail the editor's.
1016 pub fn saving(&self) -> bool {
1017 self.unsaved.load(Ordering::Acquire) > 0
1018 }984 }
1019985
1020 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {986 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
...@@ -1025,78 +991,48 @@ impl Section {...@@ -1025,78 +991,48 @@ impl Section {
1025 self.replica.pending()991 self.replica.pending()
1026 }992 }
1027993
1028 /// Every queued edit with its state: pending, awaiting confirmation of a retained994 /// The unpublished batch the remote no longer accepts, if any. Show `remote_page`
1029 /// attempt, or a conflict awaiting review.995 /// against `page` for its space, then `resolve`.
1030 pub fn queue(&self) -> Result<Vec<QueuedEdit>> {996 pub fn conflict(&self) -> Result<Option<Conflict>> {
1031 self.replica997 self.replica.conflict()
1032 .queued()?
1033 .into_iter()
1034 .map(|(id, space)| {
1035 Ok(QueuedEdit {
1036 id,
1037 space,
1038 status: self.replica.status(id)?.unwrap_or(EditStatus::Pending),
1039 })
1040 })
1041 .collect()
1042 }
1043
1044 /// The queued edits whose publication conflicted with a remote change.
1045 pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> {
1046 Ok(self
1047 .queue()?
1048 .into_iter()
1049 .filter_map(|edit| match edit.status {
1050 EditStatus::Conflict(kind) => Some((edit, kind)),
1051 _ => None,
1052 })
1053 .collect())
1054 }998 }
1055999
1056 /// The page as last observed in the section file, for reviewing a conflict.1000 /// The page as last observed in the section file, for reviewing a conflict.
1057 pub fn remote_page(&self, space: ExGuid) -> Result<Page> {1001 pub fn remote_page(&self, space: ExGuid) -> Result<Page> {
1058 let snapshot = self.replica.remote_snapshot()?;1002 let snapshot = self.replica.remote_snapshot()?;
1059 let store = Store::parse(&snapshot)?;1003 let arena = onestore::Arena::default();
1060 let index = RevisionIndex::parse(&store)?;1004 Ok(onestore::Section::open(&arena, snapshot)?.page(space)?)
1061 Ok(Page::from_space(&Document::parse(&index)?, space)?)
1062 }1005 }
10631006
1064 /// Resolves the oldest conflict with a page reviewed against `remote_page`; the1007 /// Ends a conflict: `Mine` publishes the local page over the remote change, `Theirs`
1065 /// reviewed model publishes as a whole, keeping the edit's id.1008 /// drops the local edits to the conflicted page (and its later ones).
1066 pub fn review(&self, id: u64, after: &Page) -> Result<()> {1009 pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> {
1067 let local = self.replica.snapshot()?;1010 self.replica.resolve(id, resolution)
1068 let remote = self.replica.remote_snapshot()?;
1069 self.replica.review_page(id, &local, &remote, after)?;
1070 self.wake();
1071 Ok(())
1072 }1011 }
10731012
1074 /// Retires an uncertain attempt after review, exporting the branch to `archive`1013 /// Retires an uncertain attempt after review, exporting the queue to `archive` first:
1075 /// first: `Some(page)` continues from the reviewed page against `remote_page`,1014 /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the
1076 /// `None` abandons the local branch. Neither claims the attempt was acknowledged.1015 /// attempt was acknowledged.
1077 pub fn release(&self, id: u64, archive: impl AsRef<Path>, after: Option<&Page>) -> Result<()> {1016 pub fn release(
1078 let local = self.replica.snapshot()?;1017 &self,
1079 let remote = self.replica.remote_snapshot()?;1018 id: u64,
1080 self.replica1019 archive: impl AsRef<Path>,
1081 .release_attempt(id, &local, &remote, archive.as_ref(), after)?;1020 resolution: Resolution,
1082 self.wake();1021 ) -> Result<()> {
1083 Ok(())1022 self.replica.release(id, archive.as_ref(), resolution)
1084 }1023 }
10851024
1086 /// Captures both images, the queue and its states in a read-only archive.1025 /// Captures the queue, its images and its states in a read-only archive.
1087 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {1026 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
1088 self.replica.export_recovery(path)1027 self.replica.export_recovery(path)
1089 }1028 }
10901029
1091 /// Events since the last poll, oldest first.1030 /// Events since the last poll, oldest first.
1092 pub fn events(&self) -> Vec<Event> {1031 pub fn events(&self) -> Vec<Event> {
1093 self.events.try_iter().collect()1032 self.events
1094 }1033 .lock()
10951034 .map(|events| events.try_iter().collect())
1096 /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each.1035 .unwrap_or_default()
1097 /// Consecutive saves continuing one another finish as one, reported once.
1098 pub fn saved(&self) -> Vec<(ExGuid, std::result::Result<Save, String>)> {
1099 self.saved.try_iter().collect()
1100 }1036 }
11011037
1102 /// Requests a synchronization attempt now.1038 /// Requests a synchronization attempt now.
...@@ -1115,107 +1051,118 @@ impl Section {...@@ -1115,107 +1051,118 @@ impl Section {
1115 /// Dropping instead requests cancellation without waiting; the worker retains1051 /// Dropping instead requests cancellation without waiting; the worker retains
1116 /// cache ownership until that operation finishes. Remote calls must be bounded.1052 /// cache ownership until that operation finishes. Remote calls must be bounded.
1117 pub fn close(mut self) -> Result<()> {1053 pub fn close(mut self) -> Result<()> {
1118 drop(self.saves.take());
1119 if let Some(saver) = self.saver.take() {
1120 saver
1121 .join()
1122 .map_err(|_| io::Error::other("The save thread panicked"))?;
1123 }
1124 match self.worker.take() {1054 match self.worker.take() {
1125 Some(worker) => worker.stop(),1055 Some(worker) => worker.stop(),
1126 None => Ok(()),1056 None => Ok(()),
1127 }1057 }
1128 }1058 }
1129}
11301059
1131/// Saves `after` unless the stored page no longer matches `before`, returning the working1060 // Old whole-page session API, kept until the application emits ops.
1132/// image the save leaves. `known` is the image a previous save of `before` left: while the1061
1133/// working image equals it the page is current unread, and otherwise the page `known`1062 /// Saves an edited page. `before` is the model the edit started from; a stored page
1134/// stores stands in for `before`.1063 /// that differs from it means the section changed underneath the editor.
1135fn save(1064 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {
1136 replica: &Replica,1065 let outcome = self.replica.ask(|reply| crate::working::Request::Save {
1137 space: ExGuid,
1138 before: &Page,
1139 known: Option<&[u8]>,
1140 after: &Page,
1141 author: &str,
1142) -> Result<(Save, Vec<u8>)> {
1143 let stored = |image: &[u8]| -> Result<Page> {
1144 let store = Store::parse(image)?;
1145 Ok(Page::from_space(
1146 &Document::parse(&RevisionIndex::parse(&store)?)?,
1147 space,1066 space,
1148 )?)1067 before: before.clone(),
1149 };1068 after: after.clone(),
1150 loop {1069 author: author.to_owned(),
1151 let snapshot = replica.snapshot()?;1070 reply,
1152 if known != Some(snapshot.as_slice()) {1071 })?;
1153 let current = stored(&snapshot)?;1072 Ok(outcome.unwrap_or(Save::Unchanged))
1154 let expected = match known {
1155 Some(image) => current == stored(image)?,
1156 None => current == *before,
1157 };
1158 if !expected {
1159 return Ok((Save::Stale, snapshot));
1160 }
1161 }
1162 match replica.save_image(&snapshot, space, after, author) {
1163 Ok((Some(id), image)) => return Ok((Save::Queued(id), image)),
1164 Ok((None, image)) => return Ok((Save::Unchanged, image)),
1165 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
1166 Err(error) => return Err(error),
1167 }
1168 }1073 }
1169}
11701074
1171/// Runs queued saves until the section drops its sender, folding each run of saves that1075 /// `save` on the section thread, returning at once; `saved` reports the outcome.
1172/// continue one another into one.1076 /// Saves queue in order, and one whose `before` is the previous save's `after`
1173fn save_pages(1077 /// continues it: pass each save's `after` as the next one's `before`.
1174 replica: &Replica,1078 pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> {
1175 requests: &Receiver<PageSave>,1079 let (saves, notify, unsaved) = (
1176 report: impl Fn((ExGuid, std::result::Result<Save, String>), usize),1080 self.saves.clone(),
1177) {1081 Arc::clone(&self.notify),
1178 let mut queue = VecDeque::new();1082 Arc::clone(&self.unsaved),
1179 // The last save's page and the working image it left.
1180 let mut last: Option<(ExGuid, Page, Vec<u8>)> = None;
1181 loop {
1182 if queue.is_empty() {
1183 match requests.recv() {
1184 Ok(request) => queue.push_back(request),
1185 Err(_) => return,
1186 }
1187 }
1188 queue.extend(requests.try_iter());
1189 let mut request = queue.pop_front().unwrap();
1190 let mut count = 1;
1191 while let Some(next) = queue.front()
1192 && (next.space, &next.before, &next.author)
1193 == (request.space, &request.after, &request.author)
1194 {
1195 request.after = queue.pop_front().unwrap().after;
1196 count += 1;
1197 }
1198 let known = last
1199 .take()
1200 .filter(|(space, after, _)| *space == request.space && *after == request.before);
1201 let result = save(
1202 replica,
1203 request.space,
1204 &request.before,
1205 known.as_ref().map(|(_, _, image)| image.as_slice()),
1206 &request.after,
1207 &request.author,
1208 );1083 );
1209 let save = match result {1084 self.unsaved.fetch_add(1, Ordering::Relaxed);
1210 Ok((save, image)) => {1085 self.replica.send(crate::working::Request::Save {
1211 if save != Save::Stale {1086 space,
1212 last = Some((request.space, request.after, image));1087 before,
1088 after,
1089 author: author.to_owned(),
1090 reply: Box::new(move |outcome| {
1091 unsaved.fetch_sub(1, Ordering::Release);
1092 let outcome = match outcome {
1093 Ok(None) => return,
1094 Ok(Some(save)) => Ok(save),
1095 Err(error) => Err(error.to_string()),
1096 };
1097 if saves.send((space, outcome)).is_ok() {
1098 notify();
1213 }1099 }
1214 Ok(save)1100 }),
1215 }1101 })
1216 Err(error) => Err(error.to_string()),1102 }
1217 };1103
1218 report((request.space, save), count);1104 /// Whether a queued save has yet to report, so the stored page may trail the editor's.
1105 pub fn saving(&self) -> bool {
1106 self.unsaved.load(Ordering::Acquire) > 0
1107 }
1108
1109 /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each.
1110 /// Consecutive saves continuing one another finish as one, reported once.
1111 pub fn saved(&self) -> Vec<(ExGuid, std::result::Result<Save, String>)> {
1112 self.saved
1113 .lock()
1114 .map(|saved| saved.try_iter().collect())
1115 .unwrap_or_default()
1116 }
1117
1118 /// Every queued edit with its state: pending, awaiting confirmation of a retained
1119 /// attempt, or a conflict awaiting review. `space` is the first page it edits.
1120 pub fn queue(&self) -> Result<Vec<QueuedEdit>> {
1121 self.replica
1122 .pending()?
1123 .into_iter()
1124 .map(|edit| {
1125 Ok(QueuedEdit {
1126 id: edit.id,
1127 space: edit
1128 .edit
1129 .ops
1130 .iter()
1131 .find_map(|op| match op {
1132 Op::Page { space, .. } => Some(*space),
1133 Op::Section(_) => None,
1134 })
1135 .unwrap_or(self.replica.root),
1136 status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending),
1137 })
1138 })
1139 .collect()
1140 }
1141
1142 /// The conflict as a queue entry, for the review dialog.
1143 pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> {
1144 Ok(self
1145 .replica
1146 .conflict()?
1147 .map(|conflict| {
1148 (
1149 QueuedEdit {
1150 id: conflict.id,
1151 space: conflict.space,
1152 status: EditStatus::Conflict(conflict.kind),
1153 },
1154 conflict.kind,
1155 )
1156 })
1157 .into_iter()
1158 .collect())
1159 }
1160
1161 /// Resolves the conflict holding `id` with a page reviewed against `remote_page`: the
1162 /// remote page is rewritten to `after`.
1163 pub fn review(&self, id: u64, after: &Page) -> Result<()> {
1164 self.replica
1165 .resolve_with(id, Resolution::Mine, Some(after.clone()))
1219 }1166 }
1220}1167}
12211168
crates/notebook/src/sync.rs+515-490
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1use super::*;1use super::*;
2use onestore::{CommitError, CommitState, Stamp, Transaction};2use crate::working::{Rebased, Request, Resolve, Sealed};
3use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction};
3use rusqlite::OptionalExtension;4use rusqlite::OptionalExtension;
4use std::{5use std::{
5 collections::BTreeMap,6 collections::BTreeMap,
6 sync::{MutexGuard, TryLockError, atomic::Ordering},7 sync::{MutexGuard, TryLockError},
7};8};
89
9/// A single remote file with fresh reads and native-compatible guarded publication.10/// A single remote file with fresh reads and native-compatible guarded publication.
...@@ -25,14 +26,32 @@ pub trait Remote {...@@ -25,14 +26,32 @@ pub trait Remote {
25pub enum ConflictKind {26pub enum ConflictKind {
26 /// The edited page or a selected page no longer exists remotely.27 /// The edited page or a selected page no longer exists remotely.
27 TargetUnavailable = 0,28 TargetUnavailable = 0,
28 /// The remote image no longer accepts the intent's publication.29 /// The remote section no longer accepts the edit.
29 UnsupportedEdit = 1,30 UnsupportedEdit = 1,
30 /// Page order or indentation changed remotely in a way the batch cannot absorb.31 /// Page order or indentation changed remotely in a way the batch cannot absorb.
31 StructureChanged = 2,32 StructureChanged = 2,
32 /// The remote page changed where the local model changed too; review is required.33 /// The remote page changed where the local edits changed it too; review is required.
33 ContentChanged = 3,34 ContentChanged = 3,
34}35}
3536
37impl ConflictKind {
38 fn from_stored(kind: i64) -> Result<Self> {
39 Ok(match kind {
40 0 => Self::TargetUnavailable,
41 1 => Self::UnsupportedEdit,
42 2 => Self::StructureChanged,
43 3 => Self::ContentChanged,
44 _ => {
45 return Err(io::Error::new(
46 io::ErrorKind::InvalidData,
47 "Unknown cached conflict kind",
48 )
49 .into());
50 }
51 })
52 }
53}
54
36#[derive(Debug, Clone, PartialEq, Eq)]55#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum EditStatus {56pub enum EditStatus {
38 Pending,57 Pending,
...@@ -46,499 +65,534 @@ pub enum EditStatus {...@@ -46,499 +65,534 @@ pub enum EditStatus {
46 revision: ExGuid,65 revision: ExGuid,
47 },66 },
48 /// Retired unpublished by a reviewed release; the archive at this path holds the67 /// Retired unpublished by a reviewed release; the archive at this path holds the
49 /// intent, its attempt evidence and both images.68 /// edit, its attempt evidence and both images.
50 Archived {69 Archived {
51 archive: String,70 archive: String,
52 },71 },
53}72}
5473
74/// What one synchronization step did: the state its batch reached, named by the batch's
75/// newest edit, and the pages a remote change replaced.
76#[derive(Debug, Clone, Default, PartialEq, Eq)]
77pub struct Synced {
78 pub edit: Option<(u64, EditStatus)>,
79 pub changed: Vec<ExGuid>,
80}
81
82/// The queue as a synchronization step starts it.
83struct State {
84 base: Stamp,
85 /// The observed remote image's stamp while it is not the base.
86 remote: Option<Stamp>,
87 /// The first batch waiting on a remote answer: sealed, attempted, or in conflict.
88 blocked: Option<Blocked>,
89 queued: bool,
90}
91
92struct Blocked {
93 batch: i64,
94 attempted: bool,
95 conflict: Option<ConflictKind>,
96 revisions: Option<BTreeMap<ExGuid, ExGuid>>,
97}
98
99fn state(connection: &Connection) -> Result<State> {
100 let base = base::stamp(connection, base::Image::Base)?
101 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
102 let blocked = connection
103 .query_row(
104 "SELECT id, attempted, conflict, revisions FROM batches
105 WHERE attempted=1 OR conflict IS NOT NULL ORDER BY id LIMIT 1",
106 [],
107 |row| {
108 Ok((
109 row.get::<_, i64>(0)?,
110 row.get::<_, bool>(1)?,
111 row.get::<_, Option<i64>>(2)?,
112 row.get::<_, Option<String>>(3)?,
113 ))
114 },
115 )
116 .optional()?
117 .map(|(batch, attempted, conflict, revisions)| {
118 Ok::<_, Error>(Blocked {
119 batch,
120 attempted,
121 conflict: conflict.map(ConflictKind::from_stored).transpose()?,
122 revisions: revisions
123 .map(|revisions| decode_revisions(&revisions))
124 .transpose()?,
125 })
126 })
127 .transpose()?;
128 Ok(State {
129 base,
130 remote: base::stamp(connection, base::Image::Remote)?,
131 blocked,
132 queued: connection
133 .query_row("SELECT EXISTS(SELECT 1 FROM batches)", [], |row| row.get(0))?,
134 })
135}
136
137fn decode_revisions(encoded: &str) -> Result<BTreeMap<ExGuid, ExGuid>> {
138 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded)
139 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
140 if revisions
141 .iter()
142 .any(|(space, revision)| space.guid == [0; 16] || revision.guid == [0; 16])
143 {
144 return Err(io::Error::new(
145 io::ErrorKind::InvalidData,
146 "Cached publication evidence is incomplete",
147 )
148 .into());
149 }
150 Ok(revisions)
151}
152
153/// The revision a batch's seal stored for the first page an edit changes.
154fn revision_of(edit: &onestore::op::Edit, revisions: &BTreeMap<ExGuid, ExGuid>) -> Result<ExGuid> {
155 edit.ops
156 .iter()
157 .find_map(|op| match op {
158 onestore::op::Op::Page { space, .. } => revisions.get(space),
159 onestore::op::Op::Section(_) => None,
160 })
161 .or_else(|| revisions.values().next())
162 .copied()
163 .ok_or_else(|| {
164 io::Error::new(
165 io::ErrorKind::InvalidData,
166 "Cached publication evidence is incomplete",
167 )
168 .into()
169 })
170}
171
172/// The newest edit of a batch.
173fn newest(connection: &Connection, batch: i64) -> Result<u64> {
174 unsigned(
175 connection.query_row("SELECT max(id) FROM edits WHERE batch=?1", [batch], |row| {
176 row.get(0)
177 })?,
178 )
179}
180
55impl Replica {181impl Replica {
56 /// Returns a durable receipt or the persisted state of a locally acknowledged edit.182 /// Returns a durable receipt or the persisted state of a locally acknowledged edit.
57 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {183 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
58 let connection = self184 status(&*self.lock()?, id)
59 .connection
60 .lock()
61 .map_err(|_| io::Error::other("Cache owner panicked"))?;
62 status(&connection, id)
63 }185 }
64186
65 /// The last observed remote image, retained alongside the complete local working image.187 /// The last observed remote image. Observation alone does not acknowledge any pending
66 /// Observation alone does not acknowledge any pending edit's remote durability.188 /// edit's remote durability.
67 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {189 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
68 let connection = self190 let connection = self.lock()?;
69 .connection191 let image = match base::read(&connection, base::Image::Remote)? {
70 .lock()192 Some(image) => Some(image),
71 .map_err(|_| io::Error::other("Cache owner panicked"))?;193 None => base::read(&connection, base::Image::Base)?,
72 images::base(&connection)194 };
195 Ok(image.ok_or_else(|| {
196 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
197 })?)
198 }
199
200 /// The unpublished batch the remote no longer accepts, if any.
201 pub fn conflict(&self) -> Result<Option<Conflict>> {
202 let connection = self.lock()?;
203 let row: Option<(i64, i64, String)> = connection
204 .query_row(
205 "SELECT id, conflict, space FROM batches WHERE conflict IS NOT NULL ORDER BY id LIMIT 1",
206 [],
207 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
208 )
209 .optional()?;
210 row.map(|(batch, kind, space)| {
211 Ok(Conflict {
212 id: newest(&connection, batch)?,
213 space: space.parse()?,
214 kind: ConflictKind::from_stored(kind)?,
215 })
216 })
217 .transpose()
73 }218 }
74219
75 /// Reconciles one pending edit, or refreshes the working image when the queue is empty.220 /// Publishes the oldest unpublished batch, rebasing the queue first when the remote
76 /// Network I/O holds synchronization ownership without holding the cache mutex.221 /// changed. Reads the remote image only when its stamp moved; network I/O holds
77 /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect.222 /// synchronization ownership without holding the cache mutex. Uncertain attempts are
78 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> {223 /// never replayed.
224 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> {
79 let _owner = self.sync_owner()?;225 let _owner = self.sync_owner()?;
80 struct Step<'a>(&'a Replica);226 let state = state(&*self.lock()?)?;
81 impl Drop for Step<'_> {227 let observed = remote.stamp().map_err(Error::RemoteIo)?;
82 fn drop(&mut self) {228 if let (Some(observed), Some(blocked)) = (&observed, &state.blocked)
83 self.0.in_flight.store(0, Ordering::Release);229 && Some(observed) == state.remote.as_ref().or(Some(&state.base))
84 }230 {
231 // Nothing new to decide: the remote is as it was when the batch blocked.
232 let id = newest(&*self.lock()?, blocked.batch)?;
233 return Ok(Synced {
234 edit: Some((
235 id,
236 status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending),
237 )),
238 changed: Vec::new(),
239 });
85 }240 }
86 let _step = Step(self);241 let image = match observed {
87 let base = self.remote_snapshot()?;242 Some(observed) if observed == state.base => None,
88 let changed = match remote.stamp().map_err(Error::RemoteIo)? {243 _ => {
89 Some(stamp) if stamp == Stamp::of(&base)? => None,244 let image = remote.read().map_err(Error::RemoteIo)?;
90 _ => Some(remote.read().map_err(Error::RemoteIo)?).filter(|read| *read != base),245 // A read image is compared whole: a stamp stands for it only when read alone.
91 };246 let base = base::read(&*self.lock()?, base::Image::Base)?;
92 // An unchanged remote is the base image, validated when it was stored.247 (base.as_deref() != Some(&image[..])).then_some(image)
93 let identity = changed.as_deref().map(validate).transpose()?;
94 let snapshot = changed.unwrap_or(base);
95 let (intent, attempted) = {
96 let mut connection = self
97 .connection
98 .lock()
99 .map_err(|_| io::Error::other("Cache owner panicked"))?;
100 let transaction =
101 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
102 if identity.is_none() && images::settled(&transaction)? {
103 return Ok(None);
104 }
105 let (base, working) = images::both(&transaction)?;
106 if let Some(identity) = identity
107 && RevisionIndex::parse(&Store::parse(&base)?)?.root != identity
108 {
109 return Err(io::Error::new(
110 io::ErrorKind::InvalidInput,
111 "Remote snapshot belongs to another document",
112 )
113 .into());
114 }248 }
115 let intent = {
116 let mut query = transaction
117 .prepare("SELECT id, space, operation FROM edits ORDER BY id LIMIT 1")?;
118 let mut rows = query.query([])?;
119 rows.next()?.map(pending_edit).transpose()?
120 };
121 let Some(intent) = intent else {
122 images::set_base(&transaction, &base, &snapshot, &snapshot)?;
123 transaction.commit()?;
124 return Ok(None);
125 };
126 self.in_flight.store(
127 i64::try_from(intent.id).map_err(io::Error::other)?,
128 Ordering::Release,
129 );
130 let attempted = transaction
131 .query_row(
132 "SELECT revisions FROM attempt WHERE edit_id=?1",
133 [i64::try_from(intent.id).map_err(io::Error::other)?],
134 |row| row.get::<_, String>(0),
135 )
136 .optional()?;
137 images::set_base(&transaction, &base, &snapshot, &working)?;
138 transaction.commit()?;
139 (intent, attempted)
140 };249 };
141 if let Some(encoded) = attempted {250 let mut changed = Vec::new();
142 let revisions = attempted_revisions(&encoded, intent.space)?;251 if let Some(image) = image {
143 let mut revision = revisions[&intent.space];252 if let Some(Blocked {
144 let store = Store::parse(&snapshot)?;253 batch,
145 let index = RevisionIndex::parse(&store)?;254 attempted: true,
146 if !revisions.iter().all(|(space, revision)| {255 revisions,
147 index256 ..
148 .spaces257 }) = &state.blocked
149 .get(space)258 {
150 .is_some_and(|space| space.revisions.contains_key(revision))259 let id = newest(&*self.lock()?, *batch)?;
151 }) {260 let revisions = revisions.clone().unwrap_or_default();
152 // A retired attempt confirms only when its complete effect is observed.261 let store = Store::parse(&image)?;
153 let satisfied = match &intent.operation {262 if !store.checksum_mismatches.is_empty() {
154 Operation::Page(edit) if revisions.len() == 1 => {263 return Err(io::Error::new(
155 page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after)264 io::ErrorKind::InvalidData,
265 "Notebook transaction checksum damage",
266 )
267 .into());
268 }
269 let index = RevisionIndex::parse(&store)?;
270 index.validate_current()?;
271 if index.root != self.root {
272 return Err(io::Error::new(
273 io::ErrorKind::InvalidInput,
274 "Remote snapshot belongs to another document",
275 )
276 .into());
277 }
278 let observed = revisions.iter().all(|(space, revision)| {
279 index
280 .spaces
281 .get(space)
282 .is_some_and(|space| space.revisions.contains_key(revision))
283 });
284 let sealed = working::sealed(&*self.lock()?)?
285 .filter(|sealed| sealed.batch == *batch)
286 .and_then(|sealed| sealed.transaction);
287 // Without its revisions the attempt still counts once the remote holds
288 // every page it changed as it changed them; its receipts then name the
289 // remote's revisions.
290 let receipts = if observed {
291 None
292 } else {
293 let equal = self.holds(&image, sealed.as_ref(), &revisions)?;
294 if !equal {
295 base::write(&*self.lock()?, base::Image::Remote, &image)?;
296 return Ok(Synced {
297 edit: Some((
298 id,
299 status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending),
300 )),
301 changed,
302 });
156 }303 }
157 _ => false,304 Some(
305 revisions
306 .keys()
307 .filter_map(|space| Some((*space, index.active(*space).ok()?)))
308 .collect(),
309 )
158 };310 };
159 if !satisfied {311 if let Err(error) = remote.confirm(&image) {
160 return Ok(Some((312 if error.state == CommitState::Committed {
161 intent.id,313 self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?;
162 EditStatus::AwaitingConfirmation { revision },314 }
163 )));315 return Err(error.into());
164 }316 }
165 revision = index.active(intent.space)?;317 self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?;
318 let revision = self.receipt(id)?;
319 changed = self.rebase(Some(image), None)?.unwrap_or_default();
320 return Ok(Synced {
321 edit: Some((id, EditStatus::Published { revision })),
322 changed,
323 });
166 }324 }
167 if let Err(error) = remote.confirm(&snapshot) {325 match self.rebase(Some(image), None)? {
168 if error.state == CommitState::Committed {326 Ok(spaces) => changed = spaces,
169 self.acknowledge(intent.id, revision, &snapshot)?;327 Err(conflict) => {
328 return Ok(Synced {
329 edit: Some((conflict.id, EditStatus::Conflict(conflict.kind))),
330 changed,
331 });
170 }332 }
171 return Err(error.into());
172 }333 }
173 self.acknowledge(intent.id, revision, &snapshot)?;334 } else if let Some(blocked) = &state.blocked {
174 return Ok(Some((intent.id, EditStatus::Published { revision })));335 if state.remote.is_some() {
336 // The remote is back at the base: what blocked the queue is gone.
337 let connection = self.lock()?;
338 base::clear(&connection, base::Image::Remote)?;
339 if blocked.conflict.is_some() {
340 connection.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?;
341 }
342 }
343 if blocked.attempted {
344 let id = newest(&*self.lock()?, blocked.batch)?;
345 return Ok(Synced {
346 edit: Some((
347 id,
348 status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending),
349 )),
350 changed,
351 });
352 }
353 if let Some(kind) = blocked.conflict
354 && state.remote.is_none()
355 {
356 let id = newest(&*self.lock()?, blocked.batch)?;
357 return Ok(Synced {
358 edit: Some((id, EditStatus::Conflict(kind))),
359 changed,
360 });
361 }
362 } else if !state.queued {
363 return Ok(Synced::default());
175 }364 }
176 let candidate = match &intent.operation {365 // The cache lock is released before the section thread, which takes it, seals.
177 Operation::Page(edit) => page_of(&snapshot, intent.space)?366 let waiting = working::sealed(&*self.lock()?)?;
178 .ok_or(ConflictKind::TargetUnavailable)367 let sealed = match waiting {
179 .and_then(|current| {368 Some(sealed) => {
180 let target = if current == edit.before {369 if sealed.transaction.is_some() {
181 edit.after.clone()370 self.lock()?
182 } else {371 .execute("UPDATE batches SET attempted=1 WHERE id=?1", [sealed.batch])?;
183 crate::merge::merge(&edit.before, &edit.after, &current)
184 .ok_or(ConflictKind::ContentChanged)?
185 };
186 PreparedEdit::page(&snapshot, intent.space, &target, &edit.author)
187 .map_err(|_| ConflictKind::UnsupportedEdit)
188 }),
189 Operation::CreatePage(page) => PreparedEdit::create_page(&snapshot, page)
190 .map_err(|_| ConflictKind::StructureChanged),
191 Operation::Pages(edit) => edit.prepare(&snapshot, intent.space)?,
192 Operation::DeletePages(pages) => {
193 let store = Store::parse(&snapshot)?;
194 let index = RevisionIndex::parse(&store)?;
195 if pages.iter().any(|page| {
196 index
197 .spaces
198 .get(page)
199 .is_none_or(|space| space.labels.is_empty())
200 }) || Document::parse(&index)?
201 .pages()?
202 .iter()
203 .filter(|(sid, _)| pages.contains(sid))
204 .count()
205 != pages.len()
206 {
207 Err(ConflictKind::TargetUnavailable)
208 } else {
209 PreparedEdit::delete_pages_permanently(&snapshot, pages)
210 .map_err(|_| ConflictKind::UnsupportedEdit)
211 }372 }
373 Some(sealed)
212 }374 }
375 None => self.ask(|reply| Request::Seal { reply })?,
213 };376 };
214 let prepared = match candidate {377 let Some(Sealed { batch, transaction }) = sealed else {
215 Ok(prepared) => prepared,378 return Ok(Synced {
216 Err(kind) => {379 edit: None,
217 let connection = self380 changed,
218 .connection381 });
219 .lock()
220 .map_err(|_| io::Error::other("Cache owner panicked"))?;
221 connection.execute("INSERT INTO conflicts(edit_id, kind) VALUES (?1, ?2) ON CONFLICT(edit_id) DO UPDATE SET kind=excluded.kind", params![i64::try_from(intent.id).map_err(io::Error::other)?, kind as i64])?;
222 return Ok(Some((intent.id, EditStatus::Conflict(kind))));
223 }
224 };382 };
225 if prepared.as_bytes() == snapshot {383 let id = newest(&*self.lock()?, batch)?;
226 let store = Store::parse(&snapshot)?;384 let Some(transaction) = transaction else {
227 let index = RevisionIndex::parse(&store)?;385 // Edits that changed nothing are published once the remote's image is durable.
228 let revision = index.active(intent.space)?;386 let base = base::read(&*self.lock()?, base::Image::Base)?.unwrap_or_default();
229 if let Err(error) = remote.confirm(&snapshot) {387 if let Err(error) = remote.confirm(&base) {
230 if error.state == CommitState::Committed {388 if error.state == CommitState::Committed {
231 self.acknowledge(intent.id, revision, &snapshot)?;389 self.acknowledge(batch, None, None)?;
232 }390 }
233 return Err(error.into());391 return Err(error.into());
234 }392 }
235 self.acknowledge(intent.id, revision, &snapshot)?;393 self.acknowledge(batch, None, None)?;
236 return Ok(Some((intent.id, EditStatus::Published { revision })));394 let revision = self.receipt(id)?;
237 }395 return Ok(Synced {
238 // Once acknowledged this image is the base, which polls trust without validating.396 edit: Some((id, EditStatus::Published { revision })),
239 validate(prepared.as_bytes())?;397 changed,
240 let store = Store::parse(prepared.as_bytes())?;398 });
241 let index = RevisionIndex::parse(&store)?;399 };
242 let revision = index.active(intent.space)?;400 match remote.publish(&transaction) {
243 let before_store = Store::parse(&snapshot)?;
244 let before = RevisionIndex::parse(&before_store)?;
245 let mut revisions: BTreeMap<_, _> = index
246 .spaces
247 .iter()
248 .filter_map(|(sid, space)| {
249 let revision = *space.labels.get(&(ExGuid::default(), 1))?;
250 (before
251 .spaces
252 .get(sid)
253 .and_then(|s| s.labels.get(&(ExGuid::default(), 1)))
254 != Some(&revision))
255 .then_some((*sid, revision))
256 })
257 .collect();
258 // The receipt's space can be unchanged in a multi-space edit.
259 revisions.insert(intent.space, revision);
260 {
261 let mut connection = self
262 .connection
263 .lock()
264 .map_err(|_| io::Error::other("Cache owner panicked"))?;
265 let transaction =
266 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
267 transaction.execute(
268 "DELETE FROM conflicts WHERE edit_id=?1",
269 [i64::try_from(intent.id).map_err(io::Error::other)?],
270 )?;
271 transaction.execute(
272 "INSERT INTO attempt(id, edit_id, revisions) VALUES (1, ?1, ?2)",
273 params![
274 i64::try_from(intent.id).map_err(io::Error::other)?,
275 serde_json::to_string(&revisions).map_err(io::Error::other)?
276 ],
277 )?;
278 transaction.commit()?;
279 }
280 match remote.publish(&prepared.transaction()) {
281 Ok(()) => {}401 Ok(()) => {}
282 Err(error) if error.state == CommitState::NotCommitted => {402 Err(error) if error.state == CommitState::NotCommitted => {
283 let connection = self403 self.lock()?
284 .connection404 .execute("UPDATE batches SET attempted=0 WHERE id=?1", [batch])?;
285 .lock()
286 .map_err(|_| io::Error::other("Cache owner panicked"))?;
287 connection.execute(
288 "DELETE FROM attempt WHERE edit_id=?1",
289 [i64::try_from(intent.id).map_err(io::Error::other)?],
290 )?;
291 return Err(error.into());405 return Err(error.into());
292 }406 }
293 Err(error) if error.state == CommitState::Committed => {407 Err(error) if error.state == CommitState::Committed => {
294 self.acknowledge(intent.id, revision, prepared.as_bytes())?;408 self.acknowledge(batch, Some(&transaction), None)?;
295 return Err(error.into());409 return Err(error.into());
296 }410 }
297 Err(error) => return Err(error.into()),411 Err(error) => return Err(error.into()),
298 }412 }
299 self.acknowledge(intent.id, revision, prepared.as_bytes())?;413 self.acknowledge(batch, Some(&transaction), None)?;
300 Ok(Some((intent.id, EditStatus::Published { revision })))414 let revision = self.receipt(id)?;
415 Ok(Synced {
416 edit: Some((id, EditStatus::Published { revision })),
417 changed,
418 })
301 }419 }
302420
303 /// Repositions an unattempted page-creation conflict, retaining dependent object identities.421 /// Whether `image` holds every page in `revisions` as the base with `sealed` has it.
304 pub fn rebase_page_creation_conflict(422 fn holds(
305 &self,423 &self,
306 id: u64,424 image: &[u8],
307 local: &[u8],425 sealed: Option<&Transaction>,
308 remote: &[u8],426 revisions: &BTreeMap<ExGuid, ExGuid>,
309 before: Option<ExGuid>,427 ) -> Result<bool> {
310 ) -> Result<()> {428 let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| {
311 self.resolve_conflict(id, local, remote, |intent| {429 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
312 let Operation::CreatePage(page) = intent.operation else {430 })?;
313 return Err(io::Error::new(431 let (local, remote) = (onestore::Arena::default(), onestore::Arena::default());
314 io::ErrorKind::InvalidInput,432 let mut local = onestore::Section::open(&local, base)?;
315 "Select a page-creation conflict",433 if let Some(sealed) = sealed {
316 )434 local.replay(sealed)?;
317 .into());435 }
318 };436 let remote = onestore::Section::open(&remote, image.to_vec())?;
319 let page = page.reposition(before)?;437 Ok(revisions.keys().all(
320 PreparedEdit::create_page(remote, &page)?;438 |space| matches!((local.page(*space), remote.page(*space)), (Ok(a), Ok(b)) if a == b),
321 Ok(Operation::CreatePage(page))439 ))
322 })
323 }440 }
324441
325 /// Reviews a page batch against both cache images, retaining its page and series identities.442 /// Asks the section thread to replay the queue on `image`; `Err` is the conflict found.
326 pub fn rebase_pages_conflict(443 fn rebase(
327 &self,444 &self,
328 id: u64,445 image: Option<Vec<u8>>,
329 local: &[u8],446 resolve: Option<Resolve>,
330 remote: &[u8],447 ) -> Result<std::result::Result<Vec<ExGuid>, Conflict>> {
331 edits: &[onestore::PageEdit],448 Ok(
332 ) -> Result<()> {449 match self.ask(|reply| Request::Rebase {
333 self.resolve_conflict(id, local, remote, |intent| {450 image,
334 let Operation::Pages(batch) = intent.operation else {451 resolve,
335 return Err(io::Error::new(452 reply,
336 io::ErrorKind::InvalidInput,453 })? {
337 "Select a page movement or indentation conflict",454 Rebased::Applied { changed } => Ok(changed),
338 )455 Rebased::Conflict { id, space, kind } => Err(Conflict { id, space, kind }),
339 .into());456 },
340 };457 )
341 Ok(Operation::Pages(batch.review(
342 remote,
343 intent.space,
344 edits,
345 )?))
346 })
347 }458 }
348459
349 /// Replaces a conflicting page save with a model reviewed against the remote page.460 fn receipt(&self, id: u64) -> Result<ExGuid> {
350 /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed461 match status(&*self.lock()?, id)? {
351 /// model must publish against the remote image, and its author is retained.462 Some(EditStatus::Published { revision }) => Ok(revision),
352 pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> {463 _ => Err(io::Error::other("A published edit has no receipt").into()),
353 self.resolve_conflict(id, local, remote, |intent| {464 }
354 let Operation::Page(edit) = intent.operation else {
355 return Err(io::Error::new(
356 io::ErrorKind::InvalidInput,
357 "Select a page save conflict",
358 )
359 .into());
360 };
361 let before = page_of(remote, intent.space)?.ok_or_else(|| {
362 io::Error::new(
363 io::ErrorKind::InvalidInput,
364 "The page is no longer in the remote section",
365 )
366 })?;
367 PreparedEdit::page(remote, intent.space, after, &edit.author)?;
368 Ok(Operation::Page(PageIntent {
369 before,
370 after: after.clone(),
371 author: edit.author,
372 }))
373 })
374 }465 }
375466
376 /// Retires the oldest edit's uncertain attempt after review. The branch is first467 /// Records a published batch: the base becomes the image it left, each of its edits
377 /// exported to `archive` (a new file), which is the record: no receipt is written.468 /// gets a receipt naming the revisions its seal stored, or `revisions`, and it leaves
378 /// `Some(after)` continues from the reviewed page as a fresh intent against the469 /// the queue.
379 /// current remote page, keeping the edit's id and its dependents; `None` abandons470 fn acknowledge(
380 /// the whole local branch, whose edits become `Archived`, and the working image
381 /// returns to the remote image. Both need the reviewed images to be current.
382 pub fn release_attempt(
383 &self,471 &self,
384 id: u64,472 batch: i64,
385 local: &[u8],473 transaction: Option<&Transaction>,
386 remote: &[u8],474 revisions: Option<&BTreeMap<ExGuid, ExGuid>>,
387 archive: &Path,
388 after: Option<&Page>,
389 ) -> Result<()> {475 ) -> Result<()> {
476 let mut connection = self.lock()?;
477 let database = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
478 if let Some(transaction) = transaction {
479 base::publish(&database, transaction)?;
480 }
481 let revisions = match revisions {
482 Some(revisions) => revisions.clone(),
483 None => decode_revisions(&database.query_row(
484 "SELECT revisions FROM batches WHERE id=?1",
485 [batch],
486 |row| row.get::<_, String>(0),
487 )?)?,
488 };
489 for queued in queue::load(&database, Some(batch))? {
490 database.execute(
491 "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)",
492 params![
493 signed(queued.id)?,
494 revision_of(&queued.edit, &revisions)?.to_string()
495 ],
496 )?;
497 }
498 database.execute("DELETE FROM batches WHERE id=?1", [batch])?;
499 queue::collect(&database)?;
500 database.commit()?;
501 Ok(())
502 }
503
504 /// Resolves the conflict holding edit `id`: the batch and everything queued after it
505 /// drop their ops on the conflicted page; `Mine` rewrites the remote page to the local
506 /// one first. A conflict on another page may follow.
507 pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> {
508 self.resolve_with(id, resolution, None)
509 }
510
511 pub(crate) fn resolve_with(&self, id: u64, keep: Resolution, page: Option<Page>) -> Result<()> {
390 let owner = self.sync_owner()?;512 let owner = self.sync_owner()?;
391 let intent = self513 let (first, space) = {
392 .pending()?514 let connection = self.lock()?;
393 .into_iter()515 let row: Option<(i64, String)> = connection
394 .next()516 .query_row(
395 .filter(|intent| intent.id == id)517 "SELECT (SELECT min(id) FROM edits WHERE batch=batches.id), space FROM batches
396 .ok_or_else(|| {518 WHERE conflict IS NOT NULL AND id=(SELECT batch FROM edits WHERE id=?1)",
397 io::Error::new(519 [signed(id)?],
520 |row| Ok((row.get(0)?, row.get(1)?)),
521 )
522 .optional()?;
523 let Some((first, space)) = row else {
524 return Err(io::Error::new(
398 io::ErrorKind::InvalidInput,525 io::ErrorKind::InvalidInput,
399 "Only the oldest edit can hold an attempt",526 "The edit is not in conflict",
400 )527 )
401 })?;528 .into());
402 let continued = match after {529 };
403 Some(after) => {530 (unsigned(first)?, space.parse()?)
404 let Operation::Page(edit) = intent.operation else {
405 return Err(io::Error::new(
406 io::ErrorKind::InvalidInput,
407 "Select a page save to continue from",
408 )
409 .into());
410 };
411 let before = page_of(remote, intent.space)?.ok_or_else(|| {
412 io::Error::new(
413 io::ErrorKind::InvalidInput,
414 "The page is no longer in the remote section",
415 )
416 })?;
417 PreparedEdit::page(remote, intent.space, after, &edit.author)?;
418 Some(Operation::Page(PageIntent {
419 before,
420 after: after.clone(),
421 author: edit.author,
422 }))
423 }
424 None => None,
425 };531 };
426 let mut connection = self532 // A conflict on another page is recorded like any other.
427 .connection533 let _ = self.rebase(
428 .lock()534 None,
429 .map_err(|_| io::Error::other("Cache owner panicked"))?;535 Some(Resolve {
430 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;536 first,
431 let (base, working) = images::both(&transaction)?;537 space,
432 if working != local || base != remote {538 keep,
433 return Err(io::Error::new(539 page,
434 io::ErrorKind::ResourceBusy,540 }),
435 "The reviewed cache images changed",
436 )
437 .into());
438 }
439 let id = i64::try_from(id).map_err(io::Error::other)?;
440 let attempted: bool = transaction.query_row(
441 "SELECT EXISTS(SELECT 1 FROM attempt WHERE edit_id=?1)",
442 [id],
443 |row| row.get(0),
444 )?;541 )?;
445 if !attempted {
446 return Err(io::Error::new(
447 io::ErrorKind::InvalidInput,
448 "The edit has no uncertain attempt",
449 )
450 .into());
451 }
452 drop(transaction);
453 drop(connection);
454 self.export_recovery(archive)?;
455 let mut connection = self
456 .connection
457 .lock()
458 .map_err(|_| io::Error::other("Cache owner panicked"))?;
459 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
460 transaction.execute("DELETE FROM attempt WHERE edit_id=?1", [id])?;
461 match continued {
462 Some(operation) => {
463 transaction.execute(
464 "UPDATE edits SET operation=?1 WHERE id=?2",
465 params![
466 serde_json::to_string(&operation).map_err(io::Error::other)?,
467 id
468 ],
469 )?;
470 }
471 None => {
472 transaction.execute(
473 "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits",
474 [archive.to_string_lossy().into_owned()],
475 )?;
476 transaction.execute("DELETE FROM edits", [])?;
477 transaction.execute("UPDATE replica SET working=x'' WHERE id=1", [])?;
478 }
479 }
480 transaction.commit()?;
481 drop(connection);
482 drop(owner);542 drop(owner);
483 self.wake_sync();543 self.wake_sync();
484 Ok(())544 Ok(())
485 }545 }
486546
487 fn resolve_conflict(547 /// Retires the uncertain attempt of the batch holding edit `id` after review. The
488 &self,548 /// queue is first exported to `archive` (a new file), which is the record: no receipt
489 id: u64,549 /// is written. `Mine` publishes the batch again against the current remote; `Theirs`
490 local: &[u8],550 /// abandons every unpublished edit, which become `Archived`, and the local pages return
491 remote: &[u8],551 /// to the remote's.
492 update: impl FnOnce(PendingEdit) -> Result<Operation>,552 pub fn release(&self, id: u64, archive: &Path, resolution: Resolution) -> Result<()> {
493 ) -> Result<()> {
494 let owner = self.sync_owner()?;553 let owner = self.sync_owner()?;
495 let intent = self554 let batch: Option<i64> = self
496 .pending()?555 .lock()?
497 .into_iter()556 .query_row(
498 .next()557 "SELECT id FROM batches WHERE attempted=1 AND id=(SELECT batch FROM edits WHERE id=?1)",
499 .filter(|intent| intent.id == id)558 [signed(id)?],
500 .ok_or_else(|| {559 |row| row.get(0),
501 io::Error::new(
502 io::ErrorKind::InvalidInput,
503 "Only the oldest conflict can be rebased",
504 )
505 })?;
506 let operation = update(intent)?;
507 let mut connection = self
508 .connection
509 .lock()
510 .map_err(|_| io::Error::other("Cache owner panicked"))?;
511 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
512 let (base, working) = images::both(&transaction)?;
513 if working != local || base != remote {
514 return Err(io::Error::new(
515 io::ErrorKind::ResourceBusy,
516 "The reviewed cache images changed",
517 )560 )
518 .into());561 .optional()?;
519 }562 let Some(batch) = batch else {
520 let id = i64::try_from(id).map_err(io::Error::other)?;
521 let eligible: bool = transaction.query_row(
522 "SELECT EXISTS(SELECT 1 FROM conflicts WHERE edit_id=?1) AND NOT EXISTS(SELECT 1 FROM attempt)",
523 [id], |row| row.get(0),
524 )?;
525 if !eligible {
526 return Err(io::Error::new(563 return Err(io::Error::new(
527 io::ErrorKind::InvalidInput,564 io::ErrorKind::InvalidInput,
528 "The edit is not an unattempted conflict",565 "The edit has no uncertain attempt",
529 )566 )
530 .into());567 .into());
568 };
569 self.export_recovery(archive)?;
570 {
571 let mut connection = self.lock()?;
572 let transaction =
573 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
574 match resolution {
575 Resolution::Mine => {
576 transaction.execute("UPDATE batches SET attempted=0 WHERE id=?1", [batch])?;
577 }
578 Resolution::Theirs => {
579 transaction.execute(
580 "INSERT INTO archived(edit_id, archive) SELECT id, ?1 FROM edits",
581 [archive.to_string_lossy().into_owned()],
582 )?;
583 transaction.execute("DELETE FROM batches", [])?;
584 if let Some(remote) = base::read(&transaction, base::Image::Remote)? {
585 base::write(&transaction, base::Image::Base, &remote)?;
586 base::clear(&transaction, base::Image::Remote)?;
587 }
588 queue::collect(&transaction)?;
589 }
590 }
591 transaction.commit()?;
592 }
593 if resolution == Resolution::Theirs {
594 self.ask(|reply| Request::Reopen { reply })?;
531 }595 }
532 transaction.execute(
533 "UPDATE edits SET operation=?1 WHERE id=?2",
534 params![
535 serde_json::to_string(&operation).map_err(io::Error::other)?,
536 id
537 ],
538 )?;
539 transaction.execute("DELETE FROM conflicts WHERE edit_id=?1", [id])?;
540 transaction.commit()?;
541 drop(connection);
542 drop(owner);596 drop(owner);
543 self.wake_sync();597 self.wake_sync();
544 Ok(())598 Ok(())
...@@ -556,34 +610,25 @@ impl Replica {...@@ -556,34 +610,25 @@ impl Replica {
556 })?)610 })?)
557 }611 }
558612
559 fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> {613 /// Whether nothing is queued and the remote still has the base's stamp, or the queue
560 let id = i64::try_from(id).map_err(io::Error::other)?;614 /// is blocked on a remote that has not changed since; reads neither image and does not
561 let mut connection = self615 /// lock the cache during remote I/O.
562 .connection616 pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> {
563 .lock()617 let state = state(&*self.lock()?)?;
564 .map_err(|_| io::Error::other("Cache owner panicked"))?;618 let expected = match &state.blocked {
565 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;619 Some(_) => state.remote.unwrap_or(state.base),
566 transaction.execute(620 None if !state.queued => state.base,
567 "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)",621 None => return Ok(false),
568 params![id, revision.to_string()],622 };
569 )?;623 let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else {
570 transaction.execute("DELETE FROM edits WHERE id=?1", [id])?;624 return Ok(false);
571 let (base, working) = images::both(&transaction)?;625 };
572 let pending: bool =626 Ok(stamp == expected)
573 transaction.query_row("SELECT EXISTS(SELECT 1 FROM edits)", [], |row| row.get(0))?;
574 images::set_base(
575 &transaction,
576 &base,
577 snapshot,
578 if pending { &working } else { snapshot },
579 )?;
580 transaction.commit()?;
581 Ok(())
582 }627 }
583}628}
584629
585pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> {630pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStatus>> {
586 let id = i64::try_from(id).map_err(io::Error::other)?;631 let id = signed(id)?;
587 if let Some(revision) = connection632 if let Some(revision) = connection
588 .query_row(633 .query_row(
589 "SELECT revision FROM receipts WHERE edit_id=?1",634 "SELECT revision FROM receipts WHERE edit_id=?1",
...@@ -606,46 +651,26 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat...@@ -606,46 +651,26 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
606 {651 {
607 return Ok(Some(EditStatus::Archived { archive }));652 return Ok(Some(EditStatus::Archived { archive }));
608 }653 }
609 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(654 let record: Option<(bool, Option<i64>, Option<String>, String)> = connection
610 "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()?;655 .query_row(
656 "SELECT batches.attempted, batches.conflict, batches.revisions, edits.edit
657 FROM edits JOIN batches ON batches.id=edits.batch WHERE edits.id=?1",
658 [id],
659 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
660 )
661 .optional()?;
611 Ok(match record {662 Ok(match record {
612 None => None,663 None => None,
613 Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation {664 Some((true, _, revisions, edit)) => {
614 revision: {665 let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?;
615 let space = space.parse()?;666 let edit: onestore::op::Edit = serde_json::from_str(&edit)
616 attempted_revisions(&revision, space)?[&space]667 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
617 },668 let revision = revision_of(&edit, &revisions)?;
618 }),669 Some(EditStatus::AwaitingConfirmation { revision })
619 Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind {670 }
620 0 => ConflictKind::TargetUnavailable,671 Some((false, Some(kind), ..)) => {
621 1 => ConflictKind::UnsupportedEdit,672 Some(EditStatus::Conflict(ConflictKind::from_stored(kind)?))
622 2 => ConflictKind::StructureChanged,673 }
623 3 => ConflictKind::ContentChanged,674 Some((false, None, ..)) => Some(EditStatus::Pending),
624 _ => {
625 return Err(io::Error::new(
626 io::ErrorKind::InvalidData,
627 "Unknown cached conflict kind",
628 )
629 .into());
630 }
631 })),
632 Some((None, None, _)) => Some(EditStatus::Pending),
633 })675 })
634}676}
635
636fn attempted_revisions(encoded: &str, space: ExGuid) -> Result<BTreeMap<ExGuid, ExGuid>> {
637 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(encoded)
638 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
639 if !revisions.contains_key(&space)
640 || revisions
641 .iter()
642 .any(|(sid, rid)| sid.guid == [0; 16] || rid.guid == [0; 16])
643 {
644 return Err(io::Error::new(
645 io::ErrorKind::InvalidData,
646 "Cached publication evidence is incomplete",
647 )
648 .into());
649 }
650 Ok(revisions)
651}
crates/notebook/src/worker.rs+15-10
...@@ -61,7 +61,8 @@ impl Drop for SyncWorker {...@@ -61,7 +61,8 @@ impl Drop for SyncWorker {
61impl Replica {61impl Replica {
62 /// Starts one worker, reconnecting through `connect` after transport failures.62 /// Starts one worker, reconnecting through `connect` after transport failures.
63 /// Local edits wake it; `interval` controls idle polling and transport retries. While63 /// Local edits wake it; `interval` controls idle polling and transport retries. While
64 /// nothing is queued, a remote whose `stamp` holds is not read again.64 /// nothing is queued, or the queue waits on a remote that has not changed since, a
65 /// remote whose `stamp` holds is not read again.
65 /// Contended operations returning `NotCommitted` also back off by up to one second.66 /// Contended operations returning `NotCommitted` also back off by up to one second.
66 /// `observe` runs on the worker after each attempt, including connection errors.67 /// `observe` runs on the worker after each attempt, including connection errors.
67 /// Cache/document errors stop the worker; inspect them through `observe` or `stop`.68 /// Cache/document errors stop the worker; inspect them through `observe` or `stop`.
...@@ -75,7 +76,7 @@ impl Replica {...@@ -75,7 +76,7 @@ impl Replica {
75 where76 where
76 R: Remote + 'static,77 R: Remote + 'static,
77 F: FnMut() -> io::Result<R> + Send + 'static,78 F: FnMut() -> io::Result<R> + Send + 'static,
78 O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static,79 O: FnMut(&Result<Synced>) + Send + 'static,
79 {80 {
80 if interval.is_zero() || Instant::now().checked_add(interval).is_none() {81 if interval.is_zero() || Instant::now().checked_add(interval).is_none() {
81 return Err(io::Error::new(82 return Err(io::Error::new(
...@@ -103,18 +104,19 @@ impl Replica {...@@ -103,18 +104,19 @@ impl Replica {
103 let mut remote: Option<R> = None;104 let mut remote: Option<R> = None;
104 let jitter = RandomState::new();105 let jitter = RandomState::new();
105 let mut contention = 0_u32;106 let mut contention = 0_u32;
106 // The first attempt always runs, so `observe` hears once that the remote is107 // The first step, and the first after a failure, always runs, so `observe`
107 // reachable.108 // hears that the remote is reachable and what state the queue is in.
108 let mut attempted = false;109 let mut reported = false;
109 while !worker_signal.stopped.load(Ordering::Acquire) {110 while !worker_signal.stopped.load(Ordering::Acquire) {
110 let result = match remote.as_mut() {111 let result = match remote.as_mut() {
111 Some(remote) => {112 Some(remote) => {
112 if attempted && replica.settled(remote).unwrap_or(false) {113 if reported && replica.settled(remote).unwrap_or(false) {
113 let _ = receiver.recv_timeout(interval);114 let _ = receiver.recv_timeout(interval);
114 continue;115 continue;
115 }116 }
116 attempted = true;117 let result = replica.sync_once(remote);
117 replica.sync_once(remote)118 reported = result.is_ok();
119 result
118 }120 }
119 None => match connect() {121 None => match connect() {
120 Ok(connected) => {122 Ok(connected) => {
...@@ -126,11 +128,14 @@ impl Replica {...@@ -126,11 +128,14 @@ impl Replica {
126 };128 };
127 observe(&result);129 observe(&result);
128 match result {130 match result {
129 Ok(Some((_, EditStatus::Published { .. }))) => {131 Ok(Synced {
132 edit: Some((_, EditStatus::Published { .. })),
133 ..
134 }) => {
130 contention = 0;135 contention = 0;
131 continue;136 continue;
132 }137 }
133 Ok(None) => contention = 0,138 Ok(Synced { edit: None, .. }) => contention = 0,
134 Ok(_) => {}139 Ok(_) => {}
135 Err(Error::Remote(onestore::CommitError {140 Err(Error::Remote(onestore::CommitError {
136 state: onestore::CommitState::NotCommitted,141 state: onestore::CommitState::NotCommitted,
crates/notebook/src/working.rs created+776
...@@ -0,0 +1,776 @@
1//! The section thread: the only owner of the parsed section, which is the cached base
2//! image with each sealed batch replayed and the open batch's edits applied. Edits apply
3//! here and are written in one SQLite transaction per burst; the sync thread asks it to seal
4//! and, when the remote changed, to rebase the queue.
5
6use crate::{
7 ConflictKind, Resolution, Result, base, lock, merge, queue, session::Save, signed,
8 worker::Signal,
9};
10use onestore::{
11 Arena, ExGuid, Section, Transaction,
12 op::{Edit, Op, OpError},
13 page::Page,
14};
15use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
16use std::{
17 collections::{BTreeMap, BTreeSet, VecDeque},
18 io,
19 sync::{Arc, Mutex, Weak, mpsc},
20 thread,
21};
22
23pub(crate) type Reply<T> = Box<dyn FnOnce(Result<T>) + Send>;
24
25pub(crate) enum Request {
26 Apply {
27 author: String,
28 edit: Edit,
29 reply: Reply<u64>,
30 },
31 /// A whole-page save of the old session API: `None` answers a save a later one
32 /// continuing it absorbed.
33 Save {
34 space: ExGuid,
35 before: Page,
36 after: Page,
37 author: String,
38 reply: Reply<Option<Save>>,
39 },
40 Page {
41 space: ExGuid,
42 reply: Reply<Page>,
43 },
44 Pages {
45 reply: Reply<Vec<(ExGuid, String, u32)>>,
46 },
47 Image {
48 reply: Reply<Vec<u8>>,
49 },
50 /// Seals the open batch, when no sealed batch waits for publication, recording the
51 /// attempt to publish it.
52 Seal {
53 reply: Reply<Option<Sealed>>,
54 },
55 /// Replays the queue on `image` (the stored remote image, or the base, when `None`).
56 Rebase {
57 image: Option<Vec<u8>>,
58 resolve: Option<Resolve>,
59 reply: Reply<Rebased>,
60 },
61 /// Rereads the queue after the sync thread replaced it.
62 Reopen {
63 reply: Reply<()>,
64 },
65}
66
67/// A sealed batch: the transaction publishing it, or none when its edits changed nothing.
68pub(crate) struct Sealed {
69 pub batch: i64,
70 pub transaction: Option<Transaction>,
71}
72
73/// A conflict's resolution: the batch it holds and every later edit drop their ops on
74/// `space`; `Mine` first rewrites the rebased page to the local one (or to `page`).
75pub(crate) struct Resolve {
76 pub first: u64,
77 pub space: ExGuid,
78 pub keep: Resolution,
79 pub page: Option<Page>,
80}
81
82pub(crate) enum Rebased {
83 /// The queue now applies to the image, which is the base; these pages changed.
84 Applied { changed: Vec<ExGuid> },
85 /// The batch holding edit `id` no longer applies; nothing changed but the record.
86 Conflict {
87 id: u64,
88 space: ExGuid,
89 kind: ConflictKind,
90 },
91}
92
93impl Request {
94 fn fail(self, message: &str) {
95 let error = || io::Error::other(message.to_owned()).into();
96 match self {
97 Self::Apply { reply, .. } => reply(Err(error())),
98 Self::Save { reply, .. } => reply(Err(error())),
99 Self::Page { reply, .. } => reply(Err(error())),
100 Self::Pages { reply } => reply(Err(error())),
101 Self::Image { reply } => reply(Err(error())),
102 Self::Seal { reply } => reply(Err(error())),
103 Self::Rebase { reply, .. } => reply(Err(error())),
104 Self::Reopen { reply } => reply(Err(error())),
105 }
106 }
107}
108
109type Worker = Arc<Mutex<Weak<Signal>>>;
110
111/// Starts the section thread once the queue opens.
112pub(crate) fn spawn(
113 connection: Arc<Mutex<Connection>>,
114 worker: Worker,
115) -> Result<(mpsc::Sender<Request>, thread::JoinHandle<()>, ExGuid)> {
116 let (sender, receiver) = mpsc::channel();
117 let (ready, opened) = mpsc::sync_channel(1);
118 let thread = thread::Builder::new()
119 .name("onestore-section".into())
120 .spawn(move || run(&connection, &worker, &receiver, ready))?;
121 match opened.recv() {
122 Ok(Ok(root)) => Ok((sender, thread, root)),
123 Ok(Err(error)) => {
124 let _ = thread.join();
125 Err(error)
126 }
127 Err(_) => {
128 let _ = thread.join();
129 Err(io::Error::other("The section thread panicked").into())
130 }
131 }
132}
133
134enum Next {
135 Stop,
136 Reopen,
137}
138
139fn run(
140 connection: &Mutex<Connection>,
141 worker: &Worker,
142 requests: &mpsc::Receiver<Request>,
143 ready: mpsc::SyncSender<Result<ExGuid>>,
144) {
145 let mut ready = Some(ready);
146 let mut backlog = VecDeque::new();
147 loop {
148 let arena = Arena::default();
149 let working = match Working::open(&arena, connection) {
150 Ok(working) => working,
151 Err(error) => {
152 let message = error.to_string();
153 if let Some(ready) = ready.take() {
154 let _ = ready.send(Err(error));
155 return;
156 }
157 for request in backlog.drain(..).chain(requests.iter()) {
158 request.fail(&message);
159 }
160 return;
161 }
162 };
163 if let Some(ready) = ready.take() {
164 let _ = ready.send(Ok(working.section.root()));
165 }
166 match working.serve(connection, worker, requests, &mut backlog) {
167 Next::Stop => return,
168 Next::Reopen => {}
169 }
170 }
171}
172
173/// An edit applied to the section and not yet written, with who waits for it.
174struct Accepted {
175 author: String,
176 edit: Edit,
177 done: Done,
178}
179
180enum Done {
181 Apply(Reply<u64>),
182 Save(Reply<Option<Save>>),
183}
184
185impl Done {
186 fn reply(self, written: Result<u64>) {
187 match self {
188 Self::Apply(reply) => reply(written),
189 Self::Save(reply) => reply(written.map(|id| Some(Save::Queued(id)))),
190 }
191 }
192}
193
194struct Working<'a> {
195 section: Section<'a>,
196 /// The batch collecting edits; sealed batches before it are replayed.
197 open: Option<i64>,
198 /// Spaces the open batch's edits change.
199 touched: BTreeSet<ExGuid>,
200 /// The last whole-page save: its space, the page it was given, and the page as stored.
201 saved: Option<(ExGuid, Page, Page)>,
202}
203
204impl<'a> Working<'a> {
205 fn open(arena: &'a Arena, connection: &Mutex<Connection>) -> Result<Self> {
206 let (section, open, touched) = replay(arena, &*lock(connection)?)?;
207 Ok(Self {
208 section,
209 open,
210 touched,
211 saved: None,
212 })
213 }
214
215 fn serve(
216 mut self,
217 connection: &Mutex<Connection>,
218 worker: &Worker,
219 requests: &mpsc::Receiver<Request>,
220 backlog: &mut VecDeque<Request>,
221 ) -> Next {
222 loop {
223 let first = match backlog.pop_front() {
224 Some(request) => request,
225 None => match requests.recv() {
226 Ok(request) => request,
227 Err(_) => return Next::Stop,
228 },
229 };
230 let mut burst: VecDeque<Request> = VecDeque::from([first]);
231 burst.extend(backlog.drain(..));
232 burst.extend(requests.try_iter());
233 let mut accepted = Vec::new();
234 while let Some(request) = burst.pop_front() {
235 let reopen = match request {
236 Request::Apply {
237 author,
238 edit,
239 reply,
240 } => self
241 .accept(
242 author,
243 edit,
244 Done::Apply(reply),
245 &mut accepted,
246 connection,
247 worker,
248 )
249 .err()
250 .unwrap_or(false),
251 Request::Save {
252 space,
253 before,
254 mut after,
255 author,
256 reply,
257 } => {
258 // Saves that continue one another are written once.
259 let mut replies = vec![reply];
260 while let Some(Request::Save {
261 space: next,
262 before: continued,
263 author: by,
264 ..
265 }) = burst.front()
266 && (*next, continued, by) == (space, &after, &author)
267 {
268 let Some(Request::Save {
269 after: later,
270 reply,
271 ..
272 }) = burst.pop_front()
273 else {
274 unreachable!()
275 };
276 after = later;
277 replies.push(reply);
278 }
279 let last = replies.pop().unwrap();
280 for folded in replies {
281 folded(Ok(None));
282 }
283 match self.save(space, &before, &after) {
284 Ok(Some(edit)) => match self.accept(
285 author,
286 edit,
287 Done::Save(last),
288 &mut accepted,
289 connection,
290 worker,
291 ) {
292 Ok(()) => {
293 self.saved = self
294 .section
295 .page(space)
296 .ok()
297 .map(|stored| (space, after, stored));
298 false
299 }
300 Err(broken) => broken,
301 },
302 Ok(None) => {
303 self.saved = self
304 .section
305 .page(space)
306 .ok()
307 .map(|stored| (space, after, stored));
308 last(Ok(Some(Save::Unchanged)));
309 false
310 }
311 Err(Stale::Stale) => {
312 last(Ok(Some(Save::Stale)));
313 false
314 }
315 Err(Stale::Error(error)) => {
316 last(Err(error));
317 false
318 }
319 }
320 }
321 Request::Page { space, reply } => {
322 reply(self.section.page(space).map_err(Into::into));
323 false
324 }
325 Request::Pages { reply } => {
326 reply(self.section.pages().map_err(Into::into));
327 false
328 }
329 Request::Image { reply } => {
330 if self.flush(connection, worker, &mut accepted) {
331 reply(lock(connection).and_then(|connection| image(&connection)));
332 false
333 } else {
334 reply(Err(
335 io::Error::other("The queue could not be written").into()
336 ));
337 true
338 }
339 }
340 Request::Seal { reply } => {
341 if !self.flush(connection, worker, &mut accepted) {
342 reply(Err(
343 io::Error::other("The queue could not be written").into()
344 ));
345 true
346 } else {
347 let sealed = self.seal(connection);
348 let failed = sealed.is_err();
349 reply(sealed);
350 failed
351 }
352 }
353 Request::Rebase {
354 image,
355 resolve,
356 reply,
357 } => {
358 if !self.flush(connection, worker, &mut accepted) {
359 reply(Err(
360 io::Error::other("The queue could not be written").into()
361 ));
362 true
363 } else {
364 let rebased = self.rebase(connection, image, resolve);
365 let reopen = !matches!(rebased, Ok(Rebased::Conflict { .. }));
366 reply(rebased);
367 reopen
368 }
369 }
370 Request::Reopen { reply } => {
371 self.flush(connection, worker, &mut accepted);
372 reply(Ok(()));
373 true
374 }
375 };
376 if reopen {
377 backlog.extend(burst);
378 return Next::Reopen;
379 }
380 }
381 if !self.flush(connection, worker, &mut accepted) {
382 return Next::Reopen;
383 }
384 }
385 }
386
387 /// Applies an edit, keeping it to be written with the burst. A refused edit is answered
388 /// here; `Err(true)` when it failed part way and the section must be reread.
389 fn accept(
390 &mut self,
391 author: String,
392 edit: Edit,
393 done: Done,
394 accepted: &mut Vec<Accepted>,
395 connection: &Mutex<Connection>,
396 worker: &Worker,
397 ) -> std::result::Result<(), bool> {
398 match self.section.apply(&author, &edit) {
399 Ok(()) => {
400 self.touched
401 .extend(queue::spaces(&edit, self.section.root()));
402 accepted.push(Accepted { author, edit, done });
403 Ok(())
404 }
405 Err(error) => {
406 let broken = matches!(error, OpError::Failed(_));
407 if broken {
408 self.flush(connection, worker, accepted);
409 }
410 done.reply(Err(error.into()));
411 Err(broken)
412 }
413 }
414 }
415
416 /// The edit that takes the stored page from `before` to `after`. A save continuing
417 /// the previous one finds the page as that one stored it, which may read back
418 /// normalized.
419 fn save(
420 &mut self,
421 space: ExGuid,
422 before: &Page,
423 after: &Page,
424 ) -> std::result::Result<Option<Edit>, Stale> {
425 let current = self
426 .section
427 .page(space)
428 .map_err(|error| Stale::Error(error.into()))?;
429 let expected = match &self.saved {
430 Some((saved, given, stored)) if *saved == space && given == before => stored,
431 _ => before,
432 };
433 if current != *expected {
434 return Err(Stale::Stale);
435 }
436 let ops = onestore::op::lower_page(&current, after)
437 .map_err(|error| Stale::Error(OpError::Unsupported(error.message).into()))?;
438 Ok((!ops.is_empty()).then(|| Edit {
439 at: crate::now(),
440 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
441 }))
442 }
443
444 /// Writes the accepted edits in one transaction, then answers their senders; false
445 /// when the write failed and the section holds edits the queue lacks.
446 fn flush(
447 &mut self,
448 connection: &Mutex<Connection>,
449 worker: &Worker,
450 accepted: &mut Vec<Accepted>,
451 ) -> bool {
452 if accepted.is_empty() {
453 return true;
454 }
455 let written = (|| -> Result<Vec<u64>> {
456 let mut connection = lock(connection)?;
457 let transaction =
458 connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
459 let batch = match self.open {
460 Some(batch) => batch,
461 None => {
462 transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?;
463 transaction.last_insert_rowid()
464 }
465 };
466 let mut ids = Vec::new();
467 for edit in accepted.iter() {
468 ids.push(queue::insert(
469 &transaction,
470 None,
471 batch,
472 &edit.author,
473 &edit.edit,
474 )?);
475 }
476 transaction.commit()?;
477 self.open = Some(batch);
478 Ok(ids)
479 })();
480 let ok = written.is_ok();
481 match written {
482 Ok(ids) => {
483 for (edit, id) in accepted.drain(..).zip(ids) {
484 edit.done.reply(Ok(id));
485 }
486 crate::wake(worker);
487 }
488 Err(error) => {
489 let message = error.to_string();
490 for edit in accepted.drain(..) {
491 edit.done
492 .reply(Err(io::Error::other(message.clone()).into()));
493 }
494 }
495 }
496 ok
497 }
498
499 /// Seals the open batch unless a sealed batch still waits for publication.
500 fn seal(&mut self, connection: &Mutex<Connection>) -> Result<Option<Sealed>> {
501 let Some(batch) = self.open else {
502 return Ok(None);
503 };
504 {
505 let connection = lock(connection)?;
506 let waiting: bool = connection.query_row(
507 "SELECT EXISTS(SELECT 1 FROM batches WHERE sealed IS NOT NULL OR conflict IS NOT NULL)",
508 [],
509 |row| row.get(0),
510 )?;
511 if waiting {
512 return Ok(None);
513 }
514 }
515 let transaction = self.section.seal()?;
516 // A batch whose edits change nothing is recorded against the section's root.
517 let root = self.section.root();
518 let revisions: BTreeMap<ExGuid, ExGuid> = self
519 .section
520 .revisions()
521 .filter(|(space, _)| {
522 self.touched.contains(space) || (self.touched.is_empty() && *space == root)
523 })
524 .collect();
525 // The sync thread publishes what it seals at once: the attempt is recorded with it.
526 lock(connection)?.execute(
527 "UPDATE batches SET sealed=?1, revisions=?2, attempted=?3 WHERE id=?4",
528 params![
529 serde_json::to_string(&transaction).map_err(io::Error::other)?,
530 serde_json::to_string(&revisions).map_err(io::Error::other)?,
531 transaction.is_some(),
532 batch
533 ],
534 )?;
535 self.open = None;
536 self.touched.clear();
537 Ok(Some(Sealed { batch, transaction }))
538 }
539
540 /// Replays every queued edit on `image`, keeping it as the base when all apply.
541 fn rebase(
542 &mut self,
543 connection: &Mutex<Connection>,
544 image: Option<Vec<u8>>,
545 resolve: Option<Resolve>,
546 ) -> Result<Rebased> {
547 let (base_image, image, edits) = {
548 let connection = lock(connection)?;
549 if connection.query_row(
550 "SELECT EXISTS(SELECT 1 FROM batches WHERE attempted=1)",
551 [],
552 |row| row.get::<_, bool>(0),
553 )? {
554 return Err(io::Error::new(
555 io::ErrorKind::InvalidInput,
556 "An uncertain attempt is never rebased",
557 )
558 .into());
559 }
560 let base_image = base::read(&connection, base::Image::Base)?.ok_or_else(|| {
561 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
562 })?;
563 let image = match image {
564 Some(image) => image,
565 None => base::read(&connection, base::Image::Remote)?
566 .unwrap_or_else(|| base_image.clone()),
567 };
568 (base_image, image, queue::load(&connection, None)?)
569 };
570 // The page the local edits leave, which `Mine` rewrites the remote page to; the
571 // root space lists pages and holds none.
572 let target = match &resolve {
573 Some(Resolve {
574 keep: Resolution::Mine,
575 page: None,
576 space,
577 ..
578 }) if *space != self.section.root() => Some(self.section.page(*space)?),
579 Some(Resolve { page, .. }) => page.clone(),
580 None => None,
581 };
582 let old_arena = Arena::default();
583 let mut old = Section::open(&old_arena, base_image.clone())?;
584 let before: BTreeMap<ExGuid, ExGuid> = old.revisions().collect();
585 let mut after: BTreeMap<ExGuid, ExGuid>;
586 // Pages the remote already holds as the local edits leave them: their ops are done.
587 let mut converged = BTreeSet::new();
588 let outcome = loop {
589 let arena = Arena::default();
590 let mut new = Section::open(&arena, image.clone())?;
591 if old.root() != new.root() {
592 return Err(io::Error::new(
593 io::ErrorKind::InvalidInput,
594 "Remote snapshot belongs to another document",
595 )
596 .into());
597 }
598 after = new.revisions().collect();
599 let outcome = merge::rebase(
600 &mut old,
601 &mut new,
602 &edits,
603 &converged,
604 resolve.as_ref().map(|resolve| merge::Resolving {
605 first: resolve.first,
606 space: resolve.space,
607 mine: resolve.keep == Resolution::Mine,
608 target: target.as_ref(),
609 }),
610 )?;
611 if let merge::Outcome::Conflict { space, .. } = &outcome
612 && !converged.contains(space)
613 && let Ok(local) = self.section.page(*space)
614 && Section::open(&Arena::default(), image.clone())?
615 .page(*space)
616 .is_ok_and(|remote| remote == local)
617 {
618 converged.insert(*space);
619 continue;
620 }
621 break outcome;
622 };
623 let mut connection = lock(connection)?;
624 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
625 let rebased = match outcome {
626 merge::Outcome::Conflict { id, space, kind } => {
627 transaction.execute("UPDATE batches SET conflict=NULL, space=NULL", [])?;
628 transaction.execute(
629 "UPDATE batches SET conflict=?1, space=?2 WHERE id=(SELECT batch FROM edits WHERE id=?3)",
630 params![kind as i64, space.to_string(), signed(id)?],
631 )?;
632 if image == base_image {
633 base::clear(&transaction, base::Image::Remote)?;
634 } else {
635 base::write(&transaction, base::Image::Remote, &image)?;
636 }
637 // A conflict is reported under its batch's newest edit.
638 let id = crate::unsigned(transaction.query_row(
639 "SELECT max(id) FROM edits WHERE batch=(SELECT batch FROM edits WHERE id=?1)",
640 [signed(id)?],
641 |row| row.get(0),
642 )?)?;
643 Rebased::Conflict { id, space, kind }
644 }
645 merge::Outcome::Applied(rewritten) => {
646 base::write(&transaction, base::Image::Base, &image)?;
647 base::clear(&transaction, base::Image::Remote)?;
648 transaction.execute("INSERT INTO batches DEFAULT VALUES", [])?;
649 let batch = transaction.last_insert_rowid();
650 transaction.execute("UPDATE edits SET batch=?1", [batch])?;
651 transaction.execute("DELETE FROM batches WHERE id<>?1", [batch])?;
652 for (id, edit) in rewritten {
653 queue::rewrite(&transaction, id, &edit)?;
654 }
655 if transaction
656 .query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))?
657 == 0
658 {
659 transaction.execute("DELETE FROM batches", [])?;
660 }
661 queue::collect(&transaction)?;
662 let mut changed: BTreeSet<ExGuid> = before
663 .iter()
664 .filter(|(space, rid)| after.get(space) != Some(rid))
665 .map(|(space, _)| *space)
666 .chain(
667 after
668 .keys()
669 .filter(|space| !before.contains_key(space))
670 .copied(),
671 )
672 .collect();
673 changed.extend(resolve.as_ref().map(|resolve| resolve.space));
674 changed.extend(converged);
675 Rebased::Applied {
676 changed: changed.into_iter().collect(),
677 }
678 }
679 };
680 transaction.commit()?;
681 Ok(rebased)
682 }
683}
684
685enum Stale {
686 Stale,
687 Error(crate::Error),
688}
689
690/// Batches in order with their sealed transactions: `None` while open, `Some(None)` when
691/// sealing stored nothing.
692fn batches(connection: &Connection) -> Result<Vec<(i64, Option<Option<Transaction>>)>> {
693 let mut query = connection.prepare("SELECT id, sealed FROM batches ORDER BY id")?;
694 let mut rows = query.query([])?;
695 let mut batches = Vec::new();
696 while let Some(row) = rows.next()? {
697 let sealed: Option<String> = row.get(1)?;
698 batches.push((
699 row.get(0)?,
700 sealed
701 .map(|sealed| serde_json::from_str(&sealed))
702 .transpose()
703 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
704 ));
705 }
706 Ok(batches)
707}
708
709/// The cached base with each sealed batch replayed and the open batch's edits applied,
710/// with the open batch and the spaces its edits change.
711fn replay<'a>(
712 arena: &'a Arena,
713 connection: &Connection,
714) -> Result<(Section<'a>, Option<i64>, BTreeSet<ExGuid>)> {
715 let image = base::read(connection, base::Image::Base)?
716 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?;
717 let mut section = Section::open(arena, image)?;
718 let mut open = None;
719 let mut touched = BTreeSet::new();
720 for (batch, sealed) in batches(connection)? {
721 if open.is_some() {
722 return Err(io::Error::new(
723 io::ErrorKind::InvalidData,
724 "A cached batch follows the open batch",
725 )
726 .into());
727 }
728 match sealed {
729 Some(Some(transaction)) => section.replay(&transaction)?,
730 Some(None) => {}
731 None => {
732 for queued in queue::load(connection, Some(batch))? {
733 section
734 .apply(&queued.author, &queued.edit)
735 .map_err(|error| {
736 io::Error::new(
737 io::ErrorKind::InvalidData,
738 format!("Queued edit {} no longer applies: {error}", queued.id),
739 )
740 })?;
741 touched.extend(queue::spaces(&queued.edit, section.root()));
742 }
743 open = Some(batch);
744 }
745 }
746 }
747 Ok((section, open, touched))
748}
749
750/// The image the queue leaves, its unsealed edits sealed as one more revision; that
751/// revision's identities are fresh on every call. O(section), for tests and recovery.
752pub(crate) fn image(connection: &Connection) -> Result<Vec<u8>> {
753 let arena = Arena::default();
754 let (mut section, ..) = replay(&arena, connection)?;
755 section.seal()?;
756 Ok(section.image())
757}
758
759/// The sealed batch waiting for publication, if any.
760pub(crate) fn sealed(connection: &Connection) -> Result<Option<Sealed>> {
761 let row: Option<(i64, String)> = connection
762 .query_row(
763 "SELECT id, sealed FROM batches WHERE sealed IS NOT NULL ORDER BY id LIMIT 1",
764 [],
765 |row| Ok((row.get(0)?, row.get(1)?)),
766 )
767 .optional()?;
768 row.map(|(batch, sealed)| {
769 Ok(Sealed {
770 batch,
771 transaction: serde_json::from_str(&sealed)
772 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
773 })
774 })
775 .transpose()
776}
crates/notebook/tests/assets.rs+14-11
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use notebook::{EditStatus, Error, Operation, Recovery, Replica};1use notebook::{EditStatus, Error, Recovery, Replica};
2use onestore::{2use onestore::{
3 ExGuid, RevisionIndex, Store,3 ExGuid, RevisionIndex, Store,
4 document::Document,4 document::Document,
...@@ -13,6 +13,8 @@ use std::{...@@ -13,6 +13,8 @@ use std::{
1313
14#[path = "support/model_ops.rs"]14#[path = "support/model_ops.rs"]
15mod model_ops;15mod model_ops;
16#[path = "support/server.rs"]
17mod server;
1618
17const FIXTURE: &str = "../../corpus/native-external-assets/notebook";19const FIXTURE: &str = "../../corpus/native-external-assets/notebook";
1820
...@@ -54,10 +56,7 @@ fn queued_cache(root: &Path) -> Replica {...@@ -54,10 +56,7 @@ fn queued_cache(root: &Path) -> Replica {
54 .unwrap()56 .unwrap()
55 .unwrap();57 .unwrap();
56 let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap();58 let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap();
57 cache59 server::section_op(&cache, onestore::op::SectionOp::Create(page));
58 .create_page(&cache.snapshot().unwrap(), &page)
59 .unwrap()
60 .unwrap();
61 cache60 cache
62}61}
6362
...@@ -118,7 +117,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() {...@@ -118,7 +117,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() {
118 (summary.cached_assets, summary.cached_asset_bytes),117 (summary.cached_assets, summary.cached_asset_bytes),
119 (2, 1024)118 (2, 1024)
120 );119 );
121 assert_eq!(cache.snapshot().unwrap(), working);120 assert_eq!(
121 server::pages(&cache.snapshot().unwrap()),
122 server::pages(&working)
123 );
122 assert_eq!(cache.remote_snapshot().unwrap(), remote);124 assert_eq!(cache.remote_snapshot().unwrap(), remote);
123 assert_eq!(cache.pending().unwrap(), pending);125 assert_eq!(cache.pending().unwrap(), pending);
124 assert_eq!(cache.status(1).unwrap(), uncertain);126 assert_eq!(cache.status(1).unwrap(), uncertain);
...@@ -234,10 +236,11 @@ fn download_network_wait_does_not_block_local_edits() {...@@ -234,10 +236,11 @@ fn download_network_wait_does_not_block_local_edits() {
234 release.send(()).unwrap();236 release.send(()).unwrap();
235 assert_eq!(download.join().unwrap(), bytes);237 assert_eq!(download.join().unwrap(), bytes);
236 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));238 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
237 let Operation::Page(intent) = &cache.pending().unwrap()[2].operation else {239 assert!(matches!(
238 panic!()240 &cache.pending().unwrap()[2].edit.ops[..],
239 };241 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { with, .. }, .. }]
240 assert_eq!(intent.text_change().unwrap().3, "during download ");242 if with == "during download "
243 ));
241 });244 });
242}245}
243246
...@@ -284,7 +287,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() {...@@ -284,7 +287,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() {
284 panic!("Unexpected enumeration")287 panic!("Unexpected enumeration")
285 }288 }
286 fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> {289 fn read(&mut self, _: &str, _: usize) -> io::Result<Vec<u8>> {
287 assert_eq!(self.0.sync_once(&mut Native).unwrap(), None);290 assert_eq!(self.0.sync_once(&mut Native).unwrap().edit, None);
288 Ok(payload(1024).1)291 Ok(payload(1024).1)
289 }292 }
290 }293 }
crates/notebook/tests/cache.rs+230-270
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1use notebook::{Error, Operation, Replica};1use notebook::{Error, Replica};
2use onestore::{2use onestore::{
3 ExGuid, RevisionIndex, Store,3 ExGuid, RevisionIndex, Store,
4 document::{Document, Kind},4 document::{Document, Kind},
5 op::{Edit, Op, PageOp, SectionOp},
5 page::{Outline, PageObject},6 page::{Outline, PageObject},
6};7};
7use std::{8use std::{
...@@ -14,6 +15,8 @@ use std::{...@@ -14,6 +15,8 @@ use std::{
1415
15#[path = "support/model_ops.rs"]16#[path = "support/model_ops.rs"]
16mod model_ops;17mod model_ops;
18#[path = "support/server.rs"]
19mod server;
1720
18fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {21fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
19 let store = Store::parse(source).unwrap();22 let store = Store::parse(source).unwrap();
...@@ -36,25 +39,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {...@@ -36,25 +39,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
36 .unwrap()39 .unwrap()
37}40}
3841
39fn text_of(page: &onestore::page::Page, text: ExGuid) -> String {42/// An edit typing `with` at the start of a text.
40 model_ops::paragraph_with(page, text)43fn typed(space: ExGuid, text: ExGuid, with: &str) -> Edit {
41 .unwrap()44 Edit {
42 .text()45 at: model_ops::now(),
43 .unwrap()46 ops: vec![Op::Page {
44 .text47 space,
45 .text()48 op: PageOp::Text {
46 .to_owned()49 text,
47}50 range: 0..0,
4851 with: with.into(),
49fn intent(replica: &Replica, at: usize) -> notebook::PageIntent {52 },
50 match &replica.pending().unwrap()[at].operation {53 }],
51 Operation::Page(intent) => intent.clone(),
52 other => panic!("{other:?}"),
53 }54 }
54}55}
5556
56#[test]57#[test]
57fn cache_reopen_preserves_exact_images_and_intents() {58fn cache_reopen_preserves_the_base_and_queued_edits() {
58 let dir = tempfile::tempdir().unwrap();59 let dir = tempfile::tempdir().unwrap();
59 let path = dir.path().join("section.sqlite");60 let path = dir.path().join("section.sqlite");
60 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();61 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
...@@ -72,77 +73,102 @@ fn cache_reopen_preserves_exact_images_and_intents() {...@@ -72,77 +73,102 @@ fn cache_reopen_preserves_exact_images_and_intents() {
72 })73 })
73 .unwrap()74 .unwrap()
74 .unwrap();75 .unwrap();
75 let edited = replica.snapshot().unwrap();76 assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語");
76 assert_eq!(target(&edited).2, "café 🐈 日本語");77 let edits = replica.pending().unwrap();
77 let intents = replica.pending().unwrap();78 assert_eq!(edits.len(), 1);
78 assert_eq!(intents.len(), 1);
79 assert_eq!((intents[0].id, intents[0].space), (first, sid));
80 let first_intent = intent(&replica, 0);
81 assert_eq!(79 assert_eq!(
82 first_intent.text_change(),80 (edits[0].id, edits[0].author.as_str()),
83 Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into()))81 (first, model_ops::AUTHOR)
84 );82 );
85 assert_eq!(first_intent.author, model_ops::AUTHOR);83 assert!(matches!(
84 &edits[0].edit.ops[..],
85 [Op::Page { space, op: PageOp::Text { text, range, with } }]
86 if (*space, *text, range.clone(), with.as_str()) == (sid, oid, 5..7, "🐈 日本語")
87 ));
88 let beside = |suffix: &str| {
89 let mut file = path.as_os_str().to_owned();
90 file.push(suffix);
91 std::path::PathBuf::from(file)
92 };
93 // Commits go to the write-ahead log; its index stays in memory.
94 assert!(beside("-wal").exists());
95 assert!(!beside("-shm").exists());
86 drop(replica);96 drop(replica);
97 assert!(
98 !beside("-wal").exists(),
99 "closing checkpoints and removes the log"
100 );
87 let replica = Replica::open(&path).unwrap();101 let replica = Replica::open(&path).unwrap();
88 assert_eq!(replica.snapshot().unwrap(), edited);102 assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語");
89 assert_eq!(replica.pending().unwrap(), intents);103 assert_eq!(replica.pending().unwrap(), edits);
90 let second = model_ops::save(&replica, oid, |page| {104 let second = replica
91 model_ops::replace_text(page, oid, 0..0, "Recovered ")105 .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered "))
92 })106 .unwrap();
93 .unwrap()107 assert!(second > first);
94 .unwrap();
95 assert_eq!(second, first, "an unattempted save is replaced in place");
96 let coalesced = intent(&replica, 0);
97 assert_eq!(coalesced.before, first_intent.before);
98 assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語");
99 assert_eq!(108 assert_eq!(
100 target(&replica.snapshot().unwrap()).2,109 target(&replica.snapshot().unwrap()).2,
101 "Recovered café 🐈 日本語"110 "Recovered café 🐈 日本語"
102 );111 );
112 assert_eq!(replica.pending().unwrap().len(), 2);
103}113}
104114
105#[test]115#[test]
106fn failed_saves_preserve_both_intent_queue_and_working_image() {116fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() {
107 let dir = tempfile::tempdir().unwrap();117 let dir = tempfile::tempdir().unwrap();
108 let path = dir.path().join("section.sqlite");118 let path = dir.path().join("section.sqlite");
109 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();119 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
110 let replica = Replica::create(&path, &source).unwrap();120 let replica = Replica::create(&path, &source).unwrap();
111 let (sid, oid, _) = target(&source);121 let (sid, oid, _) = target(&source);
112 let (_, mut page) = model_ops::locate(&source, oid);122 let empty = Outline {
113 let mut empty = Outline {
114 id: onestore::page::text::new_id().unwrap(),123 id: onestore::page::text::new_id().unwrap(),
115 title: false,124 title: false,
116 min_width: None,125 min_width: None,
117 layout: Default::default(),126 layout: onestore::document::Layout {
127 x: Some(72.0),
128 y: Some(400.0),
129 ..Default::default()
130 },
118 indents: Vec::new(),131 indents: Vec::new(),
119 paragraphs: Vec::new(),132 paragraphs: Vec::new(),
120 unsupported: Vec::new(),133 unsupported: Vec::new(),
121 };134 };
122 empty.layout.x = Some(72.0);135 let refused = Edit {
123 empty.layout.y = Some(400.0);136 at: model_ops::now(),
124 page.objects.push(PageObject::Outline(empty));137 ops: vec![
125 assert!(replica.save(&source, sid, &page, "Author").is_err());138 Op::Page {
126 let (_, mut page) = model_ops::locate(&source, oid);139 space: sid,
127 model_ops::replace_text(&mut page, oid, 0..0, "Elsewhere ");140 op: PageOp::Text {
128 let foreign = ExGuid::default();141 text: oid,
129 assert!(replica.save(&source, foreign, &page, "Author").is_err());142 range: 0..0,
143 with: "Undone ".into(),
144 },
145 },
146 Op::Page {
147 space: sid,
148 op: PageOp::Add {
149 object: PageObject::Outline(empty),
150 before: None,
151 },
152 },
153 ],
154 };
155 assert!(matches!(
156 replica.apply("Author", refused),
157 Err(Error::Rejected(_))
158 ));
159 assert!(matches!(
160 replica.apply("Author", typed(ExGuid::default(), oid, "Elsewhere ")),
161 Err(Error::Rejected(_))
162 ));
130 assert_eq!(replica.snapshot().unwrap(), source);163 assert_eq!(replica.snapshot().unwrap(), source);
131 assert!(replica.pending().unwrap().is_empty());164 assert!(replica.pending().unwrap().is_empty());
132 drop(replica);165 drop(replica);
133 let connection = rusqlite::Connection::open(&path).unwrap();166 let connection = rusqlite::Connection::open(&path).unwrap();
134 connection.execute_batch("CREATE TRIGGER fail_image BEFORE UPDATE ON replica BEGIN SELECT RAISE(ABORT, 'Injected image update failure'); END;").unwrap();167 connection.execute_batch("CREATE TRIGGER fail_edit BEFORE INSERT ON edits BEGIN SELECT RAISE(ABORT, 'Injected queue failure'); END;").unwrap();
135 drop(connection);168 drop(connection);
136 let replica = Replica::open(&path).unwrap();169 let replica = Replica::open(&path).unwrap();
137 assert!(matches!(170 assert!(replica.apply("Author", typed(sid, oid, "lost? ")).is_err());
138 model_ops::save(&replica, oid, |page| model_ops::replace_text(171 assert_eq!(replica.snapshot().unwrap(), source);
139 page,
140 oid,
141 0..0,
142 "lost? "
143 )),
144 Err(Error::Database(_))
145 ));
146 drop(replica);172 drop(replica);
147 let replica = Replica::open(&path).unwrap();173 let replica = Replica::open(&path).unwrap();
148 assert_eq!(replica.snapshot().unwrap(), source);174 assert_eq!(replica.snapshot().unwrap(), source);
...@@ -155,7 +181,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {...@@ -155,7 +181,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
155181
156 let directory = tempfile::tempdir().unwrap();182 let directory = tempfile::tempdir().unwrap();
157 let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap();183 let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap();
158 let (_, object, _) = target(&source);184 let (space, object, _) = target(&source);
159 let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();185 let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();
160 let start = Barrier::new(4);186 let start = Barrier::new(4);
161 std::thread::scope(|scope| {187 std::thread::scope(|scope| {
...@@ -164,18 +190,12 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {...@@ -164,18 +190,12 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
164 scope.spawn(move || {190 scope.spawn(move || {
165 start.wait();191 start.wait();
166 for edit in 0..20 {192 for edit in 0..20 {
167 loop {193 replica
168 let marker = format!("[{writer}:{edit}] ");194 .apply(
169 match model_ops::save(replica, object, |page| {195 "Fixture",
170 model_ops::replace_text(page, object, 0..0, &marker)196 typed(space, object, &format!("[{writer}:{edit}] ")),
171 }) {197 )
172 Ok(Some(_)) => break,198 .unwrap();
173 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => {
174 continue;
175 }
176 other => panic!("Unexpected local edit: {other:?}"),
177 }
178 }
179 }199 }
180 });200 });
181 }201 }
...@@ -185,20 +205,22 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {...@@ -185,20 +205,22 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
185 replica.export_recovery(&path).unwrap();205 replica.export_recovery(&path).unwrap();
186 let archive = Recovery::open(path).unwrap();206 let archive = Recovery::open(path).unwrap();
187 let pending = archive.pending().unwrap();207 let pending = archive.pending().unwrap();
188 let snapshot = target(&archive.snapshot().unwrap()).2;208 // Each edit types at the start, so the text is the queue read backwards.
189 match pending.as_slice() {209 let expected: String = pending
190 [] => assert_eq!(snapshot, "base"),210 .iter()
191 [single] => {211 .rev()
192 let Operation::Page(intent) = &single.operation else {212 .map(|edit| match &edit.edit.ops[..] {
193 panic!()213 [
194 };214 Op::Page {
195 let (id, before, range, replacement) = intent.text_change().unwrap();215 op: PageOp::Text { with, .. },
196 assert_eq!((id, before.as_str(), range), (object, "base", 0..0));216 ..
197 assert_eq!(snapshot, format!("{replacement}base"));217 },
198 assert_eq!(text_of(&intent.after, object), snapshot);218 ] => with.as_str(),
199 }219 other => panic!("{other:?}"),
200 more => panic!("saves to one page coalesce: {more:?}"),220 })
201 }221 .chain(["base"])
222 .collect();
223 assert_eq!(target(&archive.snapshot().unwrap()).2, expected);
202 assert_eq!(archive.remote_snapshot().unwrap(), source);224 assert_eq!(archive.remote_snapshot().unwrap(), source);
203 assert_eq!(225 assert_eq!(
204 archive.summary().unwrap().queued_edits,226 archive.summary().unwrap().queued_edits,
...@@ -207,7 +229,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {...@@ -207,7 +229,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
207 assert!(archive.receipts().unwrap().is_empty());229 assert!(archive.receipts().unwrap().is_empty());
208 }230 }
209 });231 });
210 assert_eq!(replica.pending().unwrap().len(), 1);232 assert_eq!(replica.pending().unwrap().len(), 60);
211 let content = target(&replica.snapshot().unwrap()).2;233 let content = target(&replica.snapshot().unwrap()).2;
212 for writer in 0..3 {234 for writer in 0..3 {
213 for edit in 0..20 {235 for edit in 0..20 {
...@@ -242,9 +264,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {...@@ -242,9 +264,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
242 .unwrap();264 .unwrap();
243 for sql in [265 for sql in [
244 "PRAGMA application_id=0".to_owned(),266 "PRAGMA application_id=0".to_owned(),
267 // Schema 14 converts; older caches do not.
245 format!(268 format!(
246 "PRAGMA application_id=1330529615; PRAGMA user_version={}",269 "PRAGMA application_id=1330529615; PRAGMA user_version={}",
247 current - 1270 current - 2
248 ),271 ),
249 format!("PRAGMA user_version={}", current + 1),272 format!("PRAGMA user_version={}", current + 1),
250 ] {273 ] {
...@@ -289,52 +312,26 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {...@@ -289,52 +312,26 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
289}312}
290313
291#[test]314#[test]
292fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {315fn twelve_local_editors_queue_every_edit_once_in_order() {
293 let dir = tempfile::tempdir().unwrap();316 let dir = tempfile::tempdir().unwrap();
294 let path = dir.path().join("section.sqlite");317 let path = dir.path().join("section.sqlite");
295 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();318 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();
296 let replica = Replica::create(&path, &source).unwrap();319 let replica = Replica::create(&path, &source).unwrap();
297 let (sid, oid, _) = target(&source);320 let (sid, oid, _) = target(&source);
298 let barrier = Barrier::new(12);321 let barrier = Barrier::new(12);
299 let deadline = Instant::now() + Duration::from_secs(60);
300 let outcomes = std::thread::scope(|scope| {322 let outcomes = std::thread::scope(|scope| {
301 let handles: Vec<_> = (0..12)323 let handles: Vec<_> = (0..12)
302 .map(|writer| {324 .map(|writer| {
303 let (replica, source, barrier) = (&replica, &source, &barrier);325 let (replica, barrier) = (&replica, &barrier);
304 scope.spawn(move || {326 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}] "));
307 barrier.wait();327 barrier.wait();
308 let mut ids = Vec::new();328 (0..20)
309 let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) {329 .map(|edit| {
310 Ok(Some(id)) => {330 replica
311 ids.push(id);331 .apply("Fixture", typed(sid, oid, &format!("[{writer}-{edit}] ")))
312 true332 .unwrap()
313 }333 })
314 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false,334 .collect::<Vec<_>>()
315 other => panic!("Unexpected first edit: {other:?}"),
316 };
317 for edit in 0..20 {
318 loop {
319 assert!(
320 Instant::now() < deadline,
321 "Writer {writer} stopped progressing at {edit}"
322 );
323 let marker = format!("[{writer}-{edit}] ");
324 match model_ops::save(replica, oid, |page| {
325 model_ops::replace_text(page, oid, 0..0, &marker)
326 }) {
327 Ok(Some(id)) => {
328 ids.push(id);
329 break;
330 }
331 Err(Error::Io(error))
332 if error.kind() == ErrorKind::ResourceBusy => {}
333 other => panic!("Unexpected edit: {other:?}"),
334 }
335 }
336 }
337 (first_won, ids)
338 })335 })
339 })336 })
340 .collect();337 .collect();
...@@ -343,31 +340,29 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {...@@ -343,31 +340,29 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
343 .map(|handle| handle.join().unwrap())340 .map(|handle| handle.join().unwrap())
344 .collect::<Vec<_>>()341 .collect::<Vec<_>>()
345 });342 });
346 assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1);343 for ids in &outcomes {
347 let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect();344 assert!(
348 assert_eq!(ids.len(), 1, "every save replaced the unattempted head");345 ids.windows(2).all(|pair| pair[0] < pair[1]),
349 let final_bytes = replica.snapshot().unwrap();346 "a writer's edits keep its order"
350 let content = target(&final_bytes).2;347 );
351 let pending = replica.pending().unwrap();348 }
352 assert_eq!(pending.len(), 1);349 let ids: BTreeSet<_> = outcomes.into_iter().flatten().collect();
353 assert_eq!(pending[0].id, *ids.first().unwrap());350 assert_eq!(ids.len(), 240);
354 let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap();351 let content = target(&replica.snapshot().unwrap()).2;
355 assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0));352 assert!(content.ends_with("Shared café 🦀"));
356 assert_eq!(content, format!("{replacement}Shared café 🦀"));
357 assert_eq!(content.matches("[initial-").count(), 1);
358 for writer in 0..12 {353 for writer in 0..12 {
359 for edit in 0..20 {354 for edit in 0..20 {
360 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);355 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);
361 }356 }
362 }357 }
363 let (_, mut stale) = model_ops::locate(&source, oid);358 let pending = replica.pending().unwrap();
364 model_ops::replace_text(&mut stale, oid, 0..0, "stale ");359 assert_eq!(
365 assert!(360 pending.iter().map(|edit| edit.id).collect::<BTreeSet<_>>(),
366 matches!(replica.save(&source, sid, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)361 ids
367 );362 );
368 drop(replica);363 drop(replica);
369 let reopened = Replica::open(&path).unwrap();364 let reopened = Replica::open(&path).unwrap();
370 assert_eq!(reopened.snapshot().unwrap(), final_bytes);365 assert_eq!(target(&reopened.snapshot().unwrap()).2, content);
371 assert_eq!(reopened.pending().unwrap(), pending);366 assert_eq!(reopened.pending().unwrap(), pending);
372}367}
373368
...@@ -375,11 +370,10 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {...@@ -375,11 +370,10 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
375fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {370fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
376 let dir = tempfile::tempdir().unwrap();371 let dir = tempfile::tempdir().unwrap();
377 let path = dir.path().join("model.sqlite");372 let path = dir.path().join("model.sqlite");
378 let original = "ab🚀ab🦀 é repeated repeated".to_owned();373 let mut text = "ab🚀ab🦀 é repeated repeated".to_owned();
379 let mut text = original.clone();
380 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();374 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();
381 let mut replica = Replica::create(&path, &source).unwrap();375 let mut replica = Replica::create(&path, &source).unwrap();
382 let (space, object, _) = target(&source);376 let (_, object, _) = target(&source);
383 let mut random = 911_u64;377 let mut random = 911_u64;
384 let mut next = || {378 let mut next = || {
385 random ^= random << 13;379 random ^= random << 13;
...@@ -387,8 +381,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -387,8 +381,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
387 random ^= random << 17;381 random ^= random << 17;
388 random382 random
389 };383 };
390 let mut acknowledged = 0;384 let mut acknowledged = Vec::new();
391 let mut pending = Vec::new();
392 for step in 0..512 {385 for step in 0..512 {
393 let boundaries: Vec<_> = text386 let boundaries: Vec<_> = text
394 .char_indices()387 .char_indices()
...@@ -403,47 +396,45 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {...@@ -403,47 +396,45 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
403 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];396 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
404 let mut expected = text.clone();397 let mut expected = text.clone();
405 expected.replace_range(bytes, replacement);398 expected.replace_range(bytes, replacement);
406 let before = replica.snapshot().unwrap();
407 let acknowledgement = model_ops::save(&replica, object, |page| {399 let acknowledgement = model_ops::save(&replica, object, |page| {
408 model_ops::replace_text(page, object, range.clone(), replacement)400 model_ops::replace_text(page, object, range.clone(), replacement)
409 })401 })
410 .unwrap();402 .unwrap();
411 if let Some(id) = acknowledgement {403 match acknowledgement {
412 assert_ne!(text, expected);404 Some(id) => {
413 acknowledged += 1;405 assert_ne!(text, expected);
414 pending = replica.pending().unwrap();406 assert!(acknowledged.last().is_none_or(|last| *last < id));
415 assert_eq!(pending.len(), 1);407 acknowledged.push(id);
416 assert_eq!((pending[0].id, pending[0].space), (1, space));
417 let saved = intent(&replica, 0);
418 assert_eq!(id, 1);
419 assert_eq!(text_of(&saved.before, object), original);
420 assert_eq!(text_of(&saved.after, object), expected);
421 if step % 37 == 0 {
422 let (_, mut stale) = model_ops::locate(&before, object);
423 model_ops::replace_text(&mut stale, object, 0..0, "stale");
424 assert!(
425 matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
426 );
427 }408 }
428 } else {409 None => assert_eq!(text, expected),
429 assert_eq!(text, expected);
430 }410 }
431 text = expected;411 text = expected;
432 let source = replica.snapshot().unwrap();412 assert_eq!(
433 assert_eq!(target(&source).2, text, "seed 911, step {step}");413 target(&replica.snapshot().unwrap()).2,
414 text,
415 "seed 911, step {step}"
416 );
434 if step % 37 == 0 {417 if step % 37 == 0 {
418 let pending = replica.pending().unwrap();
435 drop(replica);419 drop(replica);
436 replica = Replica::open(&path).unwrap();420 replica = Replica::open(&path).unwrap();
437 assert_eq!(replica.snapshot().unwrap(), source);421 assert_eq!(target(&replica.snapshot().unwrap()).2, text);
438 assert_eq!(replica.pending().unwrap(), pending);422 assert_eq!(replica.pending().unwrap(), pending);
439 }423 }
440 }424 }
441 assert!(acknowledged > 256, "Most random edits change the text");425 assert!(
442 let source = replica.snapshot().unwrap();426 acknowledged.len() > 256,
443 drop(replica);427 "Most random edits change the text"
444 let replica = Replica::open(&path).unwrap();428 );
445 assert_eq!(replica.pending().unwrap(), pending);429 assert_eq!(
446 assert_eq!(replica.snapshot().unwrap(), source);430 replica
431 .pending()
432 .unwrap()
433 .iter()
434 .map(|edit| edit.id)
435 .collect::<Vec<_>>(),
436 acknowledged
437 );
447}438}
448439
449#[test]440#[test]
...@@ -465,71 +456,43 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {...@@ -465,71 +456,43 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
465 let mut objects = Vec::new();456 let mut objects = Vec::new();
466 barrier.wait();457 barrier.wait();
467 for sequence in 0..4 {458 for sequence in 0..4 {
459 assert!(Instant::now() < deadline, "Client {client} stopped");
468 let content = if sequence == 0 {460 let content = if sequence == 0 {
469 format!("Client {client}")461 format!("Client {client}")
470 } else {462 } else {
471 format!("Paragraph {client}:{sequence}")463 format!("Paragraph {client}:{sequence}")
472 };464 };
473 let mut inserted = None;465 let mut inserted = None;
474 loop {466 let id = model_ops::save(cache, anchor, |page| {
475 assert!(467 let text = if sequence == 0 {
476 Instant::now() < deadline,468 model_ops::insert_outline(
477 "Client {client} stopped at insertion {sequence}"469 page,
478 );470 72.0,
479 let result = model_ops::save(cache, anchor, |page| {471 144.0 + client as f32 * 72.0,
480 let text = if sequence == 0 {472 &content,
481 model_ops::insert_outline(473 )
482 page,474 .2
483 72.0,475 } else {
484 144.0 + client as f32 * 72.0,476 model_ops::insert_after(page, *objects.last().unwrap(), &content).1
485 &content,477 };
486 )478 let end = content.encode_utf16().count() as u32;
487 .2479 model_ops::restyle(page, text, 0..end, |format| format.italic = None);
488 } else {480 model_ops::restyle(page, text, 0..6, italic);
489 model_ops::insert_after(481 inserted = Some(text);
490 page,482 })
491 *objects.last().unwrap(),483 .unwrap()
492 &content,484 .unwrap();
493 )485 ids.push(id);
494 .1486 objects.push(inserted.unwrap());
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 {
504 Ok(Some(id)) => {
505 ids.push(id);
506 objects.push(inserted.unwrap());
507 break;
508 }
509 Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {}
510 other => panic!("{other:?}"),
511 }
512 }
513 if sequence == 0 {487 if sequence == 0 {
514 continue;488 continue;
515 }489 }
516 let text = *objects.last().unwrap();490 let text = *objects.last().unwrap();
517 loop {491 ids.push(
518 assert!(492 cache
519 Instant::now() < deadline,493 .apply(model_ops::AUTHOR, typed(sid, text, "Edited "))
520 "Client {client} stopped at text {sequence}"494 .unwrap(),
521 );495 );
522 match model_ops::save(cache, text, |page| {
523 model_ops::replace_text(page, text, 0..0, "Edited ")
524 }) {
525 Ok(Some(id)) => {
526 ids.push(id);
527 break;
528 }
529 Err(Error::Io(e)) if e.kind() == ErrorKind::ResourceBusy => {}
530 other => panic!("{other:?}"),
531 }
532 }
533 }496 }
534 (ids, objects)497 (ids, objects)
535 })498 })
...@@ -544,12 +507,14 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {...@@ -544,12 +507,14 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
544 .iter()507 .iter()
545 .flat_map(|(ids, _)| ids.iter().copied())508 .flat_map(|(ids, _)| ids.iter().copied())
546 .collect();509 .collect();
547 assert_eq!(all.iter().map(|(ids, _)| ids.len()).sum::<usize>(), 84);510 assert_eq!(ids.len(), 84);
548 assert_eq!(ids.len(), 1);
549 let snapshot = cache.snapshot().unwrap();511 let snapshot = cache.snapshot().unwrap();
550 drop(cache);512 drop(cache);
551 let cache = Replica::open(&path).unwrap();513 let cache = Replica::open(&path).unwrap();
552 assert_eq!(cache.snapshot().unwrap(), snapshot);514 assert_eq!(
515 server::pages(&cache.snapshot().unwrap()),
516 server::pages(&snapshot)
517 );
553 assert_eq!(518 assert_eq!(
554 cache519 cache
555 .pending()520 .pending()
...@@ -588,8 +553,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {...@@ -588,8 +553,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
588}553}
589554
590#[test]555#[test]
591fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {556fn unrecognized_persisted_ops_are_rejected_without_dropping_fields() {
592 for operation in ["Page", "CreatePage", "Pages", "DeletePages"] {557 for kind in ["Text", "Create", "Pages", "Delete"] {
593 let directory = tempfile::tempdir().unwrap();558 let directory = tempfile::tempdir().unwrap();
594 let path = directory.path().join("unknown.sqlite");559 let path = directory.path().join("unknown.sqlite");
595 let first = onestore::create_section("unknown.one", "Original", "Author").unwrap();560 let first = onestore::create_section("unknown.one", "Original", "Author").unwrap();
...@@ -598,52 +563,47 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {...@@ -598,52 +563,47 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
598 .unwrap()563 .unwrap()
599 .as_bytes()564 .as_bytes()
600 .to_vec();565 .to_vec();
601 let (_, oid, _) = target(&source);566 let (space, oid, _) = target(&source);
602 let store = Store::parse(&source).unwrap();567 let store = Store::parse(&source).unwrap();
603 let index = RevisionIndex::parse(&store).unwrap();568 let index = RevisionIndex::parse(&store).unwrap();
604 let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0;569 let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0;
605 let cache = Replica::create(&path, &source).unwrap();570 let cache = Replica::create(&path, &source).unwrap();
606 match operation {571 let section = |op| {
607 "Page" => {572 server::section_op(&cache, op);
608 model_ops::save(&cache, oid, |page| {573 };
609 model_ops::replace_text(page, oid, 0..0, "New ")574 match kind {
610 })575 "Text" => {
611 .unwrap()576 cache.apply("Author", typed(space, oid, "New ")).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 }577 }
578 "Create" => section(SectionOp::Create(
579 onestore::PageCreation::new(None, Some("Created"), "Author").unwrap(),
580 )),
581 "Pages" => section(SectionOp::Pages(vec![
582 onestore::PageEdit::set_level(sid, 2).unwrap(),
583 ])),
584 _ => section(SectionOp::Delete(vec![sid])),
627 }585 }
628 drop(cache);586 drop(cache);
629 let db = rusqlite::Connection::open(&path).unwrap();587 let encoded: String = rusqlite::Connection::open(&path)
630 let encoded: String = db588 .unwrap()
631 .query_row("SELECT operation FROM edits", [], |r| r.get(0))589 .query_row("SELECT edit FROM edits", [], |r| r.get(0))
632 .unwrap();590 .unwrap();
633 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();591 for target in ["", "/ops/0", "/ops/0/Page/op/Text", "/ops/0/Section"] {
634 if value[operation].is_object() {592 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
635 value[operation]["future_option"] = true.into();593 let Some(object) = value.pointer_mut(target).and_then(|v| v.as_object_mut()) else {
636 } else {594 continue;
637 value["future_option"] = true.into();595 };
596 object.insert("future_option".into(), true.into());
597 rusqlite::Connection::open(&path)
598 .unwrap()
599 .execute("UPDATE edits SET edit=?1", [value.to_string()])
600 .unwrap();
601 let before = fs::read(&path).unwrap();
602 assert!(
603 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData),
604 "{kind} {target}"
605 );
606 assert_eq!(fs::read(&path).unwrap(), before);
638 }607 }
639 db.execute("UPDATE edits SET operation=?1", [value.to_string()])
640 .unwrap();
641 drop(db);
642 let before = fs::read(&path).unwrap();
643 assert!(
644 matches!(Replica::open(&path),Err(Error::Io(error))if error.kind()==ErrorKind::InvalidData),
645 "{operation}"
646 );
647 assert_eq!(fs::read(&path).unwrap(), before);
648 }608 }
649}609}
crates/notebook/tests/migrate.rs created+384
...@@ -0,0 +1,384 @@
1//! Converting schema-14 caches: whole-page edits become ops, verified page by page against
2//! the old working image, with a recovery archive first and nothing changed on a mismatch.
3
4use notebook::{ConflictKind, EditStatus, Replica, Resolution};
5use onestore::{ExGuid, PageCreation, PageEdit, PreparedEdit, page::Page};
6use rusqlite::{Connection, params};
7use serde_json::json;
8use std::path::Path;
9
10#[path = "support/server.rs"]
11mod server;
12use server::*;
13#[path = "support/model_ops.rs"]
14mod model_ops;
15
16const SOURCE: &[u8] = include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one");
17
18/// The body pages of the fixture with their first body text.
19fn body_pages(source: &[u8]) -> Vec<(ExGuid, ExGuid)> {
20 pages(source)
21 .into_iter()
22 .filter_map(|(space, page)| {
23 let text = page.objects.iter().find_map(|object| match object {
24 onestore::page::PageObject::Outline(outline) => {
25 outline.paragraphs.first()?.text().map(|text| text.id)
26 }
27 _ => None,
28 })?;
29 Some((space, text))
30 })
31 .collect()
32}
33
34/// A schema-14 queue entry.
35enum Queued {
36 Page(ExGuid, Page),
37 Create(PageCreation),
38 Pages(Vec<PageEdit>),
39 Delete(Vec<ExGuid>),
40}
41
42/// Writes a schema-14 cache holding `queue` on `base`, as that schema stored it; returns
43/// the working image.
44fn v14(path: &Path, base: &[u8], queue: &[Queued], working: Option<&[u8]>) -> Vec<u8> {
45 let mut image = base.to_vec();
46 let mut edits = Vec::new();
47 for entry in queue {
48 let (space, operation, next) = match entry {
49 Queued::Page(space, after) => {
50 let before = model_ops::page_of(&image, *space);
51 let next = PreparedEdit::page(&image, *space, after, "Author").unwrap();
52 (
53 *space,
54 json!({"Page": {"before": before, "after": after, "author": "Author"}}),
55 next.as_bytes().to_vec(),
56 )
57 }
58 Queued::Create(creation) => (
59 creation.space(),
60 json!({"CreatePage": creation}),
61 PreparedEdit::create_page(&image, creation)
62 .unwrap()
63 .as_bytes()
64 .to_vec(),
65 ),
66 Queued::Pages(batch) => {
67 let observed: Vec<_> = pages(&image)
68 .into_iter()
69 .map(|(space, _)| (space, 1))
70 .collect();
71 (
72 root(&image),
73 json!({"Pages": {"edits": batch, "observed": observed}}),
74 PreparedEdit::pages(&image, batch)
75 .unwrap()
76 .as_bytes()
77 .to_vec(),
78 )
79 }
80 Queued::Delete(spaces) => (
81 root(&image),
82 json!({"DeletePages": spaces}),
83 PreparedEdit::delete_pages_permanently(&image, spaces)
84 .unwrap()
85 .as_bytes()
86 .to_vec(),
87 ),
88 };
89 edits.push((space, operation));
90 image = next;
91 }
92 let working = working.map_or(image, <[u8]>::to_vec);
93 std::fs::File::create_new(path).unwrap();
94 let connection = Connection::open(path).unwrap();
95 connection
96 .execute_batch(
97 "PRAGMA application_id=1330529615; PRAGMA user_version=14;
98 CREATE TABLE replica (id INTEGER PRIMARY KEY CHECK(id=1), base BLOB NOT NULL, working BLOB NOT NULL) STRICT;
99 CREATE TABLE edits (id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, operation TEXT NOT NULL) STRICT;
100 CREATE TABLE attempt (id INTEGER PRIMARY KEY CHECK(id=1), edit_id INTEGER NOT NULL UNIQUE REFERENCES edits(id) ON DELETE CASCADE, revisions TEXT NOT NULL) STRICT;
101 CREATE TABLE receipts (edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), revision TEXT NOT NULL) STRICT;
102 CREATE TABLE archived (edit_id INTEGER PRIMARY KEY CHECK(edit_id>0), archive TEXT NOT NULL) STRICT;
103 CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3)) STRICT;
104 CREATE TABLE assets (name TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL, sha256 BLOB NOT NULL CHECK(length(sha256)=32)) STRICT;",
105 )
106 .unwrap();
107 // Schema 14 kept the working image as its length and the runs differing from the base.
108 let mut patch = (working.len() as u64).to_le_bytes().to_vec();
109 patch.extend_from_slice(&0_u64.to_le_bytes());
110 patch.extend_from_slice(&(working.len() as u64).to_le_bytes());
111 patch.extend_from_slice(&working);
112 connection
113 .execute(
114 "INSERT INTO replica VALUES (1, ?1, ?2)",
115 params![base, patch],
116 )
117 .unwrap();
118 connection
119 .execute(
120 "INSERT INTO receipts VALUES (1, ?1)",
121 [ExGuid {
122 guid: [7; 16],
123 n: 1,
124 }
125 .to_string()],
126 )
127 .unwrap();
128 connection
129 .execute(
130 "INSERT INTO sqlite_sequence(name, seq) VALUES ('edits', 1)",
131 [],
132 )
133 .unwrap();
134 for (space, operation) in edits {
135 connection
136 .execute(
137 "INSERT INTO edits(space, operation) VALUES (?1, ?2)",
138 params![space.to_string(), operation.to_string()],
139 )
140 .unwrap();
141 }
142 working
143}
144
145fn root(image: &[u8]) -> ExGuid {
146 onestore::RevisionIndex::parse(&onestore::Store::parse(image).unwrap())
147 .unwrap()
148 .root
149}
150
151fn appended(source: &[u8], space: ExGuid, text: ExGuid, suffix: &str) -> Page {
152 let mut page = model_ops::page_of(source, space);
153 let end = model_ops::paragraph_with(&page, text)
154 .unwrap()
155 .text()
156 .unwrap()
157 .text
158 .text()
159 .encode_utf16()
160 .count() as u32;
161 model_ops::replace_text(&mut page, text, end..end, suffix);
162 page
163}
164
165fn archive(path: &Path) -> std::path::PathBuf {
166 let mut archive = path.as_os_str().to_owned();
167 archive.push(".v14-recovery");
168 archive.into()
169}
170
171#[test]
172fn a_schema_14_queue_converts_to_ops_that_reach_its_working_pages_and_publish() {
173 let body = body_pages(SOURCE);
174 let ((a, a_text), (b, b_text)) = (body[0], body[1]);
175 let directory = tempfile::tempdir().unwrap();
176 let path = directory.path().join("cache.sqlite");
177 let creation = PageCreation::new(None, Some("Created offline"), "Author").unwrap();
178 let first = appended(SOURCE, a, a_text, " one");
179 let working = v14(
180 &path,
181 SOURCE,
182 &[
183 Queued::Page(a, first.clone()),
184 Queued::Page(b, appended(SOURCE, b, b_text, " other")),
185 Queued::Create(creation.clone()),
186 Queued::Pages(vec![PageEdit::move_to(b, Some(a), 1).unwrap()]),
187 Queued::Page(
188 a,
189 appended(
190 PreparedEdit::page(SOURCE, a, &first, "Author")
191 .unwrap()
192 .as_bytes(),
193 a,
194 a_text,
195 " two",
196 ),
197 ),
198 Queued::Delete(vec![body[2].0]),
199 ],
200 None,
201 );
202 let cache = Replica::open(&path).unwrap();
203 assert!(archive(&path).exists());
204 let pending = cache.pending().unwrap();
205 assert_eq!(
206 pending.iter().map(|edit| edit.id).collect::<Vec<_>>(),
207 [2, 3, 4, 5, 6, 7]
208 );
209 assert!(pending.iter().all(|edit| !edit.edit.ops.is_empty()));
210 assert_eq!(
211 cache.status(1).unwrap(),
212 Some(EditStatus::Published {
213 revision: ExGuid {
214 guid: [7; 16],
215 n: 1
216 }
217 })
218 );
219 let local = pages(&cache.snapshot().unwrap());
220 assert_eq!(local, pages(&working));
221 drop(cache);
222 // A converted cache opens as it is.
223 let cache = Replica::open(&path).unwrap();
224 assert_eq!(pages(&cache.snapshot().unwrap()), local);
225 let next = cache
226 .apply(
227 "Author",
228 onestore::op::Edit {
229 at: 133_000_000_000_000_000,
230 ops: vec![onestore::op::Op::Page {
231 space: b,
232 op: onestore::op::PageOp::Text {
233 text: b_text,
234 range: 0..0,
235 with: " three".into(),
236 },
237 }],
238 },
239 )
240 .unwrap();
241 assert!(next > 7);
242 let mut server = Server::new(SOURCE);
243 assert!(matches!(
244 cache.sync_once(&mut server).unwrap().edit,
245 Some((id, EditStatus::Published { .. })) if id == next
246 ));
247 let published = pages(&server.durable);
248 assert_eq!(published, pages(&cache.snapshot().unwrap()));
249 assert!(
250 published
251 .iter()
252 .any(|(space, _)| *space == creation.space())
253 );
254 assert!(published.iter().all(|(space, _)| *space != body[2].0));
255}
256
257#[test]
258fn an_uncertain_attempt_and_a_conflict_keep_their_states() {
259 let body = body_pages(SOURCE);
260 let (a, a_text) = body[0];
261 let directory = tempfile::tempdir().unwrap();
262
263 // The oldest edit was attempted: its schema-14 evidence names the revision it published.
264 let path = directory.path().join("attempted.sqlite");
265 let after = appended(SOURCE, a, a_text, " attempted");
266 let published = PreparedEdit::page(SOURCE, a, &after, "Author").unwrap();
267 let revision =
268 onestore::RevisionIndex::parse(&onestore::Store::parse(published.as_bytes()).unwrap())
269 .unwrap()
270 .active(a)
271 .unwrap();
272 v14(&path, SOURCE, &[Queued::Page(a, after.clone())], None);
273 Connection::open(&path)
274 .unwrap()
275 .execute(
276 "INSERT INTO attempt VALUES (1, 2, ?1)",
277 [json!({a.to_string(): revision.to_string()}).to_string()],
278 )
279 .unwrap();
280 let cache = Replica::open(&path).unwrap();
281 let awaiting = EditStatus::AwaitingConfirmation { revision };
282 assert_eq!(cache.status(2).unwrap(), Some(awaiting.clone()));
283 let mut unchanged = Server::new(SOURCE);
284 assert_eq!(
285 cache.sync_once(&mut unchanged).unwrap().edit,
286 Some((2, awaiting))
287 );
288 assert_eq!(unchanged.publications, 0);
289 let mut landed = Server::new(published.as_bytes());
290 assert_eq!(
291 cache.sync_once(&mut landed).unwrap().edit,
292 Some((2, EditStatus::Published { revision }))
293 );
294 assert_eq!(landed.publications, 0);
295 drop(cache);
296
297 // The oldest edit was in conflict: the conversion lowered it onto the remote page the
298 // conflict was recorded against, and keeping mine publishes it.
299 let path = directory.path().join("conflicted.sqlite");
300 let remote = PreparedEdit::page(SOURCE, a, &appended(SOURCE, a, a_text, " remote"), "Native")
301 .unwrap()
302 .as_bytes()
303 .to_vec();
304 let local = appended(SOURCE, a, a_text, " local");
305 let working = PreparedEdit::page(SOURCE, a, &local, "Author")
306 .unwrap()
307 .as_bytes()
308 .to_vec();
309 {
310 // Schema 14 stored the remote as the base and kept the local working image.
311 v14(&path, &remote, &[], Some(&working));
312 let connection = Connection::open(&path).unwrap();
313 connection
314 .execute(
315 "INSERT INTO edits(space, operation) VALUES (?1, ?2)",
316 params![
317 a.to_string(),
318 json!({"Page": {"before": model_ops::page_of(SOURCE, a), "after": local, "author": "Author"}}).to_string()
319 ],
320 )
321 .unwrap();
322 connection
323 .execute("INSERT INTO conflicts VALUES (2, 3)", [])
324 .unwrap();
325 }
326 let cache = Replica::open(&path).unwrap();
327 assert_eq!(
328 cache.status(2).unwrap(),
329 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
330 );
331 assert_eq!(
332 model_ops::page_of(&cache.snapshot().unwrap(), a),
333 model_ops::page_of(&working, a)
334 );
335 let mut server = Server::new(&remote);
336 assert_eq!(
337 cache.sync_once(&mut server).unwrap().edit,
338 Some((2, EditStatus::Conflict(ConflictKind::ContentChanged)))
339 );
340 cache.resolve(2, Resolution::Mine).unwrap();
341 assert!(matches!(
342 cache.sync_once(&mut server).unwrap().edit,
343 Some((2, EditStatus::Published { .. }))
344 ));
345 assert_eq!(
346 model_ops::page_of(&server.durable, a),
347 model_ops::page_of(&working, a)
348 );
349}
350
351#[test]
352fn a_page_the_conversion_cannot_reproduce_leaves_the_cache_untouched() {
353 let body = body_pages(SOURCE);
354 let (a, a_text) = body[0];
355 let directory = tempfile::tempdir().unwrap();
356 let path = directory.path().join("cache.sqlite");
357 // The working image holds a page the queued edit does not reach.
358 let stray = PreparedEdit::page(SOURCE, a, &appended(SOURCE, a, a_text, " stray"), "Author")
359 .unwrap()
360 .as_bytes()
361 .to_vec();
362 v14(
363 &path,
364 SOURCE,
365 &[Queued::Page(a, appended(SOURCE, a, a_text, " queued"))],
366 Some(&stray),
367 );
368 let before = std::fs::read(&path).unwrap();
369 let Err(notebook::Error::Io(error)) = Replica::open(&path) else {
370 panic!("the conversion must fail")
371 };
372 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
373 assert!(error.to_string().contains(".v14-recovery"), "{error}");
374 assert_eq!(std::fs::read(&path).unwrap(), before);
375 assert!(archive(&path).exists());
376 let version: u32 = Connection::open(archive(&path))
377 .unwrap()
378 .pragma_query_value(None, "user_version", |row| row.get(0))
379 .unwrap();
380 assert_eq!(version, 14);
381 // A second open tries again from the same untouched cache.
382 assert!(Replica::open(&path).is_err());
383 assert_eq!(std::fs::read(&path).unwrap(), before);
384}
crates/notebook/tests/session.rs+26-15
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1use notebook::{1use notebook::{
2 ConflictKind, EditStatus, Recovery,2 ConflictKind, EditStatus, Recovery, Resolution,
3 session::{Event, Notebook, QueuedEdit, Save, Section},3 session::{Event, Notebook, QueuedEdit, Save, Section},
4};4};
5use onestore::{ExGuid, PreparedEdit, page::Page};5use onestore::{ExGuid, PreparedEdit, page::Page};
...@@ -62,10 +62,14 @@ fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) {...@@ -62,10 +62,14 @@ fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) {
62}62}
6363
64fn published(section: &Section, id: u64) {64fn published(section: &Section, id: u64) {
65 wait(65 let deadline = Instant::now() + Duration::from_secs(20);
66 section,66 while !matches!(
67 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n == id),67 section.status(id).unwrap(),
68 );68 Some(EditStatus::Published { .. })
69 ) {
70 assert!(Instant::now() < deadline, "the edit was not published");
71 std::thread::sleep(Duration::from_millis(20));
72 }
69}73}
7074
71fn stored_page(file: &Path, space: ExGuid) -> Page {75fn stored_page(file: &Path, space: ExGuid) -> Page {
...@@ -79,6 +83,13 @@ fn assert_same(actual: Page, expected: &Page) {...@@ -79,6 +83,13 @@ fn assert_same(actual: Page, expected: &Page) {
79 assert_eq!(actual, expected);83 assert_eq!(actual, expected);
80}84}
8185
86#[test]
87fn a_section_is_shared_between_threads() {
88 fn shared<T: Send + Sync>() {}
89 shared::<Section>();
90 shared::<notebook::Replica>();
91}
92
82#[test]93#[test]
83fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() {94fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() {
84 let directory = tempfile::tempdir().unwrap();95 let directory = tempfile::tempdir().unwrap();
...@@ -765,7 +776,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov...@@ -765,7 +776,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov
765mod server;776mod server;
766777
767/// An uncertain attempt survives a restart as `AwaitingConfirmation`; after exporting the778/// An uncertain attempt survives a restart as `AwaitingConfirmation`; after exporting the
768/// archive the user either continues from a reviewed page or abandons the branch. Neither779/// archive the user either publishes the local edits again or abandons the branch. Neither
769/// path records a receipt for the uncertain attempt.780/// path records a receipt for the uncertain attempt.
770#[test]781#[test]
771fn an_uncertain_attempt_is_released_after_restart_by_review() {782fn an_uncertain_attempt_is_released_after_restart_by_review() {
...@@ -778,10 +789,8 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {...@@ -778,10 +789,8 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
778 let replica = notebook::Replica::create(&cache, &source).unwrap();789 let replica = notebook::Replica::create(&cache, &source).unwrap();
779 let space = space_of(&source);790 let space = space_of(&source);
780 let page = model_ops::page_of(&source, space);791 let page = model_ops::page_of(&source, space);
781 let text = first_text(&page);
782 let local = edited(&page, "Uncertain ");792 let local = edited(&page, "Uncertain ");
783 let id = replica793 let id = model_ops::save_as(&replica, space, &local, "Author")
784 .save(&source, space, &local, "Author")
785 .unwrap()794 .unwrap()
786 .unwrap();795 .unwrap();
787 let mut faulty = server::Server::new(&source);796 let mut faulty = server::Server::new(&source);
...@@ -815,14 +824,12 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {...@@ -815,14 +824,12 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
815 assert_same(section.page(space).unwrap(), &local);824 assert_same(section.page(space).unwrap(), &local);
816 let archive = directory.path().join("review.sqlite");825 let archive = directory.path().join("review.sqlite");
817 if continued {826 if continued {
818 let mut reviewed = section.remote_page(space).unwrap();827 section.release(id, &archive, Resolution::Mine).unwrap();
819 model_ops::replace_text(&mut reviewed, text, 0..0, "Reviewed ");
820 section.release(id, &archive, Some(&reviewed)).unwrap();
821 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));828 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
822 published(&section, id);829 published(&section, id);
823 assert_same(stored_page(&file, space), &reviewed);830 assert_same(stored_page(&file, space), &local);
824 } else {831 } else {
825 section.release(id, &archive, None).unwrap();832 section.release(id, &archive, Resolution::Theirs).unwrap();
826 assert_eq!(833 assert_eq!(
827 section.status(id).unwrap(),834 section.status(id).unwrap(),
828 Some(EditStatus::Archived {835 Some(EditStatus::Archived {
...@@ -841,7 +848,11 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {...@@ -841,7 +848,11 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
841 assert!(section.queue().unwrap().is_empty());848 assert!(section.queue().unwrap().is_empty());
842 assert!(849 assert!(
843 section850 section
844 .release(id, directory.path().join("again.sqlite"), None)851 .release(
852 id,
853 directory.path().join("again.sqlite"),
854 Resolution::Theirs
855 )
845 .is_err()856 .is_err()
846 );857 );
847 section.close().unwrap();858 section.close().unwrap();
crates/notebook/tests/smb_queue.rs created+249
...@@ -0,0 +1,249 @@
1//! The op queue against a disposable Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share
2//! `agent`): publications read only the file's header, a native change is read once and
3//! merged, and a session edits through the embedded client.
4#![cfg(feature = "smb")]
5
6use notebook::{
7 EditStatus, Remote, Replica, SmbRemote,
8 session::{Event, Section},
9 smb::{Client, Credentials},
10};
11use onestore::{
12 CommitError, ExGuid, Stamp, Transaction,
13 op::{Edit, Op, PageOp},
14};
15use std::{
16 io,
17 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
18};
19
20fn client() -> Client {
21 Client::connect(
22 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
23 "agent",
24 Credentials::default(),
25 Duration::from_secs(5),
26 )
27 .unwrap()
28}
29
30fn unique(name: &str) -> String {
31 format!(
32 "{name}-{}.one",
33 SystemTime::now()
34 .duration_since(UNIX_EPOCH)
35 .unwrap()
36 .as_nanos()
37 )
38}
39
40/// The remote, counting whole-file reads.
41struct Counted {
42 remote: SmbRemote,
43 reads: usize,
44 stamps: usize,
45}
46
47impl Remote for Counted {
48 fn read(&mut self) -> io::Result<Vec<u8>> {
49 self.reads += 1;
50 self.remote.read()
51 }
52 fn stamp(&mut self) -> io::Result<Option<Stamp>> {
53 self.stamps += 1;
54 self.remote.stamp()
55 }
56 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
57 self.remote.publish(transaction)
58 }
59 fn confirm(&mut self, snapshot: &[u8]) -> Result<(), CommitError> {
60 self.remote.confirm(snapshot)
61 }
62}
63
64/// The first page's space and first body text.
65fn target(replica: &Replica) -> (ExGuid, ExGuid) {
66 let space = replica.pages().unwrap()[0].0;
67 let text = replica
68 .page(space)
69 .unwrap()
70 .objects
71 .iter()
72 .find_map(|object| match object {
73 onestore::page::PageObject::Outline(outline) => outline
74 .paragraphs
75 .iter()
76 .find_map(|p| p.text().map(|t| t.id)),
77 _ => None,
78 })
79 .unwrap();
80 (space, text)
81}
82
83fn typed(space: ExGuid, text: ExGuid, at: u32, with: &str) -> Edit {
84 Edit {
85 at: 133_000_000_000_000_000,
86 ops: vec![Op::Page {
87 space,
88 op: PageOp::Text {
89 text,
90 range: at..at,
91 with: with.into(),
92 },
93 }],
94 }
95}
96
97fn text_of(replica: &Replica, space: ExGuid, text: ExGuid) -> String {
98 let page = replica.page(space).unwrap();
99 page.objects
100 .iter()
101 .find_map(|object| match object {
102 onestore::page::PageObject::Outline(outline) => outline
103 .paragraphs
104 .iter()
105 .find_map(|p| p.text().filter(|t| t.id == text)),
106 _ => None,
107 })
108 .unwrap()
109 .text
110 .text()
111 .to_owned()
112}
113
114#[test]
115#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
116fn live_keystrokes_publish_reading_only_the_header_and_a_native_change_merges() {
117 let lab = client();
118 let path = unique("queue");
119 let source = onestore::create_section(&path, "Body", "Author").unwrap();
120 lab.create(&path, &source).unwrap();
121 let directory = tempfile::tempdir().unwrap();
122 let replica = Replica::create(directory.path().join("cache.sqlite"), &source).unwrap();
123 let (space, text) = target(&replica);
124 let mut remote = Counted {
125 remote: SmbRemote::new(client(), path.clone(), 1 << 24),
126 reads: 0,
127 stamps: 0,
128 };
129 for n in 0..20 {
130 let at = 4 + n;
131 let id = replica
132 .apply("Author", typed(space, text, at, "x"))
133 .unwrap();
134 assert!(matches!(
135 replica.sync_once(&mut remote).unwrap().edit,
136 Some((published, EditStatus::Published { .. })) if published == id
137 ));
138 }
139 assert_eq!(remote.reads, 0, "publications read only the header");
140 assert_eq!(remote.stamps, 20);
141 assert_eq!(
142 lab.read(&path, 1 << 24).unwrap(),
143 replica.snapshot().unwrap()
144 );
145 for _ in 0..5 {
146 assert_eq!(replica.sync_once(&mut remote).unwrap().edit, None);
147 }
148 assert_eq!(remote.reads, 0, "idle polls read only the header");
149
150 // Another writer types at the start of the same text; a local edit at its end merges.
151 let native = {
152 let arena = onestore::Arena::default();
153 let mut section =
154 onestore::Section::open(&arena, lab.read(&path, 1 << 24).unwrap()).unwrap();
155 section
156 .apply("Native", &typed(space, text, 0, "Native "))
157 .unwrap();
158 section.seal().unwrap().unwrap()
159 };
160 lab.commit_transaction(&path, &native).unwrap();
161 let end = text_of(&replica, space, text).encode_utf16().count() as u32;
162 let id = replica
163 .apply("Author", typed(space, text, end, " local"))
164 .unwrap();
165 let synced = replica.sync_once(&mut remote).unwrap();
166 assert_eq!(synced.changed, [space]);
167 assert!(
168 matches!(synced.edit, Some((published, EditStatus::Published { .. })) if published == id)
169 );
170 assert_eq!(remote.reads, 1, "a changed remote is read once");
171 let merged = text_of(&replica, space, text);
172 assert!(merged.starts_with("Native Body"), "{merged}");
173 assert!(merged.ends_with(" local"), "{merged}");
174 assert_eq!(
175 lab.read(&path, 1 << 24).unwrap(),
176 replica.snapshot().unwrap()
177 );
178 lab.delete(&path).unwrap();
179}
180
181#[test]
182#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
183fn live_session_applies_edits_through_the_embedded_client_and_reopens() {
184 let lab = client();
185 let path = unique("session");
186 let source = onestore::create_section(&path, "Session", "Author").unwrap();
187 lab.create(&path, &source).unwrap();
188 let directory = tempfile::tempdir().unwrap();
189 let cache = directory.path().join("cache.sqlite");
190 let section = Section::resume_smb(
191 path.clone(),
192 Replica::create(&cache, &source).unwrap(),
193 1 << 24,
194 || {
195 Client::connect(
196 &std::env::var("ONESTORE_SMB_LAB").unwrap(),
197 "agent",
198 Credentials::default(),
199 Duration::from_secs(5),
200 )
201 },
202 || {},
203 )
204 .unwrap();
205 let (space, text) = target(section.replica());
206 for n in 0..10 {
207 section
208 .apply("Author", typed(space, text, n, &n.to_string()))
209 .unwrap();
210 }
211 // Applying does not wait: the edits are done when the file holds them.
212 let remote_text = |bytes: Vec<u8>| {
213 let arena = onestore::Arena::default();
214 let section = onestore::Section::open(&arena, bytes).unwrap();
215 let page = section.page(space).unwrap();
216 page.objects
217 .iter()
218 .find_map(|object| match object {
219 onestore::page::PageObject::Outline(outline) => {
220 outline.paragraphs.iter().find_map(|p| {
221 p.text()
222 .filter(|t| t.id == text)
223 .map(|t| t.text.text().to_owned())
224 })
225 }
226 _ => None,
227 })
228 .unwrap()
229 };
230 let deadline = Instant::now() + Duration::from_secs(30);
231 while remote_text(lab.read(&path, 1 << 24).unwrap()) != "0123456789Session"
232 || !section.pending().unwrap().is_empty()
233 {
234 assert!(Instant::now() < deadline, "the edits were not published");
235 for event in section.events() {
236 assert!(
237 !matches!(event, Event::Rejected { .. } | Event::Failed(_)),
238 "{event:?}"
239 );
240 }
241 std::thread::sleep(Duration::from_millis(50));
242 }
243 let published = lab.read(&path, 1 << 24).unwrap();
244 section.close().unwrap();
245 let replica = Replica::open(&cache).unwrap();
246 assert_eq!(text_of(&replica, space, text), "0123456789Session");
247 assert_eq!(replica.snapshot().unwrap(), published);
248 lab.delete(&path).unwrap();
249}
crates/notebook/tests/support/model_ops.rs+63-12
...@@ -225,28 +225,79 @@ pub fn delete_paragraph(page: &mut Page, text: ExGuid) {...@@ -225,28 +225,79 @@ pub fn delete_paragraph(page: &mut Page, text: ExGuid) {
225 panic!("text is on the page");225 panic!("text is on the page");
226}226}
227227
228/// Saves `edit` applied to the page containing `text`, using the replica's current image.228/// FILETIME now.
229pub fn now() -> u64 {
230 let unix = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap();
233 (unix.as_secs() + 11_644_473_600) * 10_000_000
234}
235
236/// Queues the ops taking the page in `space` to `after`, or nothing when they are equal.
237pub fn save_as(
238 cache: &Replica,
239 space: ExGuid,
240 after: &Page,
241 author: &str,
242) -> Result<Option<u64>, notebook::Error> {
243 save_from(cache, space, &cache.page(space)?, after, author)
244}
245
246/// Queues the ops taking `before`, the page in `space` as it was read, to `after`.
247pub fn save_from(
248 cache: &Replica,
249 space: ExGuid,
250 before: &Page,
251 after: &Page,
252 author: &str,
253) -> Result<Option<u64>, notebook::Error> {
254 let ops = onestore::op::lower_page(before, after)?;
255 if ops.is_empty() {
256 return Ok(None);
257 }
258 cache
259 .apply(
260 author,
261 onestore::op::Edit {
262 at: now(),
263 ops: ops
264 .into_iter()
265 .map(|op| onestore::op::Op::Page { space, op })
266 .collect(),
267 },
268 )
269 .map(Some)
270}
271
272/// Queues `edit` of the page in `space`.
273pub fn save_page(
274 cache: &Replica,
275 space: ExGuid,
276 edit: impl FnOnce(&mut Page),
277) -> Result<Option<u64>, notebook::Error> {
278 let before = cache.page(space)?;
279 let mut after = before.clone();
280 edit(&mut after);
281 save_from(cache, space, &before, &after, AUTHOR)
282}
283
284/// Queues `edit` of the page containing `text`.
229pub fn save(285pub fn save(
230 cache: &Replica,286 cache: &Replica,
231 text: ExGuid,287 text: ExGuid,
232 edit: impl FnOnce(&mut Page),288 edit: impl FnOnce(&mut Page),
233) -> Result<Option<u64>, notebook::Error> {289) -> Result<Option<u64>, notebook::Error> {
234 let source = cache.snapshot()?;290 let (space, _) = locate(&cache.snapshot()?, text);
235 let (space, mut page) = locate(&source, text);291 save_page(cache, space, edit)
236 edit(&mut page);
237 cache.save(&source, space, &page, AUTHOR)
238}292}
239293
240/// Reviews a conflicting save by applying `edit` to the current remote page.294/// Resolves a conflict by taking the remote page, then queues `edit` of it.
241pub fn review(295pub fn review(
242 cache: &Replica,296 cache: &Replica,
243 id: u64,297 id: u64,
244 text: ExGuid,298 text: ExGuid,
245 edit: impl FnOnce(&mut Page),299 edit: impl FnOnce(&mut Page),
246) -> Result<(), notebook::Error> {300) -> Result<Option<u64>, notebook::Error> {
247 let local = cache.snapshot()?;301 cache.resolve(id, notebook::Resolution::Theirs)?;
248 let remote = cache.remote_snapshot()?;302 save(cache, text, edit)
249 let (_, mut page) = locate(&remote, text);
250 edit(&mut page);
251 cache.review_page(id, &local, &remote, &page)
252}303}
crates/notebook/tests/support/model_schedule.rs+66-79
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
33
4use crate::model_ops::{self, AUTHOR};4use crate::model_ops::{self, AUTHOR};
5use crate::server::{Fault, Server};5use crate::server::{Fault, Server};
6use notebook::{EditStatus, Operation, Remote, Replica};6use notebook::{EditStatus, Remote, Replica, Resolution};
7use onestore::{7use onestore::{
8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction,8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction,
9 document::Document,9 document::Document,
...@@ -126,11 +126,10 @@ pub(crate) fn move_subtree(...@@ -126,11 +126,10 @@ pub(crate) fn move_subtree(
126 }126 }
127}127}
128128
129/// Asserts the publication invariants that survive the page model, then delegates.129/// Asserts the publication invariants, then delegates.
130struct Session<'a> {130struct Session<'a> {
131 server: &'a mut Server,131 server: &'a mut Server,
132 intent: Option<(u64, Operation)>,132 /// The head batch was already attempted, so its publication must never be repeated.
133 /// The intent was already attempted, so its publication must never be repeated.
134 retired: bool,133 retired: bool,
135 publications: usize,134 publications: usize,
136}135}
...@@ -144,18 +143,6 @@ impl Remote for Session<'_> {...@@ -144,18 +143,6 @@ impl Remote for Session<'_> {
144 assert!(!self.retired, "a retired attempt was replayed");143 assert!(!self.retired, "a retired attempt was replayed");
145 self.publications += 1;144 self.publications += 1;
146 assert_eq!(self.publications, 1);145 assert_eq!(self.publications, 1);
147 let Some((_, Operation::Page(intent))) = &self.intent else {
148 panic!("the schedule queues page saves only")
149 };
150 let mut image = self.server.visible.clone();
151 if transaction.apply(&mut image).is_ok()
152 && model_ops::page_of(&self.server.visible, SOURCE.1) == intent.before
153 {
154 assert_eq!(
155 shape(&model_ops::page_of(&image, SOURCE.1)),
156 shape(&intent.after)
157 );
158 }
159 self.server.publish(transaction)146 self.server.publish(transaction)
160 }147 }
161148
...@@ -164,6 +151,11 @@ impl Remote for Session<'_> {...@@ -164,6 +151,11 @@ impl Remote for Session<'_> {
164 }151 }
165}152}
166153
154/// The page's shape as the replica's queue leaves it.
155fn local(cache: &Replica) -> Vec<String> {
156 shape(&cache.page(SOURCE.1).unwrap())
157}
158
167fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> {159fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> {
168 cache160 cache
169 .pending()161 .pending()
...@@ -190,12 +182,13 @@ pub fn run(input: &[u8]) {...@@ -190,12 +182,13 @@ pub fn run(input: &[u8]) {
190 let path = directory.path().join(format!("{actor}.sqlite"));182 let path = directory.path().join(format!("{actor}.sqlite"));
191 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());183 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());
192 let snapshot = cache.snapshot().unwrap();184 let snapshot = cache.snapshot().unwrap();
185 let before = local(cache);
193 let pending = cache.pending().unwrap();186 let pending = cache.pending().unwrap();
194 let local = rows(&snapshot);187 let rows_now = rows(&snapshot);
195 match step[1] % 8 {188 match step[1] % 8 {
196 0..=2 => {189 0..=2 => {
197 let row = local[usize::from(step[2]) % local.len()].clone();190 let row = rows_now[usize::from(step[2]) % rows_now.len()].clone();
198 let other = local[usize::from(step[4]) % local.len()].clone();191 let other = rows_now[usize::from(step[4]) % rows_now.len()].clone();
199 let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 {192 let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 {
200 0 if !row.2.is_empty() => Box::new(move |page| {193 0 if !row.2.is_empty() => Box::new(move |page| {
201 model_ops::replace_text(194 model_ops::replace_text(
...@@ -229,24 +222,20 @@ pub fn run(input: &[u8]) {...@@ -229,24 +222,20 @@ pub fn run(input: &[u8]) {
229 }),222 }),
230 _ => Box::new(|_| {}),223 _ => Box::new(|_| {}),
231 };224 };
232 let mut after = model_ops::page_of(&snapshot, *space);225 let mut after = cache.page(*space).unwrap();
233 change(&mut after);226 change(&mut after);
234 match cache.save(&snapshot, *space, &after, AUTHOR) {227 match model_ops::save_as(cache, *space, &after, AUTHOR) {
235 Ok(id) => {228 Ok(id) => {
236 let next = cache.pending().unwrap();229 let next = cache.pending().unwrap();
237 let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> {230 let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> {
238 edits.iter().map(|edit| edit.id).collect()231 edits.iter().map(|edit| edit.id).collect()
239 };232 };
240 assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]);233 assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]);
241 assert!(next.len() <= pending.len() + 1);234 assert_eq!(next.len(), pending.len() + usize::from(id.is_some()));
242 assert_eq!(id.is_some(), cache.snapshot().unwrap() != snapshot);235 assert_eq!(local(cache), shape(&after));
243 assert_eq!(
244 shape(&model_ops::page_of(&cache.snapshot().unwrap(), *space)),
245 shape(&after)
246 );
247 }236 }
248 Err(_) => {237 Err(_) => {
249 assert_eq!(cache.snapshot().unwrap(), snapshot);238 assert_eq!(local(cache), before);
250 assert_eq!(cache.pending().unwrap(), pending);239 assert_eq!(cache.pending().unwrap(), pending);
251 }240 }
252 }241 }
...@@ -270,9 +259,6 @@ pub fn run(input: &[u8]) {...@@ -270,9 +259,6 @@ pub fn run(input: &[u8]) {
270 Some(EditStatus::AwaitingConfirmation { .. })259 Some(EditStatus::AwaitingConfirmation { .. })
271 )260 )
272 }),261 }),
273 intent: pending
274 .first()
275 .map(|edit| (edit.id, edit.operation.clone())),
276 server: &mut server,262 server: &mut server,
277 publications: 0,263 publications: 0,
278 };264 };
...@@ -280,22 +266,27 @@ pub fn run(input: &[u8]) {...@@ -280,22 +266,27 @@ pub fn run(input: &[u8]) {
280 };266 };
281 server.fault = Fault::None;267 server.fault = Fault::None;
282 let next = cache.pending().unwrap();268 let next = cache.pending().unwrap();
283 if next.len() == pending.len() {269 // Edits leave the queue oldest first, each with a durable receipt.
284 assert_eq!(next, pending);270 let left = pending.len() - next.len();
285 if !next.is_empty() {271 assert!(
286 assert_eq!(cache.snapshot().unwrap(), snapshot);272 next.iter()
287 }273 .all(|edit| pending[left..].iter().any(|kept| kept.id == edit.id))
288 } else {274 );
289 assert_eq!(next, pending[1..]);275 for edit in &pending[..left] {
290 let Some(EditStatus::Published { revision }) =276 let Some(EditStatus::Published { revision }) = cache.status(edit.id).unwrap()
291 cache.status(head.unwrap()).unwrap()
292 else {277 else {
293 panic!("an intent leaves the queue only with its receipt")278 panic!("an edit leaves the queue only with its receipt")
294 };279 };
295 durable(&server, revision);280 durable(&server, revision);
296 }281 }
297 if next.is_empty() && result.is_ok() {282 if next.is_empty() && result.is_ok() {
298 assert_eq!(cache.snapshot().unwrap(), cache.remote_snapshot().unwrap());283 assert_eq!(
284 local(cache),
285 shape(&model_ops::page_of(
286 &cache.remote_snapshot().unwrap(),
287 *space
288 ))
289 );
299 }290 }
300 }291 }
301 5 => {292 5 => {
...@@ -317,44 +308,40 @@ pub fn run(input: &[u8]) {...@@ -317,44 +308,40 @@ pub fn run(input: &[u8]) {
317 }308 }
318 }309 }
319 6 => {310 6 => {
320 if let Some(head) = pending.first()311 if let Some(conflict) = cache.conflict().unwrap() {
321 && matches!(312 let keep = if step[2] & 1 == 0 {
322 cache.status(head.id).unwrap(),313 Resolution::Mine
323 Some(EditStatus::Conflict(_))314 } else {
324 )315 Resolution::Theirs
325 {316 };
326 let remote = cache.remote_snapshot().unwrap();317 let remote = model_ops::page_of(&cache.remote_snapshot().unwrap(), *space);
327 let published = rows(&remote);318 cache.resolve(conflict.id, keep).unwrap();
328 let row = published[usize::from(step[2]) % published.len()].clone();319 match cache.conflict().unwrap() {
329 let mut after = model_ops::page_of(&remote, *space);320 Some(next) => assert_ne!(next.space, conflict.space),
330 if !row.2.is_empty() {321 None => assert_eq!(
331 model_ops::replace_text(&mut after, row.1, 0..1, "R");322 local(cache),
323 match keep {
324 Resolution::Mine => before.clone(),
325 Resolution::Theirs => shape(&remote),
326 }
327 ),
332 }328 }
333 if cache329 let (result, publications) = {
334 .review_page(head.id, &snapshot, &remote, &after)330 let mut session = Session {
335 .is_ok()331 retired: false,
336 {332 server: &mut server,
337 assert_eq!(cache.status(head.id).unwrap(), Some(EditStatus::Pending));333 publications: 0,
338 let (result, publications) = {
339 let mut session = Session {
340 intent: Some((
341 head.id,
342 cache.pending().unwrap()[0].operation.clone(),
343 )),
344 retired: false,
345 server: &mut server,
346 publications: 0,
347 };
348 (cache.sync_once(&mut session), session.publications)
349 };334 };
350 assert!(publications <= 1);335 (cache.sync_once(&mut session), session.publications)
351 if let Ok(Some((id, EditStatus::Published { revision }))) = result {336 };
352 assert_eq!(id, head.id);337 assert!(publications <= 1);
353 durable(&server, revision);338 if let Ok(notebook::Synced {
354 assert!(cache.pending().unwrap().iter().all(|e| e.id != id));339 edit: Some((id, EditStatus::Published { revision })),
355 }340 ..
356 } else {341 }) = result
357 assert_eq!(cache.pending().unwrap(), pending);342 {
343 durable(&server, revision);
344 assert!(cache.pending().unwrap().iter().all(|e| e.id != id));
358 }345 }
359 }346 }
360 }347 }
...@@ -362,7 +349,7 @@ pub fn run(input: &[u8]) {...@@ -362,7 +349,7 @@ pub fn run(input: &[u8]) {
362 let recorded = statuses(cache);349 let recorded = statuses(cache);
363 replicas[actor] = None;350 replicas[actor] = None;
364 let cache = Replica::open(&path).unwrap();351 let cache = Replica::open(&path).unwrap();
365 assert_eq!(cache.snapshot().unwrap(), snapshot);352 assert_eq!(local(&cache), before);
366 assert_eq!(cache.pending().unwrap(), pending);353 assert_eq!(cache.pending().unwrap(), pending);
367 assert_eq!(statuses(&cache), recorded);354 assert_eq!(statuses(&cache), recorded);
368 replicas[actor] = Some(cache);355 replicas[actor] = Some(cache);
crates/notebook/tests/support/page_schedule.rs+55-103
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1use crate::disk;1use crate::{disk, model_ops};
2use notebook::{EditStatus, Operation, Remote, Replica};2use notebook::{EditStatus, Remote, Replica, Resolution};
3use onestore::{3use onestore::{
4 CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, Transaction,4 CommitError, ExGuid, PageEdit, PreparedEdit, RevisionIndex, Store, Transaction,
5 document::{Document, Format, Kind, Layout},5 document::{Document, Format, Kind, Layout},
6 page::{6 page::{
7 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,7 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
...@@ -97,27 +97,9 @@ pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {...@@ -97,27 +97,9 @@ pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {
97 id97 id
98}98}
9999
100/// The text identity of the first body outline's first paragraph.100/// Publishes through the crash-injecting disk, asserting each commit is atomic.
101fn body_text(page: &Page) -> ExGuid {
102 page.objects
103 .iter()
104 .find_map(|object| match object {
105 PageObject::Outline(outline) => outline.paragraphs[0].text().map(|text| text.id),
106 _ => None,
107 })
108 .unwrap()
109}
110
111fn page_of(source: &[u8], space: ExGuid) -> Page {
112 let store = Store::parse(source).unwrap();
113 let index = RevisionIndex::parse(&store).unwrap();
114 let document = Document::parse(&index).unwrap();
115 Page::from_space(&document, space).unwrap()
116}
117
118struct Session<'a> {101struct Session<'a> {
119 disk: &'a mut disk::Disk,102 disk: &'a mut disk::Disk,
120 operation: Option<&'a Operation>,
121}103}
122104
123impl Remote for Session<'_> {105impl Remote for Session<'_> {
...@@ -128,41 +110,6 @@ impl Remote for Session<'_> {...@@ -128,41 +110,6 @@ impl Remote for Session<'_> {
128 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {110 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
129 let mut image = self.disk.visible.clone();111 let mut image = self.disk.visible.clone();
130 transaction.apply(&mut image).unwrap();112 transaction.apply(&mut image).unwrap();
131 let mut expected = pages(&self.disk.visible);
132 match self.operation.unwrap() {
133 Operation::CreatePage(page) => expected.push((page.space(), page.object(), 1)),
134 Operation::Pages(batch) => {
135 for change in &batch.edits {
136 let at = expected.iter().position(|p| p.0 == change.space()).unwrap();
137 expected[at].2 = change.level();
138 if let PagePosition::Before(before) = change.position() {
139 let page = expected.remove(at);
140 let at = before.map_or(expected.len(), |before| {
141 expected.iter().position(|p| p.0 == before).unwrap()
142 });
143 expected.insert(at, page);
144 }
145 }
146 }
147 Operation::Page(intent) => {
148 let body = body_text(&intent.after);
149 let store = Store::parse(&image).unwrap();
150 let index = RevisionIndex::parse(&store).unwrap();
151 let document = Document::parse(&index).unwrap();
152 let matching: Vec<_> = document
153 .spaces
154 .values()
155 .filter_map(|space| {
156 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
157 view.nodes.get(&body)
158 })
159 .collect();
160 assert_eq!(matching.len(), 1);
161 assert!(matches!(&matching[0].kind, Kind::RichText { text, .. } if text == BODY));
162 }
163 _ => panic!(),
164 }
165 assert_eq!(pages(&image), expected);
166 let old = current::current(&self.disk.durable);113 let old = current::current(&self.disk.durable);
167 let new = current::current(&image);114 let new = current::current(&image);
168 let result = transaction.commit(self.disk);115 let result = transaction.commit(self.disk);
...@@ -179,6 +126,16 @@ impl Remote for Session<'_> {...@@ -179,6 +126,16 @@ impl Remote for Session<'_> {
179 }126 }
180}127}
181128
129fn section_op(cache: &Replica, op: onestore::op::SectionOp) -> Result<u64, notebook::Error> {
130 cache.apply(
131 "Author",
132 onestore::op::Edit {
133 at: 133_000_000_000_000_000,
134 ops: vec![onestore::op::Op::Section(op)],
135 },
136 )
137}
138
182pub fn run(input: &[u8]) {139pub fn run(input: &[u8]) {
183 let directory = tempfile::tempdir().unwrap();140 let directory = tempfile::tempdir().unwrap();
184 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);141 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
...@@ -196,21 +153,27 @@ pub fn run(input: &[u8]) {...@@ -196,21 +153,27 @@ pub fn run(input: &[u8]) {
196 let path = directory.path().join(format!("{actor}.sqlite"));153 let path = directory.path().join(format!("{actor}.sqlite"));
197 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap());154 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap());
198 let snapshot = cache.snapshot().unwrap();155 let snapshot = cache.snapshot().unwrap();
156 let listed = pages(&snapshot);
199 let pending = cache.pending().unwrap();157 let pending = cache.pending().unwrap();
200 match step[1] % 8 {158 match step[1] % 8 {
201 0 | 1 => {159 0 | 1 => {
202 let title = (step[2] & 1 != 0).then_some("Same 🦋 é");160 let title = (step[2] & 1 != 0).then_some("Same 🦋 é");
203 let page = onestore::PageCreation::new(None, title, "Author").unwrap();161 let page = onestore::PageCreation::new(None, title, "Author").unwrap();
204 cache.create_page(&snapshot, &page).unwrap().unwrap();162 section_op(cache, onestore::op::SectionOp::Create(page.clone())).unwrap();
205 owned[actor].push((page.space(), page.object()));163 owned[actor].push((page.space(), page.object()));
206 if step[1] % 8 == 1 {164 if step[1] % 8 == 1 {
207 let source = cache.snapshot().unwrap();165 let mut model = cache.page(page.space()).unwrap();
208 let mut model = page_of(&source, page.space());
209 body_outline(&mut model, BODY);166 body_outline(&mut model, BODY);
210 cache167 model_ops::save_as(cache, page.space(), &model, "Author")
211 .save(&source, page.space(), &model, "Author")
212 .unwrap()168 .unwrap()
213 .unwrap();169 .unwrap();
170 let local = cache.page(page.space()).unwrap();
171 assert!(
172 local
173 .objects
174 .iter()
175 .any(|object| matches!(object, PageObject::Outline(o) if o.paragraphs[0].text().is_some_and(|t| t.text.text() == BODY)))
176 );
214 }177 }
215 }178 }
216 2 => {179 2 => {
...@@ -220,7 +183,7 @@ pub fn run(input: &[u8]) {...@@ -220,7 +183,7 @@ pub fn run(input: &[u8]) {
220 .collect();183 .collect();
221 replicas[actor] = None;184 replicas[actor] = None;
222 let cache = Replica::open(&path).unwrap();185 let cache = Replica::open(&path).unwrap();
223 assert!(cache.snapshot().unwrap() == snapshot);186 assert_eq!(pages(&cache.snapshot().unwrap()), listed);
224 assert_eq!(cache.pending().unwrap(), pending);187 assert_eq!(cache.pending().unwrap(), pending);
225 assert_eq!(188 assert_eq!(
226 pending189 pending
...@@ -240,15 +203,20 @@ pub fn run(input: &[u8]) {...@@ -240,15 +203,20 @@ pub fn run(input: &[u8]) {
240 let prior = pending203 let prior = pending
241 .first()204 .first()
242 .and_then(|edit| cache.status(edit.id).unwrap());205 .and_then(|edit| cache.status(edit.id).unwrap());
243 let mut session = Session {206 let result = cache.sync_once(&mut Session { disk: &mut disk });
244 disk: &mut disk,
245 operation: pending.first().map(|edit| &edit.operation),
246 };
247 let result = cache.sync_once(&mut session);
248 if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) {207 if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) {
249 assert!(208 assert!(
250 result.is_err()209 result.is_err()
251 || !matches!(result.unwrap(), Some((_, EditStatus::Conflict(_))))210 || !matches!(
211 result.as_ref().unwrap().edit,
212 Some((_, EditStatus::Conflict(_)))
213 )
214 );
215 }
216 if result.is_ok() && cache.pending().unwrap().is_empty() {
217 assert_eq!(
218 pages(&cache.snapshot().unwrap()),
219 pages(&cache.remote_snapshot().unwrap())
252 );220 );
253 }221 }
254 }222 }
...@@ -257,13 +225,12 @@ pub fn run(input: &[u8]) {...@@ -257,13 +225,12 @@ pub fn run(input: &[u8]) {
257 disk.fail_at = None;225 disk.fail_at = None;
258 }226 }
259 6 => {227 6 => {
260 let order = pages(&snapshot);
261 let count = 1 + usize::from(step[3] & 1 != 0);228 let count = 1 + usize::from(step[3] & 1 != 0);
262 let selected: Vec<_> = (0..count)229 let selected: Vec<_> = (0..count)
263 .map(|i| order[(usize::from(step[2]) + i) % order.len()].0)230 .map(|i| listed[(usize::from(step[2]) + i) % listed.len()].0)
264 .collect();231 .collect();
265 let anchor = (step[4] & 1 != 0)232 let anchor = (step[4] & 1 != 0)
266 .then_some(order[usize::from(step[5]) % order.len()].0)233 .then_some(listed[usize::from(step[5]) % listed.len()].0)
267 .filter(|sid| !selected.contains(sid));234 .filter(|sid| !selected.contains(sid));
268 let edits: Vec<_> = selected235 let edits: Vec<_> = selected
269 .iter()236 .iter()
...@@ -278,43 +245,28 @@ pub fn run(input: &[u8]) {...@@ -278,43 +245,28 @@ pub fn run(input: &[u8]) {
278 })245 })
279 .collect();246 .collect();
280 let expected = PreparedEdit::pages(&snapshot, &edits);247 let expected = PreparedEdit::pages(&snapshot, &edits);
281 match cache.pages(&snapshot, &edits) {248 match section_op(cache, onestore::op::SectionOp::Pages(edits)) {
282 Ok(id) => {249 Ok(_) => assert_eq!(
283 let expected = expected.unwrap();250 pages(&cache.snapshot().unwrap()),
284 assert_eq!(251 pages(expected.unwrap().as_bytes())
285 current::current(&cache.snapshot().unwrap()),252 ),
286 current::current(expected.as_bytes())
287 );
288 assert_eq!(id.is_some(), expected.as_bytes() != snapshot);
289 }
290 Err(_) => {253 Err(_) => {
291 assert!(expected.is_err());254 assert!(expected.is_err());
292 assert_eq!(cache.snapshot().unwrap(), snapshot);255 assert_eq!(pages(&cache.snapshot().unwrap()), listed);
293 assert_eq!(cache.pending().unwrap(), pending);256 assert_eq!(cache.pending().unwrap(), pending);
294 }257 }
295 }258 }
296 }259 }
297 7 => {260 7 => {
298 if let Some(edit) = pending.first()261 if let Some(conflict) = cache.conflict().unwrap() {
299 && matches!(262 // Taking theirs for the page list would drop the pages this actor made.
300 cache.status(edit.id).unwrap(),263 let root = listed.iter().all(|(sid, ..)| *sid != conflict.space);
301 Some(EditStatus::Conflict(_))264 let keep = if root || step[2] & 1 == 0 {
302 )265 Resolution::Mine
303 && let Operation::Pages(batch) = &edit.operation266 } else {
304 {267 Resolution::Theirs
305 let remote = cache.remote_snapshot().unwrap();268 };
306 let order = pages(&remote);269 if cache.resolve(conflict.id, keep).is_err() {
307 let anchor = (step[2] & 1 != 0)
308 .then_some(order[usize::from(step[3]) % order.len()].0)
309 .filter(|sid| batch.edits.iter().all(|e| e.space() != *sid));
310 let edits: Vec<_> = batch
311 .edits
312 .iter()
313 .map(|edit| edit.reposition(PagePosition::Before(anchor), 1).unwrap())
314 .collect();
315 let result = cache.rebase_pages_conflict(edit.id, &snapshot, &remote, &edits);
316 assert_eq!(cache.snapshot().unwrap(), snapshot);
317 if result.is_err() {
318 assert_eq!(cache.pending().unwrap(), pending);270 assert_eq!(cache.pending().unwrap(), pending);
319 }271 }
320 }272 }
crates/notebook/tests/support/server.rs+32
...@@ -133,3 +133,35 @@ pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {...@@ -133,3 +133,35 @@ pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {
133 })133 })
134 .unwrap()134 .unwrap()
135}135}
136
137/// Every page of an image, in section order: what two images holding the same edits under
138/// different revision identities share.
139pub fn pages(source: &[u8]) -> Vec<(ExGuid, onestore::page::Page)> {
140 let store = Store::parse(source).unwrap();
141 let index = RevisionIndex::parse(&store).unwrap();
142 let document = Document::parse(&index).unwrap();
143 document
144 .pages()
145 .unwrap()
146 .into_iter()
147 .map(|(space, _)| {
148 (
149 space,
150 onestore::page::Page::from_space(&document, space).unwrap(),
151 )
152 })
153 .collect()
154}
155
156/// Queues a section op as its own edit.
157pub fn section_op(cache: &notebook::Replica, op: onestore::op::SectionOp) -> u64 {
158 cache
159 .apply(
160 "Fixture",
161 onestore::op::Edit {
162 at: 133_000_000_000_000_000,
163 ops: vec![onestore::op::Op::Section(op)],
164 },
165 )
166 .unwrap()
167}
crates/notebook/tests/sync.rs+233-230
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1//! Replica protocol: durable receipts, retired attempts, conflict review and contention.1//! Replica protocol: durable receipts, retired attempts, conflict resolution and contention.
22
3use notebook::{ConflictKind, EditStatus, Error, Remote, Replica};3use notebook::{ConflictKind, EditStatus, Error, Remote, Replica, Resolution};
4use onestore::{CommitError, CommitState, ExGuid, Transaction, page::Page};4use onestore::{CommitError, CommitState, ExGuid, Transaction};
5use std::{io, ops::Range};5use std::{io, ops::Range};
66
7#[path = "support/server.rs"]7#[path = "support/server.rs"]
...@@ -10,13 +10,6 @@ use server::*;...@@ -10,13 +10,6 @@ use server::*;
10#[path = "support/model_ops.rs"]10#[path = "support/model_ops.rs"]
11mod model_ops;11mod model_ops;
1212
13/// The page holding `text`, with a UTF-16 range of that text replaced.
14fn edited(bytes: &[u8], text: ExGuid, range: Range<u32>, replacement: &str) -> Page {
15 let (_, mut page) = model_ops::locate(bytes, text);
16 model_ops::replace_text(&mut page, text, range, replacement);
17 page
18}
19
20/// Saves a replacement of `range` in the page holding `text`.13/// Saves a replacement of `range` in the page holding `text`.
21fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {14fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {
22 model_ops::save(cache, text, |page| {15 model_ops::save(cache, text, |page| {
...@@ -56,16 +49,16 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() {...@@ -56,16 +49,16 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() {
56 reads: 0,49 reads: 0,
57 };50 };
58 assert!(matches!(51 assert!(matches!(
59 cache.sync_once(&mut remote).unwrap(),52 cache.sync_once(&mut remote).unwrap().edit,
60 Some((published, EditStatus::Published { .. })) if published == id53 Some((published, EditStatus::Published { .. })) if published == id
61 ));54 ));
62 assert_eq!(cache.sync_once(&mut remote).unwrap(), None);55 assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None);
63 assert_eq!(remote.reads, 0);56 assert_eq!(remote.reads, 0);
64 // Another writer's commit moves the header, so the next step reads the file.57 // Another writer's commit moves the header, so the next step reads the file.
65 let native =58 let native =
66 onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap();59 onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap();
67 remote.server.visible.clone_from(&native);60 remote.server.visible.clone_from(&native);
68 assert_eq!(cache.sync_once(&mut remote).unwrap(), None);61 assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None);
69 assert_eq!(remote.reads, 1);62 assert_eq!(remote.reads, 1);
70 assert_eq!(cache.snapshot().unwrap(), native);63 assert_eq!(cache.snapshot().unwrap(), native);
71}64}
...@@ -82,7 +75,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -82,7 +75,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
82 let cache = Replica::create(&path, &source).unwrap();75 let cache = Replica::create(&path, &source).unwrap();
83 let published = save(&cache, object, 0..0, "Published ").unwrap();76 let published = save(&cache, object, 0..0, "Published ").unwrap();
84 let mut server = Server::new(&source);77 let mut server = Server::new(&source);
85 let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap();78 let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap();
86 let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap();79 let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap();
87 server.fault = Fault::UnknownBefore;80 server.fault = Fault::UnknownBefore;
88 assert!(matches!(81 assert!(matches!(
...@@ -94,10 +87,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -94,10 +87,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
94 ));87 ));
95 save(&cache, object, 0..0, "Queued ").unwrap();88 save(&cache, object, 0..0, "Queued ").unwrap();
96 let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap();89 let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap();
97 cache90 section_op(&cache, onestore::op::SectionOp::Create(created));
98 .create_page(&cache.snapshot().unwrap(), &created)
99 .unwrap()
100 .unwrap();
101 save(&cache, object, 0..0, "Last ").unwrap();91 save(&cache, object, 0..0, "Last ").unwrap();
102 let working = cache.snapshot().unwrap();92 let working = cache.snapshot().unwrap();
103 let remote = cache.remote_snapshot().unwrap();93 let remote = cache.remote_snapshot().unwrap();
...@@ -109,8 +99,8 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -109,8 +99,8 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
109 conflicts: 0,99 conflicts: 0,
110 uncertain_edits: 1,100 uncertain_edits: 1,
111 published_receipts: 1,101 published_receipts: 1,
112 working_bytes: working.len() as u64,102 base_bytes: remote.len() as u64,
113 remote_bytes: remote.len() as u64,103 remote_bytes: 0,
114 cached_assets: 0,104 cached_assets: 0,
115 cached_asset_bytes: 0,105 cached_asset_bytes: 0,
116 };106 };
...@@ -123,7 +113,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -123,7 +113,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
123 assert!(Recovery::open(&path).is_err());113 assert!(Recovery::open(&path).is_err());
124 let archive = Recovery::open(&archive_path).unwrap();114 let archive = Recovery::open(&archive_path).unwrap();
125 assert_eq!(archive.summary().unwrap(), summary);115 assert_eq!(archive.summary().unwrap(), summary);
126 assert_eq!(archive.snapshot().unwrap(), working);116 assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working));
127 assert_eq!(archive.remote_snapshot().unwrap(), remote);117 assert_eq!(archive.remote_snapshot().unwrap(), remote);
128 assert_eq!(archive.pending().unwrap(), pending);118 assert_eq!(archive.pending().unwrap(), pending);
129 assert_eq!(archive.status(published).unwrap(), Some(receipt.clone()));119 assert_eq!(archive.status(published).unwrap(), Some(receipt.clone()));
...@@ -150,7 +140,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin...@@ -150,7 +140,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
150 assert_eq!(std::fs::read(&path).unwrap(), source_file);140 assert_eq!(std::fs::read(&path).unwrap(), source_file);
151 assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes);141 assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes);
152 save(&cache, object, 0..0, "Later ").unwrap();142 save(&cache, object, 0..0, "Later ").unwrap();
153 assert_eq!(archive.snapshot().unwrap(), working);143 assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working));
154 assert_eq!(archive.pending().unwrap(), pending);144 assert_eq!(archive.pending().unwrap(), pending);
155 drop(archive);145 drop(archive);
156 assert_eq!(146 assert_eq!(
...@@ -193,7 +183,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv...@@ -193,7 +183,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
193 let id = save(&cache, object, 0..8, "Local").unwrap();183 let id = save(&cache, object, 0..8, "Local").unwrap();
194 let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap();184 let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap();
195 let mut server = Server::new(&changed);185 let mut server = Server::new(&changed);
196 let outcome = cache.sync_once(&mut server).unwrap().unwrap();186 let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap();
197 assert_eq!(187 assert_eq!(
198 outcome,188 outcome,
199 (id, EditStatus::Conflict(ConflictKind::ContentChanged))189 (id, EditStatus::Conflict(ConflictKind::ContentChanged))
...@@ -232,7 +222,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {...@@ -232,7 +222,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
232 let id = save(&cache, oid, 2..4, "🐈").unwrap();222 let id = save(&cache, oid, 2..4, "🐈").unwrap();
233 let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap();223 let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap();
234 let mut server = Server::new(&remote);224 let mut server = Server::new(&remote);
235 let outcome = cache.sync_once(&mut server).unwrap().unwrap();225 let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap();
236 assert_eq!(outcome.0, id);226 assert_eq!(outcome.0, id);
237 assert!(matches!(outcome.1, EditStatus::Published { .. }));227 assert!(matches!(outcome.1, EditStatus::Published { .. }));
238 assert_eq!(text(&server.durable).2, "Xab🐈cYd");228 assert_eq!(text(&server.durable).2, "Xab🐈cYd");
...@@ -241,7 +231,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {...@@ -241,7 +231,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
241 drop(cache);231 drop(cache);
242 let cache = Replica::open(&path).unwrap();232 let cache = Replica::open(&path).unwrap();
243 assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone()));233 assert_eq!(cache.status(id).unwrap(), Some(outcome.1.clone()));
244 assert_eq!(cache.sync_once(&mut server).unwrap(), None);234 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
245 assert_eq!(server.publications, 1);235 assert_eq!(server.publications, 1);
246 assert_eq!(cache.status(id + 1).unwrap(), None);236 assert_eq!(cache.status(id + 1).unwrap(), None);
247 let next = save(&cache, oid, 0..0, "Later ").unwrap();237 let next = save(&cache, oid, 0..0, "Later ").unwrap();
...@@ -260,13 +250,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() {...@@ -260,13 +250,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() {
260 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();250 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
261 let mut server = Server::new(&remote);251 let mut server = Server::new(&remote);
262 assert_eq!(252 assert_eq!(
263 cache.sync_once(&mut server).unwrap(),253 cache.sync_once(&mut server).unwrap().edit,
264 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))254 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
265 );255 );
266 assert_eq!(server.publications, 0);256 assert_eq!(server.publications, 0);
267 drop(cache);257 drop(cache);
268 let cache = Replica::open(&path).unwrap();258 let cache = Replica::open(&path).unwrap();
269 assert_eq!(cache.snapshot().unwrap(), local);259 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
270 assert_eq!(cache.remote_snapshot().unwrap(), remote);260 assert_eq!(cache.remote_snapshot().unwrap(), remote);
271 assert_eq!(cache.pending().unwrap().len(), 1);261 assert_eq!(cache.pending().unwrap().len(), 1);
272 assert_eq!(262 assert_eq!(
...@@ -298,7 +288,7 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() {...@@ -298,7 +288,7 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() {
298 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));288 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
299 drop(cache);289 drop(cache);
300 let cache = Replica::open(&path).unwrap();290 let cache = Replica::open(&path).unwrap();
301 let result = cache.sync_once(&mut server).unwrap().unwrap();291 let result = cache.sync_once(&mut server).unwrap().edit.unwrap();
302 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {292 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
303 assert!(matches!(result.1, EditStatus::Published { .. }));293 assert!(matches!(result.1, EditStatus::Published { .. }));
304 assert_eq!(text(&server.durable).2, "Once abc");294 assert_eq!(text(&server.durable).2, "Once abc");
...@@ -333,7 +323,7 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {...@@ -333,7 +323,7 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {
333 Some(EditStatus::AwaitingConfirmation { .. })323 Some(EditStatus::AwaitingConfirmation { .. })
334 ));324 ));
335 assert!(matches!(325 assert!(matches!(
336 cache.sync_once(&mut server).unwrap(),326 cache.sync_once(&mut server).unwrap().edit,
337 Some((_, EditStatus::Published { .. }))327 Some((_, EditStatus::Published { .. }))
338 ));328 ));
339 assert_eq!(server.publications, 1);329 assert_eq!(server.publications, 1);
...@@ -383,7 +373,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(...@@ -383,7 +373,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
383 if matches!(fault, Fault::Before) {373 if matches!(fault, Fault::Before) {
384 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));374 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
385 assert!(matches!(375 assert!(matches!(
386 cache.sync_once(&mut server).unwrap(),376 cache.sync_once(&mut server).unwrap().edit,
387 Some((_, EditStatus::Published { .. }))377 Some((_, EditStatus::Published { .. }))
388 ));378 ));
389 assert_eq!(server.publications, 2);379 assert_eq!(server.publications, 2);
...@@ -392,7 +382,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(...@@ -392,7 +382,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
392 cache.status(id).unwrap(),382 cache.status(id).unwrap(),
393 Some(EditStatus::Published { .. })383 Some(EditStatus::Published { .. })
394 ));384 ));
395 assert_eq!(cache.sync_once(&mut server).unwrap(), None);385 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
396 assert_eq!(server.publications, 1);386 assert_eq!(server.publications, 1);
397 }387 }
398 assert_eq!(text(&server.durable).2, "Once abc");388 assert_eq!(text(&server.durable).2, "Once abc");
...@@ -401,7 +391,10 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(...@@ -401,7 +391,10 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
401391
402#[test]392#[test]
403fn database_failures_before_and_after_publication_preserve_recovery_state() {393fn database_failures_before_and_after_publication_preserve_recovery_state() {
404 for table in ["attempt", "receipts"] {394 for (table, event) in [
395 ("attempt", "UPDATE OF attempted ON batches"),
396 ("receipts", "INSERT ON receipts"),
397 ] {
405 let dir = tempfile::tempdir().unwrap();398 let dir = tempfile::tempdir().unwrap();
406 let path = dir.path().join("cache.sqlite");399 let path = dir.path().join("cache.sqlite");
407 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();400 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
...@@ -410,7 +403,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {...@@ -410,7 +403,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {
410 let id = save(&cache, oid, 0..0, "Once ").unwrap();403 let id = save(&cache, oid, 0..0, "Once ").unwrap();
411 drop(cache);404 drop(cache);
412 let db = rusqlite::Connection::open(&path).unwrap();405 let db = rusqlite::Connection::open(&path).unwrap();
413 db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap();406 db.execute_batch(&format!("CREATE TRIGGER interrupted BEFORE {event} BEGIN SELECT RAISE(ABORT,'Injected cache failure'); END;")).unwrap();
414 drop(db);407 drop(db);
415 let cache = Replica::open(&path).unwrap();408 let cache = Replica::open(&path).unwrap();
416 let mut server = Server::new(&source);409 let mut server = Server::new(&source);
...@@ -426,7 +419,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {...@@ -426,7 +419,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {
426 drop(db);419 drop(db);
427 let cache = Replica::open(&path).unwrap();420 let cache = Replica::open(&path).unwrap();
428 assert!(matches!(421 assert!(matches!(
429 cache.sync_once(&mut server).unwrap(),422 cache.sync_once(&mut server).unwrap().edit,
430 Some((_, EditStatus::Published { .. }))423 Some((_, EditStatus::Published { .. }))
431 ));424 ));
432 assert!(matches!(425 assert!(matches!(
...@@ -474,7 +467,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -474,7 +467,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
474 for phase in ["read", "publish", "confirm"] {467 for phase in ["read", "publish", "confirm"] {
475 let dir = tempfile::tempdir().unwrap();468 let dir = tempfile::tempdir().unwrap();
476 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();469 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
477 let (_, oid, _) = text(&source);470 let (sid, oid, _) = text(&source);
478 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();471 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
479 let first = save(&cache, oid, 0..0, "First ").unwrap();472 let first = save(&cache, oid, 0..0, "First ").unwrap();
480 let mut server = Server::new(&source);473 let mut server = Server::new(&source);
...@@ -493,9 +486,17 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -493,9 +486,17 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
493 let mut server = std::thread::scope(|scope| {486 let mut server = std::thread::scope(|scope| {
494 let running = scope.spawn(|| {487 let running = scope.spawn(|| {
495 let result = cache.sync_once(&mut paused);488 let result = cache.sync_once(&mut paused);
496 assert!(489 assert!(matches!(
497 matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first)490 result,
498 );491 Ok(notebook::Synced {
492 edit: Some((_, EditStatus::Published { .. })),
493 ..
494 })
495 ));
496 assert!(matches!(
497 cache.status(first).unwrap(),
498 Some(EditStatus::Published { .. })
499 ));
499 paused.server500 paused.server
500 });501 });
501 entered_rx502 entered_rx
...@@ -504,26 +505,31 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -504,26 +505,31 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
504 assert!(505 assert!(
505 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)506 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)
506 );507 );
507 let reviewed = edited(&source, oid, 0..0, "Reviewed ");
508 assert!(508 assert!(
509 matches!(cache.review_page(first, &[], &[], &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock)509 matches!(cache.resolve(first, Resolution::Theirs), Err(Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock)
510 );510 );
511 let started = std::time::Instant::now();511 let started = std::time::Instant::now();
512 let handles: Vec<_> = (0..12)512 let handles: Vec<_> = (0..12)
513 .map(|writer| {513 .map(|writer| {
514 let cache = &cache;514 let cache = &cache;
515 scope.spawn(move || {515 scope.spawn(move || {
516 loop {516 // Ops, unlike models, commute with the other writers' edits.
517 let marker = format!("[{writer}] ");517 cache
518 match model_ops::save(cache, oid, |page| {518 .apply(
519 model_ops::replace_text(page, oid, 0..0, &marker)519 model_ops::AUTHOR,
520 }) {520 onestore::op::Edit {
521 Ok(Some(id)) => break id,521 at: model_ops::now(),
522 Err(Error::Io(error))522 ops: vec![onestore::op::Op::Page {
523 if error.kind() == io::ErrorKind::ResourceBusy => {}523 space: sid,
524 other => panic!("Unexpected local outcome {other:?}"),524 op: onestore::op::PageOp::Text {
525 }525 text: oid,
526 }526 range: 0..0,
527 with: format!("[{writer}] "),
528 },
529 }],
530 },
531 )
532 .unwrap()
527 })533 })
528 })534 })
529 .collect();535 .collect();
...@@ -531,12 +537,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -531,12 +537,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
531 .into_iter()537 .into_iter()
532 .map(|handle| handle.join().unwrap())538 .map(|handle| handle.join().unwrap())
533 .collect();539 .collect();
534 assert_eq!(ids.len(), 1, "saves to one page coalesce");540 assert_eq!(ids.len(), 12, "every local edit is its own");
535 assert_eq!(
536 ids.contains(&first),
537 phase == "read",
538 "saves coalesce into the head only until a step selects it"
539 );
540 assert!(541 assert!(
541 started.elapsed() < std::time::Duration::from_secs(2),542 started.elapsed() < std::time::Duration::from_secs(2),
542 "Local edits waited for remote {phase}"543 "Local edits waited for remote {phase}"
...@@ -544,14 +545,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio...@@ -544,14 +545,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
544 resume_tx.send(()).unwrap();545 resume_tx.send(()).unwrap();
545 running.join().unwrap()546 running.join().unwrap()
546 });547 });
547 assert_eq!(cache.pending().unwrap().len(), usize::from(phase != "read"));548 // Edits arriving while a batch publishes form the next batch.
549 assert_eq!(
550 cache.pending().unwrap().len(),
551 if phase == "read" { 0 } else { 12 }
552 );
548 let expected = text(&cache.snapshot().unwrap()).2;553 let expected = text(&cache.snapshot().unwrap()).2;
549 for writer in 0..12 {554 for writer in 0..12 {
550 assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1);555 assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1);
551 }556 }
552 while !cache.pending().unwrap().is_empty() {557 while !cache.pending().unwrap().is_empty() {
553 assert!(matches!(558 assert!(matches!(
554 cache.sync_once(&mut server).unwrap(),559 cache.sync_once(&mut server).unwrap().edit,
555 Some((_, EditStatus::Published { .. }))560 Some((_, EditStatus::Published { .. }))
556 ));561 ));
557 }562 }
...@@ -573,136 +578,155 @@ fn unrelated_remote_files_never_replace_a_local_cache() {...@@ -573,136 +578,155 @@ fn unrelated_remote_files_never_replace_a_local_cache() {
573 if pending {578 if pending {
574 save(&cache, oid, 0..0, "Local ").unwrap();579 save(&cache, oid, 0..0, "Local ").unwrap();
575 }580 }
576 let before = cache.snapshot().unwrap();581 let before = pages(&cache.snapshot().unwrap());
577 assert!(582 assert!(
578 matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput)583 matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput)
579 );584 );
580 assert_eq!(cache.snapshot().unwrap(), before);585 assert_eq!(pages(&cache.snapshot().unwrap()), before);
581 assert_eq!(cache.remote_snapshot().unwrap(), source);586 assert_eq!(cache.remote_snapshot().unwrap(), source);
582 assert_eq!(server.publications, 0);587 assert_eq!(server.publications, 0);
583 }588 }
584}589}
585590
591/// The text object's content in an image.
592fn content(bytes: &[u8], text: ExGuid) -> String {
593 let (_, page) = model_ops::locate(bytes, text);
594 model_ops::paragraph_with(&page, text)
595 .unwrap()
596 .text()
597 .unwrap()
598 .text
599 .text()
600 .to_owned()
601}
602
586#[test]603#[test]
587fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() {604fn a_conflict_holds_later_edits_until_resolved_either_way_and_survives_reopen() {
588 let dir = tempfile::tempdir().unwrap();605 for resolution in [Resolution::Mine, Resolution::Theirs] {
589 let path = dir.path().join("cache.sqlite");606 let dir = tempfile::tempdir().unwrap();
590 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();607 let path = dir.path().join("cache.sqlite");
591 let (sid, oid, _) = text(&source);608 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
592 let cache = Replica::create(&path, &source).unwrap();609 let (sid, oid, _) = text(&source);
593 let first = save(&cache, oid, 1..2, "L").unwrap();610 let cache = Replica::create(&path, &source).unwrap();
594 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();611 let first = save(&cache, oid, 1..2, "L").unwrap();
595 let mut server = Server::new(&remote);612 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
596 assert_eq!(613 let mut server = Server::new(&remote);
597 cache.sync_once(&mut server).unwrap(),614 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
598 Some((first, EditStatus::Conflict(ConflictKind::ContentChanged)))615 assert_eq!(
599 );616 cache.sync_once(&mut server).unwrap().edit,
600 let mut dependent = None;617 Some((first, conflict.clone()))
601 for n in 0..12 {618 );
602 dependent = save(&cache, oid, 0..0, &format!("[{n}] "));619 let mut dependent = None;
603 }620 for n in 0..12 {
604 let dependent = dependent.unwrap();621 dependent = save(&cache, oid, 0..0, &format!("[{n}] "));
605 assert_ne!(dependent, first, "a conflicted save is never rewritten");622 }
606 let local = cache.snapshot().unwrap();623 let dependent = dependent.unwrap();
607 let pending = cache.pending().unwrap();624 assert_ne!(dependent, first);
608 assert_eq!(pending.len(), 2);625 assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone()));
609 model_ops::review(&cache, first, oid, |page| {626 let local = content(&cache.snapshot().unwrap(), oid);
610 model_ops::replace_text(page, oid, 1..2, "L")627 assert!(local.ends_with("[0] aLc"), "{local}");
611 })628 let pending = cache.pending().unwrap();
612 .unwrap();629 assert_eq!(pending.len(), 13);
613 assert_eq!(cache.snapshot().unwrap(), local);630 assert_eq!(
614 assert_eq!(cache.remote_snapshot().unwrap(), remote);631 cache.sync_once(&mut server).unwrap().edit,
615 let reviewed = cache.pending().unwrap();632 Some((dependent, conflict.clone())),
616 assert_eq!(reviewed[1..], pending[1..]);633 "an unchanged remote leaves the conflict as it was"
617 assert_eq!(reviewed[0].id, first);634 );
618 let notebook::Operation::Page(intent) = &reviewed[0].operation else {635 assert_eq!(server.publications, 0);
619 panic!()636 drop(cache);
620 };637 let cache = Replica::open(&path).unwrap();
621 assert_eq!(638 assert_eq!(cache.pending().unwrap(), pending);
622 intent.text_change(),639 assert_eq!(content(&cache.snapshot().unwrap(), oid), local);
623 Some((oid, "aRcZ".into(), 1..2, "L".into()))640 assert_eq!(
624 );641 cache.conflict().unwrap(),
625 assert_eq!(intent.author, model_ops::AUTHOR);642 Some(notebook::Conflict {
626 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));643 id: dependent,
627 drop(cache);644 space: sid,
628 let cache = Replica::open(&path).unwrap();645 kind: ConflictKind::ContentChanged
629 assert_eq!(cache.pending().unwrap(), reviewed);646 })
630 assert_eq!(cache.snapshot().unwrap(), local);
631 for intent in &pending {
632 assert!(
633 matches!(cache.sync_once(&mut server).unwrap(), Some((id, EditStatus::Published { .. })) if id == intent.id)
634 );647 );
648 cache.resolve(first, resolution).unwrap();
649 assert_eq!(cache.conflict().unwrap(), None);
650 assert_eq!(cache.remote_snapshot().unwrap(), remote);
651 let expected = match resolution {
652 Resolution::Mine => local.clone(),
653 Resolution::Theirs => "aRcZ".to_owned(),
654 };
655 assert_eq!(content(&cache.snapshot().unwrap(), oid), expected);
656 drop(cache);
657 let cache = Replica::open(&path).unwrap();
658 match resolution {
659 Resolution::Mine => {
660 assert!(matches!(
661 cache.sync_once(&mut server).unwrap().edit,
662 Some((_, EditStatus::Published { .. }))
663 ));
664 assert_eq!(server.publications, 1);
665 for edit in &pending {
666 assert!(matches!(
667 cache.status(edit.id).unwrap(),
668 None | Some(EditStatus::Published { .. })
669 ));
670 }
671 assert!(matches!(
672 cache.status(first).unwrap(),
673 Some(EditStatus::Published { .. })
674 ));
675 }
676 Resolution::Theirs => {
677 // The emptied edits are acknowledged once the remote image is flushed.
678 assert!(matches!(
679 cache.sync_once(&mut server).unwrap().edit,
680 Some((id, EditStatus::Published { .. })) if id == dependent
681 ));
682 assert_eq!((server.publications, server.confirmations), (0, 1));
683 assert!(matches!(
684 cache.status(first).unwrap(),
685 Some(EditStatus::Published { .. })
686 ));
687 }
688 }
689 assert_eq!(content(&server.durable, oid), expected);
690 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&server.durable));
691 assert!(cache.pending().unwrap().is_empty());
635 }692 }
636 assert_eq!(server.publications, 2);
637 assert_eq!(text(&server.durable).2, text(&local).2 + "Z");
638 assert_eq!(cache.snapshot().unwrap(), server.durable);
639 assert!(cache.pending().unwrap().is_empty());
640}693}
641694
642#[test]695#[test]
643fn conflict_review_rejects_stale_images_and_nonconflicting_states() {696fn resolution_refuses_edits_that_are_not_in_conflict() {
644 let dir = tempfile::tempdir().unwrap();697 let dir = tempfile::tempdir().unwrap();
645 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();698 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
646 let (sid, oid, _) = text(&source);699 let (sid, oid, _) = text(&source);
647 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();700 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
648 let id = save(&cache, oid, 1..2, "L").unwrap();701 let id = save(&cache, oid, 1..2, "L").unwrap();
649 let local = cache.snapshot().unwrap();702 let refused = |cache: &Replica, id| matches!(cache.resolve(id, Resolution::Mine), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput);
650 let pending_review = edited(&source, oid, 1..2, "L");703 assert!(refused(&cache, id));
651 assert!(
652 matches!(cache.review_page(id, &local, &source, &pending_review), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
653 );
654 let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap();704 let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap();
655 let mut server = Server::new(&remote);705 let mut server = Server::new(&remote);
656 assert!(matches!(706 assert!(matches!(
657 cache.sync_once(&mut server).unwrap(),707 cache.sync_once(&mut server).unwrap().edit,
658 Some((_, EditStatus::Conflict(_)))708 Some((_, EditStatus::Conflict(_)))
659 ));709 ));
660 let pending = cache.pending().unwrap();710 let pending = cache.pending().unwrap();
661 let reviewed = edited(&remote, oid, 2..3, "L");711 assert!(refused(&cache, id + 1));
662 assert!(
663 matches!(cache.review_page(id + 1, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
664 );
665 assert_eq!(cache.pending().unwrap(), pending);712 assert_eq!(cache.pending().unwrap(), pending);
666 save(&cache, oid, 0..0, "Later ").unwrap();713 cache.resolve(id, Resolution::Mine).unwrap();
667 let changed = cache.snapshot().unwrap();
668 assert!(
669 matches!(cache.review_page(id, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
670 );
671 let new_remote = onestore::replace_text(&remote, sid, oid, 2..3, "Q").unwrap();
672 server.visible = new_remote.clone();
673 server.durable = new_remote;
674 assert!(matches!(
675 cache.sync_once(&mut server).unwrap(),
676 Some((_, EditStatus::Conflict(_)))
677 ));
678 assert!(
679 matches!(cache.review_page(id, &changed, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
680 );
681 assert_eq!(cache.snapshot().unwrap(), changed);
682 assert_eq!(cache.pending().unwrap()[0], pending[0]);
683 assert_eq!(server.publications, 0);
684
685 let current = cache.remote_snapshot().unwrap();
686 let reviewed = edited(&current, oid, 2..3, "L");
687 cache
688 .review_page(id, &changed, &current, &reviewed)
689 .unwrap();
690 server.fault = Fault::UnknownBefore;714 server.fault = Fault::UnknownBefore;
691 assert!(715 assert!(
692 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown)716 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown)
693 );717 );
694 let attempted = cache.status(id).unwrap();718 let attempted = cache.status(id).unwrap();
695 let pending = cache.pending().unwrap();719 assert!(matches!(
696 assert!(720 attempted,
697 matches!(cache.review_page(id, &changed, &current, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)721 Some(EditStatus::AwaitingConfirmation { .. })
698 );722 ));
723 assert!(refused(&cache, id));
699 assert_eq!(cache.status(id).unwrap(), attempted);724 assert_eq!(cache.status(id).unwrap(), attempted);
700 assert_eq!(cache.pending().unwrap(), pending);
701 assert_eq!(server.publications, 1);725 assert_eq!(server.publications, 1);
702}726}
703727
704#[test]728#[test]
705fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() {729fn a_failed_resolution_leaves_the_conflict_as_it_was() {
706 let dir = tempfile::tempdir().unwrap();730 let dir = tempfile::tempdir().unwrap();
707 let path = dir.path().join("cache.sqlite");731 let path = dir.path().join("cache.sqlite");
708 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();732 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
...@@ -712,30 +736,29 @@ fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution()...@@ -712,30 +736,29 @@ fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution()
712 let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap();736 let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap();
713 let mut server = Server::new(&remote);737 let mut server = Server::new(&remote);
714 cache.sync_once(&mut server).unwrap();738 cache.sync_once(&mut server).unwrap();
715 let local = cache.snapshot().unwrap();739 let local = pages(&cache.snapshot().unwrap());
716 let pending = cache.pending().unwrap();740 let pending = cache.pending().unwrap();
717 let status = cache.status(id).unwrap();741 let status = cache.status(id).unwrap();
718 drop(cache);742 drop(cache);
719 let connection = rusqlite::Connection::open(&path).unwrap();743 let connection = rusqlite::Connection::open(&path).unwrap();
720 connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON conflicts BEGIN SELECT RAISE(ABORT, 'test conflict clear failure'); END;").unwrap();744 connection.execute_batch("CREATE TRIGGER fail_clear BEFORE DELETE ON batches BEGIN SELECT RAISE(ABORT, 'test resolution failure'); END;").unwrap();
721 drop(connection);745 drop(connection);
722 let cache = Replica::open(&path).unwrap();746 let cache = Replica::open(&path).unwrap();
723 let reviewed = edited(&remote, oid, 2..3, "L");
724 assert!(matches!(747 assert!(matches!(
725 cache.review_page(id, &local, &remote, &reviewed),748 cache.resolve(id, Resolution::Mine),
726 Err(Error::Database(_))749 Err(Error::Database(_))
727 ));750 ));
728 drop(cache);751 drop(cache);
729 let cache = Replica::open(&path).unwrap();752 let cache = Replica::open(&path).unwrap();
730 assert_eq!(cache.pending().unwrap(), pending);753 assert_eq!(cache.pending().unwrap(), pending);
731 assert_eq!(cache.status(id).unwrap(), status);754 assert_eq!(cache.status(id).unwrap(), status);
732 assert_eq!(cache.snapshot().unwrap(), local);755 assert_eq!(pages(&cache.snapshot().unwrap()), local);
733 assert_eq!(cache.remote_snapshot().unwrap(), remote);756 assert_eq!(cache.remote_snapshot().unwrap(), remote);
734 assert_eq!(server.publications, 0);757 assert_eq!(server.publications, 0);
735}758}
736759
737#[test]760#[test]
738fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {761fn seeded_conflicts_resolve_to_the_remote_text_and_later_edits_publish_once() {
739 let dir = tempfile::tempdir().unwrap();762 let dir = tempfile::tempdir().unwrap();
740 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();763 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();
741 let mut seed = 911_u64;764 let mut seed = 911_u64;
...@@ -757,8 +780,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {...@@ -757,8 +780,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
757 let (sid, oid, _) = text(&source);780 let (sid, oid, _) = text(&source);
758 let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap();781 let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap();
759 let id = save(&cache, oid, start..end, "λ🦊μ").unwrap();782 let id = save(&cache, oid, start..end, "λ🦊μ").unwrap();
760 let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap();783 save(&cache, oid, start + 1..start + 3, "🐕").unwrap();
761 assert_eq!(dependent, id, "case {case}, seed {seed}");
762 let mut remote_text = original.clone();784 let mut remote_text = original.clone();
763 remote_text.splice(at..at + 1, "Ω🐈π".chars());785 remote_text.splice(at..at + 1, "Ω🐈π".chars());
764 let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix;786 let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix;
...@@ -771,18 +793,21 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {...@@ -771,18 +793,21 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
771 )793 )
772 .unwrap();794 .unwrap();
773 let mut server = Server::new(&remote);795 let mut server = Server::new(&remote);
774 assert_eq!(796 assert!(
775 cache.sync_once(&mut server).unwrap(),797 matches!(
776 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),798 cache.sync_once(&mut server).unwrap().edit,
799 Some((_, EditStatus::Conflict(ConflictKind::ContentChanged)))
800 ),
777 "case {case}, seed {seed}"801 "case {case}, seed {seed}"
778 );802 );
779 let at_remote = start + prefix.len() as u32;803 let at_remote = start + prefix.len() as u32;
780 model_ops::review(&cache, id, oid, |page| {804 let reviewed = model_ops::review(&cache, id, oid, |page| {
781 model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ")805 model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ")
782 })806 })
807 .unwrap()
783 .unwrap();808 .unwrap();
784 assert!(809 assert!(
785 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id),810 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == reviewed),
786 "case {case}, seed {seed}"811 "case {case}, seed {seed}"
787 );812 );
788 let mut expected = original.clone();813 let mut expected = original.clone();
...@@ -799,7 +824,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {...@@ -799,7 +824,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
799}824}
800825
801#[test]826#[test]
802fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {827fn remote_changes_after_resolution_are_never_overwritten() {
803 struct ChangedAfterRead {828 struct ChangedAfterRead {
804 server: Server,829 server: Server,
805 change: Option<Vec<u8>>,830 change: Option<Vec<u8>>,
...@@ -826,20 +851,22 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {...@@ -826,20 +851,22 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
826 let (sid, oid, _) = text(&source);851 let (sid, oid, _) = text(&source);
827 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();852 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
828 let id = save(&cache, oid, 1..2, "L").unwrap();853 let id = save(&cache, oid, 1..2, "L").unwrap();
829 let local = cache.snapshot().unwrap();
830 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();854 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
831 let mut remote = ChangedAfterRead {855 let mut remote = ChangedAfterRead {
832 server: Server::new(&remote),856 server: Server::new(&remote),
833 change: None,857 change: None,
834 };858 };
835 assert!(matches!(859 assert!(matches!(
836 cache.sync_once(&mut remote).unwrap(),860 cache.sync_once(&mut remote).unwrap().edit,
837 Some((_, EditStatus::Conflict(_)))861 Some((_, EditStatus::Conflict(_)))
838 ));862 ));
839 model_ops::review(&cache, id, oid, |page| {863 let id = model_ops::review(&cache, id, oid, |page| {
840 model_ops::replace_text(page, oid, 1..2, "L")864 model_ops::replace_text(page, oid, 1..2, "L")
841 })865 })
866 .unwrap()
842 .unwrap();867 .unwrap();
868 let local = content(&cache.snapshot().unwrap(), oid);
869 assert_eq!(local, "aLc");
843 let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap();870 let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap();
844 if after_read {871 if after_read {
845 remote.change = Some(changed.clone());872 remote.change = Some(changed.clone());
...@@ -854,20 +881,19 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {...@@ -854,20 +881,19 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
854 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));881 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
855 }882 }
856 assert_eq!(883 assert_eq!(
857 cache.sync_once(&mut remote).unwrap(),884 cache.sync_once(&mut remote).unwrap().edit,
858 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))885 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
859 );886 );
860 assert_eq!(remote.server.durable, changed);887 assert_eq!(remote.server.durable, changed);
861 assert_eq!(remote.server.visible, changed);888 assert_eq!(remote.server.visible, changed);
862 assert_eq!(remote.server.publications, usize::from(after_read));889 assert_eq!(remote.server.publications, usize::from(after_read));
863 assert_eq!(cache.snapshot().unwrap(), local);890 assert_eq!(content(&cache.snapshot().unwrap(), oid), local);
864 let notebook::Operation::Page(intent) = &cache.pending().unwrap()[0].operation else {891 assert!(cache.pending().unwrap()[0].edit.ops.is_empty());
865 panic!()892 assert!(matches!(
866 };893 &cache.pending().unwrap()[1].edit.ops[..],
867 assert_eq!(894 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }]
868 intent.text_change(),895 if *range == (1..2) && with == "L"
869 Some((oid, "aRc".into(), 1..2, "L".into()))896 ));
870 );
871 }897 }
872}898}
873899
...@@ -880,7 +906,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {...@@ -880,7 +906,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
880 let cache = Replica::create(&path, &source).unwrap();906 let cache = Replica::create(&path, &source).unwrap();
881 let mut server = Server::new(&source);907 let mut server = Server::new(&source);
882 let published = save(&cache, object, 0..0, "Published ").unwrap();908 let published = save(&cache, object, 0..0, "Published ").unwrap();
883 let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap();909 let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap();
884 assert!(matches!(receipt, EditStatus::Published { .. }));910 assert!(matches!(receipt, EditStatus::Published { .. }));
885 let published_image = cache.snapshot().unwrap();911 let published_image = cache.snapshot().unwrap();
886 cache912 cache
...@@ -891,7 +917,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {...@@ -891,7 +917,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
891 server.visible.clone_from(&source);917 server.visible.clone_from(&source);
892 server.durable.clone_from(&source);918 server.durable.clone_from(&source);
893 let cache = Replica::open(&path).unwrap();919 let cache = Replica::open(&path).unwrap();
894 assert_eq!(cache.sync_once(&mut server).unwrap(), None);920 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
895 assert_eq!(cache.snapshot().unwrap(), source);921 assert_eq!(cache.snapshot().unwrap(), source);
896 assert_eq!(cache.remote_snapshot().unwrap(), source);922 assert_eq!(cache.remote_snapshot().unwrap(), source);
897 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));923 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
...@@ -918,7 +944,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -918,7 +944,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
918 let cache = Replica::create(&path, &source).unwrap();944 let cache = Replica::create(&path, &source).unwrap();
919 let mut server = Server::new(&source);945 let mut server = Server::new(&source);
920 let published = save(&cache, object, 0..0, "Published ").unwrap();946 let published = save(&cache, object, 0..0, "Published ").unwrap();
921 let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap();947 let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap();
922 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();948 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
923 let queued = save(&cache, object, end..end, " Local").unwrap();949 let queued = save(&cache, object, end..end, " Local").unwrap();
924 if uncertain {950 if uncertain {
...@@ -936,14 +962,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -936,14 +962,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
936 server.durable.clone_from(&source);962 server.durable.clone_from(&source);
937 let attempts = server.publications;963 let attempts = server.publications;
938 let cache = Replica::open(&path).unwrap();964 let cache = Replica::open(&path).unwrap();
939 let (_, status) = cache.sync_once(&mut server).unwrap().unwrap();965 let (_, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
940 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));966 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
941 if uncertain {967 if uncertain {
942 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));968 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
943 assert_eq!(Some(status.clone()), prior_status);969 assert_eq!(Some(status.clone()), prior_status);
944 assert_eq!(server.publications, attempts);970 assert_eq!(server.publications, attempts);
945 assert_eq!(server.visible, source);971 assert_eq!(server.visible, source);
946 assert_eq!(cache.snapshot().unwrap(), local);972 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
947 assert_eq!(cache.pending().unwrap(), pending);973 assert_eq!(cache.pending().unwrap(), pending);
948 } else {974 } else {
949 assert!(matches!(status, EditStatus::Published { .. }));975 assert!(matches!(status, EditStatus::Published { .. }));
...@@ -953,7 +979,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -953,7 +979,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
953 }979 }
954 let archive =980 let archive =
955 notebook::Recovery::open(directory.path().join("before-restore.sqlite")).unwrap();981 notebook::Recovery::open(directory.path().join("before-restore.sqlite")).unwrap();
956 assert_eq!(archive.snapshot().unwrap(), local);982 assert_eq!(pages(&archive.snapshot().unwrap()), pages(&local));
957 assert_eq!(archive.pending().unwrap(), pending);983 assert_eq!(archive.pending().unwrap(), pending);
958 assert_eq!(archive.status(queued).unwrap(), prior_status);984 assert_eq!(archive.status(queued).unwrap(), prior_status);
959 drop(cache);985 drop(cache);
...@@ -964,7 +990,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {...@@ -964,7 +990,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
964}990}
965991
966#[test]992#[test]
967fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {993fn restore_conflicts_keep_dependent_work_until_mine_publishes_it() {
968 let directory = tempfile::tempdir().unwrap();994 let directory = tempfile::tempdir().unwrap();
969 let path = directory.path().join("cache.sqlite");995 let path = directory.path().join("cache.sqlite");
970 let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap();996 let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap();
...@@ -972,19 +998,20 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -972,19 +998,20 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
972 let cache = Replica::create(&path, &source).unwrap();998 let cache = Replica::create(&path, &source).unwrap();
973 let mut server = Server::new(&source);999 let mut server = Server::new(&source);
974 let published = save(&cache, object, 0..0, "Published ").unwrap();1000 let published = save(&cache, object, 0..0, "Published ").unwrap();
975 let (_, receipt) = cache.sync_once(&mut server).unwrap().unwrap();1001 let (_, receipt) = cache.sync_once(&mut server).unwrap().edit.unwrap();
976 let head = save(&cache, object, 0..9, "Revised").unwrap();1002 let head = save(&cache, object, 0..9, "Revised").unwrap();
977 server.visible.clone_from(&source);1003 server.visible.clone_from(&source);
978 server.durable.clone_from(&source);1004 server.durable.clone_from(&source);
979 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);1005 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
980 assert_eq!(1006 assert_eq!(
981 cache.sync_once(&mut server).unwrap(),1007 cache.sync_once(&mut server).unwrap().edit,
982 Some((head, conflict.clone()))1008 Some((head, conflict.clone()))
983 );1009 );
984 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();1010 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
985 let dependent = save(&cache, object, end..end, " Later").unwrap();1011 let dependent = save(&cache, object, end..end, " Later").unwrap();
986 assert_ne!(head, dependent);1012 assert_ne!(head, dependent);
987 let local = cache.snapshot().unwrap();1013 let local = text(&cache.snapshot().unwrap()).2;
1014 assert_eq!(local, "Revised Original Later");
988 let pending = cache.pending().unwrap();1015 let pending = cache.pending().unwrap();
989 assert_eq!(pending.len(), 2);1016 assert_eq!(pending.len(), 2);
990 cache1017 cache
...@@ -993,60 +1020,39 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {...@@ -993,60 +1020,39 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
993 drop(cache);1020 drop(cache);
994 let cache = Replica::open(&path).unwrap();1021 let cache = Replica::open(&path).unwrap();
995 assert_eq!(cache.status(head).unwrap(), Some(conflict.clone()));1022 assert_eq!(cache.status(head).unwrap(), Some(conflict.clone()));
1023 assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone()));
996 assert_eq!(cache.pending().unwrap(), pending);1024 assert_eq!(cache.pending().unwrap(), pending);
997 assert_eq!(cache.snapshot().unwrap(), local);
998 let reviewed = edited(&source, object, 0..0, "Revised ");
999 let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap();1025 let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap();
1000 server.visible.clone_from(&changed);1026 server.visible.clone_from(&changed);
1001 server.durable.clone_from(&changed);1027 server.durable.clone_from(&changed);
1002 assert_eq!(1028 assert_eq!(
1003 cache.sync_once(&mut server).unwrap(),1029 cache.sync_once(&mut server).unwrap().edit,
1004 Some((head, conflict.clone()))
1005 );
1006 assert!(
1007 matches!(cache.review_page(head, &local, &source, &reviewed),
1008 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy)
1009 );
1010 assert_eq!(cache.pending().unwrap(), pending);
1011 assert_eq!(server.publications, 1);
1012 let reviewed = edited(&changed, object, 0..0, "Revised ");
1013 cache
1014 .review_page(head, &local, &changed, &reviewed)
1015 .unwrap();
1016 assert_eq!(cache.pending().unwrap()[1], pending[1]);
1017 drop(cache);
1018 let cache = Replica::open(&path).unwrap();
1019 assert!(matches!(cache.sync_once(&mut server).unwrap(),
1020 Some((actual, EditStatus::Published { .. })) if actual == head));
1021 assert_eq!(
1022 cache.sync_once(&mut server).unwrap(),
1023 Some((dependent, conflict.clone()))1030 Some((dependent, conflict.clone()))
1024 );1031 );
1025 assert_eq!(server.publications, 2);1032 assert_eq!(cache.remote_snapshot().unwrap(), changed);
1026 let dependent_local = cache.snapshot().unwrap();1033 assert_eq!(server.publications, 1);
1027 let current = cache.remote_snapshot().unwrap();1034 cache.resolve(head, Resolution::Mine).unwrap();
1028 let reviewed = edited(&current, object, 16..16, " Later");
1029 cache
1030 .review_page(dependent, &dependent_local, &current, &reviewed)
1031 .unwrap();
1032 drop(cache);1035 drop(cache);
1033 let cache = Replica::open(&path).unwrap();1036 let cache = Replica::open(&path).unwrap();
1034 assert!(matches!(cache.sync_once(&mut server).unwrap(),1037 assert!(matches!(
1035 Some((actual, EditStatus::Published { .. })) if actual == dependent));1038 cache.sync_once(&mut server).unwrap().edit,
1036 assert_eq!(text(&server.durable).2, "Revised Original Later Remote");1039 Some((_, EditStatus::Published { .. }))
1040 ));
1041 assert_eq!(text(&server.durable).2, local);
1042 assert!(matches!(
1043 cache.status(head).unwrap(),
1044 Some(EditStatus::Published { .. })
1045 ));
1037 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));1046 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
1038 let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap();1047 let archive = notebook::Recovery::open(directory.path().join("conflicted.sqlite")).unwrap();
1039 assert_eq!(archive.snapshot().unwrap(), local);1048 assert_eq!(text(&archive.snapshot().unwrap()).2, local);
1040 assert_eq!(archive.pending().unwrap(), pending);1049 assert_eq!(archive.pending().unwrap(), pending);
1041 assert_eq!(archive.status(head).unwrap(), Some(conflict.clone()));1050 assert_eq!(archive.status(head).unwrap(), Some(conflict.clone()));
1042}1051}
10431052
1044#[test]1053#[test]
1045fn cache_open_validates_the_tail_before_head_only_synchronization() {1054fn cache_open_validates_the_tail_before_head_only_synchronization() {
1046 for damage in [1055 for damage in ["edit='{}'", "edit=replace(edit, 'Create', 'Delete')"] {
1047 "operation='{}'",
1048 "space=(SELECT space FROM edits ORDER BY id LIMIT 1)",
1049 ] {
1050 let directory = tempfile::tempdir().unwrap();1056 let directory = tempfile::tempdir().unwrap();
1051 let path = directory.path().join("cache.sqlite");1057 let path = directory.path().join("cache.sqlite");
1052 let source = onestore::create_section("tail.one", "Original", "Fixture").unwrap();1058 let source = onestore::create_section("tail.one", "Original", "Fixture").unwrap();
...@@ -1054,10 +1060,7 @@ fn cache_open_validates_the_tail_before_head_only_synchronization() {...@@ -1054,10 +1060,7 @@ fn cache_open_validates_the_tail_before_head_only_synchronization() {
1054 let cache = Replica::create(&path, &source).unwrap();1060 let cache = Replica::create(&path, &source).unwrap();
1055 save(&cache, object, 0..0, "Head ").unwrap();1061 save(&cache, object, 0..0, "Head ").unwrap();
1056 let page = onestore::PageCreation::new(None, Some("Tail"), "Fixture").unwrap();1062 let page = onestore::PageCreation::new(None, Some("Tail"), "Fixture").unwrap();
1057 let tail = cache1063 let tail = section_op(&cache, onestore::op::SectionOp::Create(page));
1058 .create_page(&cache.snapshot().unwrap(), &page)
1059 .unwrap()
1060 .unwrap();
1061 assert_eq!(cache.pending().unwrap().len(), 2);1064 assert_eq!(cache.pending().unwrap().len(), 2);
1062 drop(cache);1065 drop(cache);
1063 let database = rusqlite::Connection::open(&path).unwrap();1066 let database = rusqlite::Connection::open(&path).unwrap();
crates/notebook/tests/sync/tree.rs+32-29
...@@ -7,12 +7,7 @@ use onestore::page::{Paragraph, ParagraphContent, TableCell};...@@ -7,12 +7,7 @@ use onestore::page::{Paragraph, ParagraphContent, TableCell};
77
8/// Saves an edited model of `space`, reaching content that outline helpers cannot.8/// Saves an edited model of `space`, reaching content that outline helpers cannot.
9fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> {9fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> {
10 let source = cache.snapshot().unwrap();10 model_ops::save_page(cache, space, edit).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}11}
1712
18#[test]13#[test]
...@@ -97,7 +92,7 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() {...@@ -97,7 +92,7 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() {
97 drop(cache);92 drop(cache);
98 let cache = Replica::open(&path).unwrap();93 let cache = Replica::open(&path).unwrap();
99 assert_eq!(cache.pending().unwrap(), queue);94 assert_eq!(cache.pending().unwrap(), queue);
100 assert_eq!(cache.snapshot().unwrap(), local);95 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
101 let mut server = Server::new(&remote_with(&source, space, |page| {96 let mut server = Server::new(&remote_with(&source, space, |page| {
102 insert_child(page, paragraphs[0], "New remote child");97 insert_child(page, paragraphs[0], "New remote child");
103 restyle(page, texts[1], 0..7, |format| format.bold = Some(true));98 restyle(page, texts[1], 0..7, |format| format.bold = Some(true));
...@@ -169,7 +164,7 @@ fn native_empty_child_list_normalization_merges_with_a_local_deletion() {...@@ -169,7 +164,7 @@ fn native_empty_child_list_normalization_merges_with_a_local_deletion() {
169 let cache = Replica::open(&path).unwrap();164 let cache = Replica::open(&path).unwrap();
170 let mut server = Server::new(native);165 let mut server = Server::new(native);
171 assert!(matches!(166 assert!(matches!(
172 cache.sync_once(&mut server).unwrap(),167 cache.sync_once(&mut server).unwrap().edit,
173 Some((published, EditStatus::Published { .. })) if published == id168 Some((published, EditStatus::Published { .. })) if published == id
174 ));169 ));
175 assert_eq!(server.publications, 1);170 assert_eq!(server.publications, 1);
...@@ -224,18 +219,12 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() {...@@ -224,18 +219,12 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() {
224 let local = cache.snapshot().unwrap();219 let local = cache.snapshot().unwrap();
225 let mut server = Server::new(&remote_with(&source, space, remote));220 let mut server = Server::new(&remote_with(&source, space, remote));
226 assert_eq!(221 assert_eq!(
227 cache.sync_once(&mut server).unwrap(),222 cache.sync_once(&mut server).unwrap().edit,
228 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))223 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
229 );224 );
230 assert_eq!(server.publications, 0);225 assert_eq!(server.publications, 0);
231 assert_eq!(cache.pending().unwrap(), queue);226 assert_eq!(cache.pending().unwrap(), queue);
232 assert_eq!(cache.snapshot().unwrap(), local);227 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
233 let observed = cache.remote_snapshot().unwrap();
234 assert!(
235 cache
236 .review_page(id, &source, &observed, &page_of(&observed, space))
237 .is_err()
238 );
239 drop(cache);228 drop(cache);
240 let cache = Replica::open(&path).unwrap();229 let cache = Replica::open(&path).unwrap();
241 assert_eq!(230 assert_eq!(
...@@ -270,9 +259,10 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {...@@ -270,9 +259,10 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {
270 Box::new(move |page| delete_paragraph(page, texts[1])),259 Box::new(move |page| delete_paragraph(page, texts[1])),
271 Some(&[2, 3, 0]),260 Some(&[2, 3, 0]),
272 ),261 ),
262 // Both moved a paragraph last; the local move replays after the remote one.
273 (263 (
274 Box::new(move |page| move_subtree(page, paragraphs[1], None, None)),264 Box::new(move |page| move_subtree(page, paragraphs[1], None, None)),
275 Some(&[2, 3, 0, 1]),265 Some(&[2, 3, 1, 0]),
276 ),266 ),
277 (267 (
278 Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)),268 Box::new(move |page| move_subtree(page, paragraphs[0], Some(paragraphs[1]), None)),
...@@ -289,7 +279,7 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {...@@ -289,7 +279,7 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {
289 .unwrap()279 .unwrap()
290 .unwrap();280 .unwrap();
291 let mut server = Server::new(&remote_with(&source, space, remote));281 let mut server = Server::new(&remote_with(&source, space, remote));
292 let result = cache.sync_once(&mut server).unwrap().unwrap();282 let result = cache.sync_once(&mut server).unwrap().edit.unwrap();
293 assert_eq!(result.0, id);283 assert_eq!(result.0, id);
294 let Some(expected) = expected else {284 let Some(expected) = expected else {
295 assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged));285 assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged));
...@@ -361,9 +351,14 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell {...@@ -361,9 +351,14 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell {
361 .expect("the cell is on the page")351 .expect("the cell is on the page")
362}352}
363353
364/// Moves a cell's sole paragraph to the end of another cell, or deletes it.354/// Moves a cell's sole paragraph to the end of another cell, or deletes it, leaving the
355/// cell an empty paragraph as the writers do.
365fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) {356fn empty_cell(page: &mut Page, cell: ExGuid, destination: Option<ExGuid>) {
366 let moved = cell_mut(page, cell).paragraphs.remove(0);357 let emptied = cell_mut(page, cell);
358 let mut replacement = model_ops::fresh_paragraph(&emptied.paragraphs[0], "");
359 replacement.level = emptied.paragraphs[0].level;
360 emptied.paragraphs.insert(0, replacement);
361 let moved = cell_mut(page, cell).paragraphs.remove(1);
367 let Some(destination) = destination else {362 let Some(destination) = destination else {
368 return;363 return;
369 };364 };
...@@ -436,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(...@@ -436,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(
436 drop(cache);431 drop(cache);
437 let cache = Replica::open(&path).unwrap();432 let cache = Replica::open(&path).unwrap();
438 assert_eq!(cache.pending().unwrap(), queue);433 assert_eq!(cache.pending().unwrap(), queue);
439 assert_eq!(cache.snapshot().unwrap(), local);434 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
440 let mut server = Server::new(&if competing {435 let mut server = Server::new(&if competing {
441 remote_with(source, space, |page| {436 remote_with(source, space, |page| {
442 let target = cell_mut(page, cell);437 let target = cell_mut(page, cell);
...@@ -452,13 +447,21 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(...@@ -452,13 +447,21 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(
452 })447 })
453 });448 });
454 if competing {449 if competing {
455 assert_eq!(450 // The remote's new paragraph and the replacement both stay; the target goes.
456 cache.sync_once(&mut server).unwrap(),451 published(&cache, &mut server, id);
457 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))452 let durable = page_of(&server.durable, space);
453 let texts: Vec<_> = cell_of(&durable, cell)
454 .paragraphs
455 .iter()
456 .map(|paragraph| paragraph.text().unwrap())
457 .collect();
458 assert_eq!(texts.len(), 2);
459 assert!(texts.iter().all(|text| text.id != target));
460 assert!(
461 texts
462 .iter()
463 .any(|text| text.text.text() == "Remote sibling")
458 );464 );
459 assert_eq!(server.publications, 0);
460 assert_eq!(cache.pending().unwrap(), queue);
461 assert_eq!(cache.snapshot().unwrap(), local);
462 continue;465 continue;
463 }466 }
464 published(&cache, &mut server, id);467 published(&cache, &mut server, id);
...@@ -573,7 +576,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten...@@ -573,7 +576,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten
573 let id = save(&cache, dependent, save_both).unwrap().unwrap();576 let id = save(&cache, dependent, save_both).unwrap().unwrap();
574 let local = cache.snapshot().unwrap();577 let local = cache.snapshot().unwrap();
575 let queue = cache.pending().unwrap();578 let queue = cache.pending().unwrap();
576 let merged = match cache.sync_once(&mut server).unwrap().unwrap() {579 let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() {
577 (actual, EditStatus::Published { .. }) => {580 (actual, EditStatus::Published { .. }) => {
578 assert_eq!(actual, id, "{name}");581 assert_eq!(actual, id, "{name}");
579 true582 true
...@@ -585,7 +588,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten...@@ -585,7 +588,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten
585 "{name}"588 "{name}"
586 );589 );
587 assert_eq!(cache.pending().unwrap(), queue);590 assert_eq!(cache.pending().unwrap(), queue);
588 assert_eq!(cache.snapshot().unwrap(), local);591 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
589 drop(cache);592 drop(cache);
590 let cache = Replica::open(&path).unwrap();593 let cache = Replica::open(&path).unwrap();
591 review(&cache, id, dependent, save_both).unwrap();594 review(&cache, id, dependent, save_both).unwrap();
crates/notebook/tests/sync_formatting.rs+66-32
...@@ -10,7 +10,7 @@ use onestore::{...@@ -10,7 +10,7 @@ use onestore::{
1010
11#[path = "support/server.rs"]11#[path = "support/server.rs"]
12mod server;12mod server;
13use server::{Fault, Server};13use server::{Fault, Server, pages};
14#[path = "support/model_ops.rs"]14#[path = "support/model_ops.rs"]
15mod model_ops;15mod model_ops;
16use model_ops::{16use model_ops::{
...@@ -103,7 +103,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {...@@ -103,7 +103,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
103 .unwrap();103 .unwrap();
104 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));104 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
105 assert!(105 assert!(
106 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)106 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
107 );107 );
108 let published = page_of(&server.durable, space);108 let published = page_of(&server.durable, space);
109 assert!(109 assert!(
...@@ -116,7 +116,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {...@@ -116,7 +116,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
116}116}
117117
118#[test]118#[test]
119fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {119fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() {
120 let (space, page) = page_titled(OUTLINES, PAGE);120 let (space, page) = page_titled(OUTLINES, PAGE);
121 let ids = plain(&page);121 let ids = plain(&page);
122 for typed in [false, true] {122 for typed in [false, true] {
...@@ -135,13 +135,28 @@ fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {...@@ -135,13 +135,28 @@ fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {
135 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));135 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
136 }136 }
137 });137 });
138 assert_eq!(138 let outcome = cache.sync_once(&mut server).unwrap().edit;
139 cache.sync_once(&mut server).unwrap(),139 if typed {
140 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),140 // Text typed before the formatted range moves it; the ranges do not meet.
141 "typed {typed}"141 assert!(matches!(outcome, Some((n, EditStatus::Published { .. })) if n == id));
142 );142 let published = page_of(&server.durable, space);
143 assert_eq!(server.publications, 0);143 assert!(text_of(&published, ids[0]).starts_with("Remote Anchor"));
144 assert_eq!(cache.snapshot().unwrap(), local);144 let styles = formats(&published, ids[0]);
145 for (offset, format) in styles.iter().enumerate() {
146 assert_eq!(
147 format.bold == Some(true),
148 (7..13).contains(&offset),
149 "{offset}"
150 );
151 }
152 } else {
153 assert_eq!(
154 outcome,
155 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
156 );
157 assert_eq!(server.publications, 0);
158 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
159 }
145 }160 }
146}161}
147162
...@@ -163,7 +178,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {...@@ -163,7 +178,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {
163 });178 });
164 for _ in 0..2 {179 for _ in 0..2 {
165 assert_eq!(180 assert_eq!(
166 cache.sync_once(&mut server).unwrap(),181 cache.sync_once(&mut server).unwrap().edit,
167 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))182 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
168 );183 );
169 }184 }
...@@ -179,7 +194,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {...@@ -179,7 +194,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {
179 })194 })
180 .unwrap();195 .unwrap();
181 assert!(matches!(196 assert!(matches!(
182 cache.sync_once(&mut server).unwrap(),197 cache.sync_once(&mut server).unwrap().edit,
183 Some((_, EditStatus::Published { .. }))198 Some((_, EditStatus::Published { .. }))
184 ));199 ));
185 let published = formats(&page_of(&server.durable, space), ids[0]);200 let published = formats(&page_of(&server.durable, space), ids[0]);
...@@ -223,7 +238,7 @@ fn every_visual_attribute_publishes_through_a_page_model_save() {...@@ -223,7 +238,7 @@ fn every_visual_attribute_publishes_through_a_page_model_save() {
223 .unwrap();238 .unwrap();
224 let mut server = Server::new(OUTLINES);239 let mut server = Server::new(OUTLINES);
225 assert!(240 assert!(
226 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id),241 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id),
227 "{name}"242 "{name}"
228 );243 );
229 let mut expected = formats(&page_of(OUTLINES, space), ids[0]);244 let mut expected = formats(&page_of(OUTLINES, space), ids[0]);
...@@ -282,9 +297,21 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -282,9 +297,21 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
282 }297 }
283 }298 }
284 });299 });
285 let outcome = cache.sync_once(&mut server).unwrap().unwrap();300 let remote_page = page_of(&server.durable, space);
301 let outcome = cache.sync_once(&mut server).unwrap().edit.unwrap();
286 assert_eq!(outcome.0, id, "seed {seed}");302 assert_eq!(outcome.0, id, "seed {seed}");
287 if remote.contains(&target) {303 // Bullets the remote typed before the target's text, and whether it underlined a
304 // character the target already had.
305 let mut bullets = vec![0_u32; ids.len()];
306 let mut underlined = vec![false; ids.len()];
307 for (step, &at) in remote.iter().enumerate() {
308 if step % 2 == 0 {
309 bullets[at] += 1;
310 } else if bullets[at] == 0 {
311 underlined[at] = true;
312 }
313 }
314 if underlined[target] && start == 0 {
288 conflicts += 1;315 conflicts += 1;
289 assert_eq!(316 assert_eq!(
290 outcome.1,317 outcome.1,
...@@ -292,7 +319,11 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -292,7 +319,11 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
292 "seed {seed}"319 "seed {seed}"
293 );320 );
294 assert_eq!(server.publications, 0, "seed {seed}");321 assert_eq!(server.publications, 0, "seed {seed}");
295 assert_eq!(cache.snapshot().unwrap(), local, "seed {seed}");322 assert_eq!(
323 pages(&cache.snapshot().unwrap()),
324 pages(&local),
325 "seed {seed}"
326 );
296 continue;327 continue;
297 }328 }
298 published += 1;329 published += 1;
...@@ -301,8 +332,9 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -301,8 +332,9 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
301 "seed {seed}: {outcome:?}"332 "seed {seed}: {outcome:?}"
302 );333 );
303 let page = page_of(&server.durable, space);334 let page = page_of(&server.durable, space);
304 let mut expected: Vec<Format> = formats(&page_of(OUTLINES, space), ids[target]);335 let shift = bullets[target] as usize;
305 for format in &mut expected[start as usize..end as usize] {336 let mut expected: Vec<Format> = formats(&remote_page, ids[target]);
337 for format in &mut expected[start as usize + shift..end as usize + shift] {
306 attribute(format);338 attribute(format);
307 }339 }
308 assert_eq!(formats(&page, ids[target]), expected, "seed {seed}");340 assert_eq!(formats(&page, ids[target]), expected, "seed {seed}");
...@@ -316,7 +348,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone...@@ -316,7 +348,7 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
316 assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}");348 assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}");
317 }349 }
318 assert!(350 assert!(
319 published >= 16 && conflicts >= 16,351 published >= 16,
320 "{published} published, {conflicts} conflicts"352 "{published} published, {conflicts} conflicts"
321 );353 );
322}354}
...@@ -338,11 +370,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {...@@ -338,11 +370,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {
338 let pending = cache.pending().unwrap();370 let pending = cache.pending().unwrap();
339 drop(cache);371 drop(cache);
340 cache = Replica::open(&path).unwrap();372 cache = Replica::open(&path).unwrap();
341 assert_eq!(cache.snapshot().unwrap(), local);373 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
342 assert_eq!(cache.pending().unwrap(), pending);374 assert_eq!(cache.pending().unwrap(), pending);
343 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));375 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
344 assert!(376 assert!(
345 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)377 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
346 );378 );
347 let published = page_of(&server.durable, space);379 let published = page_of(&server.durable, space);
348 let inserted = plain(&published)380 let inserted = plain(&published)
...@@ -361,7 +393,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {...@@ -361,7 +393,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {
361 .unwrap()393 .unwrap()
362 .unwrap();394 .unwrap();
363 assert!(395 assert!(
364 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == styled)396 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == styled)
365 );397 );
366 let styles = formats(&page_of(&server.durable, space), inserted);398 let styles = formats(&page_of(&server.durable, space), inserted);
367 for (offset, format) in styles.iter().enumerate() {399 for (offset, format) in styles.iter().enumerate() {
...@@ -395,17 +427,17 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {...@@ -395,17 +427,17 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
395 if matches!(fault, Fault::UnknownBefore) {427 if matches!(fault, Fault::UnknownBefore) {
396 for _ in 0..3 {428 for _ in 0..3 {
397 assert_eq!(429 assert_eq!(
398 cache.sync_once(&mut server).unwrap(),430 cache.sync_once(&mut server).unwrap().edit,
399 Some((id, state.clone()))431 Some((id, state.clone()))
400 );432 );
401 }433 }
402 assert_eq!(server.publications, 1);434 assert_eq!(server.publications, 1);
403 assert_eq!(server.durable, OUTLINES);435 assert_eq!(server.durable, OUTLINES);
404 assert_eq!(cache.snapshot().unwrap(), local);436 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
405 continue;437 continue;
406 }438 }
407 assert!(matches!(439 assert!(matches!(
408 cache.sync_once(&mut server).unwrap(),440 cache.sync_once(&mut server).unwrap().edit,
409 Some((_, EditStatus::Published { .. }))441 Some((_, EditStatus::Published { .. }))
410 ));442 ));
411 assert_eq!(server.publications, 1);443 assert_eq!(server.publications, 1);
...@@ -438,7 +470,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() {...@@ -438,7 +470,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() {
438 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));470 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
439 assert_eq!(server.durable, OUTLINES);471 assert_eq!(server.durable, OUTLINES);
440 assert!(472 assert!(
441 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)473 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
442 );474 );
443 assert_eq!((server.publications, server.confirmations), (0, 2));475 assert_eq!((server.publications, server.confirmations), (0, 2));
444 assert!(cache.pending().unwrap().is_empty());476 assert!(cache.pending().unwrap().is_empty());
...@@ -459,8 +491,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {...@@ -459,8 +491,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
459 restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true));491 restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true));
460 let directory = tempfile::tempdir().unwrap();492 let directory = tempfile::tempdir().unwrap();
461 let cache = cache(&directory);493 let cache = cache(&directory);
462 let id = cache494 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
463 .save(OUTLINES, space, &after, AUTHOR)
464 .unwrap()495 .unwrap()
465 .unwrap();496 .unwrap();
466 let mut server = Server::new(OUTLINES);497 let mut server = Server::new(OUTLINES);
...@@ -476,7 +507,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {...@@ -476,7 +507,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
476 });507 });
477 server.visible.clone_from(&partial.visible);508 server.visible.clone_from(&partial.visible);
478 assert_eq!(509 assert_eq!(
479 cache.sync_once(&mut server).unwrap(),510 cache.sync_once(&mut server).unwrap().edit,
480 attempt.map(|state| (id, state))511 attempt.map(|state| (id, state))
481 );512 );
482 assert_eq!(server.confirmations, 0);513 assert_eq!(server.confirmations, 0);
...@@ -486,7 +517,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {...@@ -486,7 +517,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
486 server.visible.clone_from(&complete.visible);517 server.visible.clone_from(&complete.visible);
487 assert_eq!(page_of(&server.visible, space), after);518 assert_eq!(page_of(&server.visible, space), after);
488 assert!(519 assert!(
489 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)520 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
490 );521 );
491 assert_eq!((server.publications, server.confirmations), (1, 1));522 assert_eq!((server.publications, server.confirmations), (1, 1));
492 assert!(cache.pending().unwrap().is_empty());523 assert!(cache.pending().unwrap().is_empty());
...@@ -497,7 +528,10 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() {...@@ -497,7 +528,10 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() {
497 let (space, page) = page_titled(OUTLINES, PAGE);528 let (space, page) = page_titled(OUTLINES, PAGE);
498 let directory = tempfile::tempdir().unwrap();529 let directory = tempfile::tempdir().unwrap();
499 let cache = cache(&directory);530 let cache = cache(&directory);
500 assert_eq!(cache.save(OUTLINES, space, &page, AUTHOR).unwrap(), None);531 assert_eq!(
532 model_ops::save_as(&cache, space, &page, AUTHOR).unwrap(),
533 None
534 );
501 assert!(cache.pending().unwrap().is_empty());535 assert!(cache.pending().unwrap().is_empty());
502}536}
503537
...@@ -533,7 +567,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() {...@@ -533,7 +567,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() {
533 drop(cache);567 drop(cache);
534 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();568 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
535 assert!(569 assert!(
536 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)570 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
537 );571 );
538 assert_eq!(server.publications, 1);572 assert_eq!(server.publications, 1);
539 let page = page_of(&server.durable, space);573 let page = page_of(&server.durable, space);
crates/notebook/tests/sync_model.rs+78-91
...@@ -10,7 +10,6 @@ use server::*;...@@ -10,7 +10,6 @@ use server::*;
10#[path = "support/model_ops.rs"]10#[path = "support/model_ops.rs"]
11mod model_ops;11mod model_ops;
1212
13use notebook::{Operation, PageIntent};
14use onestore::page::{Page, PageObject};13use onestore::page::{Page, PageObject};
1514
16const OUTLINES: &[u8] =15const OUTLINES: &[u8] =
...@@ -77,70 +76,58 @@ fn a_saved_page_model_publishes_and_reads_back() {...@@ -77,70 +76,58 @@ fn a_saved_page_model_publishes_and_reads_back() {
77 let directory = tempfile::tempdir().unwrap();76 let directory = tempfile::tempdir().unwrap();
78 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();77 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
79 append(&mut after, " saved 🦀");78 append(&mut after, " saved 🦀");
80 let id = cache79 let id = model_ops::save_as(&cache, space, &after, "Model author")
81 .save(OUTLINES, space, &after, "Model author")
82 .unwrap()80 .unwrap()
83 .unwrap();81 .unwrap();
84 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));82 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
85 assert_eq!(page_of(&cache.snapshot().unwrap(), space), after);83 assert_eq!(page_of(&cache.snapshot().unwrap(), space), after);
86 let pending = cache.pending().unwrap();84 let pending = cache.pending().unwrap();
87 assert_eq!(pending.len(), 1);85 assert_eq!(pending.len(), 1);
88 let Operation::Page(PageIntent {86 assert_eq!(pending[0].author, "Model author");
89 before,87 assert!(
90 after: stored,88 pending[0]
91 author,89 .edit
92 }) = &pending[0].operation90 .ops
93 else {91 .iter()
94 panic!()92 .all(|op| matches!(op, onestore::op::Op::Page { space: s, .. } if *s == space))
95 };
96 assert_eq!(
97 (before, stored, author.as_str()),
98 (&page_of(OUTLINES, space), &after, "Model author")
99 );93 );
100 let mut server = Server::new(OUTLINES);94 let mut server = Server::new(OUTLINES);
101 assert!(95 assert!(
102 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)96 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
103 );97 );
104 assert_eq!(page_of(&server.durable, space), after);98 assert_eq!(page_of(&server.durable, space), after);
105 assert_eq!(server.publications, 1);99 assert_eq!(server.publications, 1);
106 assert!(cache.pending().unwrap().is_empty());100 assert!(cache.pending().unwrap().is_empty());
107 assert_eq!(101 assert_eq!(
108 cache102 model_ops::save_as(&cache, space, &after, "Model author").unwrap(),
109 .save(&cache.snapshot().unwrap(), space, &after, "Model author")
110 .unwrap(),
111 None103 None
112 );104 );
113}105}
114106
115#[test]107#[test]
116fn saves_before_an_attempt_coalesce_into_one_publication() {108fn saves_before_an_attempt_publish_together() {
117 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");109 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
118 let directory = tempfile::tempdir().unwrap();110 let directory = tempfile::tempdir().unwrap();
119 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();111 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
120 append(&mut after, " one");112 append(&mut after, " one");
121 let id = cache113 let id = model_ops::save_as(&cache, space, &after, "Model author")
122 .save(OUTLINES, space, &after, "Model author")
123 .unwrap()114 .unwrap()
124 .unwrap();115 .unwrap();
125 let mut later = page_of(&cache.snapshot().unwrap(), space);116 let mut later = page_of(&cache.snapshot().unwrap(), space);
126 append(&mut later, " two");117 append(&mut later, " two");
127 assert_eq!(118 let second = model_ops::save_as(&cache, space, &later, "Model author")
128 cache119 .unwrap()
129 .save(&cache.snapshot().unwrap(), space, &later, "Model author")120 .unwrap();
130 .unwrap(),121 assert!(second > id);
131 Some(id)122 assert_eq!(cache.pending().unwrap().len(), 2);
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);123 let mut server = Server::new(OUTLINES);
141 assert!(124 assert!(
142 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)125 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == second)
143 );126 );
127 assert!(matches!(
128 cache.status(id).unwrap(),
129 Some(EditStatus::Published { .. })
130 ));
144 assert_eq!(server.publications, 1);131 assert_eq!(server.publications, 1);
145 assert_eq!(132 assert_eq!(
146 first_text(&page_of(&server.durable, space)),133 first_text(&page_of(&server.durable, space)),
...@@ -148,11 +135,10 @@ fn saves_before_an_attempt_coalesce_into_one_publication() {...@@ -148,11 +135,10 @@ fn saves_before_an_attempt_coalesce_into_one_publication() {
148 );135 );
149 let mut third = page_of(&cache.snapshot().unwrap(), space);136 let mut third = page_of(&cache.snapshot().unwrap(), space);
150 append(&mut third, " three");137 append(&mut third, " three");
151 let next = cache138 let next = model_ops::save_as(&cache, space, &third, "Model author")
152 .save(&cache.snapshot().unwrap(), space, &third, "Model author")
153 .unwrap()139 .unwrap()
154 .unwrap();140 .unwrap();
155 assert!(next > id);141 assert!(next > second);
156}142}
157143
158#[test]144#[test]
...@@ -161,8 +147,7 @@ fn an_attempted_save_is_never_rewritten() {...@@ -161,8 +147,7 @@ fn an_attempted_save_is_never_rewritten() {
161 let directory = tempfile::tempdir().unwrap();147 let directory = tempfile::tempdir().unwrap();
162 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();148 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
163 append(&mut after, " uncertain");149 append(&mut after, " uncertain");
164 let id = cache150 let id = model_ops::save_as(&cache, space, &after, "Model author")
165 .save(OUTLINES, space, &after, "Model author")
166 .unwrap()151 .unwrap()
167 .unwrap();152 .unwrap();
168 let mut server = Server::new(OUTLINES);153 let mut server = Server::new(OUTLINES);
...@@ -174,21 +159,18 @@ fn an_attempted_save_is_never_rewritten() {...@@ -174,21 +159,18 @@ fn an_attempted_save_is_never_rewritten() {
174 ));159 ));
175 let mut more = page_of(&cache.snapshot().unwrap(), space);160 let mut more = page_of(&cache.snapshot().unwrap(), space);
176 append(&mut more, " more");161 append(&mut more, " more");
177 let next = cache162 let next = model_ops::save_as(&cache, space, &more, "Model author")
178 .save(&cache.snapshot().unwrap(), space, &more, "Model author")
179 .unwrap()163 .unwrap()
180 .unwrap();164 .unwrap();
181 assert!(next > id);165 assert!(next > id);
182 let pending = cache.pending().unwrap();166 let pending = cache.pending().unwrap();
183 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);167 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);
184 let Operation::Page(first) = &pending[0].operation else {168 assert_eq!(page_of(&cache.snapshot().unwrap(), space), more);
185 panic!()169 assert!(matches!(
186 };170 cache.status(id).unwrap(),
187 assert_eq!(first.after, after);171 Some(EditStatus::AwaitingConfirmation { .. })
188 let Operation::Page(second) = &pending[1].operation else {172 ));
189 panic!()173 assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending));
190 };
191 assert_eq!((&second.before, &second.after), (&after, &more));
192}174}
193175
194#[test]176#[test]
...@@ -197,8 +179,7 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {...@@ -197,8 +179,7 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
197 let directory = tempfile::tempdir().unwrap();179 let directory = tempfile::tempdir().unwrap();
198 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();180 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
199 append(&mut after, " local");181 append(&mut after, " local");
200 let id = cache182 let id = model_ops::save_as(&cache, space, &after, "Model author")
201 .save(OUTLINES, space, &after, "Model author")
202 .unwrap()183 .unwrap()
203 .unwrap();184 .unwrap();
204 let mut remote_page = page_of(OUTLINES, space);185 let mut remote_page = page_of(OUTLINES, space);
...@@ -206,26 +187,42 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {...@@ -206,26 +187,42 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
206 let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap();187 let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap();
207 let mut server = Server::new(remote.as_bytes());188 let mut server = Server::new(remote.as_bytes());
208 assert_eq!(189 assert_eq!(
209 cache.sync_once(&mut server).unwrap(),190 cache.sync_once(&mut server).unwrap().edit,
210 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))191 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
211 );192 );
212 assert_eq!(server.publications, 0);193 assert_eq!(server.publications, 0);
213 assert!(cache.pending().unwrap().len() == 1);194 assert!(cache.pending().unwrap().len() == 1);
214 let local = cache.snapshot().unwrap();
215 let observed = cache.remote_snapshot().unwrap();195 let observed = cache.remote_snapshot().unwrap();
216 assert_eq!(observed, remote.as_bytes());196 assert_eq!(observed, remote.as_bytes());
217 let mut reviewed = page_of(&observed, space);197 let mut reviewed = page_of(&observed, space);
218 append(&mut reviewed, " local");198 append(&mut reviewed, " local");
219 assert!(199 assert!(cache.resolve(id + 1, notebook::Resolution::Theirs).is_err());
220 cache200 let text = model_ops::paragraph_with(&reviewed, {
221 .review_page(id + 1, &local, &observed, &reviewed)201 let outline = reviewed
222 .is_err()202 .objects
223 );203 .iter()
224 assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err());204 .find_map(|object| match object {
225 cache.review_page(id, &local, &observed, &reviewed).unwrap();205 PageObject::Outline(outline) => Some(outline),
206 _ => None,
207 })
208 .unwrap();
209 outline.paragraphs[0].text().unwrap().id
210 })
211 .unwrap()
212 .text()
213 .unwrap()
214 .id;
215 let reviewed_id = model_ops::review(&cache, id, text, |page| append(page, " local"))
216 .unwrap()
217 .unwrap();
218 // The conflicted edit stays, emptied, and publishes with the reviewed one.
226 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));219 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
220 assert_eq!(
221 cache.status(reviewed_id).unwrap(),
222 Some(EditStatus::Pending)
223 );
227 assert!(224 assert!(
228 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)225 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == reviewed_id)
229 );226 );
230 assert_eq!(227 assert_eq!(
231 first_text(&page_of(&server.durable, space)),228 first_text(&page_of(&server.durable, space)),
...@@ -241,15 +238,14 @@ fn remote_changes_to_other_pages_do_not_conflict() {...@@ -241,15 +238,14 @@ fn remote_changes_to_other_pages_do_not_conflict() {
241 let directory = tempfile::tempdir().unwrap();238 let directory = tempfile::tempdir().unwrap();
242 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();239 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
243 append(&mut after, " local");240 append(&mut after, " local");
244 let id = cache241 let id = model_ops::save_as(&cache, space, &after, "Model author")
245 .save(OUTLINES, space, &after, "Model author")
246 .unwrap()242 .unwrap()
247 .unwrap();243 .unwrap();
248 append(&mut other_page, " elsewhere");244 append(&mut other_page, " elsewhere");
249 let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap();245 let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap();
250 let mut server = Server::new(remote.as_bytes());246 let mut server = Server::new(remote.as_bytes());
251 assert!(247 assert!(
252 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)248 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
253 );249 );
254 assert_eq!(page_of(&server.durable, space), after);250 assert_eq!(page_of(&server.durable, space), after);
255 assert_eq!(page_of(&server.durable, other), other_page);251 assert_eq!(page_of(&server.durable, other), other_page);
...@@ -263,8 +259,7 @@ fn pending_saves_survive_reopening_the_cache() {...@@ -263,8 +259,7 @@ fn pending_saves_survive_reopening_the_cache() {
263 let path = directory.path().join("cache.sqlite");259 let path = directory.path().join("cache.sqlite");
264 let cache = Replica::create(&path, OUTLINES).unwrap();260 let cache = Replica::create(&path, OUTLINES).unwrap();
265 append(&mut after, " durable");261 append(&mut after, " durable");
266 let id = cache262 let id = model_ops::save_as(&cache, space, &after, "Model author")
267 .save(OUTLINES, space, &after, "Model author")
268 .unwrap()263 .unwrap()
269 .unwrap();264 .unwrap();
270 let pending = cache.pending().unwrap();265 let pending = cache.pending().unwrap();
...@@ -327,13 +322,12 @@ fn concurrent_edits_to_different_paragraphs_merge() {...@@ -327,13 +322,12 @@ fn concurrent_edits_to_different_paragraphs_merge() {
327 let directory = tempfile::tempdir().unwrap();322 let directory = tempfile::tempdir().unwrap();
328 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();323 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
329 edit_paragraph(&mut after, 0, 0..0, "Local ");324 edit_paragraph(&mut after, 0, 0..0, "Local ");
330 let id = cache325 let id = model_ops::save_as(&cache, space, &after, "Model author")
331 .save(OUTLINES, space, &after, "Model author")
332 .unwrap()326 .unwrap()
333 .unwrap();327 .unwrap();
334 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));328 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
335 assert!(329 assert!(
336 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)330 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
337 );331 );
338 let published = texts(&page_of(&server.durable, space));332 let published = texts(&page_of(&server.durable, space));
339 assert!(published[0].starts_with("Local Anchor"), "{published:?}");333 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
...@@ -353,13 +347,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {...@@ -353,13 +347,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
353 let directory = tempfile::tempdir().unwrap();347 let directory = tempfile::tempdir().unwrap();
354 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();348 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
355 append(&mut after, " local");349 append(&mut after, " local");
356 let id = cache350 let id = model_ops::save_as(&cache, space, &after, "Model author")
357 .save(OUTLINES, space, &after, "Model author")
358 .unwrap()351 .unwrap()
359 .unwrap();352 .unwrap();
360 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));353 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
361 assert!(354 assert!(
362 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)355 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
363 );356 );
364 assert_eq!(357 assert_eq!(
365 texts(&page_of(&server.durable, space))[0],358 texts(&page_of(&server.durable, space))[0],
...@@ -369,13 +362,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {...@@ -369,13 +362,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
369 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();362 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
370 let mut after = page_of(OUTLINES, space);363 let mut after = page_of(OUTLINES, space);
371 edit_paragraph(&mut after, 0, 0..6, "Local");364 edit_paragraph(&mut after, 0, 0..6, "Local");
372 let id = cache365 let id = model_ops::save_as(&cache, space, &after, "Model author")
373 .save(OUTLINES, space, &after, "Model author")
374 .unwrap()366 .unwrap()
375 .unwrap();367 .unwrap();
376 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));368 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
377 assert_eq!(369 assert_eq!(
378 cache.sync_once(&mut server).unwrap(),370 cache.sync_once(&mut server).unwrap().edit,
379 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))371 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
380 );372 );
381 assert_eq!(server.publications, 0);373 assert_eq!(server.publications, 0);
...@@ -408,8 +400,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {...@@ -408,8 +400,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
408 );400 );
409 outline.paragraphs.insert(1, fresh);401 outline.paragraphs.insert(1, fresh);
410 }402 }
411 let id = cache403 let id = model_ops::save_as(&cache, space, &after, "Model author")
412 .save(OUTLINES, space, &after, "Model author")
413 .unwrap()404 .unwrap()
414 .unwrap();405 .unwrap();
415 let mut server = remote_with(space, |page| {406 let mut server = remote_with(space, |page| {
...@@ -424,7 +415,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {...@@ -424,7 +415,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
424 outline.paragraphs.pop();415 outline.paragraphs.pop();
425 });416 });
426 assert!(417 assert!(
427 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)418 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
428 );419 );
429 assert_eq!(420 assert_eq!(
430 texts(&page_of(&server.durable, space)),421 texts(&page_of(&server.durable, space)),
...@@ -442,8 +433,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {...@@ -442,8 +433,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
442 let directory = tempfile::tempdir().unwrap();433 let directory = tempfile::tempdir().unwrap();
443 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();434 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
444 edit_paragraph(&mut after, 2, 0..0, "Local ");435 edit_paragraph(&mut after, 2, 0..0, "Local ");
445 let id = cache436 let id = model_ops::save_as(&cache, space, &after, "Model author")
446 .save(OUTLINES, space, &after, "Model author")
447 .unwrap()437 .unwrap()
448 .unwrap();438 .unwrap();
449 let mut server = remote_with(space, |page| {439 let mut server = remote_with(space, |page| {
...@@ -458,7 +448,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {...@@ -458,7 +448,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
458 outline.paragraphs.pop();448 outline.paragraphs.pop();
459 });449 });
460 assert_eq!(450 assert_eq!(
461 cache.sync_once(&mut server).unwrap(),451 cache.sync_once(&mut server).unwrap().edit,
462 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))452 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
463 );453 );
464}454}
...@@ -481,13 +471,12 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {...@@ -481,13 +471,12 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
481 outline.layout.y = Some(300.0);471 outline.layout.y = Some(300.0);
482 outline.id472 outline.id
483 };473 };
484 let id = cache474 let id = model_ops::save_as(&cache, space, &after, "Model author")
485 .save(OUTLINES, space, &after, "Model author")
486 .unwrap()475 .unwrap()
487 .unwrap();476 .unwrap();
488 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));477 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
489 assert!(478 assert!(
490 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)479 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
491 );480 );
492 let published = page_of(&server.durable, space);481 let published = page_of(&server.durable, space);
493 let PageObject::Outline(outline) = published482 let PageObject::Outline(outline) = published
...@@ -512,8 +501,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {...@@ -512,8 +501,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
512 );501 );
513502
514 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();503 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
515 let id = cache504 let id = model_ops::save_as(&cache, space, &after, "Model author")
516 .save(OUTLINES, space, &after, "Model author")
517 .unwrap()505 .unwrap()
518 .unwrap();506 .unwrap();
519 let mut server = remote_with(space, |page| {507 let mut server = remote_with(space, |page| {
...@@ -528,7 +516,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {...@@ -528,7 +516,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
528 outline.layout.x = Some(50.0);516 outline.layout.x = Some(50.0);
529 });517 });
530 assert_eq!(518 assert_eq!(
531 cache.sync_once(&mut server).unwrap(),519 cache.sync_once(&mut server).unwrap().edit,
532 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))520 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
533 );521 );
534}522}
...@@ -565,13 +553,12 @@ fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition()...@@ -565,13 +553,12 @@ fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition()
565 })553 })
566 .unwrap();554 .unwrap();
567 outline.paragraphs[0].lists = vec![bullet];555 outline.paragraphs[0].lists = vec![bullet];
568 let id = cache556 let id = model_ops::save_as(&cache, space, &after, "Model author")
569 .save(OUTLINES, space, &after, "Model author")
570 .unwrap()557 .unwrap()
571 .unwrap();558 .unwrap();
572 let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote "));559 let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote "));
573 assert!(560 assert!(
574 matches!(cache.sync_once(&mut server).unwrap(), Some((n, EditStatus::Published { .. })) if n == id)561 matches!(cache.sync_once(&mut server).unwrap().edit, Some((n, EditStatus::Published { .. })) if n == id)
575 );562 );
576 let published = page_of(&server.durable, space);563 let published = page_of(&server.durable, space);
577 let outline = published564 let outline = published
crates/notebook/tests/sync_outline.rs+27-32
...@@ -11,7 +11,7 @@ use serde_json::json;...@@ -11,7 +11,7 @@ use serde_json::json;
1111
12#[path = "support/server.rs"]12#[path = "support/server.rs"]
13mod server;13mod server;
14use server::{Fault, Server};14use server::{Fault, Server, pages};
15#[path = "support/model_ops.rs"]15#[path = "support/model_ops.rs"]
16mod model_ops;16mod model_ops;
17use model_ops::{17use model_ops::{
...@@ -105,10 +105,21 @@ pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut...@@ -105,10 +105,21 @@ pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut
105 .to_vec()105 .to_vec()
106}106}
107107
108/// Publishes the batch holding `id`, unless an earlier step already did.
108pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) {109pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) {
109 assert!(110 if !matches!(
110 matches!(cache.sync_once(server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)111 cache.status(id).unwrap(),
111 );112 Some(EditStatus::Published { .. })
113 ) {
114 assert!(matches!(
115 cache.sync_once(server).unwrap().edit,
116 Some((_, EditStatus::Published { .. }))
117 ));
118 }
119 assert!(matches!(
120 cache.status(id).unwrap(),
121 Some(EditStatus::Published { .. })
122 ));
112}123}
113124
114fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) {125fn moved(x: f32, y: f32) -> impl Fn(&mut Outline) {
...@@ -144,7 +155,7 @@ fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page...@@ -144,7 +155,7 @@ fn layout_and_dependent_text_survive_reopen_and_a_remote_format_of_the_same_page
144 drop(cache);155 drop(cache);
145 let cache = Replica::open(&path).unwrap();156 let cache = Replica::open(&path).unwrap();
146 assert_eq!(cache.pending().unwrap(), pending);157 assert_eq!(cache.pending().unwrap(), pending);
147 assert_eq!(cache.snapshot().unwrap(), local);158 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
148 let mut server = Server::new(&remote_with(&source, space, |page| {159 let mut server = Server::new(&remote_with(&source, space, |page| {
149 restyle(page, text_id, 0..8, |format| format.bold = Some(true));160 restyle(page, text_id, 0..8, |format| format.bold = Some(true));
150 }));161 }));
...@@ -232,7 +243,7 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit...@@ -232,7 +243,7 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit
232}243}
233244
234#[test]245#[test]
235fn competing_layout_requires_review_against_the_current_remote_page() {246fn competing_layout_conflicts_until_the_local_page_is_kept() {
236 let (source, space, outline, text_id) = fixture();247 let (source, space, outline, text_id) = fixture();
237 let cases: [(Change<Outline>, Change<Outline>); 2] = [248 let cases: [(Change<Outline>, Change<Outline>); 2] = [
238 (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))),249 (Box::new(moved(180.0, 216.0)), Box::new(moved(288.0, 216.0))),
...@@ -255,41 +266,25 @@ fn competing_layout_requires_review_against_the_current_remote_page() {...@@ -255,41 +266,25 @@ fn competing_layout_requires_review_against_the_current_remote_page() {
255 remote_change(outline_mut(page, outline));266 remote_change(outline_mut(page, outline));
256 }));267 }));
257 assert_eq!(268 assert_eq!(
258 cache.sync_once(&mut server).unwrap(),269 cache.sync_once(&mut server).unwrap().edit,
259 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))270 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
260 );271 );
261 assert_eq!(server.publications, 0);272 assert_eq!(server.publications, 0);
262 assert_eq!(cache.snapshot().unwrap(), local);273 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
263 let dependent = save(&cache, text_id, |page| {274 let dependent = save(&cache, text_id, |page| {
264 replace_text(page, text_id, 0..0, "Local ")275 replace_text(page, text_id, 0..0, "Local ")
265 })276 })
266 .unwrap()277 .unwrap()
267 .unwrap();278 .unwrap();
268 let queue = cache.pending().unwrap();
269 let local = cache.snapshot().unwrap();279 let local = cache.snapshot().unwrap();
270 let observed = cache.remote_snapshot().unwrap();
271 assert!(
272 cache
273 .review_page(id, &source, &observed, &page_of(&observed, space))
274 .is_err()
275 );
276 assert!(
277 cache
278 .review_page(id, &local, &source, &page_of(&source, space))
279 .is_err()
280 );
281 drop(cache);280 drop(cache);
282 let cache = Replica::open(&path).unwrap();281 let cache = Replica::open(&path).unwrap();
283 assert_eq!(282 assert_eq!(
284 cache.status(id).unwrap(),283 cache.status(id).unwrap(),
285 Some(EditStatus::Conflict(ConflictKind::ContentChanged))284 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
286 );285 );
287 review(&cache, id, text_id, |page| {286 cache.resolve(id, notebook::Resolution::Mine).unwrap();
288 local_change(outline_mut(page, outline))
289 })
290 .unwrap();
291 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));287 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
292 assert_eq!(cache.pending().unwrap()[1], queue[1]);
293 drop(cache);288 drop(cache);
294 let cache = Replica::open(&path).unwrap();289 let cache = Replica::open(&path).unwrap();
295 for id in [id, dependent] {290 for id in [id, dependent] {
...@@ -354,7 +349,7 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart...@@ -354,7 +349,7 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart
354 let expected = page_of(&cache.snapshot().unwrap(), space);349 let expected = page_of(&cache.snapshot().unwrap(), space);
355 drop(cache);350 drop(cache);
356 let cache = Replica::open(&path).unwrap();351 let cache = Replica::open(&path).unwrap();
357 if cache.sync_once(&mut server).unwrap()352 if cache.sync_once(&mut server).unwrap().edit
358 == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))353 == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
359 {354 {
360 review(&cache, id, text_id, change).unwrap();355 review(&cache, id, text_id, change).unwrap();
...@@ -411,17 +406,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page()...@@ -411,17 +406,17 @@ fn an_uncertain_layout_attempt_confirms_by_revision_or_by_an_equal_remote_page()
411 continue;406 continue;
412 }407 }
413 assert_eq!(408 assert_eq!(
414 cache.sync_once(&mut server).unwrap(),409 cache.sync_once(&mut server).unwrap().edit,
415 Some((id, status.clone()))410 Some((id, status.clone()))
416 );411 );
417 server.visible = remote_with(&source, space, |page| {412 server.visible = remote_with(&source, space, |page| {
418 resized(216.0, true)(outline_mut(page, outline));413 resized(216.0, true)(outline_mut(page, outline));
419 });414 });
420 assert_eq!(415 assert_eq!(
421 cache.sync_once(&mut server).unwrap(),416 cache.sync_once(&mut server).unwrap().edit,
422 Some((id, status.clone()))417 Some((id, status.clone()))
423 );418 );
424 assert_eq!(cache.snapshot().unwrap(), local);419 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
425 server.visible = remote_with(&source, space, |page| {420 server.visible = remote_with(&source, space, |page| {
426 resized(144.0, true)(outline_mut(page, outline));421 resized(144.0, true)(outline_mut(page, outline));
427 });422 });
...@@ -503,7 +498,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {...@@ -503,7 +498,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {
503 let id = save(&cache, dependent, save_both).unwrap().unwrap();498 let id = save(&cache, dependent, save_both).unwrap().unwrap();
504 let local = cache.snapshot().unwrap();499 let local = cache.snapshot().unwrap();
505 let queue = cache.pending().unwrap();500 let queue = cache.pending().unwrap();
506 let merged = match cache.sync_once(&mut server).unwrap().unwrap() {501 let merged = match cache.sync_once(&mut server).unwrap().edit.unwrap() {
507 (actual, EditStatus::Published { .. }) => {502 (actual, EditStatus::Published { .. }) => {
508 assert_eq!(actual, id, "{name}");503 assert_eq!(actual, id, "{name}");
509 true504 true
...@@ -515,7 +510,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {...@@ -515,7 +510,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {
515 "{name}"510 "{name}"
516 );511 );
517 assert_eq!(cache.pending().unwrap(), queue);512 assert_eq!(cache.pending().unwrap(), queue);
518 assert_eq!(cache.snapshot().unwrap(), local);513 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
519 drop(cache);514 drop(cache);
520 let cache = Replica::open(&path).unwrap();515 let cache = Replica::open(&path).unwrap();
521 review(&cache, id, dependent, save_both).unwrap();516 review(&cache, id, dependent, save_both).unwrap();
...@@ -590,7 +585,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {...@@ -590,7 +585,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
590 .unwrap();585 .unwrap();
591 let mut server = Server::new(NATIVE);586 let mut server = Server::new(NATIVE);
592 assert_eq!(587 assert_eq!(
593 cache.sync_once(&mut server).unwrap(),588 cache.sync_once(&mut server).unwrap().edit,
594 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))589 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
595 );590 );
596 review(&cache, id, text_id, |page| {591 review(&cache, id, text_id, |page| {
crates/notebook/tests/sync_page.rs+166-71
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review,1//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review,
2//! and deterministic multi-actor schedules.2//! and deterministic multi-actor schedules.
33
4use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica};4use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution};
5use onestore::op::SectionOp;
5use onestore::{6use onestore::{
6 ExGuid, PageCreation, RevisionIndex, Store,7 ExGuid, PageCreation, RevisionIndex, Store,
7 document::{Document, Kind},8 document::{Document, Kind},
...@@ -49,47 +50,38 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {...@@ -49,47 +50,38 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
49 let path = directory.path().join(format!("{actor}.sqlite"));50 let path = directory.path().join(format!("{actor}.sqlite"));
50 let cache = Replica::create(&path, &source).unwrap();51 let cache = Replica::create(&path, &source).unwrap();
51 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();52 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
52 let id = cache.create_page(&source, &page).unwrap().unwrap();53 let id = section_op(&cache, SectionOp::Create(page.clone()));
53 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());54 let mut created = cache.page(page.space()).unwrap();
54 let body = body_outline(&mut created, "Body");55 let body = body_outline(&mut created, "Body");
55 let save = cache56 let save = model_ops::save_as(&cache, page.space(), &created, model_ops::AUTHOR)
56 .save(
57 &cache.snapshot().unwrap(),
58 page.space(),
59 &created,
60 model_ops::AUTHOR,
61 )
62 .unwrap()57 .unwrap()
63 .unwrap();58 .unwrap();
64 assert_eq!(59 let dependent = model_ops::save(&cache, body, |page| {
65 model_ops::save(&cache, body, |page| model_ops::replace_text(60 model_ops::replace_text(page, body, 4..4, &format!(" {actor}"))
66 page,61 })
67 body,62 .unwrap()
68 4..4,63 .unwrap();
69 &format!(" {actor}")64 assert!(id < save && save < dependent);
70 ))
71 .unwrap(),
72 Some(save),
73 "a dependent text edit coalesces into the unattempted body save"
74 );
75 let original = cache.pending().unwrap();65 let original = cache.pending().unwrap();
76 assert_eq!(original.len(), 2);66 assert_eq!(original.len(), 3);
77 assert!(67 assert!(
78 matches!(&original[0].operation, Operation::CreatePage(retained) if retained == &page)68 matches!(&original[0].edit.ops[..], [onestore::op::Op::Section(SectionOp::Create(retained))] if retained == &page)
79 );69 );
80 let local = cache.snapshot().unwrap();70 let local = cache.snapshot().unwrap();
81 drop(cache);71 drop(cache);
82 let cache = Replica::open(&path).unwrap();72 let cache = Replica::open(&path).unwrap();
83 assert_eq!(cache.pending().unwrap(), original);73 assert_eq!(cache.pending().unwrap(), original);
84 assert_eq!(cache.snapshot().unwrap(), local);74 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&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!(75 assert!(matches!(
90 cache.status(id).unwrap(),76 cache.sync_once(&mut server).unwrap().edit,
91 Some(EditStatus::Published { .. })77 Some((published, EditStatus::Published { .. })) if published == dependent
92 ));78 ));
79 for edit in original {
80 assert!(matches!(
81 cache.status(edit.id).unwrap(),
82 Some(EditStatus::Published { .. })
83 ));
84 }
93 expected.push((page.space(), page.object(), body, format!("Body {actor}")));85 expected.push((page.space(), page.object(), body, format!("Body {actor}")));
94 }86 }
95 let store = Store::parse(&server.durable).unwrap();87 let store = Store::parse(&server.durable).unwrap();
...@@ -103,7 +95,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {...@@ -103,7 +95,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
103 let view = &space.revisions[&space.contexts[&ExGuid::default()]];95 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
104 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));96 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
105 }97 }
106 assert_eq!(server.publications, 24);98 assert_eq!(server.publications, 12);
107 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {99 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
108 std::fs::create_dir(&output).unwrap();100 std::fs::create_dir(&output).unwrap();
109 std::fs::write(101 std::fs::write(
...@@ -127,7 +119,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -127,7 +119,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
127 let path = directory.path().join("pages.sqlite");119 let path = directory.path().join("pages.sqlite");
128 let cache = Replica::create(&path, &source).unwrap();120 let cache = Replica::create(&path, &source).unwrap();
129 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();121 let page = PageCreation::new(None, Some("Created"), "Author").unwrap();
130 let id = cache.create_page(&source, &page).unwrap().unwrap();122 let id = section_op(&cache, SectionOp::Create(page.clone()));
131 let local = cache.snapshot().unwrap();123 let local = cache.snapshot().unwrap();
132 let mut server = Server::new(&source);124 let mut server = Server::new(&source);
133 server.fault = fault;125 server.fault = fault;
...@@ -145,7 +137,11 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -145,7 +137,11 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
145 drop(cache);137 drop(cache);
146 let db = rusqlite::Connection::open(&path).unwrap();138 let db = rusqlite::Connection::open(&path).unwrap();
147 let encoded: String = db139 let encoded: String = db
148 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))140 .query_row(
141 "SELECT revisions FROM batches WHERE attempted=1",
142 [],
143 |row| row.get(0),
144 )
149 .unwrap();145 .unwrap();
150 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();146 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
151 assert_eq!(revisions.len(), 2);147 assert_eq!(revisions.len(), 2);
...@@ -153,7 +149,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -153,7 +149,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
153 drop(db);149 drop(db);
154 let cache = Replica::open(&path).unwrap();150 let cache = Replica::open(&path).unwrap();
155 let observed = server.visible.clone();151 let observed = server.visible.clone();
156 let result = cache.sync_once(&mut server).unwrap().unwrap();152 let result = cache.sync_once(&mut server).unwrap().edit.unwrap();
157 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);153 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
158 assert_eq!(server.publications, 1);154 assert_eq!(server.publications, 1);
159 assert_eq!(server.confirmations, usize::from(visible));155 assert_eq!(server.confirmations, usize::from(visible));
...@@ -164,7 +160,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {...@@ -164,7 +160,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
164 assert!(observed[252..] == server.durable[252..]);160 assert!(observed[252..] == server.durable[252..]);
165 } else {161 } else {
166 assert_eq!(result, (id, attempted.clone()));162 assert_eq!(result, (id, attempted.clone()));
167 assert_eq!(cache.snapshot().unwrap(), local);163 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
168 }164 }
169 }165 }
170}166}
...@@ -176,7 +172,7 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {...@@ -176,7 +172,7 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
176 let path = directory.path().join("pages.sqlite");172 let path = directory.path().join("pages.sqlite");
177 let cache = Replica::create(&path, &source).unwrap();173 let cache = Replica::create(&path, &source).unwrap();
178 let page = PageCreation::new(None, None, "Author").unwrap();174 let page = PageCreation::new(None, None, "Author").unwrap();
179 let id = cache.create_page(&source, &page).unwrap().unwrap();175 let id = section_op(&cache, SectionOp::Create(page.clone()));
180 let mut server = Server::new(&source);176 let mut server = Server::new(&source);
181 server.fault = Fault::UnknownAfter;177 server.fault = Fault::UnknownAfter;
182 assert!(cache.sync_once(&mut server).is_err());178 assert!(cache.sync_once(&mut server).is_err());
...@@ -184,7 +180,11 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {...@@ -184,7 +180,11 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
184 drop(cache);180 drop(cache);
185 let db = rusqlite::Connection::open(&path).unwrap();181 let db = rusqlite::Connection::open(&path).unwrap();
186 let encoded: String = db182 let encoded: String = db
187 .query_row("SELECT revisions FROM attempt", [], |row| row.get(0))183 .query_row(
184 "SELECT revisions FROM batches WHERE attempted=1",
185 [],
186 |row| row.get(0),
187 )
188 .unwrap();188 .unwrap();
189 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();189 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
190 drop(db);190 drop(db);
...@@ -222,21 +222,21 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {...@@ -222,21 +222,21 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
222 );222 );
223 let cache = Replica::open(&path).unwrap();223 let cache = Replica::open(&path).unwrap();
224 assert_eq!(224 assert_eq!(
225 cache.sync_once(&mut server).unwrap(),225 cache.sync_once(&mut server).unwrap().edit,
226 Some((id, attempted.clone()))226 Some((id, attempted.clone()))
227 );227 );
228 assert_eq!(server.confirmations, 0);228 assert_eq!(server.confirmations, 0);
229 assert_eq!(server.publications, 1);229 assert_eq!(server.publications, 1);
230 server.visible = complete;230 server.visible = complete;
231 assert!(matches!(231 assert!(matches!(
232 cache.sync_once(&mut server).unwrap(),232 cache.sync_once(&mut server).unwrap().edit,
233 Some((_, EditStatus::Published { .. }))233 Some((_, EditStatus::Published { .. }))
234 ));234 ));
235 assert_eq!(server.publications, 1);235 assert_eq!(server.publications, 1);
236}236}
237237
238#[test]238#[test]
239fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() {239fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_identities() {
240 let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");240 let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
241 let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");241 let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
242 let store = Store::parse(source).unwrap();242 let store = Store::parse(source).unwrap();
...@@ -246,48 +246,36 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities...@@ -246,48 +246,36 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities
246 let path = directory.path().join("pages.sqlite");246 let path = directory.path().join("pages.sqlite");
247 let cache = Replica::create(&path, source).unwrap();247 let cache = Replica::create(&path, source).unwrap();
248 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();248 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
249 let id = cache.create_page(source, &page).unwrap().unwrap();249 let id = section_op(&cache, SectionOp::Create(page.clone()));
250 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());250 let mut created = cache.page(page.space()).unwrap();
251 let body = body_outline(&mut created, "Retained body");251 let body = body_outline(&mut created, "Retained body");
252 cache252 let save = model_ops::save_as(&cache, page.space(), &created, "Author")
253 .save(&cache.snapshot().unwrap(), page.space(), &created, "Author")
254 .unwrap()253 .unwrap()
255 .unwrap();254 .unwrap();
256 let local = cache.snapshot().unwrap();255 let local = cache.snapshot().unwrap();
257 let pending = cache.pending().unwrap();256 let pending = cache.pending().unwrap();
258 let mut server = Server::new(remote);257 let mut server = Server::new(remote);
259 assert_eq!(258 assert_eq!(
260 cache.sync_once(&mut server).unwrap(),259 cache.sync_once(&mut server).unwrap().edit,
261 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))260 Some((save, EditStatus::Conflict(ConflictKind::StructureChanged)))
262 );261 );
263 assert!(262 cache.resolve(id, Resolution::Mine).unwrap();
264 cache
265 .rebase_page_creation_conflict(id, source, remote, None)
266 .is_err()
267 );
268 assert!(
269 cache
270 .rebase_page_creation_conflict(id, &local, source, None)
271 .is_err()
272 );
273 cache
274 .rebase_page_creation_conflict(id, &local, remote, None)
275 .unwrap();
276 assert_eq!(cache.snapshot().unwrap(), local);
277 assert_eq!(cache.pending().unwrap()[1], pending[1]);263 assert_eq!(cache.pending().unwrap()[1], pending[1]);
278 let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else {264 assert!(matches!(
279 panic!()265 &cache.pending().unwrap()[0].edit.ops[..],
280 };266 [onestore::op::Op::Section(SectionOp::Create(reviewed))] if *reviewed == page.reposition(None).unwrap()
281 assert_eq!(*reviewed, page.reposition(None).unwrap());267 ));
268 assert_eq!(
269 model_ops::page_of(&cache.snapshot().unwrap(), page.space()),
270 model_ops::page_of(&local, page.space())
271 );
282 drop(cache);272 drop(cache);
283 let cache = Replica::open(&path).unwrap();273 let cache = Replica::open(&path).unwrap();
284 for _ in 0..2 {274 assert!(matches!(
285 assert!(matches!(275 cache.sync_once(&mut server).unwrap().edit,
286 cache.sync_once(&mut server).unwrap(),276 Some((_, EditStatus::Published { .. }))
287 Some((_, EditStatus::Published { .. }))277 ));
288 ));278 assert_eq!(server.publications, 1);
289 }
290 assert_eq!(server.publications, 2);
291 let store = Store::parse(&server.durable).unwrap();279 let store = Store::parse(&server.durable).unwrap();
292 let index = RevisionIndex::parse(&store).unwrap();280 let index = RevisionIndex::parse(&store).unwrap();
293 let document = Document::parse(&index).unwrap();281 let document = Document::parse(&index).unwrap();
...@@ -296,3 +284,110 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities...@@ -296,3 +284,110 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities
296 assert!(matches!(&view.nodes[&body].kind,284 assert!(matches!(&view.nodes[&body].kind,
297 Kind::RichText { text, .. } if text == "Retained body"));285 Kind::RichText { text, .. } if text == "Retained body"));
298}286}
287
288#[test]
289fn keeping_mine_puts_back_a_page_the_remote_removed_where_it_stood() {
290 let source = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
291 let order = |image: &[u8]| -> Vec<(ExGuid, String, u32)> {
292 let arena = onestore::Arena::default();
293 onestore::Section::open(&arena, image.to_vec())
294 .unwrap()
295 .pages()
296 .unwrap()
297 };
298 let pages = order(source);
299 // A subpage without subpages of its own and with a page after it, so it has a position
300 // and a level to return to.
301 let at = (0..pages.len() - 1)
302 .find(|at| {
303 pages[*at].2 > 1
304 && pages[*at + 1].2 <= pages[*at].2
305 && model_ops::page_of(source, pages[*at].0)
306 .objects
307 .iter()
308 .any(|object| matches!(object, onestore::page::PageObject::Outline(_)))
309 })
310 .expect("a subpage with body text");
311 let (space, _, level) = pages[at].clone();
312 let text = model_ops::page_of(source, space)
313 .objects
314 .iter()
315 .find_map(|object| match object {
316 onestore::page::PageObject::Outline(outline) => {
317 outline.paragraphs[0].text().map(|t| t.id)
318 }
319 _ => None,
320 })
321 .unwrap();
322 for keep in [Resolution::Mine, Resolution::Theirs] {
323 let directory = tempfile::tempdir().unwrap();
324 let cache = Replica::create(directory.path().join("pages.sqlite"), source).unwrap();
325 let id = model_ops::save(&cache, text, |page| {
326 model_ops::replace_text(page, text, 0..0, "Kept ")
327 })
328 .unwrap()
329 .unwrap();
330 let local = cache.page(space).unwrap();
331 let removed = onestore::PreparedEdit::delete_pages_permanently(source, &[space])
332 .unwrap()
333 .as_bytes()
334 .to_vec();
335 let mut server = Server::new(&removed);
336 assert_eq!(
337 cache.sync_once(&mut server).unwrap().edit,
338 Some((id, EditStatus::Conflict(ConflictKind::TargetUnavailable)))
339 );
340 assert_eq!(
341 server.publications, 0,
342 "a removed page is not edited in place"
343 );
344 cache.resolve(id, keep).unwrap();
345 assert!(matches!(
346 cache.sync_once(&mut server).unwrap().edit,
347 Some((_, EditStatus::Published { .. }))
348 ));
349 let published = order(&server.durable);
350 assert!(published.iter().all(|(page, ..)| *page != space));
351 match keep {
352 Resolution::Mine => {
353 assert_eq!(published.len(), pages.len());
354 let (restored, title, restored_level) = published[at].clone();
355 assert_eq!(
356 (title.as_str(), restored_level),
357 (pages[at].1.as_str(), level)
358 );
359 let page = model_ops::page_of(&server.durable, restored);
360 let texts = |page: &onestore::page::Page| -> Vec<String> {
361 page.objects
362 .iter()
363 .filter_map(|object| match object {
364 onestore::page::PageObject::Outline(outline) => Some(outline),
365 _ => None,
366 })
367 .flat_map(|outline| &outline.paragraphs)
368 .filter_map(|p| p.text().map(|t| t.text.text().to_owned()))
369 .collect()
370 };
371 assert_eq!(texts(&page), texts(&local));
372 assert!(texts(&page)[0].starts_with("Kept "));
373 let others: Vec<_> = published
374 .iter()
375 .map(|(page, ..)| *page)
376 .filter(|page| *page != restored)
377 .collect();
378 let before: Vec<_> = pages
379 .iter()
380 .map(|(page, ..)| *page)
381 .filter(|page| *page != space)
382 .collect();
383 assert_eq!(others, before);
384 }
385 Resolution::Theirs => assert_eq!(published, order(&removed)),
386 }
387 assert_eq!(pages_of(&cache), published);
388 }
389}
390
391fn pages_of(cache: &Replica) -> Vec<(ExGuid, String, u32)> {
392 cache.pages().unwrap()
393}
crates/notebook/tests/sync_pages.rs+102-124
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! Page batch reconciliation: atomic batches with dependent body saves, competing moves and1//! Page batch reconciliation: atomic batches with dependent body saves, competing moves and
2//! indentation, uncertain batches, and native page movement fixtures.2//! indentation, uncertain batches, and native page movement fixtures.
33
4use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica};4use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution};
5use onestore::op::{Op, SectionOp};
5use onestore::{6use onestore::{
6 ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,7 ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
7 document::{Document, Format, Kind, Layout},8 document::{Document, Format, Kind, Layout},
...@@ -117,8 +118,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {...@@ -117,8 +118,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
117 .iter()118 .iter()
118 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())119 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
119 .collect();120 .collect();
120 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();121 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
121 assert!(cache.pages(SOURCE, &edits).is_err());
122 let mut expected_text = texts(SOURCE);122 let mut expected_text = texts(SOURCE);
123 let (&(sid, oid), original) = expected_text123 let (&(sid, oid), original) = expected_text
124 .iter()124 .iter()
...@@ -132,17 +132,22 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {...@@ -132,17 +132,22 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
132 .unwrap()132 .unwrap()
133 .unwrap();133 .unwrap();
134 let queue = cache.pending().unwrap();134 let queue = cache.pending().unwrap();
135 assert!(matches!(&queue[0].operation, Operation::Pages(batch) if batch.edits == edits));135 assert!(
136 matches!(&queue[0].edit.ops[..], [Op::Section(SectionOp::Pages(batch))] if *batch == edits)
137 );
136 let local = cache.snapshot().unwrap();138 let local = cache.snapshot().unwrap();
137 drop(cache);139 drop(cache);
138 let cache = Replica::open(&path).unwrap();140 let cache = Replica::open(&path).unwrap();
139 assert_eq!(cache.pending().unwrap(), queue);141 assert_eq!(cache.pending().unwrap(), queue);
140 assert_eq!(cache.snapshot().unwrap(), local);142 assert_eq!(
143 server::pages(&cache.snapshot().unwrap()),
144 server::pages(&local)
145 );
141 let mut server = Server::new(SOURCE);146 let mut server = Server::new(SOURCE);
142 for edit in queue {147 assert!(matches!(
143 assert!(matches!(cache.sync_once(&mut server).unwrap(),148 cache.sync_once(&mut server).unwrap().edit,
144 Some((published, EditStatus::Published { .. })) if published == edit.id));149 Some((_, EditStatus::Published { .. }))
145 }150 ));
146 let expected = [151 let expected = [
147 before[..3].to_vec(),152 before[..3].to_vec(),
148 before[6..].to_vec(),153 before[6..].to_vec(),
...@@ -155,7 +160,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {...@@ -155,7 +160,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
155 cache.status(id).unwrap(),160 cache.status(id).unwrap(),
156 Some(EditStatus::Published { .. })161 Some(EditStatus::Published { .. })
157 ));162 ));
158 assert_eq!(server.publications, 2);163 assert_eq!(server.publications, 1);
159}164}
160165
161#[test]166#[test]
...@@ -164,10 +169,10 @@ fn indentation_only_edits_preserve_remote_page_movement() {...@@ -164,10 +169,10 @@ fn indentation_only_edits_preserve_remote_page_movement() {
164 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();169 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
165 let before = order(SOURCE);170 let before = order(SOURCE);
166 let sid = before[4].0;171 let sid = before[4].0;
167 let id = cache172 let id = section_op(
168 .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()])173 &cache,
169 .unwrap()174 SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()),
170 .unwrap();175 );
171 let mut server = Server::new(SOURCE);176 let mut server = Server::new(SOURCE);
172 PreparedEdit::pages(177 PreparedEdit::pages(
173 SOURCE,178 SOURCE,
...@@ -178,22 +183,19 @@ fn indentation_only_edits_preserve_remote_page_movement() {...@@ -178,22 +183,19 @@ fn indentation_only_edits_preserve_remote_page_movement() {
178 .unwrap();183 .unwrap();
179 let mut expected = order(&server.durable);184 let mut expected = order(&server.durable);
180 expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3;185 expected.iter_mut().find(|p| p.0 == sid).unwrap().1 = 3;
181 assert!(matches!(cache.sync_once(&mut server).unwrap(),186 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
182 Some((published, EditStatus::Published { .. })) if published == id));187 Some((published, EditStatus::Published { .. })) if published == id));
183 assert_eq!(order(&server.durable), expected);188 assert_eq!(order(&server.durable), expected);
184}189}
185190
186#[test]191#[test]
187fn competing_page_moves_require_current_review_and_retain_intent_identities() {192fn competing_page_moves_conflict_until_mine_is_kept_with_its_identities() {
188 let directory = tempfile::tempdir().unwrap();193 let directory = tempfile::tempdir().unwrap();
189 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();194 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
190 let pages = order(SOURCE);195 let pages = order(SOURCE);
191 let sid = pages[6].0;196 let sid = pages[6].0;
192 let original = PageEdit::move_to(sid, None, 1).unwrap();197 let original = PageEdit::move_to(sid, None, 1).unwrap();
193 let id = cache198 let id = section_op(&cache, SectionOp::Pages(vec![original.clone()]));
194 .pages(SOURCE, std::slice::from_ref(&original))
195 .unwrap()
196 .unwrap();
197 let mut server = Server::new(SOURCE);199 let mut server = Server::new(SOURCE);
198 PreparedEdit::pages(200 PreparedEdit::pages(
199 SOURCE,201 SOURCE,
...@@ -205,37 +207,20 @@ fn competing_page_moves_require_current_review_and_retain_intent_identities() {...@@ -205,37 +207,20 @@ fn competing_page_moves_require_current_review_and_retain_intent_identities() {
205 let remote = server.visible.clone();207 let remote = server.visible.clone();
206 let local = cache.snapshot().unwrap();208 let local = cache.snapshot().unwrap();
207 assert_eq!(209 assert_eq!(
208 cache.sync_once(&mut server).unwrap(),210 cache.sync_once(&mut server).unwrap().edit,
209 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))211 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
210 );212 );
211 assert_eq!(server.publications, 0);213 assert_eq!(server.publications, 0);
212 assert_eq!(cache.snapshot().unwrap(), local);214 assert_eq!(
213 let revised = original215 server::pages(&cache.snapshot().unwrap()),
214 .reposition(PagePosition::Before(Some(pages[2].0)), 1)216 server::pages(&local)
215 .unwrap();
216 let fresh = PageEdit::move_to(sid, Some(pages[2].0), 1).unwrap();
217 assert!(
218 cache
219 .rebase_pages_conflict(id, &local, &remote, &[fresh])
220 .is_err()
221 );
222 assert!(
223 cache
224 .rebase_pages_conflict(id, SOURCE, &remote, std::slice::from_ref(&revised))
225 .is_err()
226 );
227 assert!(
228 cache
229 .rebase_pages_conflict(id, &local, SOURCE, std::slice::from_ref(&revised))
230 .is_err()
231 );217 );
232 cache218 // Keeping mine moves the page last again, from where the remote put it.
233 .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised))219 cache.resolve(id, Resolution::Mine).unwrap();
234 .unwrap();220 assert!(matches!(&cache.pending().unwrap()[0].edit.ops[..],
235 assert!(matches!(&cache.pending().unwrap()[0].operation,221 [Op::Section(SectionOp::Pages(batch))] if *batch == [original.clone()]));
236 Operation::Pages(batch) if batch.edits == [revised.clone()]));222 let expected = PreparedEdit::pages(&remote, &[original]).unwrap();
237 let expected = PreparedEdit::pages(&remote, &[revised]).unwrap();223 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
238 assert!(matches!(cache.sync_once(&mut server).unwrap(),
239 Some((published, EditStatus::Published { .. })) if published == id));224 Some((published, EditStatus::Published { .. })) if published == id));
240 assert_eq!(order(&server.durable), order(expected.as_bytes()));225 assert_eq!(order(&server.durable), order(expected.as_bytes()));
241}226}
...@@ -245,16 +230,13 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() {...@@ -245,16 +230,13 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() {
245 let directory = tempfile::tempdir().unwrap();230 let directory = tempfile::tempdir().unwrap();
246 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();231 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
247 let pages = order(SOURCE);232 let pages = order(SOURCE);
248 let id = cache233 let id = section_op(
249 .pages(234 &cache,
250 SOURCE,235 SectionOp::Pages(vec![
251 &[236 PageEdit::set_level(pages[4].0, 3).unwrap(),
252 PageEdit::set_level(pages[4].0, 3).unwrap(),237 PageEdit::set_level(pages[5].0, 2).unwrap(),
253 PageEdit::set_level(pages[5].0, 2).unwrap(),238 ]),
254 ],239 );
255 )
256 .unwrap()
257 .unwrap();
258 let local = cache.snapshot().unwrap();240 let local = cache.snapshot().unwrap();
259 let mut server = Server::new(SOURCE);241 let mut server = Server::new(SOURCE);
260 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()])242 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(pages[5].0, 1).unwrap()])
...@@ -263,12 +245,15 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() {...@@ -263,12 +245,15 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() {
263 .unwrap();245 .unwrap();
264 let remote = server.durable.clone();246 let remote = server.durable.clone();
265 assert_eq!(247 assert_eq!(
266 cache.sync_once(&mut server).unwrap(),248 cache.sync_once(&mut server).unwrap().edit,
267 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))249 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
268 );250 );
269 assert_eq!(server.publications, 0);251 assert_eq!(server.publications, 0);
270 assert_eq!(server.durable, remote);252 assert_eq!(server.durable, remote);
271 assert_eq!(cache.snapshot().unwrap(), local);253 assert_eq!(
254 server::pages(&cache.snapshot().unwrap()),
255 server::pages(&local)
256 );
272}257}
273258
274#[test]259#[test]
...@@ -287,7 +272,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {...@@ -287,7 +272,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
287 PageEdit::set_level(pages[4].0, 3).unwrap(),272 PageEdit::set_level(pages[4].0, 3).unwrap(),
288 PageEdit::set_level(pages[5].0, 2).unwrap(),273 PageEdit::set_level(pages[5].0, 2).unwrap(),
289 ];274 ];
290 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();275 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
291 let local = cache.snapshot().unwrap();276 let local = cache.snapshot().unwrap();
292 let queue = cache.pending().unwrap();277 let queue = cache.pending().unwrap();
293 let mut server = Server::new(SOURCE);278 let mut server = Server::new(SOURCE);
...@@ -304,13 +289,12 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {...@@ -304,13 +289,12 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
304 let recovery = Recovery::open(&archive).unwrap();289 let recovery = Recovery::open(&archive).unwrap();
305 assert_eq!(recovery.pending().unwrap(), queue);290 assert_eq!(recovery.pending().unwrap(), queue);
306 assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone()));291 assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone()));
307 assert_eq!(recovery.snapshot().unwrap(), local);292 assert_eq!(
308 assert!(293 server::pages(&recovery.snapshot().unwrap()),
309 cache294 server::pages(&local)
310 .rebase_pages_conflict(id, &local, SOURCE, &edits)
311 .is_err()
312 );295 );
313 let result = cache.sync_once(&mut server).unwrap().unwrap();296 assert!(cache.resolve(id, Resolution::Mine).is_err());
297 let result = cache.sync_once(&mut server).unwrap().edit.unwrap();
314 assert_eq!(server.publications, 1);298 assert_eq!(server.publications, 1);
315 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {299 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
316 assert!(matches!(result.1, EditStatus::Published { .. }));300 assert!(matches!(result.1, EditStatus::Published { .. }));
...@@ -341,10 +325,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {...@@ -341,10 +325,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
341 let directory = tempfile::tempdir().unwrap();325 let directory = tempfile::tempdir().unwrap();
342 let path = directory.path().join("pages.sqlite");326 let path = directory.path().join("pages.sqlite");
343 let cache = Replica::create(&path, &source).unwrap();327 let cache = Replica::create(&path, &source).unwrap();
344 let id = cache328 let id = section_op(
345 .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()])329 &cache,
346 .unwrap()330 SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()),
347 .unwrap();331 );
348 let mut server = Server::new(&source);332 let mut server = Server::new(&source);
349 server.fault = Fault::UnknownAfter;333 server.fault = Fault::UnknownAfter;
350 assert!(cache.sync_once(&mut server).is_err());334 assert!(cache.sync_once(&mut server).is_err());
...@@ -352,7 +336,9 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {...@@ -352,7 +336,9 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
352 drop(cache);336 drop(cache);
353 let db = rusqlite::Connection::open(&path).unwrap();337 let db = rusqlite::Connection::open(&path).unwrap();
354 let encoded: String = db338 let encoded: String = db
355 .query_row("SELECT revisions FROM attempt", [], |r| r.get(0))339 .query_row("SELECT revisions FROM batches WHERE attempted=1", [], |r| {
340 r.get(0)
341 })
356 .unwrap();342 .unwrap();
357 let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();343 let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
358 assert_eq!(proofs.len(), 2);344 assert_eq!(proofs.len(), 2);
...@@ -368,13 +354,13 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {...@@ -368,13 +354,13 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
368 let complete = server.visible.clone();354 let complete = server.visible.clone();
369 server.visible = source;355 server.visible = source;
370 assert_eq!(356 assert_eq!(
371 cache.sync_once(&mut server).unwrap(),357 cache.sync_once(&mut server).unwrap().edit,
372 Some((id, attempted.clone()))358 Some((id, attempted.clone()))
373 );359 );
374 assert_eq!(server.confirmations, 0);360 assert_eq!(server.confirmations, 0);
375 server.visible = complete;361 server.visible = complete;
376 assert!(matches!(362 assert!(matches!(
377 cache.sync_once(&mut server).unwrap(),363 cache.sync_once(&mut server).unwrap().edit,
378 Some((_, EditStatus::Published { .. }))364 Some((_, EditStatus::Published { .. }))
379 ));365 ));
380 assert_eq!(server.publications, 1);366 assert_eq!(server.publications, 1);
...@@ -386,16 +372,13 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {...@@ -386,16 +372,13 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
386 let directory = tempfile::tempdir().unwrap();372 let directory = tempfile::tempdir().unwrap();
387 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();373 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
388 let pages = order(SOURCE);374 let pages = order(SOURCE);
389 let id = cache375 let id = section_op(
390 .pages(376 &cache,
391 SOURCE,377 SectionOp::Pages(vec![
392 &[378 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),
393 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),379 PageEdit::set_level(pages[4].0, 3).unwrap(),
394 PageEdit::set_level(pages[4].0, 3).unwrap(),380 ]),
395 ],381 );
396 )
397 .unwrap()
398 .unwrap();
399 let mut server = Server::new(SOURCE);382 let mut server = Server::new(SOURCE);
400 PreparedEdit::pages(383 PreparedEdit::pages(
401 SOURCE,384 SOURCE,
...@@ -405,7 +388,7 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {...@@ -405,7 +388,7 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
405 .commit(&mut server)388 .commit(&mut server)
406 .unwrap();389 .unwrap();
407 assert_eq!(390 assert_eq!(
408 cache.sync_once(&mut server).unwrap(),391 cache.sync_once(&mut server).unwrap().edit,
409 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))392 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
410 );393 );
411 assert_eq!(server.publications, 0);394 assert_eq!(server.publications, 0);
...@@ -416,10 +399,10 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {...@@ -416,10 +399,10 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {
416 let directory = tempfile::tempdir().unwrap();399 let directory = tempfile::tempdir().unwrap();
417 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();400 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
418 let sid = order(SOURCE)[4].0;401 let sid = order(SOURCE)[4].0;
419 let id = cache402 let id = section_op(
420 .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])403 &cache,
421 .unwrap()404 SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()),
422 .unwrap();405 );
423 let mut server = Server::new(SOURCE);406 let mut server = Server::new(SOURCE);
424 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])407 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
425 .unwrap()408 .unwrap()
...@@ -429,7 +412,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {...@@ -429,7 +412,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {
429 assert!(cache.sync_once(&mut server).is_err());412 assert!(cache.sync_once(&mut server).is_err());
430 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));413 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
431 assert_eq!(server.publications, 0);414 assert_eq!(server.publications, 0);
432 assert!(matches!(cache.sync_once(&mut server).unwrap(),415 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
433 Some((published, EditStatus::Published { .. })) if published == id));416 Some((published, EditStatus::Published { .. })) if published == id));
434 assert_eq!(server.publications, 0);417 assert_eq!(server.publications, 0);
435 assert_eq!(server.confirmations, 2);418 assert_eq!(server.confirmations, 2);
...@@ -464,7 +447,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {...@@ -464,7 +447,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
464 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())447 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
465 .collect()448 .collect()
466 };449 };
467 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();450 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
468 let text_id = model_ops::save(&cache, text_object, |page| {451 let text_id = model_ops::save(&cache, text_object, |page| {
469 model_ops::replace_text(page, text_object, 0..0, "Offline ")452 model_ops::replace_text(page, text_object, 0..0, "Offline ")
470 })453 })
...@@ -484,8 +467,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {...@@ -484,8 +467,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
484 "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two"467 "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two"
485 )468 )
486 };469 };
487 let first = cache.sync_once(&mut server).unwrap().unwrap();470 let first = if expected_conflict {
488 assert_eq!(first.0, id);471 cache.sync_once(&mut server).unwrap().edit.unwrap()
472 } else {
473 (text_id, EditStatus::Pending)
474 };
489 let mut reviewed = edits.clone();475 let mut reviewed = edits.clone();
490 if expected_conflict {476 if expected_conflict {
491 assert_eq!(477 assert_eq!(
...@@ -494,26 +480,25 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {...@@ -494,26 +480,25 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
494 "{mode}:{phase}"480 "{mode}:{phase}"
495 );481 );
496 assert_eq!(server.publications, 0);482 assert_eq!(server.publications, 0);
497 assert_eq!(cache.snapshot().unwrap(), local);483 assert_eq!(
498 if mode == "indent" && phase == "08-collapsed-group-move" {484 server::pages(&cache.snapshot().unwrap()),
499 reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap();485 server::pages(&local)
500 }
501 cache
502 .rebase_pages_conflict(id, &local, &native, &reviewed)
503 .unwrap();
504 assert!(matches!(cache.sync_once(&mut server).unwrap(),
505 Some((published, EditStatus::Published { .. })) if published == id));
506 } else {
507 assert!(
508 matches!(first.1, EditStatus::Published { .. }),
509 "{mode}:{phase}"
510 );486 );
487 // Keeping mine drops the moves that no longer apply.
488 cache.resolve(id, Resolution::Mine).unwrap();
489 reviewed = match &cache.pending().unwrap()[0].edit.ops[..] {
490 [Op::Section(SectionOp::Pages(kept))] => kept.clone(),
491 [] => Vec::new(),
492 other => panic!("{other:?}"),
493 };
511 }494 }
495 assert!(
496 matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == text_id),
497 "{mode}:{phase}"
498 );
512 counts[usize::from(expected_conflict)] += 1;499 counts[usize::from(expected_conflict)] += 1;
513 drop(cache);500 drop(cache);
514 let cache = Replica::open(&path).unwrap();501 let cache = Replica::open(&path).unwrap();
515 assert!(matches!(cache.sync_once(&mut server).unwrap(),
516 Some((published, EditStatus::Published { .. })) if published == text_id));
517 assert!(cache.pending().unwrap().is_empty());502 assert!(cache.pending().unwrap().is_empty());
518 let mut expected = order(&native);503 let mut expected = order(&native);
519 for edit in &reviewed {504 for edit in &reviewed {
...@@ -529,10 +514,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {...@@ -529,10 +514,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
529 }514 }
530 assert_eq!(order(&server.durable), expected, "{mode}:{phase}");515 assert_eq!(order(&server.durable), expected, "{mode}:{phase}");
531 assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}");516 assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}");
532 assert_eq!(517 assert_eq!(server.publications, 1);
533 server.publications,
534 1 + usize::from(expected != order(&native))
535 );
536 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") {518 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") {
537 let output = std::path::Path::new(&output);519 let output = std::path::Path::new(&output);
538 assert!(output.is_absolute());520 assert!(output.is_absolute());
...@@ -580,13 +562,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {...@@ -580,13 +562,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
580 PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(),562 PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(),
581 PageEdit::set_level(group[2].space(), 2).unwrap(),563 PageEdit::set_level(group[2].space(), 2).unwrap(),
582 ];564 ];
583 cache.pages(&source, &edits).unwrap().unwrap();565 section_op(&cache, SectionOp::Pages(edits.to_vec()));
584 let body = format!("Actor {actor} 🦀 e\u{301}");566 let body = format!("Actor {actor} 🦀 e\u{301}");
585 let local = cache.snapshot().unwrap();567 let local = cache.snapshot().unwrap();
586 let mut model = model_ops::page_of(&local, group[1].space());568 let mut model = model_ops::page_of(&local, group[1].space());
587 let text = body_outline(&mut model, &body);569 let text = body_outline(&mut model, &body);
588 cache570 model_ops::save_as(&cache, group[1].space(), &model, "Author")
589 .save(&local, group[1].space(), &model, "Author")
590 .unwrap()571 .unwrap()
591 .unwrap();572 .unwrap();
592 expected.extend([573 expected.extend([
...@@ -601,18 +582,18 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {...@@ -601,18 +582,18 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
601 for (path, queue) in &queues {582 for (path, queue) in &queues {
602 let cache = Replica::open(path).unwrap();583 let cache = Replica::open(path).unwrap();
603 assert_eq!(cache.pending().unwrap(), *queue);584 assert_eq!(cache.pending().unwrap(), *queue);
604 for edit in queue {585 assert!(matches!(
605 assert!(matches!(cache.sync_once(&mut server).unwrap(),586 cache.sync_once(&mut server).unwrap().edit,
606 Some((published, EditStatus::Published { .. })) if published == edit.id));587 Some((_, EditStatus::Published { .. }))
607 }588 ));
608 assert!(cache.pending().unwrap().is_empty());589 assert!(cache.pending().unwrap().is_empty());
609 }590 }
610 assert_eq!(server.publications, 24);591 assert_eq!(server.publications, 12);
611 assert_eq!(order(&server.durable), expected);592 assert_eq!(order(&server.durable), expected);
612 assert_eq!(texts(&server.durable), expected_text);593 assert_eq!(texts(&server.durable), expected_text);
613 for (path, queue) in &queues {594 for (path, queue) in &queues {
614 let cache = Replica::open(path).unwrap();595 let cache = Replica::open(path).unwrap();
615 assert_eq!(cache.sync_once(&mut server).unwrap(), None);596 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
616 assert_eq!(order(&cache.snapshot().unwrap()), expected);597 assert_eq!(order(&cache.snapshot().unwrap()), expected);
617 for edit in queue {598 for edit in queue {
618 assert!(matches!(599 assert!(matches!(
...@@ -631,10 +612,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {...@@ -631,10 +612,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
631 .map(|(_, queue)| {612 .map(|(_, queue)| {
632 queue613 queue
633 .iter()614 .iter()
634 .map(|edit| {615 .map(|edit| serde_json::json!({"id": edit.id, "edit": edit.edit}))
635 serde_json::json!({"id": edit.id, "space": edit.space,
636 "operation": edit.operation})
637 })
638 .collect::<Vec<_>>()616 .collect::<Vec<_>>()
639 })617 })
640 .collect();618 .collect();
crates/notebook/tests/sync_paragraph.rs+59-54
...@@ -11,7 +11,7 @@ use std::path::Path;...@@ -11,7 +11,7 @@ use std::path::Path;
1111
12#[path = "support/server.rs"]12#[path = "support/server.rs"]
13mod server;13mod server;
14use server::{Fault, Server};14use server::{Fault, Server, pages};
15#[path = "support/model_ops.rs"]15#[path = "support/model_ops.rs"]
16mod model_ops;16mod model_ops;
17use model_ops::{AUTHOR, outlines_mut, page_of, replace_text};17use model_ops::{AUTHOR, outlines_mut, page_of, replace_text};
...@@ -178,16 +178,13 @@ fn save_page(...@@ -178,16 +178,13 @@ fn save_page(
178 let source = cache.snapshot()?;178 let source = cache.snapshot()?;
179 let (space, mut page) = holder(&source, text);179 let (space, mut page) = holder(&source, text);
180 edit(&mut page);180 edit(&mut page);
181 cache.save(&source, space, &page, AUTHOR)181 model_ops::save_as(cache, space, &page, AUTHOR)
182}182}
183183
184/// Reviews a conflicting save by applying `edit` to the page the remote now holds.184/// Resolves a conflict by taking the remote page, then saves `edit` of it.
185fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) {185fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) {
186 let local = cache.snapshot().unwrap();186 cache.resolve(id, notebook::Resolution::Theirs).unwrap();
187 let remote = cache.remote_snapshot().unwrap();187 save_page(cache, text, edit).unwrap().unwrap();
188 let (_, mut page) = holder(&remote, text);
189 edit(&mut page);
190 cache.review_page(id, &local, &remote, &page).unwrap();
191}188}
192189
193/// The styled text of an object anywhere on a page.190/// The styled text of an object anywhere on a page.
...@@ -246,18 +243,20 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {...@@ -246,18 +243,20 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
246 let mut cache = Replica::create(&path, BEFORE).unwrap();243 let mut cache = Replica::create(&path, BEFORE).unwrap();
247 let mut after = original.clone();244 let mut after = original.clone();
248 apply(&mut after);245 apply(&mut after);
249 let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap();246 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
247 .unwrap()
248 .unwrap();
250 let pending = cache.pending().unwrap();249 let pending = cache.pending().unwrap();
251 drop(cache);250 drop(cache);
252 cache = Replica::open(&path).unwrap();251 cache = Replica::open(&path).unwrap();
253 assert_eq!(cache.pending().unwrap(), pending);252 assert_eq!(cache.pending().unwrap(), pending);
254 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();253 let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
255 assert_eq!(actual, id);254 assert_eq!(actual, id);
256 let reviewed = matches!(status, EditStatus::Conflict(_));255 let reviewed = matches!(status, EditStatus::Conflict(_));
257 let receipt = if reviewed {256 let receipt = if reviewed {
258 assert_eq!(cache.pending().unwrap(), pending);257 assert_eq!(cache.pending().unwrap(), pending);
259 review_page(&cache, id, left, apply);258 review_page(&cache, id, left, apply);
260 cache.sync_once(&mut server).unwrap().unwrap().1259 cache.sync_once(&mut server).unwrap().edit.unwrap().1
261 } else {260 } else {
262 automatic += 1;261 automatic += 1;
263 status262 status
...@@ -293,7 +292,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {...@@ -293,7 +292,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
293 let dependent = save_page(&cache, target, |page| dependent_edit(page, target))292 let dependent = save_page(&cache, target, |page| dependent_edit(page, target))
294 .unwrap()293 .unwrap()
295 .unwrap();294 .unwrap();
296 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();295 let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
297 assert_eq!(actual, dependent);296 assert_eq!(actual, dependent);
298 let EditStatus::Published {297 let EditStatus::Published {
299 revision: dependent_revision,298 revision: dependent_revision,
...@@ -309,7 +308,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {...@@ -309,7 +308,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
309 "receipt": revision, "dependent_receipt": dependent_revision}),308 "receipt": revision, "dependent_receipt": dependent_revision}),
310 );309 );
311 }310 }
312 assert_eq!((recorded.len(), automatic), (16, 6));311 assert_eq!((recorded.len(), automatic), (16, 8));
313 assert_eq!(server.publications, 32);312 assert_eq!(server.publications, 32);
314 (server.durable, recorded)313 (server.durable, recorded)
315}314}
...@@ -321,19 +320,20 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review()...@@ -321,19 +320,20 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review()
321 ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]),320 ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]),
322 ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]),321 ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]),
323 ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]),322 ("Split remote child", "reviewed", vec!["ab", "🦀Cd"]),
324 ("Split remote list", "automatic", vec!["ab", "🦀Cd"]),323 // A split names copies of its paragraph's list nodes; the remote's new list has none.
324 ("Split remote list", "reviewed", vec!["ab", "🦀Cd"]),
325 ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]),325 ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]),
326 ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]),326 ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]),
327 ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]),327 ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]),
328 ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]),328 ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]),
329 ("Join left boundary", "reviewed", vec!["ab🦀CdXRight"]),329 ("Join left boundary", "automatic", vec!["ab🦀CdXRight"]),
330 ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]),330 ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]),
331 ("Join remote child", "reviewed", vec!["ab🦀CdRight"]),331 ("Join remote child", "automatic", vec!["ab🦀CdRight"]),
332 ("Join remote list", "reviewed", vec!["ab🦀CdRight"]),332 ("Join remote list", "reviewed", vec!["ab🦀CdRight"]),
333 ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]),333 ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]),
334 ("Join remote format", "reviewed", vec!["ab🦀CdRight"]),334 ("Join remote format", "reviewed", vec!["ab🦀CdRight"]),
335 ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]),335 ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]),
336 ("Join empty adoption", "reviewed", vec!["XIight"]),336 ("Join empty adoption", "automatic", vec!["XIight"]),
337 ];337 ];
338 for (case, (name, outcome, prefix)) in cases.iter().zip(expected) {338 for (case, (name, outcome, prefix)) in cases.iter().zip(expected) {
339 assert_eq!(case["case"], name);339 assert_eq!(case["case"], name);
...@@ -363,17 +363,19 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join(...@@ -363,17 +363,19 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join(
363 } else {363 } else {
364 join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id);364 join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id);
365 }365 }
366 let id = cache.save(BEFORE, space, &after, AUTHOR).unwrap().unwrap();366 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
367 .unwrap()
368 .unwrap();
367 let local = cache.snapshot().unwrap();369 let local = cache.snapshot().unwrap();
368 let pending = cache.pending().unwrap();370 let pending = cache.pending().unwrap();
369 assert_eq!(371 assert_eq!(
370 cache.sync_once(&mut server).unwrap(),372 cache.sync_once(&mut server).unwrap().edit,
371 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),373 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
372 "{name}"374 "{name}"
373 );375 );
374 drop(cache);376 drop(cache);
375 let cache = Replica::open(&path).unwrap();377 let cache = Replica::open(&path).unwrap();
376 assert_eq!(cache.snapshot().unwrap(), local);378 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
377 assert_eq!(cache.pending().unwrap(), pending);379 assert_eq!(cache.pending().unwrap(), pending);
378 assert_eq!(380 assert_eq!(
379 cache.status(id).unwrap(),381 cache.status(id).unwrap(),
...@@ -448,6 +450,7 @@ fn offline_paragraphs(output: Option<&Path>) {...@@ -448,6 +450,7 @@ fn offline_paragraphs(output: Option<&Path>) {
448 let cases = if split { &manifest["cases"] } else { &manifest };450 let cases = if split { &manifest["cases"] } else { &manifest };
449 let mut entries = Vec::new();451 let mut entries = Vec::new();
450 let mut refused: Vec<String> = Vec::new();452 let mut refused: Vec<String> = Vec::new();
453 let mut reviewed: Vec<String> = Vec::new();
451 for case in cases454 for case in cases
452 .as_array()455 .as_array()
453 .unwrap()456 .unwrap()
...@@ -474,16 +477,10 @@ fn offline_paragraphs(output: Option<&Path>) {...@@ -474,16 +477,10 @@ fn offline_paragraphs(output: Option<&Path>) {
474 continue;477 continue;
475 };478 };
476 let id = queued.unwrap();479 let id = queued.unwrap();
477 entries.push(480 let mut entry = serde_json::json!({"case": case["case"], "id": id, "space": space,
478 serde_json::json!({"case": case["case"], "id": id, "space": space,481 "intent": intent, "remote_prefix": prefixed});
479 "intent": intent, "remote_prefix": prefixed}),
480 );
481 drop(cache);482 drop(cache);
482 cache = Replica::open(&path).unwrap();483 cache = Replica::open(&path).unwrap();
483 }
484 let mut reviewed: Vec<String> = Vec::new();
485 for entry in &mut entries {
486 let id = entry["id"].as_u64().unwrap();
487 let case = entry["case"].as_str().unwrap().to_owned();484 let case = entry["case"].as_str().unwrap().to_owned();
488 let intent = entry["intent"].clone();485 let intent = entry["intent"].clone();
489 let mut uncertain = id % 2 == 0;486 let mut uncertain = id % 2 == 0;
...@@ -501,11 +498,20 @@ fn offline_paragraphs(output: Option<&Path>) {...@@ -501,11 +498,20 @@ fn offline_paragraphs(output: Option<&Path>) {
501 drop(cache);498 drop(cache);
502 cache = Replica::open(&path).unwrap();499 cache = Replica::open(&path).unwrap();
503 }500 }
504 Ok(Some((actual, EditStatus::Published { revision }))) => {501 Ok(notebook::Synced {
505 assert_eq!(actual, id);502 edit: Some((_, EditStatus::Published { revision })),
503 ..
504 }) => {
505 assert!(matches!(
506 cache.status(id).unwrap(),
507 Some(EditStatus::Published { .. })
508 ));
506 break revision;509 break revision;
507 }510 }
508 Ok(Some((actual, EditStatus::Conflict(_)))) => {511 Ok(notebook::Synced {
512 edit: Some((actual, EditStatus::Conflict(_))),
513 ..
514 }) => {
509 assert_eq!(actual, id);515 assert_eq!(actual, id);
510 server.fault = Fault::None;516 server.fault = Fault::None;
511 reviewed.push(case.clone());517 reviewed.push(case.clone());
...@@ -518,16 +524,15 @@ fn offline_paragraphs(output: Option<&Path>) {...@@ -518,16 +524,15 @@ fn offline_paragraphs(output: Option<&Path>) {
518 replay(page, &placed, split)524 replay(page, &placed, split)
519 });525 });
520 }526 }
521 other => panic!("{name} {}: {other:?}", entry["case"]),527 other => panic!("{name} {case}: {other:?}"),
522 }528 }
523 };529 };
524 entry["revision"] = serde_json::to_value(revision).unwrap();530 entry["revision"] = serde_json::to_value(revision).unwrap();
531 entries.push(entry);
525 }532 }
526 // A split inside a table cell the remote also changed overlaps at the cell.
527 let structural = ["Split cell".to_owned()];
528 let unrepresentable = ["Join inherited styles".to_owned()];533 let unrepresentable = ["Join inherited styles".to_owned()];
529 let expected: (usize, &[String], &[String]) = match name {534 let expected: (usize, &[String], &[String]) = match name {
530 "splits" | "joins" => (12, &structural, &[]),535 "splits" | "joins" => (12, &[], &[]),
531 "inheritance" => (4, &[], &unrepresentable),536 "inheritance" => (4, &[], &unrepresentable),
532 _ => (3, &[], &[]),537 _ => (3, &[], &[]),
533 };538 };
...@@ -537,7 +542,7 @@ fn offline_paragraphs(output: Option<&Path>) {...@@ -537,7 +542,7 @@ fn offline_paragraphs(output: Option<&Path>) {
537 "{name}"542 "{name}"
538 );543 );
539 assert_eq!(server.publications, entries.len());544 assert_eq!(server.publications, entries.len());
540 assert_eq!(cache.sync_once(&mut server).unwrap(), None);545 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
541 assert_eq!(cache.snapshot().unwrap(), server.durable);546 assert_eq!(cache.snapshot().unwrap(), server.durable);
542 if output.is_some() {547 if output.is_some() {
543 cache548 cache
...@@ -585,7 +590,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {...@@ -585,7 +590,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
585 .unwrap()590 .unwrap()
586 .unwrap();591 .unwrap();
587 assert!(matches!(592 assert!(matches!(
588 cache.sync_once(&mut server).unwrap(),593 cache.sync_once(&mut server).unwrap().edit,
589 Some((_, EditStatus::Published { .. }))594 Some((_, EditStatus::Published { .. }))
590 ));595 ));
591 let published = page_of(&server.durable, space);596 let published = page_of(&server.durable, space);
...@@ -598,7 +603,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {...@@ -598,7 +603,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
598 .unwrap()603 .unwrap()
599 .unwrap();604 .unwrap();
600 assert!(matches!(605 assert!(matches!(
601 cache.sync_once(&mut server).unwrap(),606 cache.sync_once(&mut server).unwrap().edit,
602 Some((_, EditStatus::Published { .. }))607 Some((_, EditStatus::Published { .. }))
603 ));608 ));
604 assert_eq!(609 assert_eq!(
...@@ -623,7 +628,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() {...@@ -623,7 +628,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() {
623 .unwrap();628 .unwrap();
624 let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote "));629 let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote "));
625 assert!(630 assert!(
626 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)631 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == id)
627 );632 );
628 assert_eq!(633 assert_eq!(
629 texts(&page_of(&server.durable, space)),634 texts(&page_of(&server.durable, space)),
...@@ -645,13 +650,13 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() {...@@ -645,13 +650,13 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() {
645 .unwrap();650 .unwrap();
646 let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote "));651 let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote "));
647 assert_eq!(652 assert_eq!(
648 cache.sync_once(&mut server).unwrap(),653 cache.sync_once(&mut server).unwrap().edit,
649 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))654 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
650 );655 );
651 assert_eq!(server.publications, 0);656 assert_eq!(server.publications, 0);
652 review_page(&cache, id, left, |page| join_paragraphs(page, left, right));657 review_page(&cache, id, left, |page| join_paragraphs(page, left, right));
653 assert!(matches!(658 assert!(matches!(
654 cache.sync_once(&mut server).unwrap(),659 cache.sync_once(&mut server).unwrap().edit,
655 Some((_, EditStatus::Published { .. }))660 Some((_, EditStatus::Published { .. }))
656 ));661 ));
657 assert_eq!(662 assert_eq!(
...@@ -674,7 +679,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {...@@ -674,7 +679,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
674 save_page(&cache, left, |page| split_paragraph(page, left, 2))679 save_page(&cache, left, |page| split_paragraph(page, left, 2))
675 .unwrap()680 .unwrap()
676 .unwrap();681 .unwrap();
677 cache.sync_once(&mut server).unwrap().unwrap();682 cache.sync_once(&mut server).unwrap().edit.unwrap();
678 let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1]683 let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1]
679 .text()684 .text()
680 .unwrap()685 .unwrap()
...@@ -688,7 +693,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {...@@ -688,7 +693,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
688 let pending = cache.pending().unwrap();693 let pending = cache.pending().unwrap();
689 drop(cache);694 drop(cache);
690 cache = Replica::open(&path).unwrap();695 cache = Replica::open(&path).unwrap();
691 assert_eq!(cache.snapshot().unwrap(), local);696 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
692 assert_eq!(cache.pending().unwrap(), pending);697 assert_eq!(cache.pending().unwrap(), pending);
693 let mut remote = page_of(&server.durable, space);698 let mut remote = page_of(&server.durable, space);
694 replace_text(&mut remote, other, 0..0, "Remote ");699 replace_text(&mut remote, other, 0..0, "Remote ");
...@@ -698,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {...@@ -698,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
698 .to_vec();703 .to_vec();
699 server.durable.clone_from(&server.visible);704 server.durable.clone_from(&server.visible);
700 assert!(705 assert!(
701 matches!(cache.sync_once(&mut server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == dependent)706 matches!(cache.sync_once(&mut server).unwrap().edit, Some((actual, EditStatus::Published { .. })) if actual == dependent)
702 );707 );
703 assert_eq!(708 assert_eq!(
704 texts(&page_of(&server.durable, space)),709 texts(&page_of(&server.durable, space)),
...@@ -751,7 +756,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -751,7 +756,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
751 assert_eq!(state, EditStatus::Pending);756 assert_eq!(state, EditStatus::Pending);
752 assert_eq!(cache.pending().unwrap(), pending);757 assert_eq!(cache.pending().unwrap(), pending);
753 assert!(matches!(758 assert!(matches!(
754 cache.sync_once(&mut server).unwrap().unwrap().1,759 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
755 EditStatus::Published { .. }760 EditStatus::Published { .. }
756 ));761 ));
757 assert_eq!(server.publications, 2);762 assert_eq!(server.publications, 2);
...@@ -759,10 +764,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -759,10 +764,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
759 Fault::UnknownBefore => {764 Fault::UnknownBefore => {
760 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));765 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
761 assert_eq!(cache.pending().unwrap(), pending);766 assert_eq!(cache.pending().unwrap(), pending);
762 assert_eq!(cache.snapshot().unwrap(), local);767 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
763 for _ in 0..3 {768 for _ in 0..3 {
764 assert_eq!(769 assert_eq!(
765 cache.sync_once(&mut server).unwrap(),770 cache.sync_once(&mut server).unwrap().edit,
766 Some((id, state.clone()))771 Some((id, state.clone()))
767 );772 );
768 }773 }
...@@ -772,9 +777,9 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -772,9 +777,9 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
772 }777 }
773 Fault::UnknownAfter => {778 Fault::UnknownAfter => {
774 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));779 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
775 assert_eq!(cache.snapshot().unwrap(), local);780 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
776 assert!(matches!(781 assert!(matches!(
777 cache.sync_once(&mut server).unwrap().unwrap().1,782 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
778 EditStatus::Published { .. }783 EditStatus::Published { .. }
779 ));784 ));
780 assert_eq!(server.publications, 1);785 assert_eq!(server.publications, 1);
...@@ -791,7 +796,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {...@@ -791,7 +796,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
791 &["ab", "\u{1f980}cd", "Right", "Preserved sibling"]796 &["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
792 };797 };
793 assert_eq!(texts(&page_of(&server.durable, space)), expected);798 assert_eq!(texts(&page_of(&server.durable, space)), expected);
794 assert_eq!(cache.sync_once(&mut server).unwrap(), None);799 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
795 assert_eq!(cache.snapshot().unwrap(), server.durable);800 assert_eq!(cache.snapshot().unwrap(), server.durable);
796 }801 }
797 }802 }
...@@ -818,17 +823,17 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {...@@ -818,17 +823,17 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {
818 let pending = second.pending().unwrap();823 let pending = second.pending().unwrap();
819 let mut server = Server::new(BEFORE);824 let mut server = Server::new(BEFORE);
820 assert!(matches!(825 assert!(matches!(
821 first.sync_once(&mut server).unwrap().unwrap().1,826 first.sync_once(&mut server).unwrap().edit.unwrap().1,
822 EditStatus::Published { .. }827 EditStatus::Published { .. }
823 ));828 ));
824 assert_eq!(829 assert_eq!(
825 second.sync_once(&mut server).unwrap(),830 second.sync_once(&mut server).unwrap().edit,
826 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))831 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
827 );832 );
828 assert_eq!(server.publications, 1);833 assert_eq!(server.publications, 1);
829 drop(second);834 drop(second);
830 let second = Replica::open(&path).unwrap();835 let second = Replica::open(&path).unwrap();
831 assert_eq!(second.snapshot().unwrap(), local);836 assert_eq!(pages(&second.snapshot().unwrap()), pages(&local));
832 assert_eq!(second.pending().unwrap(), pending);837 assert_eq!(second.pending().unwrap(), pending);
833 assert_eq!(838 assert_eq!(
834 second.status(id).unwrap(),839 second.status(id).unwrap(),
...@@ -838,7 +843,7 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {...@@ -838,7 +843,7 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {
838 split_paragraph(page, left, 2);843 split_paragraph(page, left, 2);
839 });844 });
840 assert!(matches!(845 assert!(matches!(
841 second.sync_once(&mut server).unwrap().unwrap().1,846 second.sync_once(&mut server).unwrap().edit.unwrap().1,
842 EditStatus::Published { .. }847 EditStatus::Published { .. }
843 ));848 ));
844 assert_eq!(849 assert_eq!(
crates/notebook/tests/sync_worker.rs+72-54
...@@ -111,7 +111,12 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {...@@ -111,7 +111,12 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
111 },111 },
112 move |result| {112 move |result| {
113 observed_tx113 observed_tx
114 .send(result.as_ref().cloned().map_err(|error| error.to_string()))114 .send(
115 result
116 .as_ref()
117 .map(|synced| synced.edit.clone())
118 .map_err(|error| error.to_string()),
119 )
115 .unwrap();120 .unwrap();
116 },121 },
117 )122 )
...@@ -166,7 +171,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -166,7 +171,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
166 Duration::from_secs(3600),171 Duration::from_secs(3600),
167 move || Ok(shared.clone()),172 move || Ok(shared.clone()),
168 move |result| {173 move |result| {
169 observed_tx.send(result.as_ref().unwrap().clone()).unwrap();174 observed_tx
175 .send(result.as_ref().unwrap().edit.clone())
176 .unwrap();
170 if first {177 if first {
171 first = false;178 first = false;
172 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();179 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
...@@ -185,16 +192,11 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -185,16 +192,11 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
185 let cache = &cache;192 let cache = &cache;
186 scope.spawn(move || {193 scope.spawn(move || {
187 let marker = format!("[{writer}] ");194 let marker = format!("[{writer}] ");
188 loop {195 model_ops::save(cache, oid, |page| {
189 match model_ops::save(cache, oid, |page| {196 model_ops::replace_text(page, oid, 0..0, &marker)
190 model_ops::replace_text(page, oid, 0..0, &marker)197 })
191 }) {198 .unwrap()
192 Ok(Some(id)) => break id,199 .unwrap()
193 Err(Error::Io(error))
194 if error.kind() == io::ErrorKind::ResourceBusy => {}
195 other => panic!("Unexpected local outcome: {other:?}"),
196 }
197 }
198 })200 })
199 })201 })
200 .collect::<Vec<_>>()202 .collect::<Vec<_>>()
...@@ -203,11 +205,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -203,11 +205,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
203 .collect::<std::collections::BTreeSet<_>>()205 .collect::<std::collections::BTreeSet<_>>()
204 });206 });
205 resume_tx.send(()).unwrap();207 resume_tx.send(()).unwrap();
206 let mut published = std::collections::BTreeSet::new();
207 while !cache.pending().unwrap().is_empty() {208 while !cache.pending().unwrap().is_empty() {
208 if let Some((id, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {209 if let Some((_, status)) = observed_rx.recv_timeout(Duration::from_secs(5)).unwrap() {
209 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");210 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
210 published.insert(id);
211 }211 }
212 }212 }
213 assert!(213 assert!(
...@@ -215,7 +215,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -215,7 +215,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
215 "Edits waited for the hourly poll"215 "Edits waited for the hourly poll"
216 );216 );
217 worker.stop().unwrap();217 worker.stop().unwrap();
218 assert_eq!(published, queued);218 for id in &queued {
219 assert!(matches!(
220 cache.status(*id).unwrap(),
221 Some(EditStatus::Published { .. })
222 ));
223 }
219 let content = text(&cache.snapshot().unwrap()).2;224 let content = text(&cache.snapshot().unwrap()).2;
220 for writer in 0..12 {225 for writer in 0..12 {
221 assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1);226 assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1);
...@@ -223,7 +228,8 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {...@@ -223,7 +228,8 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
223 assert!(content.ends_with("abc"));228 assert!(content.ends_with("abc"));
224 let server = server.lock().unwrap();229 let server = server.lock().unwrap();
225 assert_eq!(text(&server.durable).2, content);230 assert_eq!(text(&server.durable).2, content);
226 assert_eq!(server.publications, queued.len());231 // Edits queued while the worker was busy publish together.
232 assert_eq!(server.publications, 1);
227 assert_eq!(cache.snapshot().unwrap(), server.durable);233 assert_eq!(cache.snapshot().unwrap(), server.durable);
228}234}
229235
...@@ -308,7 +314,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove...@@ -308,7 +314,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
308 Duration::from_secs(3600),314 Duration::from_secs(3600),
309 move || Ok(shared.clone()),315 move || Ok(shared.clone()),
310 move |result| {316 move |result| {
311 tx.send(result.as_ref().unwrap().clone()).unwrap();317 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
312 },318 },
313 )319 )
314 .unwrap();320 .unwrap();
...@@ -332,7 +338,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()...@@ -332,7 +338,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()
332 let id = save(&cache, oid, 0..0, "L ").unwrap();338 let id = save(&cache, oid, 0..0, "L ").unwrap();
333 drop(cache);339 drop(cache);
334 let connection = rusqlite::Connection::open(&path).unwrap();340 let connection = rusqlite::Connection::open(&path).unwrap();
335 connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE INSERT ON attempt BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap();341 connection.execute_batch("CREATE TRIGGER fail_attempt BEFORE UPDATE OF attempted ON batches BEGIN SELECT RAISE(ABORT, 'test cache write failure'); END;").unwrap();
336 drop(connection);342 drop(connection);
337 let cache = Arc::new(Replica::open(&path).unwrap());343 let cache = Arc::new(Replica::open(&path).unwrap());
338 let server = Arc::new(Mutex::new(Server::new(&source)));344 let server = Arc::new(Mutex::new(Server::new(&source)));
...@@ -356,14 +362,14 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()...@@ -356,14 +362,14 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()
356}362}
357363
358#[test]364#[test]
359fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {365fn polling_reports_a_blocked_queue_once_per_remote_change_without_replay() {
360 for uncertain in [false, true] {366 for uncertain in [false, true] {
361 let dir = tempfile::tempdir().unwrap();367 let dir = tempfile::tempdir().unwrap();
362 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();368 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
363 let (sid, oid, _) = text(&source);369 let (sid, oid, _) = text(&source);
364 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());370 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
365 let id = save(&cache, oid, 1..2, "L").unwrap();371 let id = save(&cache, oid, 1..2, "L").unwrap();
366 let local = cache.snapshot().unwrap();372 let local = text(&cache.snapshot().unwrap()).2;
367 let mut server = if uncertain {373 let mut server = if uncertain {
368 Server::new(&source)374 Server::new(&source)
369 } else {375 } else {
...@@ -380,38 +386,48 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {...@@ -380,38 +386,48 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
380 Duration::from_millis(10),386 Duration::from_millis(10),
381 move || Ok(shared.clone()),387 move || Ok(shared.clone()),
382 move |result| {388 move |result| {
383 tx.send(result.as_ref().cloned().map_err(|_| ())).unwrap();389 tx.send(
390 result
391 .as_ref()
392 .map(|synced| synced.edit.clone())
393 .map_err(|_| ()),
394 )
395 .unwrap();
384 },396 },
385 )397 )
386 .unwrap();398 .unwrap();
387 if uncertain {399 if uncertain {
388 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());400 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
389 }401 }
390 let mut previous = None;402 let expected = |status: &EditStatus| {
391 let started = Instant::now();403 if uncertain {
392 for _ in 0..5 {404 matches!(status, EditStatus::AwaitingConfirmation { .. })
405 } else {
406 *status == EditStatus::Conflict(ConflictKind::ContentChanged)
407 }
408 };
409 for round in 0..3 {
393 let (actual, status) = rx410 let (actual, status) = rx
394 .recv_timeout(Duration::from_secs(5))411 .recv_timeout(Duration::from_secs(5))
395 .unwrap()412 .unwrap()
396 .unwrap()413 .unwrap()
397 .unwrap();414 .unwrap();
398 assert_eq!(actual, id);415 assert_eq!(actual, id);
399 if uncertain {416 assert!(expected(&status), "{status:?}");
400 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));417 // An unchanged remote is not read or reported again.
401 } else {418 assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
402 assert_eq!(status, EditStatus::Conflict(ConflictKind::ContentChanged));419 // Another writer's change elsewhere in the text is read and decided again.
403 }420 let mut server = server.lock().unwrap();
404 if let Some(previous) = previous {421 let changed =
405 assert_eq!(previous, status);422 onestore::replace_text(&server.visible, sid, oid, 0..0, &round.to_string())
406 }423 .unwrap();
407 previous = Some(status.clone());424 server.visible = changed.clone();
425 server.durable = changed;
426 drop(server);
427 worker.wake();
408 }428 }
409 assert!(
410 started.elapsed() >= Duration::from_millis(30),
411 "Worker spun instead of waiting between retries"
412 );
413 worker.stop().unwrap();429 worker.stop().unwrap();
414 assert_eq!(cache.snapshot().unwrap(), local);430 assert_eq!(text(&cache.snapshot().unwrap()).2, local);
415 assert_eq!(cache.pending().unwrap().len(), 1);431 assert_eq!(cache.pending().unwrap().len(), 1);
416 let server = server.lock().unwrap();432 let server = server.lock().unwrap();
417 assert_eq!(server.publications, usize::from(uncertain));433 assert_eq!(server.publications, usize::from(uncertain));
...@@ -511,7 +527,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {...@@ -511,7 +527,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
511 Duration::from_secs(3600),527 Duration::from_secs(3600),
512 move || Ok(shared.clone()),528 move || Ok(shared.clone()),
513 move |result| {529 move |result| {
514 tx.send(result.as_ref().unwrap().clone()).unwrap();530 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
515 },531 },
516 )532 )
517 .unwrap();533 .unwrap();
...@@ -521,7 +537,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {...@@ -521,7 +537,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
521}537}
522538
523#[test]539#[test]
524fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {540fn a_resolved_conflict_wakes_the_worker_and_publishes_the_original_edit_once() {
525 let dir = tempfile::tempdir().unwrap();541 let dir = tempfile::tempdir().unwrap();
526 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();542 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
527 let (sid, oid, _) = text(&source);543 let (sid, oid, _) = text(&source);
...@@ -538,7 +554,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {...@@ -538,7 +554,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
538 Duration::from_secs(3600),554 Duration::from_secs(3600),
539 move || Ok(shared.clone()),555 move || Ok(shared.clone()),
540 move |result| {556 move |result| {
541 tx.send(result.as_ref().unwrap().clone()).unwrap();557 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
542 if first {558 if first {
543 first = false;559 first = false;
544 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();560 resume_rx.recv_timeout(Duration::from_secs(5)).unwrap();
...@@ -550,10 +566,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {...@@ -550,10 +566,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
550 rx.recv_timeout(Duration::from_secs(5)).unwrap(),566 rx.recv_timeout(Duration::from_secs(5)).unwrap(),
551 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))567 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
552 );568 );
553 model_ops::review(&cache, id, oid, |page| {569 cache.resolve(id, notebook::Resolution::Mine).unwrap();
554 model_ops::replace_text(page, oid, 1..2, "L")
555 })
556 .unwrap();
557 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));570 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
558 resume_tx.send(()).unwrap();571 resume_tx.send(()).unwrap();
559 assert!(572 assert!(
...@@ -618,8 +631,13 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {...@@ -618,8 +631,13 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
618 Duration::from_millis(1),631 Duration::from_millis(1),
619 move || Ok(remote.take().expect("Contention caused a reconnect")),632 move || Ok(remote.take().expect("Contention caused a reconnect")),
620 move |result| {633 move |result| {
621 tx.send(result.as_ref().cloned().map_err(|error| error.to_string()))634 tx.send(
622 .unwrap();635 result
636 .as_ref()
637 .map(|synced| synced.edit.clone())
638 .map_err(|error| error.to_string()),
639 )
640 .unwrap();
623 },641 },
624 )642 )
625 .unwrap();643 .unwrap();
...@@ -675,14 +693,14 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {...@@ -675,14 +693,14 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
675 failed = true;693 failed = true;
676 let page =694 let page =
677 onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap();695 onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap();
678 let second = observed696 let second = section_op(&observed, onestore::op::SectionOp::Create(page));
679 .create_page(&observed.snapshot().unwrap(), &page)
680 .unwrap()
681 .unwrap();
682 tx.send((second, None)).unwrap();697 tx.send((second, None)).unwrap();
683 }698 }
684 Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(),699 Ok(notebook::Synced {
685 Ok(None) => {}700 edit: Some((id, status)),
701 ..
702 }) => tx.send((*id, Some(status.clone()))).unwrap(),
703 Ok(_) => {}
686 other => panic!("Unexpected worker result: {other:?}"),704 other => panic!("Unexpected worker result: {other:?}"),
687 },705 },
688 )706 )
crates/onestore/src/commit.rs+13
...@@ -644,6 +644,19 @@ impl Transaction {...@@ -644,6 +644,19 @@ impl Transaction {
644 }644 }
645 }645 }
646646
647 /// The image this transaction applies to.
648 pub fn base(&self) -> &Stamp {
649 &self.base
650 }
651
652 /// The bytes a commit writes, by offset, in the order `apply` writes them; the header,
653 /// last, covers bytes 0..1024.
654 pub fn writes(&self) -> impl Iterator<Item = (u64, &[u8])> {
655 std::iter::once((self.base.length, self.append.as_slice()))
656 .chain(self.patches.iter().map(|(offset, bytes)| (*offset, bytes.as_slice())))
657 .chain(std::iter::once((0, self.header.as_slice())))
658 }
659
647 /// Writes this transaction into its base image, as a successful commit leaves the file.660 /// Writes this transaction into its base image, as a successful commit leaves the file.
648 pub fn apply(&self, image: &mut Vec<u8>) -> Result<(), crate::Error> {661 pub fn apply(&self, image: &mut Vec<u8>) -> Result<(), crate::Error> {
649 if Stamp::of(image)? != self.base {662 if Stamp::of(image)? != self.base {
crates/onestore/src/op/apply.rs+4-3
...@@ -15,7 +15,7 @@ use crate::{...@@ -15,7 +15,7 @@ use crate::{
15 active::{ActivePage, Changes},15 active::{ActivePage, Changes},
16 document::Kind,16 document::Kind,
17 page::{17 page::{
18 Attachment, Image, Ink, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,18 Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent,
19 TableCell, TableColumn,19 TableCell, TableColumn,
20 },20 },
21};21};
...@@ -57,12 +57,13 @@ impl<'a> Section<'a> {...@@ -57,12 +57,13 @@ impl<'a> Section<'a> {
5757
58 /// Lowers `after` against the page `space` holds and applies the result as one edit;58 /// Lowers `after` against the page `space` holds and applies the result as one edit;
59 /// returns the ops, which reach `after` where the writers can store it.59 /// returns the ops, which reach `after` where the writers can store it.
60 pub fn apply_page(60 #[cfg(test)]
61 pub(crate) fn apply_page(
61 &mut self,62 &mut self,
62 author: &str,63 author: &str,
63 at: u64,64 at: u64,
64 space: ExGuid,65 space: ExGuid,
65 after: &Page,66 after: &crate::page::Page,
66 ) -> Result<Vec<PageOp>, OpError> {67 ) -> Result<Vec<PageOp>, OpError> {
67 let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?;68 let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?;
68 let ops = super::lower_page(&before, after)69 let ops = super::lower_page(&before, after)
crates/onestore/src/op/lower.rs+2-1
...@@ -20,7 +20,8 @@ fn invalid(message: &'static str) -> Error {...@@ -20,7 +20,8 @@ fn invalid(message: &'static str) -> Error {
20 Error { offset: 0, message }20 Error { offset: 0, message }
21}21}
2222
23/// The ops that turn `before`, a page as stored, into `after`.23/// The ops that turn `before`, a page as stored, into `after`: O(page), for importing a
24/// page, converting queued whole-page edits and resolving a conflict, never per keystroke.
24pub fn lower_page(before: &Page, after: &Page) -> Result<Vec<PageOp>, Error> {25pub fn lower_page(before: &Page, after: &Page) -> Result<Vec<PageOp>, Error> {
25 let old = View::new(before, false)?;26 let old = View::new(before, false)?;
26 let new = View::new(after, false)?;27 let new = View::new(after, false)?;
crates/onestore/src/op/mod.rs+5
...@@ -28,6 +28,7 @@ pub(crate) type Values = Vec<(u32, Vec<u8>)>;...@@ -28,6 +28,7 @@ pub(crate) type Values = Vec<(u32, Vec<u8>)>;
2828
29/// One user action, applied whole or not at all.29/// One user action, applied whole or not at all.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
31pub struct Edit {32pub struct Edit {
32 /// FILETIME when the action happened; the modification time of what it changes.33 /// FILETIME when the action happened; the modification time of what it changes.
33 pub at: u64,34 pub at: u64,
...@@ -35,6 +36,7 @@ pub struct Edit {...@@ -35,6 +36,7 @@ pub struct Edit {
35}36}
3637
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]38#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
38pub enum Op {40pub enum Op {
39 Page { space: ExGuid, op: PageOp },41 Page { space: ExGuid, op: PageOp },
40 Section(SectionOp),42 Section(SectionOp),
...@@ -43,6 +45,7 @@ pub enum Op {...@@ -43,6 +45,7 @@ pub enum Op {
43/// A change to the page an object space holds. Targets are stored identities; paragraph45/// A change to the page an object space holds. Targets are stored identities; paragraph
44/// properties name the paragraph, text edits its text object.46/// properties name the paragraph, text edits its text object.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
46pub enum PageOp {49pub enum PageOp {
47 /// Replaces a range; inserted text takes the format of the run it lands in, the50 /// Replaces a range; inserted text takes the format of the run it lands in, the
48 /// following run at a boundary except at the end.51 /// following run at a boundary except at the end.
...@@ -159,6 +162,7 @@ pub enum PageOp {...@@ -159,6 +162,7 @@ pub enum PageOp {
159}162}
160163
161#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
162pub enum TableEdit {166pub enum TableEdit {
163 /// New rows before a row, or last, each with one cell per column.167 /// New rows before a row, or last, each with one cell per column.
164 Rows {168 Rows {
...@@ -185,6 +189,7 @@ pub enum TableEdit {...@@ -185,6 +189,7 @@ pub enum TableEdit {
185}189}
186190
187#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
188pub enum SectionOp {193pub enum SectionOp {
189 Create(PageCreation),194 Create(PageCreation),
190 /// A page created with `creation` holding `page`'s content under the identities it195 /// A page created with `creation` holding `page`'s content under the identities it
crates/onestore/src/section.rs+8-1
...@@ -202,6 +202,13 @@ impl<'a> Section<'a> {...@@ -202,6 +202,13 @@ impl<'a> Section<'a> {
202 image202 image
203 }203 }
204204
205 /// Each object space with the active revision the sealed image stores for it.
206 pub fn revisions(&self) -> impl Iterator<Item = (ExGuid, ExGuid)> + '_ {
207 self.spaces
208 .iter()
209 .filter_map(|(space, stored)| Some((*space, stored.rid?)))
210 }
211
205 /// The page an object space holds, with the changes applied since the last seal.212 /// The page an object space holds, with the changes applied since the last seal.
206 pub fn page(&self, space: ExGuid) -> Result<Page> {213 pub fn page(&self, space: ExGuid) -> Result<Page> {
207 let stored = self.spaces.get(&space).ok_or(Error {214 let stored = self.spaces.get(&space).ok_or(Error {
...@@ -429,7 +436,7 @@ impl<'a> Section<'a> {...@@ -429,7 +436,7 @@ impl<'a> Section<'a> {
429 }436 }
430437
431 /// The root object space, which lists the pages.438 /// The root object space, which lists the pages.
432 pub(crate) fn root(&self) -> ExGuid {439 pub fn root(&self) -> ExGuid {
433 self.root440 self.root
434 }441 }
435442
crates/onestore/src/store.rs+1-1
...@@ -86,7 +86,7 @@ pub struct Header {...@@ -86,7 +86,7 @@ pub struct Header {
86}86}
8787
88impl Header {88impl Header {
89 pub(crate) fn parse(data: &[u8]) -> Result<Self> {89 pub fn parse(data: &[u8]) -> Result<Self> {
90 let mut c = Cursor {90 let mut c = Cursor {
91 bytes: data,91 bytes: data,
92 offset: 0,92 offset: 0,
tools/cache_images.py+34-14
...@@ -1,17 +1,37 @@...@@ -1,17 +1,37 @@
1"""The cache's working image, stored as the byte ranges where it differs from the base."""1"""Reading a notebook cache (schema 15) from outside: its section images, stored as 16 KiB
2chunk rows, and the state of its write-ahead log."""
2import struct3import struct
34
45
5def working(connection):6def image(connection, table='base'):
6 base, patch = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()7 """The `base` image the queued edits apply to, or the observed `remote` image (None
7 if not patch: return base8 unless a conflict or uncertain attempt holds one)."""
8 length, = struct.unpack_from('<Q', patch)9 chunks = connection.execute(f'SELECT chunk, bytes FROM {table} ORDER BY chunk').fetchall()
9 image = bytearray(base[:length].ljust(length, b'\0'))10 assert [chunk for chunk, _ in chunks] == list(range(len(chunks))), 'Damaged cached image'
10 at = 811 return b''.join(bytes(data) for _, data in chunks) or None
11 while at < len(patch):12
12 offset, size = struct.unpack_from('<QQ', patch, at)13
13 at += 1614def wal_commits(path):
14 assert at + size <= len(patch) and offset + size <= length, 'Damaged working image'15 """Commits in `<cache>-wal` since its last reset: frames whose header records the
15 image[offset:offset + size] = patch[at:at + size]16 database size after them, among the frames carrying the log's current salts. Frames
16 at += size17 after the last commit belong to a transaction that has not committed."""
17 return bytes(image)18 try:
19 data = path.with_name(path.name + '-wal').read_bytes()
20 except FileNotFoundError:
21 return 0, 0
22 if len(data) < 32:
23 return 0, 0
24 magic, _, page_size, _, salt1, salt2 = struct.unpack_from('>IIIIII', data)
25 assert magic in (0x377f0682, 0x377f0683), 'Not a SQLite write-ahead log'
26 commits = pending = 0
27 at = 32
28 while at + 24 + page_size <= len(data):
29 _, committed, frame1, frame2 = struct.unpack_from('>IIII', data, at)
30 if (frame1, frame2) != (salt1, salt2):
31 break
32 if committed:
33 commits, pending = commits + 1, 0
34 else:
35 pending += 1
36 at += 24 + page_size
37 return commits, pending
tools/offline_cache_crash.py+16-23
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""Interrupt owned offline-cache writers and verify every retained intent and image."""2"""Interrupt owned offline-cache writers and verify every retained intent and image."""
3import argparse3import argparse
4import cache_images
4import hashlib5import hashlib
5import json6import json
6import os7import os
...@@ -31,7 +32,7 @@ def run(binary, output):...@@ -31,7 +32,7 @@ def run(binary, output):
31 retained = []32 retained = []
32 retained_ids = []33 retained_ids = []
33 results = []34 results = []
34 delays = [None, "ack", "unack", "journal", "database-write", 0, .001, .01, .02, .04, .08, .16, .32, .64, 1.28, 2.56, None, "ack"]35 delays = [None, "ack", "unack", "wal", 0, .001, .01, .02, .04, .08, .16, .32, .64, 1.28, 2.56, None, "ack"]
35 for operation, delay in enumerate(delays, 1):36 for operation, delay in enumerate(delays, 1):
36 with (output / f"owner-{operation}.stderr").open("w") as stderr:37 with (output / f"owner-{operation}.stderr").open("w") as stderr:
37 owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE,38 owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE,
...@@ -48,34 +49,28 @@ def run(binary, output):...@@ -48,34 +49,28 @@ def run(binary, output):
48 contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15)49 contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15)
49 (output / f"contender-{operation}.stderr").write_text(contender.stderr)50 (output / f"contender-{operation}.stderr").write_text(contender.stderr)
50 assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender51 assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender
51 before_write = cache.stat().st_mtime_ns
52 owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n")52 owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n")
53 owner.stdin.flush()53 owner.stdin.flush()
54 expect(f"editing {operation}")54 expect(f"editing {operation}")
55 acknowledgement = None55 acknowledgement = None
56 journal_observation = None56 log_observation = None
57 if delay == "ack":57 if delay == "ack":
58 acknowledgement = expect(f"ack {operation}")58 acknowledgement = expect(f"ack {operation}")
59 elif delay == "unack":59 elif delay == "unack":
60 expect(f"durable {operation}")60 expect(f"durable {operation}")
61 elif delay in ("journal", "database-write"):61 elif delay == "wal":
62 # Cut the edit's transaction once its frames are in the log and its
63 # commit frame is not.
62 deadline = time.monotonic() + 1064 deadline = time.monotonic() + 10
63 journal = cache.with_name(cache.name + "-journal")65 committed, _ = cache_images.wal_commits(cache)
64 while time.monotonic() < deadline:66 while time.monotonic() < deadline:
65 try:67 commits, pending = cache_images.wal_commits(cache)
66 with journal.open("rb") as active:68 if commits == committed and pending:
67 header = active.read(28)69 log_observation = {"wal_commits": commits, "uncommitted_frames": pending}
68 size = journal.stat().st_size70 break
69 database_changed = cache.stat().st_mtime_ns != before_write71 if commits != committed:
70 if (header[:8] == bytes.fromhex("d9d505f920a163d7") and72 break
71 (delay == "journal" or database_changed)):73 assert log_observation, "No uncommitted log frames observed"
72 journal_observation = {"header": header.hex(), "bytes": size,
73 "database_changed": database_changed}
74 break
75 except FileNotFoundError:
76 pass
77 time.sleep(.0001)
78 assert journal_observation, f"No active {delay} phase observed"
79 elif delay is not None:74 elif delay is not None:
80 time.sleep(delay)75 time.sleep(delay)
81 owner.kill()76 owner.kill()
...@@ -88,9 +83,7 @@ def run(binary, output):...@@ -88,9 +83,7 @@ def run(binary, output):
88 actual = json.loads(read.stdout)83 actual = json.loads(read.stdout)
89 operations = actual["operations"]84 operations = actual["operations"]
90 ids = actual["ids"]85 ids = actual["ids"]
91 # A save replaces the newest pending save of its page under the same intent.86 assert operations in (retained, retained + [operation]), (retained, operation, actual)
92 coalesced = retained[:-1] + [operation] if ids == retained_ids else None
93 assert operations in (retained, retained + [operation], coalesced), (retained, operation, actual)
94 assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual)87 assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual)
95 if acknowledgement:88 if acknowledgement:
96 assert operations[-1] == operation89 assert operations[-1] == operation
...@@ -105,7 +98,7 @@ def run(binary, output):...@@ -105,7 +98,7 @@ def run(binary, output):
105 "retained_operations": operations, "ids": ids,98 "retained_operations": operations, "ids": ids,
106 "section_bytes": actual["section_bytes"],99 "section_bytes": actual["section_bytes"],
107 "complete_payloads": True, "exclusive_owner": True,100 "complete_payloads": True, "exclusive_owner": True,
108 "journal_observation": journal_observation})101 "log_observation": log_observation})
109 retained, retained_ids = operations, ids102 retained, retained_ids = operations, ids
110 (output / "results.json").write_text(json.dumps(results, indent=2))103 (output / "results.json").write_text(json.dumps(results, indent=2))
111 print(json.dumps(results[-1]), flush=True)104 print(json.dumps(results[-1]), flush=True)
tools/offline_publication_crash.py+17-19
...@@ -31,11 +31,6 @@ def run_command(binary, args, output):...@@ -31,11 +31,6 @@ def run_command(binary, args, output):
31def kill_at(binary, args, phase, output, receipt_window=None):31def kill_at(binary, args, phase, output, receipt_window=None):
32 events = queue.Queue()32 events = queue.Queue()
33 database = Path(args[1]) / 'cache.sqlite'33 database = Path(args[1]) / 'cache.sqlite'
34 journal = database.with_name(database.name + '-journal')
35 def header():
36 try:
37 with journal.open('rb') as stream: return stream.read(8)
38 except FileNotFoundError: return b''
39 with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error:34 with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error:
40 process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True,35 process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True,
41 env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase})36 env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase})
...@@ -57,24 +52,26 @@ def kill_at(binary, args, phase, output, receipt_window=None):...@@ -57,24 +52,26 @@ def kill_at(binary, args, phase, output, receipt_window=None):
57 if event.get('event') == 'phase' and event['name'] == phase:52 if event.get('event') == 'phase' and event['name'] == phase:
58 if receipt_window is not None:53 if receipt_window is not None:
59 assert args[0] == 'sync' and phase == 'publish-after'54 assert args[0] == 'sync' and phase == 'publish-after'
60 assert receipt_window in ('journal', 'database')55 assert receipt_window == 'wal'
61 before_mtime = database.stat().st_mtime_ns56 # The receipt transaction is cut once its frames are in the log and
57 # its commit frame is not.
58 committed, _ = cache_images.wal_commits(database)
62 process.stdin.write('\n')59 process.stdin.write('\n')
63 process.stdin.flush()60 process.stdin.flush()
64 deadline = time.monotonic() + 561 deadline = time.monotonic() + 5
65 while True:62 while True:
66 if header() == bytes.fromhex('d9d505f920a163d7') and (receipt_window == 'journal' or database.stat().st_mtime_ns != before_mtime): break63 commits, pending = cache_images.wal_commits(database)
67 assert process.poll() is None, 'Receipt transaction finished before the requested cut'64 if commits == committed and pending: break
65 assert commits == committed and process.poll() is None, 'Receipt transaction finished before the requested cut'
68 assert time.monotonic() < deadline, 'Receipt transaction window was not observed'66 assert time.monotonic() < deadline, 'Receipt transaction window was not observed'
69 time.sleep(.0001)
70 process.kill()67 process.kill()
71 break68 break
72 assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill'69 assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill'
73 proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase}70 proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase}
74 if receipt_window is not None:71 if receipt_window is not None:
75 proof.update(journal_header_after_kill=header().hex(), database_changed=database.stat().st_mtime_ns != before_mtime, receipt_window=receipt_window)72 commits, pending = cache_images.wal_commits(database)
76 assert proof['journal_header_after_kill'] == 'd9d505f920a163d7', 'Receipt transaction committed before the process died'73 proof.update(wal_commits_before=committed, wal_commits_after_kill=commits, uncommitted_frames=pending, receipt_window=receipt_window)
77 assert receipt_window != 'database' or proof['database_changed']74 assert commits == committed, 'Receipt transaction committed before the process died'
78 output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2))75 output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2))
79 finally:76 finally:
80 if process.poll() is None: process.kill()77 if process.poll() is None: process.kill()
...@@ -103,7 +100,7 @@ def state(rows, original):...@@ -103,7 +100,7 @@ def state(rows, original):
103 else:100 else:
104 assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated'101 assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated'
105 pending, = result['pending']102 pending, = result['pending']
106 assert pending['id'] == 1 and pending['before'] == original and pending['replacement'] == TOKEN103 assert pending['id'] == 1 and pending['replacement'] == TOKEN
107 at = len(original.encode('utf-16-le')) // 2104 at = len(original.encode('utf-16-le')) // 2
108 assert pending['range'] == [at, at], 'Durable intent range changed'105 assert pending['range'] == [at, at], 'Durable intent range changed'
109 return result106 return result
...@@ -187,11 +184,12 @@ def run(source, output):...@@ -187,11 +184,12 @@ def run(source, output):
187 try:184 try:
188 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]185 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
189 assert connection.execute('PRAGMA foreign_key_check').fetchall() == []186 assert connection.execute('PRAGMA foreign_key_check').fetchall() == []
190 local_image = save_image(output, cache_images.working(connection))187 # The image the queue applies to; the local text is the probe's, checked above.
188 base_image = save_image(output, cache_images.image(connection))
191 finally:189 finally:
192 connection.close()190 connection.close()
193 result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'],191 result = {'case': index, 'phase': phase, 'during_confirmation': confirmation, 'before_status': before['status'], 'after_status': after['status'],
194 'visible_before_recovery': before['remote_text'] != original, 'remote_image': remote_image, 'local_image': local_image,192 'visible_before_recovery': before['remote_text'] != original, 'remote_image': remote_image, 'base_image': base_image,
195 'remote_text': after['remote_text'], 'local_text': after['local_text']}193 'remote_text': after['remote_text'], 'local_text': after['local_text']}
196 results.append(result)194 results.append(result)
197 (output / 'results.json').write_text(json.dumps(results, indent=2))195 (output / 'results.json').write_text(json.dumps(results, indent=2))
...@@ -206,14 +204,14 @@ def run(source, output):...@@ -206,14 +204,14 @@ def run(source, output):
206def receipt_windows(source, output):204def receipt_windows(source, output):
207 binary, source_hash = prepare_run(source, output)205 binary, source_hash = prepare_run(source, output)
208 results = []206 results = []
209 for window in ('journal', 'database'):207 for window in ('wal',):
210 folder = output / window208 folder = output / window
211 initialized = run_command(binary, ['init', folder, source], output / (window+'-init'))209 initialized = run_command(binary, ['init', folder, source], output / (window+'-init'))
212 original = next(row['remote_text'] for row in initialized if row['event'] == 'state')210 original = next(row['remote_text'] for row in initialized if row['event'] == 'state')
213 state(initialized, original)211 state(initialized, original)
214 kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window)212 kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window)
215 before = state(run_command(binary, ['inspect', folder], output / (window+'-inspect')), original)213 before = state(run_command(binary, ['inspect', folder], output / (window+'-inspect')), original)
216 assert before['status'] == 'uncertain' and before['remote_text'] == original+TOKEN, 'Hot-journal recovery lost the pending confirmation'214 assert before['status'] == 'uncertain' and before['remote_text'] == original+TOKEN, 'Log recovery lost the pending confirmation'
217 before_bytes = (folder / 'remote.one').read_bytes()215 before_bytes = (folder / 'remote.one').read_bytes()
218 recovered = run_command(binary, ['sync', folder], output / (window+'-recover'))216 recovered = run_command(binary, ['sync', folder], output / (window+'-recover'))
219 after = state(recovered, original)217 after = state(recovered, original)
...@@ -226,7 +224,7 @@ def receipt_windows(source, output):...@@ -226,7 +224,7 @@ def receipt_windows(source, output):
226 print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True)224 print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True)
227 assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash225 assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash
228 (output/'results.json').write_text(json.dumps(results, indent=2))226 (output/'results.json').write_text(json.dumps(results, indent=2))
229 (output/'summary.json').write_text(json.dumps({'process_kills':2, 'recovered_receipts':2, 'republished_edits':0}, indent=2))227 (output/'summary.json').write_text(json.dumps({'process_kills':len(results), 'recovered_receipts':len(results), 'republished_edits':0}, indent=2))
230228
231229
232if __name__ == '__main__':230if __name__ == '__main__':
tools/test_offline_history.py+5-4
...@@ -139,9 +139,10 @@ class OfflineLedgerTests(unittest.TestCase):...@@ -139,9 +139,10 @@ class OfflineLedgerTests(unittest.TestCase):
139 events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2))139 events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2))
140 events.append({'event': 'done'})140 events.append({'event': 'done'})
141 connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite')141 connection = sqlite3.connect(output / 'rust' / f'{actor}.sqlite')
142 connection.executescript('CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE attempt(id INTEGER); CREATE TABLE conflicts(id INTEGER); CREATE TABLE replica(id INTEGER, base BLOB, working BLOB);')142 # Schema 15 in WAL mode, as a drained cache leaves it.
143 connection.executescript('PRAGMA journal_mode=WAL; CREATE TABLE receipts(edit_id INTEGER, revision TEXT); CREATE TABLE edits(id INTEGER); CREATE TABLE batches(id INTEGER); CREATE TABLE payloads(sha256 BLOB); CREATE TABLE base(chunk INTEGER, bytes BLOB); CREATE TABLE remote(chunk INTEGER, bytes BLOB);')
143 connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)])144 connection.executemany('INSERT INTO receipts VALUES (?,?)', [(op+1, f'{actor}-{op}') for op in range(2)])
144 connection.execute('INSERT INTO replica VALUES (1, ?, ?)', (b'opaque image', b''))145 connection.execute('INSERT INTO base VALUES (0, ?)', (b'opaque image',))
145 connection.commit()146 connection.commit()
146 connection.close()147 connection.close()
147 for i in range(4):148 for i in range(4):
...@@ -165,8 +166,8 @@ class OfflineLedgerTests(unittest.TestCase):...@@ -165,8 +166,8 @@ class OfflineLedgerTests(unittest.TestCase):
165 os.utime(output / 'rust/stop', ns=(0, 0))166 os.utime(output / 'rust/stop', ns=(0, 0))
166 connection = sqlite3.connect(output / 'rust/w0.sqlite')167 connection = sqlite3.connect(output / 'rust/w0.sqlite')
167 for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"),168 for sql, undo in [("UPDATE receipts SET revision='wrong' WHERE edit_id=1", "UPDATE receipts SET revision='w0-0' WHERE edit_id=1"),
168 ('INSERT INTO attempt VALUES (1)', 'DELETE FROM attempt'),169 ('INSERT INTO batches VALUES (1)', 'DELETE FROM batches'),
169 ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=X''")]:170 ("INSERT INTO remote VALUES (0, X'00')", 'DELETE FROM remote')]:
170 connection.execute(sql)171 connection.execute(sql)
171 connection.commit()172 connection.commit()
172 with self.assertRaises(AssertionError): verify(output)173 with self.assertRaises(AssertionError): verify(output)
tools/test_offline_publication_crash.py+2-2
...@@ -28,7 +28,7 @@ class RecoveryOracleTests(unittest.TestCase):...@@ -28,7 +28,7 @@ class RecoveryOracleTests(unittest.TestCase):
28 at = len(original.encode('utf-16-le')) // 228 at = len(original.encode('utf-16-le')) // 2
29 row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old',29 row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old',
30 'local_text': original+TOKEN, 'remote_text': original,30 'local_text': original+TOKEN, 'remote_text': original,
31 'pending': [{'id': 1, 'before': original, 'replacement': TOKEN, 'range': [at, at]}]}31 'pending': [{'id': 1, 'replacement': TOKEN, 'range': [at, at]}]}
32 self.assertEqual(state([row], original), row)32 self.assertEqual(state([row], original), row)
33 for field, value in [('revision', None), ('revision', 'old'), ('local_text', original),33 for field, value in [('revision', None), ('revision', 'old'), ('local_text', original),
34 ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]:34 ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]:
...@@ -36,7 +36,7 @@ class RecoveryOracleTests(unittest.TestCase):...@@ -36,7 +36,7 @@ class RecoveryOracleTests(unittest.TestCase):
36 changed[field] = value36 changed[field] = value
37 with self.subTest(field=field):37 with self.subTest(field=field):
38 with self.assertRaises(AssertionError): state([changed], original)38 with self.assertRaises(AssertionError): state([changed], original)
39 for field, value in [('id', 2), ('before', 'other'), ('replacement', 'other'), ('range', [at-1, at-1])]:39 for field, value in [('id', 2), ('replacement', 'other'), ('range', [at-1, at-1])]:
40 changed = copy.deepcopy(row)40 changed = copy.deepcopy(row)
41 changed['pending'][0][field] = value41 changed['pending'][0][field] = value
42 with self.assertRaises(AssertionError): state([changed], original)42 with self.assertRaises(AssertionError): state([changed], original)
tools/verify_offline.py+3-3
...@@ -4,6 +4,7 @@ import argparse...@@ -4,6 +4,7 @@ import argparse
4import hashlib4import hashlib
5import json5import json
6from pathlib import Path6from pathlib import Path
7import cache_images
7import sqlite38import sqlite3
89
9from native_stress import edit_history, native_history10from native_stress import edit_history, native_history
...@@ -41,12 +42,11 @@ def verify(output, max_gap=120):...@@ -41,12 +42,11 @@ def verify(output, max_gap=120):
41 try:42 try:
42 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed'43 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed'
43 assert connection.execute('PRAGMA foreign_key_check').fetchall() == [], 'Cache foreign keys failed'44 assert connection.execute('PRAGMA foreign_key_check').fetchall() == [], 'Cache foreign keys failed'
44 for table in ('edits', 'attempt', 'conflicts'):45 for table in ('edits', 'batches', 'payloads', 'remote'):
45 assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state'46 assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state'
46 persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts'))47 persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts'))
47 assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements'48 assert persisted == {id: event['revision'] for id, event in receipts.items()}, 'SQLite receipts differ from observed acknowledgements'
48 base, working = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()49 base = cache_images.image(connection)
49 assert working == b'', 'A drained cache retained a divergent local branch'
50 caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}50 caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
51 finally:51 finally:
52 connection.close()52 connection.close()