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
2121zeroize = { version = "1.9.0", optional = true }
2222
2323[dev-dependencies]
24libc = "0.2"
2425serde_json = "1"
2526tempfile = "3"
2627
crates/notebook/README.md+91-194
......@@ -2,184 +2,97 @@
22
33The application-facing crate: notebook discovery, a durable local replica with
44reconnect reconciliation, external-asset caching, recovery export and optional
5embedded SMB access. The replica stores the last observed remote image of one section,
6the working image as its difference from that, and the editor's intents in a
7local SQLite database; a save writes the revision it appended, not the section. `sync_once` provides a
8reconciliation step and `start_sync` owns automatic polling and reconnects. Local
9success does not acknowledge publication to a shared notebook.
10
11## Saving pages
12
13The editor works on `onestore::page::Page` values and saves whole pages. `save`
14diffs the supplied model against the page stored in the supplied local snapshot,
15writes the difference into the working image and queues one
16`Operation::Page(PageIntent { before, after, author })`; `before` is the page the
17edit started from and is the precondition reconciliation checks. Text, styles,
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,
19outline layout, insertions and deletions are all differences between `before` and
20`after`; the library never sees editor operations.
5embedded SMB access. A replica keeps one section's queue in a local SQLite database:
6the image the queued edits apply to (the base, in 16 KiB chunks), the edits as
7`onestore::op` ops in publication batches, and picture and attachment bytes once
8each by hash. Local success does not acknowledge publication to a shared notebook.
9
10```text
11editor ── Edit ──► section thread ─ Section::apply, INSERT edit ─┐
12 (the parsed section: base + sealed + open) │ one fsync per burst
13sync thread: remote.stamp() == base ? publish(sealed batch) ──────┤ base chunks += transaction
14 otherwise read once, replay the queue on it ─────────┘ conflict, or base := remote
15```
16
17## Edits
18
19`Replica::apply(author, edit)` applies an `onestore::op::Edit` to the section the
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
2225```no_run
23use onestore::{ExGuid, page::Page};
26use onestore::{ExGuid, op::{Edit, Op, PageOp}};
2427use 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)
2629# -> Result<(), Box<dyn std::error::Error>> {
27// `space` identifies the page; `edited` is the editor's current model of it.
2830let cache = Replica::create(path, source)?;
29let snapshot = cache.snapshot()?;
30let local_id = cache.save(&snapshot, space, edited, "Author")?;
31let typed = PageOp::Text { text, range: 0..0, with: "Hello ".into() };
32let id = cache.apply("Author", Edit { at: 133_000_000_000_000_000, ops: vec![Op::Page { space, op: typed }] })?;
3133drop(cache);
3234
3335let reopened = Replica::open(path)?;
34let pending = reopened.pending()?;
35assert_eq!(pending.last().map(|edit| edit.id), local_id);
36assert_eq!(reopened.pending()?.last().map(|edit| edit.id), Some(id));
3637# Ok(())
3738# }
3839```
3940
40Saves coalesce the way OneNote's own autosave does: while the newest queued intent
41for the same page has not been attempted, a later save replaces its `after` model
42under the same ID. Once an intent has been attempted or holds a conflict it is
43never rewritten, nor is the intent a synchronization step has selected for
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.
41Share one `Replica` between threads. Keep the cache on a local filesystem: the
42connection holds exclusive ownership between transactions, and a second open fails
43busy. No network wait occurs in a local edit. After a database error the section
44thread rereads the queue.
5545
5646## Sessions
5747
5848`session::Notebook::open(root, cache_dir)` discovers a notebook directory and
5949`session::Section::open(file, cache_dir, notify)` opens one section file through
60a replica stored under the cache directory, named by the section's document
61identity so the same file reopens the same queue after a relaunch. A section
62publishes in the background to the file itself under OneNote-compatible
63exclusion. `pages()` lists page spaces and titles from the local image,
64`page(space)` returns the model to edit (its `identity` and the section's
65`identity()` feed `onestore::page::link::internal_link`), and `save(space, before, after,
66author)` queues the edited model: `Save::Queued(id)` is durable locally,
67`Save::Unchanged` means the model equals the stored page, and `Save::Stale`
68means the stored page no longer matches `before` because the section changed
69underneath the editor, so the page must be reloaded before saving again.
70`queue_save(space, before, after, author)` does the same on the section's save
71thread and returns at once, so an editor's frame never waits on the write;
72`saved()` drains the outcomes. A queued save whose `before` is the previous
73one's `after` continues it, needing no reload in between, and a run of such
74saves waiting together is written once.
75`events()` drains what the synchronization thread reported since the last
76poll: refreshes, attempt outcomes and unreachable files; `notify` runs
77whenever an event or save outcome is available so the application can wake its
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`.
50a replica named by the section's document identity, so the same file reopens the
51same queue after a relaunch. A section is `Send + Sync`: `apply(author, edit)`
52returns at once and reports a refusal as `Event::Rejected`; `events()` drains
53remote changes (`Changed(spaces)`), publication outcomes, unreachable files and
54failures; `notify` runs on a background thread whenever an event waits.
55`conflict()`, `remote_page(space)` and `resolve(id, Resolution)` present and end a
56conflict; `release(id, archive, Resolution)` ends an uncertain attempt.
57`import_page` creates a page holding a copy of another; `delete_pages` removes pages.
58
59Until the application emits ops, `save`, `queue_save`, `saved`, `saving`,
60`conflicts`, `queue`, `review` and `Event::Refreshed` keep the whole-page API: a
61save lowers the page it is given against the stored page (`onestore::op::lower_page`)
62and applies the ops.
63
64`Section::resume(file, replica, notify)` starts from an owned `Replica` without
65consulting the remote file; with the `smb` feature, `Section::resume_smb(path,
66replica, limit, connect, notify)` binds a share-relative path, `connect` running on
67the worker again after transport failure.
13068
13169## Reconciliation
13270
133`sync_once(&mut remote)` processes the oldest pending intent through a `Remote`
134implementation, returning its ID and `EditStatus`. When the remote page still
135equals `before`, the intent publishes as prepared. When the remote page changed,
136a three-way merge keeps everything the remote changed and re-applies the local
137changes wherever the two sides touched different objects, fields or text ranges:
138a local text edit merges with a remote edit elsewhere in the same paragraph, an
139outline move merges with a remote text change, a new paragraph survives a remote
140deletion elsewhere. Any overlap retains `Conflict(ContentChanged)` together with
141the complete local image and the last observed remote image returned by
142`remote_snapshot`; a page removed remotely retains `TargetUnavailable`, and a
143page the writer can no longer express retains `UnsupportedEdit`.
144
145Publication attempts are recorded before network I/O. A retained attempt
146requires its recorded revision to remain present in the remote, or the remote
147page to equal the intent's `after` model exactly, followed by comparison,
148flushing and refreshed header version metadata before acknowledgement. Otherwise
149the attempt remains `AwaitingConfirmation`; an uncertain publication is never
150replayed. A durable `Published` receipt survives reopening. Transport errors
151return `Error::Remote` or `Error::RemoteIo`; inspect `status(id)` after the error
152to distinguish a retained attempt from a pending edit or receipt. The error return
153does not roll back a locally acknowledged intent.
154
155The current operation processes one queue head; while edits remain, `snapshot`
156preserves the complete local working image. An empty queue can refresh from the
157remote image. Synchronization holds a separate owner lock, so local saves can
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.
71`sync_once(&mut remote)` returns what one step did as `Synced { edit, changed }`:
72the state the step's batch reached, named by its newest edit, and the pages a remote
73change replaced. While the remote's stamp (header and length) equals the base's, the
74oldest sealed batch publishes as its `Transaction`, and the base's chunks take its
75writes; nothing is read. When the stamp moved, the remote image is read once and the
76queue replays on it (`merge.rs`): an op whose objects the remote left alone applies
77as it is; a text op shifts past the remote's changes to the same text when its
78range stays clear of them; a move, deletion or property the remote already made is
79done; a page the remote already holds as the local edits leave it drops their ops.
80Anything else is a `Conflict` on the batch: the queue stays on its base and the
81remote image is kept for review. `resolve(id, Mine)` rewrites the remote page to the
82local one (`lower_page`, once), `Theirs` drops the local ops on that page; either
83applies from the conflicted batch on.
84
85Publication attempts are recorded before network I/O. An attempt confirms only
86when the remote holds its revisions, or every page it changed as it changed them,
87followed by `Remote::confirm`; otherwise it stays `AwaitingConfirmation` and is never
88replayed. `release(id, archive, Resolution)` exports the queue first, then
89publishes the batch again (`Mine`) or abandons every unpublished edit (`Theirs`).
90A durable `Published` receipt survives reopening.
91
92A schema-14 cache is converted when opened: it is exported to
93`<cache>.v14-recovery`, each queued page is lowered against the page the conversion
94has so far, and every converted page must equal the page in the old working image,
95or the conversion rolls back and the open fails naming the archive.
18396
18497With the optional `smb` feature, `SmbRemote::new(client, path, limit)` binds an
18598`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
188101An `Arc<Replica>` can own one background worker. Supply a connection factory, poll
189102interval and observer; successful publications drain immediately, durable local
190103edits wake the worker, and `wake()` requests an immediate reachability retry.
191While nothing is queued, a remote whose `Remote::stamp` (its header and length, read
192without coordination) equals the last observed image's is not read again; publishing
193against an unchanged stamp needs no read either.
104While nothing is queued, or the queue waits on a remote that has not changed since,
105a remote whose `Remote::stamp` holds is not read again.
194106Transport failures discard the old connection and retry through the factory;
195107Read contention and `NotCommitted` operations with `WouldBlock` or `ResourceBusy`
196108reuse the connection. Contended `NotCommitted` operations use randomized backoff,
197109capped at one second, to separate competing retry cycles. Cancellation interrupts this delay; local wake notifications
198110remain coalesced until its end.
199111`RemoteIo` distinguishes connection/read failures from
200local `Io` errors. Cache/document errors stop the worker. Observers receive every
201attempt's result on the worker thread, including unchanged conflict/uncertain
202statuses; durable edit state remains available through `status`.
112local `Io` errors. Cache/document errors stop the worker.
203113
204114```no_run
205115# #[cfg(feature = "smb")]
......@@ -229,35 +139,25 @@ worker.stop()?;
229139```
230140
231141`stop()` cancels future steps and joins the worker, returning a fatal cache error
232or worker panic. Dropping it requests cancellation without waiting. An in-flight
233step completes before releasing ownership; a replacement worker cannot start
234while the old one still owns the replica. Remote operations and callbacks must
235have bounded execution times if shutdown latency matters. A dropped worker can
236briefly retain the cache; use `stop()` before requiring an immediate reopen.
237Cancellation and application restart retain pending edits and publication attempts.
238Credentials belong to the factory, not the cache database.
239
240Creation refuses existing paths. Opening recognizes the application identity and
241schema version, validates database integrity and both notebook images, and rejects
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).
142or worker panic. Dropping it requests cancellation without waiting. Remote
143operations and callbacks must have bounded execution times if shutdown latency
144matters. Credentials belong to the factory, not the cache database.
145
146Creation refuses existing paths. Opening validates database integrity and replays
147the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL
148synchronization and fullfsync, each queried back: every commit is durable, and the only
149file beside the cache is `<cache>-wal`, which holds committed pages until a checkpoint
150and must travel with the cache when it is copied. Recovery archives are single files.
151The cache contains notebook content.
250152
251153The SMB-enabled `smb_offline_client` example is an owned-lab workload for
252`tools/native_collaboration.py --offline --embedded-smb`. It separates local
253acknowledgements, remote publication attempts, persisted receipts and cache reopen
254checks. Its append-specific conflict review policy lives in the test client;
255the library continues to preserve conflicts requiring an explicit decision.
154`tools/native_collaboration.py --offline --embedded-smb`; its append-specific
155conflict policy lives in the client.
256156
257157## Recovery archives
258158
259`export_recovery(new_path)` captures both complete notebook images, the intent
260queue, uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
159`export_recovery(new_path)` captures the base and remote images, the edit queue,
160uncertain attempts, conflicts, receipts, downloaded media and the edit-ID sequence in one
261161SQLite snapshot. It refuses existing destinations and leaves the live queue
262162unchanged. Export to a local directory from a background thread: copying holds
263163the cache mutex while capturing the database. Failure after the final rename can
......@@ -448,9 +348,6 @@ physical power-loss durability.
448348## Queue measurement
449349
450350`cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000`
451measures alternating-page saves that retain separate queue IDs, cache reopen,
452recovery export and local-file publication. JSON lines record each acknowledgement
453and publication latency plus image/cache sizes. The workload verifies every ID
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.
351measures alternating-page edits, cache reopen, recovery export and one local-file
352publication of the queue. `keystroke_probe PARAGRAPHS KEYSTROKES` types into a large
353page and reports the bytes the cache and the section file write per keystroke.
crates/notebook/examples/cache_probe.rs+75-108
......@@ -1,8 +1,9 @@
1use notebook::{Operation, Replica};
1use notebook::Replica;
2use onestore::op::{Op, PageOp};
23use onestore::{
34 ExGuid, RevisionIndex, Store,
45 document::{Document, Kind},
5 page::{Page, PageObject, Paragraph, text::Edit},
6 page::{PageObject, Paragraph},
67};
78use std::{
89 io::{self, BufRead, Write},
......@@ -86,34 +87,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
8687 let mut ids = Vec::new();
8788 let mut font_size = None;
8889 for pending in cache.pending()? {
89 let Operation::Page(intent) = &pending.operation else {
90 panic!("Unexpected operation for this fixture")
90 let [Op::Page { op, .. }] = &pending.edit.ops[..] else {
91 panic!("Unexpected edit for this fixture")
9192 };
92 let operation = match operation_kind.as_str() {
93 "text" => {
94 let (object, before, range, replacement) =
95 intent.text_change().expect("one replaced paragraph");
96 assert_eq!(object, target);
97 assert_eq!(before, expected);
98 assert_eq!(range, 0..u32::try_from(expected.encode_utf16().count())?);
99 let operation: u64 = replacement.split_once(':').unwrap().0.parse()?;
93 let operation = match (operation_kind.as_str(), op) {
94 ("text", PageOp::Text { text, range, with }) => {
95 assert_eq!(*text, target);
96 assert_eq!(*range, 0..u32::try_from(expected.encode_utf16().count())?);
97 let operation: u64 = with.split_once(':').unwrap().0.parse()?;
10098 expected = payload(operation, size);
101 assert_eq!(replacement, expected);
99 assert_eq!(*with, expected);
102100 operation
103101 }
104 "insert" => {
105 let added: Vec<_> = intent
106 .after
107 .objects
108 .iter()
109 .filter(|object| {
110 !intent.before.objects.iter().any(|o| o.id() == object.id())
111 })
112 .collect();
113 // 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 };
102 (
103 "insert",
104 PageOp::Add {
105 object: PageObject::Outline(outline),
106 ..
107 },
108 ) => {
117109 let text = outline.paragraphs[0].text().unwrap().text.text();
118110 let operation: u64 = text.split_once(':').unwrap().0.parse()?;
119111 assert_eq!(text, payload(operation, size));
......@@ -124,33 +116,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124116 assert!(revision.nodes.contains_key(&outline.id));
125117 operation
126118 }
127 "format" => {
128 let paragraph = intent
129 .after
130 .objects
131 .iter()
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 );
119 ("format", PageOp::Format { text, set, .. }) => {
120 assert_eq!(*text, target);
121 let [onestore::TextAttribute::FontSize(value)] = set[..] else {
122 panic!("Expected a font size")
123 };
149124 assert!((7.0..=130.0).contains(&value) && value.fract() == 0.0);
150125 font_size = Some(value);
151126 value as u64 - 6
152127 }
153 _ => unreachable!(),
128 other => panic!("Unexpected op {other:?}"),
154129 };
155130 assert!(operations.last().is_none_or(|last| *last < operation));
156131 assert!(ids.last().is_none_or(|last| *last < pending.id));
......@@ -167,10 +142,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
167142 );
168143 }
169144 if operations.is_empty() {
170 assert!(
171 snapshot == base,
172 "An empty local queue changed its working image"
173 );
145 assert!(snapshot == base, "An empty local queue changed its image");
174146 }
175147 if let Some(output) = args.get(3) {
176148 std::fs::write(output, &snapshot)?;
......@@ -197,51 +169,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
197169 let replacement = payload(operation, size);
198170 println!("editing {operation}");
199171 io::stdout().flush()?;
200 let (space, page) = {
201 let store = Store::parse(&source)?;
202 let index = RevisionIndex::parse(&store)?;
203 let document = Document::parse(&index)?;
204 let mut page = Page::from_space(&document, sid)?;
205 fn target_paragraph(page: &mut Page, oid: ExGuid) -> &mut onestore::page::PageParagraph {
206 page.objects
207 .iter_mut()
172 let end = u32::try_from(text.encode_utf16().count())?;
173 let op = match operation_kind.as_str() {
174 "text" => PageOp::Text {
175 text: oid,
176 range: 0..end,
177 with: replacement.clone(),
178 },
179 "insert" => {
180 let page = cache.page(sid)?;
181 let template = page
182 .objects
183 .iter()
208184 .find_map(|object| match object {
209185 PageObject::Outline(outline) => outline
210186 .paragraphs
211 .iter_mut()
187 .iter()
212188 .find(|p| p.text().is_some_and(|t| t.id == oid)),
213189 _ => None,
214190 })
215191 .expect("target paragraph")
216 }
217 let end = u32::try_from(text.encode_utf16().count())?;
218 match operation_kind.as_str() {
219 "text" => {
220 let target = target_paragraph(&mut page, oid).text_mut().unwrap();
221 let format = target.text.format_at(0)?.clone();
222 target.text.apply(Edit {
223 range: 0..end,
224 replacement: Paragraph::new(replacement.clone(), format),
225 })?;
226 }
227 "insert" => {
228 let template = target_paragraph(&mut page, oid).clone();
229 let mut fresh = template.clone();
230 fresh.id = onestore::page::text::new_id()?;
231 fresh.parent = None;
232 fresh.level = 1;
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 {
192 .clone();
193 let mut fresh = template.clone();
194 fresh.id = onestore::page::text::new_id()?;
195 fresh.parent = None;
196 fresh.level = 1;
197 fresh.lists.clear();
198 fresh.tags.clear();
199 fresh.style = None;
200 let format = template.text().unwrap().text.format_at(0)?.clone();
201 fresh.content = onestore::page::ParagraphContent::Text(onestore::page::TextObject {
202 id: onestore::page::text::new_id()?,
203 date_field: None,
204 text: Paragraph::new(replacement.clone(), format),
205 tags: Vec::new(),
206 });
207 PageOp::Add {
208 object: PageObject::Outline(onestore::page::Outline {
245209 id: onestore::page::text::new_id()?,
246210 title: false,
247211 min_width: None,
......@@ -253,29 +217,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
253217 indents: Vec::new(),
254218 paragraphs: vec![fresh],
255219 unsupported: Vec::new(),
256 };
257 let at = page
220 }),
221 before: page
258222 .objects
259223 .iter()
260 .position(|o| matches!(o, PageObject::Title(_)))
261 .unwrap_or(page.objects.len());
262 page.objects.insert(at, PageObject::Outline(outline));
224 .find(|o| matches!(o, PageObject::Title(_)))
225 .map(PageObject::id),
263226 }
264 "format" => {
265 assert!((1..=124).contains(&operation));
266 let target = target_paragraph(&mut page, oid).text_mut().unwrap();
267 let mut format = target.text.format_at(0)?.clone();
268 format.font_size = Some(6.0 + operation as f32);
269 target.text.apply(Edit {
270 range: 0..end,
271 replacement: Paragraph::new(text.clone(), format),
272 })?;
227 }
228 "format" => {
229 assert!((1..=124).contains(&operation));
230 PageOp::Format {
231 text: oid,
232 range: 0..end,
233 set: vec![onestore::TextAttribute::FontSize(6.0 + operation as f32)],
234 clear: Vec::new(),
273235 }
274 _ => unreachable!(),
275236 }
276 (sid, page)
237 _ => unreachable!(),
277238 };
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 )?;
279246 if acknowledgement == "unack" {
280247 println!("durable {operation} {id}");
281248 } 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>> {
4747 let start = Instant::now();
4848 for n in 0..count {
4949 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)?;
5450 let slot = n % 2;
5551 let space = pages[slot].0;
56 let mut page = Page::from_space(&document, space)?;
52 let mut page = cache.page(space)?;
5753 let text = page
5854 .objects
5955 .iter_mut()
......@@ -76,13 +72,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
7672 range: 0..end,
7773 replacement: Paragraph::new(expected[slot].clone(), format),
7874 })?;
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 )?;
8087 assert!(ids.last().is_none_or(|previous| *previous < id));
8188 ids.push(id);
8289 println!(
8390 "{}",
8491 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})
8693 );
8794 }
8895 println!(
......@@ -114,16 +121,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
114121 serde_json::json!({"phase":"export","seconds":start.elapsed().as_secs_f64()})
115122 );
116123 let start = Instant::now();
117 for (n, id) in ids.iter().enumerate() {
118 let step = Instant::now();
119 assert!(
120 matches!(cache.sync_once(&mut remote)?, Some((actual, EditStatus::Published { .. })) if actual == *id)
121 );
122 println!(
123 "{}",
124 serde_json::json!({"phase":"publish","n":n,"id":id,"ms":step.elapsed().as_secs_f64()*1000.0})
125 );
126 }
124 // The queue publishes as one batch.
125 let step = Instant::now();
126 assert!(matches!(
127 cache.sync_once(&mut remote)?.edit,
128 Some((actual, EditStatus::Published { .. })) if Some(&actual) == ids.last()
129 ));
130 println!(
131 "{}",
132 serde_json::json!({"phase":"publish","count":ids.len(),"ms":step.elapsed().as_secs_f64()*1000.0})
133 );
127134 assert!(cache.pending()?.is_empty());
128135 let published = cache.snapshot()?;
129136 assert_eq!(onestore::read_file(&remote.0)?, published);
......@@ -145,7 +152,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
145152 .collect::<Vec<_>>(),
146153 ids
147154 );
148 assert_eq!(archive.snapshot()?, source);
155 verify(&archive.snapshot()?, &pages, &expected)?;
149156 println!(
150157 "{}",
151158 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>
9898 "{}",
9999 json!({"event":"state", "status":status, "revision":revision,
100100 "local_text":local.text, "remote_text":remote.text, "remote_revision":remote.revision.to_string(),
101 "pending":cache.pending()?.iter().map(|pending|match &pending.operation {
102 notebook::Operation::Page(intent) => match intent.text_change() {
103 Some((_, before, range, replacement)) => json!({"id":pending.id,"before":before,"replacement":replacement,"range":[range.start,range.end]}),
104 None => json!({"id":pending.id,"operation":"page"}),
105 },
106 operation => json!({"id":pending.id,"operation":operation}),
101 "pending":cache.pending()?.iter().map(|pending| match &pending.edit.ops[..] {
102 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }] =>
103 json!({"id":pending.id,"replacement":with,"range":[range.start,range.end]}),
104 _ => json!({"id":pending.id,"edit":pending.edit}),
107105 }).collect::<Vec<_>>() })
108106 );
109107 Ok(())
......@@ -128,34 +126,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
128126 drop(remote);
129127 let cache = Replica::create(root.join("cache.sqlite"), &source)?;
130128 let at = u32::try_from(target.text.encode_utf16().count())?;
131 let mut page = {
132 let store = onestore::Store::parse(&source)?;
133 let index = onestore::RevisionIndex::parse(&store)?;
134 let document = onestore::document::Document::parse(&index)?;
135 onestore::page::Page::from_space(&document, target.space)?
129 let edit = onestore::op::Edit {
130 at: 133_000_000_000_000_000,
131 ops: vec![onestore::op::Op::Page {
132 space: target.space,
133 op: onestore::op::PageOp::Text {
134 text: target.object,
135 range: at..at,
136 with: " [offline-recovery]".into(),
137 },
138 }],
136139 };
137 let paragraph = page
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 );
140 assert_eq!(cache.apply("Fixture", edit)?, 1);
159141 phase("local-after");
160142 report(&cache, root)?;
161143 } 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>> {
159159 for event in section.events() {
160160 match event {
161161 Event::Attempt {
162 id,
163 status: EditStatus::Published { revision },
164 } => receipts.push((id, revision)),
162 status: EditStatus::Published { .. },
163 ..
164 } => {}
165165 Event::Attempt { id, status } => {
166166 println!(
167167 "{}",
......@@ -175,7 +175,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
175175 );
176176 }
177177 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));
179188 }
180189 }
181190 }
crates/notebook/examples/smb_offline_client.rs+52-195
......@@ -3,9 +3,7 @@ mod concurrent;
33
44use notebook::smb::{Client, Credentials};
55use notebook::{EditStatus, Error, Remote, Replica, SmbRemote};
6use onestore::{
7 CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction, page::Paragraph,
8};
6use onestore::{CommitError, CommitState, ExGuid, RevisionIndex, Store, Transaction};
97use serde_json::json;
108use std::{
119 env,
......@@ -252,91 +250,19 @@ impl<R: Remote> Remote for Traced<R> {
252250 }
253251}
254252
255fn tokens(text: &str) -> Option<Vec<&str>> {
256 let mut remaining = text.strip_prefix("Concurrent edits:")?;
257 let mut tokens = Vec::new();
258 while !remaining.is_empty() {
259 let body = remaining.strip_prefix(" [w")?;
260 let (token, tail) = body.split_once(']')?;
261 let (actor, operation) = token.split_once(':')?;
262 if actor.is_empty()
263 || operation.is_empty()
264 || !actor
265 .bytes()
266 .chain(operation.bytes())
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)?;
253/// The edit appending `token` at `at` of a text.
254fn append(space: ExGuid, text: ExGuid, at: u32, token: &str) -> onestore::op::Edit {
255 onestore::op::Edit {
256 at: u64::try_from(now()).unwrap_or_default() * 10 + 116_444_736_000_000_000,
257 ops: vec![onestore::op::Op::Page {
258 space,
259 op: onestore::op::PageOp::Text {
260 text,
261 range: at..at,
262 with: token.to_owned(),
263 },
264 }],
335265 }
336 if current.contains(token) {
337 return None;
338 }
339 u32::try_from(current.encode_utf16().count()).ok()
340266}
341267
342268fn main() -> Result<(), Box<dyn std::error::Error>> {
......@@ -459,10 +385,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
459385 let at = u32::try_from(target.text.encode_utf16().count())?;
460386 let token = format!(" [{}:{}]", args[2], generated);
461387 let started = now();
462 let mut page = page_with(&source, target.space, target.object)?;
463 append_to(&mut page, target.object, at, &token);
464 match cache.save(&source, target.space, &page, "Offline document writer") {
465 Ok(Some(id)) => {
388 match cache.apply(
389 "Offline document writer",
390 append(target.space, target.object, at, &token),
391 ) {
392 Ok(id) => {
466393 println!(
467394 "{}",
468395 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>> {
470397 ids.push(id);
471398 generated += 1;
472399 }
473 Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy => {}
474400 other => return Err(format!("Unexpected local result: {other:?}").into()),
475401 }
476402 }
477 if let Some(intent) = pending.first()
478 && matches!(cache.status(intent.id)?, Some(EditStatus::Conflict(_)))
479 {
480 let local = cache.snapshot()?;
481 let remote = cache.remote_snapshot()?;
482 let current = view(&remote)?;
483 let notebook::Operation::Page(edit) = &intent.operation else {
484 return Err(format!(
485 "Intent {} requires review: {:?}",
486 intent.id,
487 cache.status(intent.id)?
488 )
489 .into());
490 };
491 let (object, before, _, token) = edit
492 .text_change()
493 .ok_or("A review expects one appended token")?;
494 let at = append_position(&before, &current.text, &token)
495 .ok_or("Append model disagrees with retained history")?;
496 let mut reviewed = page_with(&remote, intent.space, object)?;
497 append_to(&mut reviewed, object, at, &token);
498 match cache.review_page(intent.id, &local, &remote, &reviewed) {
499 Ok(()) => println!(
500 "{}",
501 json!({"event":"reviewed_append", "id":intent.id, "before":before, "remote":current.text, "token":token, "at_us":now()})
502 ),
403 if let Some(conflict) = cache.conflict()? {
404 // Concurrent appends meet at the end of the text: take the remote text and
405 // append the queued tokens it lacks after it again.
406 let remote = view(&cache.remote_snapshot()?)?;
407 let tokens: Vec<String> = cache
408 .pending()?
409 .iter()
410 .flat_map(|queued| queued.edit.ops.iter())
411 .filter_map(|op| match op {
412 onestore::op::Op::Page {
413 op: onestore::op::PageOp::Text { with, .. },
414 ..
415 } => Some(with.clone()),
416 _ => None,
417 })
418 .filter(|token| !remote.text.contains(token.as_str()))
419 .collect();
420 match cache.resolve(conflict.id, notebook::Resolution::Theirs) {
421 Ok(()) => {
422 let mut at = u32::try_from(remote.text.encode_utf16().count())?;
423 for token in &tokens {
424 cache.apply(
425 "Offline document writer",
426 append(remote.space, remote.object, at, token),
427 )?;
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 }
503435 Err(Error::Io(error))
504 if [
505 io::ErrorKind::ResourceBusy,
506 io::ErrorKind::WouldBlock,
507 io::ErrorKind::InvalidInput,
508 ]
509 .contains(&error.kind()) => {}
436 if [io::ErrorKind::WouldBlock, io::ErrorKind::InvalidInput]
437 .contains(&error.kind()) => {}
510438 Err(error) => return Err(error.into()),
511439 }
512440 }
......@@ -546,74 +474,3 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
546474 );
547475 Ok(())
548476}
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 @@
11use super::*;
22use onestore::FileDataReference;
3use onestore::{
4 RevisionIndex, Store,
5 document::{Document, Kind},
6};
37use rusqlite::OptionalExtension;
48use sha2::{Digest, Sha256};
59
......@@ -7,12 +11,7 @@ impl Replica {
711 /// Reads a previously downloaded external payload without network access.
812 /// Absence is distinct from an empty payload; every returned buffer passes its stored checksum.
913 pub fn cached_asset(&self, filename: &str, limit: usize) -> Result<Option<Vec<u8>>> {
10 let key = key(filename)?;
11 let connection = self
12 .connection
13 .lock()
14 .map_err(|_| io::Error::other("Cache owner panicked"))?;
15 cached(&connection, &key, limit)
14 cached(&*self.lock()?, &key(filename)?, limit)
1615 }
1716
1817 /// Fetches a declared external payload and durably retains it without changing the edit queue.
......@@ -26,32 +25,23 @@ impl Replica {
2625 limit: usize,
2726 ) -> Result<Vec<u8>> {
2827 let key = key(filename)?;
29 {
30 let connection = self
31 .connection
32 .lock()
33 .map_err(|_| io::Error::other("Cache owner panicked"))?;
34 if !referenced(&connection, &key)? {
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 }
28 if !self.references(&key)? {
29 return Err(io::Error::new(
30 io::ErrorKind::InvalidInput,
31 "The retained document images do not reference this external payload",
32 )
33 .into());
4134 }
4235 let bytes = crate::discover::read_external_asset(source, section, filename, limit)?;
43 let mut connection = self
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)? {
36 if !self.references(&key)? {
4937 return Err(io::Error::new(
5038 io::ErrorKind::ResourceBusy,
5139 "The external payload reference changed during download",
5240 )
5341 .into());
5442 }
43 let mut connection = self.lock()?;
44 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
5545 let previous = match cached(&transaction, &key, bytes.len()) {
5646 Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => {
5747 return Err(Error::AssetChanged);
......@@ -78,21 +68,22 @@ pub(crate) fn key(filename: &str) -> Result<String> {
7868 Ok(filename.to_ascii_lowercase())
7969}
8070
81fn referenced(connection: &Connection, key: &str) -> Result<bool> {
82 // One image at a time: the working image usually answers.
83 for read in [crate::images::working, crate::images::base] {
84 let image = read(connection)?;
85 let store = Store::parse(&image)?;
86 let index = RevisionIndex::parse(&store)?;
87 let document = Document::parse(&index)?;
88 if document.spaces.values().flat_map(|space| space.revisions.values())
89 .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))
91 }) {
92 return Ok(true);
71impl Replica {
72 /// Whether the local pages or the remote image declare the external payload `key`.
73 fn references(&self, key: &str) -> Result<bool> {
74 for image in [self.snapshot()?, self.remote_snapshot()?] {
75 let store = Store::parse(&image)?;
76 let index = RevisionIndex::parse(&store)?;
77 let document = Document::parse(&index)?;
78 if document.spaces.values().flat_map(|space| space.revisions.values())
79 .flat_map(|revision| revision.nodes.values()).any(|node| {
80 matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key))
81 }) {
82 return Ok(true);
83 }
9384 }
85 Ok(false)
9486 }
95 Ok(false)
9687}
9788
9889pub(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;
66pub mod smb;
77
88use onestore::{
9 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
10 document::{Document, Kind},
9 ExGuid,
10 op::{Edit, OpError},
1111 page::Page,
1212};
13use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params};
13use rusqlite::{Connection, OpenFlags, TransactionBehavior, params};
1414use std::{
1515 fs::OpenOptions,
1616 io,
1717 path::Path,
18 sync::{Mutex, atomic::AtomicI64},
18 sync::{Arc, Mutex, MutexGuard, mpsc},
19 thread::JoinHandle,
1920 time::Duration,
2021};
2122
2223mod assets;
23mod images;
24mod base;
2425mod merge;
25mod pages;
26mod rebase;
26mod migrate;
27mod queue;
2728mod recovery;
2829mod schema;
2930pub mod session;
30pub use pages::PageEdits;
3131pub use recovery::{Recovery, RecoverySummary};
3232mod sync;
33pub use sync::{ConflictKind, EditStatus, Remote};
33pub use sync::{ConflictKind, EditStatus, Remote, Synced};
3434mod worker;
35mod working;
3536#[cfg(feature = "smb")]
3637pub use smb::SmbRemote;
3738pub use worker::SyncWorker;
......@@ -50,6 +51,9 @@ pub enum Error {
5051 RemoteIo(io::Error),
5152 #[error(transparent)]
5253 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),
5357 #[error("External payload identity now refers to different bytes")]
5458 AssetChanged,
5559 #[cfg(feature = "protected")]
......@@ -60,114 +64,49 @@ pub enum Error {
6064type Result<T> = std::result::Result<T, Error>;
6165
6266const 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.
15269#[derive(Debug, Clone, PartialEq)]
15370pub 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 {
15480 pub id: u64,
15581 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,
15793}
15894
15995/// 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.
16198pub struct Replica {
162 connection: Mutex<Connection>,
99 connection: Arc<Mutex<Connection>>,
100 working: Option<mpsc::Sender<working::Request>>,
101 thread: Option<JoinHandle<()>>,
163102 synchronization: Mutex<()>,
164 /// The intent a synchronization step selected for publication, or zero.
165 in_flight: AtomicI64,
166 worker: Mutex<std::sync::Weak<worker::Signal>>,
103 worker: Arc<Mutex<std::sync::Weak<worker::Signal>>>,
104 /// The section's root object space, which names the document.
105 root: ExGuid,
167106}
168107
169108impl 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.
171110 /// An initialization error preserves the created file for inspection.
172111 pub fn create(path: impl AsRef<Path>, source: &[u8]) -> Result<Self> {
173112 validate(source)?;
......@@ -179,364 +118,224 @@ impl Replica {
179118 options.mode(0o600);
180119 }
181120 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)
183141 }
184142
185143 /// 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.
187147 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
188 Self::connect(path.as_ref(), None)
189 }
190
191 fn connect(path: &Path, source: Option<&[u8]>) -> Result<Self> {
148 let path = path.as_ref();
192149 let mut connection = cache_connection(path)?;
193 let transaction = connection.transaction_with_behavior(TransactionBehavior::Exclusive)?;
194150 let application: u32 =
195 transaction.pragma_query_value(None, "application_id", |row| row.get(0))?;
196 let version: u32 =
197 transaction.pragma_query_value(None, "user_version", |row| row.get(0))?;
198 if let Some(source) = source {
199 let tables: i64 =
200 transaction
201 .query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?;
202 if application != 0 || version != 0 || tables != 0 {
203 return Err(io::Error::new(
204 io::ErrorKind::AlreadyExists,
205 "Cache initialization found an existing database",
206 )
207 .into());
208 }
209 transaction.pragma_update(None, "application_id", APPLICATION_ID)?;
210 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
211 transaction.execute_batch(
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.
151 connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
152 if application != APPLICATION_ID {
153 return Err(io::Error::new(io::ErrorKind::InvalidData, "Not a notebook cache").into());
154 }
155 let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
156 if integrity != "ok" {
157 return Err(
158 io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(),
159 );
160 }
161 let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
162 // A schema-14 cache converts in its rollback journal, so a failed conversion leaves
163 // the file as it was.
164 match version {
165 migrate::VERSION => migrate::migrate(&mut connection, path)?,
166 schema::VERSION => {}
167 _ => {
231168 return Err(io::Error::new(
232169 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 ),
234174 )
235175 .into());
236176 }
237 validate_images(&transaction)?;
238 pending(&transaction)?;
239177 }
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))?;
241186 Ok(Self {
242 connection: Mutex::new(connection),
187 connection,
188 working: Some(sender),
189 thread: Some(thread),
243190 synchronization: Mutex::new(()),
244 in_flight: AtomicI64::new(0),
245 worker: Mutex::new(std::sync::Weak::new()),
191 worker,
192 root,
246193 })
247194 }
248195
249 /// Returns the latest complete locally committed image, including pending edits.
250 pub fn snapshot(&self) -> Result<Vec<u8>> {
251 let connection = self
252 .connection
253 .lock()
254 .map_err(|_| io::Error::other("Cache owner panicked"))?;
255 images::working(&connection)
196 fn lock(&self) -> Result<MutexGuard<'_, Connection>> {
197 lock(&self.connection)
256198 }
257199
258 /// The page in `space` as the working image stores it.
259 pub fn page(&self, space: ExGuid) -> Result<Page> {
260 let snapshot = self.snapshot()?;
261 let store = Store::parse(&snapshot)?;
262 let index = RevisionIndex::parse(&store)?;
263 Ok(Page::from_space(&Document::parse(&index)?, space)?)
200 /// Hands `request` to the section thread.
201 fn send(&self, request: working::Request) -> Result<()> {
202 self.working
203 .as_ref()
204 .and_then(|working| working.send(request).ok())
205 .ok_or_else(|| io::Error::other("The section thread stopped").into())
264206 }
265207
266 /// Whether no edit is queued and the remote still has the last observed image's stamp,
267 /// reading neither image; the cache is not locked during remote I/O.
268 pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> {
269 let lock = || {
270 self.connection
271 .lock()
272 .map_err(|_| io::Error::other("Cache owner panicked"))
273 };
274 if !images::settled(&*lock()?)? {
275 return Ok(false);
276 }
277 let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else {
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)
208 /// Asks the section thread and waits for its answer.
209 fn ask<T: Send + 'static>(
210 &self,
211 request: impl FnOnce(working::Reply<T>) -> working::Request,
212 ) -> Result<T> {
213 let (sender, receiver) = mpsc::sync_channel(1);
214 self.send(request(Box::new(move |result| {
215 let _ = sender.send(result);
216 })))?;
217 receiver
218 .recv()
219 .map_err(|_| io::Error::other("The section thread stopped"))?
289220 }
290221
291 /// Identities and page spaces of the pending edits, oldest first, without reading their
292 /// operations.
293 pub(crate) fn queued(&self) -> Result<Vec<(u64, ExGuid)>> {
294 let connection = self
295 .connection
296 .lock()
297 .map_err(|_| io::Error::other("Cache owner panicked"))?;
298 let mut query = connection.prepare("SELECT id, space FROM edits ORDER BY id")?;
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)
222 /// Applies an edit and queues it durably for publication, returning its id once
223 /// written. A refused edit returns `Rejected` and leaves every page as it was.
224 pub fn apply(&self, author: &str, edit: Edit) -> Result<u64> {
225 self.ask(|reply| working::Request::Apply {
226 author: author.to_owned(),
227 edit,
228 reply,
229 })
308230 }
309231
310 /// Durably queues an edited page model using the supplied local snapshot.
311 /// While the newest pending edit is an unattempted save of the same page, a new save
312 /// replaces its result instead of queueing another publication, as OneNote does
313 /// within its own save interval. An unchanged model returns `None`.
314 pub fn save(
232 /// `apply` without waiting: `reply` runs on the section thread once the edit is
233 /// durable or refused.
234 pub(crate) fn submit(
315235 &self,
316 source: &[u8],
317 space: ExGuid,
318 after: &Page,
319236 author: &str,
320 ) -> Result<Option<u64>> {
321 Ok(self.save_image(source, space, after, author)?.0)
237 edit: Edit,
238 reply: working::Reply<u64>,
239 ) -> Result<()> {
240 self.send(working::Request::Apply {
241 author: author.to_owned(),
242 edit,
243 reply,
244 })
322245 }
323246
324 /// `save`, with the working image it leaves.
325 pub(crate) fn save_image(
326 &self,
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()))
247 /// The page in `space` as the queued edits leave it; O(page), for opening and reloading.
248 pub fn page(&self, space: ExGuid) -> Result<Page> {
249 self.ask(|reply| working::Request::Page { space, reply })
406250 }
407251
408 /// Queues a new page and its section entry with stable identities for dependent edits.
409 pub fn create_page(&self, source: &[u8], page: &PageCreation) -> Result<Option<u64>> {
410 let edit = PreparedEdit::create_page(source, page)?;
411 self.record(
412 source,
413 page.space(),
414 Operation::CreatePage(page.clone()),
415 &edit,
416 )
252 /// Page spaces, titles and outline levels (1 at the top) in section order.
253 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
254 self.ask(|reply| working::Request::Pages { reply })
417255 }
418256
419 /// Queues the permanent removal of explicitly selected pages; the batch is republished
420 /// only while every selected page still exists remotely.
421 pub fn delete_pages(&self, source: &[u8], pages: &[ExGuid]) -> Result<Option<u64>> {
422 let edit = PreparedEdit::delete_pages_permanently(source, pages)?;
423 let space = validate(source)?;
424 self.record(source, space, Operation::DeletePages(pages.to_vec()), &edit)
257 /// The section image the queued edits leave, the unsealed ones sealed as one more
258 /// revision whose identities differ per call: O(section), for tests and diagnostics.
259 pub fn snapshot(&self) -> Result<Vec<u8>> {
260 self.ask(|reply| working::Request::Image { reply })
425261 }
426262
427 fn record(
428 &self,
429 source: &[u8],
430 space: ExGuid,
431 operation: Operation,
432 edit: &PreparedEdit<'_>,
433 ) -> Result<Option<u64>> {
434 let mut connection = self
435 .connection
436 .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))
263 /// The section file's identity, which internal links name as `section-id`.
264 pub fn identity(&self) -> Result<[u8; 16]> {
265 let stamp = base::stamp(&*self.lock()?, base::Image::Base)?
266 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?;
267 Ok(onestore::Header::parse(&stamp.header)?.file_id)
268 }
269
270 /// Queued edits, oldest first.
271 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
272 pending(&*self.lock()?)
463273 }
464274
465275 fn wake_sync(&self) {
466 if let Ok(worker) = self.worker.lock()
467 && let Some(worker) = worker.upgrade()
468 {
469 worker.wake();
276 wake(&self.worker);
277 }
278}
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();
470285 }
471286 }
472287}
473288
474fn page_of(source: &[u8], space: ExGuid) -> Result<Option<Page>> {
475 let store = Store::parse(source)?;
476 let index = RevisionIndex::parse(&store)?;
477 let document = Document::parse(&index)?;
478 Ok(Page::from_space(&document, space).ok())
289fn lock(connection: &Mutex<Connection>) -> Result<MutexGuard<'_, Connection>> {
290 connection
291 .lock()
292 .map_err(|_| io::Error::other("Cache owner panicked").into())
479293}
480294
481fn validate(source: &[u8]) -> Result<ExGuid> {
482 let store = Store::parse(source)?;
483 if !store.checksum_mismatches.is_empty() {
484 return Err(io::Error::new(
485 io::ErrorKind::InvalidData,
486 "Notebook transaction checksum damage",
487 )
488 .into());
295fn wake(worker: &Mutex<std::sync::Weak<worker::Signal>>) {
296 if let Ok(worker) = worker.lock()
297 && let Some(worker) = worker.upgrade()
298 {
299 worker.wake();
489300 }
490 let index = RevisionIndex::parse(&store)?;
491 index.validate_current()?;
492 Document::parse(&index)?;
493 Ok(index.root)
301}
302
303/// Fully validates a section image, returning its root object space.
304fn validate(source: &[u8]) -> Result<ExGuid> {
305 let arena = onestore::Arena::default();
306 Ok(onestore::Section::open(&arena, source.to_vec())?.root())
494307}
495308
496309fn pending(connection: &Connection) -> Result<Vec<PendingEdit>> {
497 let mut query = connection.prepare("SELECT id, space, operation FROM edits ORDER BY id")?;
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)
310 queue::load(connection, None)
504311}
505312
506fn pending_edit(row: &rusqlite::Row<'_>) -> Result<PendingEdit> {
507 let edit = PendingEdit {
508 id: u64::try_from(row.get::<_, i64>(0)?).map_err(io::Error::other)?,
509 space: row.get::<_, String>(1)?.parse()?,
510 operation: serde_json::from_str(&row.get::<_, String>(2)?)
511 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
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)
313fn unsigned(value: i64) -> Result<u64> {
314 Ok(u64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?)
315}
316
317fn signed(value: u64) -> Result<i64> {
318 Ok(i64::try_from(value).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?)
521319}
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.
523323fn cache_connection(path: &Path) -> Result<Connection> {
524324 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
525325 connection.busy_timeout(Duration::ZERO)?;
526326 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;",
528328 )?;
529 for (name, expected) in [("locking_mode", "exclusive"), ("journal_mode", "delete")] {
530 let actual: String = connection.pragma_query_value(None, name, |row| row.get(0))?;
531 if actual != expected {
532 return Err(io::Error::new(
533 io::ErrorKind::InvalidData,
534 "Unsupported cache locking or journal mode",
535 )
536 .into());
537 }
329 let locking: String = connection.pragma_query_value(None, "locking_mode", |row| row.get(0))?;
330 let journal: String = connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
331 if locking != "exclusive" || !["wal", "delete"].contains(&journal.as_str()) {
332 return Err(io::Error::new(
333 io::ErrorKind::InvalidData,
334 "Unsupported cache locking or journal mode",
335 )
336 .into());
538337 }
539 for (name, expected) in [("synchronous", 3), ("fullfsync", 1), ("foreign_keys", 1)] {
338 for (name, expected) in [("synchronous", 2), ("fullfsync", 1), ("foreign_keys", 1)] {
540339 let actual: i64 = connection.pragma_query_value(None, name, |row| row.get(0))?;
541340 if actual != expected {
542341 return Err(io::Error::new(
......@@ -549,20 +348,27 @@ fn cache_connection(path: &Path) -> Result<Connection> {
549348 Ok(connection)
550349}
551350
552fn validate_images(connection: &Connection) -> Result<()> {
553 let integrity: String = connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
554 if integrity != "ok" {
555 return Err(
556 io::Error::new(io::ErrorKind::InvalidData, "Cache integrity check failed").into(),
557 );
558 }
559 let (base, working) = images::both(connection)?;
560 if validate(&base)? != validate(&working)? {
351/// Switches a cache to write-ahead logging: a commit appends its pages to `<cache>-wal`
352/// instead of copying the originals to a rollback journal. Under exclusive locking the WAL
353/// index lives in memory, so the WAL is the only file beside the cache; it is checkpointed
354/// and removed on close, and replayed on the next open after a crash.
355fn write_ahead(connection: &Connection) -> Result<()> {
356 let mode: String =
357 connection.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?;
358 if mode != "wal" {
561359 return Err(io::Error::new(
562 io::ErrorKind::InvalidData,
563 "Cache images belong to different documents",
360 io::ErrorKind::Unsupported,
361 "The cache cannot use write-ahead logging",
564362 )
565363 .into());
566364 }
567365 Ok(())
568366}
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//! Three-way merge of page models: the remote page keeps everything it changed, and the
2//! local changes (`base` → `ours`) are re-applied wherever the two sides touched
3//! different objects, fields or text ranges. Any overlap is a conflict for review.
1//! Rebasing queued edits onto a changed remote section. Each op replays on the remote
2//! section when the objects it reads are as the base image had them; text ops on a text
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};
510use onestore::{
6 ExGuid,
7 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, text::Edit},
11 ExGuid, OutlineEdit, PageCreation, PageEdit, PagePosition, Section,
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,
822};
9use std::collections::{BTreeMap, BTreeSet};
1023
11pub(crate) fn merge(base: &Page, ours: &Page, theirs: &Page) -> Option<Page> {
12 if ours == base {
13 return Some(theirs.clone());
14 }
15 if theirs == base || ours == theirs {
16 return Some(ours.clone());
17 }
18 if ours.created != base.created || ours.margin_origin != base.margin_origin {
19 return None;
20 }
21 fn by_id(page: &Page) -> BTreeMap<ExGuid, &PageObject> {
22 page.objects
23 .iter()
24 .map(|object| (object.id(), object))
25 .collect()
26 }
27 let (b, o, t) = (by_id(base), by_id(ours), by_id(theirs));
28 let mut result: Vec<PageObject> = Vec::new();
29 for object in &theirs.objects {
30 let id = object.id();
31 match (b.get(&id), o.get(&id)) {
32 (Some(before), Some(after)) => match (before, after, object) {
33 (PageObject::Outline(x), PageObject::Outline(y), PageObject::Outline(z)) => {
34 result.push(PageObject::Outline(merge_outline(x, y, z)?));
24/// A conflict being resolved: from edit `first` on, ops on `space` are dropped. Keeping
25/// mine replaces the first of them by the ops rewriting the remote page to `target` (or
26/// putting `target` back as a page where the remote removed it), or, for the section's
27/// page list, keeps what of each section op still applies.
28pub(crate) struct Resolving<'p> {
29 pub first: u64,
30 pub space: ExGuid,
31 pub mine: bool,
32 pub target: Option<&'p Page>,
33}
34
35pub(crate) enum Outcome {
36 /// Every edit applied; these edits' ops changed. An edit whose ops were all dropped
37 /// stays, empty, to be published and acknowledged with its batch.
38 Applied(Vec<(u64, Edit)>),
39 Conflict {
40 id: u64,
41 space: ExGuid,
42 kind: ConflictKind,
43 },
44}
45
46/// Replays `edits` on `new`, the remote section; `old` is the base they were made on.
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;
35135 }
36 _ => result.push(pick(before, after, &object)?.clone()),
37 },
38 (Some(before), None) => {
39 // Removed locally; keep only if the remote left it untouched.
40 if object != *before {
41 return None;
136 }
137 let space = match op {
138 Op::Page { space, .. } => *space,
139 Op::Section(_) => root,
140 };
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;
42150 }
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));
43156 }
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 ));
45167 }
46168 }
47 for (index, object) in ours.objects.iter().enumerate() {
48 let id = object.id();
49 if b.contains_key(&id) {
50 if !t.contains_key(&id) {
51 // Removed remotely; a local edit to it cannot be placed.
52 if b[&id] != object {
53 return None;
169 Ok(Outcome::Applied(rewritten))
170}
171
172fn order(section: &mut Section<'_>) -> Result<Order> {
173 Ok(section
174 .pages()?
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());
54217 }
218 Ok(None)
55219 }
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 })),
57230 }
58 if t.contains_key(&id) {
59 return None;
231 }
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()))),
60270 }
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());
80271 }
81 let order = |objects: &[PageObject], keep: &dyn Fn(ExGuid) -> bool| -> Vec<ExGuid> {
82 objects
272
273 /// 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..])
83294 .iter()
84 .filter(|object| matches!(object, PageObject::Outline(_)) && keep(object.id()))
85 .map(PageObject::id)
86 .collect()
87 };
88 let common = |id: ExGuid| b.contains_key(&id) && o.contains_key(&id) && t.contains_key(&id);
89 let (base_order, our_order, their_order) = (
90 order(&base.objects, &common),
91 order(&ours.objects, &common),
92 order(&theirs.objects, &common),
93 );
94 if our_order != base_order {
95 if their_order != base_order && their_order != our_order {
96 return None;
295 .map(|(next, _)| Some(*next))
296 .filter(|next| next.is_some_and(|next| self.listed(next)))
297 .chain([None])
298 .collect::<Vec<_>>();
299 let copy = page.copy()?;
300 let mut refusal = ConflictKind::StructureChanged;
301 for before in anchors {
302 let creation = PageCreation::new(before, Some(&page.title), author)?;
303 let import = Op::Section(SectionOp::Import {
304 creation: creation.clone(),
305 page: copy.clone(),
306 });
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 }
97324 }
98 result = reorder(result, &our_order);
99 }
100 // Definitions we added or changed travel with our paragraphs; theirs win elsewhere.
101 let mut definitions = theirs.definitions.clone();
102 for (id, definition) in &ours.definitions {
103 match (base.definitions.get(id), theirs.definitions.get(id)) {
104 (Some(before), Some(after)) if before != definition => {
105 if after != before && after != definition {
106 return None;
325 Ok(Err(refusal))
326 }
327
328 fn section(&self, op: &SectionOp) -> std::result::Result<(), ConflictKind> {
329 let (old, new) = &self.orders;
330 let find =
331 |list: &[(ExGuid, u32)], space: ExGuid| list.iter().position(|(s, _)| *s == space);
332 match op {
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 }
107342 }
108 definitions.insert(*id, definition.clone());
343 Ok(())
109344 }
110 (None, None) => {
111 definitions.insert(*id, definition.clone());
345 SectionOp::Pages(edits) => {
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 }
112431 }
113 (None, Some(after)) if after != definition => return None,
114 _ => {}
115432 }
116433 }
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 })
125434}
126435
127/// Places the objects named in `order` into the slots those objects occupy in `objects`,
128/// leaving every other object where it is.
129fn reorder(objects: Vec<PageObject>, order: &[ExGuid]) -> Vec<PageObject> {
130 let mut slots: Vec<Option<PageObject>> = objects.into_iter().map(Some).collect();
131 let positions: Vec<usize> = slots
132 .iter()
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()
436/// One page as the base stored it and as the remote does, with the remote's text changes
437/// in the coordinates of the replayed edits.
438struct Diff {
439 old: Index,
440 new: Index,
441 regions: BTreeMap<ExGuid, Changes>,
151442}
152443
153fn pick<T: PartialEq + Clone>(base: &T, ours: &T, theirs: &T) -> Option<T> {
154 if ours == base {
155 Some(theirs.clone())
156 } else if theirs == base || theirs == ours {
157 Some(ours.clone())
158 } else {
159 None
444impl Diff {
445 fn ours(&self, id: ExGuid) -> bool {
446 !self.old.nodes.contains_key(&id)
160447 }
161}
162448
163fn merge_outline(base: &Outline, ours: &Outline, theirs: &Outline) -> Option<Outline> {
164 let title = pick(&base.title, &ours.title, &theirs.title)?;
165 let min_width = pick(&base.min_width, &ours.min_width, &theirs.min_width)?;
166 let indents = pick(&base.indents, &ours.indents, &theirs.indents)?;
167 let unsupported = pick(&base.unsupported, &ours.unsupported, &theirs.unsupported)?;
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}
449 /// Whether the remote left the object's own data as the base had it.
450 fn kept(&self, id: ExGuid) -> bool {
451 self.ours(id)
452 || matches!((self.old.nodes.get(&id), self.new.nodes.get(&id)), (Some(a), Some(b)) if a.own == b.own)
453 }
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 {
206 let mut children: Children = BTreeMap::new();
207 for paragraph in list {
208 children
209 .entry(paragraph.parent)
210 .or_default()
211 .push(paragraph.id);
469 /// Whether the remote kept the object under the same parent, in the same order among
470 /// the siblings both sides have.
471 fn placed(&self, id: ExGuid) -> bool {
472 if self.ours(id) {
473 return true;
474 }
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)))
212490 }
213 children
214}
215491
216fn merge_paragraphs(
217 base: &[PageParagraph],
218 ours: &[PageParagraph],
219 theirs: &[PageParagraph],
220) -> Option<Vec<PageParagraph>> {
221 fn index(list: &[PageParagraph]) -> BTreeMap<ExGuid, &PageParagraph> {
222 list.iter().map(|p| (p.id, p)).collect()
223 }
224 let (b, o, t) = (index(base), index(ours), index(theirs));
225 let mut merged: BTreeMap<ExGuid, PageParagraph> = BTreeMap::new();
226 let mut removed = BTreeSet::new();
227 for id in b.keys().chain(o.keys()).chain(t.keys()) {
228 if merged.contains_key(id) || removed.contains(id) {
229 continue;
492 /// Where the remote changed a text's characters and formats; its tags and date field
493 /// are not positions an op names.
494 fn region(&mut self, text: ExGuid) -> std::result::Result<Option<&mut Changes>, ConflictKind> {
495 if !self.regions.contains_key(&text) {
496 if self.ours(text) {
497 return Ok(None);
498 }
499 let (Some(Own::Text(a)), Some(Own::Text(b))) = (
500 self.old.nodes.get(&text).map(|node| &node.own),
501 self.new.nodes.get(&text).map(|node| &node.own),
502 ) else {
503 return Err(ConflictKind::ContentChanged);
504 };
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);
230510 }
231 let paragraph = match (b.get(id), o.get(id), t.get(id)) {
232 (Some(x), Some(y), Some(z)) => merge_paragraph(x, y, z)?,
233 (Some(x), None, Some(z)) => {
234 if z != x {
235 return None;
511 Ok(self.regions.get_mut(&text))
512 }
513
514 /// The op as it replays on the remote page; `None` when the remote already made it.
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;
236525 }
237 removed.insert(*id);
238 continue;
239526 }
240 (Some(x), Some(y), None) => {
241 if y != x {
242 return None;
527 PageOp::Format { text, range, .. } => {
528 if let Some(region) = self.region(*text)? {
529 *range = region.map(range).ok_or(Changed)?;
243530 }
244 removed.insert(*id);
245 continue;
246531 }
247 (Some(_), None, None) => {
248 removed.insert(*id);
249 continue;
532 PageOp::Link { text, .. } | PageOp::Equation { text, .. } => {
533 require(self.region(*text)?.is_none())?;
250534 }
251 (None, Some(_), Some(_)) => return None,
252 (None, Some(y), None) => (*y).clone(),
253 (None, None, Some(z)) => (*z).clone(),
254 (None, None, None) => unreachable!(),
255 };
256 merged.insert(*id, paragraph);
257 }
258 // Child order per container: the remote order with our repositioned paragraphs re-placed.
259 let (bc, oc, tc) = (children(base), children(ours), children(theirs));
260 // A paragraph we added after one they gave new children would sit after or among those
261 // children; Enter splitting a paragraph carries its children below, so neither is assumed.
262 for pair in oc.values().flat_map(|list| list.windows(2)) {
263 if b.contains_key(&pair[0])
264 && !b.contains_key(&pair[1])
265 && tc
266 .get(&Some(pair[0]))
267 .is_some_and(|children| children.iter().any(|id| !b.contains_key(id)))
268 {
269 return None;
535 PageOp::Split {
536 text, at, right, ..
537 } => {
538 // The new paragraph copies the holder's placement and formats; lists and tags
539 // stay with the holder unless the split names copies of them.
540 let holder = self.holder(*text);
541 require(holder.is_none_or(|holder| self.splits_alike(holder)))?;
542 if let Some(region) = self.region(*text)? {
543 // The cut moves with the character after it: text the remote typed at
544 // the cut stays left of it.
545 let cut = region.map(&(*at..*at + 1)).ok_or(Changed)?;
546 let right_changes = region.split(*at);
547 if !right_changes.0.is_empty() {
548 self.regions.insert(*right, right_changes);
549 }
550 *at = cut.start;
551 }
552 }
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 },
270659 }
660 Ok(Some(op))
271661 }
272 let mut order: BTreeMap<Option<ExGuid>, Vec<ExGuid>> = BTreeMap::new();
273 let containers: BTreeSet<Option<ExGuid>> = bc
274 .keys()
275 .chain(oc.keys())
276 .chain(tc.keys())
277 .copied()
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()
662
663 /// Whether the remote put the object where a move places it: under `parent` (the page
664 /// when `None`), before `before` or last.
665 fn placed_at(&self, id: ExGuid, parent: Option<ExGuid>, before: Option<ExGuid>) -> bool {
666 let Some(node) = self.new.nodes.get(&id) else {
667 return false;
288668 };
289 let (our_list, their_list) = (live(oc.get(&container)), live(tc.get(&container)));
290 let of_base = |ids: Option<&Vec<ExGuid>>| -> Vec<ExGuid> {
291 ids.map(|ids| {
292 ids.iter()
293 .copied()
294 .filter(|id| b.contains_key(id))
295 .collect()
669 let container = parent.unwrap_or(PAGE);
670 let siblings = self.new.children(container);
671 node.parent == container
672 && siblings
673 .iter()
674 .position(|sibling| *sibling == id)
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,
296688 })
297 .unwrap_or_default()
298689 };
299 let (base_ids, our_ids, their_ids) = (
300 of_base(bc.get(&container)),
301 of_base(oc.get(&container)),
302 of_base(tc.get(&container)),
303 );
304 let ours_moved = if our_ids == base_ids || our_ids == their_ids {
305 BTreeSet::new()
306 } else {
307 moved(&base_ids, &our_ids)
690 let before = tags(&self.old);
691 before.is_some() && before == tags(&self.new)
692 }
693
694 fn new_has(&self, id: ExGuid) -> bool {
695 self.ours(id) || self.new.nodes.contains_key(&id)
696 }
697
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 })
308723 };
309 // A paragraph we repositioned that the remote also moved, reparented or removed
310 // has two destinations; neither side's placement can be assumed.
311 let mut theirs_touched = moved(&base_ids, &their_ids);
312 theirs_touched.extend(base_ids.iter().filter(|id| !their_ids.contains(id)));
313 if ours_moved.iter().any(|id| theirs_touched.contains(id)) {
314 return None;
724 let before = shape(&self.old);
725 before.is_some() && before == shape(&self.new)
726 }
727}
728
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();
315750 }
316 let mut list: Vec<ExGuid> = their_list.clone();
317 list.retain(|id| merged[id].parent == container);
318 // Paragraphs we repositioned, added or brought here follow their predecessor in our list.
319 for (at, id) in our_list.iter().enumerate() {
320 if merged[id].parent != container || (list.contains(id) && !ours_moved.contains(id)) {
321 continue;
751 out.push((
752 character,
753 span.map(|span| span.format.clone()).unwrap_or_default(),
754 ));
755 }
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 }
322789 }
323 list.retain(|other| other != id);
324 let predecessor = our_list[..at]
790 let (mut i, mut j) = (0, 0);
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
325806 .iter()
326 .rev()
327 .find(|other| list.contains(other));
328 let position = predecessor.map_or(0, |p| list.iter().position(|x| x == p).unwrap() + 1);
329 list.insert(position, *id);
807 .map(|(character, _)| character.len_utf16() as u32)
808 .sum()
809 };
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);
330826 }
331 order.insert(container, list);
827 Self(regions)
332828 }
333 let mut out = Vec::new();
334 let mut pending: Vec<ExGuid> = order.get(&None).cloned().unwrap_or_default();
335 pending.reverse();
336 while let Some(id) = pending.pop() {
337 let paragraph = merged.remove(&id)?;
338 out.push(paragraph);
339 if let Some(children) = order.get(&Some(id)) {
340 pending.extend(children.iter().rev().copied());
829
830 /// The range on the remote text, when it lies clear of every change: a range may end
831 /// where a change starts or start where one ends, an insertion point may not.
832 fn map(&self, range: &Range<u32>) -> Option<Range<u32>> {
833 let empty = range.start == range.end;
834 let mut shift = 0;
835 for region in &self.0 {
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 }
341857 }
342858 }
343 if !merged.is_empty() {
344 return None;
859
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)
345875 }
346 Some(out)
347876}
348877
349/// The members of `list` outside a longest common subsequence with `base`: the paragraphs
350/// a reorder of the shared members had to move.
351fn moved(base: &[ExGuid], list: &[ExGuid]) -> BTreeSet<ExGuid> {
352 let a: Vec<ExGuid> = base
353 .iter()
354 .copied()
355 .filter(|id| list.contains(id))
356 .collect();
357 let b: Vec<ExGuid> = list
358 .iter()
359 .copied()
360 .filter(|id| base.contains(id))
361 .collect();
362 let mut table = vec![vec![0usize; b.len() + 1]; a.len() + 1];
363 for i in (0..a.len()).rev() {
364 for j in (0..b.len()).rev() {
365 table[i][j] = if a[i] == b[j] {
366 table[i + 1][j + 1] + 1
367 } else {
368 table[i + 1][j].max(table[i][j + 1])
369 };
878/// What an object holds apart from the objects under it.
879#[derive(Debug, Clone, PartialEq)]
880enum Own {
881 Outline {
882 title: bool,
883 min_width: Option<f32>,
884 layout: Layout,
885 indents: Vec<f32>,
886 unsupported: Vec<Unsupported>,
887 },
888 Title {
889 date: Option<ExGuid>,
890 layout: Layout,
891 },
892 Paragraph {
893 level: u32,
894 style: Option<ExGuid>,
895 format: Format,
896 lists: Vec<ExGuid>,
897 tags: Vec<Tag>,
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 }
370984 }
985 index
371986 }
372 let mut kept = BTreeSet::new();
373 let (mut i, mut j) = (0, 0);
374 while i < a.len() && j < b.len() {
375 if a[i] == b[j] {
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;
987
988 fn children(&self, id: ExGuid) -> &[ExGuid] {
989 if id == PAGE {
990 &self.page
381991 } else {
382 j += 1;
992 self.nodes.get(&id).map_or(&[], |node| &node.children)
383993 }
384994 }
385 b.into_iter().filter(|id| !kept.contains(id)).collect()
386}
387995
388fn merge_paragraph(
389 base: &PageParagraph,
390 ours: &PageParagraph,
391 theirs: &PageParagraph,
392) -> Option<PageParagraph> {
393 let lists = pick(&base.lists, &ours.lists, &theirs.lists)?;
394 let tags = pick(&base.tags, &ours.tags, &theirs.tags)?;
395 let style = pick(&base.style, &ours.style, &theirs.style)?;
396 let format = pick(&base.format, &ours.format, &theirs.format)?;
397 let (parent, level) = pick(
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}
996 fn add(&mut self, id: ExGuid, parent: ExGuid, own: Own, children: Vec<ExGuid>) {
997 self.nodes.insert(
998 id,
999 Node {
1000 own,
1001 parent,
1002 children,
1003 },
1004 );
1005 }
4311006
432/// Re-applies our replacement inside the remote text when its range maps unambiguously.
433fn merge_text(
434 base: &onestore::page::Paragraph,
435 ours: &onestore::page::Paragraph,
436 theirs: &onestore::page::Paragraph,
437) -> Option<onestore::page::Paragraph> {
438 if let Some(picked) = pick(base, ours, theirs) {
439 return Some(picked);
440 }
441 if base.text() == ours.text() {
442 // A local formatting-only change cannot be attributed to a range of the remote text.
443 return None;
444 }
445 let (b, o) = (base.text(), ours.text());
446 let prefix = b
447 .char_indices()
448 .zip(o.chars())
449 .take_while(|((_, x), y)| x == y)
450 .map(|((i, x), _)| i + x.len_utf8())
451 .last()
452 .unwrap_or(0);
453 let suffix = b[prefix..]
454 .chars()
455 .rev()
456 .zip(o[prefix..].chars().rev())
457 .take_while(|(x, y)| x == y)
458 .map(|(x, _)| x.len_utf8())
459 .sum::<usize>();
460 let start = base.utf16_offset(prefix).ok()?;
461 let end = base.utf16_offset(b.len() - suffix).ok()?;
462 let inserted = ours.utf16_offset(o.len() - suffix).ok()?;
463 let replacement = ours.slice(start..inserted).ok()?;
464 let our_end = ours.utf16_offset(o.len()).ok()?;
465 let base_end = base.utf16_offset(b.len()).ok()?;
466 if (start > 0 && ours.slice(0..start).ok()? != base.slice(0..start).ok()?)
467 || (end < base_end
468 && ours.slice(inserted..our_end).ok()? != base.slice(end..base_end).ok()?)
469 {
470 // Formatting changed outside the replaced range; the remote text would hide it.
471 return None;
472 }
473 let mapped = crate::rebase::rebase(b, theirs.text(), start..end)?;
474 // Text they reformatted is not ours to replace, nor to move as a split would.
475 if start < end && theirs.slice(mapped.clone()).ok()? != base.slice(start..end).ok()? {
476 return None;
477 }
478 let mut merged = theirs.clone();
479 merged
480 .apply(Edit {
481 range: mapped,
482 replacement,
483 })
484 .ok()?;
485 Some(merged)
1007 fn outline(&mut self, outline: &Outline, parent: ExGuid) {
1008 let children = self.paragraphs(&outline.paragraphs, outline.id);
1009 self.add(
1010 outline.id,
1011 parent,
1012 Own::Outline {
1013 title: outline.title,
1014 min_width: outline.min_width,
1015 layout: outline.layout.clone(),
1016 indents: outline.indents.clone(),
1017 unsupported: outline.unsupported.clone(),
1018 },
1019 children,
1020 );
1021 }
1022
1023 /// Indexes a flat paragraph list; returns the container's direct children.
1024 fn paragraphs(&mut self, list: &[PageParagraph], container: ExGuid) -> Vec<ExGuid> {
1025 let mut children: BTreeMap<ExGuid, Vec<ExGuid>> = BTreeMap::new();
1026 for paragraph in list {
1027 children
1028 .entry(paragraph.parent.unwrap_or(container))
1029 .or_default()
1030 .push(paragraph.id);
1031 }
1032 for paragraph in list {
1033 let content = match &paragraph.content {
1034 ParagraphContent::Text(text) => {
1035 self.add(text.id, paragraph.id, Own::Text(text.clone()), Vec::new());
1036 text.id
1037 }
1038 ParagraphContent::Table(table) => {
1039 let rows = table.rows.iter().map(|row| row.id).collect();
1040 for row in &table.rows {
1041 let cells = row.cells.iter().map(|cell| cell.id).collect();
1042 for cell in &row.cells {
1043 let children = self.paragraphs(&cell.paragraphs, cell.id);
1044 self.add(
1045 cell.id,
1046 row.id,
1047 Own::Cell {
1048 layout: cell.layout.clone(),
1049 indents: cell.indents.clone(),
1050 shading: cell.shading,
1051 unsupported: cell.unsupported.clone(),
1052 },
1053 children,
1054 );
1055 }
1056 self.add(row.id, table.id, Own::Row, cells);
1057 }
1058 self.add(
1059 table.id,
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 }
4861121}
4871122
4881123#[cfg(test)]
4891124mod tests {
4901125 use super::*;
491 use onestore::page::Paragraph;
4921126
4931127 fn text(s: &str) -> Paragraph {
494 Paragraph::new(s.into(), Default::default())
1128 Paragraph::new(s.into(), Format::default())
4951129 }
4961130
4971131 #[test]
498 fn same_paragraph_edits_merge_when_their_ranges_do_not_overlap() {
499 let base = text("one two three");
500 let ours = text("one two three four");
501 assert_eq!(
502 merge_text(&base, &ours, &text("zero one two three"))
503 .unwrap()
504 .text(),
505 "zero one two three four"
506 );
507 assert_eq!(
508 merge_text(&base, &ours, &text("one 2 three"))
509 .unwrap()
510 .text(),
511 "one 2 three four"
512 );
513 assert_eq!(
514 merge_text(&base, &text("one two THREE"), &text("one two 3")),
515 None
516 );
517 assert_eq!(
518 merge_text(&base, &text("one two three"), &text("x"))
519 .unwrap()
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 {
1132 fn ranges_clear_of_the_remote_changes_shift_past_them() {
1133 let region = |start, end, delta| Region { start, end, delta };
1134 let changes = Changes::between(&text("one two three"), &text("one 2 three"));
1135 assert_eq!(changes.0, [region(4, 7, -2)]);
1136 assert_eq!(changes.map(&(0..3)), Some(0..3));
1137 assert_eq!(changes.map(&(0..4)), Some(0..4));
1138 assert_eq!(changes.map(&(8..13)), Some(6..11));
1139 assert_eq!(changes.map(&(7..8)), Some(5..6));
1140 assert_eq!(changes.map(&(13..13)), Some(11..11));
1141 assert_eq!(changes.map(&(4..4)), None);
1142 assert_eq!(changes.map(&(7..7)), None);
1143 assert_eq!(changes.map(&(3..5)), None);
1144 let mut moved = changes.clone();
1145 moved.replaced(&(0..0), 5);
1146 assert_eq!(moved.0, [region(9, 12, -2)]);
1147 let both = Changes::between(&text("ab🦀cd"), &text("Xab🦀cYd"));
1148 assert_eq!(both.0, [region(0, 0, 1), region(5, 5, 1)]);
1149 assert_eq!(both.map(&(2..4)), Some(3..5));
1150 assert_eq!(both.map(&(1..1)), Some(2..2));
1151 assert_eq!(both.map(&(0..0)), None);
1152 assert_eq!(both.map(&(3..6)), None);
1153 let bold = Format {
5301154 bold: Some(true),
5311155 ..Default::default()
5321156 };
533 let remote = Paragraph::new("one two three".into(), bold);
534 let merged = merge_text(&base, &text("one two three four"), &remote).unwrap();
535 assert_eq!(merged.text(), "one two three four");
536 assert_eq!(merged.format_at(0).unwrap(), remote.format_at(0).unwrap());
537 assert_eq!(
538 merge_text(&base, &remote, &text("one two three four")),
539 None
540 );
541 assert_eq!(
542 merge_text(&base, &text("one two"), &text("X one two three"))
543 .unwrap()
544 .text(),
545 "X one two"
1157 let formatted = Changes::between(
1158 &text("abc"),
1159 &Paragraph::from_runs([
1160 ("a".to_owned(), Format::default()),
1161 ("b".to_owned(), bold),
1162 ("c".to_owned(), Format::default()),
1163 ]),
5461164 );
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);
5471171 assert_eq!(
548 merge_text(&base, &text("two three"), &text("one two three!"))
549 .unwrap()
550 .text(),
551 "two three!"
1172 (cut.0, right.0),
1173 (vec![region(1, 1, 1)], vec![region(2, 2, 1)])
5521174 );
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)]);
5531179 }
5541180}
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 {
1111 pub conflicts: u64,
1212 pub uncertain_edits: u64,
1313 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.
1517 pub remote_bytes: u64,
1618 pub cached_assets: u64,
1719 pub cached_asset_bytes: u64,
......@@ -36,33 +38,48 @@ impl Recovery {
3638 io::Error::new(io::ErrorKind::InvalidData, "Not a recovery archive").into(),
3739 );
3840 }
39 if version != SCHEMA_VERSION {
41 if version != schema::VERSION {
4042 return Err(io::Error::new(
4143 io::ErrorKind::InvalidData,
4244 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
4447 ),
4548 )
4649 .into());
4750 }
48 validate_images(&connection)?;
49 for edit in pending(&connection)? {
50 sync::status(&connection, edit.id)?;
51 let recovery = Self { connection };
52 recovery.snapshot()?;
53 for edit in pending(&recovery.connection)? {
54 sync::status(&recovery.connection, edit.id)?;
5155 }
52 receipts(&connection)?;
53 Ok(Self { connection })
56 receipts(&recovery.connection)?;
57 Ok(recovery)
5458 }
5559
5660 pub fn summary(&self) -> Result<RecoverySummary> {
5761 summary(&self.connection)
5862 }
5963
64 /// The image the archived queue leaves, as `Replica::snapshot` gives it.
6065 pub fn snapshot(&self) -> Result<Vec<u8>> {
61 crate::images::working(&self.connection)
66 working::image(&self.connection)
6267 }
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.
6478 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 }
6683 }
6784
6885 pub fn pending(&self) -> Result<Vec<PendingEdit>> {
......@@ -86,83 +103,88 @@ impl Recovery {
86103impl Replica {
87104 /// Summarizes durable state without exposing notebook content.
88105 pub fn recovery_summary(&self) -> Result<RecoverySummary> {
89 let connection = self
90 .connection
91 .lock()
92 .map_err(|_| io::Error::other("Cache owner panicked"))?;
93 summary(&connection)
106 summary(&*self.lock()?)
94107 }
95108
96109 /// Exports a consistent archive to a new local path without changing the live queue.
97110 /// Archives contain notebook content and cannot be opened as writable replicas.
98111 /// An error after publication can leave a complete archive at the destination.
99112 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
100 let path = path.as_ref();
101 if path.file_name().is_none() {
113 export(&*self.lock()?, path.as_ref(), false)
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 {
102137 return Err(io::Error::new(
103 io::ErrorKind::InvalidInput,
104 "Provide a new recovery archive filename",
138 io::ErrorKind::WouldBlock,
139 "Recovery export could not acquire the database snapshot",
105140 )
106141 .into());
107142 }
108 let parent = path
109 .parent()
110 .filter(|parent| !parent.as_os_str().is_empty())
111 .unwrap_or_else(|| Path::new("."));
112 let temporary = tempfile::Builder::new()
113 .prefix(".onestore-recovery-")
114 .tempfile_in(parent)?;
115 let mut destination = cache_connection(temporary.path())?;
116 {
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()?;
143 }
144 // One self-contained file: the copied header says WAL, as the cache does.
145 destination.pragma_update(None, "journal_mode", "DELETE")?;
146 destination.pragma_update(None, "application_id", RECOVERY_ID)?;
147 destination.close().map_err(|(_, error)| error)?;
148 temporary.as_file().sync_all()?;
149 if replace {
150 temporary.persist(path).map_err(|error| error.error)?;
151 } else {
133152 temporary
134153 .persist_noclobber(path)
135154 .map_err(|error| error.error)?;
136 File::open(parent)?.sync_all()?;
137 Ok(())
138155 }
156 File::open(parent)?.sync_all()?;
157 Ok(())
139158}
140159
141160fn 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(
143162 "SELECT count(*),coalesce(sum(length(data)),0) FROM assets",
144163 [],
145 |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)),
164 |row| Ok((row.get(0)?, row.get(1)?)),
146165 )?;
147 let working_bytes = crate::images::working_length(connection)?;
148 Ok(connection.query_row(
149 "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts),
150 (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts),
151 length(base) FROM replica WHERE id=1",
152 [],
153 |row| {
154 Ok(RecoverySummary {
155 queued_edits: unsigned(row, 0)?,
156 conflicts: unsigned(row, 1)?,
157 uncertain_edits: unsigned(row, 2)?,
158 published_receipts: unsigned(row, 3)?,
159 working_bytes,
160 remote_bytes: unsigned(row, 4)?,
161 cached_assets,
162 cached_asset_bytes,
163 })
164 },
165 )?)
166 let length = |image| -> Result<u64> {
167 Ok(base::stamp(connection, image)?.map_or(0, |stamp| stamp.length))
168 };
169 let (queued_edits, conflicts, uncertain_edits, published_receipts): (i64, i64, i64, i64) =
170 connection.query_row(
171 "SELECT (SELECT count(*) FROM edits),
172 (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE conflict IS NOT NULL),
173 (SELECT count(*) FROM edits JOIN batches ON batches.id=edits.batch WHERE attempted=1),
174 (SELECT count(*) FROM receipts)",
175 [],
176 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
177 )?;
178 Ok(RecoverySummary {
179 queued_edits: unsigned(queued_edits)?,
180 conflicts: unsigned(conflicts)?,
181 uncertain_edits: unsigned(uncertain_edits)?,
182 published_receipts: unsigned(published_receipts)?,
183 base_bytes: length(base::Image::Base)?,
184 remote_bytes: length(base::Image::Remote)?,
185 cached_assets: unsigned(cached_assets)?,
186 cached_asset_bytes: unsigned(cached_asset_bytes)?,
187 })
166188}
167189
168190fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {
......@@ -171,17 +193,7 @@ fn receipts(connection: &Connection) -> Result<BTreeMap<u64, ExGuid>> {
171193 let mut rows = statement.query([])?;
172194 let mut receipts = BTreeMap::new();
173195 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()?);
175197 }
176198 Ok(receipts)
177199}
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 @@
11use super::*;
22use rusqlite::Transaction;
33
4const CONFLICTS: &str = "CREATE TABLE conflicts (
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;";
4pub(crate) const VERSION: u32 = 15;
85
9const ASSETS: &str = "CREATE TABLE assets (
10 name TEXT PRIMARY KEY NOT NULL,
11 data BLOB NOT NULL,
12 sha256 BLOB NOT NULL CHECK(length(sha256)=32)
13) STRICT;";
6/// Tables a schema-14 cache shares with this one.
7pub(crate) const KEPT: &str = "
8 CREATE TABLE receipts (
9 edit_id INTEGER PRIMARY KEY CHECK(edit_id>0),
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
1549pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> {
16 transaction.execute_batch(
17 "CREATE TABLE edits (
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)?;
50 transaction.execute_batch(QUEUE)?;
51 transaction.execute_batch(KEPT)?;
3852 Ok(())
3953}
crates/notebook/src/session.rs+246-299
......@@ -1,15 +1,18 @@
11//! 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
44use crate::{
5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,
5 Conflict, ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result,
6 SyncWorker, discover,
67};
78use onestore::{
89 CommitError, ExGuid, PageCreation, PreparedEdit, RevisionIndex, Stamp, Store, Transaction,
9 document::Document, page::Page,
10 document::Document,
11 op::{Edit, Op, SectionOp},
12 page::Page,
1013};
1114use std::{
12 collections::{BTreeMap, VecDeque},
15 collections::BTreeMap,
1316 io,
1417 io::Write,
1518 path::{Path, PathBuf},
......@@ -18,7 +21,6 @@ use std::{
1821 atomic::{AtomicUsize, Ordering},
1922 mpsc::{self, Receiver, Sender},
2023 },
21 thread::{self, JoinHandle},
2224 time::Duration,
2325};
2426
......@@ -723,10 +725,15 @@ fn catalog_path(folder: &str, name: &str) -> String {
723725 }
724726}
725727
726/// What happened on the synchronization thread since the last poll.
728/// What happened to the section since the last poll.
727729#[derive(Debug)]
728730pub 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.
730737 Refreshed,
731738 /// A publication attempt finished with this durable state.
732739 Attempt { id: u64, status: EditStatus },
......@@ -736,7 +743,7 @@ pub enum Event {
736743 Failed(String),
737744}
738745
739/// The outcome of saving an edited page.
746/// The outcome of saving an edited page (old session API).
740747#[derive(Debug, PartialEq, Eq)]
741748pub enum Save {
742749 /// The model equals the stored page.
......@@ -755,31 +762,27 @@ pub struct QueuedEdit {
755762 pub status: EditStatus,
756763}
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.
759769pub struct Section {
760770 file: PathBuf,
761771 replica: Arc<Replica>,
762772 worker: Option<SyncWorker>,
763 events: Receiver<Event>,
764 saves: Option<Sender<PageSave>>,
765 saved: Receiver<(ExGuid, std::result::Result<Save, String>)>,
773 events: Mutex<Receiver<Event>>,
774 sender: Sender<Event>,
775 notify: Notify,
776 saved: Mutex<Receiver<(ExGuid, std::result::Result<Save, String>)>>,
777 saves: Sender<(ExGuid, std::result::Result<Save, String>)>,
766778 /// Saves queued and not yet reported.
767779 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,
777780}
778781
779782impl Section {
780783 /// 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 is
782 /// available.
784 /// file on first use and converting an older one. `notify` runs on a background thread
785 /// whenever an event is available.
783786 pub fn open(
784787 file: impl AsRef<Path>,
785788 cache: impl AsRef<Path>,
......@@ -847,58 +850,58 @@ impl Section {
847850 ) -> Result<Self> {
848851 let replica = Arc::new(replica);
849852 let (sender, events) = mpsc::channel();
850 let (saved_sender, saved) = mpsc::channel();
851 let notify = Arc::new(Mutex::new(notify));
852 let signal = move || {
853 let (saves, saved) = mpsc::channel();
854 let notify = Mutex::new(notify);
855 let notify: Notify = Arc::new(move || {
853856 if let Ok(notify) = notify.lock() {
854857 notify();
855858 }
856 };
857 let (saves, requests) = mpsc::channel();
858 let unsaved = Arc::new(AtomicUsize::new(0));
859 let saver = {
860 let (replica, signal) = (Arc::clone(&replica), signal.clone());
861 let unsaved = Arc::clone(&unsaved);
862 thread::Builder::new()
863 .name("onestore-save".into())
864 .spawn(move || {
865 save_pages(&replica, &requests, |outcome, count| {
866 unsaved.fetch_sub(count, Ordering::Release);
867 if saved_sender.send(outcome).is_ok() {
868 signal();
859 });
860 let worker = {
861 let (sender, notify) = (sender.clone(), Arc::clone(&notify));
862 replica.start_sync(Duration::from_secs(2), connect, move |result| {
863 let mut events = Vec::new();
864 match result {
865 Ok(synced) => {
866 if !synced.changed.is_empty() {
867 events.push(Event::Changed(synced.changed.clone()));
868 events.push(Event::Refreshed);
869869 }
870 });
871 })?
872 };
873 let worker = replica.start_sync(Duration::from_secs(2), connect, move |result| {
874 let event = match result {
875 Ok(None) => Event::Refreshed,
876 Ok(Some((id, status))) => Event::Attempt {
877 id: *id,
878 status: status.clone(),
879 },
880 Err(Error::RemoteIo(error)) => Event::Unreachable(match error.raw_os_error() {
881 Some(code) => io::Error::from_raw_os_error(code),
882 None => io::Error::new(error.kind(), error.to_string()),
883 }),
884 Err(Error::Remote(error)) => {
885 Event::Unreachable(io::Error::new(error.error.kind(), error.to_string()))
870 if let Some((id, status)) = &synced.edit {
871 events.push(Event::Attempt {
872 id: *id,
873 status: status.clone(),
874 });
875 }
876 }
877 Err(Error::RemoteIo(error)) => {
878 events.push(Event::Unreachable(match error.raw_os_error() {
879 Some(code) => io::Error::from_raw_os_error(code),
880 None => io::Error::new(error.kind(), error.to_string()),
881 }))
882 }
883 Err(Error::Remote(error)) => events.push(Event::Unreachable(io::Error::new(
884 error.error.kind(),
885 error.to_string(),
886 ))),
887 Err(error) => events.push(Event::Failed(error.to_string())),
886888 }
887 Err(error) => Event::Failed(error.to_string()),
888 };
889 if sender.send(event).is_ok() {
890 signal();
891 }
892 })?;
889 if !events.is_empty() && events.into_iter().all(|event| sender.send(event).is_ok())
890 {
891 notify();
892 }
893 })?
894 };
893895 Ok(Self {
894896 file,
895897 replica,
896898 worker: Some(worker),
897 events,
898 saves: Some(saves),
899 saved,
900 unsaved,
901 saver: Some(saver),
899 events: Mutex::new(events),
900 sender,
901 notify,
902 saved: Mutex::new(saved),
903 saves,
904 unsaved: Arc::new(AtomicUsize::new(0)),
902905 })
903906 }
904907
......@@ -910,111 +913,74 @@ impl Section {
910913 /// The section file identity, which internal links name as `section-id`
911914 /// (`onestore::page::link::internal_link`).
912915 pub fn identity(&self) -> Result<[u8; 16]> {
913 Ok(Store::parse(&self.replica.snapshot()?)?.header.file_id)
916 self.replica.identity()
914917 }
915918
916 /// Page spaces, titles and outline levels (1 at the top) in section order, from the
917 /// local working image.
918 pub fn pages(&self) -> Result<Vec<(ExGuid, String, u32)>> {
919 let snapshot = self.replica.snapshot()?;
920 let store = Store::parse(&snapshot)?;
921 let index = RevisionIndex::parse(&store)?;
922 let document = Document::parse(&index)?;
923 let pages = document.pages()?;
924 let mut spaces = std::collections::BTreeSet::new();
925 pages
926 .into_iter()
927 .map(|(space, page)| {
928 if !spaces.insert(space) {
929 return Err(onestore::Error {
930 offset: 0,
931 message: "Choose an object space containing one active page",
932 }
933 .into());
919 /// Applies an edit without waiting: it becomes durable on the section thread, which
920 /// reports a refusal as `Event::Rejected`. Edits apply in the order they arrive.
921 pub fn apply(&self, author: &str, edit: Edit) -> Result<()> {
922 let (sender, notify) = (self.sender.clone(), Arc::clone(&self.notify));
923 let root = self.replica.root;
924 let spaces = crate::queue::spaces(&edit, root);
925 self.replica.submit(
926 author,
927 edit,
928 Box::new(move |result| {
929 let event = match result {
930 Ok(_) => return,
931 Err(Error::Rejected(error)) => Event::Rejected {
932 spaces,
933 error: error.to_string(),
934 },
935 Err(error) => Event::Failed(error.to_string()),
936 };
937 if sender.send(event).is_ok() {
938 notify();
934939 }
935 let (title, level) = Page::heading(document.active(space)?, page);
936 Ok((space, title, level))
937 })
938 .collect()
940 }),
941 )
939942 }
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.
941951 pub fn page(&self, space: ExGuid) -> Result<Page> {
942952 self.replica.page(space)
943953 }
944954
945 /// Copies a page (usually from another section) to the end of this section as a
946 /// creation and a save queued like the user's own edits, under fresh identities.
947 /// Returns the new page's space. Content outside the model refuses to copy.
955 /// Copies a page (usually from another section) to the end of this section under
956 /// fresh identities, queued like the user's own edits. Returns the new page's space.
957 /// Content outside the model refuses to copy.
948958 pub fn import_page(&self, page: &Page, author: &str) -> Result<ExGuid> {
949 let copy = page.copy()?;
950959 let creation = PageCreation::new(None, Some(&page.title), author)?;
951 self.replica
952 .create_page(&self.replica.snapshot()?, &creation)?;
953960 let space = creation.space();
954 loop {
955 // The worker may publish the creation, and so replace the working image,
956 // between reading it and saving against it.
957 let source = self.replica.snapshot()?;
958 let mut after = Page::from_space(
959 &Document::parse(&RevisionIndex::parse(&Store::parse(&source)?)?)?,
960 space,
961 )?;
962 after
963 .objects
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();
961 self.replica.apply(
962 author,
963 Edit {
964 at: crate::now(),
965 ops: vec![Op::Section(SectionOp::Import {
966 creation,
967 page: page.copy()?,
968 })],
969 },
970 )?;
979971 Ok(space)
980972 }
981973
982974 /// Removes pages permanently, queued like the user's own edits (a move across
983975 /// sections is `import_page` there, then this here).
984 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<Option<u64>> {
985 let id = self
986 .replica
987 .delete_pages(&self.replica.snapshot()?, pages)?;
988 self.wake();
989 Ok(id)
990 }
991
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
976 pub fn delete_pages(&self, pages: &[ExGuid]) -> Result<u64> {
977 self.replica.apply(
978 "",
979 Edit {
980 at: crate::now(),
981 ops: vec![Op::Section(SectionOp::Delete(pages.to_vec()))],
982 },
983 )
1018984 }
1019985
1020986 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
......@@ -1025,78 +991,48 @@ impl Section {
1025991 self.replica.pending()
1026992 }
1027993
1028 /// Every queued edit with its state: pending, awaiting confirmation of a retained
1029 /// attempt, or a conflict awaiting review.
1030 pub fn queue(&self) -> Result<Vec<QueuedEdit>> {
1031 self.replica
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())
994 /// The unpublished batch the remote no longer accepts, if any. Show `remote_page`
995 /// against `page` for its space, then `resolve`.
996 pub fn conflict(&self) -> Result<Option<Conflict>> {
997 self.replica.conflict()
1054998 }
1055999
10561000 /// The page as last observed in the section file, for reviewing a conflict.
10571001 pub fn remote_page(&self, space: ExGuid) -> Result<Page> {
10581002 let snapshot = self.replica.remote_snapshot()?;
1059 let store = Store::parse(&snapshot)?;
1060 let index = RevisionIndex::parse(&store)?;
1061 Ok(Page::from_space(&Document::parse(&index)?, space)?)
1003 let arena = onestore::Arena::default();
1004 Ok(onestore::Section::open(&arena, snapshot)?.page(space)?)
10621005 }
10631006
1064 /// Resolves the oldest conflict with a page reviewed against `remote_page`; the
1065 /// reviewed model publishes as a whole, keeping the edit's id.
1066 pub fn review(&self, id: u64, after: &Page) -> Result<()> {
1067 let local = self.replica.snapshot()?;
1068 let remote = self.replica.remote_snapshot()?;
1069 self.replica.review_page(id, &local, &remote, after)?;
1070 self.wake();
1071 Ok(())
1007 /// Ends a conflict: `Mine` publishes the local page over the remote change, `Theirs`
1008 /// drops the local edits to the conflicted page (and its later ones).
1009 pub fn resolve(&self, id: u64, resolution: Resolution) -> Result<()> {
1010 self.replica.resolve(id, resolution)
10721011 }
10731012
1074 /// Retires an uncertain attempt after review, exporting the branch to `archive`
1075 /// first: `Some(page)` continues from the reviewed page against `remote_page`,
1076 /// `None` abandons the local branch. Neither claims the attempt was acknowledged.
1077 pub fn release(&self, id: u64, archive: impl AsRef<Path>, after: Option<&Page>) -> Result<()> {
1078 let local = self.replica.snapshot()?;
1079 let remote = self.replica.remote_snapshot()?;
1080 self.replica
1081 .release_attempt(id, &local, &remote, archive.as_ref(), after)?;
1082 self.wake();
1083 Ok(())
1013 /// Retires an uncertain attempt after review, exporting the queue to `archive` first:
1014 /// `Mine` publishes the local edits again, `Theirs` abandons them. Neither claims the
1015 /// attempt was acknowledged.
1016 pub fn release(
1017 &self,
1018 id: u64,
1019 archive: impl AsRef<Path>,
1020 resolution: Resolution,
1021 ) -> Result<()> {
1022 self.replica.release(id, archive.as_ref(), resolution)
10841023 }
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.
10871026 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
10881027 self.replica.export_recovery(path)
10891028 }
10901029
10911030 /// Events since the last poll, oldest first.
10921031 pub fn events(&self) -> Vec<Event> {
1093 self.events.try_iter().collect()
1094 }
1095
1096 /// Outcomes of queued saves since the last poll, oldest first; `notify` runs for each.
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()
1032 self.events
1033 .lock()
1034 .map(|events| events.try_iter().collect())
1035 .unwrap_or_default()
11001036 }
11011037
11021038 /// Requests a synchronization attempt now.
......@@ -1115,107 +1051,118 @@ impl Section {
11151051 /// Dropping instead requests cancellation without waiting; the worker retains
11161052 /// cache ownership until that operation finishes. Remote calls must be bounded.
11171053 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 }
11241054 match self.worker.take() {
11251055 Some(worker) => worker.stop(),
11261056 None => Ok(()),
11271057 }
11281058 }
1129}
11301059
1131/// Saves `after` unless the stored page no longer matches `before`, returning the working
1132/// image the save leaves. `known` is the image a previous save of `before` left: while the
1133/// working image equals it the page is current unread, and otherwise the page `known`
1134/// stores stands in for `before`.
1135fn save(
1136 replica: &Replica,
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)?)?,
1060 // Old whole-page session API, kept until the application emits ops.
1061
1062 /// Saves an edited page. `before` is the model the edit started from; a stored page
1063 /// that differs from it means the section changed underneath the editor.
1064 pub fn save(&self, space: ExGuid, before: &Page, after: &Page, author: &str) -> Result<Save> {
1065 let outcome = self.replica.ask(|reply| crate::working::Request::Save {
11471066 space,
1148 )?)
1149 };
1150 loop {
1151 let snapshot = replica.snapshot()?;
1152 if known != Some(snapshot.as_slice()) {
1153 let current = stored(&snapshot)?;
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 }
1067 before: before.clone(),
1068 after: after.clone(),
1069 author: author.to_owned(),
1070 reply,
1071 })?;
1072 Ok(outcome.unwrap_or(Save::Unchanged))
11681073 }
1169}
11701074
1171/// Runs queued saves until the section drops its sender, folding each run of saves that
1172/// continue one another into one.
1173fn save_pages(
1174 replica: &Replica,
1175 requests: &Receiver<PageSave>,
1176 report: impl Fn((ExGuid, std::result::Result<Save, String>), usize),
1177) {
1178 let mut queue = VecDeque::new();
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,
1075 /// `save` on the section thread, returning at once; `saved` reports the outcome.
1076 /// Saves queue in order, and one whose `before` is the previous save's `after`
1077 /// continues it: pass each save's `after` as the next one's `before`.
1078 pub fn queue_save(&self, space: ExGuid, before: Page, after: Page, author: &str) -> Result<()> {
1079 let (saves, notify, unsaved) = (
1080 self.saves.clone(),
1081 Arc::clone(&self.notify),
1082 Arc::clone(&self.unsaved),
12081083 );
1209 let save = match result {
1210 Ok((save, image)) => {
1211 if save != Save::Stale {
1212 last = Some((request.space, request.after, image));
1084 self.unsaved.fetch_add(1, Ordering::Relaxed);
1085 self.replica.send(crate::working::Request::Save {
1086 space,
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();
12131099 }
1214 Ok(save)
1215 }
1216 Err(error) => Err(error.to_string()),
1217 };
1218 report((request.space, save), count);
1100 }),
1101 })
1102 }
1103
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()))
12191166 }
12201167}
12211168
crates/notebook/src/sync.rs+515-490
......@@ -1,9 +1,10 @@
11use super::*;
2use onestore::{CommitError, CommitState, Stamp, Transaction};
2use crate::working::{Rebased, Request, Resolve, Sealed};
3use onestore::{CommitError, CommitState, RevisionIndex, Stamp, Store, Transaction};
34use rusqlite::OptionalExtension;
45use std::{
56 collections::BTreeMap,
6 sync::{MutexGuard, TryLockError, atomic::Ordering},
7 sync::{MutexGuard, TryLockError},
78};
89
910/// A single remote file with fresh reads and native-compatible guarded publication.
......@@ -25,14 +26,32 @@ pub trait Remote {
2526pub enum ConflictKind {
2627 /// The edited page or a selected page no longer exists remotely.
2728 TargetUnavailable = 0,
28 /// The remote image no longer accepts the intent's publication.
29 /// The remote section no longer accepts the edit.
2930 UnsupportedEdit = 1,
3031 /// Page order or indentation changed remotely in a way the batch cannot absorb.
3132 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.
3334 ContentChanged = 3,
3435}
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
3655#[derive(Debug, Clone, PartialEq, Eq)]
3756pub enum EditStatus {
3857 Pending,
......@@ -46,499 +65,534 @@ pub enum EditStatus {
4665 revision: ExGuid,
4766 },
4867 /// 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.
5069 Archived {
5170 archive: String,
5271 },
5372}
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
55181impl Replica {
56182 /// Returns a durable receipt or the persisted state of a locally acknowledged edit.
57183 pub fn status(&self, id: u64) -> Result<Option<EditStatus>> {
58 let connection = self
59 .connection
60 .lock()
61 .map_err(|_| io::Error::other("Cache owner panicked"))?;
62 status(&connection, id)
184 status(&*self.lock()?, id)
63185 }
64186
65 /// The last observed remote image, retained alongside the complete local working image.
66 /// Observation alone does not acknowledge any pending edit's remote durability.
187 /// The last observed remote image. Observation alone does not acknowledge any pending
188 /// edit's remote durability.
67189 pub fn remote_snapshot(&self) -> Result<Vec<u8>> {
68 let connection = self
69 .connection
70 .lock()
71 .map_err(|_| io::Error::other("Cache owner panicked"))?;
72 images::base(&connection)
190 let connection = self.lock()?;
191 let image = match base::read(&connection, base::Image::Remote)? {
192 Some(image) => Some(image),
193 None => base::read(&connection, base::Image::Base)?,
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()
73218 }
74219
75 /// Reconciles one pending edit, or refreshes the working image when the queue is empty.
76 /// Network I/O holds synchronization ownership without holding the cache mutex.
77 /// Uncertain edits are never replayed; retired formatting may confirm its complete observed effect.
78 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Option<(u64, EditStatus)>> {
220 /// Publishes the oldest unpublished batch, rebasing the queue first when the remote
221 /// changed. Reads the remote image only when its stamp moved; network I/O holds
222 /// synchronization ownership without holding the cache mutex. Uncertain attempts are
223 /// never replayed.
224 pub fn sync_once(&self, remote: &mut impl Remote) -> Result<Synced> {
79225 let _owner = self.sync_owner()?;
80 struct Step<'a>(&'a Replica);
81 impl Drop for Step<'_> {
82 fn drop(&mut self) {
83 self.0.in_flight.store(0, Ordering::Release);
84 }
226 let state = state(&*self.lock()?)?;
227 let observed = remote.stamp().map_err(Error::RemoteIo)?;
228 if let (Some(observed), Some(blocked)) = (&observed, &state.blocked)
229 && Some(observed) == state.remote.as_ref().or(Some(&state.base))
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 });
85240 }
86 let _step = Step(self);
87 let base = self.remote_snapshot()?;
88 let changed = match remote.stamp().map_err(Error::RemoteIo)? {
89 Some(stamp) if stamp == Stamp::of(&base)? => None,
90 _ => Some(remote.read().map_err(Error::RemoteIo)?).filter(|read| *read != base),
91 };
92 // An unchanged remote is the base image, validated when it was stored.
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());
241 let image = match observed {
242 Some(observed) if observed == state.base => None,
243 _ => {
244 let image = remote.read().map_err(Error::RemoteIo)?;
245 // A read image is compared whole: a stamp stands for it only when read alone.
246 let base = base::read(&*self.lock()?, base::Image::Base)?;
247 (base.as_deref() != Some(&image[..])).then_some(image)
114248 }
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)
140249 };
141 if let Some(encoded) = attempted {
142 let revisions = attempted_revisions(&encoded, intent.space)?;
143 let mut revision = revisions[&intent.space];
144 let store = Store::parse(&snapshot)?;
145 let index = RevisionIndex::parse(&store)?;
146 if !revisions.iter().all(|(space, revision)| {
147 index
148 .spaces
149 .get(space)
150 .is_some_and(|space| space.revisions.contains_key(revision))
151 }) {
152 // A retired attempt confirms only when its complete effect is observed.
153 let satisfied = match &intent.operation {
154 Operation::Page(edit) if revisions.len() == 1 => {
155 page_of(&snapshot, intent.space)?.is_some_and(|page| page == edit.after)
250 let mut changed = Vec::new();
251 if let Some(image) = image {
252 if let Some(Blocked {
253 batch,
254 attempted: true,
255 revisions,
256 ..
257 }) = &state.blocked
258 {
259 let id = newest(&*self.lock()?, *batch)?;
260 let revisions = revisions.clone().unwrap_or_default();
261 let store = Store::parse(&image)?;
262 if !store.checksum_mismatches.is_empty() {
263 return Err(io::Error::new(
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 });
156303 }
157 _ => false,
304 Some(
305 revisions
306 .keys()
307 .filter_map(|space| Some((*space, index.active(*space).ok()?)))
308 .collect(),
309 )
158310 };
159 if !satisfied {
160 return Ok(Some((
161 intent.id,
162 EditStatus::AwaitingConfirmation { revision },
163 )));
311 if let Err(error) = remote.confirm(&image) {
312 if error.state == CommitState::Committed {
313 self.acknowledge(*batch, sealed.as_ref(), receipts.as_ref())?;
314 }
315 return Err(error.into());
164316 }
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 });
166324 }
167 if let Err(error) = remote.confirm(&snapshot) {
168 if error.state == CommitState::Committed {
169 self.acknowledge(intent.id, revision, &snapshot)?;
325 match self.rebase(Some(image), None)? {
326 Ok(spaces) => changed = spaces,
327 Err(conflict) => {
328 return Ok(Synced {
329 edit: Some((conflict.id, EditStatus::Conflict(conflict.kind))),
330 changed,
331 });
170332 }
171 return Err(error.into());
172333 }
173 self.acknowledge(intent.id, revision, &snapshot)?;
174 return Ok(Some((intent.id, EditStatus::Published { revision })));
334 } else if let Some(blocked) = &state.blocked {
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());
175364 }
176 let candidate = match &intent.operation {
177 Operation::Page(edit) => page_of(&snapshot, intent.space)?
178 .ok_or(ConflictKind::TargetUnavailable)
179 .and_then(|current| {
180 let target = if current == edit.before {
181 edit.after.clone()
182 } else {
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)
365 // The cache lock is released before the section thread, which takes it, seals.
366 let waiting = working::sealed(&*self.lock()?)?;
367 let sealed = match waiting {
368 Some(sealed) => {
369 if sealed.transaction.is_some() {
370 self.lock()?
371 .execute("UPDATE batches SET attempted=1 WHERE id=?1", [sealed.batch])?;
211372 }
373 Some(sealed)
212374 }
375 None => self.ask(|reply| Request::Seal { reply })?,
213376 };
214 let prepared = match candidate {
215 Ok(prepared) => prepared,
216 Err(kind) => {
217 let connection = self
218 .connection
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 }
377 let Some(Sealed { batch, transaction }) = sealed else {
378 return Ok(Synced {
379 edit: None,
380 changed,
381 });
224382 };
225 if prepared.as_bytes() == snapshot {
226 let store = Store::parse(&snapshot)?;
227 let index = RevisionIndex::parse(&store)?;
228 let revision = index.active(intent.space)?;
229 if let Err(error) = remote.confirm(&snapshot) {
383 let id = newest(&*self.lock()?, batch)?;
384 let Some(transaction) = transaction else {
385 // Edits that changed nothing are published once the remote's image is durable.
386 let base = base::read(&*self.lock()?, base::Image::Base)?.unwrap_or_default();
387 if let Err(error) = remote.confirm(&base) {
230388 if error.state == CommitState::Committed {
231 self.acknowledge(intent.id, revision, &snapshot)?;
389 self.acknowledge(batch, None, None)?;
232390 }
233391 return Err(error.into());
234392 }
235 self.acknowledge(intent.id, revision, &snapshot)?;
236 return Ok(Some((intent.id, EditStatus::Published { revision })));
237 }
238 // Once acknowledged this image is the base, which polls trust without validating.
239 validate(prepared.as_bytes())?;
240 let store = Store::parse(prepared.as_bytes())?;
241 let index = RevisionIndex::parse(&store)?;
242 let revision = index.active(intent.space)?;
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()) {
393 self.acknowledge(batch, None, None)?;
394 let revision = self.receipt(id)?;
395 return Ok(Synced {
396 edit: Some((id, EditStatus::Published { revision })),
397 changed,
398 });
399 };
400 match remote.publish(&transaction) {
281401 Ok(()) => {}
282402 Err(error) if error.state == CommitState::NotCommitted => {
283 let connection = self
284 .connection
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 )?;
403 self.lock()?
404 .execute("UPDATE batches SET attempted=0 WHERE id=?1", [batch])?;
291405 return Err(error.into());
292406 }
293407 Err(error) if error.state == CommitState::Committed => {
294 self.acknowledge(intent.id, revision, prepared.as_bytes())?;
408 self.acknowledge(batch, Some(&transaction), None)?;
295409 return Err(error.into());
296410 }
297411 Err(error) => return Err(error.into()),
298412 }
299 self.acknowledge(intent.id, revision, prepared.as_bytes())?;
300 Ok(Some((intent.id, EditStatus::Published { revision })))
413 self.acknowledge(batch, Some(&transaction), None)?;
414 let revision = self.receipt(id)?;
415 Ok(Synced {
416 edit: Some((id, EditStatus::Published { revision })),
417 changed,
418 })
301419 }
302420
303 /// Repositions an unattempted page-creation conflict, retaining dependent object identities.
304 pub fn rebase_page_creation_conflict(
421 /// Whether `image` holds every page in `revisions` as the base with `sealed` has it.
422 fn holds(
305423 &self,
306 id: u64,
307 local: &[u8],
308 remote: &[u8],
309 before: Option<ExGuid>,
310 ) -> Result<()> {
311 self.resolve_conflict(id, local, remote, |intent| {
312 let Operation::CreatePage(page) = intent.operation else {
313 return Err(io::Error::new(
314 io::ErrorKind::InvalidInput,
315 "Select a page-creation conflict",
316 )
317 .into());
318 };
319 let page = page.reposition(before)?;
320 PreparedEdit::create_page(remote, &page)?;
321 Ok(Operation::CreatePage(page))
322 })
424 image: &[u8],
425 sealed: Option<&Transaction>,
426 revisions: &BTreeMap<ExGuid, ExGuid>,
427 ) -> Result<bool> {
428 let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| {
429 io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image")
430 })?;
431 let (local, remote) = (onestore::Arena::default(), onestore::Arena::default());
432 let mut local = onestore::Section::open(&local, base)?;
433 if let Some(sealed) = sealed {
434 local.replay(sealed)?;
435 }
436 let remote = onestore::Section::open(&remote, image.to_vec())?;
437 Ok(revisions.keys().all(
438 |space| matches!((local.page(*space), remote.page(*space)), (Ok(a), Ok(b)) if a == b),
439 ))
323440 }
324441
325 /// Reviews a page batch against both cache images, retaining its page and series identities.
326 pub fn rebase_pages_conflict(
442 /// Asks the section thread to replay the queue on `image`; `Err` is the conflict found.
443 fn rebase(
327444 &self,
328 id: u64,
329 local: &[u8],
330 remote: &[u8],
331 edits: &[onestore::PageEdit],
332 ) -> Result<()> {
333 self.resolve_conflict(id, local, remote, |intent| {
334 let Operation::Pages(batch) = intent.operation else {
335 return Err(io::Error::new(
336 io::ErrorKind::InvalidInput,
337 "Select a page movement or indentation conflict",
338 )
339 .into());
340 };
341 Ok(Operation::Pages(batch.review(
342 remote,
343 intent.space,
344 edits,
345 )?))
346 })
445 image: Option<Vec<u8>>,
446 resolve: Option<Resolve>,
447 ) -> Result<std::result::Result<Vec<ExGuid>, Conflict>> {
448 Ok(
449 match self.ask(|reply| Request::Rebase {
450 image,
451 resolve,
452 reply,
453 })? {
454 Rebased::Applied { changed } => Ok(changed),
455 Rebased::Conflict { id, space, kind } => Err(Conflict { id, space, kind }),
456 },
457 )
347458 }
348459
349 /// Replaces a conflicting page save with a model reviewed against the remote page.
350 /// Both supplied images must match `snapshot` and `remote_snapshot`; the reviewed
351 /// model must publish against the remote image, and its author is retained.
352 pub fn review_page(&self, id: u64, local: &[u8], remote: &[u8], after: &Page) -> Result<()> {
353 self.resolve_conflict(id, local, remote, |intent| {
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 })
460 fn receipt(&self, id: u64) -> Result<ExGuid> {
461 match status(&*self.lock()?, id)? {
462 Some(EditStatus::Published { revision }) => Ok(revision),
463 _ => Err(io::Error::other("A published edit has no receipt").into()),
464 }
374465 }
375466
376 /// Retires the oldest edit's uncertain attempt after review. The branch is first
377 /// exported to `archive` (a new file), which is the record: no receipt is written.
378 /// `Some(after)` continues from the reviewed page as a fresh intent against the
379 /// current remote page, keeping the edit's id and its dependents; `None` abandons
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(
467 /// Records a published batch: the base becomes the image it left, each of its edits
468 /// gets a receipt naming the revisions its seal stored, or `revisions`, and it leaves
469 /// the queue.
470 fn acknowledge(
383471 &self,
384 id: u64,
385 local: &[u8],
386 remote: &[u8],
387 archive: &Path,
388 after: Option<&Page>,
472 batch: i64,
473 transaction: Option<&Transaction>,
474 revisions: Option<&BTreeMap<ExGuid, ExGuid>>,
389475 ) -> 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<()> {
390512 let owner = self.sync_owner()?;
391 let intent = self
392 .pending()?
393 .into_iter()
394 .next()
395 .filter(|intent| intent.id == id)
396 .ok_or_else(|| {
397 io::Error::new(
513 let (first, space) = {
514 let connection = self.lock()?;
515 let row: Option<(i64, String)> = connection
516 .query_row(
517 "SELECT (SELECT min(id) FROM edits WHERE batch=batches.id), space FROM batches
518 WHERE conflict IS NOT NULL AND id=(SELECT batch FROM edits WHERE id=?1)",
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(
398525 io::ErrorKind::InvalidInput,
399 "Only the oldest edit can hold an attempt",
526 "The edit is not in conflict",
400527 )
401 })?;
402 let continued = match after {
403 Some(after) => {
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,
528 .into());
529 };
530 (unsigned(first)?, space.parse()?)
425531 };
426 let mut connection = self
427 .connection
428 .lock()
429 .map_err(|_| io::Error::other("Cache owner panicked"))?;
430 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
431 let (base, working) = images::both(&transaction)?;
432 if working != local || base != remote {
433 return Err(io::Error::new(
434 io::ErrorKind::ResourceBusy,
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),
532 // A conflict on another page is recorded like any other.
533 let _ = self.rebase(
534 None,
535 Some(Resolve {
536 first,
537 space,
538 keep,
539 page,
540 }),
444541 )?;
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);
482542 drop(owner);
483543 self.wake_sync();
484544 Ok(())
485545 }
486546
487 fn resolve_conflict(
488 &self,
489 id: u64,
490 local: &[u8],
491 remote: &[u8],
492 update: impl FnOnce(PendingEdit) -> Result<Operation>,
493 ) -> Result<()> {
547 /// Retires the uncertain attempt of the batch holding edit `id` after review. The
548 /// queue is first exported to `archive` (a new file), which is the record: no receipt
549 /// is written. `Mine` publishes the batch again against the current remote; `Theirs`
550 /// abandons every unpublished edit, which become `Archived`, and the local pages return
551 /// to the remote's.
552 pub fn release(&self, id: u64, archive: &Path, resolution: Resolution) -> Result<()> {
494553 let owner = self.sync_owner()?;
495 let intent = self
496 .pending()?
497 .into_iter()
498 .next()
499 .filter(|intent| intent.id == id)
500 .ok_or_else(|| {
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",
554 let batch: Option<i64> = self
555 .lock()?
556 .query_row(
557 "SELECT id FROM batches WHERE attempted=1 AND id=(SELECT batch FROM edits WHERE id=?1)",
558 [signed(id)?],
559 |row| row.get(0),
517560 )
518 .into());
519 }
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 {
561 .optional()?;
562 let Some(batch) = batch else {
526563 return Err(io::Error::new(
527564 io::ErrorKind::InvalidInput,
528 "The edit is not an unattempted conflict",
565 "The edit has no uncertain attempt",
529566 )
530567 .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 })?;
531595 }
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);
542596 drop(owner);
543597 self.wake_sync();
544598 Ok(())
......@@ -556,34 +610,25 @@ impl Replica {
556610 })?)
557611 }
558612
559 fn acknowledge(&self, id: u64, revision: ExGuid, snapshot: &[u8]) -> Result<()> {
560 let id = i64::try_from(id).map_err(io::Error::other)?;
561 let mut connection = self
562 .connection
563 .lock()
564 .map_err(|_| io::Error::other("Cache owner panicked"))?;
565 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
566 transaction.execute(
567 "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)",
568 params![id, revision.to_string()],
569 )?;
570 transaction.execute("DELETE FROM edits WHERE id=?1", [id])?;
571 let (base, working) = images::both(&transaction)?;
572 let pending: bool =
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(())
613 /// Whether nothing is queued and the remote still has the base's stamp, or the queue
614 /// is blocked on a remote that has not changed since; reads neither image and does not
615 /// lock the cache during remote I/O.
616 pub(crate) fn settled(&self, remote: &mut impl Remote) -> Result<bool> {
617 let state = state(&*self.lock()?)?;
618 let expected = match &state.blocked {
619 Some(_) => state.remote.unwrap_or(state.base),
620 None if !state.queued => state.base,
621 None => return Ok(false),
622 };
623 let Some(stamp) = remote.stamp().map_err(Error::RemoteIo)? else {
624 return Ok(false);
625 };
626 Ok(stamp == expected)
582627 }
583628}
584629
585630pub(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)?;
587632 if let Some(revision) = connection
588633 .query_row(
589634 "SELECT revision FROM receipts WHERE edit_id=?1",
......@@ -606,46 +651,26 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result<Option<EditStat
606651 {
607652 return Ok(Some(EditStatus::Archived { archive }));
608653 }
609 let record: Option<(Option<String>, Option<i64>, String)> = connection.query_row(
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()?;
654 let record: Option<(bool, Option<i64>, Option<String>, String)> = connection
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()?;
611662 Ok(match record {
612663 None => None,
613 Some((Some(revision), _, space)) => Some(EditStatus::AwaitingConfirmation {
614 revision: {
615 let space = space.parse()?;
616 attempted_revisions(&revision, space)?[&space]
617 },
618 }),
619 Some((None, Some(kind), _)) => Some(EditStatus::Conflict(match kind {
620 0 => ConflictKind::TargetUnavailable,
621 1 => ConflictKind::UnsupportedEdit,
622 2 => ConflictKind::StructureChanged,
623 3 => ConflictKind::ContentChanged,
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),
664 Some((true, _, revisions, edit)) => {
665 let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?;
666 let edit: onestore::op::Edit = serde_json::from_str(&edit)
667 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
668 let revision = revision_of(&edit, &revisions)?;
669 Some(EditStatus::AwaitingConfirmation { revision })
670 }
671 Some((false, Some(kind), ..)) => {
672 Some(EditStatus::Conflict(ConflictKind::from_stored(kind)?))
673 }
674 Some((false, None, ..)) => Some(EditStatus::Pending),
633675 })
634676}
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 {
6161impl Replica {
6262 /// Starts one worker, reconnecting through `connect` after transport failures.
6363 /// 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.
6566 /// Contended operations returning `NotCommitted` also back off by up to one second.
6667 /// `observe` runs on the worker after each attempt, including connection errors.
6768 /// Cache/document errors stop the worker; inspect them through `observe` or `stop`.
......@@ -75,7 +76,7 @@ impl Replica {
7576 where
7677 R: Remote + 'static,
7778 F: FnMut() -> io::Result<R> + Send + 'static,
78 O: FnMut(&Result<Option<(u64, EditStatus)>>) + Send + 'static,
79 O: FnMut(&Result<Synced>) + Send + 'static,
7980 {
8081 if interval.is_zero() || Instant::now().checked_add(interval).is_none() {
8182 return Err(io::Error::new(
......@@ -103,18 +104,19 @@ impl Replica {
103104 let mut remote: Option<R> = None;
104105 let jitter = RandomState::new();
105106 let mut contention = 0_u32;
106 // The first attempt always runs, so `observe` hears once that the remote is
107 // reachable.
108 let mut attempted = false;
107 // The first step, and the first after a failure, always runs, so `observe`
108 // hears that the remote is reachable and what state the queue is in.
109 let mut reported = false;
109110 while !worker_signal.stopped.load(Ordering::Acquire) {
110111 let result = match remote.as_mut() {
111112 Some(remote) => {
112 if attempted && replica.settled(remote).unwrap_or(false) {
113 if reported && replica.settled(remote).unwrap_or(false) {
113114 let _ = receiver.recv_timeout(interval);
114115 continue;
115116 }
116 attempted = true;
117 replica.sync_once(remote)
117 let result = replica.sync_once(remote);
118 reported = result.is_ok();
119 result
118120 }
119121 None => match connect() {
120122 Ok(connected) => {
......@@ -126,11 +128,14 @@ impl Replica {
126128 };
127129 observe(&result);
128130 match result {
129 Ok(Some((_, EditStatus::Published { .. }))) => {
131 Ok(Synced {
132 edit: Some((_, EditStatus::Published { .. })),
133 ..
134 }) => {
130135 contention = 0;
131136 continue;
132137 }
133 Ok(None) => contention = 0,
138 Ok(Synced { edit: None, .. }) => contention = 0,
134139 Ok(_) => {}
135140 Err(Error::Remote(onestore::CommitError {
136141 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 @@
1use notebook::{EditStatus, Error, Operation, Recovery, Replica};
1use notebook::{EditStatus, Error, Recovery, Replica};
22use onestore::{
33 ExGuid, RevisionIndex, Store,
44 document::Document,
......@@ -13,6 +13,8 @@ use std::{
1313
1414#[path = "support/model_ops.rs"]
1515mod model_ops;
16#[path = "support/server.rs"]
17mod server;
1618
1719const FIXTURE: &str = "../../corpus/native-external-assets/notebook";
1820
......@@ -54,10 +56,7 @@ fn queued_cache(root: &Path) -> Replica {
5456 .unwrap()
5557 .unwrap();
5658 let page = onestore::PageCreation::new(None, Some("Queued page"), "Author").unwrap();
57 cache
58 .create_page(&cache.snapshot().unwrap(), &page)
59 .unwrap()
60 .unwrap();
59 server::section_op(&cache, onestore::op::SectionOp::Create(page));
6160 cache
6261}
6362
......@@ -118,7 +117,10 @@ fn downloaded_media_survives_reopen_and_recovery_with_the_queue_intact() {
118117 (summary.cached_assets, summary.cached_asset_bytes),
119118 (2, 1024)
120119 );
121 assert_eq!(cache.snapshot().unwrap(), working);
120 assert_eq!(
121 server::pages(&cache.snapshot().unwrap()),
122 server::pages(&working)
123 );
122124 assert_eq!(cache.remote_snapshot().unwrap(), remote);
123125 assert_eq!(cache.pending().unwrap(), pending);
124126 assert_eq!(cache.status(1).unwrap(), uncertain);
......@@ -234,10 +236,11 @@ fn download_network_wait_does_not_block_local_edits() {
234236 release.send(()).unwrap();
235237 assert_eq!(download.join().unwrap(), bytes);
236238 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
237 let Operation::Page(intent) = &cache.pending().unwrap()[2].operation else {
238 panic!()
239 };
240 assert_eq!(intent.text_change().unwrap().3, "during download ");
239 assert!(matches!(
240 &cache.pending().unwrap()[2].edit.ops[..],
241 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { with, .. }, .. }]
242 if with == "during download "
243 ));
241244 });
242245}
243246
......@@ -284,7 +287,7 @@ fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() {
284287 panic!("Unexpected enumeration")
285288 }
286289 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);
288291 Ok(payload(1024).1)
289292 }
290293 }
crates/notebook/tests/cache.rs+230-270
......@@ -1,7 +1,8 @@
1use notebook::{Error, Operation, Replica};
1use notebook::{Error, Replica};
22use onestore::{
33 ExGuid, RevisionIndex, Store,
44 document::{Document, Kind},
5 op::{Edit, Op, PageOp, SectionOp},
56 page::{Outline, PageObject},
67};
78use std::{
......@@ -14,6 +15,8 @@ use std::{
1415
1516#[path = "support/model_ops.rs"]
1617mod model_ops;
18#[path = "support/server.rs"]
19mod server;
1720
1821fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
1922 let store = Store::parse(source).unwrap();
......@@ -36,25 +39,23 @@ fn target(source: &[u8]) -> (ExGuid, ExGuid, String) {
3639 .unwrap()
3740}
3841
39fn text_of(page: &onestore::page::Page, text: ExGuid) -> String {
40 model_ops::paragraph_with(page, text)
41 .unwrap()
42 .text()
43 .unwrap()
44 .text
45 .text()
46 .to_owned()
47}
48
49fn intent(replica: &Replica, at: usize) -> notebook::PageIntent {
50 match &replica.pending().unwrap()[at].operation {
51 Operation::Page(intent) => intent.clone(),
52 other => panic!("{other:?}"),
42/// An edit typing `with` at the start of a text.
43fn typed(space: ExGuid, text: ExGuid, with: &str) -> Edit {
44 Edit {
45 at: model_ops::now(),
46 ops: vec![Op::Page {
47 space,
48 op: PageOp::Text {
49 text,
50 range: 0..0,
51 with: with.into(),
52 },
53 }],
5354 }
5455}
5556
5657#[test]
57fn cache_reopen_preserves_exact_images_and_intents() {
58fn cache_reopen_preserves_the_base_and_queued_edits() {
5859 let dir = tempfile::tempdir().unwrap();
5960 let path = dir.path().join("section.sqlite");
6061 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
......@@ -72,77 +73,102 @@ fn cache_reopen_preserves_exact_images_and_intents() {
7273 })
7374 .unwrap()
7475 .unwrap();
75 let edited = replica.snapshot().unwrap();
76 assert_eq!(target(&edited).2, "café 🐈 日本語");
77 let intents = replica.pending().unwrap();
78 assert_eq!(intents.len(), 1);
79 assert_eq!((intents[0].id, intents[0].space), (first, sid));
80 let first_intent = intent(&replica, 0);
76 assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語");
77 let edits = replica.pending().unwrap();
78 assert_eq!(edits.len(), 1);
8179 assert_eq!(
82 first_intent.text_change(),
83 Some((oid, "café 🦀".into(), 5..7, "🐈 日本語".into()))
80 (edits[0].id, edits[0].author.as_str()),
81 (first, model_ops::AUTHOR)
8482 );
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());
8696 drop(replica);
97 assert!(
98 !beside("-wal").exists(),
99 "closing checkpoints and removes the log"
100 );
87101 let replica = Replica::open(&path).unwrap();
88 assert_eq!(replica.snapshot().unwrap(), edited);
89 assert_eq!(replica.pending().unwrap(), intents);
90 let second = model_ops::save(&replica, oid, |page| {
91 model_ops::replace_text(page, oid, 0..0, "Recovered ")
92 })
93 .unwrap()
94 .unwrap();
95 assert_eq!(second, first, "an unattempted save is replaced in place");
96 let coalesced = intent(&replica, 0);
97 assert_eq!(coalesced.before, first_intent.before);
98 assert_eq!(text_of(&coalesced.after, oid), "Recovered café 🐈 日本語");
102 assert_eq!(target(&replica.snapshot().unwrap()).2, "café 🐈 日本語");
103 assert_eq!(replica.pending().unwrap(), edits);
104 let second = replica
105 .apply(model_ops::AUTHOR, typed(sid, oid, "Recovered "))
106 .unwrap();
107 assert!(second > first);
99108 assert_eq!(
100109 target(&replica.snapshot().unwrap()).2,
101110 "Recovered café 🐈 日本語"
102111 );
112 assert_eq!(replica.pending().unwrap().len(), 2);
103113}
104114
105115#[test]
106fn failed_saves_preserve_both_intent_queue_and_working_image() {
116fn refused_edits_and_failed_writes_leave_the_queue_as_it_was() {
107117 let dir = tempfile::tempdir().unwrap();
108118 let path = dir.path().join("section.sqlite");
109119 let source = onestore::create_section("section.one", "café 🦀", "Fixture").unwrap();
110120 let replica = Replica::create(&path, &source).unwrap();
111121 let (sid, oid, _) = target(&source);
112 let (_, mut page) = model_ops::locate(&source, oid);
113 let mut empty = Outline {
122 let empty = Outline {
114123 id: onestore::page::text::new_id().unwrap(),
115124 title: false,
116125 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 },
118131 indents: Vec::new(),
119132 paragraphs: Vec::new(),
120133 unsupported: Vec::new(),
121134 };
122 empty.layout.x = Some(72.0);
123 empty.layout.y = Some(400.0);
124 page.objects.push(PageObject::Outline(empty));
125 assert!(replica.save(&source, sid, &page, "Author").is_err());
126 let (_, mut page) = model_ops::locate(&source, oid);
127 model_ops::replace_text(&mut page, oid, 0..0, "Elsewhere ");
128 let foreign = ExGuid::default();
129 assert!(replica.save(&source, foreign, &page, "Author").is_err());
135 let refused = Edit {
136 at: model_ops::now(),
137 ops: vec![
138 Op::Page {
139 space: sid,
140 op: PageOp::Text {
141 text: oid,
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 ));
130163 assert_eq!(replica.snapshot().unwrap(), source);
131164 assert!(replica.pending().unwrap().is_empty());
132165 drop(replica);
133166 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();
135168 drop(connection);
136169 let replica = Replica::open(&path).unwrap();
137 assert!(matches!(
138 model_ops::save(&replica, oid, |page| model_ops::replace_text(
139 page,
140 oid,
141 0..0,
142 "lost? "
143 )),
144 Err(Error::Database(_))
145 ));
170 assert!(replica.apply("Author", typed(sid, oid, "lost? ")).is_err());
171 assert_eq!(replica.snapshot().unwrap(), source);
146172 drop(replica);
147173 let replica = Replica::open(&path).unwrap();
148174 assert_eq!(replica.snapshot().unwrap(), source);
......@@ -155,7 +181,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
155181
156182 let directory = tempfile::tempdir().unwrap();
157183 let source = onestore::create_section("recovery.one", "base", "Fixture").unwrap();
158 let (_, object, _) = target(&source);
184 let (space, object, _) = target(&source);
159185 let replica = Replica::create(directory.path().join("live.sqlite"), &source).unwrap();
160186 let start = Barrier::new(4);
161187 std::thread::scope(|scope| {
......@@ -164,18 +190,12 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
164190 scope.spawn(move || {
165191 start.wait();
166192 for edit in 0..20 {
167 loop {
168 let marker = format!("[{writer}:{edit}] ");
169 match model_ops::save(replica, object, |page| {
170 model_ops::replace_text(page, object, 0..0, &marker)
171 }) {
172 Ok(Some(_)) => break,
173 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => {
174 continue;
175 }
176 other => panic!("Unexpected local edit: {other:?}"),
177 }
178 }
193 replica
194 .apply(
195 "Fixture",
196 typed(space, object, &format!("[{writer}:{edit}] ")),
197 )
198 .unwrap();
179199 }
180200 });
181201 }
......@@ -185,20 +205,22 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
185205 replica.export_recovery(&path).unwrap();
186206 let archive = Recovery::open(path).unwrap();
187207 let pending = archive.pending().unwrap();
188 let snapshot = target(&archive.snapshot().unwrap()).2;
189 match pending.as_slice() {
190 [] => assert_eq!(snapshot, "base"),
191 [single] => {
192 let Operation::Page(intent) = &single.operation else {
193 panic!()
194 };
195 let (id, before, range, replacement) = intent.text_change().unwrap();
196 assert_eq!((id, before.as_str(), range), (object, "base", 0..0));
197 assert_eq!(snapshot, format!("{replacement}base"));
198 assert_eq!(text_of(&intent.after, object), snapshot);
199 }
200 more => panic!("saves to one page coalesce: {more:?}"),
201 }
208 // Each edit types at the start, so the text is the queue read backwards.
209 let expected: String = pending
210 .iter()
211 .rev()
212 .map(|edit| match &edit.edit.ops[..] {
213 [
214 Op::Page {
215 op: PageOp::Text { with, .. },
216 ..
217 },
218 ] => with.as_str(),
219 other => panic!("{other:?}"),
220 })
221 .chain(["base"])
222 .collect();
223 assert_eq!(target(&archive.snapshot().unwrap()).2, expected);
202224 assert_eq!(archive.remote_snapshot().unwrap(), source);
203225 assert_eq!(
204226 archive.summary().unwrap().queued_edits,
......@@ -207,7 +229,7 @@ fn concurrent_recovery_exports_capture_one_complete_acknowledged_queue() {
207229 assert!(archive.receipts().unwrap().is_empty());
208230 }
209231 });
210 assert_eq!(replica.pending().unwrap().len(), 1);
232 assert_eq!(replica.pending().unwrap().len(), 60);
211233 let content = target(&replica.snapshot().unwrap()).2;
212234 for writer in 0..3 {
213235 for edit in 0..20 {
......@@ -242,9 +264,10 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
242264 .unwrap();
243265 for sql in [
244266 "PRAGMA application_id=0".to_owned(),
267 // Schema 14 converts; older caches do not.
245268 format!(
246269 "PRAGMA application_id=1330529615; PRAGMA user_version={}",
247 current - 1
270 current - 2
248271 ),
249272 format!("PRAGMA user_version={}", current + 1),
250273 ] {
......@@ -289,52 +312,26 @@ fn ownership_and_foreign_file_rejection_preserve_existing_data() {
289312}
290313
291314#[test]
292fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
315fn twelve_local_editors_queue_every_edit_once_in_order() {
293316 let dir = tempfile::tempdir().unwrap();
294317 let path = dir.path().join("section.sqlite");
295318 let source = onestore::create_section("section.one", "Shared café 🦀", "Fixture").unwrap();
296319 let replica = Replica::create(&path, &source).unwrap();
297320 let (sid, oid, _) = target(&source);
298321 let barrier = Barrier::new(12);
299 let deadline = Instant::now() + Duration::from_secs(60);
300322 let outcomes = std::thread::scope(|scope| {
301323 let handles: Vec<_> = (0..12)
302324 .map(|writer| {
303 let (replica, source, barrier) = (&replica, &source, &barrier);
325 let (replica, barrier) = (&replica, &barrier);
304326 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}] "));
307327 barrier.wait();
308 let mut ids = Vec::new();
309 let first_won = match replica.save(source, sid, &page, model_ops::AUTHOR) {
310 Ok(Some(id)) => {
311 ids.push(id);
312 true
313 }
314 Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy => false,
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)
328 (0..20)
329 .map(|edit| {
330 replica
331 .apply("Fixture", typed(sid, oid, &format!("[{writer}-{edit}] ")))
332 .unwrap()
333 })
334 .collect::<Vec<_>>()
338335 })
339336 })
340337 .collect();
......@@ -343,31 +340,29 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
343340 .map(|handle| handle.join().unwrap())
344341 .collect::<Vec<_>>()
345342 });
346 assert_eq!(outcomes.iter().filter(|(won, _)| *won).count(), 1);
347 let ids: BTreeSet<_> = outcomes.into_iter().flat_map(|(_, ids)| ids).collect();
348 assert_eq!(ids.len(), 1, "every save replaced the unattempted head");
349 let final_bytes = replica.snapshot().unwrap();
350 let content = target(&final_bytes).2;
351 let pending = replica.pending().unwrap();
352 assert_eq!(pending.len(), 1);
353 assert_eq!(pending[0].id, *ids.first().unwrap());
354 let (id, before, range, replacement) = intent(&replica, 0).text_change().unwrap();
355 assert_eq!((id, before.as_str(), range), (oid, "Shared café 🦀", 0..0));
356 assert_eq!(content, format!("{replacement}Shared café 🦀"));
357 assert_eq!(content.matches("[initial-").count(), 1);
343 for ids in &outcomes {
344 assert!(
345 ids.windows(2).all(|pair| pair[0] < pair[1]),
346 "a writer's edits keep its order"
347 );
348 }
349 let ids: BTreeSet<_> = outcomes.into_iter().flatten().collect();
350 assert_eq!(ids.len(), 240);
351 let content = target(&replica.snapshot().unwrap()).2;
352 assert!(content.ends_with("Shared café 🦀"));
358353 for writer in 0..12 {
359354 for edit in 0..20 {
360355 assert_eq!(content.matches(&format!("[{writer}-{edit}] ")).count(), 1);
361356 }
362357 }
363 let (_, mut stale) = model_ops::locate(&source, oid);
364 model_ops::replace_text(&mut stale, oid, 0..0, "stale ");
365 assert!(
366 matches!(replica.save(&source, sid, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
358 let pending = replica.pending().unwrap();
359 assert_eq!(
360 pending.iter().map(|edit| edit.id).collect::<BTreeSet<_>>(),
361 ids
367362 );
368363 drop(replica);
369364 let reopened = Replica::open(&path).unwrap();
370 assert_eq!(reopened.snapshot().unwrap(), final_bytes);
365 assert_eq!(target(&reopened.snapshot().unwrap()).2, content);
371366 assert_eq!(reopened.pending().unwrap(), pending);
372367}
373368
......@@ -375,11 +370,10 @@ fn twelve_local_editors_reject_stale_images_and_coalesce_into_one_save() {
375370fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
376371 let dir = tempfile::tempdir().unwrap();
377372 let path = dir.path().join("model.sqlite");
378 let original = "ab🚀ab🦀 é repeated repeated".to_owned();
379 let mut text = original.clone();
373 let mut text = "ab🚀ab🦀 é repeated repeated".to_owned();
380374 let source = onestore::create_section("model.one", &text, "Fixture").unwrap();
381375 let mut replica = Replica::create(&path, &source).unwrap();
382 let (space, object, _) = target(&source);
376 let (_, object, _) = target(&source);
383377 let mut random = 911_u64;
384378 let mut next = || {
385379 random ^= random << 13;
......@@ -387,8 +381,7 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
387381 random ^= random << 17;
388382 random
389383 };
390 let mut acknowledged = 0;
391 let mut pending = Vec::new();
384 let mut acknowledged = Vec::new();
392385 for step in 0..512 {
393386 let boundaries: Vec<_> = text
394387 .char_indices()
......@@ -403,47 +396,45 @@ fn seeded_unicode_edits_and_restarts_match_an_independent_text_model() {
403396 let replacement = ["", "🐈", "日本語", "repeated", "é", "ab🦀ab"][next() as usize % 6];
404397 let mut expected = text.clone();
405398 expected.replace_range(bytes, replacement);
406 let before = replica.snapshot().unwrap();
407399 let acknowledgement = model_ops::save(&replica, object, |page| {
408400 model_ops::replace_text(page, object, range.clone(), replacement)
409401 })
410402 .unwrap();
411 if let Some(id) = acknowledgement {
412 assert_ne!(text, expected);
413 acknowledged += 1;
414 pending = replica.pending().unwrap();
415 assert_eq!(pending.len(), 1);
416 assert_eq!((pending[0].id, pending[0].space), (1, space));
417 let saved = intent(&replica, 0);
418 assert_eq!(id, 1);
419 assert_eq!(text_of(&saved.before, object), original);
420 assert_eq!(text_of(&saved.after, object), expected);
421 if step % 37 == 0 {
422 let (_, mut stale) = model_ops::locate(&before, object);
423 model_ops::replace_text(&mut stale, object, 0..0, "stale");
424 assert!(
425 matches!(replica.save(&before, space, &stale, "Author"), Err(Error::Io(error)) if error.kind() == ErrorKind::ResourceBusy)
426 );
403 match acknowledgement {
404 Some(id) => {
405 assert_ne!(text, expected);
406 assert!(acknowledged.last().is_none_or(|last| *last < id));
407 acknowledged.push(id);
427408 }
428 } else {
429 assert_eq!(text, expected);
409 None => assert_eq!(text, expected),
430410 }
431411 text = expected;
432 let source = replica.snapshot().unwrap();
433 assert_eq!(target(&source).2, text, "seed 911, step {step}");
412 assert_eq!(
413 target(&replica.snapshot().unwrap()).2,
414 text,
415 "seed 911, step {step}"
416 );
434417 if step % 37 == 0 {
418 let pending = replica.pending().unwrap();
435419 drop(replica);
436420 replica = Replica::open(&path).unwrap();
437 assert_eq!(replica.snapshot().unwrap(), source);
421 assert_eq!(target(&replica.snapshot().unwrap()).2, text);
438422 assert_eq!(replica.pending().unwrap(), pending);
439423 }
440424 }
441 assert!(acknowledged > 256, "Most random edits change the text");
442 let source = replica.snapshot().unwrap();
443 drop(replica);
444 let replica = Replica::open(&path).unwrap();
445 assert_eq!(replica.pending().unwrap(), pending);
446 assert_eq!(replica.snapshot().unwrap(), source);
425 assert!(
426 acknowledged.len() > 256,
427 "Most random edits change the text"
428 );
429 assert_eq!(
430 replica
431 .pending()
432 .unwrap()
433 .iter()
434 .map(|edit| edit.id)
435 .collect::<Vec<_>>(),
436 acknowledged
437 );
447438}
448439
449440#[test]
......@@ -465,71 +456,43 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
465456 let mut objects = Vec::new();
466457 barrier.wait();
467458 for sequence in 0..4 {
459 assert!(Instant::now() < deadline, "Client {client} stopped");
468460 let content = if sequence == 0 {
469461 format!("Client {client}")
470462 } else {
471463 format!("Paragraph {client}:{sequence}")
472464 };
473465 let mut inserted = None;
474 loop {
475 assert!(
476 Instant::now() < deadline,
477 "Client {client} stopped at insertion {sequence}"
478 );
479 let result = model_ops::save(cache, anchor, |page| {
480 let text = if sequence == 0 {
481 model_ops::insert_outline(
482 page,
483 72.0,
484 144.0 + client as f32 * 72.0,
485 &content,
486 )
487 .2
488 } else {
489 model_ops::insert_after(
490 page,
491 *objects.last().unwrap(),
492 &content,
493 )
494 .1
495 };
496 let end = content.encode_utf16().count() as u32;
497 model_ops::restyle(page, text, 0..end, |format| {
498 format.italic = None
499 });
500 model_ops::restyle(page, text, 0..6, italic);
501 inserted = Some(text);
502 });
503 match result {
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 }
466 let id = model_ops::save(cache, anchor, |page| {
467 let text = if sequence == 0 {
468 model_ops::insert_outline(
469 page,
470 72.0,
471 144.0 + client as f32 * 72.0,
472 &content,
473 )
474 .2
475 } else {
476 model_ops::insert_after(page, *objects.last().unwrap(), &content).1
477 };
478 let end = content.encode_utf16().count() as u32;
479 model_ops::restyle(page, text, 0..end, |format| format.italic = None);
480 model_ops::restyle(page, text, 0..6, italic);
481 inserted = Some(text);
482 })
483 .unwrap()
484 .unwrap();
485 ids.push(id);
486 objects.push(inserted.unwrap());
513487 if sequence == 0 {
514488 continue;
515489 }
516490 let text = *objects.last().unwrap();
517 loop {
518 assert!(
519 Instant::now() < deadline,
520 "Client {client} stopped at text {sequence}"
521 );
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 }
491 ids.push(
492 cache
493 .apply(model_ops::AUTHOR, typed(sid, text, "Edited "))
494 .unwrap(),
495 );
533496 }
534497 (ids, objects)
535498 })
......@@ -544,12 +507,14 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
544507 .iter()
545508 .flat_map(|(ids, _)| ids.iter().copied())
546509 .collect();
547 assert_eq!(all.iter().map(|(ids, _)| ids.len()).sum::<usize>(), 84);
548 assert_eq!(ids.len(), 1);
510 assert_eq!(ids.len(), 84);
549511 let snapshot = cache.snapshot().unwrap();
550512 drop(cache);
551513 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 );
553518 assert_eq!(
554519 cache
555520 .pending()
......@@ -588,8 +553,8 @@ fn twelve_local_clients_preserve_inserted_identities_and_dependent_edits() {
588553}
589554
590555#[test]
591fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
592 for operation in ["Page", "CreatePage", "Pages", "DeletePages"] {
556fn unrecognized_persisted_ops_are_rejected_without_dropping_fields() {
557 for kind in ["Text", "Create", "Pages", "Delete"] {
593558 let directory = tempfile::tempdir().unwrap();
594559 let path = directory.path().join("unknown.sqlite");
595560 let first = onestore::create_section("unknown.one", "Original", "Author").unwrap();
......@@ -598,52 +563,47 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() {
598563 .unwrap()
599564 .as_bytes()
600565 .to_vec();
601 let (_, oid, _) = target(&source);
566 let (space, oid, _) = target(&source);
602567 let store = Store::parse(&source).unwrap();
603568 let index = RevisionIndex::parse(&store).unwrap();
604569 let sid = Document::parse(&index).unwrap().pages().unwrap()[1].0;
605570 let cache = Replica::create(&path, &source).unwrap();
606 match operation {
607 "Page" => {
608 model_ops::save(&cache, oid, |page| {
609 model_ops::replace_text(page, oid, 0..0, "New ")
610 })
611 .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();
571 let section = |op| {
572 server::section_op(&cache, op);
573 };
574 match kind {
575 "Text" => {
576 cache.apply("Author", typed(space, oid, "New ")).unwrap();
626577 }
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])),
627585 }
628586 drop(cache);
629 let db = rusqlite::Connection::open(&path).unwrap();
630 let encoded: String = db
631 .query_row("SELECT operation FROM edits", [], |r| r.get(0))
587 let encoded: String = rusqlite::Connection::open(&path)
588 .unwrap()
589 .query_row("SELECT edit FROM edits", [], |r| r.get(0))
632590 .unwrap();
633 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
634 if value[operation].is_object() {
635 value[operation]["future_option"] = true.into();
636 } else {
637 value["future_option"] = true.into();
591 for target in ["", "/ops/0", "/ops/0/Page/op/Text", "/ops/0/Section"] {
592 let mut value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
593 let Some(object) = value.pointer_mut(target).and_then(|v| v.as_object_mut()) else {
594 continue;
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);
638607 }
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);
648608 }
649609}
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 @@
11use notebook::{
2 ConflictKind, EditStatus, Recovery,
2 ConflictKind, EditStatus, Recovery, Resolution,
33 session::{Event, Notebook, QueuedEdit, Save, Section},
44};
55use onestore::{ExGuid, PreparedEdit, page::Page};
......@@ -62,10 +62,14 @@ fn wait(section: &Section, mut accept: impl FnMut(&Event) -> bool) {
6262}
6363
6464fn published(section: &Section, id: u64) {
65 wait(
66 section,
67 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Published { .. } } if *n == id),
68 );
65 let deadline = Instant::now() + Duration::from_secs(20);
66 while !matches!(
67 section.status(id).unwrap(),
68 Some(EditStatus::Published { .. })
69 ) {
70 assert!(Instant::now() < deadline, "the edit was not published");
71 std::thread::sleep(Duration::from_millis(20));
72 }
6973}
7074
7175fn stored_page(file: &Path, space: ExGuid) -> Page {
......@@ -79,6 +83,13 @@ fn assert_same(actual: Page, expected: &Page) {
7983 assert_eq!(actual, expected);
8084}
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
8293#[test]
8394fn a_section_opens_through_its_replica_and_a_save_reaches_the_file_and_survives_relaunch() {
8495 let directory = tempfile::tempdir().unwrap();
......@@ -765,7 +776,7 @@ fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recov
765776mod server;
766777
767778/// 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. Neither
779/// archive the user either publishes the local edits again or abandons the branch. Neither
769780/// path records a receipt for the uncertain attempt.
770781#[test]
771782fn an_uncertain_attempt_is_released_after_restart_by_review() {
......@@ -778,10 +789,8 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
778789 let replica = notebook::Replica::create(&cache, &source).unwrap();
779790 let space = space_of(&source);
780791 let page = model_ops::page_of(&source, space);
781 let text = first_text(&page);
782792 let local = edited(&page, "Uncertain ");
783 let id = replica
784 .save(&source, space, &local, "Author")
793 let id = model_ops::save_as(&replica, space, &local, "Author")
785794 .unwrap()
786795 .unwrap();
787796 let mut faulty = server::Server::new(&source);
......@@ -815,14 +824,12 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
815824 assert_same(section.page(space).unwrap(), &local);
816825 let archive = directory.path().join("review.sqlite");
817826 if continued {
818 let mut reviewed = section.remote_page(space).unwrap();
819 model_ops::replace_text(&mut reviewed, text, 0..0, "Reviewed ");
820 section.release(id, &archive, Some(&reviewed)).unwrap();
827 section.release(id, &archive, Resolution::Mine).unwrap();
821828 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
822829 published(&section, id);
823 assert_same(stored_page(&file, space), &reviewed);
830 assert_same(stored_page(&file, space), &local);
824831 } else {
825 section.release(id, &archive, None).unwrap();
832 section.release(id, &archive, Resolution::Theirs).unwrap();
826833 assert_eq!(
827834 section.status(id).unwrap(),
828835 Some(EditStatus::Archived {
......@@ -841,7 +848,11 @@ fn an_uncertain_attempt_is_released_after_restart_by_review() {
841848 assert!(section.queue().unwrap().is_empty());
842849 assert!(
843850 section
844 .release(id, directory.path().join("again.sqlite"), None)
851 .release(
852 id,
853 directory.path().join("again.sqlite"),
854 Resolution::Theirs
855 )
845856 .is_err()
846857 );
847858 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) {
225225 panic!("text is on the page");
226226}
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`.
229285pub fn save(
230286 cache: &Replica,
231287 text: ExGuid,
232288 edit: impl FnOnce(&mut Page),
233289) -> Result<Option<u64>, notebook::Error> {
234 let source = cache.snapshot()?;
235 let (space, mut page) = locate(&source, text);
236 edit(&mut page);
237 cache.save(&source, space, &page, AUTHOR)
290 let (space, _) = locate(&cache.snapshot()?, text);
291 save_page(cache, space, edit)
238292}
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.
241295pub fn review(
242296 cache: &Replica,
243297 id: u64,
244298 text: ExGuid,
245299 edit: impl FnOnce(&mut Page),
246) -> Result<(), notebook::Error> {
247 let local = cache.snapshot()?;
248 let remote = cache.remote_snapshot()?;
249 let (_, mut page) = locate(&remote, text);
250 edit(&mut page);
251 cache.review_page(id, &local, &remote, &page)
300) -> Result<Option<u64>, notebook::Error> {
301 cache.resolve(id, notebook::Resolution::Theirs)?;
302 save(cache, text, edit)
252303}
crates/notebook/tests/support/model_schedule.rs+66-79
......@@ -3,7 +3,7 @@
33
44use crate::model_ops::{self, AUTHOR};
55use crate::server::{Fault, Server};
6use notebook::{EditStatus, Operation, Remote, Replica};
6use notebook::{EditStatus, Remote, Replica, Resolution};
77use onestore::{
88 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, Transaction,
99 document::Document,
......@@ -126,11 +126,10 @@ pub(crate) fn move_subtree(
126126 }
127127}
128128
129/// Asserts the publication invariants that survive the page model, then delegates.
129/// Asserts the publication invariants, then delegates.
130130struct Session<'a> {
131131 server: &'a mut Server,
132 intent: Option<(u64, Operation)>,
133 /// The intent was already attempted, so its publication must never be repeated.
132 /// The head batch was already attempted, so its publication must never be repeated.
134133 retired: bool,
135134 publications: usize,
136135}
......@@ -144,18 +143,6 @@ impl Remote for Session<'_> {
144143 assert!(!self.retired, "a retired attempt was replayed");
145144 self.publications += 1;
146145 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 }
159146 self.server.publish(transaction)
160147 }
161148
......@@ -164,6 +151,11 @@ impl Remote for Session<'_> {
164151 }
165152}
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
167159fn statuses(cache: &Replica) -> Vec<(u64, Option<EditStatus>)> {
168160 cache
169161 .pending()
......@@ -190,12 +182,13 @@ pub fn run(input: &[u8]) {
190182 let path = directory.path().join(format!("{actor}.sqlite"));
191183 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, source).unwrap());
192184 let snapshot = cache.snapshot().unwrap();
185 let before = local(cache);
193186 let pending = cache.pending().unwrap();
194 let local = rows(&snapshot);
187 let rows_now = rows(&snapshot);
195188 match step[1] % 8 {
196189 0..=2 => {
197 let row = local[usize::from(step[2]) % local.len()].clone();
198 let other = local[usize::from(step[4]) % local.len()].clone();
190 let row = rows_now[usize::from(step[2]) % rows_now.len()].clone();
191 let other = rows_now[usize::from(step[4]) % rows_now.len()].clone();
199192 let change: Box<dyn Fn(&mut Page)> = match step[3] % 6 {
200193 0 if !row.2.is_empty() => Box::new(move |page| {
201194 model_ops::replace_text(
......@@ -229,24 +222,20 @@ pub fn run(input: &[u8]) {
229222 }),
230223 _ => Box::new(|_| {}),
231224 };
232 let mut after = model_ops::page_of(&snapshot, *space);
225 let mut after = cache.page(*space).unwrap();
233226 change(&mut after);
234 match cache.save(&snapshot, *space, &after, AUTHOR) {
227 match model_ops::save_as(cache, *space, &after, AUTHOR) {
235228 Ok(id) => {
236229 let next = cache.pending().unwrap();
237230 let ids = |edits: &[notebook::PendingEdit]| -> Vec<u64> {
238231 edits.iter().map(|edit| edit.id).collect()
239232 };
240233 assert_eq!(ids(&next)[..pending.len()], ids(&pending)[..]);
241 assert!(next.len() <= pending.len() + 1);
242 assert_eq!(id.is_some(), cache.snapshot().unwrap() != snapshot);
243 assert_eq!(
244 shape(&model_ops::page_of(&cache.snapshot().unwrap(), *space)),
245 shape(&after)
246 );
234 assert_eq!(next.len(), pending.len() + usize::from(id.is_some()));
235 assert_eq!(local(cache), shape(&after));
247236 }
248237 Err(_) => {
249 assert_eq!(cache.snapshot().unwrap(), snapshot);
238 assert_eq!(local(cache), before);
250239 assert_eq!(cache.pending().unwrap(), pending);
251240 }
252241 }
......@@ -270,9 +259,6 @@ pub fn run(input: &[u8]) {
270259 Some(EditStatus::AwaitingConfirmation { .. })
271260 )
272261 }),
273 intent: pending
274 .first()
275 .map(|edit| (edit.id, edit.operation.clone())),
276262 server: &mut server,
277263 publications: 0,
278264 };
......@@ -280,22 +266,27 @@ pub fn run(input: &[u8]) {
280266 };
281267 server.fault = Fault::None;
282268 let next = cache.pending().unwrap();
283 if next.len() == pending.len() {
284 assert_eq!(next, pending);
285 if !next.is_empty() {
286 assert_eq!(cache.snapshot().unwrap(), snapshot);
287 }
288 } else {
289 assert_eq!(next, pending[1..]);
290 let Some(EditStatus::Published { revision }) =
291 cache.status(head.unwrap()).unwrap()
269 // Edits leave the queue oldest first, each with a durable receipt.
270 let left = pending.len() - next.len();
271 assert!(
272 next.iter()
273 .all(|edit| pending[left..].iter().any(|kept| kept.id == edit.id))
274 );
275 for edit in &pending[..left] {
276 let Some(EditStatus::Published { revision }) = cache.status(edit.id).unwrap()
292277 else {
293 panic!("an intent leaves the queue only with its receipt")
278 panic!("an edit leaves the queue only with its receipt")
294279 };
295280 durable(&server, revision);
296281 }
297282 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 );
299290 }
300291 }
301292 5 => {
......@@ -317,44 +308,40 @@ pub fn run(input: &[u8]) {
317308 }
318309 }
319310 6 => {
320 if let Some(head) = pending.first()
321 && matches!(
322 cache.status(head.id).unwrap(),
323 Some(EditStatus::Conflict(_))
324 )
325 {
326 let remote = cache.remote_snapshot().unwrap();
327 let published = rows(&remote);
328 let row = published[usize::from(step[2]) % published.len()].clone();
329 let mut after = model_ops::page_of(&remote, *space);
330 if !row.2.is_empty() {
331 model_ops::replace_text(&mut after, row.1, 0..1, "R");
311 if let Some(conflict) = cache.conflict().unwrap() {
312 let keep = if step[2] & 1 == 0 {
313 Resolution::Mine
314 } else {
315 Resolution::Theirs
316 };
317 let remote = model_ops::page_of(&cache.remote_snapshot().unwrap(), *space);
318 cache.resolve(conflict.id, keep).unwrap();
319 match cache.conflict().unwrap() {
320 Some(next) => assert_ne!(next.space, conflict.space),
321 None => assert_eq!(
322 local(cache),
323 match keep {
324 Resolution::Mine => before.clone(),
325 Resolution::Theirs => shape(&remote),
326 }
327 ),
332328 }
333 if cache
334 .review_page(head.id, &snapshot, &remote, &after)
335 .is_ok()
336 {
337 assert_eq!(cache.status(head.id).unwrap(), Some(EditStatus::Pending));
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)
329 let (result, publications) = {
330 let mut session = Session {
331 retired: false,
332 server: &mut server,
333 publications: 0,
349334 };
350 assert!(publications <= 1);
351 if let Ok(Some((id, EditStatus::Published { revision }))) = result {
352 assert_eq!(id, head.id);
353 durable(&server, revision);
354 assert!(cache.pending().unwrap().iter().all(|e| e.id != id));
355 }
356 } else {
357 assert_eq!(cache.pending().unwrap(), pending);
335 (cache.sync_once(&mut session), session.publications)
336 };
337 assert!(publications <= 1);
338 if let Ok(notebook::Synced {
339 edit: Some((id, EditStatus::Published { revision })),
340 ..
341 }) = result
342 {
343 durable(&server, revision);
344 assert!(cache.pending().unwrap().iter().all(|e| e.id != id));
358345 }
359346 }
360347 }
......@@ -362,7 +349,7 @@ pub fn run(input: &[u8]) {
362349 let recorded = statuses(cache);
363350 replicas[actor] = None;
364351 let cache = Replica::open(&path).unwrap();
365 assert_eq!(cache.snapshot().unwrap(), snapshot);
352 assert_eq!(local(&cache), before);
366353 assert_eq!(cache.pending().unwrap(), pending);
367354 assert_eq!(statuses(&cache), recorded);
368355 replicas[actor] = Some(cache);
crates/notebook/tests/support/page_schedule.rs+55-103
......@@ -1,7 +1,7 @@
1use crate::disk;
2use notebook::{EditStatus, Operation, Remote, Replica};
1use crate::{disk, model_ops};
2use notebook::{EditStatus, Remote, Replica, Resolution};
33use onestore::{
4 CommitError, ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store, Transaction,
4 CommitError, ExGuid, PageEdit, PreparedEdit, RevisionIndex, Store, Transaction,
55 document::{Document, Format, Kind, Layout},
66 page::{
77 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
......@@ -97,27 +97,9 @@ pub fn body_outline(page: &mut Page, text: &str) -> ExGuid {
9797 id
9898}
9999
100/// The text identity of the first body outline's first paragraph.
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
100/// Publishes through the crash-injecting disk, asserting each commit is atomic.
118101struct Session<'a> {
119102 disk: &'a mut disk::Disk,
120 operation: Option<&'a Operation>,
121103}
122104
123105impl Remote for Session<'_> {
......@@ -128,41 +110,6 @@ impl Remote for Session<'_> {
128110 fn publish(&mut self, transaction: &Transaction) -> Result<(), CommitError> {
129111 let mut image = self.disk.visible.clone();
130112 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);
166113 let old = current::current(&self.disk.durable);
167114 let new = current::current(&image);
168115 let result = transaction.commit(self.disk);
......@@ -179,6 +126,16 @@ impl Remote for Session<'_> {
179126 }
180127}
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
182139pub fn run(input: &[u8]) {
183140 let directory = tempfile::tempdir().unwrap();
184141 let mut replicas: [Option<Replica>; 12] = std::array::from_fn(|_| None);
......@@ -196,21 +153,27 @@ pub fn run(input: &[u8]) {
196153 let path = directory.path().join(format!("{actor}.sqlite"));
197154 let cache = replicas[actor].get_or_insert_with(|| Replica::create(&path, &SOURCE).unwrap());
198155 let snapshot = cache.snapshot().unwrap();
156 let listed = pages(&snapshot);
199157 let pending = cache.pending().unwrap();
200158 match step[1] % 8 {
201159 0 | 1 => {
202 let title = (step[2] & 1 != 0).then_some("Same 🦋 é");
160 let title = (step[2] & 1 != 0).then_some("Same 🦋 é");
203161 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();
205163 owned[actor].push((page.space(), page.object()));
206164 if step[1] % 8 == 1 {
207 let source = cache.snapshot().unwrap();
208 let mut model = page_of(&source, page.space());
165 let mut model = cache.page(page.space()).unwrap();
209166 body_outline(&mut model, BODY);
210 cache
211 .save(&source, page.space(), &model, "Author")
167 model_ops::save_as(cache, page.space(), &model, "Author")
212168 .unwrap()
213169 .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 );
214177 }
215178 }
216179 2 => {
......@@ -220,7 +183,7 @@ pub fn run(input: &[u8]) {
220183 .collect();
221184 replicas[actor] = None;
222185 let cache = Replica::open(&path).unwrap();
223 assert!(cache.snapshot().unwrap() == snapshot);
186 assert_eq!(pages(&cache.snapshot().unwrap()), listed);
224187 assert_eq!(cache.pending().unwrap(), pending);
225188 assert_eq!(
226189 pending
......@@ -240,15 +203,20 @@ pub fn run(input: &[u8]) {
240203 let prior = pending
241204 .first()
242205 .and_then(|edit| cache.status(edit.id).unwrap());
243 let mut session = Session {
244 disk: &mut disk,
245 operation: pending.first().map(|edit| &edit.operation),
246 };
247 let result = cache.sync_once(&mut session);
206 let result = cache.sync_once(&mut Session { disk: &mut disk });
248207 if matches!(prior, Some(EditStatus::AwaitingConfirmation { .. })) {
249208 assert!(
250209 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())
252220 );
253221 }
254222 }
......@@ -257,13 +225,12 @@ pub fn run(input: &[u8]) {
257225 disk.fail_at = None;
258226 }
259227 6 => {
260 let order = pages(&snapshot);
261228 let count = 1 + usize::from(step[3] & 1 != 0);
262229 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)
264231 .collect();
265232 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)
267234 .filter(|sid| !selected.contains(sid));
268235 let edits: Vec<_> = selected
269236 .iter()
......@@ -278,43 +245,28 @@ pub fn run(input: &[u8]) {
278245 })
279246 .collect();
280247 let expected = PreparedEdit::pages(&snapshot, &edits);
281 match cache.pages(&snapshot, &edits) {
282 Ok(id) => {
283 let expected = expected.unwrap();
284 assert_eq!(
285 current::current(&cache.snapshot().unwrap()),
286 current::current(expected.as_bytes())
287 );
288 assert_eq!(id.is_some(), expected.as_bytes() != snapshot);
289 }
248 match section_op(cache, onestore::op::SectionOp::Pages(edits)) {
249 Ok(_) => assert_eq!(
250 pages(&cache.snapshot().unwrap()),
251 pages(expected.unwrap().as_bytes())
252 ),
290253 Err(_) => {
291254 assert!(expected.is_err());
292 assert_eq!(cache.snapshot().unwrap(), snapshot);
255 assert_eq!(pages(&cache.snapshot().unwrap()), listed);
293256 assert_eq!(cache.pending().unwrap(), pending);
294257 }
295258 }
296259 }
297260 7 => {
298 if let Some(edit) = pending.first()
299 && matches!(
300 cache.status(edit.id).unwrap(),
301 Some(EditStatus::Conflict(_))
302 )
303 && let Operation::Pages(batch) = &edit.operation
304 {
305 let remote = cache.remote_snapshot().unwrap();
306 let order = pages(&remote);
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() {
261 if let Some(conflict) = cache.conflict().unwrap() {
262 // Taking theirs for the page list would drop the pages this actor made.
263 let root = listed.iter().all(|(sid, ..)| *sid != conflict.space);
264 let keep = if root || step[2] & 1 == 0 {
265 Resolution::Mine
266 } else {
267 Resolution::Theirs
268 };
269 if cache.resolve(conflict.id, keep).is_err() {
318270 assert_eq!(cache.pending().unwrap(), pending);
319271 }
320272 }
crates/notebook/tests/support/server.rs+32
......@@ -133,3 +133,35 @@ pub fn text(source: &[u8]) -> (ExGuid, ExGuid, String) {
133133 })
134134 .unwrap()
135135}
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//! 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};
4use onestore::{CommitError, CommitState, ExGuid, Transaction, page::Page};
3use notebook::{ConflictKind, EditStatus, Error, Remote, Replica, Resolution};
4use onestore::{CommitError, CommitState, ExGuid, Transaction};
55use std::{io, ops::Range};
66
77#[path = "support/server.rs"]
......@@ -10,13 +10,6 @@ use server::*;
1010#[path = "support/model_ops.rs"]
1111mod 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
2013/// Saves a replacement of `range` in the page holding `text`.
2114fn save(cache: &Replica, text: ExGuid, range: Range<u32>, replacement: &str) -> Option<u64> {
2215 model_ops::save(cache, text, |page| {
......@@ -56,16 +49,16 @@ fn an_unchanged_stamp_publishes_and_settles_without_reading_the_remote() {
5649 reads: 0,
5750 };
5851 assert!(matches!(
59 cache.sync_once(&mut remote).unwrap(),
52 cache.sync_once(&mut remote).unwrap().edit,
6053 Some((published, EditStatus::Published { .. })) if published == id
6154 ));
62 assert_eq!(cache.sync_once(&mut remote).unwrap(), None);
55 assert_eq!(cache.sync_once(&mut remote).unwrap().edit, None);
6356 assert_eq!(remote.reads, 0);
6457 // Another writer's commit moves the header, so the next step reads the file.
6558 let native =
6659 onestore::replace_text(&remote.server.visible, sid, object, 0..0, "Native ").unwrap();
6760 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);
6962 assert_eq!(remote.reads, 1);
7063 assert_eq!(cache.snapshot().unwrap(), native);
7164}
......@@ -82,7 +75,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
8275 let cache = Replica::create(&path, &source).unwrap();
8376 let published = save(&cache, object, 0..0, "Published ").unwrap();
8477 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();
8679 let attempted = save(&cache, object, 0..0, "Uncertain ").unwrap();
8780 server.fault = Fault::UnknownBefore;
8881 assert!(matches!(
......@@ -94,10 +87,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
9487 ));
9588 save(&cache, object, 0..0, "Queued ").unwrap();
9689 let created = onestore::PageCreation::new(None, Some("Recovery 🦀"), "Fixture").unwrap();
97 cache
98 .create_page(&cache.snapshot().unwrap(), &created)
99 .unwrap()
100 .unwrap();
90 section_op(&cache, onestore::op::SectionOp::Create(created));
10191 save(&cache, object, 0..0, "Last ").unwrap();
10292 let working = cache.snapshot().unwrap();
10393 let remote = cache.remote_snapshot().unwrap();
......@@ -109,8 +99,8 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
10999 conflicts: 0,
110100 uncertain_edits: 1,
111101 published_receipts: 1,
112 working_bytes: working.len() as u64,
113 remote_bytes: remote.len() as u64,
102 base_bytes: remote.len() as u64,
103 remote_bytes: 0,
114104 cached_assets: 0,
115105 cached_asset_bytes: 0,
116106 };
......@@ -123,7 +113,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
123113 assert!(Recovery::open(&path).is_err());
124114 let archive = Recovery::open(&archive_path).unwrap();
125115 assert_eq!(archive.summary().unwrap(), summary);
126 assert_eq!(archive.snapshot().unwrap(), working);
116 assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working));
127117 assert_eq!(archive.remote_snapshot().unwrap(), remote);
128118 assert_eq!(archive.pending().unwrap(), pending);
129119 assert_eq!(archive.status(published).unwrap(), Some(receipt.clone()));
......@@ -150,7 +140,7 @@ fn recovery_archive_preserves_the_queue_uncertainty_and_receipts_without_becomin
150140 assert_eq!(std::fs::read(&path).unwrap(), source_file);
151141 assert_eq!(std::fs::read(&archive_path).unwrap(), archive_bytes);
152142 save(&cache, object, 0..0, "Later ").unwrap();
153 assert_eq!(archive.snapshot().unwrap(), working);
143 assert_eq!(pages(&archive.snapshot().unwrap()), pages(&working));
154144 assert_eq!(archive.pending().unwrap(), pending);
155145 drop(archive);
156146 assert_eq!(
......@@ -193,7 +183,7 @@ fn recovery_archive_retains_conflict_images_and_rejects_foreign_or_future_archiv
193183 let id = save(&cache, object, 0..8, "Local").unwrap();
194184 let changed = onestore::replace_text(&source, space, object, 0..8, "Remote").unwrap();
195185 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();
197187 assert_eq!(
198188 outcome,
199189 (id, EditStatus::Conflict(ConflictKind::ContentChanged))
......@@ -232,7 +222,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
232222 let id = save(&cache, oid, 2..4, "🐈").unwrap();
233223 let remote = onestore::replace_text(&source, sid, oid, 0..6, "Xab🦀cYd").unwrap();
234224 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();
236226 assert_eq!(outcome.0, id);
237227 assert!(matches!(outcome.1, EditStatus::Published { .. }));
238228 assert_eq!(text(&server.durable).2, "Xab🐈cYd");
......@@ -241,7 +231,7 @@ fn disjoint_remote_changes_merge_and_persist_the_remote_receipt() {
241231 drop(cache);
242232 let cache = Replica::open(&path).unwrap();
243233 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);
245235 assert_eq!(server.publications, 1);
246236 assert_eq!(cache.status(id + 1).unwrap(), None);
247237 let next = save(&cache, oid, 0..0, "Later ").unwrap();
......@@ -260,13 +250,13 @@ fn overlapping_changes_preserve_both_images_and_survive_restart() {
260250 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
261251 let mut server = Server::new(&remote);
262252 assert_eq!(
263 cache.sync_once(&mut server).unwrap(),
253 cache.sync_once(&mut server).unwrap().edit,
264254 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
265255 );
266256 assert_eq!(server.publications, 0);
267257 drop(cache);
268258 let cache = Replica::open(&path).unwrap();
269 assert_eq!(cache.snapshot().unwrap(), local);
259 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
270260 assert_eq!(cache.remote_snapshot().unwrap(), remote);
271261 assert_eq!(cache.pending().unwrap().len(), 1);
272262 assert_eq!(
......@@ -298,7 +288,7 @@ fn lost_replies_and_process_termination_never_blindly_replay_an_attempt() {
298288 assert!(matches!(attempted, EditStatus::AwaitingConfirmation { .. }));
299289 drop(cache);
300290 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();
302292 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
303293 assert!(matches!(result.1, EditStatus::Published { .. }));
304294 assert_eq!(text(&server.durable).2, "Once abc");
......@@ -333,7 +323,7 @@ fn failed_confirmation_does_not_promote_visible_bytes_to_a_receipt() {
333323 Some(EditStatus::AwaitingConfirmation { .. })
334324 ));
335325 assert!(matches!(
336 cache.sync_once(&mut server).unwrap(),
326 cache.sync_once(&mut server).unwrap().edit,
337327 Some((_, EditStatus::Published { .. }))
338328 ));
339329 assert_eq!(server.publications, 1);
......@@ -383,7 +373,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
383373 if matches!(fault, Fault::Before) {
384374 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
385375 assert!(matches!(
386 cache.sync_once(&mut server).unwrap(),
376 cache.sync_once(&mut server).unwrap().edit,
387377 Some((_, EditStatus::Published { .. }))
388378 ));
389379 assert_eq!(server.publications, 2);
......@@ -392,7 +382,7 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
392382 cache.status(id).unwrap(),
393383 Some(EditStatus::Published { .. })
394384 ));
395 assert_eq!(cache.sync_once(&mut server).unwrap(), None);
385 assert_eq!(cache.sync_once(&mut server).unwrap().edit, None);
396386 assert_eq!(server.publications, 1);
397387 }
398388 assert_eq!(text(&server.durable).2, "Once abc");
......@@ -401,7 +391,10 @@ fn proven_unpublished_attempts_retry_and_committed_cleanup_errors_keep_receipts(
401391
402392#[test]
403393fn 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 ] {
405398 let dir = tempfile::tempdir().unwrap();
406399 let path = dir.path().join("cache.sqlite");
407400 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
......@@ -410,7 +403,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {
410403 let id = save(&cache, oid, 0..0, "Once ").unwrap();
411404 drop(cache);
412405 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();
414407 drop(db);
415408 let cache = Replica::open(&path).unwrap();
416409 let mut server = Server::new(&source);
......@@ -426,7 +419,7 @@ fn database_failures_before_and_after_publication_preserve_recovery_state() {
426419 drop(db);
427420 let cache = Replica::open(&path).unwrap();
428421 assert!(matches!(
429 cache.sync_once(&mut server).unwrap(),
422 cache.sync_once(&mut server).unwrap().edit,
430423 Some((_, EditStatus::Published { .. }))
431424 ));
432425 assert!(matches!(
......@@ -474,7 +467,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
474467 for phase in ["read", "publish", "confirm"] {
475468 let dir = tempfile::tempdir().unwrap();
476469 let source = onestore::create_section("sync.one", "abc", "Fixture").unwrap();
477 let (_, oid, _) = text(&source);
470 let (sid, oid, _) = text(&source);
478471 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
479472 let first = save(&cache, oid, 0..0, "First ").unwrap();
480473 let mut server = Server::new(&source);
......@@ -493,9 +486,17 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
493486 let mut server = std::thread::scope(|scope| {
494487 let running = scope.spawn(|| {
495488 let result = cache.sync_once(&mut paused);
496 assert!(
497 matches!(result, Ok(Some((id, EditStatus::Published { .. }))) if id == first)
498 );
489 assert!(matches!(
490 result,
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 ));
499500 paused.server
500501 });
501502 entered_rx
......@@ -504,26 +505,31 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
504505 assert!(
505506 matches!(cache.sync_once(&mut Server::new(&source)),Err(Error::Io(error)) if error.kind()==io::ErrorKind::WouldBlock)
506507 );
507 let reviewed = edited(&source, oid, 0..0, "Reviewed ");
508508 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)
510510 );
511511 let started = std::time::Instant::now();
512512 let handles: Vec<_> = (0..12)
513513 .map(|writer| {
514514 let cache = &cache;
515515 scope.spawn(move || {
516 loop {
517 let marker = format!("[{writer}] ");
518 match model_ops::save(cache, oid, |page| {
519 model_ops::replace_text(page, oid, 0..0, &marker)
520 }) {
521 Ok(Some(id)) => break id,
522 Err(Error::Io(error))
523 if error.kind() == io::ErrorKind::ResourceBusy => {}
524 other => panic!("Unexpected local outcome {other:?}"),
525 }
526 }
516 // Ops, unlike models, commute with the other writers' edits.
517 cache
518 .apply(
519 model_ops::AUTHOR,
520 onestore::op::Edit {
521 at: model_ops::now(),
522 ops: vec![onestore::op::Op::Page {
523 space: sid,
524 op: onestore::op::PageOp::Text {
525 text: oid,
526 range: 0..0,
527 with: format!("[{writer}] "),
528 },
529 }],
530 },
531 )
532 .unwrap()
527533 })
528534 })
529535 .collect();
......@@ -531,12 +537,7 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
531537 .into_iter()
532538 .map(|handle| handle.join().unwrap())
533539 .collect();
534 assert_eq!(ids.len(), 1, "saves to one page coalesce");
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_eq!(ids.len(), 12, "every local edit is its own");
540541 assert!(
541542 started.elapsed() < std::time::Duration::from_secs(2),
542543 "Local edits waited for remote {phase}"
......@@ -544,14 +545,18 @@ fn twelve_local_editors_progress_during_remote_reads_publication_and_confirmatio
544545 resume_tx.send(()).unwrap();
545546 running.join().unwrap()
546547 });
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 );
548553 let expected = text(&cache.snapshot().unwrap()).2;
549554 for writer in 0..12 {
550555 assert_eq!(expected.matches(&format!("[{writer}] ")).count(), 1);
551556 }
552557 while !cache.pending().unwrap().is_empty() {
553558 assert!(matches!(
554 cache.sync_once(&mut server).unwrap(),
559 cache.sync_once(&mut server).unwrap().edit,
555560 Some((_, EditStatus::Published { .. }))
556561 ));
557562 }
......@@ -573,136 +578,155 @@ fn unrelated_remote_files_never_replace_a_local_cache() {
573578 if pending {
574579 save(&cache, oid, 0..0, "Local ").unwrap();
575580 }
576 let before = cache.snapshot().unwrap();
581 let before = pages(&cache.snapshot().unwrap());
577582 assert!(
578583 matches!(cache.sync_once(&mut server),Err(Error::Io(error)) if error.kind()==io::ErrorKind::InvalidInput)
579584 );
580 assert_eq!(cache.snapshot().unwrap(), before);
585 assert_eq!(pages(&cache.snapshot().unwrap()), before);
581586 assert_eq!(cache.remote_snapshot().unwrap(), source);
582587 assert_eq!(server.publications, 0);
583588 }
584589}
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
586603#[test]
587fn reviewing_a_conflict_preserves_the_dependent_save_and_survives_reopen() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("cache.sqlite");
590 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
591 let (sid, oid, _) = text(&source);
592 let cache = Replica::create(&path, &source).unwrap();
593 let first = save(&cache, oid, 1..2, "L").unwrap();
594 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
595 let mut server = Server::new(&remote);
596 assert_eq!(
597 cache.sync_once(&mut server).unwrap(),
598 Some((first, EditStatus::Conflict(ConflictKind::ContentChanged)))
599 );
600 let mut dependent = None;
601 for n in 0..12 {
602 dependent = save(&cache, oid, 0..0, &format!("[{n}] "));
603 }
604 let dependent = dependent.unwrap();
605 assert_ne!(dependent, first, "a conflicted save is never rewritten");
606 let local = cache.snapshot().unwrap();
607 let pending = cache.pending().unwrap();
608 assert_eq!(pending.len(), 2);
609 model_ops::review(&cache, first, oid, |page| {
610 model_ops::replace_text(page, oid, 1..2, "L")
611 })
612 .unwrap();
613 assert_eq!(cache.snapshot().unwrap(), local);
614 assert_eq!(cache.remote_snapshot().unwrap(), remote);
615 let reviewed = cache.pending().unwrap();
616 assert_eq!(reviewed[1..], pending[1..]);
617 assert_eq!(reviewed[0].id, first);
618 let notebook::Operation::Page(intent) = &reviewed[0].operation else {
619 panic!()
620 };
621 assert_eq!(
622 intent.text_change(),
623 Some((oid, "aRcZ".into(), 1..2, "L".into()))
624 );
625 assert_eq!(intent.author, model_ops::AUTHOR);
626 assert_eq!(cache.status(first).unwrap(), Some(EditStatus::Pending));
627 drop(cache);
628 let cache = Replica::open(&path).unwrap();
629 assert_eq!(cache.pending().unwrap(), reviewed);
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)
604fn a_conflict_holds_later_edits_until_resolved_either_way_and_survives_reopen() {
605 for resolution in [Resolution::Mine, Resolution::Theirs] {
606 let dir = tempfile::tempdir().unwrap();
607 let path = dir.path().join("cache.sqlite");
608 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
609 let (sid, oid, _) = text(&source);
610 let cache = Replica::create(&path, &source).unwrap();
611 let first = save(&cache, oid, 1..2, "L").unwrap();
612 let remote = onestore::replace_text(&source, sid, oid, 0..3, "aRcZ").unwrap();
613 let mut server = Server::new(&remote);
614 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
615 assert_eq!(
616 cache.sync_once(&mut server).unwrap().edit,
617 Some((first, conflict.clone()))
618 );
619 let mut dependent = None;
620 for n in 0..12 {
621 dependent = save(&cache, oid, 0..0, &format!("[{n}] "));
622 }
623 let dependent = dependent.unwrap();
624 assert_ne!(dependent, first);
625 assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone()));
626 let local = content(&cache.snapshot().unwrap(), oid);
627 assert!(local.ends_with("[0] aLc"), "{local}");
628 let pending = cache.pending().unwrap();
629 assert_eq!(pending.len(), 13);
630 assert_eq!(
631 cache.sync_once(&mut server).unwrap().edit,
632 Some((dependent, conflict.clone())),
633 "an unchanged remote leaves the conflict as it was"
634 );
635 assert_eq!(server.publications, 0);
636 drop(cache);
637 let cache = Replica::open(&path).unwrap();
638 assert_eq!(cache.pending().unwrap(), pending);
639 assert_eq!(content(&cache.snapshot().unwrap(), oid), local);
640 assert_eq!(
641 cache.conflict().unwrap(),
642 Some(notebook::Conflict {
643 id: dependent,
644 space: sid,
645 kind: ConflictKind::ContentChanged
646 })
634647 );
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());
635692 }
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());
640693}
641694
642695#[test]
643fn conflict_review_rejects_stale_images_and_nonconflicting_states() {
696fn resolution_refuses_edits_that_are_not_in_conflict() {
644697 let dir = tempfile::tempdir().unwrap();
645698 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
646699 let (sid, oid, _) = text(&source);
647700 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
648701 let id = save(&cache, oid, 1..2, "L").unwrap();
649 let local = cache.snapshot().unwrap();
650 let pending_review = edited(&source, oid, 1..2, "L");
651 assert!(
652 matches!(cache.review_page(id, &local, &source, &pending_review), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
653 );
702 let refused = |cache: &Replica, id| matches!(cache.resolve(id, Resolution::Mine), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput);
703 assert!(refused(&cache, id));
654704 let remote = onestore::replace_text(&source, sid, oid, 0..3, "🦀Rc").unwrap();
655705 let mut server = Server::new(&remote);
656706 assert!(matches!(
657 cache.sync_once(&mut server).unwrap(),
707 cache.sync_once(&mut server).unwrap().edit,
658708 Some((_, EditStatus::Conflict(_)))
659709 ));
660710 let pending = cache.pending().unwrap();
661 let reviewed = edited(&remote, oid, 2..3, "L");
662 assert!(
663 matches!(cache.review_page(id + 1, &local, &remote, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
664 );
711 assert!(refused(&cache, id + 1));
665712 assert_eq!(cache.pending().unwrap(), pending);
666 save(&cache, oid, 0..0, "Later ").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();
713 cache.resolve(id, Resolution::Mine).unwrap();
690714 server.fault = Fault::UnknownBefore;
691715 assert!(
692716 matches!(cache.sync_once(&mut server), Err(Error::Remote(error)) if error.state == CommitState::Unknown)
693717 );
694718 let attempted = cache.status(id).unwrap();
695 let pending = cache.pending().unwrap();
696 assert!(
697 matches!(cache.review_page(id, &changed, &current, &reviewed), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput)
698 );
719 assert!(matches!(
720 attempted,
721 Some(EditStatus::AwaitingConfirmation { .. })
722 ));
723 assert!(refused(&cache, id));
699724 assert_eq!(cache.status(id).unwrap(), attempted);
700 assert_eq!(cache.pending().unwrap(), pending);
701725 assert_eq!(server.publications, 1);
702726}
703727
704728#[test]
705fn failure_between_review_and_conflict_clear_rolls_back_the_entire_resolution() {
729fn a_failed_resolution_leaves_the_conflict_as_it_was() {
706730 let dir = tempfile::tempdir().unwrap();
707731 let path = dir.path().join("cache.sqlite");
708732 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()
712736 let remote = onestore::replace_text(&source, sid, oid, 0..3, "XaRc").unwrap();
713737 let mut server = Server::new(&remote);
714738 cache.sync_once(&mut server).unwrap();
715 let local = cache.snapshot().unwrap();
739 let local = pages(&cache.snapshot().unwrap());
716740 let pending = cache.pending().unwrap();
717741 let status = cache.status(id).unwrap();
718742 drop(cache);
719743 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();
721745 drop(connection);
722746 let cache = Replica::open(&path).unwrap();
723 let reviewed = edited(&remote, oid, 2..3, "L");
724747 assert!(matches!(
725 cache.review_page(id, &local, &remote, &reviewed),
748 cache.resolve(id, Resolution::Mine),
726749 Err(Error::Database(_))
727750 ));
728751 drop(cache);
729752 let cache = Replica::open(&path).unwrap();
730753 assert_eq!(cache.pending().unwrap(), pending);
731754 assert_eq!(cache.status(id).unwrap(), status);
732 assert_eq!(cache.snapshot().unwrap(), local);
755 assert_eq!(pages(&cache.snapshot().unwrap()), local);
733756 assert_eq!(cache.remote_snapshot().unwrap(), remote);
734757 assert_eq!(server.publications, 0);
735758}
736759
737760#[test]
738fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
761fn seeded_conflicts_resolve_to_the_remote_text_and_later_edits_publish_once() {
739762 let dir = tempfile::tempdir().unwrap();
740763 let original: Vec<_> = "abcdefghij🦀klmnop".chars().collect();
741764 let mut seed = 911_u64;
......@@ -757,8 +780,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
757780 let (sid, oid, _) = text(&source);
758781 let cache = Replica::create(dir.path().join(format!("{case}.sqlite")), &source).unwrap();
759782 let id = save(&cache, oid, start..end, "λ🦊μ").unwrap();
760 let dependent = save(&cache, oid, start + 1..start + 3, "🐕").unwrap();
761 assert_eq!(dependent, id, "case {case}, seed {seed}");
783 save(&cache, oid, start + 1..start + 3, "🐕").unwrap();
762784 let mut remote_text = original.clone();
763785 remote_text.splice(at..at + 1, "Ω🐈π".chars());
764786 let remote_text = prefix.clone() + &remote_text.iter().collect::<String>() + &suffix;
......@@ -771,18 +793,21 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
771793 )
772794 .unwrap();
773795 let mut server = Server::new(&remote);
774 assert_eq!(
775 cache.sync_once(&mut server).unwrap(),
776 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
796 assert!(
797 matches!(
798 cache.sync_once(&mut server).unwrap().edit,
799 Some((_, EditStatus::Conflict(ConflictKind::ContentChanged)))
800 ),
777801 "case {case}, seed {seed}"
778802 );
779803 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| {
781805 model_ops::replace_text(page, oid, at_remote..at_remote + 4, "λ🐕μ")
782806 })
807 .unwrap()
783808 .unwrap();
784809 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),
786811 "case {case}, seed {seed}"
787812 );
788813 let mut expected = original.clone();
......@@ -799,7 +824,7 @@ fn seeded_reviews_preserve_unicode_around_the_reviewed_replacement() {
799824}
800825
801826#[test]
802fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
827fn remote_changes_after_resolution_are_never_overwritten() {
803828 struct ChangedAfterRead {
804829 server: Server,
805830 change: Option<Vec<u8>>,
......@@ -826,20 +851,22 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
826851 let (sid, oid, _) = text(&source);
827852 let cache = Replica::create(dir.path().join("cache.sqlite"), &source).unwrap();
828853 let id = save(&cache, oid, 1..2, "L").unwrap();
829 let local = cache.snapshot().unwrap();
830854 let remote = onestore::replace_text(&source, sid, oid, 1..2, "R").unwrap();
831855 let mut remote = ChangedAfterRead {
832856 server: Server::new(&remote),
833857 change: None,
834858 };
835859 assert!(matches!(
836 cache.sync_once(&mut remote).unwrap(),
860 cache.sync_once(&mut remote).unwrap().edit,
837861 Some((_, EditStatus::Conflict(_)))
838862 ));
839 model_ops::review(&cache, id, oid, |page| {
863 let id = model_ops::review(&cache, id, oid, |page| {
840864 model_ops::replace_text(page, oid, 1..2, "L")
841865 })
866 .unwrap()
842867 .unwrap();
868 let local = content(&cache.snapshot().unwrap(), oid);
869 assert_eq!(local, "aLc");
843870 let changed = onestore::replace_text(&remote.server.visible, sid, oid, 1..2, "Q").unwrap();
844871 if after_read {
845872 remote.change = Some(changed.clone());
......@@ -854,20 +881,19 @@ fn remote_changes_after_review_cannot_be_overwritten_by_the_reviewed_page() {
854881 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
855882 }
856883 assert_eq!(
857 cache.sync_once(&mut remote).unwrap(),
884 cache.sync_once(&mut remote).unwrap().edit,
858885 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
859886 );
860887 assert_eq!(remote.server.durable, changed);
861888 assert_eq!(remote.server.visible, changed);
862889 assert_eq!(remote.server.publications, usize::from(after_read));
863 assert_eq!(cache.snapshot().unwrap(), local);
864 let notebook::Operation::Page(intent) = &cache.pending().unwrap()[0].operation else {
865 panic!()
866 };
867 assert_eq!(
868 intent.text_change(),
869 Some((oid, "aRc".into(), 1..2, "L".into()))
870 );
890 assert_eq!(content(&cache.snapshot().unwrap(), oid), local);
891 assert!(cache.pending().unwrap()[0].edit.ops.is_empty());
892 assert!(matches!(
893 &cache.pending().unwrap()[1].edit.ops[..],
894 [onestore::op::Op::Page { op: onestore::op::PageOp::Text { range, with, .. }, .. }]
895 if *range == (1..2) && with == "L"
896 ));
871897 }
872898}
873899
......@@ -880,7 +906,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
880906 let cache = Replica::create(&path, &source).unwrap();
881907 let mut server = Server::new(&source);
882908 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();
884910 assert!(matches!(receipt, EditStatus::Published { .. }));
885911 let published_image = cache.snapshot().unwrap();
886912 cache
......@@ -891,7 +917,7 @@ fn remote_restore_retains_historical_receipts_without_replaying_them() {
891917 server.visible.clone_from(&source);
892918 server.durable.clone_from(&source);
893919 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);
895921 assert_eq!(cache.snapshot().unwrap(), source);
896922 assert_eq!(cache.remote_snapshot().unwrap(), source);
897923 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() {
918944 let cache = Replica::create(&path, &source).unwrap();
919945 let mut server = Server::new(&source);
920946 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();
922948 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
923949 let queued = save(&cache, object, end..end, " Local").unwrap();
924950 if uncertain {
......@@ -936,14 +962,14 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
936962 server.durable.clone_from(&source);
937963 let attempts = server.publications;
938964 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();
940966 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
941967 if uncertain {
942968 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
943969 assert_eq!(Some(status.clone()), prior_status);
944970 assert_eq!(server.publications, attempts);
945971 assert_eq!(server.visible, source);
946 assert_eq!(cache.snapshot().unwrap(), local);
972 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
947973 assert_eq!(cache.pending().unwrap(), pending);
948974 } else {
949975 assert!(matches!(status, EditStatus::Published { .. }));
......@@ -953,7 +979,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
953979 }
954980 let archive =
955981 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));
957983 assert_eq!(archive.pending().unwrap(), pending);
958984 assert_eq!(archive.status(queued).unwrap(), prior_status);
959985 drop(cache);
......@@ -964,7 +990,7 @@ fn remote_restore_rebases_unsent_work_but_never_replays_an_uncertain_attempt() {
964990}
965991
966992#[test]
967fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
993fn restore_conflicts_keep_dependent_work_until_mine_publishes_it() {
968994 let directory = tempfile::tempdir().unwrap();
969995 let path = directory.path().join("cache.sqlite");
970996 let source = onestore::create_section("restore.one", "Original", "Fixture").unwrap();
......@@ -972,19 +998,20 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
972998 let cache = Replica::create(&path, &source).unwrap();
973999 let mut server = Server::new(&source);
9741000 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();
9761002 let head = save(&cache, object, 0..9, "Revised").unwrap();
9771003 server.visible.clone_from(&source);
9781004 server.durable.clone_from(&source);
9791005 let conflict = EditStatus::Conflict(ConflictKind::ContentChanged);
9801006 assert_eq!(
981 cache.sync_once(&mut server).unwrap(),
1007 cache.sync_once(&mut server).unwrap().edit,
9821008 Some((head, conflict.clone()))
9831009 );
9841010 let end = u32::try_from(text(&cache.snapshot().unwrap()).2.encode_utf16().count()).unwrap();
9851011 let dependent = save(&cache, object, end..end, " Later").unwrap();
9861012 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");
9881015 let pending = cache.pending().unwrap();
9891016 assert_eq!(pending.len(), 2);
9901017 cache
......@@ -993,60 +1020,39 @@ fn restore_conflicts_keep_dependent_work_and_reject_stale_review() {
9931020 drop(cache);
9941021 let cache = Replica::open(&path).unwrap();
9951022 assert_eq!(cache.status(head).unwrap(), Some(conflict.clone()));
1023 assert_eq!(cache.status(dependent).unwrap(), Some(conflict.clone()));
9961024 assert_eq!(cache.pending().unwrap(), pending);
997 assert_eq!(cache.snapshot().unwrap(), local);
998 let reviewed = edited(&source, object, 0..0, "Revised ");
9991025 let changed = onestore::replace_text(&source, space, object, 8..8, " Remote").unwrap();
10001026 server.visible.clone_from(&changed);
10011027 server.durable.clone_from(&changed);
10021028 assert_eq!(
1003 cache.sync_once(&mut server).unwrap(),
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(),
1029 cache.sync_once(&mut server).unwrap().edit,
10231030 Some((dependent, conflict.clone()))
10241031 );
1025 assert_eq!(server.publications, 2);
1026 let dependent_local = cache.snapshot().unwrap();
1027 let current = cache.remote_snapshot().unwrap();
1028 let reviewed = edited(&current, object, 16..16, " Later");
1029 cache
1030 .review_page(dependent, &dependent_local, &current, &reviewed)
1031 .unwrap();
1032 assert_eq!(cache.remote_snapshot().unwrap(), changed);
1033 assert_eq!(server.publications, 1);
1034 cache.resolve(head, Resolution::Mine).unwrap();
10321035 drop(cache);
10331036 let cache = Replica::open(&path).unwrap();
1034 assert!(matches!(cache.sync_once(&mut server).unwrap(),
1035 Some((actual, EditStatus::Published { .. })) if actual == dependent));
1036 assert_eq!(text(&server.durable).2, "Revised Original Later Remote");
1037 assert!(matches!(
1038 cache.sync_once(&mut server).unwrap().edit,
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 ));
10371046 assert_eq!(cache.status(published).unwrap(), Some(receipt.clone()));
10381047 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);
10401049 assert_eq!(archive.pending().unwrap(), pending);
10411050 assert_eq!(archive.status(head).unwrap(), Some(conflict.clone()));
10421051}
10431052
10441053#[test]
10451054fn cache_open_validates_the_tail_before_head_only_synchronization() {
1046 for damage in [
1047 "operation='{}'",
1048 "space=(SELECT space FROM edits ORDER BY id LIMIT 1)",
1049 ] {
1055 for damage in ["edit='{}'", "edit=replace(edit, 'Create', 'Delete')"] {
10501056 let directory = tempfile::tempdir().unwrap();
10511057 let path = directory.path().join("cache.sqlite");
10521058 let source = onestore::create_section("tail.one", "Original", "Fixture").unwrap();
......@@ -1054,10 +1060,7 @@ fn cache_open_validates_the_tail_before_head_only_synchronization() {
10541060 let cache = Replica::create(&path, &source).unwrap();
10551061 save(&cache, object, 0..0, "Head ").unwrap();
10561062 let page = onestore::PageCreation::new(None, Some("Tail"), "Fixture").unwrap();
1057 let tail = cache
1058 .create_page(&cache.snapshot().unwrap(), &page)
1059 .unwrap()
1060 .unwrap();
1063 let tail = section_op(&cache, onestore::op::SectionOp::Create(page));
10611064 assert_eq!(cache.pending().unwrap().len(), 2);
10621065 drop(cache);
10631066 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};
77
88/// Saves an edited model of `space`, reaching content that outline helpers cannot.
99fn save_page(cache: &Replica, space: ExGuid, edit: impl FnOnce(&mut Page)) -> Option<u64> {
10 let source = cache.snapshot().unwrap();
11 let mut page = page_of(&source, space);
12 edit(&mut page);
13 cache
14 .save(&source, space, &page, model_ops::AUTHOR)
15 .unwrap()
10 model_ops::save_page(cache, space, edit).unwrap()
1611}
1712
1813#[test]
......@@ -97,7 +92,7 @@ fn move_preserves_remote_content_and_dependent_edits_through_reopen() {
9792 drop(cache);
9893 let cache = Replica::open(&path).unwrap();
9994 assert_eq!(cache.pending().unwrap(), queue);
100 assert_eq!(cache.snapshot().unwrap(), local);
95 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
10196 let mut server = Server::new(&remote_with(&source, space, |page| {
10297 insert_child(page, paragraphs[0], "New remote child");
10398 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() {
169164 let cache = Replica::open(&path).unwrap();
170165 let mut server = Server::new(native);
171166 assert!(matches!(
172 cache.sync_once(&mut server).unwrap(),
167 cache.sync_once(&mut server).unwrap().edit,
173168 Some((published, EditStatus::Published { .. })) if published == id
174169 ));
175170 assert_eq!(server.publications, 1);
......@@ -224,18 +219,12 @@ fn deletion_requires_review_of_remote_content_and_preserves_later_work() {
224219 let local = cache.snapshot().unwrap();
225220 let mut server = Server::new(&remote_with(&source, space, remote));
226221 assert_eq!(
227 cache.sync_once(&mut server).unwrap(),
222 cache.sync_once(&mut server).unwrap().edit,
228223 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
229224 );
230225 assert_eq!(server.publications, 0);
231226 assert_eq!(cache.pending().unwrap(), queue);
232 assert_eq!(cache.snapshot().unwrap(), 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 );
227 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
239228 drop(cache);
240229 let cache = Replica::open(&path).unwrap();
241230 assert_eq!(
......@@ -270,9 +259,10 @@ fn sibling_and_ancestry_changes_have_explicit_merge_or_conflict() {
270259 Box::new(move |page| delete_paragraph(page, texts[1])),
271260 Some(&[2, 3, 0]),
272261 ),
262 // Both moved a paragraph last; the local move replays after the remote one.
273263 (
274264 Box::new(move |page| move_subtree(page, paragraphs[1], None, None)),
275 Some(&[2, 3, 0, 1]),
265 Some(&[2, 3, 1, 0]),
276266 ),
277267 (
278268 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() {
289279 .unwrap()
290280 .unwrap();
291281 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();
293283 assert_eq!(result.0, id);
294284 let Some(expected) = expected else {
295285 assert_eq!(result.1, EditStatus::Conflict(ConflictKind::ContentChanged));
......@@ -361,9 +351,14 @@ fn cell_mut(page: &mut Page, id: ExGuid) -> &mut TableCell {
361351 .expect("the cell is on the page")
362352}
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.
365356fn 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);
367362 let Some(destination) = destination else {
368363 return;
369364 };
......@@ -436,7 +431,7 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(
436431 drop(cache);
437432 let cache = Replica::open(&path).unwrap();
438433 assert_eq!(cache.pending().unwrap(), queue);
439 assert_eq!(cache.snapshot().unwrap(), local);
434 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
440435 let mut server = Server::new(&if competing {
441436 remote_with(source, space, |page| {
442437 let target = cell_mut(page, cell);
......@@ -452,13 +447,21 @@ fn emptied_cell_replacement_is_durable_and_cannot_be_silently_omitted_on_replay(
452447 })
453448 });
454449 if competing {
455 assert_eq!(
456 cache.sync_once(&mut server).unwrap(),
457 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
450 // The remote's new paragraph and the replacement both stay; the target goes.
451 published(&cache, &mut server, id);
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")
458464 );
459 assert_eq!(server.publications, 0);
460 assert_eq!(cache.pending().unwrap(), queue);
461 assert_eq!(cache.snapshot().unwrap(), local);
462465 continue;
463466 }
464467 published(&cache, &mut server, id);
......@@ -573,7 +576,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten
573576 let id = save(&cache, dependent, save_both).unwrap().unwrap();
574577 let local = cache.snapshot().unwrap();
575578 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() {
577580 (actual, EditStatus::Published { .. }) => {
578581 assert_eq!(actual, id, "{name}");
579582 true
......@@ -585,7 +588,7 @@ fn native_tree_and_layout_changes_reconcile_without_discarding_unreviewed_conten
585588 "{name}"
586589 );
587590 assert_eq!(cache.pending().unwrap(), queue);
588 assert_eq!(cache.snapshot().unwrap(), local);
591 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
589592 drop(cache);
590593 let cache = Replica::open(&path).unwrap();
591594 review(&cache, id, dependent, save_both).unwrap();
crates/notebook/tests/sync_formatting.rs+66-32
......@@ -10,7 +10,7 @@ use onestore::{
1010
1111#[path = "support/server.rs"]
1212mod server;
13use server::{Fault, Server};
13use server::{Fault, Server, pages};
1414#[path = "support/model_ops.rs"]
1515mod model_ops;
1616use model_ops::{
......@@ -103,7 +103,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
103103 .unwrap();
104104 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
105105 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)
107107 );
108108 let published = page_of(&server.durable, space);
109109 assert!(
......@@ -116,7 +116,7 @@ fn offline_formatting_merges_with_a_remote_edit_to_another_paragraph() {
116116}
117117
118118#[test]
119fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {
119fn offline_formatting_conflicts_only_with_remote_changes_it_overlaps() {
120120 let (space, page) = page_titled(OUTLINES, PAGE);
121121 let ids = plain(&page);
122122 for typed in [false, true] {
......@@ -135,13 +135,28 @@ fn offline_formatting_conflicts_with_any_remote_change_to_its_own_paragraph() {
135135 restyle(page, ids[0], 0..3, |format| format.italic = Some(true));
136136 }
137137 });
138 assert_eq!(
139 cache.sync_once(&mut server).unwrap(),
140 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
141 "typed {typed}"
142 );
143 assert_eq!(server.publications, 0);
144 assert_eq!(cache.snapshot().unwrap(), local);
138 let outcome = cache.sync_once(&mut server).unwrap().edit;
139 if typed {
140 // Text typed before the formatted range moves it; the ranges do not meet.
141 assert!(matches!(outcome, Some((n, EditStatus::Published { .. })) if n == id));
142 let published = page_of(&server.durable, space);
143 assert!(text_of(&published, ids[0]).starts_with("Remote Anchor"));
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 }
145160 }
146161}
147162
......@@ -163,7 +178,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {
163178 });
164179 for _ in 0..2 {
165180 assert_eq!(
166 cache.sync_once(&mut server).unwrap(),
181 cache.sync_once(&mut server).unwrap().edit,
167182 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
168183 );
169184 }
......@@ -179,7 +194,7 @@ fn competing_formatting_of_one_paragraph_conflicts_until_reviewed() {
179194 })
180195 .unwrap();
181196 assert!(matches!(
182 cache.sync_once(&mut server).unwrap(),
197 cache.sync_once(&mut server).unwrap().edit,
183198 Some((_, EditStatus::Published { .. }))
184199 ));
185200 let published = formats(&page_of(&server.durable, space), ids[0]);
......@@ -223,7 +238,7 @@ fn every_visual_attribute_publishes_through_a_page_model_save() {
223238 .unwrap();
224239 let mut server = Server::new(OUTLINES);
225240 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),
227242 "{name}"
228243 );
229244 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
282297 }
283298 }
284299 });
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();
286302 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 {
288315 conflicts += 1;
289316 assert_eq!(
290317 outcome.1,
......@@ -292,7 +319,11 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
292319 "seed {seed}"
293320 );
294321 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 );
296327 continue;
297328 }
298329 published += 1;
......@@ -301,8 +332,9 @@ fn seeded_formatting_reconciles_exactly_when_the_remote_left_the_paragraph_alone
301332 "seed {seed}: {outcome:?}"
302333 );
303334 let page = page_of(&server.durable, space);
304 let mut expected: Vec<Format> = formats(&page_of(OUTLINES, space), ids[target]);
305 for format in &mut expected[start as usize..end as usize] {
335 let shift = bullets[target] 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] {
306338 attribute(format);
307339 }
308340 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
316348 assert_eq!(cache.snapshot().unwrap(), server.durable, "seed {seed}");
317349 }
318350 assert!(
319 published >= 16 && conflicts >= 16,
351 published >= 16,
320352 "{published} published, {conflicts} conflicts"
321353 );
322354}
......@@ -338,11 +370,11 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {
338370 let pending = cache.pending().unwrap();
339371 drop(cache);
340372 cache = Replica::open(&path).unwrap();
341 assert_eq!(cache.snapshot().unwrap(), local);
373 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
342374 assert_eq!(cache.pending().unwrap(), pending);
343375 let mut server = remote_with(space, |page| replace_text(page, ids[3], 0..0, "Remote "));
344376 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)
346378 );
347379 let published = page_of(&server.durable, space);
348380 let inserted = plain(&published)
......@@ -361,7 +393,7 @@ fn offline_insertions_survive_reopen_and_carry_their_formatting() {
361393 .unwrap()
362394 .unwrap();
363395 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)
365397 );
366398 let styles = formats(&page_of(&server.durable, space), inserted);
367399 for (offset, format) in styles.iter().enumerate() {
......@@ -395,17 +427,17 @@ fn uncertain_formatting_attempts_keep_the_original_attempt_and_never_replay() {
395427 if matches!(fault, Fault::UnknownBefore) {
396428 for _ in 0..3 {
397429 assert_eq!(
398 cache.sync_once(&mut server).unwrap(),
430 cache.sync_once(&mut server).unwrap().edit,
399431 Some((id, state.clone()))
400432 );
401433 }
402434 assert_eq!(server.publications, 1);
403435 assert_eq!(server.durable, OUTLINES);
404 assert_eq!(cache.snapshot().unwrap(), local);
436 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
405437 continue;
406438 }
407439 assert!(matches!(
408 cache.sync_once(&mut server).unwrap(),
440 cache.sync_once(&mut server).unwrap().edit,
409441 Some((_, EditStatus::Published { .. }))
410442 ));
411443 assert_eq!(server.publications, 1);
......@@ -438,7 +470,7 @@ fn a_format_the_remote_already_carries_is_confirmed_without_republishing() {
438470 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
439471 assert_eq!(server.durable, OUTLINES);
440472 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)
442474 );
443475 assert_eq!((server.publications, server.confirmations), (0, 2));
444476 assert!(cache.pending().unwrap().is_empty());
......@@ -459,8 +491,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
459491 restyle(&mut after, ids[0], 0..6, |format| format.bold = Some(true));
460492 let directory = tempfile::tempdir().unwrap();
461493 let cache = cache(&directory);
462 let id = cache
463 .save(OUTLINES, space, &after, AUTHOR)
494 let id = model_ops::save_as(&cache, space, &after, AUTHOR)
464495 .unwrap()
465496 .unwrap();
466497 let mut server = Server::new(OUTLINES);
......@@ -476,7 +507,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
476507 });
477508 server.visible.clone_from(&partial.visible);
478509 assert_eq!(
479 cache.sync_once(&mut server).unwrap(),
510 cache.sync_once(&mut server).unwrap().edit,
480511 attempt.map(|state| (id, state))
481512 );
482513 assert_eq!(server.confirmations, 0);
......@@ -486,7 +517,7 @@ fn a_retired_attempt_confirms_only_once_the_remote_page_equals_it() {
486517 server.visible.clone_from(&complete.visible);
487518 assert_eq!(page_of(&server.visible, space), after);
488519 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)
490521 );
491522 assert_eq!((server.publications, server.confirmations), (1, 1));
492523 assert!(cache.pending().unwrap().is_empty());
......@@ -497,7 +528,10 @@ fn a_formatted_save_of_an_unchanged_model_queues_nothing() {
497528 let (space, page) = page_titled(OUTLINES, PAGE);
498529 let directory = tempfile::tempdir().unwrap();
499530 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 );
501535 assert!(cache.pending().unwrap().is_empty());
502536}
503537
......@@ -533,7 +567,7 @@ fn paragraph_formatting_survives_reopen_rebase_and_a_lost_publication_reply() {
533567 drop(cache);
534568 let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap();
535569 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)
537571 );
538572 assert_eq!(server.publications, 1);
539573 let page = page_of(&server.durable, space);
crates/notebook/tests/sync_model.rs+78-91
......@@ -10,7 +10,6 @@ use server::*;
1010#[path = "support/model_ops.rs"]
1111mod model_ops;
1212
13use notebook::{Operation, PageIntent};
1413use onestore::page::{Page, PageObject};
1514
1615const OUTLINES: &[u8] =
......@@ -77,70 +76,58 @@ fn a_saved_page_model_publishes_and_reads_back() {
7776 let directory = tempfile::tempdir().unwrap();
7877 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
7978 append(&mut after, " saved 🦀");
80 let id = cache
81 .save(OUTLINES, space, &after, "Model author")
79 let id = model_ops::save_as(&cache, space, &after, "Model author")
8280 .unwrap()
8381 .unwrap();
8482 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
8583 assert_eq!(page_of(&cache.snapshot().unwrap(), space), after);
8684 let pending = cache.pending().unwrap();
8785 assert_eq!(pending.len(), 1);
88 let Operation::Page(PageIntent {
89 before,
90 after: stored,
91 author,
92 }) = &pending[0].operation
93 else {
94 panic!()
95 };
96 assert_eq!(
97 (before, stored, author.as_str()),
98 (&page_of(OUTLINES, space), &after, "Model author")
86 assert_eq!(pending[0].author, "Model author");
87 assert!(
88 pending[0]
89 .edit
90 .ops
91 .iter()
92 .all(|op| matches!(op, onestore::op::Op::Page { space: s, .. } if *s == space))
9993 );
10094 let mut server = Server::new(OUTLINES);
10195 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)
10397 );
10498 assert_eq!(page_of(&server.durable, space), after);
10599 assert_eq!(server.publications, 1);
106100 assert!(cache.pending().unwrap().is_empty());
107101 assert_eq!(
108 cache
109 .save(&cache.snapshot().unwrap(), space, &after, "Model author")
110 .unwrap(),
102 model_ops::save_as(&cache, space, &after, "Model author").unwrap(),
111103 None
112104 );
113105}
114106
115107#[test]
116fn saves_before_an_attempt_coalesce_into_one_publication() {
108fn saves_before_an_attempt_publish_together() {
117109 let (space, mut after) = page_titled(OUTLINES, "Move leaf down");
118110 let directory = tempfile::tempdir().unwrap();
119111 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
120112 append(&mut after, " one");
121 let id = cache
122 .save(OUTLINES, space, &after, "Model author")
113 let id = model_ops::save_as(&cache, space, &after, "Model author")
123114 .unwrap()
124115 .unwrap();
125116 let mut later = page_of(&cache.snapshot().unwrap(), space);
126117 append(&mut later, " two");
127 assert_eq!(
128 cache
129 .save(&cache.snapshot().unwrap(), space, &later, "Model author")
130 .unwrap(),
131 Some(id)
132 );
133 let pending = cache.pending().unwrap();
134 assert_eq!(pending.len(), 1);
135 let Operation::Page(intent) = &pending[0].operation else {
136 panic!()
137 };
138 assert_eq!(intent.before, page_of(OUTLINES, space));
139 assert_eq!(intent.after, later);
118 let second = model_ops::save_as(&cache, space, &later, "Model author")
119 .unwrap()
120 .unwrap();
121 assert!(second > id);
122 assert_eq!(cache.pending().unwrap().len(), 2);
140123 let mut server = Server::new(OUTLINES);
141124 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)
143126 );
127 assert!(matches!(
128 cache.status(id).unwrap(),
129 Some(EditStatus::Published { .. })
130 ));
144131 assert_eq!(server.publications, 1);
145132 assert_eq!(
146133 first_text(&page_of(&server.durable, space)),
......@@ -148,11 +135,10 @@ fn saves_before_an_attempt_coalesce_into_one_publication() {
148135 );
149136 let mut third = page_of(&cache.snapshot().unwrap(), space);
150137 append(&mut third, " three");
151 let next = cache
152 .save(&cache.snapshot().unwrap(), space, &third, "Model author")
138 let next = model_ops::save_as(&cache, space, &third, "Model author")
153139 .unwrap()
154140 .unwrap();
155 assert!(next > id);
141 assert!(next > second);
156142}
157143
158144#[test]
......@@ -161,8 +147,7 @@ fn an_attempted_save_is_never_rewritten() {
161147 let directory = tempfile::tempdir().unwrap();
162148 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
163149 append(&mut after, " uncertain");
164 let id = cache
165 .save(OUTLINES, space, &after, "Model author")
150 let id = model_ops::save_as(&cache, space, &after, "Model author")
166151 .unwrap()
167152 .unwrap();
168153 let mut server = Server::new(OUTLINES);
......@@ -174,21 +159,18 @@ fn an_attempted_save_is_never_rewritten() {
174159 ));
175160 let mut more = page_of(&cache.snapshot().unwrap(), space);
176161 append(&mut more, " more");
177 let next = cache
178 .save(&cache.snapshot().unwrap(), space, &more, "Model author")
162 let next = model_ops::save_as(&cache, space, &more, "Model author")
179163 .unwrap()
180164 .unwrap();
181165 assert!(next > id);
182166 let pending = cache.pending().unwrap();
183167 assert_eq!(pending.iter().map(|p| p.id).collect::<Vec<_>>(), [id, next]);
184 let Operation::Page(first) = &pending[0].operation else {
185 panic!()
186 };
187 assert_eq!(first.after, after);
188 let Operation::Page(second) = &pending[1].operation else {
189 panic!()
190 };
191 assert_eq!((&second.before, &second.after), (&after, &more));
168 assert_eq!(page_of(&cache.snapshot().unwrap(), space), more);
169 assert!(matches!(
170 cache.status(id).unwrap(),
171 Some(EditStatus::AwaitingConfirmation { .. })
172 ));
173 assert_eq!(cache.status(next).unwrap(), Some(EditStatus::Pending));
192174}
193175
194176#[test]
......@@ -197,8 +179,7 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
197179 let directory = tempfile::tempdir().unwrap();
198180 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
199181 append(&mut after, " local");
200 let id = cache
201 .save(OUTLINES, space, &after, "Model author")
182 let id = model_ops::save_as(&cache, space, &after, "Model author")
202183 .unwrap()
203184 .unwrap();
204185 let mut remote_page = page_of(OUTLINES, space);
......@@ -206,26 +187,42 @@ fn a_remote_change_to_the_saved_page_conflicts_until_reviewed() {
206187 let remote = PreparedEdit::page(OUTLINES, space, &remote_page, "Native author").unwrap();
207188 let mut server = Server::new(remote.as_bytes());
208189 assert_eq!(
209 cache.sync_once(&mut server).unwrap(),
190 cache.sync_once(&mut server).unwrap().edit,
210191 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
211192 );
212193 assert_eq!(server.publications, 0);
213194 assert!(cache.pending().unwrap().len() == 1);
214 let local = cache.snapshot().unwrap();
215195 let observed = cache.remote_snapshot().unwrap();
216196 assert_eq!(observed, remote.as_bytes());
217197 let mut reviewed = page_of(&observed, space);
218198 append(&mut reviewed, " local");
219 assert!(
220 cache
221 .review_page(id + 1, &local, &observed, &reviewed)
222 .is_err()
223 );
224 assert!(cache.review_page(id, &local, OUTLINES, &reviewed).is_err());
225 cache.review_page(id, &local, &observed, &reviewed).unwrap();
199 assert!(cache.resolve(id + 1, notebook::Resolution::Theirs).is_err());
200 let text = model_ops::paragraph_with(&reviewed, {
201 let outline = reviewed
202 .objects
203 .iter()
204 .find_map(|object| match object {
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.
226219 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
220 assert_eq!(
221 cache.status(reviewed_id).unwrap(),
222 Some(EditStatus::Pending)
223 );
227224 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)
229226 );
230227 assert_eq!(
231228 first_text(&page_of(&server.durable, space)),
......@@ -241,15 +238,14 @@ fn remote_changes_to_other_pages_do_not_conflict() {
241238 let directory = tempfile::tempdir().unwrap();
242239 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
243240 append(&mut after, " local");
244 let id = cache
245 .save(OUTLINES, space, &after, "Model author")
241 let id = model_ops::save_as(&cache, space, &after, "Model author")
246242 .unwrap()
247243 .unwrap();
248244 append(&mut other_page, " elsewhere");
249245 let remote = PreparedEdit::page(OUTLINES, other, &other_page, "Native author").unwrap();
250246 let mut server = Server::new(remote.as_bytes());
251247 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)
253249 );
254250 assert_eq!(page_of(&server.durable, space), after);
255251 assert_eq!(page_of(&server.durable, other), other_page);
......@@ -263,8 +259,7 @@ fn pending_saves_survive_reopening_the_cache() {
263259 let path = directory.path().join("cache.sqlite");
264260 let cache = Replica::create(&path, OUTLINES).unwrap();
265261 append(&mut after, " durable");
266 let id = cache
267 .save(OUTLINES, space, &after, "Model author")
262 let id = model_ops::save_as(&cache, space, &after, "Model author")
268263 .unwrap()
269264 .unwrap();
270265 let pending = cache.pending().unwrap();
......@@ -327,13 +322,12 @@ fn concurrent_edits_to_different_paragraphs_merge() {
327322 let directory = tempfile::tempdir().unwrap();
328323 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
329324 edit_paragraph(&mut after, 0, 0..0, "Local ");
330 let id = cache
331 .save(OUTLINES, space, &after, "Model author")
325 let id = model_ops::save_as(&cache, space, &after, "Model author")
332326 .unwrap()
333327 .unwrap();
334328 let mut server = remote_with(space, |page| edit_paragraph(page, 2, 0..0, "Remote "));
335329 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)
337331 );
338332 let published = texts(&page_of(&server.durable, space));
339333 assert!(published[0].starts_with("Local Anchor"), "{published:?}");
......@@ -353,13 +347,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
353347 let directory = tempfile::tempdir().unwrap();
354348 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
355349 append(&mut after, " local");
356 let id = cache
357 .save(OUTLINES, space, &after, "Model author")
350 let id = model_ops::save_as(&cache, space, &after, "Model author")
358351 .unwrap()
359352 .unwrap();
360353 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
361354 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)
363356 );
364357 assert_eq!(
365358 texts(&page_of(&server.durable, space))[0],
......@@ -369,13 +362,12 @@ fn concurrent_edits_to_one_paragraph_merge_unless_their_ranges_overlap() {
369362 let cache = Replica::create(directory.path().join("overlap.sqlite"), OUTLINES).unwrap();
370363 let mut after = page_of(OUTLINES, space);
371364 edit_paragraph(&mut after, 0, 0..6, "Local");
372 let id = cache
373 .save(OUTLINES, space, &after, "Model author")
365 let id = model_ops::save_as(&cache, space, &after, "Model author")
374366 .unwrap()
375367 .unwrap();
376368 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..6, "Remote"));
377369 assert_eq!(
378 cache.sync_once(&mut server).unwrap(),
370 cache.sync_once(&mut server).unwrap().edit,
379371 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
380372 );
381373 assert_eq!(server.publications, 0);
......@@ -408,8 +400,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
408400 );
409401 outline.paragraphs.insert(1, fresh);
410402 }
411 let id = cache
412 .save(OUTLINES, space, &after, "Model author")
403 let id = model_ops::save_as(&cache, space, &after, "Model author")
413404 .unwrap()
414405 .unwrap();
415406 let mut server = remote_with(space, |page| {
......@@ -424,7 +415,7 @@ fn a_local_insertion_merges_with_a_remote_deletion_elsewhere() {
424415 outline.paragraphs.pop();
425416 });
426417 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)
428419 );
429420 assert_eq!(
430421 texts(&page_of(&server.durable, space)),
......@@ -442,8 +433,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
442433 let directory = tempfile::tempdir().unwrap();
443434 let cache = Replica::create(directory.path().join("cache.sqlite"), OUTLINES).unwrap();
444435 edit_paragraph(&mut after, 2, 0..0, "Local ");
445 let id = cache
446 .save(OUTLINES, space, &after, "Model author")
436 let id = model_ops::save_as(&cache, space, &after, "Model author")
447437 .unwrap()
448438 .unwrap();
449439 let mut server = remote_with(space, |page| {
......@@ -458,7 +448,7 @@ fn a_remote_deletion_of_the_edited_paragraph_conflicts() {
458448 outline.paragraphs.pop();
459449 });
460450 assert_eq!(
461 cache.sync_once(&mut server).unwrap(),
451 cache.sync_once(&mut server).unwrap().edit,
462452 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
463453 );
464454}
......@@ -481,13 +471,12 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
481471 outline.layout.y = Some(300.0);
482472 outline.id
483473 };
484 let id = cache
485 .save(OUTLINES, space, &after, "Model author")
474 let id = model_ops::save_as(&cache, space, &after, "Model author")
486475 .unwrap()
487476 .unwrap();
488477 let mut server = remote_with(space, |page| edit_paragraph(page, 0, 0..0, "Remote "));
489478 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)
491480 );
492481 let published = page_of(&server.durable, space);
493482 let PageObject::Outline(outline) = published
......@@ -512,8 +501,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
512501 );
513502
514503 let cache = Replica::create(directory.path().join("moves.sqlite"), OUTLINES).unwrap();
515 let id = cache
516 .save(OUTLINES, space, &after, "Model author")
504 let id = model_ops::save_as(&cache, space, &after, "Model author")
517505 .unwrap()
518506 .unwrap();
519507 let mut server = remote_with(space, |page| {
......@@ -528,7 +516,7 @@ fn outline_moves_merge_with_remote_text_edits_but_not_with_remote_moves() {
528516 outline.layout.x = Some(50.0);
529517 });
530518 assert_eq!(
531 cache.sync_once(&mut server).unwrap(),
519 cache.sync_once(&mut server).unwrap().edit,
532520 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
533521 );
534522}
......@@ -565,13 +553,12 @@ fn a_local_bullet_merges_with_a_remote_text_edit_and_publishes_its_definition()
565553 })
566554 .unwrap();
567555 outline.paragraphs[0].lists = vec![bullet];
568 let id = cache
569 .save(OUTLINES, space, &after, "Model author")
556 let id = model_ops::save_as(&cache, space, &after, "Model author")
570557 .unwrap()
571558 .unwrap();
572559 let mut server = remote_with(space, |page| edit_paragraph(page, 1, 0..0, "Remote "));
573560 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)
575562 );
576563 let published = page_of(&server.durable, space);
577564 let outline = published
crates/notebook/tests/sync_outline.rs+27-32
......@@ -11,7 +11,7 @@ use serde_json::json;
1111
1212#[path = "support/server.rs"]
1313mod server;
14use server::{Fault, Server};
14use server::{Fault, Server, pages};
1515#[path = "support/model_ops.rs"]
1616mod model_ops;
1717use model_ops::{
......@@ -105,10 +105,21 @@ pub(crate) fn remote_with(source: &[u8], space: ExGuid, change: impl FnOnce(&mut
105105 .to_vec()
106106}
107107
108/// Publishes the batch holding `id`, unless an earlier step already did.
108109pub(crate) fn published(cache: &Replica, server: &mut Server, id: u64) {
109 assert!(
110 matches!(cache.sync_once(server).unwrap(), Some((actual, EditStatus::Published { .. })) if actual == id)
111 );
110 if !matches!(
111 cache.status(id).unwrap(),
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 ));
112123}
113124
114125fn 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
144155 drop(cache);
145156 let cache = Replica::open(&path).unwrap();
146157 assert_eq!(cache.pending().unwrap(), pending);
147 assert_eq!(cache.snapshot().unwrap(), local);
158 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
148159 let mut server = Server::new(&remote_with(&source, space, |page| {
149160 restyle(page, text_id, 0..8, |format| format.bold = Some(true));
150161 }));
......@@ -232,7 +243,7 @@ fn an_uncertain_move_confirms_from_the_remote_revision_and_adopts_the_remote_tit
232243}
233244
234245#[test]
235fn competing_layout_requires_review_against_the_current_remote_page() {
246fn competing_layout_conflicts_until_the_local_page_is_kept() {
236247 let (source, space, outline, text_id) = fixture();
237248 let cases: [(Change<Outline>, Change<Outline>); 2] = [
238249 (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() {
255266 remote_change(outline_mut(page, outline));
256267 }));
257268 assert_eq!(
258 cache.sync_once(&mut server).unwrap(),
269 cache.sync_once(&mut server).unwrap().edit,
259270 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
260271 );
261272 assert_eq!(server.publications, 0);
262 assert_eq!(cache.snapshot().unwrap(), local);
273 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
263274 let dependent = save(&cache, text_id, |page| {
264275 replace_text(page, text_id, 0..0, "Local ")
265276 })
266277 .unwrap()
267278 .unwrap();
268 let queue = cache.pending().unwrap();
269279 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 );
281280 drop(cache);
282281 let cache = Replica::open(&path).unwrap();
283282 assert_eq!(
284283 cache.status(id).unwrap(),
285284 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
286285 );
287 review(&cache, id, text_id, |page| {
288 local_change(outline_mut(page, outline))
289 })
290 .unwrap();
286 cache.resolve(id, notebook::Resolution::Mine).unwrap();
291287 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
292 assert_eq!(cache.pending().unwrap()[1], queue[1]);
293288 drop(cache);
294289 let cache = Replica::open(&path).unwrap();
295290 for id in [id, dependent] {
......@@ -354,7 +349,7 @@ fn twelve_offline_writers_preserve_all_layout_intents_through_review_and_restart
354349 let expected = page_of(&cache.snapshot().unwrap(), space);
355350 drop(cache);
356351 let cache = Replica::open(&path).unwrap();
357 if cache.sync_once(&mut server).unwrap()
352 if cache.sync_once(&mut server).unwrap().edit
358353 == Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
359354 {
360355 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()
411406 continue;
412407 }
413408 assert_eq!(
414 cache.sync_once(&mut server).unwrap(),
409 cache.sync_once(&mut server).unwrap().edit,
415410 Some((id, status.clone()))
416411 );
417412 server.visible = remote_with(&source, space, |page| {
418413 resized(216.0, true)(outline_mut(page, outline));
419414 });
420415 assert_eq!(
421 cache.sync_once(&mut server).unwrap(),
416 cache.sync_once(&mut server).unwrap().edit,
422417 Some((id, status.clone()))
423418 );
424 assert_eq!(cache.snapshot().unwrap(), local);
419 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
425420 server.visible = remote_with(&source, space, |page| {
426421 resized(144.0, true)(outline_mut(page, outline));
427422 });
......@@ -503,7 +498,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {
503498 let id = save(&cache, dependent, save_both).unwrap().unwrap();
504499 let local = cache.snapshot().unwrap();
505500 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() {
507502 (actual, EditStatus::Published { .. }) => {
508503 assert_eq!(actual, id, "{name}");
509504 true
......@@ -515,7 +510,7 @@ fn native_moves_deletions_and_layout_changes_merge_or_require_review() {
515510 "{name}"
516511 );
517512 assert_eq!(cache.pending().unwrap(), queue);
518 assert_eq!(cache.snapshot().unwrap(), local);
513 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
519514 drop(cache);
520515 let cache = Replica::open(&path).unwrap();
521516 review(&cache, id, dependent, save_both).unwrap();
......@@ -590,7 +585,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
590585 .unwrap();
591586 let mut server = Server::new(NATIVE);
592587 assert_eq!(
593 cache.sync_once(&mut server).unwrap(),
588 cache.sync_once(&mut server).unwrap().edit,
594589 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
595590 );
596591 review(&cache, id, text_id, |page| {
crates/notebook/tests/sync_page.rs+166-71
......@@ -1,7 +1,8 @@
11//! Page creation reconciliation: dependent body saves, uncertain publication, anchor review,
22//! and deterministic multi-actor schedules.
33
4use notebook::{ConflictKind, EditStatus, Operation, Recovery, Replica};
4use notebook::{ConflictKind, EditStatus, Recovery, Replica, Resolution};
5use onestore::op::SectionOp;
56use onestore::{
67 ExGuid, PageCreation, RevisionIndex, Store,
78 document::{Document, Kind},
......@@ -49,47 +50,38 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
4950 let path = directory.path().join(format!("{actor}.sqlite"));
5051 let cache = Replica::create(&path, &source).unwrap();
5152 let page = PageCreation::new(None, Some("Same 🦋 é"), "Offline author").unwrap();
52 let id = cache.create_page(&source, &page).unwrap().unwrap();
53 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());
53 let id = section_op(&cache, SectionOp::Create(page.clone()));
54 let mut created = cache.page(page.space()).unwrap();
5455 let body = body_outline(&mut created, "Body");
55 let save = cache
56 .save(
57 &cache.snapshot().unwrap(),
58 page.space(),
59 &created,
60 model_ops::AUTHOR,
61 )
56 let save = model_ops::save_as(&cache, page.space(), &created, model_ops::AUTHOR)
6257 .unwrap()
6358 .unwrap();
64 assert_eq!(
65 model_ops::save(&cache, body, |page| model_ops::replace_text(
66 page,
67 body,
68 4..4,
69 &format!(" {actor}")
70 ))
71 .unwrap(),
72 Some(save),
73 "a dependent text edit coalesces into the unattempted body save"
74 );
59 let dependent = model_ops::save(&cache, body, |page| {
60 model_ops::replace_text(page, body, 4..4, &format!(" {actor}"))
61 })
62 .unwrap()
63 .unwrap();
64 assert!(id < save && save < dependent);
7565 let original = cache.pending().unwrap();
76 assert_eq!(original.len(), 2);
66 assert_eq!(original.len(), 3);
7767 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)
7969 );
8070 let local = cache.snapshot().unwrap();
8171 drop(cache);
8272 let cache = Replica::open(&path).unwrap();
8373 assert_eq!(cache.pending().unwrap(), original);
84 assert_eq!(cache.snapshot().unwrap(), local);
85 for edit in original {
86 assert!(matches!(cache.sync_once(&mut server).unwrap(),
87 Some((published, EditStatus::Published { .. })) if published == edit.id));
88 }
74 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
8975 assert!(matches!(
90 cache.status(id).unwrap(),
91 Some(EditStatus::Published { .. })
76 cache.sync_once(&mut server).unwrap().edit,
77 Some((published, EditStatus::Published { .. })) if published == dependent
9278 ));
79 for edit in original {
80 assert!(matches!(
81 cache.status(edit.id).unwrap(),
82 Some(EditStatus::Published { .. })
83 ));
84 }
9385 expected.push((page.space(), page.object(), body, format!("Body {actor}")));
9486 }
9587 let store = Store::parse(&server.durable).unwrap();
......@@ -103,7 +95,7 @@ fn offline_page_creation_rebases_with_dependent_edits_and_duplicate_titles() {
10395 let view = &space.revisions[&space.contexts[&ExGuid::default()]];
10496 assert!(matches!(&view.nodes[text].kind, Kind::RichText { text, .. } if text == expected));
10597 }
106 assert_eq!(server.publications, 24);
98 assert_eq!(server.publications, 12);
10799 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_OUTPUT") {
108100 std::fs::create_dir(&output).unwrap();
109101 std::fs::write(
......@@ -127,7 +119,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
127119 let path = directory.path().join("pages.sqlite");
128120 let cache = Replica::create(&path, &source).unwrap();
129121 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()));
131123 let local = cache.snapshot().unwrap();
132124 let mut server = Server::new(&source);
133125 server.fault = fault;
......@@ -145,7 +137,11 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
145137 drop(cache);
146138 let db = rusqlite::Connection::open(&path).unwrap();
147139 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 )
149145 .unwrap();
150146 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
151147 assert_eq!(revisions.len(), 2);
......@@ -153,7 +149,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
153149 drop(db);
154150 let cache = Replica::open(&path).unwrap();
155151 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();
157153 let visible = matches!(fault, Fault::UnknownAfter | Fault::PanicAfter);
158154 assert_eq!(server.publications, 1);
159155 assert_eq!(server.confirmations, usize::from(visible));
......@@ -164,7 +160,7 @@ fn uncertain_page_publication_retains_both_revisions_and_never_replays() {
164160 assert!(observed[252..] == server.durable[252..]);
165161 } else {
166162 assert_eq!(result, (id, attempted.clone()));
167 assert_eq!(cache.snapshot().unwrap(), local);
163 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
168164 }
169165 }
170166}
......@@ -176,7 +172,7 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
176172 let path = directory.path().join("pages.sqlite");
177173 let cache = Replica::create(&path, &source).unwrap();
178174 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()));
180176 let mut server = Server::new(&source);
181177 server.fault = Fault::UnknownAfter;
182178 assert!(cache.sync_once(&mut server).is_err());
......@@ -184,7 +180,11 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
184180 drop(cache);
185181 let db = rusqlite::Connection::open(&path).unwrap();
186182 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 )
188188 .unwrap();
189189 let revisions: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
190190 drop(db);
......@@ -222,21 +222,21 @@ fn surviving_page_revision_alone_does_not_confirm_section_publication() {
222222 );
223223 let cache = Replica::open(&path).unwrap();
224224 assert_eq!(
225 cache.sync_once(&mut server).unwrap(),
225 cache.sync_once(&mut server).unwrap().edit,
226226 Some((id, attempted.clone()))
227227 );
228228 assert_eq!(server.confirmations, 0);
229229 assert_eq!(server.publications, 1);
230230 server.visible = complete;
231231 assert!(matches!(
232 cache.sync_once(&mut server).unwrap(),
232 cache.sync_once(&mut server).unwrap().edit,
233233 Some((_, EditStatus::Published { .. }))
234234 ));
235235 assert_eq!(server.publications, 1);
236236}
237237
238238#[test]
239fn changed_page_anchor_requires_review_without_regenerating_dependent_identities() {
239fn changed_page_anchor_conflicts_until_kept_without_regenerating_dependent_identities() {
240240 let source = include_bytes!("../../../corpus/page-lifecycle/03-renamed/notebook/Lifecycle.one");
241241 let remote = include_bytes!("../../../corpus/page-lifecycle/04-nested/notebook/Lifecycle.one");
242242 let store = Store::parse(source).unwrap();
......@@ -246,48 +246,36 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities
246246 let path = directory.path().join("pages.sqlite");
247247 let cache = Replica::create(&path, source).unwrap();
248248 let page = PageCreation::new(Some(pages[4].0), Some("Created"), "Author").unwrap();
249 let id = cache.create_page(source, &page).unwrap().unwrap();
250 let mut created = model_ops::page_of(&cache.snapshot().unwrap(), page.space());
249 let id = section_op(&cache, SectionOp::Create(page.clone()));
250 let mut created = cache.page(page.space()).unwrap();
251251 let body = body_outline(&mut created, "Retained body");
252 cache
253 .save(&cache.snapshot().unwrap(), page.space(), &created, "Author")
252 let save = model_ops::save_as(&cache, page.space(), &created, "Author")
254253 .unwrap()
255254 .unwrap();
256255 let local = cache.snapshot().unwrap();
257256 let pending = cache.pending().unwrap();
258257 let mut server = Server::new(remote);
259258 assert_eq!(
260 cache.sync_once(&mut server).unwrap(),
261 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
259 cache.sync_once(&mut server).unwrap().edit,
260 Some((save, EditStatus::Conflict(ConflictKind::StructureChanged)))
262261 );
263 assert!(
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);
262 cache.resolve(id, Resolution::Mine).unwrap();
277263 assert_eq!(cache.pending().unwrap()[1], pending[1]);
278 let Operation::CreatePage(reviewed) = &cache.pending().unwrap()[0].operation else {
279 panic!()
280 };
281 assert_eq!(*reviewed, page.reposition(None).unwrap());
264 assert!(matches!(
265 &cache.pending().unwrap()[0].edit.ops[..],
266 [onestore::op::Op::Section(SectionOp::Create(reviewed))] if *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 );
282272 drop(cache);
283273 let cache = Replica::open(&path).unwrap();
284 for _ in 0..2 {
285 assert!(matches!(
286 cache.sync_once(&mut server).unwrap(),
287 Some((_, EditStatus::Published { .. }))
288 ));
289 }
290 assert_eq!(server.publications, 2);
274 assert!(matches!(
275 cache.sync_once(&mut server).unwrap().edit,
276 Some((_, EditStatus::Published { .. }))
277 ));
278 assert_eq!(server.publications, 1);
291279 let store = Store::parse(&server.durable).unwrap();
292280 let index = RevisionIndex::parse(&store).unwrap();
293281 let document = Document::parse(&index).unwrap();
......@@ -296,3 +284,110 @@ fn changed_page_anchor_requires_review_without_regenerating_dependent_identities
296284 assert!(matches!(&view.nodes[&body].kind,
297285 Kind::RichText { text, .. } if text == "Retained body"));
298286}
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 @@
11//! Page batch reconciliation: atomic batches with dependent body saves, competing moves and
22//! 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};
56use onestore::{
67 ExGuid, PageEdit, PagePosition, PreparedEdit, RevisionIndex, Store,
78 document::{Document, Format, Kind, Layout},
......@@ -117,8 +118,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
117118 .iter()
118119 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
119120 .collect();
120 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
121 assert!(cache.pages(SOURCE, &edits).is_err());
121 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
122122 let mut expected_text = texts(SOURCE);
123123 let (&(sid, oid), original) = expected_text
124124 .iter()
......@@ -132,17 +132,22 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
132132 .unwrap()
133133 .unwrap();
134134 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 );
136138 let local = cache.snapshot().unwrap();
137139 drop(cache);
138140 let cache = Replica::open(&path).unwrap();
139141 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 );
141146 let mut server = Server::new(SOURCE);
142 for edit in queue {
143 assert!(matches!(cache.sync_once(&mut server).unwrap(),
144 Some((published, EditStatus::Published { .. })) if published == edit.id));
145 }
147 assert!(matches!(
148 cache.sync_once(&mut server).unwrap().edit,
149 Some((_, EditStatus::Published { .. }))
150 ));
146151 let expected = [
147152 before[..3].to_vec(),
148153 before[6..].to_vec(),
......@@ -155,7 +160,7 @@ fn atomic_page_batches_survive_reopen_with_dependent_text() {
155160 cache.status(id).unwrap(),
156161 Some(EditStatus::Published { .. })
157162 ));
158 assert_eq!(server.publications, 2);
163 assert_eq!(server.publications, 1);
159164}
160165
161166#[test]
......@@ -164,10 +169,10 @@ fn indentation_only_edits_preserve_remote_page_movement() {
164169 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
165170 let before = order(SOURCE);
166171 let sid = before[4].0;
167 let id = cache
168 .pages(SOURCE, &[PageEdit::set_level(sid, 3).unwrap()])
169 .unwrap()
170 .unwrap();
172 let id = section_op(
173 &cache,
174 SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()),
175 );
171176 let mut server = Server::new(SOURCE);
172177 PreparedEdit::pages(
173178 SOURCE,
......@@ -178,22 +183,19 @@ fn indentation_only_edits_preserve_remote_page_movement() {
178183 .unwrap();
179184 let mut expected = order(&server.durable);
180185 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,
182187 Some((published, EditStatus::Published { .. })) if published == id));
183188 assert_eq!(order(&server.durable), expected);
184189}
185190
186191#[test]
187fn competing_page_moves_require_current_review_and_retain_intent_identities() {
192fn competing_page_moves_conflict_until_mine_is_kept_with_its_identities() {
188193 let directory = tempfile::tempdir().unwrap();
189194 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
190195 let pages = order(SOURCE);
191196 let sid = pages[6].0;
192197 let original = PageEdit::move_to(sid, None, 1).unwrap();
193 let id = cache
194 .pages(SOURCE, std::slice::from_ref(&original))
195 .unwrap()
196 .unwrap();
198 let id = section_op(&cache, SectionOp::Pages(vec![original.clone()]));
197199 let mut server = Server::new(SOURCE);
198200 PreparedEdit::pages(
199201 SOURCE,
......@@ -205,37 +207,20 @@ fn competing_page_moves_require_current_review_and_retain_intent_identities() {
205207 let remote = server.visible.clone();
206208 let local = cache.snapshot().unwrap();
207209 assert_eq!(
208 cache.sync_once(&mut server).unwrap(),
210 cache.sync_once(&mut server).unwrap().edit,
209211 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
210212 );
211213 assert_eq!(server.publications, 0);
212 assert_eq!(cache.snapshot().unwrap(), local);
213 let revised = original
214 .reposition(PagePosition::Before(Some(pages[2].0)), 1)
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()
214 assert_eq!(
215 server::pages(&cache.snapshot().unwrap()),
216 server::pages(&local)
231217 );
232 cache
233 .rebase_pages_conflict(id, &local, &remote, std::slice::from_ref(&revised))
234 .unwrap();
235 assert!(matches!(&cache.pending().unwrap()[0].operation,
236 Operation::Pages(batch) if batch.edits == [revised.clone()]));
237 let expected = PreparedEdit::pages(&remote, &[revised]).unwrap();
238 assert!(matches!(cache.sync_once(&mut server).unwrap(),
218 // Keeping mine moves the page last again, from where the remote put it.
219 cache.resolve(id, Resolution::Mine).unwrap();
220 assert!(matches!(&cache.pending().unwrap()[0].edit.ops[..],
221 [Op::Section(SectionOp::Pages(batch))] if *batch == [original.clone()]));
222 let expected = PreparedEdit::pages(&remote, &[original]).unwrap();
223 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
239224 Some((published, EditStatus::Published { .. })) if published == id));
240225 assert_eq!(order(&server.durable), order(expected.as_bytes()));
241226}
......@@ -245,16 +230,13 @@ fn one_competing_level_prevents_publication_of_the_whole_batch() {
245230 let directory = tempfile::tempdir().unwrap();
246231 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
247232 let pages = order(SOURCE);
248 let id = cache
249 .pages(
250 SOURCE,
251 &[
252 PageEdit::set_level(pages[4].0, 3).unwrap(),
253 PageEdit::set_level(pages[5].0, 2).unwrap(),
254 ],
255 )
256 .unwrap()
257 .unwrap();
233 let id = section_op(
234 &cache,
235 SectionOp::Pages(vec![
236 PageEdit::set_level(pages[4].0, 3).unwrap(),
237 PageEdit::set_level(pages[5].0, 2).unwrap(),
238 ]),
239 );
258240 let local = cache.snapshot().unwrap();
259241 let mut server = Server::new(SOURCE);
260242 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() {
263245 .unwrap();
264246 let remote = server.durable.clone();
265247 assert_eq!(
266 cache.sync_once(&mut server).unwrap(),
248 cache.sync_once(&mut server).unwrap().edit,
267249 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
268250 );
269251 assert_eq!(server.publications, 0);
270252 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 );
272257}
273258
274259#[test]
......@@ -287,7 +272,7 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
287272 PageEdit::set_level(pages[4].0, 3).unwrap(),
288273 PageEdit::set_level(pages[5].0, 2).unwrap(),
289274 ];
290 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
275 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
291276 let local = cache.snapshot().unwrap();
292277 let queue = cache.pending().unwrap();
293278 let mut server = Server::new(SOURCE);
......@@ -304,13 +289,12 @@ fn uncertain_page_batches_survive_recovery_and_do_not_replay() {
304289 let recovery = Recovery::open(&archive).unwrap();
305290 assert_eq!(recovery.pending().unwrap(), queue);
306291 assert_eq!(recovery.status(id).unwrap(), Some(attempted.clone()));
307 assert_eq!(recovery.snapshot().unwrap(), local);
308 assert!(
309 cache
310 .rebase_pages_conflict(id, &local, SOURCE, &edits)
311 .is_err()
292 assert_eq!(
293 server::pages(&recovery.snapshot().unwrap()),
294 server::pages(&local)
312295 );
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();
314298 assert_eq!(server.publications, 1);
315299 if matches!(fault, Fault::UnknownAfter | Fault::PanicAfter) {
316300 assert!(matches!(result.1, EditStatus::Published { .. }));
......@@ -341,10 +325,10 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
341325 let directory = tempfile::tempdir().unwrap();
342326 let path = directory.path().join("pages.sqlite");
343327 let cache = Replica::create(&path, &source).unwrap();
344 let id = cache
345 .pages(&source, &[PageEdit::set_level(sid, 3).unwrap()])
346 .unwrap()
347 .unwrap();
328 let id = section_op(
329 &cache,
330 SectionOp::Pages([PageEdit::set_level(sid, 3).unwrap()].to_vec()),
331 );
348332 let mut server = Server::new(&source);
349333 server.fault = Fault::UnknownAfter;
350334 assert!(cache.sync_once(&mut server).is_err());
......@@ -352,7 +336,9 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
352336 drop(cache);
353337 let db = rusqlite::Connection::open(&path).unwrap();
354338 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 })
356342 .unwrap();
357343 let proofs: BTreeMap<ExGuid, ExGuid> = serde_json::from_str(&encoded).unwrap();
358344 assert_eq!(proofs.len(), 2);
......@@ -368,13 +354,13 @@ fn an_unchanged_section_revision_cannot_confirm_a_changed_page() {
368354 let complete = server.visible.clone();
369355 server.visible = source;
370356 assert_eq!(
371 cache.sync_once(&mut server).unwrap(),
357 cache.sync_once(&mut server).unwrap().edit,
372358 Some((id, attempted.clone()))
373359 );
374360 assert_eq!(server.confirmations, 0);
375361 server.visible = complete;
376362 assert!(matches!(
377 cache.sync_once(&mut server).unwrap(),
363 cache.sync_once(&mut server).unwrap().edit,
378364 Some((_, EditStatus::Published { .. }))
379365 ));
380366 assert_eq!(server.publications, 1);
......@@ -386,16 +372,13 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
386372 let directory = tempfile::tempdir().unwrap();
387373 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
388374 let pages = order(SOURCE);
389 let id = cache
390 .pages(
391 SOURCE,
392 &[
393 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),
394 PageEdit::set_level(pages[4].0, 3).unwrap(),
395 ],
396 )
397 .unwrap()
398 .unwrap();
375 let id = section_op(
376 &cache,
377 SectionOp::Pages(vec![
378 PageEdit::move_to(pages[6].0, Some(pages[7].0), 1).unwrap(),
379 PageEdit::set_level(pages[4].0, 3).unwrap(),
380 ]),
381 );
399382 let mut server = Server::new(SOURCE);
400383 PreparedEdit::pages(
401384 SOURCE,
......@@ -405,7 +388,7 @@ fn an_explicit_anchor_is_retained_even_when_originally_in_place() {
405388 .commit(&mut server)
406389 .unwrap();
407390 assert_eq!(
408 cache.sync_once(&mut server).unwrap(),
391 cache.sync_once(&mut server).unwrap().edit,
409392 Some((id, EditStatus::Conflict(ConflictKind::StructureChanged)))
410393 );
411394 assert_eq!(server.publications, 0);
......@@ -416,10 +399,10 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {
416399 let directory = tempfile::tempdir().unwrap();
417400 let cache = Replica::create(directory.path().join("pages.sqlite"), SOURCE).unwrap();
418401 let sid = order(SOURCE)[4].0;
419 let id = cache
420 .pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
421 .unwrap()
422 .unwrap();
402 let id = section_op(
403 &cache,
404 SectionOp::Pages([PageEdit::set_level(sid, 1).unwrap()].to_vec()),
405 );
423406 let mut server = Server::new(SOURCE);
424407 PreparedEdit::pages(SOURCE, &[PageEdit::set_level(sid, 1).unwrap()])
425408 .unwrap()
......@@ -429,7 +412,7 @@ fn convergent_page_indentation_requires_confirmation_without_republishing() {
429412 assert!(cache.sync_once(&mut server).is_err());
430413 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
431414 assert_eq!(server.publications, 0);
432 assert!(matches!(cache.sync_once(&mut server).unwrap(),
415 assert!(matches!(cache.sync_once(&mut server).unwrap().edit,
433416 Some((published, EditStatus::Published { .. })) if published == id));
434417 assert_eq!(server.publications, 0);
435418 assert_eq!(server.confirmations, 2);
......@@ -464,7 +447,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
464447 .map(|(sid, level)| PageEdit::move_to(*sid, None, *level).unwrap())
465448 .collect()
466449 };
467 let id = cache.pages(SOURCE, &edits).unwrap().unwrap();
450 let id = section_op(&cache, SectionOp::Pages(edits.to_vec()));
468451 let text_id = model_ops::save(&cache, text_object, |page| {
469452 model_ops::replace_text(page, text_object, 0..0, "Offline ")
470453 })
......@@ -484,8 +467,11 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
484467 "02-promoted-parent" | "03-selected-group-move" | "06-promoted-level-two"
485468 )
486469 };
487 let first = cache.sync_once(&mut server).unwrap().unwrap();
488 assert_eq!(first.0, id);
470 let first = if expected_conflict {
471 cache.sync_once(&mut server).unwrap().edit.unwrap()
472 } else {
473 (text_id, EditStatus::Pending)
474 };
489475 let mut reviewed = edits.clone();
490476 if expected_conflict {
491477 assert_eq!(
......@@ -494,26 +480,25 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
494480 "{mode}:{phase}"
495481 );
496482 assert_eq!(server.publications, 0);
497 assert_eq!(cache.snapshot().unwrap(), local);
498 if mode == "indent" && phase == "08-collapsed-group-move" {
499 reviewed[0] = reviewed[0].reposition(PagePosition::Keep, 1).unwrap();
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}"
483 assert_eq!(
484 server::pages(&cache.snapshot().unwrap()),
485 server::pages(&local)
510486 );
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 };
511494 }
495 assert!(
496 matches!(cache.sync_once(&mut server).unwrap().edit, Some((published, EditStatus::Published { .. })) if published == text_id),
497 "{mode}:{phase}"
498 );
512499 counts[usize::from(expected_conflict)] += 1;
513500 drop(cache);
514501 let cache = Replica::open(&path).unwrap();
515 assert!(matches!(cache.sync_once(&mut server).unwrap(),
516 Some((published, EditStatus::Published { .. })) if published == text_id));
517502 assert!(cache.pending().unwrap().is_empty());
518503 let mut expected = order(&native);
519504 for edit in &reviewed {
......@@ -529,10 +514,7 @@ fn native_page_changes_reconcile_with_atomic_offline_batches_and_review() {
529514 }
530515 assert_eq!(order(&server.durable), expected, "{mode}:{phase}");
531516 assert_eq!(texts(&server.durable), expected_text, "{mode}:{phase}");
532 assert_eq!(
533 server.publications,
534 1 + usize::from(expected != order(&native))
535 );
517 assert_eq!(server.publications, 1);
536518 if let Some(output) = std::env::var_os("ONESTORE_OFFLINE_PAGE_EDIT_OUTPUT") {
537519 let output = std::path::Path::new(&output);
538520 assert!(output.is_absolute());
......@@ -580,13 +562,12 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
580562 PageEdit::move_to(group[1].space(), Some(group[0].space()), 1).unwrap(),
581563 PageEdit::set_level(group[2].space(), 2).unwrap(),
582564 ];
583 cache.pages(&source, &edits).unwrap().unwrap();
565 section_op(&cache, SectionOp::Pages(edits.to_vec()));
584566 let body = format!("Actor {actor} 🦀 e\u{301}");
585567 let local = cache.snapshot().unwrap();
586568 let mut model = model_ops::page_of(&local, group[1].space());
587569 let text = body_outline(&mut model, &body);
588 cache
589 .save(&local, group[1].space(), &model, "Author")
570 model_ops::save_as(&cache, group[1].space(), &model, "Author")
590571 .unwrap()
591572 .unwrap();
592573 expected.extend([
......@@ -601,18 +582,18 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
601582 for (path, queue) in &queues {
602583 let cache = Replica::open(path).unwrap();
603584 assert_eq!(cache.pending().unwrap(), *queue);
604 for edit in queue {
605 assert!(matches!(cache.sync_once(&mut server).unwrap(),
606 Some((published, EditStatus::Published { .. })) if published == edit.id));
607 }
585 assert!(matches!(
586 cache.sync_once(&mut server).unwrap().edit,
587 Some((_, EditStatus::Published { .. }))
588 ));
608589 assert!(cache.pending().unwrap().is_empty());
609590 }
610 assert_eq!(server.publications, 24);
591 assert_eq!(server.publications, 12);
611592 assert_eq!(order(&server.durable), expected);
612593 assert_eq!(texts(&server.durable), expected_text);
613594 for (path, queue) in &queues {
614595 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);
616597 assert_eq!(order(&cache.snapshot().unwrap()), expected);
617598 for edit in queue {
618599 assert!(matches!(
......@@ -631,10 +612,7 @@ fn twelve_disjoint_page_batches_merge_with_dependent_bodies_without_review() {
631612 .map(|(_, queue)| {
632613 queue
633614 .iter()
634 .map(|edit| {
635 serde_json::json!({"id": edit.id, "space": edit.space,
636 "operation": edit.operation})
637 })
615 .map(|edit| serde_json::json!({"id": edit.id, "edit": edit.edit}))
638616 .collect::<Vec<_>>()
639617 })
640618 .collect();
crates/notebook/tests/sync_paragraph.rs+59-54
......@@ -11,7 +11,7 @@ use std::path::Path;
1111
1212#[path = "support/server.rs"]
1313mod server;
14use server::{Fault, Server};
14use server::{Fault, Server, pages};
1515#[path = "support/model_ops.rs"]
1616mod model_ops;
1717use model_ops::{AUTHOR, outlines_mut, page_of, replace_text};
......@@ -178,16 +178,13 @@ fn save_page(
178178 let source = cache.snapshot()?;
179179 let (space, mut page) = holder(&source, text);
180180 edit(&mut page);
181 cache.save(&source, space, &page, AUTHOR)
181 model_ops::save_as(cache, space, &page, AUTHOR)
182182}
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.
185185fn review_page(cache: &Replica, id: u64, text: ExGuid, edit: impl FnOnce(&mut Page)) {
186 let local = cache.snapshot().unwrap();
187 let remote = cache.remote_snapshot().unwrap();
188 let (_, mut page) = holder(&remote, text);
189 edit(&mut page);
190 cache.review_page(id, &local, &remote, &page).unwrap();
186 cache.resolve(id, notebook::Resolution::Theirs).unwrap();
187 save_page(cache, text, edit).unwrap().unwrap();
191188}
192189
193190/// The styled text of an object anywhere on a page.
......@@ -246,18 +243,20 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
246243 let mut cache = Replica::create(&path, BEFORE).unwrap();
247244 let mut after = original.clone();
248245 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();
250249 let pending = cache.pending().unwrap();
251250 drop(cache);
252251 cache = Replica::open(&path).unwrap();
253252 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();
255254 assert_eq!(actual, id);
256255 let reviewed = matches!(status, EditStatus::Conflict(_));
257256 let receipt = if reviewed {
258257 assert_eq!(cache.pending().unwrap(), pending);
259258 review_page(&cache, id, left, apply);
260 cache.sync_once(&mut server).unwrap().unwrap().1
259 cache.sync_once(&mut server).unwrap().edit.unwrap().1
261260 } else {
262261 automatic += 1;
263262 status
......@@ -293,7 +292,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
293292 let dependent = save_page(&cache, target, |page| dependent_edit(page, target))
294293 .unwrap()
295294 .unwrap();
296 let (actual, status) = cache.sync_once(&mut server).unwrap().unwrap();
295 let (actual, status) = cache.sync_once(&mut server).unwrap().edit.unwrap();
297296 assert_eq!(actual, dependent);
298297 let EditStatus::Published {
299298 revision: dependent_revision,
......@@ -309,7 +308,7 @@ fn reconcile_controls() -> (Vec<u8>, Vec<serde_json::Value>) {
309308 "receipt": revision, "dependent_receipt": dependent_revision}),
310309 );
311310 }
312 assert_eq!((recorded.len(), automatic), (16, 6));
311 assert_eq!((recorded.len(), automatic), (16, 8));
313312 assert_eq!(server.publications, 32);
314313 (server.durable, recorded)
315314}
......@@ -321,19 +320,20 @@ fn native_keyboard_edits_merge_with_offline_splits_and_joins_or_survive_review()
321320 ("Split remote prefix", "automatic", vec!["Xab", "🦀Cd"]),
322321 ("Split remote boundary", "automatic", vec!["abX", "🦀Cd"]),
323322 ("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"]),
325325 ("Split remote tag", "automatic", vec!["ab", "🦀Cd"]),
326326 ("Split remote format", "reviewed", vec!["ab", "🦀Cd"]),
327327 ("Split remote sibling", "automatic", vec!["ab", "🦀Cd"]),
328328 ("Join remote prefix", "reviewed", vec!["Xab🦀CdRightY"]),
329 ("Join left boundary", "reviewed", vec!["ab🦀CdXRight"]),
329 ("Join left boundary", "automatic", vec!["ab🦀CdXRight"]),
330330 ("Join right boundary", "reviewed", vec!["ab🦀CdXRight"]),
331 ("Join remote child", "reviewed", vec!["ab🦀CdRight"]),
331 ("Join remote child", "automatic", vec!["ab🦀CdRight"]),
332332 ("Join remote list", "reviewed", vec!["ab🦀CdRight"]),
333333 ("Join remote tag", "reviewed", vec!["ab🦀CdRight"]),
334334 ("Join remote format", "reviewed", vec!["ab🦀CdRight"]),
335335 ("Join remote sibling", "automatic", vec!["ab🦀CdRight"]),
336 ("Join empty adoption", "reviewed", vec!["XIight"]),
336 ("Join empty adoption", "automatic", vec!["XIight"]),
337337 ];
338338 for (case, (name, outcome, prefix)) in cases.iter().zip(expected) {
339339 assert_eq!(case["case"], name);
......@@ -363,17 +363,19 @@ fn a_native_text_object_replacement_conflicts_with_every_offline_split_and_join(
363363 } else {
364364 join_paragraphs(&mut after, left, body(&original)[1].text().unwrap().id);
365365 }
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();
367369 let local = cache.snapshot().unwrap();
368370 let pending = cache.pending().unwrap();
369371 assert_eq!(
370 cache.sync_once(&mut server).unwrap(),
372 cache.sync_once(&mut server).unwrap().edit,
371373 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged))),
372374 "{name}"
373375 );
374376 drop(cache);
375377 let cache = Replica::open(&path).unwrap();
376 assert_eq!(cache.snapshot().unwrap(), local);
378 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
377379 assert_eq!(cache.pending().unwrap(), pending);
378380 assert_eq!(
379381 cache.status(id).unwrap(),
......@@ -448,6 +450,7 @@ fn offline_paragraphs(output: Option<&Path>) {
448450 let cases = if split { &manifest["cases"] } else { &manifest };
449451 let mut entries = Vec::new();
450452 let mut refused: Vec<String> = Vec::new();
453 let mut reviewed: Vec<String> = Vec::new();
451454 for case in cases
452455 .as_array()
453456 .unwrap()
......@@ -474,16 +477,10 @@ fn offline_paragraphs(output: Option<&Path>) {
474477 continue;
475478 };
476479 let id = queued.unwrap();
477 entries.push(
478 serde_json::json!({"case": case["case"], "id": id, "space": space,
479 "intent": intent, "remote_prefix": prefixed}),
480 );
480 let mut entry = serde_json::json!({"case": case["case"], "id": id, "space": space,
481 "intent": intent, "remote_prefix": prefixed});
481482 drop(cache);
482483 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();
487484 let case = entry["case"].as_str().unwrap().to_owned();
488485 let intent = entry["intent"].clone();
489486 let mut uncertain = id % 2 == 0;
......@@ -501,11 +498,20 @@ fn offline_paragraphs(output: Option<&Path>) {
501498 drop(cache);
502499 cache = Replica::open(&path).unwrap();
503500 }
504 Ok(Some((actual, EditStatus::Published { revision }))) => {
505 assert_eq!(actual, id);
501 Ok(notebook::Synced {
502 edit: Some((_, EditStatus::Published { revision })),
503 ..
504 }) => {
505 assert!(matches!(
506 cache.status(id).unwrap(),
507 Some(EditStatus::Published { .. })
508 ));
506509 break revision;
507510 }
508 Ok(Some((actual, EditStatus::Conflict(_)))) => {
511 Ok(notebook::Synced {
512 edit: Some((actual, EditStatus::Conflict(_))),
513 ..
514 }) => {
509515 assert_eq!(actual, id);
510516 server.fault = Fault::None;
511517 reviewed.push(case.clone());
......@@ -518,16 +524,15 @@ fn offline_paragraphs(output: Option<&Path>) {
518524 replay(page, &placed, split)
519525 });
520526 }
521 other => panic!("{name} {}: {other:?}", entry["case"]),
527 other => panic!("{name} {case}: {other:?}"),
522528 }
523529 };
524530 entry["revision"] = serde_json::to_value(revision).unwrap();
531 entries.push(entry);
525532 }
526 // A split inside a table cell the remote also changed overlaps at the cell.
527 let structural = ["Split cell".to_owned()];
528533 let unrepresentable = ["Join inherited styles".to_owned()];
529534 let expected: (usize, &[String], &[String]) = match name {
530 "splits" | "joins" => (12, &structural, &[]),
535 "splits" | "joins" => (12, &[], &[]),
531536 "inheritance" => (4, &[], &unrepresentable),
532537 _ => (3, &[], &[]),
533538 };
......@@ -537,7 +542,7 @@ fn offline_paragraphs(output: Option<&Path>) {
537542 "{name}"
538543 );
539544 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);
541546 assert_eq!(cache.snapshot().unwrap(), server.durable);
542547 if output.is_some() {
543548 cache
......@@ -585,7 +590,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
585590 .unwrap()
586591 .unwrap();
587592 assert!(matches!(
588 cache.sync_once(&mut server).unwrap(),
593 cache.sync_once(&mut server).unwrap().edit,
589594 Some((_, EditStatus::Published { .. }))
590595 ));
591596 let published = page_of(&server.durable, space);
......@@ -598,7 +603,7 @@ fn a_split_publishes_two_paragraphs_and_a_join_puts_them_back() {
598603 .unwrap()
599604 .unwrap();
600605 assert!(matches!(
601 cache.sync_once(&mut server).unwrap(),
606 cache.sync_once(&mut server).unwrap().edit,
602607 Some((_, EditStatus::Published { .. }))
603608 ));
604609 assert_eq!(
......@@ -623,7 +628,7 @@ fn a_split_merges_with_a_remote_edit_to_another_paragraph() {
623628 .unwrap();
624629 let mut server = remote_with(space, |page| replace_text(page, other, 0..0, "Remote "));
625630 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)
627632 );
628633 assert_eq!(
629634 texts(&page_of(&server.durable, space)),
......@@ -645,13 +650,13 @@ fn a_join_conflicts_when_the_remote_changed_the_paragraph_it_removes() {
645650 .unwrap();
646651 let mut server = remote_with(space, |page| replace_text(page, right, 0..0, "Remote "));
647652 assert_eq!(
648 cache.sync_once(&mut server).unwrap(),
653 cache.sync_once(&mut server).unwrap().edit,
649654 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
650655 );
651656 assert_eq!(server.publications, 0);
652657 review_page(&cache, id, left, |page| join_paragraphs(page, left, right));
653658 assert!(matches!(
654 cache.sync_once(&mut server).unwrap(),
659 cache.sync_once(&mut server).unwrap().edit,
655660 Some((_, EditStatus::Published { .. }))
656661 ));
657662 assert_eq!(
......@@ -674,7 +679,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
674679 save_page(&cache, left, |page| split_paragraph(page, left, 2))
675680 .unwrap()
676681 .unwrap();
677 cache.sync_once(&mut server).unwrap().unwrap();
682 cache.sync_once(&mut server).unwrap().edit.unwrap();
678683 let tail = body(&page_of(&cache.snapshot().unwrap(), space))[1]
679684 .text()
680685 .unwrap()
......@@ -688,7 +693,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
688693 let pending = cache.pending().unwrap();
689694 drop(cache);
690695 cache = Replica::open(&path).unwrap();
691 assert_eq!(cache.snapshot().unwrap(), local);
696 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
692697 assert_eq!(cache.pending().unwrap(), pending);
693698 let mut remote = page_of(&server.durable, space);
694699 replace_text(&mut remote, other, 0..0, "Remote ");
......@@ -698,7 +703,7 @@ fn edits_dependent_on_a_published_split_survive_reopen() {
698703 .to_vec();
699704 server.durable.clone_from(&server.visible);
700705 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)
702707 );
703708 assert_eq!(
704709 texts(&page_of(&server.durable, space)),
......@@ -751,7 +756,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
751756 assert_eq!(state, EditStatus::Pending);
752757 assert_eq!(cache.pending().unwrap(), pending);
753758 assert!(matches!(
754 cache.sync_once(&mut server).unwrap().unwrap().1,
759 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
755760 EditStatus::Published { .. }
756761 ));
757762 assert_eq!(server.publications, 2);
......@@ -759,10 +764,10 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
759764 Fault::UnknownBefore => {
760765 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
761766 assert_eq!(cache.pending().unwrap(), pending);
762 assert_eq!(cache.snapshot().unwrap(), local);
767 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
763768 for _ in 0..3 {
764769 assert_eq!(
765 cache.sync_once(&mut server).unwrap(),
770 cache.sync_once(&mut server).unwrap().edit,
766771 Some((id, state.clone()))
767772 );
768773 }
......@@ -772,9 +777,9 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
772777 }
773778 Fault::UnknownAfter => {
774779 assert!(matches!(state, EditStatus::AwaitingConfirmation { .. }));
775 assert_eq!(cache.snapshot().unwrap(), local);
780 assert_eq!(pages(&cache.snapshot().unwrap()), pages(&local));
776781 assert!(matches!(
777 cache.sync_once(&mut server).unwrap().unwrap().1,
782 cache.sync_once(&mut server).unwrap().edit.unwrap().1,
778783 EditStatus::Published { .. }
779784 ));
780785 assert_eq!(server.publications, 1);
......@@ -791,7 +796,7 @@ fn uncertain_splits_and_joins_keep_the_original_attempt_across_reopen() {
791796 &["ab", "\u{1f980}cd", "Right", "Preserved sibling"]
792797 };
793798 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);
795800 assert_eq!(cache.snapshot().unwrap(), server.durable);
796801 }
797802 }
......@@ -818,17 +823,17 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {
818823 let pending = second.pending().unwrap();
819824 let mut server = Server::new(BEFORE);
820825 assert!(matches!(
821 first.sync_once(&mut server).unwrap().unwrap().1,
826 first.sync_once(&mut server).unwrap().edit.unwrap().1,
822827 EditStatus::Published { .. }
823828 ));
824829 assert_eq!(
825 second.sync_once(&mut server).unwrap(),
830 second.sync_once(&mut server).unwrap().edit,
826831 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
827832 );
828833 assert_eq!(server.publications, 1);
829834 drop(second);
830835 let second = Replica::open(&path).unwrap();
831 assert_eq!(second.snapshot().unwrap(), local);
836 assert_eq!(pages(&second.snapshot().unwrap()), pages(&local));
832837 assert_eq!(second.pending().unwrap(), pending);
833838 assert_eq!(
834839 second.status(id).unwrap(),
......@@ -838,7 +843,7 @@ fn a_competing_join_leaves_the_other_client_a_reviewable_split() {
838843 split_paragraph(page, left, 2);
839844 });
840845 assert!(matches!(
841 second.sync_once(&mut server).unwrap().unwrap().1,
846 second.sync_once(&mut server).unwrap().edit.unwrap().1,
842847 EditStatus::Published { .. }
843848 ));
844849 assert_eq!(
crates/notebook/tests/sync_worker.rs+72-54
......@@ -111,7 +111,12 @@ fn reconnects_after_connect_read_and_uncertain_publish_without_replaying() {
111111 },
112112 move |result| {
113113 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 )
115120 .unwrap();
116121 },
117122 )
......@@ -166,7 +171,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
166171 Duration::from_secs(3600),
167172 move || Ok(shared.clone()),
168173 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();
170177 if first {
171178 first = false;
172179 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() {
185192 let cache = &cache;
186193 scope.spawn(move || {
187194 let marker = format!("[{writer}] ");
188 loop {
189 match model_ops::save(cache, oid, |page| {
190 model_ops::replace_text(page, oid, 0..0, &marker)
191 }) {
192 Ok(Some(id)) => break id,
193 Err(Error::Io(error))
194 if error.kind() == io::ErrorKind::ResourceBusy => {}
195 other => panic!("Unexpected local outcome: {other:?}"),
196 }
197 }
195 model_ops::save(cache, oid, |page| {
196 model_ops::replace_text(page, oid, 0..0, &marker)
197 })
198 .unwrap()
199 .unwrap()
198200 })
199201 })
200202 .collect::<Vec<_>>()
......@@ -203,11 +205,9 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
203205 .collect::<std::collections::BTreeSet<_>>()
204206 });
205207 resume_tx.send(()).unwrap();
206 let mut published = std::collections::BTreeSet::new();
207208 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() {
209210 assert!(matches!(status, EditStatus::Published { .. }), "{status:?}");
210 published.insert(id);
211211 }
212212 }
213213 assert!(
......@@ -215,7 +215,12 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
215215 "Edits waited for the hourly poll"
216216 );
217217 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 }
219224 let content = text(&cache.snapshot().unwrap()).2;
220225 for writer in 0..12 {
221226 assert_eq!(content.matches(&format!("[{writer}] ")).count(), 1);
......@@ -223,7 +228,8 @@ fn local_saves_wake_an_idle_worker_and_publish_every_writer_marker() {
223228 assert!(content.ends_with("abc"));
224229 let server = server.lock().unwrap();
225230 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);
227233 assert_eq!(cache.snapshot().unwrap(), server.durable);
228234}
229235
......@@ -308,7 +314,7 @@ fn dropping_during_publication_is_nonblocking_and_retains_ownership_until_recove
308314 Duration::from_secs(3600),
309315 move || Ok(shared.clone()),
310316 move |result| {
311 tx.send(result.as_ref().unwrap().clone()).unwrap();
317 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
312318 },
313319 )
314320 .unwrap();
......@@ -332,7 +338,7 @@ fn cache_failures_stop_retries_and_return_the_error_without_remote_publication()
332338 let id = save(&cache, oid, 0..0, "L ").unwrap();
333339 drop(cache);
334340 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();
336342 drop(connection);
337343 let cache = Arc::new(Replica::open(&path).unwrap());
338344 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()
356362}
357363
358364#[test]
359fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
365fn polling_reports_a_blocked_queue_once_per_remote_change_without_replay() {
360366 for uncertain in [false, true] {
361367 let dir = tempfile::tempdir().unwrap();
362368 let source = onestore::create_section("worker.one", "abc", "Fixture").unwrap();
363369 let (sid, oid, _) = text(&source);
364370 let cache = Arc::new(Replica::create(dir.path().join("cache.sqlite"), &source).unwrap());
365371 let id = save(&cache, oid, 1..2, "L").unwrap();
366 let local = cache.snapshot().unwrap();
372 let local = text(&cache.snapshot().unwrap()).2;
367373 let mut server = if uncertain {
368374 Server::new(&source)
369375 } else {
......@@ -380,38 +386,48 @@ fn polling_preserves_conflicts_and_absent_uncertain_revisions_without_replay() {
380386 Duration::from_millis(10),
381387 move || Ok(shared.clone()),
382388 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();
384396 },
385397 )
386398 .unwrap();
387399 if uncertain {
388400 assert!(rx.recv_timeout(Duration::from_secs(5)).unwrap().is_err());
389401 }
390 let mut previous = None;
391 let started = Instant::now();
392 for _ in 0..5 {
402 let expected = |status: &EditStatus| {
403 if uncertain {
404 matches!(status, EditStatus::AwaitingConfirmation { .. })
405 } else {
406 *status == EditStatus::Conflict(ConflictKind::ContentChanged)
407 }
408 };
409 for round in 0..3 {
393410 let (actual, status) = rx
394411 .recv_timeout(Duration::from_secs(5))
395412 .unwrap()
396413 .unwrap()
397414 .unwrap();
398415 assert_eq!(actual, id);
399 if uncertain {
400 assert!(matches!(status, EditStatus::AwaitingConfirmation { .. }));
401 } else {
402 assert_eq!(status, EditStatus::Conflict(ConflictKind::ContentChanged));
403 }
404 if let Some(previous) = previous {
405 assert_eq!(previous, status);
406 }
407 previous = Some(status.clone());
416 assert!(expected(&status), "{status:?}");
417 // An unchanged remote is not read or reported again.
418 assert!(rx.recv_timeout(Duration::from_millis(100)).is_err());
419 // Another writer's change elsewhere in the text is read and decided again.
420 let mut server = server.lock().unwrap();
421 let changed =
422 onestore::replace_text(&server.visible, sid, oid, 0..0, &round.to_string())
423 .unwrap();
424 server.visible = changed.clone();
425 server.durable = changed;
426 drop(server);
427 worker.wake();
408428 }
409 assert!(
410 started.elapsed() >= Duration::from_millis(30),
411 "Worker spun instead of waiting between retries"
412 );
413429 worker.stop().unwrap();
414 assert_eq!(cache.snapshot().unwrap(), local);
430 assert_eq!(text(&cache.snapshot().unwrap()).2, local);
415431 assert_eq!(cache.pending().unwrap().len(), 1);
416432 let server = server.lock().unwrap();
417433 assert_eq!(server.publications, usize::from(uncertain));
......@@ -511,7 +527,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
511527 Duration::from_secs(3600),
512528 move || Ok(shared.clone()),
513529 move |result| {
514 tx.send(result.as_ref().unwrap().clone()).unwrap();
530 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
515531 },
516532 )
517533 .unwrap();
......@@ -521,7 +537,7 @@ fn invalid_intervals_and_callback_panics_leave_worker_ownership_recoverable() {
521537}
522538
523539#[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() {
525541 let dir = tempfile::tempdir().unwrap();
526542 let source = onestore::create_section("resolve.one", "abc", "Fixture").unwrap();
527543 let (sid, oid, _) = text(&source);
......@@ -538,7 +554,7 @@ fn reviewed_conflict_wakes_the_worker_and_publishes_the_original_intent_once() {
538554 Duration::from_secs(3600),
539555 move || Ok(shared.clone()),
540556 move |result| {
541 tx.send(result.as_ref().unwrap().clone()).unwrap();
557 tx.send(result.as_ref().unwrap().edit.clone()).unwrap();
542558 if first {
543559 first = false;
544560 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() {
550566 rx.recv_timeout(Duration::from_secs(5)).unwrap(),
551567 Some((id, EditStatus::Conflict(ConflictKind::ContentChanged)))
552568 );
553 model_ops::review(&cache, id, oid, |page| {
554 model_ops::replace_text(page, oid, 1..2, "L")
555 })
556 .unwrap();
569 cache.resolve(id, notebook::Resolution::Mine).unwrap();
557570 assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending));
558571 resume_tx.send(()).unwrap();
559572 assert!(
......@@ -618,8 +631,13 @@ fn ordinary_read_and_unpublished_write_contention_reuse_the_connection() {
618631 Duration::from_millis(1),
619632 move || Ok(remote.take().expect("Contention caused a reconnect")),
620633 move |result| {
621 tx.send(result.as_ref().cloned().map_err(|error| error.to_string()))
622 .unwrap();
634 tx.send(
635 result
636 .as_ref()
637 .map(|synced| synced.edit.clone())
638 .map_err(|error| error.to_string()),
639 )
640 .unwrap();
623641 },
624642 )
625643 .unwrap();
......@@ -675,14 +693,14 @@ fn publication_backoff_drains_local_wakes_without_waiting_for_the_idle_poll() {
675693 failed = true;
676694 let page =
677695 onestore::PageCreation::new(None, Some("Queued"), "Fixture").unwrap();
678 let second = observed
679 .create_page(&observed.snapshot().unwrap(), &page)
680 .unwrap()
681 .unwrap();
696 let second = section_op(&observed, onestore::op::SectionOp::Create(page));
682697 tx.send((second, None)).unwrap();
683698 }
684 Ok(Some((id, status))) => tx.send((*id, Some(status.clone()))).unwrap(),
685 Ok(None) => {}
699 Ok(notebook::Synced {
700 edit: Some((id, status)),
701 ..
702 }) => tx.send((*id, Some(status.clone()))).unwrap(),
703 Ok(_) => {}
686704 other => panic!("Unexpected worker result: {other:?}"),
687705 },
688706 )
crates/onestore/src/commit.rs+13
......@@ -644,6 +644,19 @@ impl Transaction {
644644 }
645645 }
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
647660 /// Writes this transaction into its base image, as a successful commit leaves the file.
648661 pub fn apply(&self, image: &mut Vec<u8>) -> Result<(), crate::Error> {
649662 if Stamp::of(image)? != self.base {
crates/onestore/src/op/apply.rs+4-3
......@@ -15,7 +15,7 @@ use crate::{
1515 active::{ActivePage, Changes},
1616 document::Kind,
1717 page::{
18 Attachment, Image, Ink, Page, PageObject, PageParagraph, Paragraph, ParagraphContent,
18 Attachment, Image, Ink, PageObject, PageParagraph, Paragraph, ParagraphContent,
1919 TableCell, TableColumn,
2020 },
2121};
......@@ -57,12 +57,13 @@ impl<'a> Section<'a> {
5757
5858 /// Lowers `after` against the page `space` holds and applies the result as one edit;
5959 /// 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(
6162 &mut self,
6263 author: &str,
6364 at: u64,
6465 space: ExGuid,
65 after: &Page,
66 after: &crate::page::Page,
6667 ) -> Result<Vec<PageOp>, OpError> {
6768 let before = self.page(space).map_err(|error| self.classify(Failure::Rejected(error)))?;
6869 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 {
2020 Error { offset: 0, message }
2121}
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.
2425pub fn lower_page(before: &Page, after: &Page) -> Result<Vec<PageOp>, Error> {
2526 let old = View::new(before, false)?;
2627 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>)>;
2828
2929/// One user action, applied whole or not at all.
3030#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
3132pub struct Edit {
3233 /// FILETIME when the action happened; the modification time of what it changes.
3334 pub at: u64,
......@@ -35,6 +36,7 @@ pub struct Edit {
3536}
3637
3738#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
3840pub enum Op {
3941 Page { space: ExGuid, op: PageOp },
4042 Section(SectionOp),
......@@ -43,6 +45,7 @@ pub enum Op {
4345/// A change to the page an object space holds. Targets are stored identities; paragraph
4446/// properties name the paragraph, text edits its text object.
4547#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
4649pub enum PageOp {
4750 /// Replaces a range; inserted text takes the format of the run it lands in, the
4851 /// following run at a boundary except at the end.
......@@ -159,6 +162,7 @@ pub enum PageOp {
159162}
160163
161164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
162166pub enum TableEdit {
163167 /// New rows before a row, or last, each with one cell per column.
164168 Rows {
......@@ -185,6 +189,7 @@ pub enum TableEdit {
185189}
186190
187191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
188193pub enum SectionOp {
189194 Create(PageCreation),
190195 /// 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> {
202202 image
203203 }
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
205212 /// The page an object space holds, with the changes applied since the last seal.
206213 pub fn page(&self, space: ExGuid) -> Result<Page> {
207214 let stored = self.spaces.get(&space).ok_or(Error {
......@@ -429,7 +436,7 @@ impl<'a> Section<'a> {
429436 }
430437
431438 /// The root object space, which lists the pages.
432 pub(crate) fn root(&self) -> ExGuid {
439 pub fn root(&self) -> ExGuid {
433440 self.root
434441 }
435442
crates/onestore/src/store.rs+1-1
......@@ -86,7 +86,7 @@ pub struct Header {
8686}
8787
8888impl Header {
89 pub(crate) fn parse(data: &[u8]) -> Result<Self> {
89 pub fn parse(data: &[u8]) -> Result<Self> {
9090 let mut c = Cursor {
9191 bytes: data,
9292 offset: 0,
tools/cache_images.py+34-14
......@@ -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."""
23import struct
34
45
5def working(connection):
6 base, patch = connection.execute('SELECT base, working FROM replica WHERE id=1').fetchone()
7 if not patch: return base
8 length, = struct.unpack_from('<Q', patch)
9 image = bytearray(base[:length].ljust(length, b'\0'))
10 at = 8
11 while at < len(patch):
12 offset, size = struct.unpack_from('<QQ', patch, at)
13 at += 16
14 assert at + size <= len(patch) and offset + size <= length, 'Damaged working image'
15 image[offset:offset + size] = patch[at:at + size]
16 at += size
17 return bytes(image)
6def image(connection, table='base'):
7 """The `base` image the queued edits apply to, or the observed `remote` image (None
8 unless a conflict or uncertain attempt holds one)."""
9 chunks = connection.execute(f'SELECT chunk, bytes FROM {table} ORDER BY chunk').fetchall()
10 assert [chunk for chunk, _ in chunks] == list(range(len(chunks))), 'Damaged cached image'
11 return b''.join(bytes(data) for _, data in chunks) or None
12
13
14def wal_commits(path):
15 """Commits in `<cache>-wal` since its last reset: frames whose header records the
16 database size after them, among the frames carrying the log's current salts. Frames
17 after the last commit belong to a transaction that has not committed."""
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 @@
11#!/usr/bin/env python3
22"""Interrupt owned offline-cache writers and verify every retained intent and image."""
33import argparse
4import cache_images
45import hashlib
56import json
67import os
......@@ -31,7 +32,7 @@ def run(binary, output):
3132 retained = []
3233 retained_ids = []
3334 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"]
3536 for operation, delay in enumerate(delays, 1):
3637 with (output / f"owner-{operation}.stderr").open("w") as stderr:
3738 owner = subprocess.Popen([binary, "edit", cache], stdin=subprocess.PIPE,
......@@ -48,34 +49,28 @@ def run(binary, output):
4849 contender = subprocess.run([binary, "read", cache], capture_output=True, text=True, timeout=15)
4950 (output / f"contender-{operation}.stderr").write_text(contender.stderr)
5051 assert contender.returncode != 0 and "DatabaseBusy" in contender.stderr, contender
51 before_write = cache.stat().st_mtime_ns
5252 owner.stdin.write(f"{operation} {'unack' if delay == 'unack' else 'ack'}\n")
5353 owner.stdin.flush()
5454 expect(f"editing {operation}")
5555 acknowledgement = None
56 journal_observation = None
56 log_observation = None
5757 if delay == "ack":
5858 acknowledgement = expect(f"ack {operation}")
5959 elif delay == "unack":
6060 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.
6264 deadline = time.monotonic() + 10
63 journal = cache.with_name(cache.name + "-journal")
65 committed, _ = cache_images.wal_commits(cache)
6466 while time.monotonic() < deadline:
65 try:
66 with journal.open("rb") as active:
67 header = active.read(28)
68 size = journal.stat().st_size
69 database_changed = cache.stat().st_mtime_ns != before_write
70 if (header[:8] == bytes.fromhex("d9d505f920a163d7") and
71 (delay == "journal" or database_changed)):
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"
67 commits, pending = cache_images.wal_commits(cache)
68 if commits == committed and pending:
69 log_observation = {"wal_commits": commits, "uncommitted_frames": pending}
70 break
71 if commits != committed:
72 break
73 assert log_observation, "No uncommitted log frames observed"
7974 elif delay is not None:
8075 time.sleep(delay)
8176 owner.kill()
......@@ -88,9 +83,7 @@ def run(binary, output):
8883 actual = json.loads(read.stdout)
8984 operations = actual["operations"]
9085 ids = actual["ids"]
91 # A save replaces the newest pending save of its page under the same intent.
92 coalesced = retained[:-1] + [operation] if ids == retained_ids else None
93 assert operations in (retained, retained + [operation], coalesced), (retained, operation, actual)
86 assert operations in (retained, retained + [operation]), (retained, operation, actual)
9487 assert ids[:len(retained_ids)] == retained_ids, (retained_ids, actual)
9588 if acknowledgement:
9689 assert operations[-1] == operation
......@@ -105,7 +98,7 @@ def run(binary, output):
10598 "retained_operations": operations, "ids": ids,
10699 "section_bytes": actual["section_bytes"],
107100 "complete_payloads": True, "exclusive_owner": True,
108 "journal_observation": journal_observation})
101 "log_observation": log_observation})
109102 retained, retained_ids = operations, ids
110103 (output / "results.json").write_text(json.dumps(results, indent=2))
111104 print(json.dumps(results[-1]), flush=True)
tools/offline_publication_crash.py+17-19
......@@ -31,11 +31,6 @@ def run_command(binary, args, output):
3131def kill_at(binary, args, phase, output, receipt_window=None):
3232 events = queue.Queue()
3333 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''
3934 with output.with_suffix('.jsonl').open('w') as log, output.with_suffix('.stderr').open('w') as error:
4035 process = subprocess.Popen([str(binary), *map(str, args)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=error, text=True,
4136 env={**os.environ, 'ONESTORE_RECOVERY_PAUSE': phase})
......@@ -57,24 +52,26 @@ def kill_at(binary, args, phase, output, receipt_window=None):
5752 if event.get('event') == 'phase' and event['name'] == phase:
5853 if receipt_window is not None:
5954 assert args[0] == 'sync' and phase == 'publish-after'
60 assert receipt_window in ('journal', 'database')
61 before_mtime = database.stat().st_mtime_ns
55 assert receipt_window == 'wal'
56 # 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)
6259 process.stdin.write('\n')
6360 process.stdin.flush()
6461 deadline = time.monotonic() + 5
6562 while True:
66 if header() == bytes.fromhex('d9d505f920a163d7') and (receipt_window == 'journal' or database.stat().st_mtime_ns != before_mtime): break
67 assert process.poll() is None, 'Receipt transaction finished before the requested cut'
63 commits, pending = cache_images.wal_commits(database)
64 if commits == committed and pending: break
65 assert commits == committed and process.poll() is None, 'Receipt transaction finished before the requested cut'
6866 assert time.monotonic() < deadline, 'Receipt transaction window was not observed'
69 time.sleep(.0001)
7067 process.kill()
7168 break
7269 assert process.wait(timeout=10) == -signal.SIGKILL, 'Expected an actual process kill'
7370 proof = {'pid': process.pid, 'exit': process.returncode, 'phase': phase}
7471 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)
76 assert proof['journal_header_after_kill'] == 'd9d505f920a163d7', 'Receipt transaction committed before the process died'
77 assert receipt_window != 'database' or proof['database_changed']
72 commits, pending = cache_images.wal_commits(database)
73 proof.update(wal_commits_before=committed, wal_commits_after_kill=commits, uncommitted_frames=pending, receipt_window=receipt_window)
74 assert commits == committed, 'Receipt transaction committed before the process died'
7875 output.with_suffix('.cut.json').write_text(json.dumps(proof, indent=2))
7976 finally:
8077 if process.poll() is None: process.kill()
......@@ -103,7 +100,7 @@ def state(rows, original):
103100 else:
104101 assert len(result['pending']) == 1, 'Unacknowledged intent was lost or duplicated'
105102 pending, = result['pending']
106 assert pending['id'] == 1 and pending['before'] == original and pending['replacement'] == TOKEN
103 assert pending['id'] == 1 and pending['replacement'] == TOKEN
107104 at = len(original.encode('utf-16-le')) // 2
108105 assert pending['range'] == [at, at], 'Durable intent range changed'
109106 return result
......@@ -187,11 +184,12 @@ def run(source, output):
187184 try:
188185 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)]
189186 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))
191189 finally:
192190 connection.close()
193191 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,
195193 'remote_text': after['remote_text'], 'local_text': after['local_text']}
196194 results.append(result)
197195 (output / 'results.json').write_text(json.dumps(results, indent=2))
......@@ -206,14 +204,14 @@ def run(source, output):
206204def receipt_windows(source, output):
207205 binary, source_hash = prepare_run(source, output)
208206 results = []
209 for window in ('journal', 'database'):
207 for window in ('wal',):
210208 folder = output / window
211209 initialized = run_command(binary, ['init', folder, source], output / (window+'-init'))
212210 original = next(row['remote_text'] for row in initialized if row['event'] == 'state')
213211 state(initialized, original)
214212 kill_at(binary, ['sync', folder], 'publish-after', output / (window+'-kill'), receipt_window=window)
215213 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'
217215 before_bytes = (folder / 'remote.one').read_bytes()
218216 recovered = run_command(binary, ['sync', folder], output / (window+'-recover'))
219217 after = state(recovered, original)
......@@ -226,7 +224,7 @@ def receipt_windows(source, output):
226224 print(json.dumps({'receipt_window':window, 'status':after['status'], 'remote_image':digest}), flush=True)
227225 assert hashlib.sha256(source.read_bytes()).hexdigest() == source_hash
228226 (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
232230if __name__ == '__main__':
tools/test_offline_history.py+5-4
......@@ -139,9 +139,10 @@ class OfflineLedgerTests(unittest.TestCase):
139139 events.extend({'event': 'reopened_receipt', 'id': op+1, 'revision': f'{actor}-{op}'} for op in range(2))
140140 events.append({'event': 'done'})
141141 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);')
143144 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',))
145146 connection.commit()
146147 connection.close()
147148 for i in range(4):
......@@ -165,8 +166,8 @@ class OfflineLedgerTests(unittest.TestCase):
165166 os.utime(output / 'rust/stop', ns=(0, 0))
166167 connection = sqlite3.connect(output / 'rust/w0.sqlite')
167168 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 ("UPDATE replica SET working=X'00'", "UPDATE replica SET working=X''")]:
169 ('INSERT INTO batches VALUES (1)', 'DELETE FROM batches'),
170 ("INSERT INTO remote VALUES (0, X'00')", 'DELETE FROM remote')]:
170171 connection.execute(sql)
171172 connection.commit()
172173 with self.assertRaises(AssertionError): verify(output)
tools/test_offline_publication_crash.py+2-2
......@@ -28,7 +28,7 @@ class RecoveryOracleTests(unittest.TestCase):
2828 at = len(original.encode('utf-16-le')) // 2
2929 row = {'event': 'state', 'status': 'uncertain', 'revision': 'new', 'remote_revision': 'old',
3030 '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]}]}
3232 self.assertEqual(state([row], original), row)
3333 for field, value in [('revision', None), ('revision', 'old'), ('local_text', original),
3434 ('remote_text', original+TOKEN+TOKEN), ('status', 'missing'), ('pending', [])]:
......@@ -36,7 +36,7 @@ class RecoveryOracleTests(unittest.TestCase):
3636 changed[field] = value
3737 with self.subTest(field=field):
3838 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])]:
4040 changed = copy.deepcopy(row)
4141 changed['pending'][0][field] = value
4242 with self.assertRaises(AssertionError): state([changed], original)
tools/verify_offline.py+3-3
......@@ -4,6 +4,7 @@ import argparse
44import hashlib
55import json
66from pathlib import Path
7import cache_images
78import sqlite3
89
910from native_stress import edit_history, native_history
......@@ -41,12 +42,11 @@ def verify(output, max_gap=120):
4142 try:
4243 assert connection.execute('PRAGMA quick_check').fetchall() == [('ok',)], 'Cache integrity failed'
4344 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'):
4546 assert connection.execute(f'SELECT count(*) FROM {table}').fetchone() == (0,), 'Completed cache retains unresolved state'
4647 persisted = dict(connection.execute('SELECT edit_id, revision FROM receipts'))
4748 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 assert working == b'', 'A drained cache retained a divergent local branch'
49 base = cache_images.image(connection)
5050 caches[actor] = {'receipts': len(persisted), 'image_bytes': len(base), 'image_sha256': hashlib.sha256(base).hexdigest(), 'database_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}
5151 finally:
5252 connection.close()